mirror of
https://github.com/exogen/t2-mapper.git
synced 2026-01-19 12:14:47 +00:00
use server.cs CreateServer() as the entry point for mission loading (#11)
* use server.cs CreateServer() as the entry point for mission loading * explain why onMissionLoadDone is necessary
This commit is contained in:
parent
10b4a65a87
commit
62f3487189
|
|
@ -411,13 +411,13 @@ MultiplicativeExpression
|
|||
}
|
||||
|
||||
UnaryExpression
|
||||
= operator:("-" / "!" / "~") _ argument:AssignmentExpression {
|
||||
= operator:("-" / "!" / "~") _ argument:UnaryOperand {
|
||||
return buildUnaryExpression(operator, argument);
|
||||
}
|
||||
/ operator:("++" / "--") _ argument:UnaryExpression {
|
||||
/ operator:("++" / "--") _ argument:UnaryOperand {
|
||||
return buildUnaryExpression(operator, argument);
|
||||
}
|
||||
/ "*" _ argument:UnaryExpression {
|
||||
/ "*" _ argument:UnaryOperand {
|
||||
return {
|
||||
type: 'TagDereferenceExpression',
|
||||
argument
|
||||
|
|
@ -425,6 +425,21 @@ UnaryExpression
|
|||
}
|
||||
/ PostfixExpression
|
||||
|
||||
// Allow assignment expressions as unary operands without parentheses.
|
||||
// This matches official TorqueScript behavior where `!%x = foo()` parses as `!(%x = foo())`.
|
||||
// We can't use full Expression here or it would break precedence (e.g., `!a + b` would
|
||||
// incorrectly parse as `!(a + b)` instead of `(!a) + b`).
|
||||
UnaryOperand
|
||||
= target:LeftHandSide _ operator:AssignmentOperator _ value:AssignmentExpression {
|
||||
return {
|
||||
type: 'AssignmentExpression',
|
||||
operator,
|
||||
target,
|
||||
value
|
||||
};
|
||||
}
|
||||
/ UnaryExpression
|
||||
|
||||
PostfixExpression
|
||||
= argument:CallExpression _ operator:("++" / "--") {
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -648,14 +648,22 @@ function peg$parse(input, options) {
|
|||
argument
|
||||
};
|
||||
}
|
||||
function peg$f50(argument, operator) {
|
||||
function peg$f50(target, operator, value) {
|
||||
return {
|
||||
type: 'AssignmentExpression',
|
||||
operator,
|
||||
target,
|
||||
value
|
||||
};
|
||||
}
|
||||
function peg$f51(argument, operator) {
|
||||
return {
|
||||
type: 'PostfixExpression',
|
||||
operator,
|
||||
argument
|
||||
};
|
||||
}
|
||||
function peg$f51(base, tail) {
|
||||
function peg$f52(base, tail) {
|
||||
return tail.reduce((obj, item) => {
|
||||
// Check if it's a function call
|
||||
if (item[1] === '(') {
|
||||
|
|
@ -679,7 +687,7 @@ function peg$parse(input, options) {
|
|||
}
|
||||
}, base);
|
||||
}
|
||||
function peg$f52(base, accessors) {
|
||||
function peg$f53(base, accessors) {
|
||||
return accessors.reduce((obj, [, accessor]) => {
|
||||
if (accessor.type === 'property') {
|
||||
return {
|
||||
|
|
@ -696,34 +704,28 @@ function peg$parse(input, options) {
|
|||
}
|
||||
}, base);
|
||||
}
|
||||
function peg$f53(head, tail) {
|
||||
function peg$f54(head, tail) {
|
||||
return [head, ...tail.map(([,,,expr]) => expr)];
|
||||
}
|
||||
function peg$f54(expr) { return expr; }
|
||||
function peg$f55(name) {
|
||||
function peg$f55(expr) { return expr; }
|
||||
function peg$f56(name) {
|
||||
return {
|
||||
type: 'Variable',
|
||||
scope: 'local',
|
||||
name
|
||||
};
|
||||
}
|
||||
function peg$f56(name) {
|
||||
function peg$f57(name) {
|
||||
return {
|
||||
type: 'Variable',
|
||||
scope: 'global',
|
||||
name
|
||||
};
|
||||
}
|
||||
function peg$f57(name) {
|
||||
return {
|
||||
type: 'Identifier',
|
||||
name: name.replace(/\s+/g, '')
|
||||
};
|
||||
}
|
||||
function peg$f58(name) {
|
||||
return {
|
||||
type: 'Identifier',
|
||||
name
|
||||
name: name.replace(/\s+/g, '')
|
||||
};
|
||||
}
|
||||
function peg$f59(name) {
|
||||
|
|
@ -732,13 +734,19 @@ function peg$parse(input, options) {
|
|||
name
|
||||
};
|
||||
}
|
||||
function peg$f60(chars) {
|
||||
function peg$f60(name) {
|
||||
return {
|
||||
type: 'Identifier',
|
||||
name
|
||||
};
|
||||
}
|
||||
function peg$f61(chars) {
|
||||
return {
|
||||
type: 'StringLiteral',
|
||||
value: chars.join('')
|
||||
};
|
||||
}
|
||||
function peg$f61(chars) {
|
||||
function peg$f62(chars) {
|
||||
// Single-quoted strings are "tagged" strings in TorqueScript,
|
||||
// used for network optimization (string sent once, then only tag ID)
|
||||
return {
|
||||
|
|
@ -747,52 +755,52 @@ function peg$parse(input, options) {
|
|||
tagged: true
|
||||
};
|
||||
}
|
||||
function peg$f62(char) { return char; }
|
||||
function peg$f63(char) { return char; }
|
||||
function peg$f64() { return "\n"; }
|
||||
function peg$f65() { return "\r"; }
|
||||
function peg$f66() { return "\t"; }
|
||||
function peg$f67(hex) { return String.fromCharCode(parseInt(hex, 16)); }
|
||||
function peg$f68() { return String.fromCharCode(0x0F); }
|
||||
function peg$f69() { return String.fromCharCode(0x10); }
|
||||
function peg$f70() { return String.fromCharCode(0x11); }
|
||||
function peg$f71(code) {
|
||||
function peg$f64(char) { return char; }
|
||||
function peg$f65() { return "\n"; }
|
||||
function peg$f66() { return "\r"; }
|
||||
function peg$f67() { return "\t"; }
|
||||
function peg$f68(hex) { return String.fromCharCode(parseInt(hex, 16)); }
|
||||
function peg$f69() { return String.fromCharCode(0x0F); }
|
||||
function peg$f70() { return String.fromCharCode(0x10); }
|
||||
function peg$f71() { return String.fromCharCode(0x11); }
|
||||
function peg$f72(code) {
|
||||
// collapseRemap: \c0-\c9 map to bytes that avoid \t, \n, \r
|
||||
const collapseRemap = [0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x0B, 0x0C, 0x0E];
|
||||
return String.fromCharCode(collapseRemap[parseInt(code, 10)]);
|
||||
}
|
||||
function peg$f72(char) { return char; }
|
||||
function peg$f73(hex) {
|
||||
function peg$f73(char) { return char; }
|
||||
function peg$f74(hex) {
|
||||
return {
|
||||
type: 'NumberLiteral',
|
||||
value: parseInt(hex, 16)
|
||||
};
|
||||
}
|
||||
function peg$f74(number) {
|
||||
function peg$f75(number) {
|
||||
return {
|
||||
type: 'NumberLiteral',
|
||||
value: parseFloat(number)
|
||||
};
|
||||
}
|
||||
function peg$f75(value) {
|
||||
function peg$f76(value) {
|
||||
return {
|
||||
type: 'BooleanLiteral',
|
||||
value: value === "true"
|
||||
};
|
||||
}
|
||||
function peg$f76(text) {
|
||||
return {
|
||||
type: 'Comment',
|
||||
value: text
|
||||
};
|
||||
}
|
||||
function peg$f77(text) {
|
||||
return {
|
||||
type: 'Comment',
|
||||
value: text
|
||||
};
|
||||
}
|
||||
function peg$f78() { return null; }
|
||||
function peg$f78(text) {
|
||||
return {
|
||||
type: 'Comment',
|
||||
value: text
|
||||
};
|
||||
}
|
||||
function peg$f79() { return null; }
|
||||
let peg$currPos = options.peg$currPos | 0;
|
||||
let peg$savedPos = peg$currPos;
|
||||
const peg$posDetailsCache = [{ line: 1, column: 1 }];
|
||||
|
|
@ -4169,7 +4177,7 @@ function peg$parse(input, options) {
|
|||
}
|
||||
if (s1 !== peg$FAILED) {
|
||||
s2 = peg$parse_();
|
||||
s3 = peg$parseAssignmentExpression();
|
||||
s3 = peg$parseUnaryOperand();
|
||||
if (s3 !== peg$FAILED) {
|
||||
peg$savedPos = s0;
|
||||
s0 = peg$f47(s1, s3);
|
||||
|
|
@ -4201,7 +4209,7 @@ function peg$parse(input, options) {
|
|||
}
|
||||
if (s1 !== peg$FAILED) {
|
||||
s2 = peg$parse_();
|
||||
s3 = peg$parseUnaryExpression();
|
||||
s3 = peg$parseUnaryOperand();
|
||||
if (s3 !== peg$FAILED) {
|
||||
peg$savedPos = s0;
|
||||
s0 = peg$f48(s1, s3);
|
||||
|
|
@ -4224,7 +4232,7 @@ function peg$parse(input, options) {
|
|||
}
|
||||
if (s1 !== peg$FAILED) {
|
||||
s2 = peg$parse_();
|
||||
s3 = peg$parseUnaryExpression();
|
||||
s3 = peg$parseUnaryOperand();
|
||||
if (s3 !== peg$FAILED) {
|
||||
peg$savedPos = s0;
|
||||
s0 = peg$f49(s3);
|
||||
|
|
@ -4245,6 +4253,39 @@ function peg$parse(input, options) {
|
|||
return s0;
|
||||
}
|
||||
|
||||
function peg$parseUnaryOperand() {
|
||||
let s0, s1, s2, s3, s4, s5;
|
||||
|
||||
s0 = peg$currPos;
|
||||
s1 = peg$parseLeftHandSide();
|
||||
if (s1 !== peg$FAILED) {
|
||||
s2 = peg$parse_();
|
||||
s3 = peg$parseAssignmentOperator();
|
||||
if (s3 !== peg$FAILED) {
|
||||
s4 = peg$parse_();
|
||||
s5 = peg$parseAssignmentExpression();
|
||||
if (s5 !== peg$FAILED) {
|
||||
peg$savedPos = s0;
|
||||
s0 = peg$f50(s1, s3, s5);
|
||||
} else {
|
||||
peg$currPos = s0;
|
||||
s0 = peg$FAILED;
|
||||
}
|
||||
} else {
|
||||
peg$currPos = s0;
|
||||
s0 = peg$FAILED;
|
||||
}
|
||||
} else {
|
||||
peg$currPos = s0;
|
||||
s0 = peg$FAILED;
|
||||
}
|
||||
if (s0 === peg$FAILED) {
|
||||
s0 = peg$parseUnaryExpression();
|
||||
}
|
||||
|
||||
return s0;
|
||||
}
|
||||
|
||||
function peg$parsePostfixExpression() {
|
||||
let s0, s1, s2, s3;
|
||||
|
||||
|
|
@ -4270,7 +4311,7 @@ function peg$parse(input, options) {
|
|||
}
|
||||
if (s3 !== peg$FAILED) {
|
||||
peg$savedPos = s0;
|
||||
s0 = peg$f50(s1, s3);
|
||||
s0 = peg$f51(s1, s3);
|
||||
} else {
|
||||
peg$currPos = s0;
|
||||
s0 = peg$FAILED;
|
||||
|
|
@ -4389,7 +4430,7 @@ function peg$parse(input, options) {
|
|||
}
|
||||
}
|
||||
peg$savedPos = s0;
|
||||
s0 = peg$f51(s1, s2);
|
||||
s0 = peg$f52(s1, s2);
|
||||
} else {
|
||||
peg$currPos = s0;
|
||||
s0 = peg$FAILED;
|
||||
|
|
@ -4429,7 +4470,7 @@ function peg$parse(input, options) {
|
|||
}
|
||||
}
|
||||
peg$savedPos = s0;
|
||||
s0 = peg$f52(s1, s2);
|
||||
s0 = peg$f53(s1, s2);
|
||||
} else {
|
||||
peg$currPos = s0;
|
||||
s0 = peg$FAILED;
|
||||
|
|
@ -4495,7 +4536,7 @@ function peg$parse(input, options) {
|
|||
}
|
||||
}
|
||||
peg$savedPos = s0;
|
||||
s0 = peg$f53(s1, s2);
|
||||
s0 = peg$f54(s1, s2);
|
||||
} else {
|
||||
peg$currPos = s0;
|
||||
s0 = peg$FAILED;
|
||||
|
|
@ -4555,7 +4596,7 @@ function peg$parse(input, options) {
|
|||
}
|
||||
if (s5 !== peg$FAILED) {
|
||||
peg$savedPos = s0;
|
||||
s0 = peg$f54(s3);
|
||||
s0 = peg$f55(s3);
|
||||
} else {
|
||||
peg$currPos = s0;
|
||||
s0 = peg$FAILED;
|
||||
|
|
@ -4639,7 +4680,7 @@ function peg$parse(input, options) {
|
|||
}
|
||||
if (s2 !== peg$FAILED) {
|
||||
peg$savedPos = s0;
|
||||
s0 = peg$f55(s2);
|
||||
s0 = peg$f56(s2);
|
||||
} else {
|
||||
peg$currPos = s0;
|
||||
s0 = peg$FAILED;
|
||||
|
|
@ -4809,7 +4850,7 @@ function peg$parse(input, options) {
|
|||
}
|
||||
if (s2 !== peg$FAILED) {
|
||||
peg$savedPos = s0;
|
||||
s0 = peg$f56(s2);
|
||||
s0 = peg$f57(s2);
|
||||
} else {
|
||||
peg$currPos = s0;
|
||||
s0 = peg$FAILED;
|
||||
|
|
@ -4927,7 +4968,7 @@ function peg$parse(input, options) {
|
|||
}
|
||||
if (s1 !== peg$FAILED) {
|
||||
peg$savedPos = s0;
|
||||
s1 = peg$f57(s1);
|
||||
s1 = peg$f58(s1);
|
||||
}
|
||||
s0 = s1;
|
||||
if (s0 === peg$FAILED) {
|
||||
|
|
@ -5058,7 +5099,7 @@ function peg$parse(input, options) {
|
|||
}
|
||||
if (s1 !== peg$FAILED) {
|
||||
peg$savedPos = s0;
|
||||
s1 = peg$f58(s1);
|
||||
s1 = peg$f59(s1);
|
||||
}
|
||||
s0 = s1;
|
||||
if (s0 === peg$FAILED) {
|
||||
|
|
@ -5198,7 +5239,7 @@ function peg$parse(input, options) {
|
|||
}
|
||||
if (s1 !== peg$FAILED) {
|
||||
peg$savedPos = s0;
|
||||
s1 = peg$f59(s1);
|
||||
s1 = peg$f60(s1);
|
||||
}
|
||||
s0 = s1;
|
||||
}
|
||||
|
|
@ -5248,7 +5289,7 @@ function peg$parse(input, options) {
|
|||
}
|
||||
if (s3 !== peg$FAILED) {
|
||||
peg$savedPos = s0;
|
||||
s0 = peg$f60(s2);
|
||||
s0 = peg$f61(s2);
|
||||
} else {
|
||||
peg$currPos = s0;
|
||||
s0 = peg$FAILED;
|
||||
|
|
@ -5282,7 +5323,7 @@ function peg$parse(input, options) {
|
|||
}
|
||||
if (s3 !== peg$FAILED) {
|
||||
peg$savedPos = s0;
|
||||
s0 = peg$f61(s2);
|
||||
s0 = peg$f62(s2);
|
||||
} else {
|
||||
peg$currPos = s0;
|
||||
s0 = peg$FAILED;
|
||||
|
|
@ -5311,7 +5352,7 @@ function peg$parse(input, options) {
|
|||
s2 = peg$parseEscapeSequence();
|
||||
if (s2 !== peg$FAILED) {
|
||||
peg$savedPos = s0;
|
||||
s0 = peg$f62(s2);
|
||||
s0 = peg$f63(s2);
|
||||
} else {
|
||||
peg$currPos = s0;
|
||||
s0 = peg$FAILED;
|
||||
|
|
@ -5348,7 +5389,7 @@ function peg$parse(input, options) {
|
|||
s2 = peg$parseEscapeSequence();
|
||||
if (s2 !== peg$FAILED) {
|
||||
peg$savedPos = s0;
|
||||
s0 = peg$f63(s2);
|
||||
s0 = peg$f64(s2);
|
||||
} else {
|
||||
peg$currPos = s0;
|
||||
s0 = peg$FAILED;
|
||||
|
|
@ -5383,7 +5424,7 @@ function peg$parse(input, options) {
|
|||
}
|
||||
if (s1 !== peg$FAILED) {
|
||||
peg$savedPos = s0;
|
||||
s1 = peg$f64();
|
||||
s1 = peg$f65();
|
||||
}
|
||||
s0 = s1;
|
||||
if (s0 === peg$FAILED) {
|
||||
|
|
@ -5397,7 +5438,7 @@ function peg$parse(input, options) {
|
|||
}
|
||||
if (s1 !== peg$FAILED) {
|
||||
peg$savedPos = s0;
|
||||
s1 = peg$f65();
|
||||
s1 = peg$f66();
|
||||
}
|
||||
s0 = s1;
|
||||
if (s0 === peg$FAILED) {
|
||||
|
|
@ -5411,7 +5452,7 @@ function peg$parse(input, options) {
|
|||
}
|
||||
if (s1 !== peg$FAILED) {
|
||||
peg$savedPos = s0;
|
||||
s1 = peg$f66();
|
||||
s1 = peg$f67();
|
||||
}
|
||||
s0 = s1;
|
||||
if (s0 === peg$FAILED) {
|
||||
|
|
@ -5459,7 +5500,7 @@ function peg$parse(input, options) {
|
|||
}
|
||||
if (s2 !== peg$FAILED) {
|
||||
peg$savedPos = s0;
|
||||
s0 = peg$f67(s2);
|
||||
s0 = peg$f68(s2);
|
||||
} else {
|
||||
peg$currPos = s0;
|
||||
s0 = peg$FAILED;
|
||||
|
|
@ -5479,7 +5520,7 @@ function peg$parse(input, options) {
|
|||
}
|
||||
if (s1 !== peg$FAILED) {
|
||||
peg$savedPos = s0;
|
||||
s1 = peg$f68();
|
||||
s1 = peg$f69();
|
||||
}
|
||||
s0 = s1;
|
||||
if (s0 === peg$FAILED) {
|
||||
|
|
@ -5493,7 +5534,7 @@ function peg$parse(input, options) {
|
|||
}
|
||||
if (s1 !== peg$FAILED) {
|
||||
peg$savedPos = s0;
|
||||
s1 = peg$f69();
|
||||
s1 = peg$f70();
|
||||
}
|
||||
s0 = s1;
|
||||
if (s0 === peg$FAILED) {
|
||||
|
|
@ -5507,7 +5548,7 @@ function peg$parse(input, options) {
|
|||
}
|
||||
if (s1 !== peg$FAILED) {
|
||||
peg$savedPos = s0;
|
||||
s1 = peg$f70();
|
||||
s1 = peg$f71();
|
||||
}
|
||||
s0 = s1;
|
||||
if (s0 === peg$FAILED) {
|
||||
|
|
@ -5529,7 +5570,7 @@ function peg$parse(input, options) {
|
|||
}
|
||||
if (s2 !== peg$FAILED) {
|
||||
peg$savedPos = s0;
|
||||
s0 = peg$f71(s2);
|
||||
s0 = peg$f72(s2);
|
||||
} else {
|
||||
peg$currPos = s0;
|
||||
s0 = peg$FAILED;
|
||||
|
|
@ -5549,7 +5590,7 @@ function peg$parse(input, options) {
|
|||
}
|
||||
if (s1 !== peg$FAILED) {
|
||||
peg$savedPos = s0;
|
||||
s1 = peg$f72(s1);
|
||||
s1 = peg$f73(s1);
|
||||
}
|
||||
s0 = s1;
|
||||
}
|
||||
|
|
@ -5641,7 +5682,7 @@ function peg$parse(input, options) {
|
|||
}
|
||||
if (s2 !== peg$FAILED) {
|
||||
peg$savedPos = s0;
|
||||
s0 = peg$f73(s1);
|
||||
s0 = peg$f74(s1);
|
||||
} else {
|
||||
peg$currPos = s0;
|
||||
s0 = peg$FAILED;
|
||||
|
|
@ -5810,7 +5851,7 @@ function peg$parse(input, options) {
|
|||
}
|
||||
if (s2 !== peg$FAILED) {
|
||||
peg$savedPos = s0;
|
||||
s0 = peg$f74(s1);
|
||||
s0 = peg$f75(s1);
|
||||
} else {
|
||||
peg$currPos = s0;
|
||||
s0 = peg$FAILED;
|
||||
|
|
@ -5857,7 +5898,7 @@ function peg$parse(input, options) {
|
|||
}
|
||||
if (s2 !== peg$FAILED) {
|
||||
peg$savedPos = s0;
|
||||
s0 = peg$f75(s1);
|
||||
s0 = peg$f76(s1);
|
||||
} else {
|
||||
peg$currPos = s0;
|
||||
s0 = peg$FAILED;
|
||||
|
|
@ -5924,7 +5965,7 @@ function peg$parse(input, options) {
|
|||
s3 = null;
|
||||
}
|
||||
peg$savedPos = s0;
|
||||
s0 = peg$f76(s2);
|
||||
s0 = peg$f77(s2);
|
||||
} else {
|
||||
peg$currPos = s0;
|
||||
s0 = peg$FAILED;
|
||||
|
|
@ -6032,7 +6073,7 @@ function peg$parse(input, options) {
|
|||
}
|
||||
if (s3 !== peg$FAILED) {
|
||||
peg$savedPos = s0;
|
||||
s0 = peg$f77(s2);
|
||||
s0 = peg$f78(s2);
|
||||
} else {
|
||||
peg$currPos = s0;
|
||||
s0 = peg$FAILED;
|
||||
|
|
@ -6073,7 +6114,7 @@ function peg$parse(input, options) {
|
|||
}
|
||||
if (s1 !== peg$FAILED) {
|
||||
peg$savedPos = s0;
|
||||
s1 = peg$f78();
|
||||
s1 = peg$f79();
|
||||
}
|
||||
s0 = s1;
|
||||
|
||||
|
|
|
|||
10
package-lock.json
generated
10
package-lock.json
generated
|
|
@ -18,6 +18,7 @@
|
|||
"lodash.orderby": "^4.6.0",
|
||||
"match-sorter": "^8.2.0",
|
||||
"next": "^15.5.2",
|
||||
"picomatch": "^4.0.3",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-error-boundary": "^6.0.0",
|
||||
|
|
@ -28,6 +29,7 @@
|
|||
"@types/express": "^5.0.5",
|
||||
"@types/lodash.orderby": "^4.6.9",
|
||||
"@types/node": "24.3.1",
|
||||
"@types/picomatch": "^4.0.2",
|
||||
"@types/react": "^19.2.4",
|
||||
"@types/three": "^0.180.0",
|
||||
"@types/unzipper": "^0.10.11",
|
||||
|
|
@ -1804,6 +1806,13 @@
|
|||
"integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/picomatch": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/picomatch/-/picomatch-4.0.2.tgz",
|
||||
"integrity": "sha512-qHHxQ+P9PysNEGbALT8f8YOSHW0KJu6l2xU8DYY0fu/EmGxXdVnuTLvFUvBgPJMSqXq29SYHveejeAha+4AYgA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/qs": {
|
||||
"version": "6.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
|
||||
|
|
@ -3504,7 +3513,6 @@
|
|||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@
|
|||
"lodash.orderby": "^4.6.0",
|
||||
"match-sorter": "^8.2.0",
|
||||
"next": "^15.5.2",
|
||||
"picomatch": "^4.0.3",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-error-boundary": "^6.0.0",
|
||||
|
|
@ -41,6 +42,7 @@
|
|||
"@types/express": "^5.0.5",
|
||||
"@types/lodash.orderby": "^4.6.9",
|
||||
"@types/node": "24.3.1",
|
||||
"@types/picomatch": "^4.0.2",
|
||||
"@types/react": "^19.2.4",
|
||||
"@types/three": "^0.180.0",
|
||||
"@types/unzipper": "^0.10.11",
|
||||
|
|
|
|||
|
|
@ -1,16 +1,33 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import picomatch from "picomatch";
|
||||
import { loadMission } from "../loaders";
|
||||
import {
|
||||
executeMission,
|
||||
type ParsedMission,
|
||||
type ExecutedMission,
|
||||
} from "../mission";
|
||||
import { type ParsedMission } from "../mission";
|
||||
import { createScriptLoader } from "../torqueScript/scriptLoader.browser";
|
||||
import { renderObject } from "./renderObject";
|
||||
import { memo, useEffect, useState } from "react";
|
||||
import { TickProvider } from "./TickProvider";
|
||||
import {
|
||||
createScriptCache,
|
||||
FileSystemHandler,
|
||||
runServer,
|
||||
TorqueObject,
|
||||
} from "../torqueScript";
|
||||
import { getResourceKey, getResourceList, getResourceMap } from "../manifest";
|
||||
|
||||
const loadScript = createScriptLoader();
|
||||
// Shared cache for parsed scripts - survives runtime restarts
|
||||
const scriptCache = createScriptCache();
|
||||
const fileSystem: FileSystemHandler = {
|
||||
findFiles: (pattern) => {
|
||||
const isMatch = picomatch(pattern, { nocase: true });
|
||||
return getResourceList().filter((path) => isMatch(path));
|
||||
},
|
||||
isFile: (resourcePath) => {
|
||||
const resourceKeys = getResourceMap();
|
||||
const resourceKey = getResourceKey(resourcePath);
|
||||
return resourceKeys[resourceKey] != null;
|
||||
},
|
||||
};
|
||||
|
||||
function useParsedMission(name: string) {
|
||||
return useQuery({
|
||||
|
|
@ -19,61 +36,52 @@ function useParsedMission(name: string) {
|
|||
});
|
||||
}
|
||||
|
||||
function useExecutedMission(parsedMission: ParsedMission | undefined) {
|
||||
const [executedMission, setExecutedMission] = useState<
|
||||
ExecutedMission | undefined
|
||||
>();
|
||||
function useExecutedMission(
|
||||
missionName: string,
|
||||
parsedMission: ParsedMission | undefined,
|
||||
) {
|
||||
const [missionGroup, setMissionGroup] = useState<TorqueObject | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!parsedMission) {
|
||||
setExecutedMission(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear previous mission immediately to avoid rendering with destroyed runtime
|
||||
setExecutedMission(undefined);
|
||||
const controller = new AbortController();
|
||||
// FIXME: Always just runs as the first game type for now...
|
||||
const missionType = parsedMission.missionTypes[0];
|
||||
|
||||
let cancelled = false;
|
||||
let result: ExecutedMission | undefined;
|
||||
|
||||
async function run() {
|
||||
try {
|
||||
const executed = await executeMission(parsedMission, { loadScript });
|
||||
if (cancelled) {
|
||||
executed.runtime.destroy();
|
||||
} else {
|
||||
result = executed;
|
||||
setExecutedMission(executed);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
console.error("Failed to execute mission:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
const { runtime } = runServer({
|
||||
missionName,
|
||||
missionType,
|
||||
runtimeOptions: {
|
||||
loadScript,
|
||||
fileSystem,
|
||||
cache: scriptCache,
|
||||
signal: controller.signal,
|
||||
},
|
||||
onMissionLoadDone: () => {
|
||||
const missionGroup = runtime.getObjectByName("MissionGroup");
|
||||
setMissionGroup(missionGroup);
|
||||
},
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
result?.runtime.destroy();
|
||||
controller.abort();
|
||||
runtime.destroy();
|
||||
};
|
||||
}, [parsedMission]);
|
||||
}, [missionName, parsedMission]);
|
||||
|
||||
return executedMission;
|
||||
return missionGroup;
|
||||
}
|
||||
|
||||
export const Mission = memo(function Mission({ name }: { name: string }) {
|
||||
const { data: parsedMission } = useParsedMission(name);
|
||||
const executedMission = useExecutedMission(parsedMission);
|
||||
const missionGroup = useExecutedMission(name, parsedMission);
|
||||
|
||||
if (!executedMission) {
|
||||
if (!missionGroup) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<TickProvider>
|
||||
{executedMission.objects.map((object, i) => renderObject(object, i))}
|
||||
</TickProvider>
|
||||
);
|
||||
return <TickProvider>{renderObject(missionGroup)}</TickProvider>;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ const componentMap = {
|
|||
WayPoint,
|
||||
};
|
||||
|
||||
export function renderObject(object: TorqueObject, key: string | number) {
|
||||
export function renderObject(object: TorqueObject, key?: string | number) {
|
||||
const Component = componentMap[object._className];
|
||||
return Component ? <Component key={key} object={object} /> : null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,10 +27,14 @@ const manifest = untypedManifest as unknown as {
|
|||
>;
|
||||
};
|
||||
|
||||
function getResourceKey(resourcePath: string): string {
|
||||
export function getResourceKey(resourcePath: string): string {
|
||||
return normalizePath(resourcePath).toLowerCase();
|
||||
}
|
||||
|
||||
export function getResourceMap() {
|
||||
return manifest.resources;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the source vl2 archive for a resource (or empty string for loose files).
|
||||
* Returns the last/winning source since later vl2s override earlier ones.
|
||||
|
|
|
|||
|
|
@ -1,8 +1,18 @@
|
|||
import type { BuiltinsContext, TorqueFunction } from "./types";
|
||||
import { normalizePath } from "./utils";
|
||||
|
||||
/** Coerce value to string, treating null/undefined as empty string. */
|
||||
function toStr(v: any): string {
|
||||
return String(v ?? "");
|
||||
}
|
||||
|
||||
/** Coerce value to number, treating null/undefined/NaN as 0. */
|
||||
function toNum(v: any): number {
|
||||
return Number(v) || 0;
|
||||
}
|
||||
|
||||
function parseVector(v: any): [number, number, number] {
|
||||
const parts = String(v ?? "0 0 0")
|
||||
const parts = toStr(v || "0 0 0")
|
||||
.split(" ")
|
||||
.map(Number);
|
||||
return [parts[0] || 0, parts[1] || 0, parts[2] || 0];
|
||||
|
|
@ -12,9 +22,172 @@ function parseVector(v: any): [number, number, number] {
|
|||
// - Words: space, tab, newline (" \t\n")
|
||||
// - Fields: tab, newline ("\t\n")
|
||||
// - Records: newline ("\n")
|
||||
const FIELD_DELIM = /[\t\n]/;
|
||||
const WORD_DELIM_SET = " \t\n";
|
||||
const FIELD_DELIM_SET = "\t\n";
|
||||
const RECORD_DELIM_SET = "\n";
|
||||
const FIELD_DELIM_CHAR = "\t"; // Use tab when joining
|
||||
|
||||
/**
|
||||
* Get the span of characters NOT in the delimiter set (like C's strcspn).
|
||||
* Returns the length of the initial segment that doesn't contain any delimiter.
|
||||
*/
|
||||
function strcspn(str: string, pos: number, delims: string): number {
|
||||
let len = 0;
|
||||
while (pos + len < str.length && !delims.includes(str[pos + len])) {
|
||||
len++;
|
||||
}
|
||||
return len;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a unit (word/field/record) at the given index.
|
||||
* Matches engine behavior: doesn't collapse consecutive delimiters.
|
||||
*/
|
||||
function getUnit(str: string, index: number, delims: string): string {
|
||||
let pos = 0;
|
||||
|
||||
// Skip to the target index
|
||||
while (index > 0) {
|
||||
if (pos >= str.length) return "";
|
||||
const sz = strcspn(str, pos, delims);
|
||||
if (pos + sz >= str.length) return ""; // No more delimiters
|
||||
pos += sz + 1; // Skip word + ONE delimiter
|
||||
index--;
|
||||
}
|
||||
|
||||
// Get the unit at this position
|
||||
const sz = strcspn(str, pos, delims);
|
||||
if (sz === 0) return ""; // Empty unit
|
||||
return str.substring(pos, pos + sz);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get units from startIndex to endIndex (inclusive).
|
||||
* Matches engine behavior.
|
||||
*/
|
||||
function getUnits(
|
||||
str: string,
|
||||
startIndex: number,
|
||||
endIndex: number,
|
||||
delims: string,
|
||||
): string {
|
||||
let pos = 0;
|
||||
let index = startIndex;
|
||||
|
||||
// Skip to startIndex
|
||||
while (index > 0) {
|
||||
if (pos >= str.length) return "";
|
||||
const sz = strcspn(str, pos, delims);
|
||||
if (pos + sz >= str.length) return "";
|
||||
pos += sz + 1;
|
||||
index--;
|
||||
}
|
||||
|
||||
const startPos = pos;
|
||||
|
||||
// Find end position
|
||||
let count = endIndex - startIndex + 1;
|
||||
while (count > 0) {
|
||||
const sz = strcspn(str, pos, delims);
|
||||
pos += sz;
|
||||
if (pos >= str.length) break;
|
||||
pos++; // Skip delimiter
|
||||
count--;
|
||||
}
|
||||
|
||||
// Trim trailing delimiter if we stopped at one
|
||||
let endPos = pos;
|
||||
if (endPos > startPos && delims.includes(str[endPos - 1])) {
|
||||
endPos--;
|
||||
}
|
||||
|
||||
return str.substring(startPos, endPos);
|
||||
}
|
||||
|
||||
/**
|
||||
* Count the number of units in the string.
|
||||
* Engine behavior: counts delimiters + 1.
|
||||
* So "a b" has 1 delimiter -> 2 units.
|
||||
* And "a b" has 2 delimiters -> 3 units (with an empty one in the middle).
|
||||
*/
|
||||
function getUnitCount(str: string, delims: string): number {
|
||||
if (str === "") return 0;
|
||||
|
||||
let count = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
if (delims.includes(str[i])) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
return count + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a unit at the given index, preserving other units.
|
||||
*/
|
||||
function setUnit(
|
||||
str: string,
|
||||
index: number,
|
||||
value: string,
|
||||
delims: string,
|
||||
joinChar: string,
|
||||
): string {
|
||||
const parts: string[] = [];
|
||||
let pos = 0;
|
||||
let i = 0;
|
||||
|
||||
while (pos < str.length || i <= index) {
|
||||
if (pos < str.length) {
|
||||
const sz = strcspn(str, pos, delims);
|
||||
if (i === index) {
|
||||
parts.push(value);
|
||||
} else {
|
||||
parts.push(str.substring(pos, pos + sz));
|
||||
}
|
||||
pos += sz;
|
||||
if (pos < str.length) pos++; // Skip delimiter
|
||||
} else {
|
||||
// Past end of string, pad with empty strings
|
||||
if (i === index) {
|
||||
parts.push(value);
|
||||
} else {
|
||||
parts.push("");
|
||||
}
|
||||
}
|
||||
i++;
|
||||
if (i > index && pos >= str.length) break;
|
||||
}
|
||||
|
||||
return parts.join(joinChar);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a unit at the given index.
|
||||
*/
|
||||
function removeUnit(
|
||||
str: string,
|
||||
index: number,
|
||||
delims: string,
|
||||
joinChar: string,
|
||||
): string {
|
||||
const parts: string[] = [];
|
||||
let pos = 0;
|
||||
let i = 0;
|
||||
|
||||
while (pos < str.length) {
|
||||
const sz = strcspn(str, pos, delims);
|
||||
if (i !== index) {
|
||||
parts.push(str.substring(pos, pos + sz));
|
||||
}
|
||||
pos += sz;
|
||||
if (pos < str.length) pos++; // Skip delimiter
|
||||
i++;
|
||||
}
|
||||
|
||||
return parts.join(joinChar);
|
||||
}
|
||||
|
||||
/**
|
||||
* Default TorqueScript built-in functions.
|
||||
*
|
||||
|
|
@ -23,20 +196,26 @@ const FIELD_DELIM_CHAR = "\t"; // Use tab when joining
|
|||
export function createBuiltins(
|
||||
ctx: BuiltinsContext,
|
||||
): Record<string, TorqueFunction> {
|
||||
const { runtime } = ctx;
|
||||
const { runtime, fileSystem } = ctx;
|
||||
|
||||
// File search iterator state
|
||||
let fileSearchResults: string[] = [];
|
||||
let fileSearchIndex = 0;
|
||||
let fileSearchPattern: string = "";
|
||||
|
||||
return {
|
||||
// Console
|
||||
echo(...args: any[]): void {
|
||||
console.log(...args.map((a) => String(a ?? "")));
|
||||
console.log(...args.map(toStr));
|
||||
},
|
||||
warn(...args: any[]): void {
|
||||
console.warn(...args.map((a) => String(a ?? "")));
|
||||
console.warn(...args.map(toStr));
|
||||
},
|
||||
error(...args: any[]): void {
|
||||
console.error(...args.map((a) => String(a ?? "")));
|
||||
console.error(...args.map(toStr));
|
||||
},
|
||||
call(funcName: any, ...args: any[]): any {
|
||||
return runtime().$f.call(String(funcName ?? ""), ...args);
|
||||
return runtime().$f.call(toStr(funcName), ...args);
|
||||
},
|
||||
eval(_code: any): any {
|
||||
throw new Error(
|
||||
|
|
@ -45,7 +224,7 @@ export function createBuiltins(
|
|||
},
|
||||
collapseescape(str: any): string {
|
||||
// Single-pass replacement to correctly handle sequences like \\n
|
||||
return String(str ?? "").replace(/\\([ntr\\])/g, (_, char) => {
|
||||
return toStr(str).replace(/\\([ntr\\])/g, (_, char) => {
|
||||
if (char === "n") return "\n";
|
||||
if (char === "t") return "\t";
|
||||
if (char === "r") return "\r";
|
||||
|
|
@ -53,7 +232,7 @@ export function createBuiltins(
|
|||
});
|
||||
},
|
||||
expandescape(str: any): string {
|
||||
return String(str ?? "")
|
||||
return toStr(str)
|
||||
.replace(/\\/g, "\\\\")
|
||||
.replace(/\n/g, "\\n")
|
||||
.replace(/\t/g, "\\t")
|
||||
|
|
@ -81,159 +260,142 @@ export function createBuiltins(
|
|||
|
||||
// String functions
|
||||
strlen(str: any): number {
|
||||
return String(str ?? "").length;
|
||||
return toStr(str).length;
|
||||
},
|
||||
strchr(str: any, char: any): string {
|
||||
// Returns remainder of string starting at first occurrence of char, or ""
|
||||
const s = String(str ?? "");
|
||||
const c = String(char ?? "")[0] ?? "";
|
||||
const s = toStr(str);
|
||||
const c = toStr(char)[0] ?? "";
|
||||
const idx = s.indexOf(c);
|
||||
return idx >= 0 ? s.substring(idx) : "";
|
||||
},
|
||||
strpos(haystack: any, needle: any, offset?: any): number {
|
||||
const s = String(haystack ?? "");
|
||||
const n = String(needle ?? "");
|
||||
const o = Number(offset) || 0;
|
||||
return s.indexOf(n, o);
|
||||
return toStr(haystack).indexOf(toStr(needle), toNum(offset));
|
||||
},
|
||||
strcmp(a: any, b: any): number {
|
||||
const sa = String(a ?? "");
|
||||
const sb = String(b ?? "");
|
||||
const sa = toStr(a);
|
||||
const sb = toStr(b);
|
||||
return sa < sb ? -1 : sa > sb ? 1 : 0;
|
||||
},
|
||||
stricmp(a: any, b: any): number {
|
||||
const sa = String(a ?? "").toLowerCase();
|
||||
const sb = String(b ?? "").toLowerCase();
|
||||
const sa = toStr(a).toLowerCase();
|
||||
const sb = toStr(b).toLowerCase();
|
||||
return sa < sb ? -1 : sa > sb ? 1 : 0;
|
||||
},
|
||||
strstr(haystack: any, needle: any): number {
|
||||
return String(haystack ?? "").indexOf(String(needle ?? ""));
|
||||
return toStr(haystack).indexOf(toStr(needle));
|
||||
},
|
||||
getsubstr(str: any, start: any, len?: any): string {
|
||||
const s = String(str ?? "");
|
||||
const st = Number(start) || 0;
|
||||
const s = toStr(str);
|
||||
const st = toNum(start);
|
||||
if (len === undefined) return s.substring(st);
|
||||
return s.substring(st, st + (Number(len) || 0));
|
||||
return s.substring(st, st + toNum(len));
|
||||
},
|
||||
getword(str: any, index: any): string {
|
||||
const words = String(str ?? "").split(/\s+/);
|
||||
const i = Number(index) || 0;
|
||||
return words[i] ?? "";
|
||||
return getUnit(toStr(str), toNum(index), WORD_DELIM_SET);
|
||||
},
|
||||
getwordcount(str: any): number {
|
||||
const s = String(str ?? "").trim();
|
||||
if (s === "") return 0;
|
||||
return s.split(/\s+/).length;
|
||||
return getUnitCount(toStr(str), WORD_DELIM_SET);
|
||||
},
|
||||
getfield(str: any, index: any): string {
|
||||
const fields = String(str ?? "").split(FIELD_DELIM);
|
||||
const i = Number(index) || 0;
|
||||
return fields[i] ?? "";
|
||||
return getUnit(toStr(str), toNum(index), FIELD_DELIM_SET);
|
||||
},
|
||||
getfieldcount(str: any): number {
|
||||
const s = String(str ?? "");
|
||||
if (s === "") return 0;
|
||||
return s.split(FIELD_DELIM).length;
|
||||
return getUnitCount(toStr(str), FIELD_DELIM_SET);
|
||||
},
|
||||
setword(str: any, index: any, value: any): string {
|
||||
const words = String(str ?? "").split(/\s+/);
|
||||
const i = Number(index) || 0;
|
||||
words[i] = String(value ?? "");
|
||||
return words.join(" ");
|
||||
return setUnit(
|
||||
toStr(str),
|
||||
toNum(index),
|
||||
toStr(value),
|
||||
WORD_DELIM_SET,
|
||||
" ",
|
||||
);
|
||||
},
|
||||
setfield(str: any, index: any, value: any): string {
|
||||
const fields = String(str ?? "").split(FIELD_DELIM);
|
||||
const i = Number(index) || 0;
|
||||
fields[i] = String(value ?? "");
|
||||
return fields.join(FIELD_DELIM_CHAR);
|
||||
return setUnit(
|
||||
toStr(str),
|
||||
toNum(index),
|
||||
toStr(value),
|
||||
FIELD_DELIM_SET,
|
||||
FIELD_DELIM_CHAR,
|
||||
);
|
||||
},
|
||||
firstword(str: any): string {
|
||||
const words = String(str ?? "").split(/\s+/);
|
||||
return words[0] ?? "";
|
||||
return getUnit(toStr(str), 0, WORD_DELIM_SET);
|
||||
},
|
||||
restwords(str: any): string {
|
||||
const words = String(str ?? "").split(/\s+/);
|
||||
return words.slice(1).join(" ");
|
||||
// Get all words starting from index 1
|
||||
return getUnits(toStr(str), 1, 1000000, WORD_DELIM_SET);
|
||||
},
|
||||
trim(str: any): string {
|
||||
return String(str ?? "").trim();
|
||||
return toStr(str).trim();
|
||||
},
|
||||
ltrim(str: any): string {
|
||||
return String(str ?? "").replace(/^\s+/, "");
|
||||
return toStr(str).replace(/^\s+/, "");
|
||||
},
|
||||
rtrim(str: any): string {
|
||||
return String(str ?? "").replace(/\s+$/, "");
|
||||
return toStr(str).replace(/\s+$/, "");
|
||||
},
|
||||
strupr(str: any): string {
|
||||
return String(str ?? "").toUpperCase();
|
||||
return toStr(str).toUpperCase();
|
||||
},
|
||||
strlwr(str: any): string {
|
||||
return String(str ?? "").toLowerCase();
|
||||
return toStr(str).toLowerCase();
|
||||
},
|
||||
strreplace(str: any, from: any, to: any): string {
|
||||
return String(str ?? "")
|
||||
.split(String(from ?? ""))
|
||||
.join(String(to ?? ""));
|
||||
return toStr(str).split(toStr(from)).join(toStr(to));
|
||||
},
|
||||
filterstring(str: any, _replacementChars?: any): string {
|
||||
// Filters profanity/bad words from the string (requires bad word dictionary)
|
||||
// Since we don't have a bad word filter, just return the string unchanged
|
||||
return String(str ?? "");
|
||||
return toStr(str);
|
||||
},
|
||||
stripchars(str: any, chars: any): string {
|
||||
// Removes all characters in `chars` from the string
|
||||
const s = String(str ?? "");
|
||||
const toRemove = new Set(String(chars ?? "").split(""));
|
||||
const s = toStr(str);
|
||||
const toRemove = new Set(toStr(chars).split(""));
|
||||
return s
|
||||
.split("")
|
||||
.filter((c) => !toRemove.has(c))
|
||||
.join("");
|
||||
},
|
||||
getfields(str: any, start: any, end?: any): string {
|
||||
const fields = String(str ?? "").split(FIELD_DELIM);
|
||||
const s = Number(start) || 0;
|
||||
const e = end !== undefined ? Number(end) + 1 : 1000000;
|
||||
return fields.slice(s, e).join(FIELD_DELIM_CHAR);
|
||||
const e = end !== undefined ? Number(end) : 1000000;
|
||||
return getUnits(toStr(str), toNum(start), e, FIELD_DELIM_SET);
|
||||
},
|
||||
getwords(str: any, start: any, end?: any): string {
|
||||
const words = String(str ?? "").split(/\s+/);
|
||||
const s = Number(start) || 0;
|
||||
const e = end !== undefined ? Number(end) + 1 : 1000000;
|
||||
return words.slice(s, e).join(" ");
|
||||
const e = end !== undefined ? Number(end) : 1000000;
|
||||
return getUnits(toStr(str), toNum(start), e, WORD_DELIM_SET);
|
||||
},
|
||||
removeword(str: any, index: any): string {
|
||||
const words = String(str ?? "").split(/\s+/);
|
||||
const i = Number(index) || 0;
|
||||
words.splice(i, 1);
|
||||
return words.join(" ");
|
||||
return removeUnit(toStr(str), toNum(index), WORD_DELIM_SET, " ");
|
||||
},
|
||||
removefield(str: any, index: any): string {
|
||||
const fields = String(str ?? "").split(FIELD_DELIM);
|
||||
const i = Number(index) || 0;
|
||||
fields.splice(i, 1);
|
||||
return fields.join(FIELD_DELIM_CHAR);
|
||||
return removeUnit(
|
||||
toStr(str),
|
||||
toNum(index),
|
||||
FIELD_DELIM_SET,
|
||||
FIELD_DELIM_CHAR,
|
||||
);
|
||||
},
|
||||
getrecord(str: any, index: any): string {
|
||||
const records = String(str ?? "").split("\n");
|
||||
const i = Number(index) || 0;
|
||||
return records[i] ?? "";
|
||||
return getUnit(toStr(str), toNum(index), RECORD_DELIM_SET);
|
||||
},
|
||||
getrecordcount(str: any): number {
|
||||
const s = String(str ?? "");
|
||||
if (s === "") return 0;
|
||||
return s.split("\n").length;
|
||||
return getUnitCount(toStr(str), RECORD_DELIM_SET);
|
||||
},
|
||||
setrecord(str: any, index: any, value: any): string {
|
||||
const records = String(str ?? "").split("\n");
|
||||
const i = Number(index) || 0;
|
||||
records[i] = String(value ?? "");
|
||||
return records.join("\n");
|
||||
return setUnit(
|
||||
toStr(str),
|
||||
toNum(index),
|
||||
toStr(value),
|
||||
RECORD_DELIM_SET,
|
||||
"\n",
|
||||
);
|
||||
},
|
||||
removerecord(str: any, index: any): string {
|
||||
const records = String(str ?? "").split("\n");
|
||||
const i = Number(index) || 0;
|
||||
records.splice(i, 1);
|
||||
return records.join("\n");
|
||||
return removeUnit(toStr(str), toNum(index), RECORD_DELIM_SET, "\n");
|
||||
},
|
||||
nexttoken(_str: any, _tokenVar: any, _delim: any): string {
|
||||
// nextToken modifies a variable to store the remainder of the string,
|
||||
|
|
@ -244,48 +406,48 @@ export function createBuiltins(
|
|||
},
|
||||
strtoplayername(str: any): string {
|
||||
// Sanitizes a string to be a valid player name
|
||||
return String(str ?? "")
|
||||
return toStr(str)
|
||||
.replace(/[^\w\s-]/g, "")
|
||||
.trim();
|
||||
},
|
||||
|
||||
// Math functions
|
||||
mabs(n: any): number {
|
||||
return Math.abs(Number(n) || 0);
|
||||
return Math.abs(toNum(n));
|
||||
},
|
||||
mfloor(n: any): number {
|
||||
return Math.floor(Number(n) || 0);
|
||||
return Math.floor(toNum(n));
|
||||
},
|
||||
mceil(n: any): number {
|
||||
return Math.ceil(Number(n) || 0);
|
||||
return Math.ceil(toNum(n));
|
||||
},
|
||||
msqrt(n: any): number {
|
||||
return Math.sqrt(Number(n) || 0);
|
||||
return Math.sqrt(toNum(n));
|
||||
},
|
||||
mpow(base: any, exp: any): number {
|
||||
return Math.pow(Number(base) || 0, Number(exp) || 0);
|
||||
return Math.pow(toNum(base), toNum(exp));
|
||||
},
|
||||
msin(n: any): number {
|
||||
return Math.sin(Number(n) || 0);
|
||||
return Math.sin(toNum(n));
|
||||
},
|
||||
mcos(n: any): number {
|
||||
return Math.cos(Number(n) || 0);
|
||||
return Math.cos(toNum(n));
|
||||
},
|
||||
mtan(n: any): number {
|
||||
return Math.tan(Number(n) || 0);
|
||||
return Math.tan(toNum(n));
|
||||
},
|
||||
masin(n: any): number {
|
||||
return Math.asin(Number(n) || 0);
|
||||
return Math.asin(toNum(n));
|
||||
},
|
||||
macos(n: any): number {
|
||||
return Math.acos(Number(n) || 0);
|
||||
return Math.acos(toNum(n));
|
||||
},
|
||||
matan(rise: any, run: any): number {
|
||||
// SDK: mAtan(rise, run) - always requires 2 args, returns atan2
|
||||
return Math.atan2(Number(rise) || 0, Number(run) || 0);
|
||||
return Math.atan2(toNum(rise), toNum(run));
|
||||
},
|
||||
mlog(n: any): number {
|
||||
return Math.log(Number(n) || 0);
|
||||
return Math.log(toNum(n));
|
||||
},
|
||||
getrandom(a?: any, b?: any): number {
|
||||
// SDK behavior:
|
||||
|
|
@ -296,26 +458,24 @@ export function createBuiltins(
|
|||
return Math.random();
|
||||
}
|
||||
if (b === undefined) {
|
||||
return Math.floor(Math.random() * (Number(a) + 1));
|
||||
return Math.floor(Math.random() * (toNum(a) + 1));
|
||||
}
|
||||
const min = Number(a) || 0;
|
||||
const max = Number(b) || 0;
|
||||
const min = toNum(a);
|
||||
const max = toNum(b);
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
},
|
||||
mdegtorad(deg: any): number {
|
||||
return (Number(deg) || 0) * (Math.PI / 180);
|
||||
return toNum(deg) * (Math.PI / 180);
|
||||
},
|
||||
mradtodeg(rad: any): number {
|
||||
return (Number(rad) || 0) * (180 / Math.PI);
|
||||
return toNum(rad) * (180 / Math.PI);
|
||||
},
|
||||
mfloatlength(n: any, precision: any): string {
|
||||
return (Number(n) || 0).toFixed(Number(precision) || 0);
|
||||
return toNum(n).toFixed(toNum(precision));
|
||||
},
|
||||
getboxcenter(box: any): string {
|
||||
// Box format: "minX minY minZ maxX maxY maxZ"
|
||||
const parts = String(box ?? "")
|
||||
.split(" ")
|
||||
.map(Number);
|
||||
const parts = toStr(box).split(" ").map(Number);
|
||||
const minX = parts[0] || 0;
|
||||
const minY = parts[1] || 0;
|
||||
const minZ = parts[2] || 0;
|
||||
|
|
@ -338,7 +498,7 @@ export function createBuiltins(
|
|||
},
|
||||
vectorscale(v: any, s: any): string {
|
||||
const [x, y, z] = parseVector(v);
|
||||
const scale = Number(s) || 0;
|
||||
const scale = toNum(s);
|
||||
return `${x * scale} ${y * scale} ${z * scale}`;
|
||||
},
|
||||
vectordot(a: any, b: any): number {
|
||||
|
|
@ -417,7 +577,12 @@ export function createBuiltins(
|
|||
const rt = runtime();
|
||||
const timeoutId = setTimeout(() => {
|
||||
rt.state.pendingTimeouts.delete(timeoutId);
|
||||
rt.$f.call(String(func), ...args);
|
||||
try {
|
||||
rt.$f.call(String(func), ...args);
|
||||
} catch (err) {
|
||||
console.error(`schedule: error calling ${func}:`, err);
|
||||
throw err;
|
||||
}
|
||||
}, ms);
|
||||
rt.state.pendingTimeouts.add(timeoutId);
|
||||
return timeoutId;
|
||||
|
|
@ -470,22 +635,25 @@ export function createBuiltins(
|
|||
|
||||
// Misc
|
||||
isdemo(): boolean {
|
||||
// FIXME: Unsure if this is referring to demo (.rec) playback, or a demo
|
||||
// version of the game.
|
||||
// NOTE: Refers to demo version of the game, not demo recordings (.rec file playback)
|
||||
return false;
|
||||
},
|
||||
|
||||
// Files
|
||||
isfile(_path: any): boolean {
|
||||
throw new Error("isFile() not implemented: requires filesystem access");
|
||||
isfile(path: any): boolean {
|
||||
if (!fileSystem) {
|
||||
console.warn("isFile(): no fileSystem handler configured");
|
||||
return false;
|
||||
}
|
||||
return fileSystem.isFile(toStr(path));
|
||||
},
|
||||
fileext(path: any): string {
|
||||
const s = String(path ?? "");
|
||||
const s = toStr(path);
|
||||
const dot = s.lastIndexOf(".");
|
||||
return dot >= 0 ? s.substring(dot) : "";
|
||||
},
|
||||
filebase(path: any): string {
|
||||
const s = String(path ?? "");
|
||||
const s = toStr(path);
|
||||
const slash = Math.max(s.lastIndexOf("/"), s.lastIndexOf("\\"));
|
||||
const dot = s.lastIndexOf(".");
|
||||
const start = slash >= 0 ? slash + 1 : 0;
|
||||
|
|
@ -493,7 +661,7 @@ export function createBuiltins(
|
|||
return s.substring(start, end);
|
||||
},
|
||||
filepath(path: any): string {
|
||||
const s = String(path ?? "");
|
||||
const s = toStr(path);
|
||||
const slash = Math.max(s.lastIndexOf("/"), s.lastIndexOf("\\"));
|
||||
return slash >= 0 ? s.substring(0, slash) : "";
|
||||
},
|
||||
|
|
@ -502,20 +670,35 @@ export function createBuiltins(
|
|||
"expandFilename() not implemented: requires filesystem path expansion",
|
||||
);
|
||||
},
|
||||
findfirstfile(_pattern: any): string {
|
||||
throw new Error(
|
||||
"findFirstFile() not implemented: requires filesystem directory listing",
|
||||
);
|
||||
findfirstfile(pattern: any): string {
|
||||
if (!fileSystem) {
|
||||
console.warn("findFirstFile(): no fileSystem handler configured");
|
||||
return "";
|
||||
}
|
||||
fileSearchPattern = toStr(pattern);
|
||||
fileSearchResults = fileSystem.findFiles(fileSearchPattern);
|
||||
fileSearchIndex = 0;
|
||||
return fileSearchResults[fileSearchIndex++] ?? "";
|
||||
},
|
||||
findnextfile(_pattern: any): string {
|
||||
throw new Error(
|
||||
"findNextFile() not implemented: requires filesystem directory listing",
|
||||
);
|
||||
findnextfile(pattern: any): string {
|
||||
const patternStr = toStr(pattern);
|
||||
// If pattern changed, get new results but keep the cursor position.
|
||||
// This matches engine behavior where the cursor is global and persists
|
||||
// across pattern changes. If the cursor is past the end of the new
|
||||
// results, we return "" (no more matches).
|
||||
if (patternStr !== fileSearchPattern) {
|
||||
if (!fileSystem) {
|
||||
return "";
|
||||
}
|
||||
fileSearchPattern = patternStr;
|
||||
fileSearchResults = fileSystem.findFiles(patternStr);
|
||||
// Don't reset fileSearchIndex - maintain global cursor position
|
||||
}
|
||||
return fileSearchResults[fileSearchIndex++] ?? "";
|
||||
},
|
||||
getfilecrc(_path: any): number {
|
||||
throw new Error(
|
||||
"getFileCRC() not implemented: requires filesystem access",
|
||||
);
|
||||
getfilecrc(path: any): string {
|
||||
// Return path as a pseudo-CRC for identification purposes
|
||||
return toStr(path);
|
||||
},
|
||||
iswriteablefilename(path: any): boolean {
|
||||
return false;
|
||||
|
|
@ -523,13 +706,19 @@ export function createBuiltins(
|
|||
|
||||
// Package management
|
||||
activatepackage(name: any): void {
|
||||
runtime().$.activatePackage(String(name ?? ""));
|
||||
runtime().$.activatePackage(toStr(name));
|
||||
},
|
||||
deactivatepackage(name: any): void {
|
||||
runtime().$.deactivatePackage(String(name ?? ""));
|
||||
runtime().$.deactivatePackage(toStr(name));
|
||||
},
|
||||
ispackage(name: any): boolean {
|
||||
return runtime().$.isPackage(String(name ?? ""));
|
||||
return runtime().$.isPackage(toStr(name));
|
||||
},
|
||||
isactivepackage(name: any): boolean {
|
||||
return runtime().$.isActivePackage(toStr(name));
|
||||
},
|
||||
getpackagelist(): string {
|
||||
return runtime().$.getPackageList();
|
||||
},
|
||||
|
||||
// Messaging (stubs - no networking layer)
|
||||
|
|
@ -621,7 +810,7 @@ export function createBuiltins(
|
|||
return "";
|
||||
},
|
||||
detag(_tagged: any): string {
|
||||
return String(_tagged ?? "");
|
||||
return toStr(_tagged);
|
||||
},
|
||||
gettag(_str: any): number {
|
||||
return 0;
|
||||
|
|
@ -638,12 +827,22 @@ export function createBuiltins(
|
|||
setnetport(_port: any): boolean {
|
||||
return true;
|
||||
},
|
||||
allowconnections(_allow: any): void {},
|
||||
startheartbeat(): void {},
|
||||
stopheartbeat(): void {},
|
||||
gotowebpage(_url: any): void {
|
||||
// Could potentially open URL in browser
|
||||
},
|
||||
|
||||
// Simulation management
|
||||
deletedatablocks(): void {
|
||||
// Clears all datablocks in preparation for loading a new mission.
|
||||
// For map parsing, we don't need to actually delete anything.
|
||||
},
|
||||
preloaddatablock(_datablock: any): boolean {
|
||||
return true;
|
||||
},
|
||||
|
||||
// Scene/Physics
|
||||
containerboxempty(..._args: any[]): boolean {
|
||||
return true;
|
||||
|
|
|
|||
|
|
@ -1,10 +1,7 @@
|
|||
import type * as AST from "./ast";
|
||||
import { parseMethodName } from "./ast";
|
||||
|
||||
const INTEGER_OPERATORS = new Set(["%", "&", "|", "^", "<<", ">>"]);
|
||||
const ARITHMETIC_OPERATORS = new Set(["+", "-", "*", "/"]);
|
||||
const COMPARISON_OPERATORS = new Set(["<", "<=", ">", ">=", "==", "!="]);
|
||||
|
||||
/** Operators that require runtime helpers for proper TorqueScript coercion semantics */
|
||||
const OPERATOR_HELPERS: Record<string, string> = {
|
||||
// Arithmetic
|
||||
"+": "$.add",
|
||||
|
|
@ -107,6 +104,25 @@ export class CodeGenerator {
|
|||
};
|
||||
}
|
||||
|
||||
// MemberExpression with index: obj.prop[0] becomes obj with field "prop0"
|
||||
// In TorqueScript, obj.field[idx] constructs field name: field + idx
|
||||
if (target.object.type === "MemberExpression") {
|
||||
const member = target.object;
|
||||
const obj = this.expression(member.object);
|
||||
const baseProp =
|
||||
member.property.type === "Identifier"
|
||||
? JSON.stringify(member.property.name)
|
||||
: this.expression(member.property);
|
||||
const prop = `${this.runtime}.key(${baseProp}, ${indices.join(", ")})`;
|
||||
return {
|
||||
getter: `${this.runtime}.prop(${obj}, ${prop})`,
|
||||
setter: (value) =>
|
||||
`${this.runtime}.setProp(${obj}, ${prop}, ${value})`,
|
||||
postIncHelper: `${this.runtime}.propPostInc(${obj}, ${prop})`,
|
||||
postDecHelper: `${this.runtime}.propPostDec(${obj}, ${prop})`,
|
||||
};
|
||||
}
|
||||
|
||||
// Object index access: obj[key]
|
||||
const obj = this.expression(target.object);
|
||||
const index =
|
||||
|
|
@ -542,12 +558,6 @@ export class CodeGenerator {
|
|||
const right = this.expression(node.right);
|
||||
const op = node.operator;
|
||||
|
||||
// Integer operations need runtime helpers
|
||||
if (INTEGER_OPERATORS.has(op)) {
|
||||
const helper = OPERATOR_HELPERS[op];
|
||||
return `${helper}(${left}, ${right})`;
|
||||
}
|
||||
|
||||
// String concat operators
|
||||
const concat = this.concatExpression(left, op, right);
|
||||
if (concat) return concat;
|
||||
|
|
@ -560,20 +570,15 @@ export class CodeGenerator {
|
|||
return `!${this.runtime}.streq(${left}, ${right})`;
|
||||
}
|
||||
|
||||
// Logical operators (short-circuit, pass through)
|
||||
// Logical operators (short-circuit, pass through to JS)
|
||||
if (op === "&&" || op === "||") {
|
||||
return `(${left} ${op} ${right})`;
|
||||
}
|
||||
|
||||
// Arithmetic operators use runtime helpers for proper numeric coercion
|
||||
if (ARITHMETIC_OPERATORS.has(op)) {
|
||||
const helper = OPERATOR_HELPERS[op];
|
||||
return `${helper}(${left}, ${right})`;
|
||||
}
|
||||
|
||||
// Comparison operators use runtime helpers for proper numeric coercion
|
||||
if (COMPARISON_OPERATORS.has(op)) {
|
||||
const helper = OPERATOR_HELPERS[op];
|
||||
// Arithmetic, comparison, and bitwise operators use runtime helpers
|
||||
// for proper TorqueScript numeric coercion
|
||||
const helper = OPERATOR_HELPERS[op];
|
||||
if (helper) {
|
||||
return `${helper}(${left}, ${right})`;
|
||||
}
|
||||
|
||||
|
|
@ -694,6 +699,19 @@ export class CodeGenerator {
|
|||
return `${store}.get(${baseName}, ${indices.join(", ")})`;
|
||||
}
|
||||
|
||||
// MemberExpression with index: obj.prop[0] becomes obj with field "prop0"
|
||||
// In TorqueScript, obj.field[idx] constructs field name: field + idx
|
||||
if (node.object.type === "MemberExpression") {
|
||||
const member = node.object;
|
||||
const obj = this.expression(member.object);
|
||||
const baseProp =
|
||||
member.property.type === "Identifier"
|
||||
? JSON.stringify(member.property.name)
|
||||
: this.expression(member.property);
|
||||
const prop = `${this.runtime}.key(${baseProp}, ${indices.join(", ")})`;
|
||||
return `${this.runtime}.prop(${obj}, ${prop})`;
|
||||
}
|
||||
|
||||
const obj = this.expression(node.object);
|
||||
if (indices.length === 1) {
|
||||
return `${this.runtime}.getIndex(${obj}, ${indices[0]})`;
|
||||
|
|
@ -729,19 +747,13 @@ export class CodeGenerator {
|
|||
op: string,
|
||||
value: string,
|
||||
): string {
|
||||
// Integer operators need runtime helpers
|
||||
if (INTEGER_OPERATORS.has(op)) {
|
||||
const helper = OPERATOR_HELPERS[op];
|
||||
return `${helper}(${getter}, ${value})`;
|
||||
}
|
||||
|
||||
// String concat operators
|
||||
const concat = this.concatExpression(getter, op, value);
|
||||
if (concat) return concat;
|
||||
|
||||
// Arithmetic operators need runtime helpers for proper numeric coercion
|
||||
if (ARITHMETIC_OPERATORS.has(op)) {
|
||||
const helper = OPERATOR_HELPERS[op];
|
||||
// Arithmetic and bitwise operators use runtime helpers
|
||||
const helper = OPERATOR_HELPERS[op];
|
||||
if (helper) {
|
||||
return `${helper}(${getter}, ${value})`;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,16 +1,20 @@
|
|||
import TorqueScript from "@/generated/TorqueScript.cjs";
|
||||
import { generate, type GeneratorOptions } from "./codegen";
|
||||
import type { Program } from "./ast";
|
||||
import { createRuntime } from "./runtime";
|
||||
import { TorqueObject, TorqueRuntime, TorqueRuntimeOptions } from "./types";
|
||||
|
||||
export { generate, type GeneratorOptions } from "./codegen";
|
||||
export type { Program } from "./ast";
|
||||
export { createBuiltins } from "./builtins";
|
||||
export { createRuntime } from "./runtime";
|
||||
export { createRuntime, createScriptCache } from "./runtime";
|
||||
export { normalizePath } from "./utils";
|
||||
export type {
|
||||
BuiltinsContext,
|
||||
BuiltinsFactory,
|
||||
FileSystemHandler,
|
||||
RuntimeState,
|
||||
ScriptCache,
|
||||
TorqueObject,
|
||||
TorqueRuntime,
|
||||
TorqueRuntimeOptions,
|
||||
|
|
@ -44,3 +48,90 @@ export function transpile(
|
|||
const code = generate(ast, options);
|
||||
return { code, ast };
|
||||
}
|
||||
|
||||
export interface RunServerOptions {
|
||||
missionName: string;
|
||||
missionType: string;
|
||||
runtimeOptions?: TorqueRuntimeOptions;
|
||||
onMissionLoadDone?: (game: TorqueObject) => void;
|
||||
}
|
||||
|
||||
export interface RunServerResult {
|
||||
/** The runtime instance - available immediately for cleanup */
|
||||
runtime: TorqueRuntime;
|
||||
/** Promise that resolves when the mission is fully loaded and CreateServer has run */
|
||||
ready: Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a TorqueScript runtime and loads a mission.
|
||||
*
|
||||
* Returns the runtime immediately (for cleanup) along with a promise that
|
||||
* resolves when the mission is ready. The caller is responsible for calling
|
||||
* runtime.destroy() in their cleanup, regardless of whether ready resolves
|
||||
* or rejects.
|
||||
*/
|
||||
export function runServer(options: RunServerOptions): RunServerResult {
|
||||
const { missionName, missionType, runtimeOptions, onMissionLoadDone } =
|
||||
options;
|
||||
const { signal } = runtimeOptions ?? {};
|
||||
|
||||
const runtime = createRuntime({
|
||||
...runtimeOptions,
|
||||
globals: {
|
||||
...runtimeOptions?.globals,
|
||||
"$Host::Map": missionName,
|
||||
"$Host::MissionType": missionType,
|
||||
},
|
||||
});
|
||||
const gameTypeName = `${missionType}Game`;
|
||||
const gameTypeScript = `scripts/${gameTypeName}.cs`;
|
||||
|
||||
const ready = (async () => {
|
||||
try {
|
||||
// Load all required scripts
|
||||
const serverScript = await runtime.loadFromPath("scripts/server.cs");
|
||||
signal?.throwIfAborted();
|
||||
// These are dynamic exec() calls in server.cs since their paths are
|
||||
// computed based on the game type and mission. So, we need to load them
|
||||
// ahead of time so they're available to execute.
|
||||
await runtime.loadFromPath(gameTypeScript);
|
||||
signal?.throwIfAborted();
|
||||
await runtime.loadFromPath(`missions/${missionName}.mis`);
|
||||
signal?.throwIfAborted();
|
||||
|
||||
// Execute server.cs - it will exec() the game type and mission scripts
|
||||
serverScript.execute();
|
||||
|
||||
// Set up mission ready hook. It's unfortunate that we have to do it this
|
||||
// way, but there's no event system in TorqueScript. The problem is that
|
||||
// `CreateServer` will defer some actions using `schedule()`, so the
|
||||
// objects are created some arbitrary amount of time afterward, and we
|
||||
// don't actually know when they're ready. But, we can spy on the
|
||||
// `missionLoadDone` method using the runtime's `onMethodCalled` feature,
|
||||
// which we added specifically to solve this problem.
|
||||
if (onMissionLoadDone) {
|
||||
runtime.$.onMethodCalled(
|
||||
gameTypeName,
|
||||
"missionLoadDone",
|
||||
onMissionLoadDone,
|
||||
);
|
||||
}
|
||||
|
||||
// Run CreateServer to start the mission
|
||||
const createServerScript = await runtime.loadFromSource(
|
||||
"CreateServer($Host::Map, $Host::MissionType);",
|
||||
);
|
||||
signal?.throwIfAborted();
|
||||
createServerScript.execute();
|
||||
} catch (err) {
|
||||
// AbortError is expected when the caller cancels - don't propagate
|
||||
if (err instanceof Error && err.name === "AbortError") {
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
})();
|
||||
|
||||
return { runtime, ready };
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,7 +1,7 @@
|
|||
import { generate } from "./codegen";
|
||||
import { parse, type Program } from "./index";
|
||||
import { createBuiltins as defaultCreateBuiltins } from "./builtins";
|
||||
import { CaseInsensitiveMap, normalizePath } from "./utils";
|
||||
import { CaseInsensitiveMap, CaseInsensitiveSet, normalizePath } from "./utils";
|
||||
import type {
|
||||
BuiltinsContext,
|
||||
FunctionStack,
|
||||
|
|
@ -14,6 +14,7 @@ import type {
|
|||
PackageState,
|
||||
RuntimeAPI,
|
||||
RuntimeState,
|
||||
ScriptCache,
|
||||
TorqueFunction,
|
||||
TorqueMethod,
|
||||
TorqueObject,
|
||||
|
|
@ -22,6 +23,18 @@ import type {
|
|||
VariableStoreAPI,
|
||||
} from "./types";
|
||||
|
||||
/**
|
||||
* Create a script cache that can be shared across runtime instances.
|
||||
* This allows parsed ASTs and generated code to be reused when switching
|
||||
* missions or restarting the runtime.
|
||||
*/
|
||||
export function createScriptCache(): ScriptCache {
|
||||
return {
|
||||
scripts: new Map<string, Program>(),
|
||||
generatedCode: new WeakMap<Program, string>(),
|
||||
};
|
||||
}
|
||||
|
||||
function normalize(name: string): string {
|
||||
return name.toLowerCase();
|
||||
}
|
||||
|
|
@ -49,6 +62,8 @@ export function createRuntime(
|
|||
const functions = new CaseInsensitiveMap<FunctionStack>();
|
||||
const packages = new CaseInsensitiveMap<PackageState>();
|
||||
const activePackages: string[] = [];
|
||||
// Track package names that were activated before being defined (deferred activation)
|
||||
const pendingActivations = new CaseInsensitiveSet();
|
||||
|
||||
const FIRST_DATABLOCK_ID = 3;
|
||||
const FIRST_DYNAMIC_ID = 1027;
|
||||
|
|
@ -59,14 +74,89 @@ export function createRuntime(
|
|||
const objectsByName = new CaseInsensitiveMap<TorqueObject>();
|
||||
const datablocks = new CaseInsensitiveMap<TorqueObject>();
|
||||
const globals = new CaseInsensitiveMap<any>();
|
||||
const methodHooks = new CaseInsensitiveMap<
|
||||
CaseInsensitiveMap<Array<(thisObj: TorqueObject, ...args: any[]) => void>>
|
||||
>();
|
||||
// Namespace inheritance: className -> superClassName (for ScriptObject/ScriptGroup)
|
||||
const namespaceParents = new CaseInsensitiveMap<string>();
|
||||
|
||||
// Populate initial globals from options
|
||||
if (options.globals) {
|
||||
for (const [key, value] of Object.entries(options.globals)) {
|
||||
if (!key.startsWith("$")) {
|
||||
throw new Error(
|
||||
`Global variable "${key}" must start with $, e.g. "$${key}"`,
|
||||
);
|
||||
}
|
||||
globals.set(key.slice(1), value);
|
||||
}
|
||||
}
|
||||
|
||||
const executedScripts = new Set<string>();
|
||||
const scripts = new Map<string, Program>();
|
||||
const failedScripts = new Set<string>();
|
||||
// Use cache if provided, otherwise create new maps
|
||||
const cache = options.cache ?? createScriptCache();
|
||||
const scripts = cache.scripts;
|
||||
const generatedCode = cache.generatedCode;
|
||||
|
||||
// Execution context: tracks which stack index is currently executing for each function/method
|
||||
// This is needed for Parent:: to correctly call the parent in the stack, not just stack[length-2]
|
||||
// Key format: "funcname" for functions, "classname::methodname" for methods
|
||||
const executionContext = new Map<string, number[]>();
|
||||
|
||||
function pushExecutionContext(key: string, stackIndex: number): void {
|
||||
let stack = executionContext.get(key);
|
||||
if (!stack) {
|
||||
stack = [];
|
||||
executionContext.set(key, stack);
|
||||
}
|
||||
stack.push(stackIndex);
|
||||
}
|
||||
|
||||
function popExecutionContext(key: string): void {
|
||||
const stack = executionContext.get(key);
|
||||
if (stack) {
|
||||
stack.pop();
|
||||
}
|
||||
}
|
||||
|
||||
function getCurrentExecutionIndex(key: string): number | undefined {
|
||||
const stack = executionContext.get(key);
|
||||
return stack && stack.length > 0 ? stack[stack.length - 1] : undefined;
|
||||
}
|
||||
|
||||
/** Execute a function with execution context tracking for proper Parent:: support */
|
||||
function withExecutionContext<T>(key: string, index: number, fn: () => T): T {
|
||||
pushExecutionContext(key, index);
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
popExecutionContext(key);
|
||||
}
|
||||
}
|
||||
|
||||
/** Build the execution context key for a method */
|
||||
function methodContextKey(className: string, methodName: string): string {
|
||||
return `${className.toLowerCase()}::${methodName.toLowerCase()}`;
|
||||
}
|
||||
|
||||
/** Get the method stack for a class/method pair, or null if not found */
|
||||
function getMethodStack(
|
||||
className: string,
|
||||
methodName: string,
|
||||
): MethodStack | null {
|
||||
return methods.get(className)?.get(methodName) ?? null;
|
||||
}
|
||||
|
||||
const pendingTimeouts = new Set<ReturnType<typeof setTimeout>>();
|
||||
let currentPackage: PackageState | null = null;
|
||||
let runtimeRef: TorqueRuntime | null = null;
|
||||
const getRuntime = () => runtimeRef!;
|
||||
const createBuiltins = options.builtins ?? defaultCreateBuiltins;
|
||||
const builtinsCtx: BuiltinsContext = { runtime: getRuntime };
|
||||
const builtinsCtx: BuiltinsContext = {
|
||||
runtime: getRuntime,
|
||||
fileSystem: options.fileSystem ?? null,
|
||||
};
|
||||
const builtins = createBuiltins(builtinsCtx);
|
||||
|
||||
function registerMethod(
|
||||
|
|
@ -104,7 +194,14 @@ export function createRuntime(
|
|||
|
||||
function activatePackage(name: string): void {
|
||||
const pkg = packages.get(name);
|
||||
if (!pkg || pkg.active) return;
|
||||
if (!pkg) {
|
||||
// Package doesn't exist yet - defer activation until it's defined
|
||||
// This matches Torque engine behavior where activatePackage can be
|
||||
// called before the package block is executed
|
||||
pendingActivations.add(name);
|
||||
return;
|
||||
}
|
||||
if (pkg.active) return;
|
||||
|
||||
pkg.active = true;
|
||||
activePackages.push(pkg.name);
|
||||
|
|
@ -180,7 +277,12 @@ export function createRuntime(
|
|||
fn();
|
||||
currentPackage = prevPackage;
|
||||
|
||||
activatePackage(name);
|
||||
// Check for deferred activation - if activatePackage was called before
|
||||
// the package was defined, activate it now
|
||||
if (pendingActivations.has(name)) {
|
||||
pendingActivations.delete(name);
|
||||
activatePackage(name);
|
||||
}
|
||||
}
|
||||
|
||||
function createObject(
|
||||
|
|
@ -202,6 +304,15 @@ export function createRuntime(
|
|||
obj[normalize(key)] = value;
|
||||
}
|
||||
|
||||
// Extract superClass for namespace inheritance (used by ScriptObject/ScriptGroup)
|
||||
if (obj.superclass) {
|
||||
obj._superClass = normalize(String(obj.superclass));
|
||||
// Register the class -> superClass link for method lookup chains
|
||||
if (obj.class) {
|
||||
namespaceParents.set(normalize(String(obj.class)), obj._superClass);
|
||||
}
|
||||
}
|
||||
|
||||
objectsById.set(id, obj);
|
||||
|
||||
const name = toName(instanceName);
|
||||
|
|
@ -318,32 +429,52 @@ export function createRuntime(
|
|||
return obj;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an object reference to an actual TorqueObject.
|
||||
* In TorqueScript, bareword identifiers in member expressions (e.g., `Game.cdtrack`)
|
||||
* are looked up by name via Sim::findObject(). This function handles that resolution.
|
||||
*/
|
||||
function resolveObject(obj: any): TorqueObject | null {
|
||||
if (obj == null || obj === "") return null;
|
||||
// Already an object with an ID - return as-is
|
||||
if (typeof obj === "object" && obj._id != null) return obj;
|
||||
// String or number - look up by name or ID
|
||||
if (typeof obj === "string") return objectsByName.get(obj) ?? null;
|
||||
if (typeof obj === "number") return objectsById.get(obj) ?? null;
|
||||
return null;
|
||||
}
|
||||
|
||||
function prop(obj: any, name: string): any {
|
||||
if (obj == null) return "";
|
||||
return obj[normalize(name)] ?? "";
|
||||
const resolved = resolveObject(obj);
|
||||
if (resolved == null) return "";
|
||||
return resolved[normalize(name)] ?? "";
|
||||
}
|
||||
|
||||
function setProp(obj: any, name: string, value: any): any {
|
||||
if (obj == null) return value;
|
||||
obj[normalize(name)] = value;
|
||||
const resolved = resolveObject(obj);
|
||||
if (resolved == null) return value;
|
||||
resolved[normalize(name)] = value;
|
||||
return value;
|
||||
}
|
||||
|
||||
function getIndex(obj: any, index: any): any {
|
||||
if (obj == null) return "";
|
||||
return obj[String(index)] ?? "";
|
||||
const resolved = resolveObject(obj);
|
||||
if (resolved == null) return "";
|
||||
return resolved[String(index)] ?? "";
|
||||
}
|
||||
|
||||
function setIndex(obj: any, index: any, value: any): any {
|
||||
if (obj == null) return value;
|
||||
obj[String(index)] = value;
|
||||
const resolved = resolveObject(obj);
|
||||
if (resolved == null) return value;
|
||||
resolved[String(index)] = value;
|
||||
return value;
|
||||
}
|
||||
|
||||
function postIncDec(obj: any, key: string, delta: 1 | -1): number {
|
||||
if (obj == null) return 0;
|
||||
const oldValue = toNum(obj[key]);
|
||||
obj[key] = oldValue + delta;
|
||||
const resolved = resolveObject(obj);
|
||||
if (resolved == null) return 0;
|
||||
const oldValue = toNum(resolved[key]);
|
||||
resolved[key] = oldValue + delta;
|
||||
return oldValue;
|
||||
}
|
||||
|
||||
|
|
@ -372,22 +503,47 @@ export function createRuntime(
|
|||
className: string,
|
||||
methodName: string,
|
||||
): TorqueMethod | null {
|
||||
const classMethods = methods.get(className);
|
||||
if (classMethods) {
|
||||
const stack = classMethods.get(methodName);
|
||||
if (stack && stack.length > 0) {
|
||||
return stack[stack.length - 1];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
const stack = getMethodStack(className, methodName);
|
||||
return stack && stack.length > 0 ? stack[stack.length - 1] : null;
|
||||
}
|
||||
|
||||
/** Call a method with execution context tracking for proper Parent:: support */
|
||||
function callMethodWithContext(
|
||||
className: string,
|
||||
methodName: string,
|
||||
thisObj: any,
|
||||
args: any[],
|
||||
): { found: true; result: any } | { found: false } {
|
||||
const stack = getMethodStack(className, methodName);
|
||||
if (!stack || stack.length === 0) return { found: false };
|
||||
|
||||
const key = methodContextKey(className, methodName);
|
||||
const result = withExecutionContext(key, stack.length - 1, () =>
|
||||
stack[stack.length - 1](thisObj, ...args),
|
||||
);
|
||||
return { found: true, result };
|
||||
}
|
||||
|
||||
function findFunction(name: string): TorqueFunction | null {
|
||||
const stack = functions.get(name);
|
||||
if (stack && stack.length > 0) {
|
||||
return stack[stack.length - 1];
|
||||
return stack && stack.length > 0 ? stack[stack.length - 1] : null;
|
||||
}
|
||||
|
||||
function fireMethodHooks(
|
||||
className: string,
|
||||
methodName: string,
|
||||
thisObj: TorqueObject,
|
||||
args: any[],
|
||||
): void {
|
||||
const classHooks = methodHooks.get(className);
|
||||
if (classHooks) {
|
||||
const hooks = classHooks.get(methodName);
|
||||
if (hooks) {
|
||||
for (const hook of hooks) {
|
||||
hook(thisObj, ...args);
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function call(obj: any, methodName: string, ...args: any[]): any {
|
||||
|
|
@ -399,24 +555,51 @@ export function createRuntime(
|
|||
if (obj == null) return "";
|
||||
}
|
||||
|
||||
const objClass = obj._className || obj._class;
|
||||
// For ScriptObject/ScriptGroup, the "class" property overrides the C++ class name
|
||||
const objClass = obj.class || obj._className || obj._class;
|
||||
|
||||
if (objClass) {
|
||||
const fn = findMethod(objClass, methodName);
|
||||
if (fn) {
|
||||
return fn(obj, ...args);
|
||||
const callResult = callMethodWithContext(objClass, methodName, obj, args);
|
||||
if (callResult.found) {
|
||||
fireMethodHooks(objClass, methodName, obj, args);
|
||||
return callResult.result;
|
||||
}
|
||||
}
|
||||
|
||||
// Walk the superClass chain (for ScriptObject/ScriptGroup inheritance)
|
||||
// First check the object's direct _superClass, then walk namespaceParents
|
||||
let currentClass = obj._superClass || namespaceParents.get(objClass);
|
||||
while (currentClass) {
|
||||
const callResult = callMethodWithContext(
|
||||
currentClass,
|
||||
methodName,
|
||||
obj,
|
||||
args,
|
||||
);
|
||||
if (callResult.found) {
|
||||
fireMethodHooks(currentClass, methodName, obj, args);
|
||||
return callResult.result;
|
||||
}
|
||||
// Walk up the namespace parent chain
|
||||
currentClass = namespaceParents.get(currentClass);
|
||||
}
|
||||
|
||||
// Walk datablock parent chain
|
||||
const db = obj._datablock || obj;
|
||||
if (db._parent) {
|
||||
let current = db._parent;
|
||||
while (current) {
|
||||
const parentClass = current._className || current._class;
|
||||
if (parentClass) {
|
||||
const fn = findMethod(parentClass, methodName);
|
||||
if (fn) {
|
||||
return fn(obj, ...args);
|
||||
const callResult = callMethodWithContext(
|
||||
parentClass,
|
||||
methodName,
|
||||
obj,
|
||||
args,
|
||||
);
|
||||
if (callResult.found) {
|
||||
fireMethodHooks(parentClass, methodName, obj, args);
|
||||
return callResult.result;
|
||||
}
|
||||
}
|
||||
current = current._parent;
|
||||
|
|
@ -427,22 +610,37 @@ export function createRuntime(
|
|||
}
|
||||
|
||||
function nsCall(namespace: string, method: string, ...args: any[]): any {
|
||||
const fn = findMethod(namespace, method);
|
||||
if (fn) {
|
||||
return (fn as TorqueFunction)(...args);
|
||||
// For nsCall, args are passed directly to the method (including %this as args[0])
|
||||
// This is different from call() where thisObj is passed separately
|
||||
const stack = getMethodStack(namespace, method);
|
||||
if (!stack || stack.length === 0) return "";
|
||||
|
||||
const key = methodContextKey(namespace, method);
|
||||
const fn = stack[stack.length - 1] as (...args: any[]) => any;
|
||||
const result = withExecutionContext(key, stack.length - 1, () =>
|
||||
fn(...args),
|
||||
);
|
||||
|
||||
// First arg is typically the object (e.g., %game in DefaultGame::missionLoadDone(%game))
|
||||
const thisObj = args[0];
|
||||
if (thisObj && typeof thisObj === "object") {
|
||||
fireMethodHooks(namespace, method, thisObj, args.slice(1));
|
||||
}
|
||||
return "";
|
||||
return result;
|
||||
}
|
||||
|
||||
function nsRef(
|
||||
namespace: string,
|
||||
method: string,
|
||||
): ((...args: any[]) => any) | null {
|
||||
const fn = findMethod(namespace, method);
|
||||
if (fn) {
|
||||
return (...args: any[]) => (fn as TorqueFunction)(...args);
|
||||
}
|
||||
return null;
|
||||
const stack = getMethodStack(namespace, method);
|
||||
if (!stack || stack.length === 0) return null;
|
||||
|
||||
const key = methodContextKey(namespace, method);
|
||||
const fn = stack[stack.length - 1] as (...args: any[]) => any;
|
||||
// Return a wrapper that tracks execution context for proper Parent:: support
|
||||
return (...args: any[]) =>
|
||||
withExecutionContext(key, stack.length - 1, () => fn(...args));
|
||||
}
|
||||
|
||||
function parent(
|
||||
|
|
@ -451,21 +649,31 @@ export function createRuntime(
|
|||
thisObj: any,
|
||||
...args: any[]
|
||||
): any {
|
||||
const classMethods = methods.get(currentClass);
|
||||
if (!classMethods) return "";
|
||||
const stack = getMethodStack(currentClass, methodName);
|
||||
if (!stack) return "";
|
||||
|
||||
const stack = classMethods.get(methodName);
|
||||
if (!stack || stack.length < 2) return "";
|
||||
const key = methodContextKey(currentClass, methodName);
|
||||
const currentIndex = getCurrentExecutionIndex(key);
|
||||
if (currentIndex === undefined || currentIndex < 1) return "";
|
||||
|
||||
// Call parent method with the object as first argument
|
||||
return stack[stack.length - 2](thisObj, ...args);
|
||||
const parentIndex = currentIndex - 1;
|
||||
return withExecutionContext(key, parentIndex, () =>
|
||||
stack[parentIndex](thisObj, ...args),
|
||||
);
|
||||
}
|
||||
|
||||
function parentFunc(currentFunc: string, ...args: any[]): any {
|
||||
const stack = functions.get(currentFunc);
|
||||
if (!stack || stack.length < 2) return "";
|
||||
if (!stack) return "";
|
||||
|
||||
return stack[stack.length - 2](...args);
|
||||
const key = currentFunc.toLowerCase();
|
||||
const currentIndex = getCurrentExecutionIndex(key);
|
||||
if (currentIndex === undefined || currentIndex < 1) return "";
|
||||
|
||||
const parentIndex = currentIndex - 1;
|
||||
return withExecutionContext(key, parentIndex, () =>
|
||||
stack[parentIndex](...args),
|
||||
);
|
||||
}
|
||||
|
||||
function toNum(value: any): number {
|
||||
|
|
@ -487,9 +695,7 @@ export function createRuntime(
|
|||
}
|
||||
|
||||
function div(a: any, b: any): number {
|
||||
const divisor = toNum(b);
|
||||
if (divisor === 0) return 0; // TorqueScript returns 0 for division by zero
|
||||
return toNum(a) / divisor;
|
||||
return toNum(a) / toNum(b);
|
||||
}
|
||||
|
||||
function neg(a: any): number {
|
||||
|
|
@ -577,14 +783,64 @@ export function createRuntime(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find an object by path. Supports:
|
||||
* - Simple names: "MissionGroup"
|
||||
* - Path resolution: "MissionGroup/Teams/team0"
|
||||
* - Numeric IDs: "123" or "123/child"
|
||||
* - Absolute paths: "/MissionGroup/Teams"
|
||||
*/
|
||||
function findObjectByPath(name: string): TorqueObject | null {
|
||||
if (!name || name === "") return null;
|
||||
|
||||
// Handle leading slash (absolute path from root)
|
||||
if (name.startsWith("/")) {
|
||||
name = name.slice(1);
|
||||
}
|
||||
|
||||
// Split into path segments
|
||||
const segments = name.split("/");
|
||||
let current: TorqueObject | null = null;
|
||||
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const segment = segments[i];
|
||||
if (!segment) continue;
|
||||
|
||||
if (i === 0) {
|
||||
// First segment: look up in global dictionaries
|
||||
// Check if it's a numeric ID
|
||||
if (/^\d+$/.test(segment)) {
|
||||
current = objectsById.get(parseInt(segment, 10)) ?? null;
|
||||
} else {
|
||||
current = objectsByName.get(segment) ?? null;
|
||||
}
|
||||
} else {
|
||||
// Subsequent segments: look in children of current object
|
||||
if (!current || !current._children) {
|
||||
return null;
|
||||
}
|
||||
const segmentLower = segment.toLowerCase();
|
||||
const child = current._children.find(
|
||||
(c) => c._name?.toLowerCase() === segmentLower,
|
||||
);
|
||||
current = child ?? null;
|
||||
}
|
||||
|
||||
if (!current) return null;
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
function deref(tag: any): any {
|
||||
if (tag == null || tag === "") return null;
|
||||
return objectsByName.get(String(tag)) ?? null;
|
||||
return findObjectByPath(String(tag));
|
||||
}
|
||||
|
||||
function nameToId(name: string): number {
|
||||
const obj = objectsByName.get(name);
|
||||
return obj ? obj._id : 0;
|
||||
const obj = findObjectByPath(name);
|
||||
// TorqueScript returns -1 when object not found, not 0
|
||||
return obj ? obj._id : -1;
|
||||
}
|
||||
|
||||
function isObject(obj: any): boolean {
|
||||
|
|
@ -596,13 +852,23 @@ export function createRuntime(
|
|||
}
|
||||
|
||||
function isFunction(name: string): boolean {
|
||||
return functions.has(name);
|
||||
// Check both user-defined functions and builtins
|
||||
return functions.has(name) || name.toLowerCase() in builtins;
|
||||
}
|
||||
|
||||
function isPackage(name: string): boolean {
|
||||
return packages.has(name);
|
||||
}
|
||||
|
||||
function isActivePackage(name: string): boolean {
|
||||
const pkg = packages.get(name);
|
||||
return pkg?.active ?? false;
|
||||
}
|
||||
|
||||
function getPackageList(): string {
|
||||
return activePackages.join(" ");
|
||||
}
|
||||
|
||||
function createVariableStore(
|
||||
storage: CaseInsensitiveMap<any>,
|
||||
): VariableStoreAPI {
|
||||
|
|
@ -696,14 +962,36 @@ export function createRuntime(
|
|||
isObject,
|
||||
isFunction,
|
||||
isPackage,
|
||||
isActivePackage,
|
||||
getPackageList,
|
||||
locals: createLocals,
|
||||
onMethodCalled(
|
||||
className: string,
|
||||
methodName: string,
|
||||
callback: (thisObj: TorqueObject, ...args: any[]) => void,
|
||||
): void {
|
||||
let classMethods = methodHooks.get(className);
|
||||
if (!classMethods) {
|
||||
classMethods = new CaseInsensitiveMap();
|
||||
methodHooks.set(className, classMethods);
|
||||
}
|
||||
let hooks = classMethods.get(methodName);
|
||||
if (!hooks) {
|
||||
hooks = [];
|
||||
classMethods.set(methodName, hooks);
|
||||
}
|
||||
hooks.push(callback);
|
||||
},
|
||||
};
|
||||
|
||||
const $f: FunctionsAPI = {
|
||||
call(name: string, ...args: any[]): any {
|
||||
const fn = findFunction(name);
|
||||
if (fn) {
|
||||
return fn(...args);
|
||||
const fnStack = functions.get(name);
|
||||
if (fnStack && fnStack.length > 0) {
|
||||
const key = name.toLowerCase();
|
||||
return withExecutionContext(key, fnStack.length - 1, () =>
|
||||
fnStack[fnStack.length - 1](...args),
|
||||
);
|
||||
}
|
||||
|
||||
// Builtins are stored with lowercase keys
|
||||
|
|
@ -712,18 +1000,18 @@ export function createRuntime(
|
|||
return builtin(...args);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
// Match TorqueScript behavior: warn and return empty string
|
||||
console.warn(
|
||||
`Unknown function: ${name}(${args
|
||||
.map((a) => JSON.stringify(a))
|
||||
.join(", ")})`,
|
||||
);
|
||||
return "";
|
||||
},
|
||||
};
|
||||
|
||||
const $g: GlobalsAPI = createVariableStore(globals);
|
||||
|
||||
const generatedCode = new WeakMap<Program, string>();
|
||||
|
||||
const state: RuntimeState = {
|
||||
methods,
|
||||
functions,
|
||||
|
|
@ -734,6 +1022,7 @@ export function createRuntime(
|
|||
datablocks,
|
||||
globals,
|
||||
executedScripts,
|
||||
failedScripts,
|
||||
scripts,
|
||||
generatedCode,
|
||||
pendingTimeouts,
|
||||
|
|
@ -779,6 +1068,7 @@ export function createRuntime(
|
|||
async function loadDependencies(
|
||||
ast: Program,
|
||||
loading: Set<string>,
|
||||
includePreload: boolean = false,
|
||||
): Promise<void> {
|
||||
const loader = options.loadScript;
|
||||
if (!loader) {
|
||||
|
|
@ -792,11 +1082,21 @@ export function createRuntime(
|
|||
return;
|
||||
}
|
||||
|
||||
for (const ref of ast.execScriptPaths) {
|
||||
// Combine static exec() paths with preload scripts (on first call only)
|
||||
const scriptsToLoad = includePreload
|
||||
? [...ast.execScriptPaths, ...(options.preloadScripts ?? [])]
|
||||
: ast.execScriptPaths;
|
||||
|
||||
for (const ref of scriptsToLoad) {
|
||||
options.signal?.throwIfAborted();
|
||||
const normalized = normalizePath(ref);
|
||||
|
||||
// Skip if already loaded or currently loading (cycle detection)
|
||||
if (state.scripts.has(normalized) || loading.has(normalized)) {
|
||||
// Skip if already loaded, failed, or currently loading (cycle detection)
|
||||
if (
|
||||
state.scripts.has(normalized) ||
|
||||
state.failedScripts.has(normalized) ||
|
||||
loading.has(normalized)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -805,6 +1105,7 @@ export function createRuntime(
|
|||
const source = await loader(ref);
|
||||
if (source == null) {
|
||||
console.warn(`Script not found: ${ref}`);
|
||||
state.failedScripts.add(normalized);
|
||||
loading.delete(normalized);
|
||||
continue;
|
||||
}
|
||||
|
|
@ -814,6 +1115,7 @@ export function createRuntime(
|
|||
depAst = parse(source, { filename: ref });
|
||||
} catch (err) {
|
||||
console.warn(`Failed to parse script: ${ref}`, err);
|
||||
state.failedScripts.add(normalized);
|
||||
loading.delete(normalized);
|
||||
continue;
|
||||
}
|
||||
|
|
@ -870,14 +1172,14 @@ export function createRuntime(
|
|||
ast: Program,
|
||||
loadOptions?: LoadScriptOptions,
|
||||
): Promise<LoadedScript> {
|
||||
// Load dependencies
|
||||
// Load dependencies (include preload scripts on initial load)
|
||||
const loading = new Set<string>();
|
||||
if (loadOptions?.path) {
|
||||
const normalized = normalizePath(loadOptions.path);
|
||||
loading.add(normalized);
|
||||
state.scripts.set(normalized, ast);
|
||||
}
|
||||
await loadDependencies(ast, loading);
|
||||
await loadDependencies(ast, loading, true);
|
||||
|
||||
return createLoadedScript(ast, loadOptions?.path);
|
||||
}
|
||||
|
|
@ -892,6 +1194,8 @@ export function createRuntime(
|
|||
loadFromPath,
|
||||
loadFromSource,
|
||||
loadFromAST,
|
||||
call: (name: string, ...args: any[]) => $f.call(name, ...args),
|
||||
getObjectByName: (name: string) => objectsByName.get(name),
|
||||
};
|
||||
return runtimeRef;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ export interface TorqueObject {
|
|||
_id: number;
|
||||
_name?: string;
|
||||
_isDatablock?: boolean;
|
||||
_superClass?: string; // normalized superClass name (for ScriptObjects)
|
||||
_parent?: TorqueObject;
|
||||
_children?: TorqueObject[];
|
||||
[key: string]: any;
|
||||
|
|
@ -35,6 +36,7 @@ export interface RuntimeState {
|
|||
datablocks: CaseInsensitiveMap<TorqueObject>;
|
||||
globals: CaseInsensitiveMap<any>;
|
||||
executedScripts: Set<string>;
|
||||
failedScripts: Set<string>;
|
||||
scripts: Map<string, Program>;
|
||||
generatedCode: WeakMap<Program, string>;
|
||||
pendingTimeouts: Set<ReturnType<typeof setTimeout>>;
|
||||
|
|
@ -54,23 +56,71 @@ export interface TorqueRuntime {
|
|||
options?: LoadScriptOptions,
|
||||
): Promise<LoadedScript>;
|
||||
loadFromAST(ast: Program, options?: LoadScriptOptions): Promise<LoadedScript>;
|
||||
/** Call a TorqueScript function by name. Shorthand for $f.call(). */
|
||||
call(name: string, ...args: any[]): any;
|
||||
/** Get an object by its name. Returns undefined if not found. */
|
||||
getObjectByName(name: string): TorqueObject | undefined;
|
||||
}
|
||||
|
||||
export type ScriptLoader = (path: string) => Promise<string | null>;
|
||||
|
||||
/**
|
||||
* Handler for file system operations (findFirstFile, findNextFile, isFile).
|
||||
* The runtime maintains an iterator state for the current file search.
|
||||
*/
|
||||
export interface FileSystemHandler {
|
||||
/**
|
||||
* Find files matching a glob pattern.
|
||||
* Returns an array of matching file paths (relative to game root).
|
||||
*/
|
||||
findFiles(pattern: string): string[];
|
||||
|
||||
/**
|
||||
* Check if a file exists at the given path.
|
||||
*/
|
||||
isFile(path: string): boolean;
|
||||
}
|
||||
|
||||
export interface LoadedScript {
|
||||
execute(): void;
|
||||
}
|
||||
|
||||
export interface TorqueRuntimeOptions {
|
||||
loadScript?: ScriptLoader;
|
||||
fileSystem?: FileSystemHandler;
|
||||
builtins?: BuiltinsFactory;
|
||||
signal?: AbortSignal;
|
||||
globals?: Record<string, any>;
|
||||
/**
|
||||
* Scripts to preload during dependency resolution. Useful for scripts that
|
||||
* are exec()'d dynamically and can't be statically analyzed.
|
||||
*/
|
||||
preloadScripts?: string[];
|
||||
/**
|
||||
* Cache for parsed scripts and generated code. If provided, the runtime
|
||||
* will use this cache to store and retrieve parsed ASTs, avoiding redundant
|
||||
* parsing when scripts are loaded multiple times across runtime instances.
|
||||
* Create with `createScriptCache()`.
|
||||
*/
|
||||
cache?: ScriptCache;
|
||||
}
|
||||
|
||||
export interface LoadScriptOptions {
|
||||
path?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache for parsed scripts and generated code. Can be shared across
|
||||
* multiple runtime instances to speed up script loading when switching
|
||||
* missions or restarting the runtime.
|
||||
*/
|
||||
export interface ScriptCache {
|
||||
/** Parsed ASTs by normalized path */
|
||||
scripts: Map<string, Program>;
|
||||
/** Generated JavaScript code by AST */
|
||||
generatedCode: WeakMap<Program, string>;
|
||||
}
|
||||
|
||||
export interface RuntimeAPI {
|
||||
// Registration
|
||||
registerMethod(className: string, methodName: string, fn: TorqueMethod): void;
|
||||
|
|
@ -150,9 +200,23 @@ export interface RuntimeAPI {
|
|||
isObject(obj: any): boolean;
|
||||
isFunction(name: string): boolean;
|
||||
isPackage(name: string): boolean;
|
||||
isActivePackage(name: string): boolean;
|
||||
getPackageList(): string;
|
||||
|
||||
// Local variable scope
|
||||
locals(): LocalsAPI;
|
||||
|
||||
// Hooks
|
||||
/**
|
||||
* Register a callback to be called after a method is invoked.
|
||||
* Useful for hooking into game events like missionLoadDone without
|
||||
* worrying about method registration order.
|
||||
*/
|
||||
onMethodCalled(
|
||||
className: string,
|
||||
methodName: string,
|
||||
callback: (thisObj: TorqueObject, ...args: any[]) => void,
|
||||
): void;
|
||||
}
|
||||
|
||||
export interface FunctionsAPI {
|
||||
|
|
@ -172,6 +236,7 @@ export type LocalsAPI = VariableStoreAPI;
|
|||
|
||||
export interface BuiltinsContext {
|
||||
runtime: () => TorqueRuntime;
|
||||
fileSystem: FileSystemHandler | null;
|
||||
}
|
||||
|
||||
export type BuiltinsFactory = (
|
||||
|
|
|
|||
|
|
@ -89,6 +89,50 @@ export class CaseInsensitiveMap<V> {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set with case-insensitive membership checks.
|
||||
*/
|
||||
export class CaseInsensitiveSet {
|
||||
private set = new Set<string>();
|
||||
|
||||
constructor(values?: Iterable<string> | null) {
|
||||
if (values) {
|
||||
for (const value of values) {
|
||||
this.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this.set.size;
|
||||
}
|
||||
|
||||
add(value: string): this {
|
||||
this.set.add(value.toLowerCase());
|
||||
return this;
|
||||
}
|
||||
|
||||
has(value: string): boolean {
|
||||
return this.set.has(value.toLowerCase());
|
||||
}
|
||||
|
||||
delete(value: string): boolean {
|
||||
return this.set.delete(value.toLowerCase());
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.set.clear();
|
||||
}
|
||||
|
||||
[Symbol.iterator](): IterableIterator<string> {
|
||||
return this.set[Symbol.iterator]();
|
||||
}
|
||||
|
||||
get [Symbol.toStringTag](): string {
|
||||
return "CaseInsensitiveSet";
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizePath(path: string): string {
|
||||
return path.replace(/\\/g, "/").toLowerCase();
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue