Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions compiler/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -78,6 +79,12 @@ export const CANONICAL: Partial<Record<ArgKind, [number, string][]>> = {
],
};

/** 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);
Expand Down Expand Up @@ -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`;
}
}
Expand Down
6 changes: 6 additions & 0 deletions compiler/compiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,12 @@ export function compileScript(ts: typeof TS, files: ScriptFiles, names: ScriptNa
const diagnostics: ScriptDiagnostic[] = [];
const result = (extra: Partial<CompileResult> = {}): 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<string>();
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 };
};

Expand Down
12 changes: 11 additions & 1 deletion compiler/declarations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -40,6 +41,8 @@ const HEADER = `// ── TrigScript ──────────────
function types(kw: string): string {
return `
${kw}type Brand<K extends string> = { 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 extends number = number> = 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. */
Expand All @@ -52,6 +55,8 @@ ${kw}type Upgrade<N extends number = number> = N & Brand<"upgrade">;
${kw}type Tech<N extends number = number> = N & Brand<"tech">;
/** An order (orders.*, or a raw orders.dat id): what a unit is doing. */
${kw}type Order<N extends number = number> = 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 extends number = number> = N & Brand<"color">;
${kw}type ColorName = __COLOR_NAMES__;
Expand Down Expand Up @@ -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)}`);
Expand Down
78 changes: 77 additions & 1 deletion compiler/hoist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,77 @@ export function usesGame(ts: typeof TS, checker: TS.TypeChecker, fn: TS.Node, se
return found;
}

const keepsStateMemo = new WeakMap<TS.Node, boolean>();

/**
* 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<TS.Node>()): 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);
Expand Down Expand Up @@ -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; }
Expand All @@ -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;
Expand Down
4 changes: 2 additions & 2 deletions compiler/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
}
};

Expand Down
15 changes: 14 additions & 1 deletion compiler/structured.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading