From e4ab2dba8d761e0f7d02d7bb8c0a0153200d90c6 Mon Sep 17 00:00:00 2001 From: Jeany Date: Mon, 19 Oct 2026 23:58:19 -0600 Subject: [PATCH 1/2] The action order() gives orders.Move, orders.Patrol and orders.AttackMove as move, patrol and attack orders.Move made an Order action of modifier 6, orders.dat's number, where the action knows 0, 1 and 2; any other of orders.* passed as a number too, orders.Die and orders.Stop taken for move and patrol. The three the action gives are now its own numbers, every other a type error (a raw number is a second signature: in one union with it, TypeScript took Order<0> for a number). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01T1CM9pLuTuAn3mgTEkjgVg --- compiler/api.ts | 11 +++++++++++ compiler/declarations.ts | 12 +++++++++++- compiler/runtime.ts | 4 ++-- ledger/game/order.ts | 8 ++++---- tests/events.test.ts | 12 ++++++++++++ 5 files changed, 40 insertions(+), 7 deletions(-) diff --git a/compiler/api.ts b/compiler/api.ts index 2c38770..1d5133b 100644 --- a/compiler/api.ts +++ b/compiler/api.ts @@ -12,6 +12,7 @@ import { ACTION_DEFS, CHOICES, CONDITION_DEFS, type ActionDef, type ArgDef, type ArgKind, type ConditionDef, } from "../vendor/triggerDefs"; +import { ORDER_OF_VERB } from "./orders"; import { ActionType, AllianceStatus, Comparison, ConditionType, Order, ResourceType, ScoreType, SetModifier, SwitchAction, SwitchState, TriggerFlag, UnitState, } from "../vendor/triggers"; @@ -78,6 +79,12 @@ export const CANONICAL: Partial> = { ], }; +/** The Order action's own number for an order of orders.* it gives (move 0, patrol 1, attack 2); a raw number as it is. */ +export function orderOfAction(value: number): number { + const verb = (Object.keys(ORDER_OF_VERB) as (keyof typeof ORDER_OF_VERB)[]).find((v) => ORDER_OF_VERB[v] === value); + return verb === undefined ? value : CANONICAL.order!.find(([, w]) => w === verb)![0]; +} + /** The canonical word for a value, else the table's label. */ export function choiceWord(kind: ArgKind, value: number): string | undefined { const hit = CANONICAL[kind]?.find(([v]) => v === value); @@ -118,6 +125,10 @@ export function argType(kind: ArgKind): string { case "count": return "Count"; case "textFlags": return "boolean"; case "number": case "amount": case "duration": case "percent": case "cuwp": case "slot": return "number"; + // orders.Move, orders.Patrol and orders.AttackMove are the action's three (`orderOfAction`); any other of orders.* is + // what a unit is doing, which the action cannot give. A raw number is a second signature (`declarations.ts`): in one + // union with it, TypeScript takes orders.Die for a number. + case "order": return "OrderKind | OrderOfAction"; default: return `${CHOICE_TYPES[kind] ?? "number"} | number`; } } diff --git a/compiler/declarations.ts b/compiler/declarations.ts index 7d68626..d875c42 100644 --- a/compiler/declarations.ts +++ b/compiler/declarations.ts @@ -14,6 +14,7 @@ */ import { ACTION_FIELDS, CONDITION_FIELDS } from "./record"; import { READ_ARITY } from "./runtime"; +import { ORDER_OF_VERB } from "./orders"; import { ACTION_IDENTS, argType, CHOICE_TYPES, choiceWords, CONDITION_IDENTS, MODULE_NAME, propertyKey, scriptParams, TRIGGER_OPTION_NAMES } from "./api"; import type { ActionDef, ArgKind, ConditionDef } from "../vendor/triggerDefs"; import { allTables, defaultScriptNames, type NameTable, type ScriptNames } from "./names"; @@ -40,6 +41,8 @@ const HEADER = `// ── TrigScript ────────────── function types(kw: string): string { return ` ${kw}type Brand = { readonly __kind?: K }; +/** A number that is none of the named tables' (units.*, orders.* …): what an argument takes where a table's would be wrong. */ +${kw}type RawNumber = number & { readonly __kind?: never }; /** A player or player group (P1 … P12, CurrentPlayer, AllPlayers, players.*, or a raw group number). */ ${kw}type Player = N & Brand<"player">; /** A unit type (units.*, or a raw units.dat id): what a condition or an action names. A unit on the map is a Unit. */ @@ -52,6 +55,8 @@ ${kw}type Upgrade = N & Brand<"upgrade">; ${kw}type Tech = N & Brand<"tech">; /** An order (orders.*, or a raw orders.dat id): what a unit is doing. */ ${kw}type Order = N & Brand<"order">; +/** The orders the Order action gives: orders.Move, orders.Patrol and orders.AttackMove ("move", "patrol", "attack"). */ +${kw}type OrderOfAction = ${Object.values(ORDER_OF_VERB).map((n) => `Order<${n}>`).join(" | ")}; /** A player colour (colors.*), for stats(player).color. */ ${kw}type PlayerColor = N & Brand<"color">; ${kw}type ColorName = __COLOR_NAMES__; @@ -839,7 +844,12 @@ function signature(kw: string, ident: string, def: ConditionDef | ActionDef, ret const all = scriptParams(def); const params = all.map((p) => `${p.name}${p.optional ? "?" : ""}: ${argType(p.arg.kind)}`); const doc = def.args.length ? `${def.name} — ${def.args.map((a) => a.label).join(", ")}` : def.name; - const out = `/** ${doc} */\n${kw}function ${ident}(${params.join(", ")}): ${returns};`; + let out = `/** ${doc} */\n${kw}function ${ident}(${params.join(", ")}): ${returns};`; + const order = all.findIndex((p) => p.arg.kind === "order"); + if (order >= 0) { + const raw = all.map((p, i) => `${p.name}${p.optional ? "?" : ""}: ${i === order ? "RawNumber" : argType(p.arg.kind)}`); + out += `\n/** ${doc}: the action's own number for the order, 0 move, 1 patrol, 2 attack. */\n${kw}function ${ident}(${raw.join(", ")}): ${returns};`; + } if (returns !== "Condition" || !READ_ARITY.has(ident)) return out; // Without the comparison and the amount, the quantity itself: a read, inside program(). const read = all.filter((p) => p.arg.kind !== "comparison" && p.arg.kind !== "amount").map((p) => `${p.name}: ${argType(p.arg.kind)}`); diff --git a/compiler/runtime.ts b/compiler/runtime.ts index 7da889f..2fc6477 100644 --- a/compiler/runtime.ts +++ b/compiler/runtime.ts @@ -17,7 +17,7 @@ import { type ActionRecord, type ConditionRecord, type TriggerRecord, } from "../vendor/triggers"; import { aiScriptByName, type ActionDef, type ArgKind, type ConditionDef } from "../vendor/triggerDefs"; -import { ACTION_IDENTS, CANONICAL, choiceOf, choiceWords, CONDITION_IDENTS, scriptParams, TRIGGER_OPTION_NAMES } from "./api"; +import { ACTION_IDENTS, CANONICAL, choiceOf, choiceWords, CONDITION_IDENTS, orderOfAction, scriptParams, TRIGGER_OPTION_NAMES } from "./api"; import { ACTION_FIELDS, CONDITION_FIELDS } from "./record"; import { hyperTriggers, negateCondition, PLAYER_SLOTS } from "./lower"; import { allTables, type ScriptNames } from "./names"; @@ -327,7 +327,7 @@ export function createRuntime(names: ScriptNames, collector: Collector, options: if (n === undefined) throw new ScriptError(`${what}: unknown ${kind} ${describe(v)}: one of ${choiceWords(kind).map((w) => JSON.stringify(w)).join(", ")}.`); return n; } - return integer(v, what); + return kind === "order" ? orderOfAction(integer(v, what)) : integer(v, what); } }; diff --git a/ledger/game/order.ts b/ledger/game/order.ts index 0af5271..3bd056b 100644 --- a/ledger/game/order.ts +++ b/ledger/game/order.ts @@ -1,7 +1,7 @@ // ledger: step 5, refused: the action Order -// The action Order: units at a location sent to another, to move, patrol or attack. (Built outside the -// program and stood as a statement inside it: written inside, `order(…)` does not compile.) -const charge = order(P1, units.TerranMarine, locations.Anywhere, locations.Anywhere, "attack"); +// The action Order: units at a location sent to another, to move, patrol or attack — "attack" or orders.AttackMove, +// one of the three orders the action gives (any other of orders.* is a compile error). program(() => { - charge; + order(P1, units.TerranMarine, locations.Anywhere, locations.Anywhere, "attack"); + order(P1, units.TerranMarine, locations.Anywhere, locations.Anywhere, orders.Move); }); diff --git a/tests/events.test.ts b/tests/events.test.ts index 4435439..68b99c8 100644 --- a/tests/events.test.ts +++ b/tests/events.test.ts @@ -136,6 +136,18 @@ describe("events, second part: killed, order, morphed, at the top of a script", expect(test).toMatchObject({ kind: "if", then: [{ kind: "unitDo", verb: { do: "order", order: "move" } }] }); }); + it("the action order() gives orders.Move, orders.Patrol and orders.AttackMove as move, patrol and attack; no other of orders.*", () => { + // (orders.Move made the action's modifier 6 — orders.dat's number — where the action knows 0, 1 and 2.) + const modifier = (order: string) => ((compile(`order(P1, units.TerranMarine, HOME, HOME, ${order});`).ir[0].body[0]) as Extract).record.modifier; + expect(["orders.Move", "orders.Patrol", "orders.AttackMove", `"attack"`, "1"].map(modifier)).toEqual([0, 1, 2, 2, 1]); + // orders.Die is 0 and orders.Stop 1, the action's own numbers for move and patrol: refused, not taken for them. + for (const order of ["orders.Die", "orders.Stop", "orders.HoldPosition"]) { + expect(messages(`order(P1, units.TerranMarine, HOME, HOME, ${order});`).join("\n")).toContain("not assignable to parameter of type 'OrderKind | OrderOfAction'"); + } + const r = compileScript(ts, { "main.ts": `trigger(P1, [always()], [order(P1, units.TerranMarine, 1, 1, orders.Patrol)]);` }, NAMES, { lib: LIB }); + expect(r.triggers[0].actions[0]).toMatchObject({ type: ActionType.Order, modifier: 1 }); + }); + it("on.order hands the unit, the order it took up and the one it had; a filter on the order", () => { const r = compile(` on.order((u, order, was) => { print(\`\${u.type} \${was} -> \${order}\`); }); From 40d481dca365a1a67070403f1e070f96e787cef6 Mon Sep 17 00:00:00 2001 From: Jeany Date: Mon, 19 Oct 2026 23:58:19 -0600 Subject: [PATCH 2/2] A function that keeps a state is the program's, not worked out once when the script is built A function changing a variable declared outside it (a closure's, a let of the file's) or a field of this outside a constructor was worked out where its call was written: a loop printing next() of `const next = counter()` showed 1, 1, 1. A call of it is now the program's (hoist.ts keepsState): a closure made in the program runs in the game; one made when the script was built, or a method of an instance made then, is refused; a function writing the script's own let or record is refused as the write written in the program is. A cache filled by a method stays a build-time call. Diagnostics said again for each call of the same function are said once. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01T1CM9pLuTuAn3mgTEkjgVg --- compiler/compiler.ts | 6 ++ compiler/hoist.ts | 78 +++++++++++++++++++++++- compiler/structured.ts | 15 ++++- docs/v4-plan.md | 28 ++++++--- ledger/language/closure-keeps-a-count.ts | 18 ++++++ tests/script-functions.test.ts | 36 +++++++++++ tests/script-structured.test.ts | 3 +- 7 files changed, 174 insertions(+), 10 deletions(-) create mode 100644 ledger/language/closure-keeps-a-count.ts diff --git a/compiler/compiler.ts b/compiler/compiler.ts index 08a5cfd..538c543 100644 --- a/compiler/compiler.ts +++ b/compiler/compiler.ts @@ -214,6 +214,12 @@ export function compileScript(ts: typeof TS, files: ScriptFiles, names: ScriptNa const diagnostics: ScriptDiagnostic[] = []; const result = (extra: Partial = {}): CompileResult => { diagnostics.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line || a.column - b.column); + // A function of the script's is compiled where each call is: its error once, not once a call. + const said = new Set(); + for (let i = diagnostics.length - 1; i >= 0; i--) { + const d = diagnostics[i], key = `${d.file}:${d.line}:${d.column}:${d.message}`; + if (said.has(key)) diagnostics.splice(i, 1); else said.add(key); + } return { triggers: [], sources: [], strings: [], sounds: [], variables: [], programs: [], buildTime: [], hints: [], refs, ir: [], input: null, tests: null, ...extra, diagnostics, ok: diagnostics.length === 0 }; }; diff --git a/compiler/hoist.ts b/compiler/hoist.ts index f0c1928..e41803b 100644 --- a/compiler/hoist.ts +++ b/compiler/hoist.ts @@ -353,6 +353,77 @@ export function usesGame(ts: typeof TS, checker: TS.TypeChecker, fn: TS.Node, se return found; } +const keepsStateMemo = new WeakMap(); + +/** + * Whether a function of the script's own changes what outlives a call of it: a variable declared outside the function + * that writes it (`count += 1` of a `let` at the top of a file; `n += 1` in the function `counter()` gives back, whose + * `n` is counter's), or a field of `this` outside a constructor — itself, in a function written inside it, or through + * another of the script's functions. Worked out when the script is built, a call of it in a program would change that + * once, where the call is written: a loop printing `next()` showed 1, 1, 1 for 1, 2, 3. So such a call is the program's, + * and the program's own checks say what the game cannot change. A function handed to `forEach` or `map` writing the + * variables of the function it is written in is that function's own business; a cache filled by a method + * (`films.set(…)`) is not an assignment, and gives the same answer each time. + */ +export function keepsState(ts: typeof TS, checker: TS.TypeChecker, fn: TS.Node, seen = new Set()): boolean { + const hit = keepsStateMemo.get(fn); + if (hit !== undefined) return hit; + if (seen.has(fn)) return false; + seen.add(fn); + let found = false; + const isFunction = (n: TS.Node): n is TS.FunctionLikeDeclaration => ts.isFunctionLike(n) && !!(n as TS.FunctionLikeDeclaration).body; + /** The function whose variables a write at `at` may change without outliving the call: through the callbacks it is written in. */ + const owner = (at: TS.Node): TS.FunctionLikeDeclaration | undefined => { + let f: TS.Node | undefined = at.parent; + while (f && !isFunction(f)) f = f.parent; + while (f && isCallbackArgument(ts, f)) { f = f.parent; while (f && !isFunction(f)) f = f.parent; } + return f as TS.FunctionLikeDeclaration | undefined; + }; + const strip = (e: TS.Expression): TS.Expression => { + while (ts.isParenthesizedExpression(e) || ts.isAsExpression(e) || ts.isNonNullExpression(e) || ts.isSatisfiesExpression(e) || ts.isTypeAssertionExpression(e)) e = e.expression; + return e; + }; + const outside = (target: TS.Expression, at: TS.Node): boolean => { + let t = strip(target); + if (ts.isArrayLiteralExpression(t)) return t.elements.some((x) => outside(ts.isSpreadElement(x) ? x.expression : x, at)); + if (ts.isObjectLiteralExpression(t)) { + return t.properties.some((p) => ts.isPropertyAssignment(p) ? outside(p.initializer, at) : ts.isShorthandPropertyAssignment(p) ? outside(p.name, at) : ts.isSpreadAssignment(p) && outside(p.expression, at)); + } + if (ts.isBinaryExpression(t) && t.operatorToken.kind === ts.SyntaxKind.EqualsToken) return outside(t.left, at); + while (ts.isPropertyAccessExpression(t) || ts.isElementAccessExpression(t)) t = strip(t.expression); + const f = owner(at); + if (t.kind === ts.SyntaxKind.ThisKeyword) { + // An arrow's `this` is the function's it is written in. + let g: TS.Node | undefined = f; + while (g && ts.isArrowFunction(g)) { g = g.parent; while (g && !isFunction(g)) g = g.parent; } + return !g || !ts.isConstructorDeclaration(g); + } + if (!ts.isIdentifier(t)) return false; + const decl = declarationOf(ts, checker, t); + if (!decl || decl.getSourceFile() !== at.getSourceFile()) return !!decl; + return !f || decl.pos < f.pos || decl.end > f.end; + }; + const other = (decl: TS.Node | undefined): boolean => + !!decl && decl !== fn && isFunction(decl) && !decl.getSourceFile().isDeclarationFile && keepsState(ts, checker, decl, seen); + const look = (n: TS.Node): void => { + if (found) return; + if (ts.isBinaryExpression(n) && n.operatorToken.kind >= ts.SyntaxKind.FirstAssignment && n.operatorToken.kind <= ts.SyntaxKind.LastAssignment && outside(n.left, n)) { found = true; return; } + if ((ts.isPrefixUnaryExpression(n) || ts.isPostfixUnaryExpression(n)) && (n.operator === ts.SyntaxKind.PlusPlusToken || n.operator === ts.SyntaxKind.MinusMinusToken) && outside(n.operand, n)) { found = true; return; } + if (ts.isDeleteExpression(n) && outside(n.expression, n)) { found = true; return; } + if ((ts.isCallExpression(n) || ts.isNewExpression(n)) && other(checker.getResolvedSignature(n)?.declaration)) { found = true; return; } + if (ts.isIdentifier(n)) { + const decl = declarationOf(ts, checker, n); + if (decl && (isScriptFunction(ts, decl) || scriptArrow(ts, decl)) && other(isScriptFunction(ts, decl) ? decl : scriptArrow(ts, decl))) { found = true; return; } + return; + } + ts.forEachChild(n, look); + }; + const body = (fn as TS.FunctionLikeDeclaration).body; + if (body) look(body); + keepsStateMemo.set(fn, found); + return found; +} + /** A method, an accessor or a constructor of one of the script's classes: what a call through an instance runs. */ function classMember(ts: typeof TS, decl: TS.Node): boolean { return (ts.isMethodDeclaration(decl) || ts.isGetAccessorDeclaration(decl) || ts.isSetAccessorDeclaration(decl) || ts.isConstructorDeclaration(decl)) && !!decl.body && isScriptClass(ts, decl.parent); @@ -602,7 +673,7 @@ function planWithin(ts: typeof TS, checker: TS.TypeChecker, plan: ProgramPlan, a // A function of the script's own that does what only the game can (`range(2, 4)` draws the game's random numbers): // run in the game, whatever its arguments. const own = decl && (isScriptFunction(ts, decl) ? decl : scriptArrow(ts, decl)); - if (own && usesGame(ts, checker, own)) { ok = false; return; } + if (own && (usesGame(ts, checker, own) || keepsState(ts, checker, own))) { ok = false; return; } // A function of the script's own kept in an array or a record (`const fs = [a, b]`): the program's functions as // values, which it calls with its own values. Handed to a function (`xs.map(sq)`) or called, it is the script's. if (decl && (isScriptFunction(ts, decl) || scriptArrow(ts, decl)) && (ts.isArrayLiteralExpression(p) || (ts.isPropertyAssignment(p) && p.initializer === n) || ts.isShorthandPropertyAssignment(p))) { ok = false; return; } @@ -628,6 +699,11 @@ function planWithin(ts: typeof TS, checker: TS.TypeChecker, plan: ProgramPlan, a if (clockCall(ts, checker, n)) { ok = false; return; } // A call of a game() function runs in the game, whatever its arguments are. if (ts.isCallExpression(n) && isGame(n)) { ok = false; return; } + // So does one of a function that changes what outlives the call, a closure's variable or a field (`keepsState`). + if (ts.isCallExpression(n)) { + const callee = checker.getResolvedSignature(n)?.declaration; + if (callee && ts.isFunctionLike(callee) && (callee as TS.FunctionLikeDeclaration).body && !callee.getSourceFile().isDeclarationFile && keepsState(ts, checker, callee)) { ok = false; return; } + } if (ts.isCallExpression(n) && isReadCall(libraryCallName(ts, checker, n), n.arguments.length)) { ok = false; return; } if (ts.isBinaryExpression(n)) { const k = n.operatorToken.kind; diff --git a/compiler/structured.ts b/compiler/structured.ts index b223ecb..9d4ebfd 100644 --- a/compiler/structured.ts +++ b/compiler/structured.ts @@ -60,7 +60,7 @@ import type * as TS from "typescript"; import { ActionType, PlayerGroup } from "../vendor/triggers"; import type { ActionRecord } from "../vendor/triggers"; import type { HoistedThunks, ProgramPlan } from "./hoist"; -import { clockCall, declarationOf, isOnCall, isScriptClass, isScriptFunction, libraryCallName, ON_EVENTS, scriptArrow } from "./hoist"; +import { clockCall, declarationOf, isOnCall, isScriptClass, isScriptFunction, keepsState, libraryCallName, ON_EVENTS, scriptArrow } from "./hoist"; import { ORDER_NAMES } from "./orders"; import { DEAF_KEYS, KEY_NAMES, keyName, parseChatPattern, type ChatPattern, type InputSource } from "./input"; import { scriptParams } from "./api"; @@ -765,6 +765,12 @@ export class Structured { return undefined; } + /** Whether a call is of a function of the script's own that changes what outlives the call (`keepsState` in hoist.ts). */ + private keepsState(call: TS.CallExpression): boolean { + const decl = this.c.checker.getResolvedSignature(call)?.declaration; + return !!decl && this.ts.isFunctionLike(decl) && !!(decl as TS.FunctionLikeDeclaration).body && !decl.getSourceFile().isDeclarationFile && keepsState(this.ts, this.c.checker, decl); + } + private varOf(expr: TS.Expression): VarDecl | undefined { const b = this.bindingOf(expr); return b?.kind === "var" ? b.v : undefined; @@ -820,6 +826,9 @@ export class Structured { if (ts.isCallExpression(e)) { const callee = sub(e.expression); if (!callee || typeof callee.value !== "function" || isGameFunction(callee.value)) return undefined; + // A function that changes what outlives the call (`keepsState`) — a closure made when the script was built, a method + // writing its instance — would change it once, here, however often the program calls it. + if (this.keepsState(e)) return undefined; const self = ts.isPropertyAccessExpression(e.expression) ? sub(e.expression.expression)?.value : undefined; const args: unknown[] = []; for (const a of e.arguments) { @@ -11314,6 +11323,10 @@ export class Structured { return acc; } if (isBuilder(callee)) { this.c.error(e, callee.kind === "condition" ? "This is a condition; test it in an if or a while." : "This is an action; it stands as a statement."); return null; } + if (typeof callee === "function" && this.keepsState(e)) { + this.c.error(e, `${e.expression.getText()}() changes what it keeps between calls, but that was made when the script was built, where the game cannot change it. Make it inside the program, or keep what changes in a let of the program.`); + return null; + } if (typeof callee === "function") { this.c.error(e, "This helper is computed when the script is built and cannot take a variable of the program. Write it as a game() function to run it in the game."); return null; } this.notConstant(e, "A call's arguments"); return null; diff --git a/docs/v4-plan.md b/docs/v4-plan.md index 7c7eff7..ec57310 100644 --- a/docs/v4-plan.md +++ b/docs/v4-plan.md @@ -3879,7 +3879,7 @@ Bad Apple: 5, 6, 7, as the fog library. The bullets: 5 (a death count read), 9. ### The ledger (2026-10-19) -`ledger/` holds a script for each feature of the language and the library — 831 of them, in folders by area — whose +`ledger/` holds a script for each feature of the language and the library — 832 of them, in folders by area — whose first line says which step of the way off eudplib makes it a map of our own's, and whether a map of our own takes it today, in the words of its refusal: @@ -3905,7 +3905,7 @@ lists what no script reaches. A step is done when its scripts say so. | 6 The game's memory | 0 of 49 | | 7 Programs, owners and players | 1 of 22 | | 8 Texts as values | 0 of 43 | -| 9 Arrays that grow, the heap | 0 of 27 | +| 9 Arrays that grow, the heap | 0 of 28 | | 10 Units | 0 of 99 | | 11 Tasks, `async` and errors | 0 of 44 | | 12 Input | 0 of 31 | @@ -3931,12 +3931,26 @@ the ledger is the list it can be written again from at 4.0. - A map of our own refused a print to the owner named by its slot (`{ to: P1 }` in P1's program), and took a program owned by P9 to P12, whose triggers the game does not run — refused now (to be confirmed in the game). +*Fixed after it (both builds):* +- A function that keeps a state — one changing a variable declared outside it, a closure's, or a field of `this` + outside a constructor, itself or through another function of the script's — was worked out when the script was + built, once where its call was written: `` const next = counter(); while (…) print(`${next()}`) `` printed + 1, 1, 1 for 1, 2, 3 (`hoist.ts` `keepsState`). Now a call of it is the program's: a closure made in the program runs + in the game (`ledger/language/closure-keeps-a-count.ts`), and one made when the script was built, a method of an + instance made then, or a function writing a `let` or a record of the script's is refused, as the write written in + the program is (`tests/script-functions.test.ts`). A cache filled by a method (`films.set(…)`) is no assignment, and + a function handed to `forEach` writing its own function's variables is that function's business: both are still + worked out when built. Three calls written one after the other used to come out right; they are refused now too. + Not caught: a state changed only through a method (`log.push(…)`, `counts.set(…)` on something of the script's) — a + cache does the same, and the two cannot be told apart from the code. +- `order(…, orders.Move)` made an Order action of modifier 6 (orders.dat's number), where the action knows move, + patrol and attack (0, 1, 2). orders.Move, orders.Patrol and orders.AttackMove are now those three, and any other of + `orders.*` a type error — `orders.Die` and `orders.Stop`, 0 and 1, were taken for move and patrol + (`tests/events.test.ts`). A raw number is a second signature, the action's own. + *Open:* -- **Built wrong without a word:** a function written outside the program that keeps a state and is called inside it - (`` const next = counter(); while (…) print(`${next()}`) ``) is worked out once when the script is built: - 1, 1, 1 for 1, 2, 3. Kept out of the ledger until it is refused or right. -- **Built wrong without a word:** `order(…, orders.Move)` type-checks (`OrderKind | number`) and makes an Order action - of modifier 6; the action knows move, patrol and attack (0, 1, 2). +- `new P(4).x` written in a program's expression is "A value must be known when the script is built"; the same through + a function (`function made(x) { return new P(x).x; }`) is taken. - `displayText(text, always)` made a print loses its flag — to check against the flags a print gives Display Text. - `disabled(…)` inside a program is neither refused nor said: a disabled condition in an `if` holds, as in a trigger. `preserve()` and `preserveTrigger()` there are dropped (every program is preserved), also without a word. diff --git a/ledger/language/closure-keeps-a-count.ts b/ledger/language/closure-keeps-a-count.ts new file mode 100644 index 0000000..5f7d27e --- /dev/null +++ b/ledger/language/closure-keeps-a-count.ts @@ -0,0 +1,18 @@ +// ledger: step 9, refused: a variable a closure keeps +// A closure that keeps a count, made in the program by a function of the script's (`const next = counter()`) and +// called at each turn of a loop: 1, 2, 3. +function counter() { + let n = 0; + return () => { + n += 1; + return n; + }; +} +program(() => { + const next = counter(); + let k = 0; + while (k < 3) { + print(`${next()}`); + k += 1; + } +}); diff --git a/tests/script-functions.test.ts b/tests/script-functions.test.ts index 83bd632..f783d5f 100644 --- a/tests/script-functions.test.ts +++ b/tests/script-functions.test.ts @@ -40,3 +40,39 @@ describe("functions from any file", () => { expect(raw({ "main.ts": "function bad(n: number) { return [n].map((x) => `${x}`).join(); } const ok = bad(3); program(() => { print(ok); });" }).diagnostics).toEqual([]); }); }); + +describe("a function that keeps a state", () => { + // Worked out when the script was built, a call changed the state once where it was written: a loop printed 1, 1, 1. + const COUNTER = "function counter() { let n = 0; return () => { n += 1; return n; }; }"; + const loop = (call: string) => `program(() => { let k = 0; while (k < 3) { print(\`\${${call}}\`); k += 1; } });`; + const said = (files: Record) => raw(files).diagnostics.map((d) => `${d.line}: ${d.message}`); + + it("a closure made in the program runs in the game, each call its own", () => { + expect(shown({ "main.ts": `${COUNTER}\nprogram(() => { const next = counter(); let k = 0; while (k < 3) { print(\`\${next()}\`); k += 1; } });` })).toEqual(["1", "2", "3"]); + }); + + it("one made when the script was built, or a method of an instance made then, is refused: the game cannot change what they keep", () => { + expect(said({ "main.ts": `${COUNTER}\nconst next = counter();\n${loop("next()")}` })).toEqual([ + "3: next() changes what it keeps between calls, but that was made when the script was built, where the game cannot change it. Make it inside the program, or keep what changes in a let of the program.", + ]); + expect(said({ "main.ts": `class Counter { n = 0; inc() { this.n += 1; return this.n; } }\nconst c = new Counter();\n${loop("c.inc()")}` })[0]).toMatch(/^3: c\.inc\(\) changes what it keeps between calls/); + }); + + it("a function writing a variable or a record of the script's is the program's, and refused as the write would be — once", () => { + expect(said({ "main.ts": `let count = 0;\nfunction bump() { count += 1; return count; }\nprogram(() => { print(\`\${bump()}\`); print(\`\${bump()}\`); });` })).toEqual(["2: Only the program's let variables can be assigned."]); + expect(said({ "main.ts": `const box = { n: 0 };\nfunction bump() { box.n += 1; return box.n; }\n${loop("bump()")}` })[0]).toMatch(/^2: This object is computed when the script is built/); + }); + + it("what keeps no state is still worked out when the script is built: its own variables, a callback's writes to them, a cache", () => { + const r = compile({ "main.ts": [ + "const cache = new Map();", + "function sq(v: number) { const had = cache.get(v); if (had !== undefined) return had; const r = v * v; cache.set(v, r); return r; }", + "function sum(xs: number[]) { let t = 0; xs.forEach((x) => { t += x; }); return t; }", + "class P { x = 0; constructor(x: number) { this.x = x; } }", + "function made(x: number) { return new P(x).x; }", + "program(() => { let k = 0; while (k < 2) { print(`${sq(7)} ${sum([1, 2, 3])} ${made(4)}`); k += 1; } });", + ].join("\n") }); + expect(simulatePrograms(r.ir, 1, { strings: r.strings }).events.map((e) => e.text)).toEqual(["49 6 4", "49 6 4"]); + expect(JSON.stringify(r.ir)).not.toContain("\"call\""); + }); +}); diff --git a/tests/script-structured.test.ts b/tests/script-structured.test.ts index a345997..c11ad02 100644 --- a/tests/script-structured.test.ts +++ b/tests/script-structured.test.ts @@ -856,7 +856,8 @@ describe("structured: short circuits, lazy constants, names", () => { }); it("build-time parts of a body run once", () => { - const counted = files({ "main.ts": 'import { tick } from "./count";\nprogram(() => { let n = 0; if (bring(P1, units.AnyUnit, locations.Anywhere, ">=", tick())) n++; });', "count.ts": "let calls = 0;\nexport const tick = () => ++calls;\nexport const seen = () => calls;" }); + // (Counted by a push: a function assigning a variable of its file keeps a state, and a call of it is the program's.) + const counted = files({ "main.ts": 'import { tick } from "./count";\nprogram(() => { let n = 0; if (bring(P1, units.AnyUnit, locations.Anywhere, ">=", tick())) n++; });', "count.ts": "const calls: number[] = [];\nexport const tick = () => calls.push(0);\nexport const seen = () => calls.length;" }); expect(counted.ok).toBe(true); const stmt = counted.ir[0].body.find((s) => s.kind === "if"); expect(stmt && stmt.kind === "if" && stmt.cond.kind === "cond" ? stmt.cond.record.amount : null).toBe(1);