@homie-rocks/studio 0.32.1 → 0.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/CHANGELOG.md +57 -1
  2. package/README.md +24 -6
  3. package/agents/agents.ts +57 -24
  4. package/bin/homie-studio.mjs +3 -3
  5. package/lib/build.mjs +64 -9
  6. package/lib/cf-usage.mjs +76 -0
  7. package/lib/cloudflare.mjs +25 -2
  8. package/lib/dev.mjs +45 -4
  9. package/lib/lab-check.mjs +3 -2
  10. package/lib/mcp-tools.mjs +1 -1
  11. package/lib/mcp.mjs +1 -1
  12. package/lib/office.mjs +7 -0
  13. package/lib/perf.mjs +8 -3
  14. package/lib/player-image.mjs +40 -0
  15. package/lib/rules-build.mjs +184 -0
  16. package/lib/rules-guard.mjs +770 -0
  17. package/lib/scaffold.mjs +43 -16
  18. package/lib/shop-catalog.mjs +10 -9
  19. package/lib/shop.mjs +32 -15
  20. package/lib/studio.mjs +21 -1
  21. package/lib/template-history.json +50 -0
  22. package/lib/upgrade.mjs +19 -15
  23. package/lib/version.mjs +2 -7
  24. package/netplay/NETPLAY.md +249 -4
  25. package/netplay/netplay.ts +55 -13
  26. package/package.json +23 -1
  27. package/rules/core.ts +1250 -0
  28. package/rules/guard.ts +419 -0
  29. package/rules/host.ts +707 -0
  30. package/rules/math.ts +266 -0
  31. package/rules/pack.ts +292 -0
  32. package/rules/rules.ts +544 -0
  33. package/rules/view.ts +447 -0
  34. package/shop/SELLING.md +14 -16
  35. package/shop/SHOP.md +119 -30
  36. package/shop/shop.ts +6 -8
  37. package/site/SITE.md +119 -2
  38. package/starters/coin-dash/game.json +11 -0
  39. package/starters/coin-dash/index.html +17 -0
  40. package/starters/coin-dash/map/main.json +16 -0
  41. package/starters/coin-dash/src/move.ts +27 -0
  42. package/starters/coin-dash/src/rules.ts +86 -0
  43. package/starters/coin-dash/src/view.ts +106 -0
  44. package/starters/coin-dash/tunables.json +5 -0
  45. package/starters/ember-vale/index.html +11 -0
  46. package/worker/brain.mjs +8 -7
  47. package/worker/chat-page.mjs +1 -1
  48. package/worker/embed.mjs +135 -0
  49. package/worker/hosted.mjs +56 -0
  50. package/worker/index.mjs +413 -22
  51. package/worker/office-page.mjs +1 -0
  52. package/worker/office.mjs +24 -7
  53. package/worker/pages.mjs +189 -43
  54. package/worker/players.mjs +10 -6
  55. package/worker/referrals.mjs +192 -57
  56. package/worker/room-store.mjs +54 -0
  57. package/worker/room.mjs +294 -119
  58. package/worker/saves-shell.mjs +7 -1
  59. package/worker/schema.mjs +4 -3
  60. package/worker/shop-page.mjs +103 -37
  61. package/worker/shop-rules.mjs +156 -117
  62. package/worker/shop-store.mjs +70 -9
  63. package/worker/shop.mjs +275 -143
  64. package/worker/site.mjs +26 -5
  65. package/worker/stripe.mjs +9 -6
  66. package/worker/version.mjs +1 -1
