t2-mapper/src/torqueScript/runtime.ts

1202 lines
33 KiB
TypeScript
Raw Normal View History

import { generate } from "./codegen";
import { parse, type Program } from "./index";
import { createBuiltins as defaultCreateBuiltins } from "./builtins";
import { CaseInsensitiveMap, CaseInsensitiveSet, normalizePath } from "./utils";
import type {
BuiltinsContext,
FunctionStack,
FunctionsAPI,
GlobalsAPI,
LoadedScript,
LoadScriptOptions,
LocalsAPI,
MethodStack,
PackageState,
RuntimeAPI,
RuntimeState,
ScriptCache,
TorqueFunction,
TorqueMethod,
TorqueObject,
TorqueRuntime,
TorqueRuntimeOptions,
VariableStoreAPI,
} from "./types";
/**
* Create a script cache that can be shared across runtime instances.
* This allows parsed ASTs and generated code to be reused when switching
* missions or restarting the runtime.
*/
export function createScriptCache(): ScriptCache {
return {
scripts: new Map<string, Program>(),
generatedCode: new WeakMap<Program, string>(),
};
}
function normalize(name: string): string {
return name.toLowerCase();
}
function toU32(value: any): number {
return (Number(value) | 0) >>> 0;
}
function toI32(value: any): number {
return Number(value) | 0;
}
/** Coerce instance name to string, returning null for empty/null values. */
function toName(value: any): string | null {
if (value == null) return null;
if (typeof value === "string") return value || null;
if (typeof value === "number") return String(value);
throw new Error(`Invalid instance name type: ${typeof value}`);
}
export function createRuntime(
options: TorqueRuntimeOptions = {},
): TorqueRuntime {
const methods = new CaseInsensitiveMap<CaseInsensitiveMap<MethodStack>>();
const functions = new CaseInsensitiveMap<FunctionStack>();
const packages = new CaseInsensitiveMap<PackageState>();
const activePackages: string[] = [];
// Track package names that were activated before being defined (deferred activation)
const pendingActivations = new CaseInsensitiveSet();
const FIRST_DATABLOCK_ID = 3;
const FIRST_DYNAMIC_ID = 1027;
let nextDatablockId = FIRST_DATABLOCK_ID;
let nextObjectId = FIRST_DYNAMIC_ID;
const objectsById = new Map<number, TorqueObject>();
const objectsByName = new CaseInsensitiveMap<TorqueObject>();
const datablocks = new CaseInsensitiveMap<TorqueObject>();
const globals = new CaseInsensitiveMap<any>();
const methodHooks = new CaseInsensitiveMap<
CaseInsensitiveMap<Array<(thisObj: TorqueObject, ...args: any[]) => void>>
>();
// Namespace inheritance: className -> superClassName (for ScriptObject/ScriptGroup)
const namespaceParents = new CaseInsensitiveMap<string>();
// Populate initial globals from options
if (options.globals) {
for (const [key, value] of Object.entries(options.globals)) {
if (!key.startsWith("$")) {
throw new Error(
`Global variable "${key}" must start with $, e.g. "$${key}"`,
);
}
globals.set(key.slice(1), value);
}
}
const executedScripts = new Set<string>();
const failedScripts = new Set<string>();
// Use cache if provided, otherwise create new maps
const cache = options.cache ?? createScriptCache();
const scripts = cache.scripts;
const generatedCode = cache.generatedCode;
// Execution context: tracks which stack index is currently executing for each function/method
// This is needed for Parent:: to correctly call the parent in the stack, not just stack[length-2]
// Key format: "funcname" for functions, "classname::methodname" for methods
const executionContext = new Map<string, number[]>();
function pushExecutionContext(key: string, stackIndex: number): void {
let stack = executionContext.get(key);
if (!stack) {
stack = [];
executionContext.set(key, stack);
}
stack.push(stackIndex);
}
function popExecutionContext(key: string): void {
const stack = executionContext.get(key);
if (stack) {
stack.pop();
}
}
function getCurrentExecutionIndex(key: string): number | undefined {
const stack = executionContext.get(key);
return stack && stack.length > 0 ? stack[stack.length - 1] : undefined;
}
/** Execute a function with execution context tracking for proper Parent:: support */
function withExecutionContext<T>(key: string, index: number, fn: () => T): T {
pushExecutionContext(key, index);
try {
return fn();
} finally {
popExecutionContext(key);
}
}
/** Build the execution context key for a method */
function methodContextKey(className: string, methodName: string): string {
return `${className.toLowerCase()}::${methodName.toLowerCase()}`;
}
/** Get the method stack for a class/method pair, or null if not found */
function getMethodStack(
className: string,
methodName: string,
): MethodStack | null {
return methods.get(className)?.get(methodName) ?? null;
}
const pendingTimeouts = new Set<ReturnType<typeof setTimeout>>();
let currentPackage: PackageState | null = null;
let runtimeRef: TorqueRuntime | null = null;
const getRuntime = () => runtimeRef!;
const createBuiltins = options.builtins ?? defaultCreateBuiltins;
const builtinsCtx: BuiltinsContext = {
runtime: getRuntime,
fileSystem: options.fileSystem ?? null,
};
const builtins = createBuiltins(builtinsCtx);
function registerMethod(
className: string,
methodName: string,
fn: TorqueMethod,
): void {
if (currentPackage) {
if (!currentPackage.methods.has(className)) {
currentPackage.methods.set(className, new CaseInsensitiveMap());
}
currentPackage.methods.get(className)!.set(methodName, fn);
} else {
if (!methods.has(className)) {
methods.set(className, new CaseInsensitiveMap());
}
const classMethods = methods.get(className)!;
if (!classMethods.has(methodName)) {
classMethods.set(methodName, []);
}
classMethods.get(methodName)!.push(fn);
}
}
function registerFunction(name: string, fn: TorqueFunction): void {
if (currentPackage) {
currentPackage.functions.set(name, fn);
} else {
if (!functions.has(name)) {
functions.set(name, []);
}
functions.get(name)!.push(fn);
}
}
function activatePackage(name: string): void {
const pkg = packages.get(name);
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);
for (const [className, methodMap] of pkg.methods) {
if (!methods.has(className)) {
methods.set(className, new CaseInsensitiveMap());
}
const classMethods = methods.get(className)!;
for (const [methodName, fn] of methodMap) {
if (!classMethods.has(methodName)) {
classMethods.set(methodName, []);
}
classMethods.get(methodName)!.push(fn);
}
}
for (const [funcName, fn] of pkg.functions) {
if (!functions.has(funcName)) {
functions.set(funcName, []);
}
functions.get(funcName)!.push(fn);
}
}
function deactivatePackage(name: string): void {
const pkg = packages.get(name);
if (!pkg || !pkg.active) return;
pkg.active = false;
// Find and remove from activePackages (case-insensitive search)
const idx = activePackages.findIndex(
(n) => n.toLowerCase() === name.toLowerCase(),
);
if (idx !== -1) activePackages.splice(idx, 1);
// Remove the specific functions this package added (not just pop!)
for (const [className, methodMap] of pkg.methods) {
const classMethods = methods.get(className);
if (!classMethods) continue;
for (const [methodName, fn] of methodMap) {
const stack = classMethods.get(methodName);
if (stack) {
const fnIdx = stack.indexOf(fn);
if (fnIdx !== -1) stack.splice(fnIdx, 1);
}
}
}
for (const [funcName, fn] of pkg.functions) {
const stack = functions.get(funcName);
if (stack) {
const fnIdx = stack.indexOf(fn);
if (fnIdx !== -1) stack.splice(fnIdx, 1);
}
}
}
function packageFn(name: string, fn: () => void): void {
let pkg = packages.get(name);
if (!pkg) {
pkg = {
name,
active: false,
methods: new CaseInsensitiveMap(),
functions: new CaseInsensitiveMap(),
};
packages.set(name, pkg);
}
const prevPackage = currentPackage;
currentPackage = pkg;
fn();
currentPackage = prevPackage;
// 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(
className: string,
instanceName: string | null,
props: Record<string, any>,
children?: TorqueObject[],
): TorqueObject {
const normClass = normalize(className);
const id = nextObjectId++;
const obj: TorqueObject = {
_class: normClass,
_className: className,
_id: id,
};
for (const [key, value] of Object.entries(props)) {
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);
if (name) {
obj._name = name;
objectsByName.set(name, obj);
}
if (children) {
for (const child of children) {
child._parent = obj;
}
obj._children = children;
}
const onAdd = findMethod(className, "onAdd");
if (onAdd) {
onAdd(obj);
}
return obj;
}
function deleteObject(obj: any): boolean {
if (obj == null) return false;
// Resolve object if given by ID or name
let target: TorqueObject | undefined;
if (typeof obj === "number") {
target = objectsById.get(obj);
} else if (typeof obj === "string") {
target = objectsByName.get(obj);
} else if (typeof obj === "object" && obj._id) {
target = obj;
}
if (!target) return false;
// Call onRemove if it exists
const onRemove = findMethod(target._className, "onRemove");
if (onRemove) {
onRemove(target);
}
// Remove from tracking maps
objectsById.delete(target._id);
if (target._name) {
objectsByName.delete(target._name);
}
if (target._isDatablock && target._name) {
datablocks.delete(target._name);
}
// Remove from parent's children array
if (target._parent && target._parent._children) {
const idx = target._parent._children.indexOf(target);
if (idx !== -1) {
target._parent._children.splice(idx, 1);
}
}
// Recursively delete children
if (target._children) {
for (const child of [...target._children]) {
deleteObject(child);
}
}
return true;
}
function datablock(
className: string,
instanceName: string | null,
parentName: string | null,
props: Record<string, any>,
): TorqueObject {
const normClass = normalize(className);
const id = nextDatablockId++;
const obj: TorqueObject = {
_class: normClass,
_className: className,
_id: id,
_isDatablock: true,
};
const parentKey = toName(parentName);
if (parentKey) {
const parentObj = datablocks.get(parentKey);
if (parentObj) {
for (const [key, value] of Object.entries(parentObj)) {
if (!key.startsWith("_")) {
obj[key] = value;
}
}
obj._parent = parentObj;
}
}
for (const [key, value] of Object.entries(props)) {
obj[normalize(key)] = value;
}
objectsById.set(id, obj);
const name = toName(instanceName);
if (name) {
obj._name = name;
objectsByName.set(name, obj);
datablocks.set(name, obj);
}
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 {
const resolved = resolveObject(obj);
if (resolved == null) return "";
return resolved[normalize(name)] ?? "";
}
function setProp(obj: any, name: string, value: any): any {
const resolved = resolveObject(obj);
if (resolved == null) return value;
resolved[normalize(name)] = value;
return value;
}
function getIndex(obj: any, index: any): any {
const resolved = resolveObject(obj);
if (resolved == null) return "";
return resolved[String(index)] ?? "";
}
function setIndex(obj: any, index: any, value: any): any {
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 {
const resolved = resolveObject(obj);
if (resolved == null) return 0;
const oldValue = toNum(resolved[key]);
resolved[key] = oldValue + delta;
return oldValue;
}
function propPostInc(obj: any, name: string): number {
return postIncDec(obj, normalize(name), 1);
}
function propPostDec(obj: any, name: string): number {
return postIncDec(obj, normalize(name), -1);
}
function indexPostInc(obj: any, index: any): number {
return postIncDec(obj, String(index), 1);
}
function indexPostDec(obj: any, index: any): number {
return postIncDec(obj, String(index), -1);
}
// TorqueScript array indexing: foo[0] -> foo0, foo[0,1] -> foo0_1
function key(base: string, ...indices: any[]): string {
return base + indices.join("_");
}
function findMethod(
className: string,
methodName: string,
): TorqueMethod | 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);
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);
}
}
}
}
function call(obj: any, methodName: string, ...args: any[]): any {
if (obj == null) return "";
// Dereference string/number names to actual objects
if (typeof obj === "string" || typeof obj === "number") {
obj = deref(obj);
if (obj == null) return "";
}
// For ScriptObject/ScriptGroup, the "class" property overrides the C++ class name
const objClass = obj.class || obj._className || obj._class;
if (objClass) {
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 callResult = callMethodWithContext(
parentClass,
methodName,
obj,
args,
);
if (callResult.found) {
fireMethodHooks(parentClass, methodName, obj, args);
return callResult.result;
}
}
current = current._parent;
}
}
return "";
}
function nsCall(namespace: string, method: string, ...args: any[]): any {
// 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 result;
}
function nsRef(
namespace: string,
method: string,
): ((...args: any[]) => any) | 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(
currentClass: string,
methodName: string,
thisObj: any,
...args: any[]
): any {
const stack = getMethodStack(currentClass, methodName);
if (!stack) return "";
const key = methodContextKey(currentClass, methodName);
const currentIndex = getCurrentExecutionIndex(key);
if (currentIndex === undefined || currentIndex < 1) return "";
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) return "";
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 {
if (value == null || value === "") return 0;
const n = Number(value);
return isNaN(n) ? 0 : n;
}
function add(a: any, b: any): number {
return toNum(a) + toNum(b);
}
function sub(a: any, b: any): number {
return toNum(a) - toNum(b);
}
function mul(a: any, b: any): number {
return toNum(a) * toNum(b);
}
function div(a: any, b: any): number {
return toNum(a) / toNum(b);
}
function neg(a: any): number {
return -toNum(a);
}
function lt(a: any, b: any): boolean {
return toNum(a) < toNum(b);
}
function le(a: any, b: any): boolean {
return toNum(a) <= toNum(b);
}
function gt(a: any, b: any): boolean {
return toNum(a) > toNum(b);
}
function ge(a: any, b: any): boolean {
return toNum(a) >= toNum(b);
}
function eq(a: any, b: any): boolean {
return toNum(a) === toNum(b);
}
function ne(a: any, b: any): boolean {
return toNum(a) !== toNum(b);
}
function mod(a: any, b: any): number {
const ib = toI32(b);
if (ib === 0) return 0;
return toI32(a) % ib;
}
function bitand(a: any, b: any): number {
return toU32(a) & toU32(b);
}
function bitor(a: any, b: any): number {
return toU32(a) | toU32(b);
}
function bitxor(a: any, b: any): number {
return toU32(a) ^ toU32(b);
}
function shl(a: any, b: any): number {
return toU32(toU32(a) << (toU32(b) & 31));
}
function shr(a: any, b: any): number {
return toU32(a) >>> (toU32(b) & 31);
}
function bitnot(a: any): number {
return ~toU32(a) >>> 0;
}
function concat(...parts: any[]): string {
return parts.map((p) => String(p ?? "")).join("");
}
function streq(a: any, b: any): boolean {
return String(a ?? "").toLowerCase() === String(b ?? "").toLowerCase();
}
function switchStr(
value: any,
cases: Record<string, () => void> & { default?: () => void },
): void {
const normValue = String(value ?? "").toLowerCase();
for (const [caseValue, handler] of Object.entries(cases)) {
if (caseValue === "default") continue;
if (normalize(caseValue) === normValue) {
handler();
return;
}
}
if (cases.default) {
cases.default();
}
}
/**
* 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 findObjectByPath(String(tag));
}
function nameToId(name: string): number {
const obj = findObjectByPath(name);
// TorqueScript returns -1 when object not found, not 0
return obj ? obj._id : -1;
}
function isObject(obj: any): boolean {
if (obj == null) return false;
if (typeof obj === "object" && obj._id) return true;
if (typeof obj === "number") return objectsById.has(obj);
if (typeof obj === "string") return objectsByName.has(obj);
return false;
}
function isFunction(name: string): boolean {
// Check both user-defined functions and builtins
return functions.has(name) || name.toLowerCase() in builtins;
}
function isPackage(name: string): boolean {
return packages.has(name);
}
function isActivePackage(name: string): boolean {
const pkg = packages.get(name);
return pkg?.active ?? false;
}
function getPackageList(): string {
return activePackages.join(" ");
}
function createVariableStore(
storage: CaseInsensitiveMap<any>,
): VariableStoreAPI {
// TorqueScript array indexing: $foo[0] -> $foo0, $foo[0,1] -> $foo0_1
function fullName(name: string, indices: any[]): string {
return name + indices.join("_");
}
return {
get(name: string, ...indices: any[]): any {
return storage.get(fullName(name, indices)) ?? "";
},
set(name: string, ...args: any[]): any {
if (args.length === 0) {
throw new Error("set() requires at least a value argument");
}
if (args.length === 1) {
storage.set(name, args[0]);
return args[0];
}
const value = args[args.length - 1];
const indices = args.slice(0, -1);
storage.set(fullName(name, indices), value);
return value;
},
postInc(name: string, ...indices: any[]): number {
const key = fullName(name, indices);
const oldValue = toNum(storage.get(key));
storage.set(key, oldValue + 1);
return oldValue;
},
postDec(name: string, ...indices: any[]): number {
const key = fullName(name, indices);
const oldValue = toNum(storage.get(key));
storage.set(key, oldValue - 1);
return oldValue;
},
};
}
function createLocals(): LocalsAPI {
return createVariableStore(new CaseInsensitiveMap<any>());
}
const $: RuntimeAPI = {
registerMethod,
registerFunction,
package: packageFn,
activatePackage,
deactivatePackage,
create: createObject,
datablock,
deleteObject,
prop,
setProp,
getIndex,
setIndex,
propPostInc,
propPostDec,
indexPostInc,
indexPostDec,
key,
call,
nsCall,
nsRef,
parent,
parentFunc,
add,
sub,
mul,
div,
neg,
lt,
le,
gt,
ge,
eq,
ne,
mod,
bitand,
bitor,
bitxor,
shl,
shr,
bitnot,
concat,
streq,
switchStr,
deref,
nameToId,
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 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
const builtin = builtins[name.toLowerCase()];
if (builtin) {
return builtin(...args);
}
// 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 state: RuntimeState = {
methods,
functions,
packages,
activePackages,
objectsById,
objectsByName,
datablocks,
globals,
executedScripts,
failedScripts,
scripts,
generatedCode,
pendingTimeouts,
startTime: Date.now(),
};
function destroy(): void {
for (const timeoutId of state.pendingTimeouts) {
clearTimeout(timeoutId);
}
state.pendingTimeouts.clear();
}
function getOrGenerateCode(ast: Program): string {
let code = generatedCode.get(ast);
if (code == null) {
code = generate(ast);
generatedCode.set(ast, code);
}
return code;
}
function executeAST(ast: Program): void {
const code = getOrGenerateCode(ast);
2025-12-02 06:33:12 +00:00
// Provide $l (locals) at module scope for top-level local variable access
const $l = createLocals();
const execFn = new Function("$", "$f", "$g", "$l", code);
execFn($, $f, $g, $l);
}
function createLoadedScript(ast: Program, path?: string): LoadedScript {
return {
execute(): void {
if (path) {
const normalized = normalizePath(path);
state.executedScripts.add(normalized);
}
executeAST(ast);
},
};
}
async function loadDependencies(
ast: Program,
loading: Set<string>,
includePreload: boolean = false,
): Promise<void> {
const loader = options.loadScript;
if (!loader) {
// No loader, can't resolve dependencies
if (ast.execScriptPaths.length > 0) {
console.warn(
`Script has exec() calls but no loadScript provided:`,
ast.execScriptPaths,
);
}
return;
}
// 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, failed, or currently loading (cycle detection)
if (
state.scripts.has(normalized) ||
state.failedScripts.has(normalized) ||
loading.has(normalized)
) {
continue;
}
loading.add(normalized);
const source = await loader(ref);
if (source == null) {
console.warn(`Script not found: ${ref}`);
state.failedScripts.add(normalized);
loading.delete(normalized);
continue;
}
let depAst: Program;
try {
depAst = parse(source, { filename: ref });
} catch (err) {
console.warn(`Failed to parse script: ${ref}`, err);
state.failedScripts.add(normalized);
loading.delete(normalized);
continue;
}
// Recursively load this script's dependencies first
await loadDependencies(depAst, loading);
// Store the parsed AST
state.scripts.set(normalized, depAst);
loading.delete(normalized);
}
}
async function loadFromPath(path: string): Promise<LoadedScript> {
const loader = options.loadScript;
if (!loader) {
throw new Error("loadFromPath requires loadScript option to be set");
}
// Check if already loaded (avoid unnecessary fetch)
const normalized = normalizePath(path);
if (state.scripts.has(normalized)) {
return createLoadedScript(state.scripts.get(normalized)!, path);
}
const source = await loader(path);
if (source == null) {
throw new Error(`Script not found: ${path}`);
}
return loadFromSource(source, { path });
}
async function loadFromSource(
source: string,
loadOptions?: LoadScriptOptions,
): Promise<LoadedScript> {
// Check if already loaded
if (loadOptions?.path) {
const normalized = normalizePath(loadOptions.path);
if (state.scripts.has(normalized)) {
return createLoadedScript(
state.scripts.get(normalized)!,
loadOptions.path,
);
}
}
const ast = parse(source, { filename: loadOptions?.path });
return loadFromAST(ast, loadOptions);
}
async function loadFromAST(
ast: Program,
loadOptions?: LoadScriptOptions,
): Promise<LoadedScript> {
// Load dependencies (include preload scripts on initial load)
const loading = new Set<string>();
if (loadOptions?.path) {
const normalized = normalizePath(loadOptions.path);
loading.add(normalized);
state.scripts.set(normalized, ast);
}
await loadDependencies(ast, loading, true);
return createLoadedScript(ast, loadOptions?.path);
}
runtimeRef = {
$,
$f,
$g,
state,
destroy,
executeAST,
loadFromPath,
loadFromSource,
loadFromAST,
call: (name: string, ...args: any[]) => $f.call(name, ...args),
getObjectByName: (name: string) => objectsByName.get(name),
};
return runtimeRef;
}