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(), generatedCode: new WeakMap(), }; } 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>(); 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; let nextDatablockId = FIRST_DATABLOCK_ID; let nextObjectId = FIRST_DYNAMIC_ID; const objectsById = new Map(); 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 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, 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, 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, ): 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 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, ): 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()); } 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); // 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, includePreload: boolean = false, ): Promise { 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 { 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 { // 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 { // 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, 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; }