@@ -0,0 +1,770 @@
1
+ /**
2
+ * The wall around rules, at build: a parser pass that refuses what rules may not use, with the line named, and
3
+ * rewrites what is left so that it runs counted and guarded (rooms-milestone-1-design.md, section 10).
4
+ *
5
+ * A game's rules run in the same isolate as its studio's site, accounts and shop until rules get an isolate of their
6
+ * own. So `homie-studio build` never puts a rules module into the Worker as its author wrote it:
7
+ *
8
+ * 1 every file the rules are made of (`rules.ts`, `move.ts`, and the game's own files they import) has its types
9
+ * removed (esbuild) and is read by a real parser (Babel, scope-aware: it tells a global from a field of the
10
+ * same name);
11
+ * 2 CHECK: globals, methods and syntax are allowlists. Anything else is a problem with its file and line, in
12
+ * the words of this file's messages. A build with problems writes nothing;
13
+ * 3 REWRITE: a counter at the top of every loop and function, every computed key and every method call through
14
+ * the guard (rules/guard.ts), every text made by `+` or a template charged and capped, every value an operator
15
+ * would turn into a number or a text checked to be one already, and every write to a property checked not to
16
+ * land on a function;
17
+ * 4 the rewritten files are linked into one module (esbuild), which is read once more and held to the same
18
+ * rules: no unguarded computed key, no uncounted loop or function, no unchecked operator and no unchecked
19
+ * write is left in the file that is deployed.
20
+ *
21
+ * WHY OPERATORS ARE CHECKED. JavaScript turns a list into a text whenever an operator wants a number or a text
22
+ * (`list - 1`, `list < 1`, `list == 1`, a template, `String(list)`), by walking every entry of it and of every list
23
+ * inside it. No counter sees that walk, and for a list that holds another twice, thirty levels deep, it is a thousand
24
+ * million steps. So the operand of every such operator, unless the pass can see it is a number or a text, goes through
25
+ * the guard's `p`, which refuses a list, an object or a function there. `==` and `!=` are refused outright (except
26
+ * against `null`), and so are whole numbers of any size (`10n`), which grow without limit when multiplied.
27
+ *
28
+ * WHY THE CHECK READS EACH FILE BEFORE LINKING, NOT ONLY THE LINKED MODULE. The linker turns every top-level
29
+ * `const` into `var`, so in the linked module a module-level variable the author declared could not be told from a
30
+ * constant. Step 2 therefore reads the files one by one, and step 4 reads the deployed file again for everything else.
31
+ *
32
+ * WHY BABEL. The pass needs a parser that keeps up with the language, scope analysis, a code generator and source
33
+ * maps. `@babel/parser`, `@babel/traverse`, `@babel/generator` and `@babel/types` are that, are what most of the
34
+ * JavaScript world's own tools are built on, and are MIT-licensed. `@jridgewell/trace-mapping` (MIT) reads the
35
+ * type-removal's source map, so a problem names the line the author wrote.
36
+ *
37
+ * This is a wall against a careless module written by the studio's own AI. It is not a security boundary against
38
+ * code written to attack: no rules from outside the studio run on its server.
39
+ */
40
+ import { parse } from '@babel/parser';
41
+ import babelTraverse from '@babel/traverse';
42
+ import * as babelGenerator from '@babel/generator';
43
+ import * as t from '@babel/types';
44
+ import { TraceMap, originalPositionFor } from '@jridgewell/trace-mapping';
45
+ import { existsSync, readFileSync, statSync } from 'node:fs';
46
+ import { dirname, join, relative, resolve } from 'node:path';
47
+
48
+ const traverse = babelTraverse.default ?? babelTraverse;
49
+ const generate = babelGenerator.generate ?? babelGenerator.default;
50
+
51
+ /** Where rules import Homie's side from, and where the rewritten code finds the guard. */
52
+ export const RULES_MODULE = '@homie-rocks/studio/rules';
53
+ export const GUARD_MODULE = '@homie-rocks/studio/rules/guard';
54
+ const G = '__homie';
55
+
56
+ /*
57
+ * THE ALLOWLISTS. The method lists and the refused names are the ones rules/guard.ts enforces at run time, and
58
+ * the maths names are rules/math.ts's (a test holds each pair equal: this file is loaded by Node from an installed
59
+ * package, where a .ts file cannot be imported).
60
+ */
61
+ export const MATH_FNS = ['sqrt', 'abs', 'floor', 'ceil', 'round', 'trunc', 'min', 'max', 'sign', 'imul', 'fround'];
62
+ export const ARRAY_METHODS = ['push', 'pop', 'shift', 'unshift', 'slice', 'splice', 'concat', 'indexOf', 'lastIndexOf', 'includes', 'find', 'findIndex', 'some', 'every', 'map', 'filter', 'reduce', 'forEach', 'flat', 'flatMap', 'sort', 'reverse', 'join', 'at'];
63
+ export const STRING_METHODS = ['slice', 'indexOf', 'includes', 'startsWith', 'endsWith', 'split', 'trim', 'toUpperCase', 'toLowerCase', 'charCodeAt', 'at'];
64
+ export const MAPSET_METHODS = ['get', 'set', 'has', 'add', 'delete', 'clear', 'forEach', 'keys', 'values', 'entries'];
65
+ export const REFUSED_NAMES = ['constructor', 'prototype', '__proto__', 'stack', 'localeCompare'];
66
+ /** The names JavaScript calls by itself to turn an object into a number or a text: no function may sit under one. */
67
+ export const HOOK_NAMES = ['valueOf', 'toString', 'toJSON'];
68
+ /** What the host runtime's own objects offer: `world`, `ctx`, the round, the map (rules/core.ts) and `world.math` (rules/math.ts). */
69
+ export const WORLD_METHODS = ['ticks', 'random', 'send', 'sendRoom', 'announce', 'sendArea', 'after', 'emit', 'spawn', 'despawn', 'place', 'near', 'inBox', 'ray', 'sweep', 'ask', 'goalDone', 'finish', 'end', 'spot', 'spots'];
70
+ export const MATH_METHODS = ['sin', 'cos', 'tan', 'atan', 'atan2', 'asin', 'acos', 'exp', 'log', 'pow', 'hypot', 'rad', 'deg', 'clamp', 'lerp', 'vec', 'add', 'sub', 'scale', 'dot', 'cross', 'len', 'dist', 'norm', 'clampLen', 'lerpVec', 'dir', 'angle'];
71
+ const VALUE_METHODS = new Set([...ARRAY_METHODS, ...STRING_METHODS, ...MAPSET_METHODS]);
72
+ const CALLABLE = new Set([...VALUE_METHODS, ...WORLD_METHODS, ...MATH_METHODS]);
73
+ const refusedName = (k) => REFUSED_NAMES.includes(k) || k.startsWith('toLocale');
74
+ /** Operators that turn each operand into a number or a text. (`+` is handled beside them; `==` and `!=` are refused.) */
75
+ const CONVERTING = new Set(['-', '*', '/', '%', '&', '|', '^', '<<', '>>', '>>>', '<', '>', '<=', '>=']);
76
+ const LOGICAL_ASSIGN = ['||=', '&&=', '??='];
77
+ /** rules/guard.ts `SIZE_CAP`: no list, text, Map, Set or object in rules passes it. */
78
+ export const SIZE_CAP = 65_536;
79
+ /** A text shorter than this is compared or converted for nothing worth charging. */
80
+ const SHORT = 64;
81
+ const GLOBAL_MEMBERS = {
82
+ Math: { call: MATH_FNS, read: [] },
83
+ Number: { call: ['isFinite', 'isInteger', 'isNaN'], read: ['MAX_SAFE_INTEGER', 'EPSILON'] },
84
+ Object: { call: ['keys', 'values', 'entries'], read: [] },
85
+ Array: { call: ['isArray'], read: [] },
86
+ };
87
+ const GLOBAL_CALLS = ['Number', 'String', 'Boolean', 'Error'];
88
+ const GLOBAL_NEW = ['Map', 'Set', 'Error'];
89
+ const GLOBAL_VALUES = ['Infinity', 'NaN', 'undefined'];
90
+ const HOMIE_CALLS = ['defineRules', 'defineMove'];
91
+
92
+ /** What a refused global is, in a few words, with what to use instead. */
93
+ const GLOBAL_WORDS = {
94
+ Date: 'rules have no wall clock: use world.tick and world.ticks(seconds)',
95
+ JSON: 'rules pack nothing themselves: declared fields are packed for them',
96
+ Promise: 'a handler finishes inside its tick: nothing in rules waits',
97
+ setTimeout: 'use world.after(ticks, event, data)', setInterval: 'use world.after(ticks, event, data)', queueMicrotask: 'a handler finishes inside its tick',
98
+ fetch: 'rules reach nothing outside their room', crypto: 'use world.random()', performance: 'use world.tick',
99
+ Symbol: 'rules name things with plain strings', Proxy: 'rules hold plain data', Reflect: 'rules hold plain data', globalThis: 'rules are handed a world object and nothing else',
100
+ eval: 'nothing in rules is made from text', Function: 'nothing in rules is made from text', console: 'rules do not log: the runtime counts what goes wrong',
101
+ window: 'rules do not run in a page', document: 'rules do not run in a page', arguments: 'name the parameters, or use a rest parameter (...args)',
102
+ parseInt: 'use Number(x) and Math.trunc', parseFloat: 'use Number(x)', isNaN: 'use Number.isNaN(x)', isFinite: 'use Number.isFinite(x)',
103
+ };
104
+
105
+ /** A problem as the build prints it: `games/coin-dash/src/rules.ts:14 Math.sin is not available in rules: use world.math.sin`. */
106
+ export const problemLine = (p) => `${p.file}:${p.line} ${p.message}`;
107
+
108
+ function statementsOf(path) {
109
+ const p = path.parentPath;
110
+ return p && (p.isBlockStatement() || p.isProgram()) ? p : null;
111
+ }
112
+ const inFunction = (path) => Boolean(path.getFunctionParent());
113
+ /** The identifier an assignment target starts from (`a.b[c].d` is `a`), or null. */
114
+ function rootOf(node) {
115
+ let n = node;
116
+ while (t.isMemberExpression(n) || t.isOptionalMemberExpression(n)) n = n.object;
117
+ return t.isIdentifier(n) ? n : null;
118
+ }
119
+ /** The linker may number a second import of the guard (`__homie2`): every such name is the guard's. */
120
+ const isGuard = (node) => t.isIdentifier(node) && /^__homie\d*$/.test(node.name);
121
+ const isGuardCall = (node) => t.isCallExpression(node) && t.isMemberExpression(node.callee) && isGuard(node.callee.object);
122
+ /**
123
+ * Whether an expression is a number, true, false, null or undefined whatever runs: never a text, a list, an object or a
124
+ * function. An operator can use such a value for nothing (a list would be turned into a text first, a long text read end
125
+ * to end), so it needs no check; and `+` between two of them makes a number, never a text. A variable is one when it is
126
+ * declared with such a value and everything ever assigned to it is one too. `scope` is where the expression stands.
127
+ * After the rewrite has begun a name is judged by what was read of it before (`ids`), never by a scope that has since
128
+ * changed.
129
+ */
130
+ function num(n, scope, seen = new Set(), ids = null) {
131
+ if (!n) return true;
132
+ if (t.isNumericLiteral(n) || t.isBooleanLiteral(n) || t.isNullLiteral(n)) return true;
133
+ if (t.isUnaryExpression(n)) return ['-', '+', '~', '!', 'void'].includes(n.operator);
134
+ if (t.isUpdateExpression(n)) return true;
135
+ if (t.isBinaryExpression(n)) return n.operator !== '+' || (num(n.left, scope, seen, ids) && num(n.right, scope, seen, ids));
136
+ if (t.isLogicalExpression(n)) return num(n.left, scope, seen, ids) && num(n.right, scope, seen, ids);
137
+ if (t.isConditionalExpression(n)) return num(n.consequent, scope, seen, ids) && num(n.alternate, scope, seen, ids);
138
+ if (t.isParenthesizedExpression(n)) return num(n.expression, scope, seen, ids);
139
+ if (t.isSequenceExpression(n)) return num(n.expressions[n.expressions.length - 1], scope, seen, ids);
140
+ if (t.isAssignmentExpression(n)) return n.operator === '=' ? num(n.right, scope, seen, ids) : n.operator === '+=' || LOGICAL_ASSIGN.includes(n.operator) ? num(n.left, scope, seen, ids) && num(n.right, scope, seen, ids) : true;
141
+ if (isGuardCall(n)) return n.callee.property.name === 'eq';
142
+ if (t.isCallExpression(n)) {
143
+ const c = n.callee;
144
+ if (t.isIdentifier(c) && ['Number', 'Boolean'].includes(c.name) && !scope.getBinding(c.name)) return true;
145
+ return t.isMemberExpression(c) && !c.computed && t.isIdentifier(c.object, { name: 'Math' }) && !scope.getBinding('Math');
146
+ }
147
+ if (t.isIdentifier(n)) {
148
+ if (ids) return ids.has(n);
149
+ const b = scope.getBinding(n.name);
150
+ if (!b) return ['undefined', 'NaN', 'Infinity'].includes(n.name);
151
+ return numBinding(b, seen);
152
+ }
153
+ return false;
154
+ }
155
+ function numBinding(b, seen) {
156
+ if (seen.has(b)) return true;
157
+ seen.add(b);
158
+ if (!['let', 'const', 'var'].includes(b.kind) || !b.path.isVariableDeclarator() || !t.isIdentifier(b.path.node.id)) return false;
159
+ const loop = b.path.parentPath.parentPath;
160
+ // A loop's own variable is whatever the loop hands it: a key (a text) or an entry (anything).
161
+ if (loop.isForXStatement() && loop.node.left === b.path.parent) return false;
162
+ if (!num(b.path.node.init, b.path.scope, seen)) return false;
163
+ for (const v of b.constantViolations) {
164
+ const n = v.node;
165
+ if (t.isUpdateExpression(n) && t.isIdentifier(n.argument)) continue;
166
+ if (!(t.isAssignmentExpression(n) && t.isIdentifier(n.left))) return false;
167
+ if ((n.operator === '=' || n.operator === '+=' || LOGICAL_ASSIGN.includes(n.operator)) && !num(n.right, v.scope, seen)) return false;
168
+ }
169
+ return true;
170
+ }
171
+ /** An operand that needs no check: a number (above), a short text written out, or what the guard's `p` has already checked. */
172
+ const light = (n, scope, ids = null) => num(n, scope, new Set(), ids) || (t.isStringLiteral(n) && n.value.length < SHORT) || (isGuardCall(n) && n.callee.property.name === 'p');
173
+ /** One side of `===` that makes the comparison cheap whatever the other is: a number, a short text written out, or what `typeof` says. */
174
+ const cheapSide = (n, scope, ids = null) => num(n, scope, new Set(), ids) || (t.isStringLiteral(n) && n.value.length < SHORT) || t.isUnaryExpression(n, { operator: 'typeof' });
175
+ /** Whether a pattern takes a rest out of the value it is handed: `[first, ...others]`, `{ a, ...others }`. */
176
+ const hasRest = (pat) => (t.isArrayPattern(pat) && pat.elements.some((e) => t.isRestElement(e))) || (t.isObjectPattern(pat) && pat.properties.some((e) => t.isRestElement(e)));
177
+ /** Whether a member expression is written to: the left of an assignment, the operand of `++`, a loop's variable, or a place in a pattern. */
178
+ const isTarget = (path) => (path.parentPath.isAssignmentExpression() && path.parent.left === path.node) || (path.parentPath.isUpdateExpression()) || (path.parentPath.isForXStatement() && path.parent.left === path.node)
179
+ || path.parentPath.isArrayPattern() || (path.parentPath.isObjectProperty() && path.parentPath.parentPath.isObjectPattern()) || path.parentPath.isRestElement() || (path.parentPath.isAssignmentPattern() && path.parent.left === path.node);
180
+
181
+ /**
182
+ * One file of rules, checked and (when it has no problem) rewritten.
183
+ * code the file as JavaScript (types already removed)
184
+ * file what a problem calls it (`games/coin-dash/src/rules.ts`)
185
+ * map the type-removal's source map, so lines are the author's
186
+ * linked this is the linked module (step 4): top-level `var` and the guard's own names are expected,
187
+ * and nothing is rewritten
188
+ * Returns { code, problems }.
189
+ */
190
+ export function guardSource(code, { file = 'rules.js', map = null, linked = false } = {}) {
191
+ const problems = [];
192
+ const tracer = map ? new TraceMap(typeof map === 'string' ? JSON.parse(map) : map) : null;
193
+ const lineOf = (node) => {
194
+ const at = node?.loc?.start;
195
+ if (!at) return 0;
196
+ if (!tracer) return at.line;
197
+ const o = originalPositionFor(tracer, { line: at.line, column: at.column });
198
+ return o && o.line ? o.line : at.line;
199
+ };
200
+ const bad = (node, message) => { if (problems.length < 60 && !problems.some((p) => p.line === lineOf(node) && p.message === message)) problems.push({ file, line: lineOf(node), message }); };
201
+ let ast;
202
+ try { ast = parse(code, { sourceType: 'module', sourceFilename: file }); } catch (error) {
203
+ const said = String(error?.message ?? error).split('\n')[0];
204
+ // `await` outside an async function does not parse at all: said as what it is.
205
+ return { code: null, problems: [{ file, line: (() => { const at = error?.loc; if (!at || !tracer) return at?.line ?? 0; const o = originalPositionFor(tracer, { line: at.line, column: at.column }); return o?.line || at.line; })(), message: /'await'/.test(said) ? 'await is refused: a handler finishes inside its tick' : `this file does not parse: ${said}` }] };
206
+ }
207
+ const homie = new Set(); // names imported from Homie's rules module
208
+ const own = new Set(); // names imported from the game's own files
209
+
210
+ /* ------------------------------------------------------------ what the top level of a rules file may hold */
211
+ const loadSafe = (n, top = false) => {
212
+ if (!n) return true;
213
+ if (t.isNumericLiteral(n) || t.isStringLiteral(n) || t.isBooleanLiteral(n) || t.isNullLiteral(n)) return true;
214
+ if (t.isFunctionExpression(n) || t.isArrowFunctionExpression(n)) return true;
215
+ if (t.isIdentifier(n)) return true;
216
+ if (t.isTemplateLiteral(n)) return n.expressions.every((e) => loadSafe(e));
217
+ if (t.isUnaryExpression(n)) return loadSafe(n.argument);
218
+ if (t.isBinaryExpression(n) || t.isLogicalExpression(n)) return loadSafe(n.left) && loadSafe(n.right);
219
+ if (t.isConditionalExpression(n)) return loadSafe(n.test) && loadSafe(n.consequent) && loadSafe(n.alternate);
220
+ if (t.isParenthesizedExpression(n)) return loadSafe(n.expression);
221
+ if (t.isArrayExpression(n)) return n.elements.every((e) => e === null || (t.isSpreadElement(e) ? loadSafe(e.argument) : loadSafe(e)));
222
+ if (t.isObjectExpression(n)) return n.properties.every((p) => (t.isSpreadElement(p) ? loadSafe(p.argument) : t.isObjectMethod(p) ? true : loadSafe(p.value) && (!p.computed || loadSafe(p.key))));
223
+ if (t.isMemberExpression(n)) return loadSafe(n.object) && (!n.computed || loadSafe(n.property));
224
+ if (t.isCallExpression(n)) {
225
+ const c = n.callee;
226
+ const homieCall = (t.isIdentifier(c) && homie.has(c.name) && (HOMIE_CALLS.includes(c.name) || linked)) || (t.isMemberExpression(c) && !c.computed && t.isIdentifier(c.object) && homie.has(c.object.name));
227
+ const guardCall = linked && isGuardCall(n);
228
+ if (!homieCall && !guardCall) { bad(n, 'nothing of the game\'s runs when a rules file loads: the top level holds imports, constants made of literals, functions, and calls to defineRules, defineMove and f. Move this call into a handler'); return false; }
229
+ return n.arguments.every((a) => (t.isSpreadElement(a) ? loadSafe(a.argument) : loadSafe(a)));
230
+ }
231
+ if (top) bad(n, 'a module-level constant is made of literals: numbers, texts, lists and objects of them');
232
+ return false;
233
+ };
234
+ let defaultName = null;
235
+ for (const st of ast.program.body) if (t.isExportNamedDeclaration(st) && !st.declaration) for (const sp of st.specifiers) if (t.isExportSpecifier(sp) && (sp.exported.name ?? sp.exported.value) === 'default') defaultName = sp.local.name;
236
+ for (const st of ast.program.body) {
237
+ if (t.isImportDeclaration(st)) {
238
+ const from = st.source.value;
239
+ if (from === RULES_MODULE || (linked && from === GUARD_MODULE)) { for (const s of st.specifiers) homie.add(s.local.name); continue; }
240
+ if (/^\.\.?\//.test(from) && !linked) { for (const s of st.specifiers) own.add(s.local.name); continue; }
241
+ bad(st, `rules import only ${RULES_MODULE} and the game's own files, not "${from}"`);
242
+ continue;
243
+ }
244
+ const decl = t.isExportNamedDeclaration(st) || t.isExportDefaultDeclaration(st) ? st.declaration : st;
245
+ if (t.isExportAllDeclaration(st) || (t.isExportNamedDeclaration(st) && !st.declaration)) { if (st.source) bad(st, 'rules export their own declarations, not another file\'s'); continue; }
246
+ if (t.isFunctionDeclaration(decl)) continue;
247
+ if (t.isVariableDeclaration(decl)) {
248
+ // Removing types turns `export default <expression>` into a `var` that is exported as the default: that one is the module's own.
249
+ const isDefault = decl.kind === 'var' && decl.declarations.length === 1 && t.isIdentifier(decl.declarations[0].id) && defaultName === decl.declarations[0].id.name;
250
+ if (decl.kind !== 'const' && !isDefault && !(linked && decl.kind === 'var')) { bad(decl, `a rules module has no state of its own: "${decl.kind}" at module level is refused. Put this in a declared field (fields, motion or shared), or make it a const`); continue; }
251
+ for (const d of decl.declarations) if (!loadSafe(d.init, true) && !problems.length) bad(d, 'a module-level constant is made of literals: numbers, texts, lists and objects of them');
252
+ continue;
253
+ }
254
+ if (t.isExportDefaultDeclaration(st)) { if (!loadSafe(st.declaration, true) && !problems.length) bad(st, 'a rules module is `export default defineRules({ … })`'); continue; }
255
+ bad(st, 'nothing of the game\'s runs when a rules file loads: the top level holds imports, constants, functions and the defineRules / defineMove calls');
256
+ }
257
+
258
+ /* ------------------------------------------------------------ the check */
259
+ const moduleBinding = (path, name) => { const b = path.scope.getBinding(name); return Boolean(b && b.scope.block === ast.program); };
260
+ const checkName = (node, name) => {
261
+ if (typeof name !== 'string') return;
262
+ if (refusedName(name)) bad(node, `"${name}" is a name rules may not use: it reaches the machinery under a value, not the value`);
263
+ };
264
+ const checkGlobal = (path) => {
265
+ const name = path.node.name;
266
+ const parent = path.parent;
267
+ if (GLOBAL_VALUES.includes(name)) return;
268
+ const asObject = (t.isMemberExpression(parent) || t.isOptionalMemberExpression(parent)) && parent.object === path.node;
269
+ if (GLOBAL_MEMBERS[name]) {
270
+ const spec = GLOBAL_MEMBERS[name];
271
+ const prop = asObject && !parent.computed && t.isIdentifier(parent.property) ? parent.property.name : null;
272
+ const called = prop && t.isCallExpression(path.parentPath.parent) && path.parentPath.parent.callee === parent;
273
+ if (prop && spec.read.includes(prop) && !called) return;
274
+ if (prop && spec.call.includes(prop) && called) return;
275
+ if (name === 'Number' && t.isCallExpression(parent) && parent.callee === path.node) return;
276
+ if (name === 'Math' && prop && MATH_METHODS.includes(prop)) return bad(parent, `Math.${prop} is not available in rules, because browsers and servers may disagree on its last digits: use world.math.${prop} (ctx.math.${prop} in move.ts)`);
277
+ if (prop && spec.call.includes(prop)) return bad(parent, `${name}.${prop} may be called, never read as a value`);
278
+ return bad(parent, `${name}${prop ? `.${prop}` : ''} is not available in rules (${name} offers ${[...spec.call, ...spec.read].join(', ')})`);
279
+ }
280
+ if (t.isCallExpression(parent) && parent.callee === path.node && GLOBAL_CALLS.includes(name)) return;
281
+ if (t.isNewExpression(parent) && parent.callee === path.node && GLOBAL_NEW.includes(name)) {
282
+ if (name !== 'Error' && !inFunction(path)) bad(parent, `a ${name} is made inside a handler only: a rules module has no state of its own`);
283
+ return;
284
+ }
285
+ if (GLOBAL_CALLS.includes(name) || GLOBAL_NEW.includes(name)) return bad(path.node, `${name} is used in rules only as ${GLOBAL_NEW.includes(name) ? `new ${name}(…)` : `${name}(x)`}`);
286
+ bad(path.node, `${name} is not available in rules${GLOBAL_WORDS[name] ? `: ${GLOBAL_WORDS[name]}` : ''}`);
287
+ };
288
+ const checkWrite = (path, target) => {
289
+ if (t.isObjectPattern(target) || t.isArrayPattern(target)) return;
290
+ const root = rootOf(target);
291
+ if (!root) return;
292
+ if (!path.scope.getBinding(root.name)) return bad(target, `${root.name} is not the game's to change`);
293
+ if (moduleBinding(path, root.name) && !linked) bad(target, `a rules module has no state of its own: "${root.name}" is declared at module level, so nothing may assign to it or to anything in it. Put what changes in a declared field`);
294
+ if ((t.isMemberExpression(target) && !target.computed && t.isIdentifier(target.property, { name: 'length' }))) bad(target, 'a list\'s length is not set by hand: use push, pop or splice');
295
+ };
296
+ const chainTop = (path) => !(path.parentPath.isOptionalMemberExpression() || path.parentPath.isOptionalCallExpression()) || (path.parent.object !== path.node && path.parent.callee !== path.node);
297
+ const refuseSyntax = {
298
+ ImportExpression: 'import() is refused: rules are one module, loaded once',
299
+ MetaProperty: 'import.meta and new.target are refused in rules',
300
+ AwaitExpression: 'await is refused: a handler finishes inside its tick',
301
+ YieldExpression: 'generators are refused: a handler finishes inside its tick',
302
+ ClassDeclaration: 'class is refused in rules: state is declared fields, behaviour is handlers',
303
+ ClassExpression: 'class is refused in rules: state is declared fields, behaviour is handlers',
304
+ ThisExpression: '`this` is refused in rules: a handler is handed world and self',
305
+ TryStatement: 'try, catch and finally are refused in rules: the runtime catches what a handler throws',
306
+ RegExpLiteral: 'regular expressions are refused in rules: use the text methods (includes, startsWith, split…)',
307
+ WithStatement: '`with` is refused in rules',
308
+ TaggedTemplateExpression: 'tagged templates are refused in rules',
309
+ DebuggerStatement: '`debugger` is refused in rules',
310
+ Super: '`super` is refused in rules',
311
+ BigIntLiteral: 'a whole number of any size (10n) is refused in rules: it grows without limit. Use a plain number',
312
+ };
313
+ const hookName = (node) => { const name = node.computed ? null : node.key.name ?? node.key.value; return typeof name === 'string' && HOOK_NAMES.includes(name) ? name : null; };
314
+ const nullish = (n, path) => t.isNullLiteral(n) || (t.isIdentifier(n, { name: 'undefined' }) && !path.scope.getBinding('undefined'));
315
+ const member = (path) => {
316
+ const n = path.node;
317
+ if (!n.computed) checkName(n, n.property.name);
318
+ else if (t.isStringLiteral(n.property)) checkName(n, n.property.value);
319
+ if (linked && n.computed && !t.isNumericLiteral(n.property)) bad(n, 'the linked module holds a computed key the guard did not rewrite');
320
+ if (path.isOptionalMemberExpression() && !chainTop(path) && (n.computed || CALLABLE.has(n.property.name)) && !linked) bad(n, 'this optional chain is too long for the guard to follow: take the first step into a const, then go on from it');
321
+ };
322
+ const visitor = {};
323
+ for (const [type, message] of Object.entries(refuseSyntax)) visitor[type] = (path) => bad(path.node, message);
324
+ Object.assign(visitor, {
325
+ Function(path) {
326
+ if (path.node.async) bad(path.node, 'async is refused: a handler finishes inside its tick');
327
+ if (path.node.generator) bad(path.node, 'generators are refused: a handler finishes inside its tick');
328
+ },
329
+ ObjectMethod(path) {
330
+ if (path.node.kind !== 'method') bad(path.node, 'getters and setters are refused in rules');
331
+ checkName(path.node, !path.node.computed && (path.node.key.name ?? path.node.key.value));
332
+ if (hookName(path.node)) bad(path.node, `a function named "${hookName(path.node)}" is refused in rules: JavaScript would call it by itself whenever the object is used as a number or a text, outside any handler`);
333
+ },
334
+ ForOfStatement(path) { if (path.node.await) bad(path.node, 'await is refused: a handler finishes inside its tick'); if (!t.isVariableDeclaration(path.node.left)) checkWrite(path, path.node.left); },
335
+ ForInStatement(path) { if (!t.isVariableDeclaration(path.node.left)) checkWrite(path, path.node.left); },
336
+ // A rest in a pattern copies every entry or key that is left, at one go and uncounted. It is allowed where the guard
337
+ // can charge for the copy first: in a declaration or an assignment of its own, with the value written beside it.
338
+ RestElement(path) {
339
+ const pat = path.parentPath;
340
+ if (linked || !(pat.isArrayPattern() || pat.isObjectPattern())) return; // a function's own rest parameter is filled by its caller, who is charged
341
+ const owner = pat.parentPath;
342
+ const declared = owner.isVariableDeclarator() && owner.node.id === pat.node && Boolean(owner.node.init);
343
+ const assigned = owner.isAssignmentExpression({ operator: '=' }) && owner.node.left === pat.node;
344
+ if (!declared && !assigned) bad(path.node, 'take the rest (...) out of a value in a declaration of its own, `const [first, ...others] = list`, not in a parameter, a loop\'s head or inside another pattern');
345
+ },
346
+ // Nothing in rules passes the size cap, a constant written out in full no more than a list built in a loop.
347
+ ArrayExpression(path) { if (path.node.elements.length > SIZE_CAP) bad(path.node, `a list holds ${SIZE_CAP} entries at most: this one is written out with ${path.node.elements.length}`); },
348
+ ObjectExpression(path) { if (path.node.properties.length > SIZE_CAP) bad(path.node, `an object holds ${SIZE_CAP} keys at most: this one is written out with ${path.node.properties.length}`); },
349
+ StringLiteral(path) { if (path.node.value.length > SIZE_CAP) bad(path.node, `a text holds ${SIZE_CAP} characters at most`); },
350
+ TemplateElement(path) { if ((path.node.value.cooked ?? path.node.value.raw).length > SIZE_CAP) bad(path.node, `a text holds ${SIZE_CAP} characters at most`); },
351
+ BinaryExpression(path) {
352
+ const n = path.node;
353
+ if (n.operator === '**') bad(n, 'the ** operator is refused, because browsers and servers may disagree on its last digits: use world.math.pow (ctx.math.pow in move.ts)');
354
+ if ((n.operator === '==' || n.operator === '!=') && !nullish(n.left, path) && !nullish(n.right, path)) bad(n, `the ${n.operator} operator is refused in rules: use ${n.operator}= (a loose comparison turns a list into a text to compare it). \`x ${n.operator} null\` is allowed`);
355
+ },
356
+ UnaryExpression(path) { if (path.node.operator === 'delete') bad(path.node, 'delete is refused in rules: a declared map drops a key when its value is set through the map\'s own field'); },
357
+ AssignmentExpression(path) {
358
+ if (path.node.operator === '**=') bad(path.node, 'the **= operator is refused: use world.math.pow');
359
+ checkWrite(path, path.node.left);
360
+ },
361
+ UpdateExpression(path) { checkWrite(path, path.node.argument); },
362
+ Identifier(path) {
363
+ const name = path.node.name;
364
+ if (name.startsWith(G) && !linked) return bad(path.node, `names beginning ${G} are the runtime's own`);
365
+ if (!path.isReferencedIdentifier()) return;
366
+ if (name === 'arguments' || !path.scope.getBinding(name)) { if (!(linked && isGuard(path.node))) checkGlobal(path); }
367
+ },
368
+ MemberExpression(path) { member(path); },
369
+ OptionalMemberExpression(path) { member(path); },
370
+ 'ObjectProperty'(path) {
371
+ const n = path.node;
372
+ if (!n.computed) checkName(n, n.key.name ?? n.key.value);
373
+ if (path.parentPath.isObjectExpression() && hookName(n) && t.isFunction(n.value)) bad(n, `a function named "${hookName(n)}" is refused in rules: JavaScript would call it by itself whenever the object is used as a number or a text, outside any handler`);
374
+ },
375
+ CallExpression(path) {
376
+ const c = path.node.callee;
377
+ if (!t.isMemberExpression(c) || c.computed || linked) return;
378
+ const name = c.property.name;
379
+ const obj = c.object;
380
+ if (t.isIdentifier(obj) && !path.scope.getBinding(obj.name)) return; // a global: checkGlobal has it
381
+ if (t.isIdentifier(obj) && homie.has(obj.name) && !inFunction(path)) return; // f.u16() at the top level
382
+ if (!CALLABLE.has(name) && !refusedName(name)) bad(path.node, `"${name}" is not a method rules may call. On a list: ${ARRAY_METHODS.slice(0, 8).join(', ')}…; on a text: ${STRING_METHODS.slice(0, 5).join(', ')}…; on a Map or Set: ${MAPSET_METHODS.slice(0, 5).join(', ')}…; and what world, ctx and world.math offer`);
383
+ },
384
+ OptionalCallExpression(path) {
385
+ const c = path.node.callee;
386
+ if (linked || t.isIdentifier(c)) return;
387
+ if (!(t.isOptionalMemberExpression(c) && !c.computed && c.optional && !path.node.optional && chainTop(path))) return bad(path.node, 'this optional call is too involved for the guard to follow: test for the value first, then call');
388
+ if (!CALLABLE.has(c.property.name)) bad(path.node, `"${c.property.name}" is not a method rules may call`);
389
+ },
390
+ NewExpression(path) {
391
+ const c = path.node.callee;
392
+ if (!(t.isIdentifier(c) && GLOBAL_NEW.includes(c.name) && !path.scope.getBinding(c.name))) bad(path.node, 'rules make new values only with new Map(), new Set() and new Error(message)');
393
+ },
394
+ ObjectPattern(path) {
395
+ if (linked) return;
396
+ // A pattern may not take a method out of a value. In a declaration the guard checks each such name once it is bound.
397
+ const decl = path.parentPath.isVariableDeclarator() && path.parentPath.parentPath.isVariableDeclaration() && statementsOf(path.parentPath.parentPath);
398
+ for (const p of path.node.properties) {
399
+ if (t.isRestElement(p)) continue;
400
+ const risky = p.computed || CALLABLE.has(p.key.name ?? p.key.value);
401
+ if (risky && !(decl && t.isIdentifier(p.value))) bad(p, `take "${p.computed ? 'a computed key' : p.key.name ?? p.key.value}" out with a plain \`const { … } = value\` declaration, or read it with a dot: a pattern here could take a method out of a value`);
402
+ }
403
+ },
404
+ });
405
+ traverse(ast, visitor);
406
+ if (problems.length || linked) {
407
+ if (linked && !problems.length) verifyLinked(ast, bad);
408
+ return { code: linked && !problems.length ? code : null, problems };
409
+ }
410
+
411
+ /* ------------------------------------------------------------ which names always hold a number, read before anything is rewritten */
412
+ const numIds = new WeakSet();
413
+ // Every place a name stands, as a value read or as the target of an assignment.
414
+ traverse(ast, { Identifier(path) { if ((path.isReferencedIdentifier() || path.parentPath.isAssignmentExpression() || path.parentPath.isUpdateExpression()) && num(path.node, path.scope)) numIds.add(path.node); } });
415
+ /** At rewrite time: an operand that needs no check. A name is judged by the reading above; everything else by its shape. */
416
+ const isNum = (n, scope) => num(n, scope, new Set(), numIds);
417
+
418
+ /* ------------------------------------------------------------ the rewrite */
419
+ const line = (node) => t.numericLiteral(lineOf(node));
420
+ const call = (fn, args) => { const c = t.callExpression(t.memberExpression(t.identifier(G), t.identifier(fn)), args); c._homie = true; return c; };
421
+ const counted = () => t.expressionStatement(call('t', []));
422
+ const block = (path, key) => {
423
+ const body = path.node[key];
424
+ if (!t.isBlockStatement(body)) path.node[key] = t.blockStatement(t.isExpression(body) ? [t.returnStatement(body)] : [body]);
425
+ path.node[key].body.unshift(counted());
426
+ };
427
+ const args = (list) => t.arrayExpression(list.map((a) => (t.isSpreadElement(a) ? t.spreadElement(call('sp', [a.argument, line(a)])) : a)));
428
+ const globalObject = (path, obj) => t.isIdentifier(obj) && !path.scope.getBinding(obj.name);
429
+ const frozen = new Set();
430
+ const computedKey = (path) => { if (path.node.computed && !t.isNumericLiteral(path.node.key) && !isGuardCall(path.node.key)) path.node.key = call('k', [path.node.key, line(path.node)]); };
431
+ /** An operand, checked to be a number or a text unless the pass can see that it is. */
432
+ const P = (n, at, scope) => (light(n, scope, numIds) ? n : call('p', [n, line(at ?? n)]));
433
+ /** A write's target as it reads: the same place, without the write's own check. `a.b` and names only, so reading it twice runs nothing twice. */
434
+ const unW = (x) => (t.isMemberExpression(x) && isGuardCall(x.object) && x.object.callee.property.name === 'w' ? t.memberExpression(x.object.arguments[0], x.property) : x);
435
+ const plainTarget = (x) => t.isIdentifier(x) || (t.isMemberExpression(unW(x)) && !x.computed && plainTarget(unW(x).object));
436
+ const readOf = (x) => t.cloneNode(unW(x), true);
437
+ const TWICE = 'this changes a place that is reached through a call or a computed key: take the object into a const first, then change its field';
438
+ traverse(ast, {
439
+ Loop(path) { block(path, 'body'); },
440
+ // `for (k in o)` lists every key of `o` before its first turn: the guard charges a constant's first.
441
+ ForInStatement: { exit(path) { if (!isGuardCall(path.node.right)) path.node.right = call('fi', [path.node.right, line(path.node)]); } },
442
+ Function(path) { if (!path.node._homie) block(path, 'body'); },
443
+ VariableDeclarator: {
444
+ // `const [first, ...others] = list`: the copy the rest makes is charged, and capped, before it is made.
445
+ exit(path) { const d = path.node; if (hasRest(d.id) && d.init && !isGuardCall(d.init)) d.init = call('sp', [d.init, line(d)]); },
446
+ },
447
+ VariableDeclaration: {
448
+ exit(path) {
449
+ const list = statementsOf(path);
450
+ // Module-level constants are frozen all the way down at load.
451
+ if (path.parentPath.isProgram() || (path.parentPath.isExportNamedDeclaration() && path.parentPath.parentPath.isProgram())) {
452
+ for (const d of path.node.declarations) if ((t.isArrayExpression(d.init) || t.isObjectExpression(d.init)) && !frozen.has(d)) { frozen.add(d); d.init = call('deepFreeze', [d.init]); }
453
+ return;
454
+ }
455
+ if (!list) return;
456
+ const checks = [];
457
+ for (const d of path.node.declarations) {
458
+ if (!t.isObjectPattern(d.id)) continue;
459
+ for (const p of d.id.properties) if (!t.isRestElement(p) && t.isIdentifier(p.value) && (p.computed || CALLABLE.has(p.key.name ?? p.key.value))) checks.push(t.expressionStatement(call('nf', [t.identifier(p.value.name), line(p)])));
460
+ }
461
+ if (checks.length) path.insertAfter(checks);
462
+ },
463
+ },
464
+ ObjectProperty: {
465
+ exit(path) {
466
+ computedKey(path);
467
+ // A value under `valueOf`, `toString` or `toJSON` that the pass cannot see is not a function is checked when it is made.
468
+ const n = path.node;
469
+ if (path.parentPath.isObjectExpression() && hookName(n) && !isNum(n.value, path.scope) && !t.isStringLiteral(n.value) && !isGuardCall(n.value)) { n.value = call('nh', [n.value, line(n)]); n.shorthand = false; }
470
+ },
471
+ },
472
+ ObjectMethod: { exit(path) { computedKey(path); } },
473
+ SpreadElement: {
474
+ exit(path) { if (path.parentPath.isObjectExpression() && !isGuardCall(path.node.argument)) path.node.argument = call('sp', [path.node.argument, line(path.node)]); },
475
+ },
476
+ ArrayExpression: {
477
+ exit(path) {
478
+ if (path.node._homie || !path.node.elements.some((e) => t.isSpreadElement(e))) return;
479
+ const a = args(path.node.elements.filter((e) => e !== null));
480
+ a._homie = true;
481
+ path.replaceWith(call('lit', [a, line(path.node)]));
482
+ path.skip();
483
+ },
484
+ },
485
+ TemplateLiteral: {
486
+ exit(path) {
487
+ if (!path.node.expressions.length || path.node._homie || path.parentPath.isTaggedTemplateExpression()) return;
488
+ path.node._homie = true;
489
+ path.node.expressions = path.node.expressions.map((e) => P(e, path.node, path.scope));
490
+ path.replaceWith(call('tpl', [path.node, line(path.node)]));
491
+ path.skip();
492
+ },
493
+ },
494
+ BinaryExpression: {
495
+ exit(path) {
496
+ const n = path.node;
497
+ if (CONVERTING.has(n.operator) || (n.operator === '+' && (isNum(n.left, path.scope) || isNum(n.right, path.scope)))) { n.left = P(n.left, n, path.scope); n.right = P(n.right, n, path.scope); return; }
498
+ if (n.operator === 'in') { n.left = P(n.left, n, path.scope); return; }
499
+ // `a === b` between two values that may both be long texts is charged for what it compares.
500
+ if (n.operator === '===' || n.operator === '!==') {
501
+ if (cheapSide(n.left, path.scope, numIds) || cheapSide(n.right, path.scope, numIds)) return;
502
+ const same = call('eq', [n.left, n.right]);
503
+ path.replaceWith(n.operator === '===' ? same : t.unaryExpression('!', same));
504
+ path.skip();
505
+ return;
506
+ }
507
+ if (n.operator !== '+') return;
508
+ path.replaceWith(call('add', [n.left, n.right, line(n)]));
509
+ path.skip();
510
+ },
511
+ },
512
+ UnaryExpression: {
513
+ exit(path) { if (['-', '+', '~'].includes(path.node.operator)) path.node.argument = P(path.node.argument, path.node, path.scope); },
514
+ },
515
+ SwitchStatement: {
516
+ exit(path) { if (!isGuardCall(path.node.discriminant)) path.node.discriminant = call('sw', [path.node.discriminant, t.numericLiteral(path.node.cases.length)]); },
517
+ },
518
+ NewExpression: {
519
+ exit(path) {
520
+ const name = path.node.callee.name;
521
+ // `new Error(message)` is made by the guard: charged, and with nothing recorded about where.
522
+ if (name === 'Error') { path.replaceWith(call('err', path.node.arguments.slice(0, 1).map((a) => (t.isSpreadElement(a) ? a : P(a, path.node, path.scope))))); path.skip(); return; }
523
+ if (name !== 'Map' && name !== 'Set') return;
524
+ path.replaceWith(call(name === 'Map' ? 'map' : 'set', path.node.arguments));
525
+ path.skip();
526
+ },
527
+ },
528
+ AssignmentExpression: {
529
+ exit(path) {
530
+ const n = path.node;
531
+ const left = n.left;
532
+ const op = n.operator.slice(0, -1);
533
+ const logical = ['||', '&&', '??'].includes(op);
534
+ if (t.isMemberExpression(left) && left.computed && !t.isNumericLiteral(left.property)) {
535
+ if (n.operator === '=') { path.replaceWith(call('s', [left.object, left.property, n.right, line(n)])); path.skip(); return; }
536
+ const old = t.identifier(`${G}V`);
537
+ const next = logical ? t.logicalExpression(op, old, n.right) : op === '+' && !isNum(n.right, path.scope) ? call('add', [old, n.right, line(n)]) : t.binaryExpression(op, call('p', [old, line(n)]), P(n.right, n, path.scope));
538
+ const fn = t.arrowFunctionExpression([t.identifier(`${G}V`)], next);
539
+ fn._homie = true;
540
+ path.replaceWith(call('u', [left.object, left.property, fn, t.booleanLiteral(false), line(n)]));
541
+ path.skip();
542
+ return;
543
+ }
544
+ if (n.operator === '=') {
545
+ if (hasRest(left) && !isGuardCall(n.right)) n.right = call('sp', [n.right, line(n)]);
546
+ // `o.toString = v`: never a function under a name JavaScript calls by itself.
547
+ if (t.isMemberExpression(left) && !left.computed && HOOK_NAMES.includes(left.property.name) && !isNum(n.right, path.scope) && !t.isStringLiteral(n.right)) n.right = call('nh', [n.right, line(n)]);
548
+ return;
549
+ }
550
+ if (logical || t.isObjectPattern(left) || t.isArrayPattern(left)) return;
551
+ // `x op= v` on a name the pass knows always holds a number: only the right side needs its check.
552
+ if (t.isIdentifier(left) && numIds.has(left)) { n.right = P(n.right, n, path.scope); return; }
553
+ // Everything else is written out as `x = x op v`, so the old value is checked (and a text made by `+` is charged and
554
+ // capped). The target is then read twice, so it must be a plain name or a dotted one.
555
+ if (!plainTarget(left)) { bad(n, TWICE); return; }
556
+ const next = op === '+' && !isNum(n.right, path.scope) ? call('add', [readOf(left), n.right, line(n)]) : t.binaryExpression(op, call('p', [readOf(left), line(n)]), P(n.right, n, path.scope));
557
+ path.replaceWith(t.assignmentExpression('=', left, next));
558
+ path.skip();
559
+ },
560
+ },
561
+ UpdateExpression: {
562
+ exit(path) {
563
+ const a = path.node.argument;
564
+ if (t.isMemberExpression(a) && a.computed && !t.isNumericLiteral(a.property)) {
565
+ const fn = t.arrowFunctionExpression([t.identifier(`${G}V`)], t.binaryExpression(path.node.operator === '++' ? '+' : '-', call('p', [t.identifier(`${G}V`), line(path.node)]), t.numericLiteral(1)));
566
+ fn._homie = true;
567
+ path.replaceWith(call('u', [a.object, a.property, fn, t.booleanLiteral(!path.node.prefix), line(path.node)]));
568
+ path.skip();
569
+ return;
570
+ }
571
+ if (path.node._homie || (t.isIdentifier(a) && numIds.has(a))) return;
572
+ // `x++` on anything else: the old value is checked first, then the update runs as written.
573
+ if (!plainTarget(a)) { bad(path.node, TWICE); return; }
574
+ path.node._homie = true;
575
+ path.replaceWith(t.sequenceExpression([call('p', [readOf(a), line(path.node)]), path.node]));
576
+ path.skip();
577
+ },
578
+ },
579
+ CallExpression: {
580
+ exit(path) {
581
+ const n = path.node;
582
+ if (n._homie || isGuardCall(n)) return;
583
+ const c = n.callee;
584
+ if (t.isMemberExpression(c)) {
585
+ if (!c.computed && globalObject(path, c.object)) {
586
+ if (c.object.name === 'Object') { path.replaceWith(call(c.property.name, n.arguments)); path.skip(); }
587
+ // `Math.max(a, ...list)` turns every argument into a number: each is checked to be one.
588
+ if (c.object.name === 'Math') n.arguments = n.arguments.map((a) => (t.isSpreadElement(a) ? (isGuardCall(a.argument) ? a : t.spreadElement(call('spp', [a.argument, line(a)]))) : P(a, n, path.scope)));
589
+ return;
590
+ }
591
+ if (!inFunction(path)) return; // f.u16() and friends, at the top level
592
+ path.replaceWith(c.computed ? call('cc', [c.object, c.property, args(n.arguments), line(n)]) : call('c', [c.object, t.stringLiteral(c.property.name), args(n.arguments), line(n)]));
593
+ path.skip();
594
+ return;
595
+ }
596
+ for (let i = 0; i < n.arguments.length; i += 1) { const a = n.arguments[i]; if (t.isSpreadElement(a) && !isGuardCall(a.argument)) a.argument = call('sp', [a.argument, line(a)]); }
597
+ // `Number(x)`, `String(x)` and `Error(x)` turn their argument into a number or a text: it is checked to be one.
598
+ if (t.isIdentifier(c) && ['Number', 'String', 'Error'].includes(c.name) && !path.scope.getBinding(c.name)) {
599
+ n.arguments = n.arguments.map((a) => (t.isSpreadElement(a) ? a : P(a, n, path.scope)));
600
+ // `Error(x)` is `new Error(x)`. And the text `String(x)` makes is charged and capped like any other: a number is not written out for nothing.
601
+ if (c.name === 'Error') { path.replaceWith(call('err', n.arguments.slice(0, 1))); path.skip(); } else if (c.name === 'String') { n._homie = true; path.replaceWith(call('tpl', [n, line(n)])); path.skip(); }
602
+ }
603
+ },
604
+ },
605
+ OptionalCallExpression: {
606
+ exit(path) {
607
+ const c = path.node.callee;
608
+ if (!t.isOptionalMemberExpression(c)) return;
609
+ path.replaceWith(call('co', [c.object, t.stringLiteral(c.property.name), args(path.node.arguments), line(path.node)]));
610
+ path.skip();
611
+ },
612
+ },
613
+ MemberExpression: {
614
+ exit(path) {
615
+ const n = path.node;
616
+ if (isGuard(n.object)) return;
617
+ if ((path.parentPath.isCallExpression() || path.parentPath.isOptionalCallExpression()) && path.parent.callee === n) return;
618
+ if (isTarget(path)) {
619
+ if (n.computed && !t.isNumericLiteral(n.property) && !(path.parentPath.isAssignmentExpression() || path.parentPath.isUpdateExpression())) bad(n, 'assign to a computed key with a plain `a[k] = value`, not inside a pattern');
620
+ // A write lands on a list or an object, never on a function: nothing is kept on one, and no built-in is changed.
621
+ if (!(n.computed && !t.isNumericLiteral(n.property)) && !isGuardCall(n.object)) n.object = call('w', [n.object, line(n)]);
622
+ return;
623
+ }
624
+ if (n.computed) { if (t.isNumericLiteral(n.property)) return; path.replaceWith(call('g', [n.object, n.property, line(n)])); path.skip(); return; }
625
+ // A name that is also a method's (a list's, a text's, a Map's, world's, world.math's): the value, never the function.
626
+ if (CALLABLE.has(n.property.name) && !globalObject(path, n.object)) { path.replaceWith(call('rd', [n.object, t.stringLiteral(n.property.name), line(n)])); path.skip(); }
627
+ },
628
+ },
629
+ OptionalMemberExpression: {
630
+ exit(path) {
631
+ const n = path.node;
632
+ if (path.parentPath.isOptionalCallExpression() && path.parent.callee === n) return;
633
+ if (n.computed) { if (t.isNumericLiteral(n.property)) return; path.replaceWith(call('go', [n.object, n.property, line(n)])); path.skip(); return; }
634
+ if (CALLABLE.has(n.property.name)) { path.replaceWith(call('rdo', [n.object, t.stringLiteral(n.property.name), line(n)])); path.skip(); }
635
+ },
636
+ },
637
+ });
638
+ if (problems.length) return { code: null, problems };
639
+ ast.program.body.unshift(t.importDeclaration([t.importNamespaceSpecifier(t.identifier(G))], t.stringLiteral(GUARD_MODULE)));
640
+ return { code: generate(ast, { comments: false }).code, problems: [] };
641
+ }
642
+
643
+ /**
644
+ * Step 4: the deployed file holds no loop or function the guard did not count, no operator whose operand the guard
645
+ * did not check, and no write to a property the guard did not check. (Computed keys are checked while it is read.)
646
+ */
647
+ function verifyLinked(ast, bad) {
648
+ const counts = (body) => {
649
+ const first = t.isBlockStatement(body) ? body.body[0] : null;
650
+ return Boolean(first && t.isExpressionStatement(first) && isGuardCall(first.expression) && first.expression.callee.property.name === 't');
651
+ };
652
+ const OPERATOR = 'the linked module holds an operator whose operand the guard did not check';
653
+ const WRITE = 'the linked module holds a write to a property the guard did not check';
654
+ const ok = (n, path) => light(n, path.scope);
655
+ traverse(ast, {
656
+ Loop(path) { if (!counts(path.node.body)) bad(path.node, 'the linked module holds a loop the guard did not count'); },
657
+ ForInStatement(path) { if (!isGuardCall(path.node.right)) bad(path.node, 'the linked module holds a loop the guard did not count'); },
658
+ RestElement(path) {
659
+ const pat = path.parentPath;
660
+ if (!(pat.isArrayPattern() || pat.isObjectPattern())) return;
661
+ const owner = pat.parentPath;
662
+ const from = owner.isVariableDeclarator() && owner.node.id === pat.node ? owner.node.init : owner.isAssignmentExpression({ operator: '=' }) && owner.node.left === pat.node ? owner.node.right : null;
663
+ if (!from || !isGuardCall(from)) bad(path.node, 'the linked module holds a rest (...) in a pattern that the guard did not count');
664
+ },
665
+ Function(path) {
666
+ const own = path.node.params.length === 1 && t.isIdentifier(path.node.params[0]) && path.node.params[0].name.startsWith(G);
667
+ if (!own && !counts(path.node.body)) bad(path.node, 'the linked module holds a function the guard did not count');
668
+ },
669
+ BinaryExpression(path) {
670
+ const n = path.node;
671
+ if (CONVERTING.has(n.operator) || n.operator === '+') { if (!ok(n.left, path) || !ok(n.right, path)) bad(n, OPERATOR); } else if (n.operator === 'in' && !ok(n.left, path)) bad(n, OPERATOR);
672
+ else if ((n.operator === '===' || n.operator === '!==') && !cheapSide(n.left, path.scope) && !cheapSide(n.right, path.scope)) bad(n, OPERATOR);
673
+ },
674
+ SwitchStatement(path) { if (!isGuardCall(path.node.discriminant)) bad(path.node, OPERATOR); },
675
+ NewExpression(path) { if (t.isIdentifier(path.node.callee, { name: 'Error' })) bad(path.node, 'the linked module makes an Error the guard did not count'); },
676
+ CallExpression(path) {
677
+ const c = path.node.callee;
678
+ if (!t.isIdentifier(c) || path.scope.getBinding(c.name)) return;
679
+ if (c.name === 'Error') bad(path.node, 'the linked module makes an Error the guard did not count');
680
+ if (c.name === 'String' && !(isGuardCall(path.parent) && path.parent.callee.property.name === 'tpl')) bad(path.node, OPERATOR);
681
+ },
682
+ UnaryExpression(path) { if (['-', '+', '~'].includes(path.node.operator) && !ok(path.node.argument, path)) bad(path.node, OPERATOR); },
683
+ TemplateLiteral(path) { if (!path.node.expressions.every((e) => ok(e, path))) bad(path.node, OPERATOR); },
684
+ MemberExpression(path) { if (isTarget(path) && !isGuardCall(path.node.object)) bad(path.node, WRITE); },
685
+ UpdateExpression(path) {
686
+ const a = path.node.argument;
687
+ // Either a name that only ever holds a number or a text, or the update the guard wrote: `(__homie.p(x), x++)`.
688
+ const seq = path.parentPath.isSequenceExpression() ? path.parent.expressions : null;
689
+ const guarded = seq && seq.length === 2 && seq[1] === path.node && isGuardCall(seq[0]) && seq[0].callee.property.name === 'p';
690
+ if (!guarded && !(t.isIdentifier(a) && ok(a, path))) bad(path.node, OPERATOR);
691
+ },
692
+ AssignmentExpression(path) {
693
+ const n = path.node;
694
+ if (n.operator === '=' || LOGICAL_ASSIGN.includes(n.operator)) return;
695
+ if (!(t.isIdentifier(n.left) && ok(n.left, path) && ok(n.right, path))) bad(n, OPERATOR);
696
+ },
697
+ });
698
+ }
699
+
700
+ /* ---------------------------------------------------------------- a game's rules, from its files to one module */
701
+
702
+ const exts = ['', '.ts', '.js', '.mjs'];
703
+ function resolveOwn(from, spec) {
704
+ const base = resolve(dirname(from), spec);
705
+ for (const e of exts) if (existsSync(base + e) && statSync(base + e).isFile()) return base + e;
706
+ return null;
707
+ }
708
+
709
+ /**
710
+ * Every file a game's rules are made of, each with its types removed, checked and rewritten.
711
+ * entry games/<id>/src/rules.ts (or move.ts, for a view's bundle)
712
+ * Returns { files: Map<absolute path, guarded code>, problems }. With problems, nothing is to be written.
713
+ */
714
+ export async function guardFiles(esbuild, root, gameDir, entry) {
715
+ const files = new Map();
716
+ const problems = [];
717
+ const todo = [entry];
718
+ while (todo.length) {
719
+ const file = todo.pop();
720
+ if (files.has(file)) continue;
721
+ const shown = relative(root, file).split('\\').join('/');
722
+ if (relative(gameDir, file).startsWith('..')) { problems.push({ file: shown, line: 0, message: 'rules import only files of their own game' }); files.set(file, null); continue; }
723
+ let stripped;
724
+ try { stripped = await esbuild.transform(readFileSync(file, 'utf8'), { loader: /\.ts$/.test(file) ? 'ts' : 'js', format: 'esm', target: 'es2022', sourcemap: 'external', sourcefile: shown }); } catch (error) {
725
+ const first = error.errors?.[0];
726
+ problems.push({ file: shown, line: first?.location?.line ?? 0, message: `this file does not parse: ${first?.text ?? error.message}` }); files.set(file, null); continue;
727
+ }
728
+ const res = guardSource(stripped.code, { file: shown, map: stripped.map });
729
+ problems.push(...res.problems);
730
+ files.set(file, res.code);
731
+ for (const m of stripped.code.matchAll(/^\s*import\s[^'"]*['"](\.\.?\/[^'"]+)['"]/gm)) {
732
+ const next = resolveOwn(file, m[1]);
733
+ if (next) todo.push(next); else problems.push({ file: shown, line: 0, message: `"${m[1]}" is not a file of this game` });
734
+ }
735
+ }
736
+ return { files, problems };
737
+ }
738
+
739
+ /** An esbuild plugin that serves a game's guarded files in place of its sources (`stub`: files a bundle must not hold at all). */
740
+ export function guardedPlugin(files, { stub = [] } = {}) {
741
+ return {
742
+ name: 'homie-rules-guard',
743
+ setup(b) {
744
+ b.onLoad({ filter: /\.(?:ts|js|mjs)$/ }, (a) => {
745
+ if (stub.includes(a.path)) return { contents: 'export default {};\n', loader: 'js' };
746
+ const code = files.get(a.path);
747
+ return typeof code === 'string' ? { contents: code, loader: 'js' } : null;
748
+ });
749
+ },
750
+ };
751
+ }
752
+
753
+ /**
754
+ * A game's rules as the one module the Worker loads: guarded, linked, and read once more as linked.
755
+ * Returns { ok, code, problems, files }. `code` imports only Homie's rules module and the guard.
756
+ */
757
+ export async function guardRules(esbuild, root, gameDir, { entry = join(gameDir, 'src', 'rules.ts') } = {}) {
758
+ const { files, problems } = await guardFiles(esbuild, root, gameDir, entry);
759
+ if (problems.length) return { ok: false, code: null, problems, files };
760
+ let linked;
761
+ try {
762
+ linked = await esbuild.build({ entryPoints: [entry], bundle: true, format: 'esm', target: 'es2022', write: false, absWorkingDir: root, logLevel: 'silent', external: [RULES_MODULE, GUARD_MODULE], plugins: [guardedPlugin(files)], legalComments: 'none' });
763
+ } catch (error) {
764
+ const first = error.errors?.[0];
765
+ return { ok: false, code: null, files, problems: [{ file: first?.location?.file ?? relative(root, entry), line: first?.location?.line ?? 0, message: `the rules did not link: ${first?.text ?? error.message}` }] };
766
+ }
767
+ const code = linked.outputFiles[0].text;
768
+ const again = guardSource(code, { file: `${relative(root, gameDir).split('\\').join('/')} (linked rules)`, linked: true });
769
+ return { ok: !again.problems.length, code: again.problems.length ? null : code, problems: again.problems, files };
770
+ }