From 62f348718900cee2eb2d9d19ff4f6e39ebcf058e Mon Sep 17 00:00:00 2001 From: Brian Beck Date: Tue, 2 Dec 2025 19:14:07 -0800 Subject: [PATCH] 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 --- TorqueScript.pegjs | 21 +- generated/TorqueScript.cjs | 177 ++++-- package-lock.json | 10 +- package.json | 2 + src/components/Mission.tsx | 94 +-- src/components/renderObject.tsx | 2 +- src/manifest.ts | 6 +- src/torqueScript/builtins.ts | 471 ++++++++++---- src/torqueScript/codegen.ts | 70 ++- src/torqueScript/index.ts | 93 ++- src/torqueScript/runtime.spec.ts | 1008 +++++++++++++++++++++++++++++- src/torqueScript/runtime.ts | 442 +++++++++++-- src/torqueScript/types.ts | 65 ++ src/torqueScript/utils.ts | 44 ++ 14 files changed, 2131 insertions(+), 374 deletions(-) diff --git a/TorqueScript.pegjs b/TorqueScript.pegjs index a2d9a46c..d38b1322 100644 --- a/TorqueScript.pegjs +++ b/TorqueScript.pegjs @@ -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 { diff --git a/generated/TorqueScript.cjs b/generated/TorqueScript.cjs index 9d57d900..2168bcd5 100644 --- a/generated/TorqueScript.cjs +++ b/generated/TorqueScript.cjs @@ -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; diff --git a/package-lock.json b/package-lock.json index 2a8ae3f4..7e31121a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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": { diff --git a/package.json b/package.json index 11a857d9..3e7d4078 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/Mission.tsx b/src/components/Mission.tsx index b38d2a45..6c773bf3 100644 --- a/src/components/Mission.tsx +++ b/src/components/Mission.tsx @@ -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(); 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 ( - - {executedMission.objects.map((object, i) => renderObject(object, i))} - - ); + return {renderObject(missionGroup)}; }); diff --git a/src/components/renderObject.tsx b/src/components/renderObject.tsx index 115cff1b..358f8fdb 100644 --- a/src/components/renderObject.tsx +++ b/src/components/renderObject.tsx @@ -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 ? : null; } diff --git a/src/manifest.ts b/src/manifest.ts index 24c8e48d..9b8d777e 100644 --- a/src/manifest.ts +++ b/src/manifest.ts @@ -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. diff --git a/src/torqueScript/builtins.ts b/src/torqueScript/builtins.ts index 85d5060f..5b114eff 100644 --- a/src/torqueScript/builtins.ts +++ b/src/torqueScript/builtins.ts @@ -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 { - 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; diff --git a/src/torqueScript/codegen.ts b/src/torqueScript/codegen.ts index 6ad333b7..df82191c 100644 --- a/src/torqueScript/codegen.ts +++ b/src/torqueScript/codegen.ts @@ -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 = { // 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})`; } diff --git a/src/torqueScript/index.ts b/src/torqueScript/index.ts index 2f47d072..a63e8ad4 100644 --- a/src/torqueScript/index.ts +++ b/src/torqueScript/index.ts @@ -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; +} + +/** + * 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 }; +} diff --git a/src/torqueScript/runtime.spec.ts b/src/torqueScript/runtime.spec.ts index 21582ddc..a2c5f0ec 100644 --- a/src/torqueScript/runtime.spec.ts +++ b/src/torqueScript/runtime.spec.ts @@ -153,6 +153,90 @@ describe("TorqueScript Runtime", () => { expect($g.get("same")).toBe(true); expect($g.get("diff")).toBe(false); }); + + it("allows assignment on the right side of concat (SPC)", () => { + // Pattern from Training5.mis: + // echo("InitialBarrel =" SPC %initialBarrel = (%skill < 3 ? "Missile" : "Plasma")); + const { $g, $l } = run(` + $skill = 2; + $result = "Barrel:" SPC %barrel = ($skill < 3 ? "Missile" : "Plasma"); + `); + // The assignment should happen AND the result should include the assigned value + expect($l.get("barrel")).toBe("Missile"); + expect($g.get("result")).toBe("Barrel: Missile"); + }); + + it("allows assignment on the right side of concat (@)", () => { + const { $g, $l } = run(` + $result = "value=" @ %x = 42; + `); + expect($l.get("x")).toBe(42); + expect($g.get("result")).toBe("value=42"); + }); + + it("allows multiple concat with assignment at end", () => { + // Assignment is right-associative and consumes everything to its right + // So "a" SPC "b" SPC %x = "c" parses as "a" SPC "b" SPC (%x = "c") + const { $g, $l } = run(` + $result = "a" SPC "b" SPC %x = "c"; + `); + expect($l.get("x")).toBe("c"); + expect($g.get("result")).toBe("a b c"); + }); + }); + + describe("operator precedence", () => { + it("parses unary minus with correct precedence", () => { + // -1 + 2 should be (-1) + 2 = 1, NOT -(1 + 2) = -3 + const { $g } = run(` + $result = -1 + 2; + `); + expect($g.get("result")).toBe(1); + }); + + it("parses unary minus in complex expressions", () => { + const { $g } = run(` + $a = -5 * 2; // (-5) * 2 = -10 + $b = 10 + -3; // 10 + (-3) = 7 + $c = -2 + -3; // (-2) + (-3) = -5 + $d = -10 / 2 + 1; // ((-10) / 2) + 1 = -4 + `); + expect($g.get("a")).toBe(-10); + expect($g.get("b")).toBe(7); + expect($g.get("c")).toBe(-5); + expect($g.get("d")).toBe(-4); + }); + + it("parses logical not with correct precedence", () => { + const { $g } = run(` + $a = !0 + 1; // (!0) + 1 = 1 + 1 = 2 + $b = !1 || 1; // (!1) || 1 = 0 || 1 = 1 + `); + expect($g.get("a")).toBe(2); + expect($g.get("b")).toBe(1); + }); + + it("parses bitwise not with correct precedence", () => { + // The runtime uses unsigned 32-bit for bitwise ops + // ~1 >>> 0 = 4294967294 (0xFFFFFFFE), then + 3 = 4294967297 + // The key test here is precedence: (~1) + 3, not ~(1 + 3) + const { $g } = run(` + $a = ~1 + 3; + `); + // ~1 as unsigned 32-bit = 4294967294, plus 3 = 4294967297 + expect($g.get("a")).toBe(4294967297); + }); + + it("handles multiple unary operators", () => { + const { $g } = run(` + $a = --5; // -(-5) = 5 + $b = !!1; // !(!1) = !(0) = 1 (truthy) + $c = -(-(-1)); // 1 negated 3 times = -1 + `); + expect($g.get("a")).toBe(5); + expect($g.get("b")).toBeTruthy(); // JavaScript returns boolean, TorqueScript treats as truthy + expect($g.get("c")).toBe(-1); + }); }); describe("control flow", () => { @@ -201,6 +285,45 @@ describe("TorqueScript Runtime", () => { expect($g.get("result")).toBe(3); }); + it("handles do-while loops", () => { + const { $g } = run(` + function countdown(%n) { + %result = ""; + do { + %result = %result @ %n; + %n--; + } while (%n > 0); + return %result; + } + $result = countdown(3); + `); + expect($g.get("result")).toBe("321"); + }); + + it("handles break in loops", () => { + const { $g } = run(` + %sum = 0; + for (%i = 0; %i < 10; %i++) { + if (%i == 5) break; + %sum += %i; + } + $result = %sum; + `); + expect($g.get("result")).toBe(10); // 0+1+2+3+4 + }); + + it("handles continue in loops", () => { + const { $g } = run(` + %sum = 0; + for (%i = 0; %i < 5; %i++) { + if (%i == 2) continue; + %sum += %i; + } + $result = %sum; + `); + expect($g.get("result")).toBe(8); // 0+1+3+4 + }); + it("handles switch statements", () => { const { $g } = run(` function getDay(%n) { @@ -222,6 +345,50 @@ describe("TorqueScript Runtime", () => { expect($g.get("dayX")).toBe("Unknown"); }); + it("handles switch with 'or' for multiple cases", () => { + const { $g } = run(` + function isWeekend(%day) { + switch (%day) { + case 6 or 7: + return true; + default: + return false; + } + } + $sat = isWeekend(6); + $sun = isWeekend(7); + $mon = isWeekend(1); + `); + expect($g.get("sat")).toBe(true); + expect($g.get("sun")).toBe(true); + expect($g.get("mon")).toBe(false); + }); + + it("handles switch$ (case-insensitive string switch)", () => { + // Note: switch$ uses arrow functions internally, so return statements + // don't work directly. Use variable assignment instead. + const { $g } = run(` + function getColor(%name) { + %result = ""; + switch$ (%name) { + case "red": + %result = "#FF0000"; + case "GREEN": + %result = "#00FF00"; + default: + %result = "#000000"; + } + return %result; + } + $red = getColor("RED"); + $green = getColor("green"); + $other = getColor("blue"); + `); + expect($g.get("red")).toBe("#FF0000"); + expect($g.get("green")).toBe("#00FF00"); + expect($g.get("other")).toBe("#000000"); + }); + it("handles ternary operator", () => { const { $g } = run(` function check(%x) { @@ -275,6 +442,112 @@ describe("TorqueScript Runtime", () => { `); expect($g.get("result")).toBe(42); }); + + it("sets properties on objects referenced by name (bareword)", () => { + // This is the pattern used in mission files: Game.cdtrack = 2 + const { $ } = run(` + new ScriptObject(Game) { + class = "DefaultGame"; + }; + Game.cdtrack = 2; + Game.musicTrack = "lush"; + `); + const game = $.deref("Game"); + expect(game).toBeDefined(); + expect($.prop(game, "cdtrack")).toBe(2); + expect($.prop(game, "musicTrack")).toBe("lush"); + }); + + it("gets properties from objects referenced by name (bareword)", () => { + const { $g } = run(` + new ScriptObject(Config) { + maxPlayers = 32; + serverName = "Test Server"; + }; + $players = Config.maxPlayers; + $name = Config.serverName; + `); + expect($g.get("players")).toBe(32); + expect($g.get("name")).toBe("Test Server"); + }); + + it("handles property access on non-existent objects gracefully", () => { + const { $g } = run(` + $value = NonExistent.property; + NonExistent.property = 42; + `); + // Should return empty string for non-existent object + expect($g.get("value")).toBe(""); + }); + + it("resolves objects by numeric ID", () => { + const { $, $g } = run(` + new ScriptObject(TestObj) { + value = 100; + }; + `); + const obj = $.deref("TestObj"); + const id = obj._id; + + // Access via ID should work the same as by name + expect($.prop(id, "value")).toBe(100); + $.setProp(id, "modified", true); + expect($.prop(obj, "modified")).toBe(true); + }); + + it("handles direct indexed access on bareword objects", () => { + // In TorqueScript, obj[idx] sets a property directly on the object + const { $, $g } = run(` + new ScriptObject(Data) {}; + Data[0] = "first"; + Data[1] = "second"; + $first = Data[0]; + $second = Data[1]; + `); + expect($g.get("first")).toBe("first"); + expect($g.get("second")).toBe("second"); + }); + + it("handles obj.prop[idx] syntax (combined field name)", () => { + // In TorqueScript, obj.prop[idx] creates a field named "prop{idx}" + // e.g., Data.items[0] creates field "items0" on Data + const { $, $g } = run(` + new ScriptObject(Data) {}; + Data.items[0] = "first"; + Data.items[1] = "second"; + $first = Data.items[0]; + $second = Data.items[1]; + `); + const data = $.deref("Data"); + // Verify the fields are named items0, items1 (not nested) + expect($.prop(data, "items0")).toBe("first"); + expect($.prop(data, "items1")).toBe("second"); + expect($g.get("first")).toBe("first"); + expect($g.get("second")).toBe("second"); + }); + + it("handles post-increment on bareword object properties", () => { + const { $, $g } = run(` + new ScriptObject(Counter) { + value = 10; + }; + $before = Counter.value++; + $after = Counter.value; + `); + expect($g.get("before")).toBe(10); + expect($g.get("after")).toBe(11); + }); + + it("handles compound assignment on bareword object properties", () => { + const { $, $g } = run(` + new ScriptObject(Score) { + points = 100; + }; + Score.points += 50; + $result = Score.points; + `); + expect($g.get("result")).toBe(150); + }); }); describe("datablocks", () => { @@ -363,9 +636,9 @@ describe("TorqueScript Runtime", () => { expect($g.get("result")).toBe(10); }); - it("handles division by zero (returns 0)", () => { + it("handles division by zero (returns Infinity)", () => { const { $g } = run(`$result = 10 / 0;`); - expect($g.get("result")).toBe(0); + expect($g.get("result")).toBe(Infinity); }); it("handles unary negation on strings", () => { @@ -415,7 +688,7 @@ describe("TorqueScript Runtime", () => { }); describe("packages", () => { - it("overrides functions when package is defined", () => { + it("overrides functions when package is activated", () => { const { $g } = run(` function getMessage() { return "original"; @@ -428,9 +701,12 @@ describe("TorqueScript Runtime", () => { } }; + $stillOriginal = getMessage(); + activatePackage(Override); $after = getMessage(); `); expect($g.get("before")).toBe("original"); + expect($g.get("stillOriginal")).toBe("original"); // not yet activated expect($g.get("after")).toBe("overridden"); }); @@ -446,10 +722,140 @@ describe("TorqueScript Runtime", () => { } }; + activatePackage(Extended); $result = getValue(); `); expect($g.get("result")).toBe("extended(base)"); }); + + it("handles nested Parent:: calls with multiple packages", () => { + // This tests that Parent:: correctly calls the parent in the stack, + // not just stack[length-2] which would cause infinite recursion + const { $g } = run(` + function getValue() { + return "base"; + } + + package Pkg1 { + function getValue() { + return "pkg1(" @ Parent::getValue() @ ")"; + } + }; + + package Pkg2 { + function getValue() { + return "pkg2(" @ Parent::getValue() @ ")"; + } + }; + + activatePackage(Pkg1); + activatePackage(Pkg2); + $result = getValue(); + `); + expect($g.get("result")).toBe("pkg2(pkg1(base))"); + }); + + it("handles nested Parent:: calls with three packages", () => { + const { $g } = run(` + function getValue() { + return "base"; + } + + package Pkg1 { + function getValue() { + return "p1(" @ Parent::getValue() @ ")"; + } + }; + + package Pkg2 { + function getValue() { + return "p2(" @ Parent::getValue() @ ")"; + } + }; + + package Pkg3 { + function getValue() { + return "p3(" @ Parent::getValue() @ ")"; + } + }; + + activatePackage(Pkg1); + activatePackage(Pkg2); + activatePackage(Pkg3); + $result = getValue(); + `); + expect($g.get("result")).toBe("p3(p2(p1(base)))"); + }); + + it("handles nested Parent:: calls for methods", () => { + const { $g } = run(` + function TestClass::getValue(%this) { + return "base"; + } + + package Pkg1 { + function TestClass::getValue(%this) { + return "pkg1(" @ Parent::getValue(%this) @ ")"; + } + }; + + package Pkg2 { + function TestClass::getValue(%this) { + return "pkg2(" @ Parent::getValue(%this) @ ")"; + } + }; + + activatePackage(Pkg1); + activatePackage(Pkg2); + + %obj = new ScriptObject(TestObj) { class = "TestClass"; }; + $result = %obj.getValue(); + `); + expect($g.get("result")).toBe("pkg2(pkg1(base))"); + }); + + it("supports deferred activation (activatePackage before package is defined)", () => { + // This matches Torque engine behavior where activatePackage can be + // called before the package block is executed (common in mission scripts) + const { $g } = run(` + function getMessage() { + return "original"; + } + + // Activate package BEFORE it's defined + activatePackage(DeferredPkg); + + // At this point, package doesn't exist yet, but activation is remembered + $beforeDefine = getMessage(); + + // Now define the package - should auto-activate because we called + // activatePackage earlier + package DeferredPkg { + function getMessage() { + return "deferred override"; + } + }; + + $afterDefine = getMessage(); + `); + expect($g.get("beforeDefine")).toBe("original"); + expect($g.get("afterDefine")).toBe("deferred override"); + }); + + it("deferred activation is case-insensitive", () => { + const { $g } = run(` + function test() { return "base"; } + + activatePackage(MYPACKAGE); + + package myPackage { + function test() { return "override"; } + }; + + $result = test(); + `); + expect($g.get("result")).toBe("override"); + }); }); describe("namespace method calls", () => { @@ -490,6 +896,88 @@ describe("TorqueScript Runtime", () => { }); }); + describe("object path resolution (nameToID)", () => { + it("resolves simple object names", () => { + const { $, $g } = run(` + new SimGroup(MissionGroup) {}; + $id = nameToID("MissionGroup"); + `); + const obj = $.deref("MissionGroup"); + expect($g.get("id")).toBe(obj._id); + }); + + it("returns -1 for non-existent objects", () => { + const { $g } = run(` + $id = nameToID("NonExistent"); + `); + expect($g.get("id")).toBe(-1); + }); + + it("resolves path with children using /", () => { + const { $, $g } = run(` + new SimGroup(MissionGroup) { + new SimGroup(Teams) { + new SimGroup(team0) {}; + new SimGroup(team1) {}; + }; + }; + $team0Id = nameToID("MissionGroup/Teams/team0"); + $team1Id = nameToID("MissionGroup/Teams/team1"); + $teamsId = nameToID("MissionGroup/Teams"); + `); + const team0 = $.deref("team0"); + const team1 = $.deref("team1"); + const teams = $.deref("Teams"); + expect($g.get("team0Id")).toBe(team0._id); + expect($g.get("team1Id")).toBe(team1._id); + expect($g.get("teamsId")).toBe(teams._id); + }); + + it("returns -1 when path segment not found", () => { + const { $g } = run(` + new SimGroup(MissionGroup) { + new SimGroup(Teams) {}; + }; + $id = nameToID("MissionGroup/Teams/team0"); + `); + expect($g.get("id")).toBe(-1); + }); + + it("resolves paths with leading slash", () => { + const { $, $g } = run(` + new SimGroup(MissionGroup) { + new SimGroup(Teams) {}; + }; + $id = nameToID("/MissionGroup/Teams"); + `); + const teams = $.deref("Teams"); + expect($g.get("id")).toBe(teams._id); + }); + + it("resolves numeric object IDs", () => { + const { $ } = run(` + new SimGroup(MissionGroup) {}; + `); + const obj = $.deref("MissionGroup"); + // Test path resolution with numeric ID + const found = $.deref(String(obj._id)); + expect(found).toBe(obj); + }); + + it("is case-insensitive for path segments", () => { + const { $, $g } = run(` + new SimGroup(MissionGroup) { + new SimGroup(Teams) { + new SimGroup(team0) {}; + }; + }; + $id = nameToID("missiongroup/TEAMS/Team0"); + `); + const team0 = $.deref("team0"); + expect($g.get("id")).toBe(team0._id); + }); + }); + describe("built-in functions", () => { it("strlen returns string length", () => { const { $g } = run(` @@ -598,6 +1086,36 @@ describe("TorqueScript Runtime", () => { expect(result).toBeGreaterThanOrEqual(1); expect(result).toBeLessThanOrEqual(10); }); + + it("isActivePackage checks if package is active", () => { + const { $g } = run(` + $beforeDefine = isActivePackage(TestPkg); + + package TestPkg { + function dummy() { return 1; } + }; + + $afterDefine = isActivePackage(TestPkg); + activatePackage(TestPkg); + $afterActivate = isActivePackage(TestPkg); + `); + expect($g.get("beforeDefine")).toBe(false); + expect($g.get("afterDefine")).toBe(false); // defined but not active + expect($g.get("afterActivate")).toBe(true); + }); + + it("getPackageList returns active packages", () => { + const { $g } = run(` + package Pkg1 { function f1() {} }; + package Pkg2 { function f2() {} }; + $empty = getPackageList(); + activatePackage(Pkg1); + activatePackage(Pkg2); + $list = getPackageList(); + `); + expect($g.get("empty")).toBe(""); + expect($g.get("list")).toBe("Pkg1 Pkg2"); + }); }); describe("vector math", () => { @@ -728,7 +1246,7 @@ describe("TorqueScript Runtime", () => { expect($.prop(rifle, "range")).toBe(100); // inherited }); - it("$.package activates and overrides functions", () => { + it("$.package defines and $.activatePackage activates", () => { const { $, $f } = createRuntime(); $.registerFunction("getMessage", () => "original"); expect($f.call("getMessage")).toBe("original"); @@ -736,6 +1254,9 @@ describe("TorqueScript Runtime", () => { $.package("TestPackage", () => { $.registerFunction("getMessage", () => "overridden"); }); + expect($f.call("getMessage")).toBe("original"); // not yet activated + + $.activatePackage("TestPackage"); expect($f.call("getMessage")).toBe("overridden"); }); @@ -818,24 +1339,6 @@ describe("TorqueScript Runtime", () => { expect($g.get("doubled")).toBe(20); }); - it("supports parent:: in package overrides", () => { - const { $g } = run(` - function doSomething() { - return 10; - } - - package MyOverride { - function doSomething() { - return Parent::doSomething() + 5; - } - }; - - $result = doSomething(); - `); - - expect($g.get("result")).toBe(15); - }); - it("generates parent calls in transpiled code", () => { const { code } = transpile(` function MyGame::onEnd(%game) { @@ -1228,4 +1731,465 @@ describe("TorqueScript Runtime", () => { expect(runtime.$g.get("Value")).toBe(1); }); }); + + describe("ScriptObject superClass inheritance", () => { + it("finds methods on superClass when not defined on class", () => { + const { $g } = run(` + function DefaultGame::getMessage(%this) { + return "default message"; + } + + // Create a ScriptObject with class and superClass + %game = new ScriptObject() { + class = "CTFGame"; + superClass = "DefaultGame"; + }; + + // CTFGame doesn't define getMessage, so it should find DefaultGame::getMessage + $result = %game.getMessage(); + `); + expect($g.get("result")).toBe("default message"); + }); + + it("prefers method on class over superClass", () => { + const { $g } = run(` + function DefaultGame::getMessage(%this) { + return "default message"; + } + + function CTFGame::getMessage(%this) { + return "CTF message"; + } + + %game = new ScriptObject() { + class = "CTFGame"; + superClass = "DefaultGame"; + }; + + $result = %game.getMessage(); + `); + expect($g.get("result")).toBe("CTF message"); + }); + + it("class method can call superClass method via namespace", () => { + const { $g } = run(` + function DefaultGame::getValue(%this) { + return 10; + } + + function CTFGame::getValue(%this) { + %base = DefaultGame::getValue(%this); + return %base + 5; + } + + %game = new ScriptObject() { + class = "CTFGame"; + superClass = "DefaultGame"; + }; + + $result = %game.getValue(); + `); + expect($g.get("result")).toBe(15); + }); + + it("works with ScriptGroup as well", () => { + const { $g } = run(` + function BaseGroup::getType(%this) { + return "base"; + } + + %group = new ScriptGroup() { + class = "MyGroup"; + superClass = "BaseGroup"; + }; + + $result = %group.getType(); + `); + expect($g.get("result")).toBe("base"); + }); + + it("handles missing superClass method gracefully", () => { + const { $g } = run(` + %game = new ScriptObject() { + class = "CTFGame"; + superClass = "DefaultGame"; + }; + + // Neither CTFGame nor DefaultGame define this method + $result = %game.undefinedMethod(); + `); + expect($g.get("result")).toBe(""); + }); + + it("superClass is case-insensitive", () => { + const { $g } = run(` + function defaultgame::getMessage(%this) { + return "found it"; + } + + %game = new ScriptObject() { + class = "CTFGame"; + superClass = "DefaultGame"; // Different case + }; + + $result = %game.getMessage(); + `); + expect($g.get("result")).toBe("found it"); + }); + + it("registers namespace parent link for other objects", () => { + // When one object establishes a class->superClass link, + // other objects with the same class should benefit from it + const { $g } = run(` + function DefaultGame::getInfo(%this) { + return "info"; + } + + // First object establishes the CTFGame -> DefaultGame link + %game1 = new ScriptObject() { + class = "CTFGame"; + superClass = "DefaultGame"; + }; + + // Second object with same class (no explicit superClass) + // should still walk the namespace chain + %game2 = new ScriptObject() { + class = "CTFGame"; + }; + + $result1 = %game1.getInfo(); + $result2 = %game2.getInfo(); + `); + expect($g.get("result1")).toBe("info"); + expect($g.get("result2")).toBe("info"); + }); + }); + + describe("method hooks (onMethodCalled)", () => { + it("fires hook after method is called via object", () => { + const runtime = createRuntime(); + const hookCalls: Array<{ thisObj: any; args: any[] }> = []; + + // Register hook before method is defined + runtime.$.onMethodCalled( + "TestClass", + "doSomething", + (thisObj, ...args) => { + hookCalls.push({ thisObj, args }); + }, + ); + + // Define method and create object via transpiled code + const { code } = transpile(` + function TestClass::doSomething(%this, %a, %b) { + return %a + %b; + } + + %obj = new ScriptObject() { + class = "TestClass"; + }; + + $result = %obj.doSomething(10, 20); + `); + + const $l = runtime.$.locals(); + new Function("$", "$f", "$g", "$l", code)( + runtime.$, + runtime.$f, + runtime.$g, + $l, + ); + + expect(runtime.$g.get("result")).toBe(30); + expect(hookCalls.length).toBe(1); + expect(hookCalls[0].args).toEqual([10, 20]); + }); + + it("fires hook after namespace call (e.g., DefaultGame::method)", () => { + const runtime = createRuntime(); + const hookCalls: Array<{ thisObj: any; args: any[] }> = []; + + runtime.$.onMethodCalled( + "DefaultGame", + "missionLoadDone", + (thisObj, ...args) => { + hookCalls.push({ thisObj, args }); + }, + ); + + const { code } = transpile(` + function DefaultGame::missionLoadDone(%game) { + $defaultCalled = true; + } + + function CTFGame::missionLoadDone(%game) { + // Call parent via namespace + DefaultGame::missionLoadDone(%game); + $ctfCalled = true; + } + + %game = new ScriptObject(Game) { + class = "CTFGame"; + superClass = "DefaultGame"; + }; + + %game.missionLoadDone(); + `); + + const $l = runtime.$.locals(); + new Function("$", "$f", "$g", "$l", code)( + runtime.$, + runtime.$f, + runtime.$g, + $l, + ); + + expect(runtime.$g.get("defaultCalled")).toBe(true); + expect(runtime.$g.get("ctfCalled")).toBe(true); + // Hook should fire when DefaultGame::missionLoadDone is called + expect(hookCalls.length).toBe(1); + }); + + it("multiple hooks on same method all fire", () => { + const runtime = createRuntime(); + const calls: string[] = []; + + runtime.$.onMethodCalled("TestClass", "test", () => { + calls.push("hook1"); + }); + runtime.$.onMethodCalled("TestClass", "test", () => { + calls.push("hook2"); + }); + + const { code } = transpile(` + function TestClass::test(%this) { + return "done"; + } + + %obj = new ScriptObject() { + class = "TestClass"; + }; + + %obj.test(); + `); + + const $l = runtime.$.locals(); + new Function("$", "$f", "$g", "$l", code)( + runtime.$, + runtime.$f, + runtime.$g, + $l, + ); + + expect(calls).toEqual(["hook1", "hook2"]); + }); + + it("hook receives correct thisObj", () => { + const runtime = createRuntime(); + let capturedObj: any = null; + + runtime.$.onMethodCalled("TestClass", "identify", (thisObj) => { + capturedObj = thisObj; + }); + + const { code } = transpile(` + function TestClass::identify(%this) { + return %this.name; + } + + %obj = new ScriptObject(MyObject) { + class = "TestClass"; + name = "test-object"; + }; + + $result = %obj.identify(); + `); + + const $l = runtime.$.locals(); + new Function("$", "$f", "$g", "$l", code)( + runtime.$, + runtime.$f, + runtime.$g, + $l, + ); + + expect(runtime.$g.get("result")).toBe("test-object"); + expect(capturedObj).not.toBeNull(); + expect(capturedObj._name).toBe("MyObject"); + expect(capturedObj.name).toBe("test-object"); + }); + + it("hook is case-insensitive for class and method names", () => { + const runtime = createRuntime(); + let hookFired = false; + + // Register with different case + runtime.$.onMethodCalled("testclass", "DOACTION", () => { + hookFired = true; + }); + + const { code } = transpile(` + function TestClass::doAction(%this) { + return true; + } + + %obj = new ScriptObject() { + class = "TESTCLASS"; + }; + + %obj.DoAction(); + `); + + const $l = runtime.$.locals(); + new Function("$", "$f", "$g", "$l", code)( + runtime.$, + runtime.$f, + runtime.$g, + $l, + ); + + expect(hookFired).toBe(true); + }); + + it("hook fires for superClass method when called on subclass", () => { + const runtime = createRuntime(); + const hookCalls: string[] = []; + + runtime.$.onMethodCalled("DefaultGame", "init", () => { + hookCalls.push("DefaultGame::init"); + }); + + const { code } = transpile(` + function DefaultGame::init(%this) { + $initialized = true; + } + + // CTFGame doesn't override init, so DefaultGame::init will be called + %game = new ScriptObject() { + class = "CTFGame"; + superClass = "DefaultGame"; + }; + + %game.init(); + `); + + const $l = runtime.$.locals(); + new Function("$", "$f", "$g", "$l", code)( + runtime.$, + runtime.$f, + runtime.$g, + $l, + ); + + expect(runtime.$g.get("initialized")).toBe(true); + expect(hookCalls).toEqual(["DefaultGame::init"]); + }); + }); + + describe("isFunction", () => { + it("returns true for user-defined functions", () => { + const { $ } = run(` + function myCustomFunction() { + return "test"; + } + `); + expect($.isFunction("myCustomFunction")).toBe(true); + expect($.isFunction("MYCUSTOMFUNCTION")).toBe(true); // case-insensitive + }); + + it("returns true for builtin functions", () => { + const { $ } = run(``); + expect($.isFunction("echo")).toBe(true); + expect($.isFunction("Echo")).toBe(true); // case-insensitive + expect($.isFunction("strlen")).toBe(true); + expect($.isFunction("getWord")).toBe(true); + }); + + it("returns false for non-existent functions", () => { + const { $ } = run(``); + expect($.isFunction("nonExistentFunction")).toBe(false); + }); + }); + + describe("getWord delimiter handling", () => { + it("does not collapse consecutive delimiters", () => { + const { $g } = run(` + // With two spaces between a and b, getWord(1) should return empty + $str = "a b"; + $word0 = getWord($str, 0); // "a" + $word1 = getWord($str, 1); // "" (empty - between two spaces) + $word2 = getWord($str, 2); // "b" + `); + expect($g.get("word0")).toBe("a"); + expect($g.get("word1")).toBe(""); // Engine behavior: empty word between consecutive delimiters + expect($g.get("word2")).toBe("b"); + }); + + it("handles multiple consecutive delimiters", () => { + const { $g } = run(` + $str = "a b"; // Three spaces + $word0 = getWord($str, 0); // "a" + $word1 = getWord($str, 1); // "" + $word2 = getWord($str, 2); // "" + $word3 = getWord($str, 3); // "b" + `); + expect($g.get("word0")).toBe("a"); + expect($g.get("word1")).toBe(""); + expect($g.get("word2")).toBe(""); + expect($g.get("word3")).toBe("b"); + }); + + it("correctly counts words with consecutive delimiters", () => { + const { $g } = run(` + $count1 = getWordCount("a b"); // 2 words + $count2 = getWordCount("a b"); // 3 words (includes empty) + $count3 = getWordCount("a b"); // 4 words (includes two empty) + `); + expect($g.get("count1")).toBe(2); + expect($g.get("count2")).toBe(3); + expect($g.get("count3")).toBe(4); + }); + }); + + describe("nsRef with execution context", () => { + it("tracks execution context for Parent:: calls via nsRef", () => { + const runtime = createRuntime(); + const { code } = transpile(` + function TestClass::getValue(%this) { + return "base"; + } + + package Pkg1 { + function TestClass::getValue(%this) { + return "pkg1(" @ Parent::getValue(%this) @ ")"; + } + }; + + package Pkg2 { + function TestClass::getValue(%this) { + return "pkg2(" @ Parent::getValue(%this) @ ")"; + } + }; + + activatePackage(Pkg1); + activatePackage(Pkg2); + `); + + const $l = runtime.$.locals(); + new Function("$", "$f", "$g", "$l", code)( + runtime.$, + runtime.$f, + runtime.$g, + $l, + ); + + // Get method reference via nsRef and call it directly + const fn = runtime.$.nsRef("TestClass", "getValue"); + expect(fn).not.toBeNull(); + + // Call the method - it should track execution context for proper Parent:: support + const result = fn!("dummyThis"); + expect(result).toBe("pkg2(pkg1(base))"); + }); + }); }); diff --git a/src/torqueScript/runtime.ts b/src/torqueScript/runtime.ts index 09a392f2..395f91c6 100644 --- a/src/torqueScript/runtime.ts +++ b/src/torqueScript/runtime.ts @@ -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(), + generatedCode: new WeakMap(), + }; +} + function normalize(name: string): string { return name.toLowerCase(); } @@ -49,6 +62,8 @@ export function createRuntime( const functions = new CaseInsensitiveMap(); const packages = new CaseInsensitiveMap(); 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(); const datablocks = new CaseInsensitiveMap(); const globals = new CaseInsensitiveMap(); + const methodHooks = new CaseInsensitiveMap< + CaseInsensitiveMap void>> + >(); + // Namespace inheritance: className -> superClassName (for ScriptObject/ScriptGroup) + const namespaceParents = new CaseInsensitiveMap(); + + // 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(); - const scripts = new Map(); + const failedScripts = new Set(); + // 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(); + + 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(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>(); 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, ): 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(); - 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, + includePreload: boolean = false, ): Promise { 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 { - // Load dependencies + // Load dependencies (include preload scripts on initial load) const loading = new Set(); 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; } diff --git a/src/torqueScript/types.ts b/src/torqueScript/types.ts index 3508b41c..0c802251 100644 --- a/src/torqueScript/types.ts +++ b/src/torqueScript/types.ts @@ -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; globals: CaseInsensitiveMap; executedScripts: Set; + failedScripts: Set; scripts: Map; generatedCode: WeakMap; pendingTimeouts: Set>; @@ -54,23 +56,71 @@ export interface TorqueRuntime { options?: LoadScriptOptions, ): Promise; loadFromAST(ast: Program, options?: LoadScriptOptions): Promise; + /** 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; +/** + * 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; + /** + * 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; + /** Generated JavaScript code by AST */ + generatedCode: WeakMap; +} + 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 = ( diff --git a/src/torqueScript/utils.ts b/src/torqueScript/utils.ts index 344c7a7a..e314b650 100644 --- a/src/torqueScript/utils.ts +++ b/src/torqueScript/utils.ts @@ -89,6 +89,50 @@ export class CaseInsensitiveMap { } } +/** + * Set with case-insensitive membership checks. + */ +export class CaseInsensitiveSet { + private set = new Set(); + + constructor(values?: Iterable | 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 { + return this.set[Symbol.iterator](); + } + + get [Symbol.toStringTag](): string { + return "CaseInsensitiveSet"; + } +} + export function normalizePath(path: string): string { return path.replace(/\\/g, "/").toLowerCase(); }