@homie-rocks/studio 0.35.1 → 0.37.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.
- package/CHANGELOG.md +40 -0
- package/README.md +4 -2
- package/agents/agents.ts +8 -6
- package/app-starters/welcome/app.json +106 -0
- package/app-starters/welcome/index.html +4 -0
- package/app-starters/welcome/src/main.ts +107 -0
- package/apps/APPS.md +61 -0
- package/apps/apps.ts +28 -0
- package/bin/homie-studio.mjs +22 -10
- package/lab/lab.ts +15 -0
- package/lib/art-cli.mjs +2 -2
- package/lib/art-tools.mjs +6 -6
- package/lib/asset-check.mjs +3 -2
- package/lib/asset-import.mjs +3 -2
- package/lib/asset-manifest.mjs +8 -8
- package/lib/build.mjs +26 -18
- package/lib/characters.mjs +7 -7
- package/lib/check.mjs +49 -0
- package/lib/cloudflare.mjs +1 -1
- package/lib/codex.mjs +2 -2
- package/lib/collision.mjs +1 -1
- package/lib/decisions.mjs +7 -7
- package/lib/dev.mjs +42 -10
- package/lib/directory.mjs +1 -1
- package/lib/doctor.mjs +1 -1
- package/lib/handoff.mjs +1 -1
- package/lib/lab-check.mjs +18 -3
- package/lib/lab.mjs +29 -15
- package/lib/library.mjs +2 -2
- package/lib/mcp-tools.mjs +23 -3
- package/lib/parts-build.mjs +4 -3
- package/lib/parts-cli.mjs +3 -3
- package/lib/parts-store.mjs +8 -7
- package/lib/parts-tools.mjs +7 -7
- package/lib/parts.mjs +19 -14
- package/lib/perf.mjs +2 -1
- package/lib/player-image.mjs +2 -2
- package/lib/rules-build.mjs +97 -49
- package/lib/rules-check-worker.mjs +405 -0
- package/lib/rules-check.mjs +69 -0
- package/lib/rules-compiler.mjs +30 -0
- package/lib/rules-guard.mjs +26 -7
- package/lib/rules-types.mjs +167 -0
- package/lib/scaffold.mjs +21 -2
- package/lib/shop.mjs +1 -1
- package/lib/standalone-files.mjs +2 -2
- package/lib/standalone.mjs +12 -3
- package/lib/stats.mjs +2 -1
- package/lib/studio.mjs +32 -6
- package/lib/style-board.mjs +10 -9
- package/lib/template-history.json +85 -1
- package/lib/typecheck.mjs +52 -3
- package/links/links.d.ts +3 -0
- package/links/links.mjs +16 -0
- package/netplay/NETPLAY.md +123 -6
- package/netplay/netplay.ts +289 -53
- package/netplay/rules-output.mjs +85 -0
- package/package.json +16 -4
- package/parts/PARTS.md +10 -5
- package/rules/browser.ts +100 -0
- package/rules/core.ts +159 -58
- package/rules/guard.ts +11 -4
- package/rules/host.ts +90 -15
- package/rules/math.ts +16 -0
- package/rules/pack.ts +94 -17
- package/rules/rules.ts +21 -13
- package/rules/types.ts +63 -0
- package/rules/view.ts +36 -33
- package/shop/SHOP.md +13 -0
- package/site/SITE.md +1 -1
- package/standalone/shell/shell.js +8 -6
- package/worker/account-page.mjs +4 -3
- package/worker/app-format.mjs +42 -0
- package/worker/app-records.mjs +100 -0
- package/worker/app-shell.mjs +29 -0
- package/worker/brain.mjs +2 -1
- package/worker/embed.mjs +4 -2
- package/worker/hosted.mjs +1 -1
- package/worker/index.mjs +40 -20
- package/worker/limits.mjs +41 -0
- package/worker/pages.mjs +37 -29
- package/worker/parts.mjs +11 -9
- package/worker/room.mjs +136 -63
- package/worker/schema.mjs +6 -5
- package/worker/seats.mjs +2 -2
- package/worker/shop-page.mjs +4 -3
- package/worker/site.mjs +49 -40
- package/worker/standalone.mjs +2 -2
- package/worker/version.mjs +1 -1
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The generated play of a rules game, in a worker thread of its own (rules-check.mjs starts it).
|
|
3
|
+
*
|
|
4
|
+
* The module that is checked is the module that is deployed, linked with the real runtime (rules/host.ts and what it
|
|
5
|
+
* imports), and nothing here judges a game: this file only plays the people of a room and listens to the runtime.
|
|
6
|
+
* A build is refused for five things, each of which the runtime itself says:
|
|
7
|
+
*
|
|
8
|
+
* a handler threw the runtime caught it (core.ts `observe`); the line is the guard's own (`G.file`, `G.line`)
|
|
9
|
+
* a value did not fit the runtime stored something else than a handler wrote, or dropped it (core.ts `noted`):
|
|
10
|
+
* NaN stored as 0, a list cut to its size. A room does that and carries on; the build
|
|
11
|
+
* names the write. (A whole number held to its range is only counted, and said.)
|
|
12
|
+
* a tick ran out of budget the runtime's count (`stats.ticksCut`)
|
|
13
|
+
* a held-back body the runtime cut an owner-moved body's own `move` short of where it claimed (`stats.held`)
|
|
14
|
+
* a save that does not hold the room is rebuilt from its own save (`host.save()` into `createHost({ restore })`, the
|
|
15
|
+
* one path a restart takes), saved again, and played one tick beside the room that kept
|
|
16
|
+
* running: the three saves must be the same bytes
|
|
17
|
+
*
|
|
18
|
+
* Everything else it sees is an information line. All of it is bounded in the runtime's own units (ticks played, budget
|
|
19
|
+
* units used by the room and by the browsers' `move`, bytes of save rebuilt), never by a clock, so the same source gives
|
|
20
|
+
* the same verdict and the same lines on every computer.
|
|
21
|
+
*/
|
|
22
|
+
import { parentPort, workerData } from 'node:worker_threads';
|
|
23
|
+
import { compileFunction } from 'node:vm';
|
|
24
|
+
import { checkDecide } from '../worker/brain.mjs';
|
|
25
|
+
import { stateHash } from './rules-check.mjs';
|
|
26
|
+
|
|
27
|
+
const { bundle, data, declarationsOnly, allowance } = workerData;
|
|
28
|
+
const sites = data.sites ?? {};
|
|
29
|
+
const here = () => sites.default ?? `games/${data.id}/src/rules.ts`;
|
|
30
|
+
|
|
31
|
+
/** A runtime error in the words of the chat: what was absent or read-only, and the usual fix. */
|
|
32
|
+
function diagnostic(message) {
|
|
33
|
+
message = message.replace('Maximum call stack size exceeded', 'Calls went too deep (a function that calls itself without end)');
|
|
34
|
+
let m = /Cannot read properties of (?:undefined|null) \(reading '([^']+)'\)/.exec(message);
|
|
35
|
+
if (m) return `The value before .${m[1]} is absent. Check that the map spot, list entry or query result exists before reading .${m[1]}.`;
|
|
36
|
+
if (/cannot call (\w+) on (undefined|null)/.test(message)) return message.replace(/cannot call (\w+) on (undefined|null)/, 'Cannot call $1 because the value it is called on is absent. Check the index or query result first');
|
|
37
|
+
if (/Cannot convert undefined or null to object/.test(message)) return 'A value that is absent was used as a list or an object. Check that it exists first.';
|
|
38
|
+
m = /Cannot add property ([^,]+), object is not extensible/.exec(message);
|
|
39
|
+
if (m) return `Field "${m[1]}" is not declared. Declare it in fields, motion or shared before assigning it.`;
|
|
40
|
+
m = /Cannot assign to read only property '([^']+)'|Cannot set property (\w+) of/.exec(message);
|
|
41
|
+
if (m) return `"${m[1] ?? m[2]}" is read only here. A handler writes its own entity's fields and motion (room handlers write shared); constants, query results and event data are read only. Send an event to change another entity.`;
|
|
42
|
+
return message;
|
|
43
|
+
}
|
|
44
|
+
const fix = (message) => (/[.!?]$/.test(message) ? message : `${message}.`) + (/too deep/.test(message) ? ' Use a bounded loop or an explicit list instead.' : /ran too long/.test(message) ? ' Bound this loop or split the work across ticks.' : /is not declared in shapes/.test(message) ? ' Declare the name and its payload in shapes before sending it.' : '');
|
|
45
|
+
|
|
46
|
+
/** The linked module and the runtime it was linked with: { def, R, H, C, W, P, M } (rules-build.mjs `loadRules`). */
|
|
47
|
+
let L;
|
|
48
|
+
|
|
49
|
+
/** The declarations, compiled as a room compiles them. What is wrong with them is said with the line they are on. */
|
|
50
|
+
function compile() {
|
|
51
|
+
const { settings, problems } = L.R.roomSettings(data.room);
|
|
52
|
+
for (const [name, ask] of Object.entries(L.def.asks ?? {})) {
|
|
53
|
+
const checked = checkDecide({}, ask.questions);
|
|
54
|
+
if (!checked.ok) throw new Error(`asks.${name}.questions: ${checked.why}. Each question needs a type and instructions, for example { type: 'noul', instructions: 'Should the party advance?' }`);
|
|
55
|
+
}
|
|
56
|
+
return { c: L.R.compileRules(L.def, { tune: data.tune, map: L.R.compileMap(data.map, data.map.name), settings, seats: data.seats }), problems };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** How the runtime names a handler, as the chat reads it and as the source declares it. */
|
|
60
|
+
const shown = (kind, handler) => (kind === 'room' || handler.startsWith('asks.') ? `room.${handler}` : `${kind}.${handler}`);
|
|
61
|
+
const siteOf = (kind, handler) => sites[handler.startsWith('asks.') ? handler : handler === 'move' ? `move.${kind}` : kind === 'room' ? `room.${handler}` : `entities.${kind}.${handler}`] ?? here();
|
|
62
|
+
function declared(c) {
|
|
63
|
+
const list = [];
|
|
64
|
+
if (c.start) list.push(['room', 'start']);
|
|
65
|
+
if (c.join) list.push(['room', 'join']);
|
|
66
|
+
for (const ev of Object.keys(c.roomOn)) list.push(['room', `on.${ev}`]);
|
|
67
|
+
for (const name of Object.keys(c.asks)) list.push(['room', `asks.${name}.floor`]);
|
|
68
|
+
for (const k of c.kinds) {
|
|
69
|
+
for (const key of ['tick', 'think', 'move']) if (k[key]) list.push([k.name, key]);
|
|
70
|
+
if (k.guide?.view) list.push([k.name, 'guide.view']);
|
|
71
|
+
if (k.guide?.floor) list.push([k.name, 'guide.floor']);
|
|
72
|
+
for (const group of ['on', 'commands', 'onRoom']) for (const ev of Object.keys(k[group])) list.push([k.name, `${group}.${ev}`]);
|
|
73
|
+
}
|
|
74
|
+
return list;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** What was written, as the runtime says it (pack.ts `said`): a text in quotes, a kind of thing as it is. */
|
|
78
|
+
const plain = (written) => (/^(null|a (Map|Set|list|value of type \w+))$/.test(written) ? written : JSON.stringify(written));
|
|
79
|
+
/**
|
|
80
|
+
* A written value the runtime changed or dropped, in the words of the chat. `what` is pack.ts `Adjusted`, or `effect`,
|
|
81
|
+
* `think`, `decision`; `at` is the field; `written` is what the rules wrote, as the runtime says it.
|
|
82
|
+
*/
|
|
83
|
+
function unfit(what, at, written, inPlace) {
|
|
84
|
+
const room = 'A live room does not stop for this:';
|
|
85
|
+
// Changed in place (a push, a key set on it), a field is held to its type when the handler ends, not at a write.
|
|
86
|
+
const got = inPlace ? 'was changed in place in this handler (not assigned), and when the handler ended it held' : 'was written';
|
|
87
|
+
const name = at || 'a value';
|
|
88
|
+
switch (what) {
|
|
89
|
+
case 'nan': return written === 'NaN'
|
|
90
|
+
? `${name} ${got} NaN, which is not a number. ${room} it stores 0 there, and the game plays on with a wrong value. Check the divisor before dividing, and that every value read exists.`
|
|
91
|
+
: `${name} ${got} ${plain(written)}, which is not a number. ${room} it stores 0 there. Write a number there.`;
|
|
92
|
+
case 'infinite': return /Infinity/.test(written)
|
|
93
|
+
? `${name} ${got} ${written}, which a fraction or a vector cannot hold. ${room} it stores 0 there. Check the divisor before dividing; for "never" or "no limit", use a whole-number field (it holds Infinity as the end of its range).`
|
|
94
|
+
: `${name} ${got} ${written}, which is too large for the 32-bit numbers of a vector. ${room} it stores 0 there. Keep positions and velocities far below 3e38.`;
|
|
95
|
+
case 'text': return `${name} ${got} a text of ${written}. ${room} it cuts the text to its size. Shorten the text, or declare the size the game needs.`;
|
|
96
|
+
case 'list': return `${name} ${got} a list of ${written}. ${room} it keeps the first entries and drops the rest. Remove old entries before adding, or declare the size the game needs.`;
|
|
97
|
+
case 'map': return `${name} ${got} a map of ${written}. ${room} it drops the keys past its size. Remove old keys before adding, or declare the size the game needs.`;
|
|
98
|
+
case 'key': return `${name} ${got} the key ${JSON.stringify(written.slice(0, 24))}${written.length > 24 ? ' (and more)' : ''}, which cannot be a key of a map: a key is at most 32 characters and not a name every object has (constructor, prototype, __proto__). ${room} it drops that entry. Use another key.`;
|
|
99
|
+
case 'kind': return `${name} ${got} ${plain(written)} where a list or a plain object belongs. ${room} it stores it empty. Copy the entries of a Map or a Set into a list or a plain object first.`;
|
|
100
|
+
case 'effect': return `more than 256 effects were emitted in one tick (this one: ${written}). ${room} it drops the rest, and nobody sees them. Combine effects, or spread them across ticks.`;
|
|
101
|
+
case 'think': return at ? `think returned "${at}", which is not an input this kind declares. ${room} it ignores it. Return only the kind's input fields.` : `think returned ${written}, not an object of its declared inputs. ${room} the body gets neutral input. Return an object such as { ax: 0 }.`;
|
|
102
|
+
default: return `guide.floor returned ${at}: ${written}. ${room} it discards the whole decision. Use the names in agents.json and the values the view offered.`;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const rng = (seed) => () => { seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0; return seed / 4294967296; };
|
|
107
|
+
const same = (a, b) => a.length === b.length && Buffer.compare(a, b) === 0;
|
|
108
|
+
|
|
109
|
+
/** Where two saves first differ, in the game's own names. Only for the message: the verdict is the bytes. */
|
|
110
|
+
function firstDifference(c, a, b) {
|
|
111
|
+
const named = (s) => {
|
|
112
|
+
const core = s.core; const fields = (list, values) => L.P.unpackFields(list, values, c.dims);
|
|
113
|
+
const ent = (e) => { const k = c.kinds[e[2]]; return [e[0], { kind: k.name, pos: e[7], vel: e[8], heading: e[9], fields: fields(k.fields, e[17]), motion: fields(k.motion, e[18]), input: fields(k.input, e[19]), commands: e[20], runtime: [...e.slice(1, 7), ...e.slice(10, 17)] }]; };
|
|
114
|
+
return { ...s, core: { ...core, shared: fields(c.shared, core.shared), ents: Object.fromEntries(core.ents.map(ent)), spawns: Object.fromEntries(core.spawns.map(ent)) } };
|
|
115
|
+
};
|
|
116
|
+
const walk = (x, y, path) => {
|
|
117
|
+
if (Object.is(x, y)) return null;
|
|
118
|
+
if (!x || !y || typeof x !== 'object' || typeof y !== 'object') return `${path || 'the save'} (${JSON.stringify(x)?.slice(0, 60)} against ${JSON.stringify(y)?.slice(0, 60)})`;
|
|
119
|
+
const kx = Object.keys(x); const ky = Object.keys(y);
|
|
120
|
+
for (let i = 0; i < Math.max(kx.length, ky.length); i += 1) if (kx[i] !== ky[i]) return `${path ? `${path}.` : ''}${kx[i] ?? ky[i]} (a key is absent, or the keys are in another order)`;
|
|
121
|
+
for (const k of kx) { const found = walk(x[k], y[k], path ? `${path}.${k}` : k); if (found) return found; }
|
|
122
|
+
return null;
|
|
123
|
+
};
|
|
124
|
+
try { return (walk(named(L.P.fromBytes(a)), named(L.P.fromBytes(b)), '') ?? 'the save').replace(/^core\./, ''); } catch { return 'the save'; }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* One room, played from its first tick until its allowance is used or something is proven.
|
|
129
|
+
* Returns what the play covered; `fault` is the first thing proven, in full, or null.
|
|
130
|
+
*/
|
|
131
|
+
function play(c, companions, allow) {
|
|
132
|
+
const { G } = L.W;
|
|
133
|
+
const tickHz = c.settings.tickHz; const budget = c.settings.budget.tick;
|
|
134
|
+
const secs = (s) => Math.max(1, Math.round(s * tickHz));
|
|
135
|
+
const dice = rng(193);
|
|
136
|
+
const ran = new Map();
|
|
137
|
+
let fault = null; let step = 0; let roundNow = 0; let rounds = 0; let units = 0;
|
|
138
|
+
let did = []; let sentCommands = new Map();
|
|
139
|
+
const when = () => ` This was tick ${step} of the generated play (${(step / tickHz).toFixed(1)} seconds in${roundNow ? `, round ${roundNow}` : ''}).`;
|
|
140
|
+
/** Whole numbers held to their range, by field: the runtime's own count, said in one line each. */
|
|
141
|
+
const ranges = new Map();
|
|
142
|
+
const noted = (kind, handler, what, at, written) => {
|
|
143
|
+
const inPlace = what.endsWith(' in place');
|
|
144
|
+
if (inPlace) what = what.slice(0, -9);
|
|
145
|
+
// Changed in place, the field is held to its type when the handler ends: the line then is not the write's, so the handler is named instead.
|
|
146
|
+
const where = inPlace ? siteOf(kind, handler) : `${G.file || siteOf(kind, handler).split(':')[0]}:${G.line || 1}`;
|
|
147
|
+
if (what === 'range') {
|
|
148
|
+
const field = at.replace(/\[\d+\]/g, '[]');
|
|
149
|
+
const row = ranges.get(field);
|
|
150
|
+
if (row) row.count += 1; else ranges.set(field, { count: 1, written, where, handler: shown(kind, handler) });
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
fault ??= `${where} ${shown(kind, handler)}: ${unfit(what, at, written, inPlace)}${when()}`;
|
|
154
|
+
};
|
|
155
|
+
const observe = (kind, handler, error) => {
|
|
156
|
+
let set = ran.get(kind); if (!set) ran.set(kind, set = new Set());
|
|
157
|
+
set.add(handler);
|
|
158
|
+
if (!error || fault) return;
|
|
159
|
+
const sent = handler.startsWith('commands.') ? sentCommands.get(handler.slice(9)) : undefined;
|
|
160
|
+
const seats = (handler === 'join' || handler.startsWith('on.seat')) && did.length ? ` Before this tick ${did.join(', ')}; game.json permits ${data.seats} seats.` : '';
|
|
161
|
+
fault = `${G.file || siteOf(kind, handler).split(':')[0]}:${G.line || 1} ${shown(kind, handler)}: ${fix(diagnostic(error))}${when()}${seats}${sent !== undefined ? ` The check sent the command ${handler.slice(9)} with ${JSON.stringify(sent)}, which shapes.commands allows.` : ''}`;
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
/* ---- the relay's part: who is in which seat, and what a restored room is told again */
|
|
165
|
+
let now = 0; let snap = null; const offers = new Map();
|
|
166
|
+
const send = (m) => {
|
|
167
|
+
if (m.t === 'snap') snap = m;
|
|
168
|
+
else if (m.t === 'round') { roundNow = m.round.n; if (m.round.phase === 'over') rounds += 1; }
|
|
169
|
+
else if (m.t === 'ev' && m.k === 'agent:offer') offers.set(m.d.slot, m.d.view);
|
|
170
|
+
};
|
|
171
|
+
const open = (o = {}) => L.H.createHost({ game: data.id, compiled: c, check: true, random: rng(7), clock: { now: () => now, setTimer: () => 0, clearTimer() {} }, send() {}, ...o });
|
|
172
|
+
const vocab = data.vocab ?? { v: 1, goals: {}, lines: {}, asks: {} };
|
|
173
|
+
let policy = companions ? { kind: 'beginner', aiSeats: data.seats > 1 ? 1 : 0, guides: 1, bots: 'off', brain: 'script' } : null;
|
|
174
|
+
/** What the relay says to a room again after a restore: the vocabulary and the policy. Neither is in the save. */
|
|
175
|
+
const told = () => [...(companions ? [{ t: 'vocabulary', vocab }] : []), ...(policy ? [{ t: 'policy', policy }] : [])];
|
|
176
|
+
const people = data.seats - (companions ? Math.min(policy.aiSeats + policy.guides, data.seats - 1) : 0);
|
|
177
|
+
const seated = new Map(); // seat -> the relay's number for this stay in it; negative while the holder is away
|
|
178
|
+
let stays = 0;
|
|
179
|
+
let frames = [];
|
|
180
|
+
const joined = (seat, occ) => ({ t: 'join', peer: { id: `check${occ}`, seat, name: 'Check', occ } });
|
|
181
|
+
const join = (seat) => { const back = seated.get(seat); const occ = back ? Math.abs(back) : (stays += 1); seated.set(seat, occ); did.push(`seat ${seat} ${back ? 'returned' : 'joined'}`); frames.push(joined(seat, occ)); };
|
|
182
|
+
const away = (seat) => { if (!(seated.get(seat) > 0)) return; seated.set(seat, -seated.get(seat)); did.push(`seat ${seat} went away`); frames.push({ t: 'leave', seat }); };
|
|
183
|
+
const free = (seat) => { if (!seated.has(seat)) return; seated.delete(seat); did.push(`seat ${seat} left`); frames.push({ t: 'free', seat }); };
|
|
184
|
+
const bots = (mode) => { policy = { ...(policy ?? {}), bots: mode }; frames.push({ t: 'policy', policy }); };
|
|
185
|
+
|
|
186
|
+
let A = open({ observe, noted, send });
|
|
187
|
+
for (const m of told()) A.frame(m);
|
|
188
|
+
/** The room stops and starts again as a deploy restarts it: a new epoch, everybody away until they are back. */
|
|
189
|
+
const restart = () => {
|
|
190
|
+
const bytes = A.save(); const epoch = A.epoch + 1; A.stop();
|
|
191
|
+
A = open({ observe, noted, send, restore: bytes, restoreEpoch: epoch });
|
|
192
|
+
for (const m of told()) A.frame(m);
|
|
193
|
+
// Everybody is back at once, but for the highest seat, which is a second late.
|
|
194
|
+
const top = Math.max(...seated.keys());
|
|
195
|
+
for (const [seat, occ] of [...seated]) if (occ > 0) { if (seat === top && top > 0) seated.set(seat, -occ); else join(seat); }
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
/*
|
|
199
|
+
* ---- what happens to the seats, by the tick. The first seconds hold one of everything. After them the room is full
|
|
200
|
+
* for one round and small for the next (two people and no bots, so that something is left when a round ends), and
|
|
201
|
+
* all the while somebody goes away and comes back, gives up a seat and takes it again, and the room restarts once
|
|
202
|
+
* every 40 seconds as a deploy restarts it.
|
|
203
|
+
*/
|
|
204
|
+
const opening = Math.max(secs(5), 80);
|
|
205
|
+
let chapter = -1;
|
|
206
|
+
function seats(t) {
|
|
207
|
+
if (!c.join) return;
|
|
208
|
+
if (t === 1) join(0);
|
|
209
|
+
if (people > 1) {
|
|
210
|
+
if (t === 3) join(1); if (t === 5) away(1); if (t === 6) join(1); if (t === 8) free(1); if (t === 10) join(1);
|
|
211
|
+
if (t === 12) for (let s = 2; s < people; s += 1) join(s);
|
|
212
|
+
if (t === 20 && people > 2) free(people - 1); if (t === 26 && people > 2) join(people - 1);
|
|
213
|
+
}
|
|
214
|
+
if (!companions) { if (t === 22) bots('off'); if (t === 24) bots('fill'); }
|
|
215
|
+
if (t === 70) for (const s of [...seated.keys()]) free(s);
|
|
216
|
+
if (t === 72) { join(0); if (people > 1) join(1); }
|
|
217
|
+
if (t < opening) return;
|
|
218
|
+
// A chapter is a round, or a minute of a game without rounds: full, then small, in turn.
|
|
219
|
+
const next = c.rounds ? rounds : Math.floor(t / secs(60));
|
|
220
|
+
if (next !== chapter) {
|
|
221
|
+
chapter = next;
|
|
222
|
+
const small = next % 2 === 1;
|
|
223
|
+
if (!companions) bots(small ? 'off' : 'fill');
|
|
224
|
+
for (let s = 0; s < people; s += 1) { if (small && s > 1) free(s); else if (!(seated.get(s) > 0)) join(s); }
|
|
225
|
+
}
|
|
226
|
+
const top = Math.max(...seated.keys());
|
|
227
|
+
const at = (s) => (t - opening) % secs(40) === secs(s);
|
|
228
|
+
if (top > 0) { if (at(6)) away(top); if (at(18)) join(top); if (at(22)) free(top); if (at(24)) join(top); }
|
|
229
|
+
if (at(34)) restart();
|
|
230
|
+
if (at(35)) for (const [seat, occ] of [...seated]) if (occ < 0) join(seat);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/* ---- what the people press. Each plays one of three ways, changing every 20 s; seat 1 never sends anything. */
|
|
234
|
+
const ends = (fd) => (fd.t === 'bit' || fd.t === 'press' ? [false, true] : fd.t === 'fix' ? [-1, 1] : [L.P.coerce(fd, -Infinity, c.dims), L.P.coerce(fd, Infinity, c.dims)]);
|
|
235
|
+
const steering = new Map();
|
|
236
|
+
let moveTick = 0;
|
|
237
|
+
const moveCtx = new Map(c.kinds.filter((k) => k.move && k.body).map((k) => [k.name, L.M.moveContext({ tick: () => moveTick, tickHz, tune: c.publicTune, map: c.map, name: c.map.name, spots: c.map.spots, radius: () => k.body.radius, dims: c.dims })]));
|
|
238
|
+
/**
|
|
239
|
+
* An owner-moved body's own browser, for one tick: the game's `move` from where the last snapshot has the body, by
|
|
240
|
+
* the step a browser takes (pack.ts `stepMove`), and the claim of where that leaves it. Its units are the play's.
|
|
241
|
+
* (A real browser keeps its own position between snapshots; starting from the server's each tick asks one thing
|
|
242
|
+
* only, whether a step of this move is one the server lets a body take.)
|
|
243
|
+
*/
|
|
244
|
+
function claim(k, b, values, t) {
|
|
245
|
+
const w = snap?.d[1].find((e) => e[0] === b.id);
|
|
246
|
+
const e = w && L.P.unpackEntity(c.kinds, w, c.dims);
|
|
247
|
+
if (!e) return null;
|
|
248
|
+
G.note = (what, at, written) => noted(k.name, 'move', what, at, written);
|
|
249
|
+
moveTick = t;
|
|
250
|
+
const body = L.P.stepMove(k.move, { pos: e.pos, vel: e.vel, heading: e.heading, grounded: e.grounded, motion: L.P.thawFields(k.motion, e.motion) }, Object.freeze(values), moveCtx.get(k.name), Math.max(1, Math.floor(budget / 4)), k.motion, c.dims,
|
|
251
|
+
(error) => { fault ??= `${G.file}:${G.line} ${k.name}.move: ${fix(diagnostic(String(error?.message ?? error)))} A player's own browser runs this move for its body.${when()}`; });
|
|
252
|
+
G.note = null;
|
|
253
|
+
units += body.used;
|
|
254
|
+
return L.H.claimOf(body.pos, body.vel, body.heading);
|
|
255
|
+
}
|
|
256
|
+
function sample(fd, n, refs) {
|
|
257
|
+
if (fd.t === 'ref') return n % 2 ? refs[(n >> 1) % refs.length] ?? '' : '';
|
|
258
|
+
if (fd.t === 'text') return n % 2 ? 'x'.repeat(fd.max) : '';
|
|
259
|
+
if (fd.t === 'vec3' || fd.t === 'dir') return { x: n % 2, y: 0, z: 0 };
|
|
260
|
+
if (fd.t === 'list') return n % 2 ? Array.from({ length: fd.max }, () => sample(fd.of, 0, refs)) : [];
|
|
261
|
+
if (fd.t === 'map') return n % 2 ? { key: sample(fd.of, n, refs) } : {};
|
|
262
|
+
if (fd.t === 'struct') return Object.fromEntries(Object.entries(fd.fields).map(([name, sub]) => [name, sample(sub, n, refs)]));
|
|
263
|
+
const [lo, hi] = ends(fd);
|
|
264
|
+
const values = [lo, hi, ...[0, 1, 2, 3, -1].filter((v) => v > lo && v < hi)];
|
|
265
|
+
return values[n % values.length];
|
|
266
|
+
}
|
|
267
|
+
function presses(t) {
|
|
268
|
+
const bodies = A.core.bodies();
|
|
269
|
+
const refs = bodies.map((b) => b.id);
|
|
270
|
+
sentCommands = new Map();
|
|
271
|
+
for (const b of bodies) {
|
|
272
|
+
if (b.driver !== 'person' || !(seated.get(b.seat) > 0)) continue;
|
|
273
|
+
const way = b.seat === 1 ? 'idle' : ['steer', 'hold', 'steer', 'idle'][(b.seat + Math.floor(t / secs(20))) % 4];
|
|
274
|
+
if (way === 'idle') continue;
|
|
275
|
+
const k = c.kindOf[b.kind];
|
|
276
|
+
let values;
|
|
277
|
+
if (way === 'hold') values = Object.fromEntries(k.input.map(([name, fd]) => [name, ends(fd)[Math.floor(t / secs(2)) % 2]]));
|
|
278
|
+
else {
|
|
279
|
+
let s = steering.get(b.seat);
|
|
280
|
+
if (!s || t >= s.until) steering.set(b.seat, s = { until: t + 1 + Math.floor(dice() * secs(3)), values: Object.fromEntries(k.input.map(([name, fd]) => { const r = dice(); return [name, r < 0.4 ? ends(fd)[0] : r < 0.8 ? ends(fd)[1] : L.P.initOf(fd, c.dims)]; })) });
|
|
281
|
+
values = s.values;
|
|
282
|
+
}
|
|
283
|
+
const row = k.input.map(([name]) => (typeof values[name] === 'boolean' ? Number(values[name]) : values[name]));
|
|
284
|
+
if (k.body?.owner && k.move) { const claimed = claim(k, b, values, t); if (claimed) row.push(...claimed); }
|
|
285
|
+
frames.push({ t: 'in', from: b.seat, e: A.epoch, k: t, r: b.r, s: [[0, ...row]] });
|
|
286
|
+
if (t % 5 === 0) for (const [name, fields] of Object.entries(c.commands)) {
|
|
287
|
+
const d = Object.fromEntries(fields.map(([field, fd]) => [field, sample(fd, Math.floor(t / 5) + b.seat, refs)]));
|
|
288
|
+
sentCommands.set(name, d);
|
|
289
|
+
frames.push({ t: 'ev', from: b.seat, k: 'cmd', d: [name, d] });
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
// A person asks each companion for something the vocabulary offers, every two seconds.
|
|
293
|
+
const asks = companions ? Object.entries(vocab.asks ?? {}) : [];
|
|
294
|
+
const person = bodies.find((b) => b.driver === 'person' && seated.get(b.seat) > 0);
|
|
295
|
+
if (asks.length && person && t % secs(2) === 0) {
|
|
296
|
+
const [name, ask] = asks[Math.floor(t / secs(2)) % asks.length];
|
|
297
|
+
for (const b of bodies) {
|
|
298
|
+
if (b.driver !== 'ai') continue;
|
|
299
|
+
const view = offers.get(b.seat) ?? {};
|
|
300
|
+
const args = Object.fromEntries(Object.entries(ask.args ?? {}).map(([key, type]) => {
|
|
301
|
+
const offered = typeof type === 'string' && type.startsWith('view.') ? view[type.slice(5)] : null;
|
|
302
|
+
const value = type === 'player' ? person.seat : Array.isArray(type) ? type[0] : Array.isArray(offered) ? offered[0] : offered;
|
|
303
|
+
return [key, value && typeof value === 'object' ? value.id : value];
|
|
304
|
+
}));
|
|
305
|
+
if (!Object.values(args).some((v) => v === undefined || v === null)) frames.push({ t: 'ev', from: person.seat, k: `ask:${name}`, d: { slot: b.seat, args } });
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/* ---- the play */
|
|
311
|
+
let rebuilt = 0; let restores = 0; let largest = 0; let saved = null; let stopped = 'ticks';
|
|
312
|
+
let peak = 0; let most = 0; let worst = ''; // a restart begins the runtime's own counts again, so the busiest tick and handler are kept here
|
|
313
|
+
const heldBack = () => {
|
|
314
|
+
const k = c.kinds.filter((x) => x.player && x.body?.owner && x.move);
|
|
315
|
+
return `${siteOf(k[0]?.name, 'move')} ${k.map((x) => x.name).join(' or ')}.move: the server held this body back: one step of this move, from where the server has the body, goes further than the server lets a body go in a tick (body.maxSpeed is ${k.map((x) => x.body.maxSpeed).join(', ')} m/s). A player would see their character pulled back. Clamp the speed (a diagonal is longer than either stick axis), or declare the body's real top speed.${when()}`;
|
|
316
|
+
};
|
|
317
|
+
while (!fault) {
|
|
318
|
+
if (step >= allow.ticks) break;
|
|
319
|
+
if (units >= allow.units) { stopped = 'units'; break; }
|
|
320
|
+
step += 1; did = []; frames = [];
|
|
321
|
+
const t = A.tick + 1; const room = A; // `seats` may restart the room; that tick is not also compared
|
|
322
|
+
// Before this tick the room is rebuilt from its save and compared, if this is a tick for it. Who is here is noted
|
|
323
|
+
// before this tick's comings and goings: a rebuilt room has nobody connected until they are back.
|
|
324
|
+
const compare = rebuilt < allow.restoreBytes && (step <= allow.everyTick || step % allow.every === 0);
|
|
325
|
+
const present = compare ? [...seated].filter(([, occ]) => occ > 0).map(([seat, occ]) => joined(seat, occ)) : [];
|
|
326
|
+
seats(step);
|
|
327
|
+
presses(t);
|
|
328
|
+
if (fault) break;
|
|
329
|
+
let B = null; let before = null;
|
|
330
|
+
if (compare && A === room) {
|
|
331
|
+
before = saved ?? A.save();
|
|
332
|
+
try { B = open({ restore: before }); } catch (error) { fault = `${here()}: the runtime could not rebuild this room from its own save: ${error.message}.${when()} This is a fault in Homie's save, not in the game's rules: report the game with this message.`; break; }
|
|
333
|
+
if (!same(before, B.save())) { fault = `${here()}: a room rebuilt from its save does not save the same again; first difference at ${firstDifference(c, before, B.save())}.${when()} This is a fault in Homie's save, not in the game's rules: report the game with this message.`; break; }
|
|
334
|
+
// The relay tells the rebuilt room its vocabulary and policy again, and everybody who is here reconnects: to both
|
|
335
|
+
// rooms, so that both hold the same connections (a reconnect is an ordinary thing for a room to be sent).
|
|
336
|
+
for (const m of told()) B.frame(m);
|
|
337
|
+
for (const m of present) { A.frame(m); B.frame(m); }
|
|
338
|
+
rebuilt += before.length; restores += 1; if (before.length > largest) largest = before.length;
|
|
339
|
+
}
|
|
340
|
+
for (const m of frames) { A.frame(m); B?.frame(m); }
|
|
341
|
+
now = step * 1000 / tickHz;
|
|
342
|
+
const stats = A.core.stats; const cut = stats.ticksCut; const held = stats.held;
|
|
343
|
+
A.tickNow();
|
|
344
|
+
units += A.core.stats.tickUnits;
|
|
345
|
+
if (A.core.stats.tickUnits > peak) peak = A.core.stats.tickUnits;
|
|
346
|
+
if (A.core.stats.maxUnits > most) { most = A.core.stats.maxUnits; worst = A.core.stats.worst; }
|
|
347
|
+
const facts = A.facts();
|
|
348
|
+
if (!fault && facts.faults) fault = `${here()}: the runtime itself failed: ${facts.lastFault}.${when()}`;
|
|
349
|
+
if (!fault && A.core.stats.ticksCut > cut) {
|
|
350
|
+
// No handler was stopped: the handlers of this tick together used all of it. The one that used the most is named.
|
|
351
|
+
const [kind, handler] = (A.core.stats.worst || 'room.start').split(/\.(.*)/s);
|
|
352
|
+
fault = `${siteOf(kind, handler)} ${shown(kind, handler)}: this tick used its whole budget of ${budget} units, so handlers were left unrun; this handler used the most (${A.core.stats.maxUnits} units in one call). Bound the work of each tick, or keep fewer entities and less state.${when()}`;
|
|
353
|
+
}
|
|
354
|
+
if (!fault && A.core.stats.held > held) fault = heldBack();
|
|
355
|
+
saved = null;
|
|
356
|
+
if (B && !fault) {
|
|
357
|
+
B.tickNow();
|
|
358
|
+
units += B.core.stats.tickUnits;
|
|
359
|
+
saved = A.save(); const other = B.save();
|
|
360
|
+
if (!same(saved, other)) fault = `${here()}: a room rebuilt from its save and given the same input does not play the same as the room that kept running; first difference at ${firstDifference(c, saved, other)}.${when()} Keep everything that changes in fields, motion or shared (a value kept anywhere else is not saved). If the game keeps none, the fault is in Homie's save: report the game with this message.`;
|
|
361
|
+
}
|
|
362
|
+
B?.stop();
|
|
363
|
+
}
|
|
364
|
+
const end = A.core.save();
|
|
365
|
+
const size = A.save().length;
|
|
366
|
+
A.stop();
|
|
367
|
+
return { fault, ran, ranges, allowed: allow, companions, ticks: step, seconds: step / tickHz, rounds, restores, units, stopped, entities: end.ents.length, waiting: end.queue.length, saveBytes: size, largestSaveBytes: Math.max(largest, size), maxUnits: most, maxTickUnits: peak, worst };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const linked = { exports: {} };
|
|
371
|
+
try {
|
|
372
|
+
try { L = compileFunction(`"use strict";\n${bundle}\nreturn module.exports;`, ['module', 'exports'])(linked, linked.exports); } catch (error) {
|
|
373
|
+
// Thrown while the module loaded (a constant read from something absent). The guard had the line, if it got that far.
|
|
374
|
+
let at = null; try { const g = linked.exports.W.G; if (g.line) at = `${g.file}:${g.line}`; } catch { /* the guard itself did not load */ }
|
|
375
|
+
throw new Error(`${at ?? here()}: ${diagnostic(String(error?.message ?? error))}`);
|
|
376
|
+
}
|
|
377
|
+
const { c, problems } = compile();
|
|
378
|
+
const result = { vocab: data.vocab, schema: L.R.schemaOf(c), publicTune: c.publicTune, settings: c.settings, problems, rounds: c.rounds, stateHash: stateHash(c),
|
|
379
|
+
declarations: { events: c.events, asks: Object.fromEntries(Object.entries(c.asks).map(([n, a]) => [n, { state: a.stateFields, questions: a.questions }])), view: L.def.shapes?.view ?? {} } };
|
|
380
|
+
if (declarationsOnly) parentPort.postMessage({ result });
|
|
381
|
+
else {
|
|
382
|
+
// The room without companions is played long, to the allowance: a full round at least, and for a typical game a
|
|
383
|
+
// quarter of an hour of the room's clock. A game with companions is then played a second, short time with them
|
|
384
|
+
// seated, on an eighth as much again: it is there for what companions do, not to play the match twice.
|
|
385
|
+
const tickHz = c.settings.tickHz;
|
|
386
|
+
const round = c.rounds?.seconds > 0 ? Math.round(c.rounds.seconds * tickHz) : 0;
|
|
387
|
+
const whole = Math.min(2 * allowance.ticks, round + Math.round((c.rounds?.breakSeconds ?? 0) * tickHz) + 10 * tickHz);
|
|
388
|
+
const plays = [play(c, false, { ...allowance, ticks: Math.max(allowance.ticks, whole) })];
|
|
389
|
+
if (!plays[0].fault && (data.vocab || c.kinds.some((k) => k.guide && k.think))) plays.push(play(c, true, { ...allowance, ticks: allowance.companionTicks, units: Math.floor(allowance.units / 8), restoreBytes: Math.floor(allowance.restoreBytes / 8) }));
|
|
390
|
+
const fault = plays.at(-1).fault;
|
|
391
|
+
if (fault) throw new Error(fault);
|
|
392
|
+
const sum = (key) => plays.reduce((n, p) => n + p[key], 0);
|
|
393
|
+
const said = (p) => `${p.ticks} ticks (${Math.round(p.seconds)} seconds of the room's clock)${p.companions ? ' with AI companions' : ''}, ${p.rounds} ${p.rounds === 1 ? 'round' : 'rounds'} finished${!p.rounds && round > p.ticks ? ` (a round lasts ${round} ticks, longer than this play)` : ''}, the room rebuilt from its save and compared ${p.restores} times, stopped at its allowance of ${p.stopped === 'units' ? `${p.allowed.units} budget units` : `${p.allowed.ticks} ticks`}; it ended holding ${p.entities} entities and ${p.waiting} waiting events in a save of ${p.saveBytes} bytes`;
|
|
394
|
+
const information = [`${here()}: the generated play covered ${plays.map(said).join('; and ')}.${allowance.full ? '' : ' `homie-studio build --long-check` plays eight times as much.'} Play it did not reach is not checked.`];
|
|
395
|
+
for (const p of plays) for (const [field, row] of p.ranges) if (!information.some((line) => line.includes(` ${field} was written a whole number outside its range`))) information.push(`${row.where}: ${row.handler}: ${field} was written a whole number outside its range ${row.count} ${row.count === 1 ? 'time' : 'times'} in this play (the first: ${row.written}), and holds the nearest end of the range, as a live room does. This is information: nothing to change if the clamp is meant.`);
|
|
396
|
+
for (const [kind, handler] of declared(c)) if (!plays.some((p) => p.ran.get(kind)?.has(handler))) information.push(`${siteOf(kind, handler)}: ${shown(kind, handler)} never ran in the generated play. This is information, not proof that play cannot reach it.`);
|
|
397
|
+
parentPort.postMessage({ result: { ...result, information, plays: plays.map(({ ran, ranges, allowed, fault: none, ...p }) => p), ticks: sum('ticks'), roundsPlayed: sum('rounds'), restores: sum('restores'), units: sum('units'),
|
|
398
|
+
maxUnits: Math.max(...plays.map((p) => p.maxUnits)), maxTickUnits: Math.max(...plays.map((p) => p.maxTickUnits)), largestSaveBytes: Math.max(...plays.map((p) => p.largestSaveBytes)) } });
|
|
399
|
+
}
|
|
400
|
+
} catch (e) {
|
|
401
|
+
// A declaration that is wrong is named by its path (`asks.bonus.floor`, `entities.runner.fields.score`): the line is where the source declares it.
|
|
402
|
+
const message = String(e?.message ?? e);
|
|
403
|
+
const declaration = Object.keys(sites).sort((a, b) => b.length - a.length).find((key) => message.startsWith(`${key}:`) || message.startsWith(`${key}.`) || message.startsWith(`${key} `));
|
|
404
|
+
parentPort.postMessage({ error: /\.(?:ts|json)[: ]/.test(message.split('\n')[0]) ? message : `${declaration ? sites[declaration] : here()}: ${diagnostic(message)}` });
|
|
405
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The generated check of a rules game: its guarded module, linked with the real runtime, played in a worker thread
|
|
3
|
+
* (rules-check-worker.mjs says what is played and what refuses a build).
|
|
4
|
+
*
|
|
5
|
+
* The play is bounded by its ALLOWANCE and by nothing else, so its verdict and every line it prints are the same on
|
|
6
|
+
* every computer. No clock decides anything. The thread has a memory limit and a last-resort supervisor, for a fault of
|
|
7
|
+
* Homie's own that would otherwise hang a build; what either says is that the check stopped, never that the game is wrong.
|
|
8
|
+
*/
|
|
9
|
+
import { createHash } from 'node:crypto';
|
|
10
|
+
import { Worker } from 'node:worker_threads';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* How much is played, in the runtime's own units. A room's play ends when its ticks or its units are used up.
|
|
14
|
+
* ticks ticks of the room's clock (at 20 a second, 18,000 is a quarter of an hour of play). A game whose
|
|
15
|
+
* round is longer is played to the end of one round, up to twice this
|
|
16
|
+
* units budget units, the unit a tick's budget is counted in: what every handler of the room used, and
|
|
17
|
+
* what each person's browser used running the game's `move` for its own body
|
|
18
|
+
* everyTick the room is rebuilt from its save and compared before every one of this many first ticks,
|
|
19
|
+
* every and before every so-many-th tick after that,
|
|
20
|
+
* restoreBytes until this many bytes of save have been rebuilt
|
|
21
|
+
* companionTicks a game with companions is played a second, short time with them seated: this many ticks, on an
|
|
22
|
+
* eighth as many units and bytes again
|
|
23
|
+
* A light game uses its ticks, a second or two of processor time. A heavy one uses its units: about five seconds.
|
|
24
|
+
*/
|
|
25
|
+
export const ALLOWANCE = Object.freeze({ ticks: 18_000, units: 280_000_000, everyTick: 200, every: 29, restoreBytes: 8_000_000, companionTicks: 2_400 });
|
|
26
|
+
/** `homie-studio build --long-check`: eight times the play, for a person or a CI run that wants it. */
|
|
27
|
+
export const LONG_ALLOWANCE = Object.freeze({ ...ALLOWANCE, ticks: 8 * ALLOWANCE.ticks, units: 8 * ALLOWANCE.units, restoreBytes: 8 * ALLOWANCE.restoreBytes, companionTicks: 8 * ALLOWANCE.companionTicks, full: true });
|
|
28
|
+
|
|
29
|
+
/** The ordered declarations a save depends on. Functions contribute their presence, never their implementation. */
|
|
30
|
+
export function stateHash(c) {
|
|
31
|
+
const { kinds, events, commands, effects, shared, rounds, bots, roomOn, asks, map, dims, contract, seats, save } = c;
|
|
32
|
+
return createHash('sha256').update(JSON.stringify({ kinds, events, commands, effects, shared, rounds, bots, roomOn, asks, map, dims, contract, seats, tickHz: c.settings.tickHz, save }, (k, v) => typeof v === 'function' ? true : v)).digest('hex').slice(0, 32);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Run a linked bundle (rules-build.mjs `loadRules` with `bundleOnly`) in the worker. Resolves with what the worker
|
|
37
|
+
* found; rejects with the lines to fix. `data` is { id, tune, map, room, seats, vocab, sites }.
|
|
38
|
+
*/
|
|
39
|
+
export function runRulesWorker(bundle, data, { declarationsOnly = false, allowance = ALLOWANCE, memoryMb = 512, superviseSeconds = 3600 } = {}) {
|
|
40
|
+
return new Promise((resolve, reject) => {
|
|
41
|
+
let done = false; let beats = 0;
|
|
42
|
+
const worker = new Worker(new URL('./rules-check-worker.mjs', import.meta.url), {
|
|
43
|
+
workerData: { bundle, data, declarationsOnly, allowance: { ...ALLOWANCE, ...allowance } },
|
|
44
|
+
execArgv: [], // The bundle is self-contained; the parent's flags and loaders do not belong in this worker.
|
|
45
|
+
resourceLimits: { maxOldGenerationSizeMb: memoryMb, stackSizeMb: 4 },
|
|
46
|
+
});
|
|
47
|
+
const finish = async (error, result) => {
|
|
48
|
+
if (done) return;
|
|
49
|
+
done = true; clearInterval(supervisor);
|
|
50
|
+
await worker.terminate();
|
|
51
|
+
if (error) reject(error); else resolve(result);
|
|
52
|
+
};
|
|
53
|
+
const stopped = (how) => new Error(`games/${data.id}: the check itself stopped ${how}, before it could decide anything about this game. This is a fault in Homie's check, not in the game's rules: report the game with this message.`);
|
|
54
|
+
// The play ends by its allowance, hundreds of times sooner than this. Beats are counted, not a clock read, so a
|
|
55
|
+
// computer that slept is not mistaken for a check that hung.
|
|
56
|
+
const supervisor = setInterval(() => { if ((beats += 1) > superviseSeconds) finish(stopped(`after running for more than ${superviseSeconds} seconds`)); }, 1000);
|
|
57
|
+
supervisor.unref();
|
|
58
|
+
worker.on('message', (m) => { if (m.error) finish(new Error(m.error)); else finish(null, m.result); });
|
|
59
|
+
worker.on('error', (e) => finish(stopped(e.code === 'ERR_WORKER_OUT_OF_MEMORY' ? `when it passed its ${memoryMb} MB of memory` : `with an error of its own (${e.message})`)));
|
|
60
|
+
worker.on('exit', (code) => finish(stopped(`when its thread exited (${code})`)));
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The same, from a game's guarded and linked module (`code`: rules-guard.mjs `guardRules`). */
|
|
65
|
+
export async function checkRules(esbuild, root, id, code, data, options = {}) {
|
|
66
|
+
// Imported here, not at the top: the worker imports this file for `stateHash`, and has no use for the build's tools.
|
|
67
|
+
const { loadRules } = await import('./rules-build.mjs');
|
|
68
|
+
return runRulesWorker(await loadRules(esbuild, root, id, code, { bundleOnly: true }), { id, ...data }, options);
|
|
69
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The type compiler in a thread of its own, so a compiler that fails or never ends does not take the build with it.
|
|
3
|
+
*
|
|
4
|
+
* Like the play (rules-check.mjs), it is ended from outside by counted beats only, thousands of times past what a
|
|
5
|
+
* game's types take, and that end is said to be the compiler's fault, never a type error in the game. No clock is
|
|
6
|
+
* read, and nothing here decides anything about the rules.
|
|
7
|
+
*/
|
|
8
|
+
import { Worker } from 'node:worker_threads';
|
|
9
|
+
export function runRulesCompiler(compiler, args, { superviseSeconds = 3600 } = {}) {
|
|
10
|
+
return new Promise((resolve, reject) => {
|
|
11
|
+
let stdout = ''; let stderr = ''; let done = false; let beats = 0;
|
|
12
|
+
const worker = new Worker(`
|
|
13
|
+
const {parentPort,workerData} = require('node:worker_threads');
|
|
14
|
+
process.argv = [process.execPath,workerData.compiler,...workerData.args];
|
|
15
|
+
process.stdout.write = (text) => {parentPort.postMessage({stdout:String(text)});return true;};
|
|
16
|
+
process.stderr.write = (text) => {parentPort.postMessage({stderr:String(text)});return true;};
|
|
17
|
+
require(workerData.compiler);
|
|
18
|
+
`, { eval: true, workerData: { compiler, args }, resourceLimits: { maxOldGenerationSizeMb: 1536 } });
|
|
19
|
+
const finish = async (error, status) => {
|
|
20
|
+
if (done) return;
|
|
21
|
+
done = true; clearInterval(supervisor); await worker.terminate();
|
|
22
|
+
if (error) reject(error); else resolve({ stdout, stderr, status });
|
|
23
|
+
};
|
|
24
|
+
const supervisor = setInterval(() => { if ((beats += 1) > superviseSeconds) finish(new Error(`The rules compiler was still running after ${superviseSeconds} seconds and was ended from outside. This is a fault in the compiler, not a type error in the rules: report the game with this message.`)); }, 1000);
|
|
25
|
+
supervisor.unref();
|
|
26
|
+
worker.on('message', (message) => { stdout += message.stdout ?? ''; stderr += message.stderr ?? ''; if (stdout.length + stderr.length > 8 * 1024 * 1024) finish(new Error('The rules compiler produced more than 8 MB of diagnostics.')); });
|
|
27
|
+
worker.on('error', (error) => finish(error));
|
|
28
|
+
worker.on('exit', (code) => finish(null, code));
|
|
29
|
+
});
|
|
30
|
+
}
|
package/lib/rules-guard.mjs
CHANGED
|
@@ -416,13 +416,13 @@ export function guardSource(code, { file = 'rules.js', map = null, linked = fals
|
|
|
416
416
|
const isNum = (n, scope) => num(n, scope, new Set(), numIds);
|
|
417
417
|
|
|
418
418
|
/* ------------------------------------------------------------ the rewrite */
|
|
419
|
-
const line = (node) => t.numericLiteral(lineOf(node));
|
|
419
|
+
const line = (node) => call('l', [t.stringLiteral(file), t.numericLiteral(lineOf(node))]);
|
|
420
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', []));
|
|
421
|
+
const counted = (node) => t.expressionStatement(call('t', [t.stringLiteral(file), t.numericLiteral(lineOf(node))]));
|
|
422
422
|
const block = (path, key) => {
|
|
423
423
|
const body = path.node[key];
|
|
424
424
|
if (!t.isBlockStatement(body)) path.node[key] = t.blockStatement(t.isExpression(body) ? [t.returnStatement(body)] : [body]);
|
|
425
|
-
path.node[key].body.unshift(counted());
|
|
425
|
+
path.node[key].body.unshift(counted(path.node));
|
|
426
426
|
};
|
|
427
427
|
const args = (list) => t.arrayExpression(list.map((a) => (t.isSpreadElement(a) ? t.spreadElement(call('sp', [a.argument, line(a)])) : a)));
|
|
428
428
|
const globalObject = (path, obj) => t.isIdentifier(obj) && !path.scope.getBinding(obj.name);
|
|
@@ -715,6 +715,7 @@ export async function guardFiles(esbuild, root, gameDir, entry) {
|
|
|
715
715
|
const files = new Map();
|
|
716
716
|
const problems = [];
|
|
717
717
|
const todo = [entry];
|
|
718
|
+
const sites = {};
|
|
718
719
|
while (todo.length) {
|
|
719
720
|
const file = todo.pop();
|
|
720
721
|
if (files.has(file)) continue;
|
|
@@ -725,15 +726,33 @@ export async function guardFiles(esbuild, root, gameDir, entry) {
|
|
|
725
726
|
const first = error.errors?.[0];
|
|
726
727
|
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
|
}
|
|
729
|
+
const original = parse(readFileSync(file, 'utf8'), { sourceType: 'module', plugins: ['typescript'] });
|
|
730
|
+
traverse(original, {
|
|
731
|
+
ExportDefaultDeclaration(path) { sites.default = `${shown}:${path.node.loc.start.line}`; },
|
|
732
|
+
'ObjectProperty|ObjectMethod'(path) {
|
|
733
|
+
const keys = [];
|
|
734
|
+
for (let p = path; p; p = p.parentPath) if (p.isObjectProperty() || p.isObjectMethod()) {
|
|
735
|
+
if (!p.node.computed) keys.unshift(p.node.key.name ?? p.node.key.value);
|
|
736
|
+
}
|
|
737
|
+
if (keys.length) {
|
|
738
|
+
sites[keys.join('.')] = `${shown}:${path.node.loc.start.line}`;
|
|
739
|
+
const binding = path.findParent(p => p.isVariableDeclarator());
|
|
740
|
+
if (binding?.node.id?.name === 'move') sites[`move.${keys.join('.')}`] = `${shown}:${path.node.loc.start.line}`;
|
|
741
|
+
}
|
|
742
|
+
},
|
|
743
|
+
});
|
|
728
744
|
const res = guardSource(stripped.code, { file: shown, map: stripped.map });
|
|
729
745
|
problems.push(...res.problems);
|
|
730
746
|
files.set(file, res.code);
|
|
731
747
|
for (const m of stripped.code.matchAll(/^\s*import\s[^'"]*['"](\.\.?\/[^'"]+)['"]/gm)) {
|
|
732
748
|
const next = resolveOwn(file, m[1]);
|
|
733
|
-
|
|
749
|
+
const line = original.program.body.find(n => n.type === 'ImportDeclaration' && n.source.value === m[1])?.loc?.start.line ?? 1;
|
|
750
|
+
if (next && relative(gameDir,next).startsWith('..')) problems.push({file:shown,line,message:'rules import only files of their own game'});
|
|
751
|
+
else if (next) todo.push(next);
|
|
752
|
+
else problems.push({ file: shown, line, message: `"${m[1]}" is not a file of this game` });
|
|
734
753
|
}
|
|
735
754
|
}
|
|
736
|
-
return { files, problems };
|
|
755
|
+
return { files, problems, sites };
|
|
737
756
|
}
|
|
738
757
|
|
|
739
758
|
/** An esbuild plugin that serves a game's guarded files in place of its sources (`stub`: files a bundle must not hold at all). */
|
|
@@ -755,7 +774,7 @@ export function guardedPlugin(files, { stub = [] } = {}) {
|
|
|
755
774
|
* Returns { ok, code, problems, files }. `code` imports only Homie's rules module and the guard.
|
|
756
775
|
*/
|
|
757
776
|
export async function guardRules(esbuild, root, gameDir, { entry = join(gameDir, 'src', 'rules.ts') } = {}) {
|
|
758
|
-
const { files, problems } = await guardFiles(esbuild, root, gameDir, entry);
|
|
777
|
+
const { files, problems, sites } = await guardFiles(esbuild, root, gameDir, entry);
|
|
759
778
|
if (problems.length) return { ok: false, code: null, problems, files };
|
|
760
779
|
let linked;
|
|
761
780
|
try {
|
|
@@ -766,5 +785,5 @@ export async function guardRules(esbuild, root, gameDir, { entry = join(gameDir,
|
|
|
766
785
|
}
|
|
767
786
|
const code = linked.outputFiles[0].text;
|
|
768
787
|
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 };
|
|
788
|
+
return { ok: !again.problems.length, code: again.problems.length ? null : code, problems: again.problems, files, sites };
|
|
770
789
|
}
|