@homie-rocks/studio 0.35.1 → 0.36.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 +24 -0
- package/agents/agents.ts +8 -6
- package/bin/homie-studio.mjs +9 -6
- package/lab/lab.ts +15 -0
- package/lib/build.mjs +20 -15
- package/lib/dev.mjs +16 -1
- package/lib/lab-check.mjs +18 -3
- package/lib/lab.mjs +21 -7
- package/lib/mcp-tools.mjs +2 -1
- 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/standalone-files.mjs +2 -2
- package/lib/standalone.mjs +6 -2
- package/lib/template-history.json +44 -1
- package/lib/typecheck.mjs +52 -3
- package/netplay/NETPLAY.md +123 -6
- package/netplay/netplay.ts +289 -53
- package/netplay/rules-output.mjs +85 -0
- package/package.json +3 -2
- 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/standalone/shell/shell.js +8 -6
- package/worker/brain.mjs +2 -1
- package/worker/hosted.mjs +1 -1
- package/worker/index.mjs +7 -11
- package/worker/limits.mjs +41 -0
- package/worker/pages.mjs +4 -2
- package/worker/room.mjs +136 -63
- package/worker/version.mjs +1 -1
package/rules/host.ts
CHANGED
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
* after five of them, not after five seconds' worth of ticks at its rate.
|
|
44
44
|
* =============================================================================
|
|
45
45
|
*/
|
|
46
|
-
import { useAgents, type Agents, type AgentsSaved, type Vocabulary } from '../agents/agents.ts';
|
|
46
|
+
import { AGENT_RULES, useAgents, type Agents, type AgentsSaved, type Vocabulary } from '../agents/agents.ts';
|
|
47
47
|
import { DEFAULT_POLICY, type Netplay, type Peer, type Policy, type Slot } from '../netplay/netplay.ts';
|
|
48
48
|
import { createCore } from './core.ts';
|
|
49
49
|
import type { Core, CoreOut, Driver, SavedCore, StepInput } from './core.ts';
|
|
@@ -58,6 +58,10 @@ export interface HostClock {
|
|
|
58
58
|
}
|
|
59
59
|
export interface HostOptions {
|
|
60
60
|
game: string;
|
|
61
|
+
/** Told of every handler as it ends, and what it threw if it threw (core.ts `observe`): the build check listens. */
|
|
62
|
+
observe?(kind: string, handler: string, error?: string): void;
|
|
63
|
+
/** Told of a written value the runtime changed to make it fit (core.ts `noted`): the build check listens. */
|
|
64
|
+
noted?(kind: string, handler: string, what: string, at: string, written: string): void;
|
|
61
65
|
/** The game's build, for the log. */
|
|
62
66
|
build?: string;
|
|
63
67
|
compiled: Compiled;
|
|
@@ -72,11 +76,15 @@ export interface HostOptions {
|
|
|
72
76
|
restoreEpoch?: number;
|
|
73
77
|
startDelayMs?: number;
|
|
74
78
|
onTick?(): void;
|
|
79
|
+
/** Only the shared server isolate can attribute timer lateness to another tick. */
|
|
80
|
+
enforceOverrun?: boolean;
|
|
75
81
|
log?(line: Record<string, unknown>): void;
|
|
76
82
|
/** A number in [0, 1) for a fresh epoch and seed. */
|
|
77
83
|
random?(): number;
|
|
78
84
|
/** A room saved by `save()`, to carry on from. */
|
|
79
85
|
restore?: Uint8Array | null;
|
|
86
|
+
/** A browser promotion starts immediately; server recovery starts paused by default. */
|
|
87
|
+
startPaused?: boolean;
|
|
80
88
|
stage?: string;
|
|
81
89
|
onPause?(): void;
|
|
82
90
|
onResume?(): void;
|
|
@@ -94,6 +102,8 @@ export interface Host {
|
|
|
94
102
|
start(): void;
|
|
95
103
|
pause(): void;
|
|
96
104
|
resume(): void;
|
|
105
|
+
/** Publish the round, the roster, the shared state and the capabilities again (a browser host's relay restarted). */
|
|
106
|
+
announce(): void;
|
|
97
107
|
/** Stop for good: the room has ended. */
|
|
98
108
|
stop(): void;
|
|
99
109
|
/** Run the next tick now, whatever the clock says (tests, and the build check's runs). Like the timer's own tick, it never throws. */
|
|
@@ -157,7 +167,40 @@ export function createHost(o: HostOptions): Host {
|
|
|
157
167
|
if (saved) {
|
|
158
168
|
const check = (ok: unknown): void => { if (!ok) throw new Error('saved host inputs are invalid'); };
|
|
159
169
|
const uint = (n: unknown): boolean => Number.isSafeInteger(n) && (n as number) >= 0;
|
|
170
|
+
check(Object.keys(saved).every(k => ['v', 'core', 'names', 'agents', 'guideViews', 'queues', 'inputs'].includes(k)));
|
|
160
171
|
check(saved.core && Array.isArray(saved.names) && Array.isArray(saved.queues));
|
|
172
|
+
if (saved.agents !== undefined) {
|
|
173
|
+
const tables = ['goals', 'asks', 'avoid', 'sayAt', 'viewAt', 'floorAt', 'askAt'];
|
|
174
|
+
check(saved.agents && Object.keys(saved.agents).every(k => tables.includes(k)));
|
|
175
|
+
const data = (v: unknown): boolean => Boolean(v && typeof v === 'object' && !Array.isArray(v));
|
|
176
|
+
const stamp = (v: unknown, future = 0): boolean => typeof v === 'number' && Number.isFinite(v) && v >= 0 && v <= saved.core.tick * period + future;
|
|
177
|
+
for (const key of tables) {
|
|
178
|
+
const rows = own(saved.agents, key) as any;
|
|
179
|
+
check(Array.isArray(rows) && rows.length <= c.seats);
|
|
180
|
+
const seats = new Set();
|
|
181
|
+
for (const row of rows) {
|
|
182
|
+
check(Array.isArray(row) && row.length === 2 && uint(row[0]) && row[0] < c.seats && !seats.has(row[0])); seats.add(row[0]);
|
|
183
|
+
const v = row[1];
|
|
184
|
+
if (key === 'goals') check(data(v) && Object.keys(v).every(k => ['goal', 'args', 'from', 'at', 'state', 'asked'].includes(k)) && typeof v.goal === 'string' && data(v.args) && ['brain', 'floor'].includes(v.from) && ['active', 'done', 'failed'].includes(v.state) && stamp(v.at) && (v.asked === undefined || typeof v.asked === 'boolean'));
|
|
185
|
+
else if (key === 'asks') {
|
|
186
|
+
check(Array.isArray(v) && v.length <= 3);
|
|
187
|
+
for (const a of v) check(data(a) && Object.keys(a).every(k => ['k', 'args', 'from', 'at'].includes(k)) && typeof a.k === 'string' && data(a.args) && uint(a.from) && a.from < c.seats && stamp(a.at));
|
|
188
|
+
} else if (key === 'avoid') {
|
|
189
|
+
check(Array.isArray(v) && v.length <= c.seats && new Set(v.map(a => a?.[0])).size === v.length);
|
|
190
|
+
for (const a of v) check(Array.isArray(a) && a.length === 2 && uint(a[0]) && a[0] < c.seats && stamp(a[1], AGENT_RULES.leaveMs));
|
|
191
|
+
} else check(stamp(v));
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
if (saved.guideViews !== undefined) {
|
|
196
|
+
check(Array.isArray(saved.guideViews) && saved.guideViews.length <= c.seats && new Set(saved.guideViews.map(row => row?.[0])).size === saved.guideViews.length);
|
|
197
|
+
for (const row of saved.guideViews) {
|
|
198
|
+
check(Array.isArray(row) && row.length === 2);
|
|
199
|
+
const [seat, v] = row;
|
|
200
|
+
check(uint(seat) && seat < c.seats && v && Object.keys(v).every(k => ['id', 'at', 'value'].includes(k)) && typeof v.id === 'string' && uint(v.at) && v.at <= saved.core.tick);
|
|
201
|
+
check(JSON.stringify(v.value) === JSON.stringify(coerceFields(c.view, v.value, c.dims)));
|
|
202
|
+
}
|
|
203
|
+
}
|
|
161
204
|
check(saved.names.length <= c.seats && saved.queues.length <= c.seats);
|
|
162
205
|
for (const rows of [saved.names, saved.queues, ...(saved.inputs === undefined ? [] : [saved.inputs])]) {
|
|
163
206
|
check(Array.isArray(rows) && rows.every((row) => Array.isArray(row)));
|
|
@@ -183,9 +226,9 @@ export function createHost(o: HostOptions): Host {
|
|
|
183
226
|
}
|
|
184
227
|
}
|
|
185
228
|
if (saved && saved.v !== 1) throw new Error('this save was written by another version of the runtime');
|
|
186
|
-
const core = createCore(c, { seed: Math.floor(random() * 4294967296) >>> 0, epoch: (Math.floor(random() * 4294967295) >>> 0) + 1, restore: saved?.core ?? null, restoreEpoch: o.restoreEpoch, stage: o.stage, decisions: !o.check });
|
|
229
|
+
const core = createCore(c, { observe: o.observe, noted: o.noted, seed: Math.floor(random() * 4294967296) >>> 0, epoch: saved ? undefined : (Math.floor(random() * 4294967295) >>> 0) + 1, restore: saved?.core ?? null, restoreEpoch: o.restoreEpoch, stage: o.stage, decisions: !o.check });
|
|
187
230
|
let epoch = core.epoch;
|
|
188
|
-
if (saved && o.restoreEpoch !== undefined) for (const body of core.bodies()) if (body.driver !== 'bot' && body.owner !== 'reserved') core.seatAway(body.seat, true);
|
|
231
|
+
if (saved && (o.startPaused ?? o.restoreEpoch !== undefined)) for (const body of core.bodies()) if (body.driver !== 'bot' && body.owner !== 'reserved') core.seatAway(body.seat, true);
|
|
189
232
|
let lastSaveAt = o.clock.now();
|
|
190
233
|
let saveTick = core.tick;
|
|
191
234
|
let forceSave = false;
|
|
@@ -203,7 +246,7 @@ export function createHost(o: HostOptions): Host {
|
|
|
203
246
|
let savedAgents = saved?.agents;
|
|
204
247
|
const guideOwners = new Map(core.bodies().map((b) => [b.seat, `${b.id}/${b.owner}/${b.driver}`]));
|
|
205
248
|
const guideViews = new Map<number, { id: string; at: number; value: Record<string, unknown> }>();
|
|
206
|
-
for (const [seat, v] of
|
|
249
|
+
for (const [seat, v] of saved?.guideViews ?? []) if (core.bodyOf(seat)?.id === v.id) guideViews.set(seat, v);
|
|
207
250
|
const agentEvents = new Set<(m: any) => void>();
|
|
208
251
|
let agentInbox: Record<string, unknown>[] = [];
|
|
209
252
|
function agentSlots(brainsOnly = false): Slot[] {
|
|
@@ -238,7 +281,7 @@ export function createHost(o: HostOptions): Host {
|
|
|
238
281
|
send: (k: string, d: unknown, to?: number) => o.send({ t: 'ev', k, d, ...(to !== undefined ? { to } : {}) }),
|
|
239
282
|
} as unknown as Netplay;
|
|
240
283
|
agents = useAgents(net, vocab, { roles: ['guide', 'party'], manual: true, carryFloor: true, now: () => core.tick * period, restore: savedAgents,
|
|
241
|
-
view: agentView, decide: (v, ctx) => core.guide(ctx.slot, v),
|
|
284
|
+
view: agentView, decide: (v, ctx) => core.guide(ctx.slot, { ...v, goal: ctx.goal ? { ...ctx.goal, at: Math.round(ctx.goal.at / period) } : null }),
|
|
242
285
|
});
|
|
243
286
|
savedAgents = undefined;
|
|
244
287
|
agents.on('goal', (d) => o.send({ t: 'ev', k: 'agent:goal', d }));
|
|
@@ -269,7 +312,7 @@ export function createHost(o: HostOptions): Host {
|
|
|
269
312
|
}
|
|
270
313
|
}
|
|
271
314
|
let running = false;
|
|
272
|
-
let paused = Boolean(saved && o.restoreEpoch !== undefined);
|
|
315
|
+
let paused = Boolean(saved && (o.startPaused ?? o.restoreEpoch !== undefined));
|
|
273
316
|
let ended = false;
|
|
274
317
|
/** The pending timer, and whether there is one. `armed` is the truth: a clock may hand back any value as its handle. */
|
|
275
318
|
let timer: unknown = null;
|
|
@@ -288,6 +331,14 @@ export function createHost(o: HostOptions): Host {
|
|
|
288
331
|
let overTicks = 0;
|
|
289
332
|
let overBlamed = 0;
|
|
290
333
|
let lastStart = 0;
|
|
334
|
+
let roundRebaseAt = -Infinity;
|
|
335
|
+
let roundRebasePending = false;
|
|
336
|
+
function publishRebase(now: number): void {
|
|
337
|
+
roundRebasePending = true;
|
|
338
|
+
if (now - roundRebaseAt < 250) return;
|
|
339
|
+
roundRebaseAt = now; roundRebasePending = false;
|
|
340
|
+
const { results, ...round } = core.round(); handle([round], now, true);
|
|
341
|
+
}
|
|
291
342
|
/** When the run of failed ticks this room is in began, or null. */
|
|
292
343
|
let failingSince: number | null = null;
|
|
293
344
|
/** Where in a tick the runtime is, for the log line of a fault. */
|
|
@@ -391,9 +442,11 @@ export function createHost(o: HostOptions): Host {
|
|
|
391
442
|
names.set(seat, typeof p.name === 'string' ? p.name.slice(0, 40) : '');
|
|
392
443
|
present.set(seat, driver === 'person');
|
|
393
444
|
// The relay numbers every stay in a seat (`occ`): the same number is the same holder, back.
|
|
394
|
-
|
|
445
|
+
const owner = `p${Number.isInteger(p.occ) ? p.occ : typeof p.id === 'string' ? p.id.slice(0, 40) : seat}`;
|
|
446
|
+
const previous = core.bodies().find((s) => s.seat === seat);
|
|
447
|
+
core.seatJoin({ seat, driver, owner });
|
|
395
448
|
core.seatAway(seat, false);
|
|
396
|
-
queues.delete(seat);
|
|
449
|
+
if (!previous || previous.driver !== driver || previous.owner !== owner) queues.delete(seat);
|
|
397
450
|
rosterText = '';
|
|
398
451
|
if (people() > 0) { if (!running && !paused) start(); else if (paused) resume(); }
|
|
399
452
|
return;
|
|
@@ -462,16 +515,16 @@ export function createHost(o: HostOptions): Host {
|
|
|
462
515
|
rosterText = text;
|
|
463
516
|
o.send({ t: 'roster', slots });
|
|
464
517
|
}
|
|
465
|
-
function handle(out: CoreOut[], now: number): void {
|
|
518
|
+
function handle(out: CoreOut[], now: number, retime = false): void {
|
|
466
519
|
let roster = false;
|
|
467
520
|
for (const x of out) {
|
|
468
521
|
if (x.t === 'round') {
|
|
469
522
|
if (x.phase === 'over') forceSave = true;
|
|
470
523
|
const ms = (t: number): number => Math.round(now + (t - core.tick) * period);
|
|
471
524
|
const drivers = new Map(core.bodies().map((b) => [b.seat, b.driver]));
|
|
472
|
-
o.send({ t: 'round', round: {
|
|
525
|
+
o.send({ t: 'round', ...(retime ? { retime: true } : {}), round: {
|
|
473
526
|
n: x.n, phase: x.phase, startedAt: ms(x.startedAt), endsAt: x.endsAt ? ms(x.endsAt) : ms(core.tick + 86_400 * tickHz),
|
|
474
|
-
...(x.results ? { results: x.results.map((r) => ({ slot: r.seat, seat: r.driver === 'bot' ? null : r.seat, name: nameOf(r.seat, drivers.get(r.seat) ?? r.driver), score: r.score, bot: r.driver === 'bot', ...(r.driver === 'ai' ? { agent: true } : {}), place: r.place })) } : {}),
|
|
527
|
+
...(!retime && x.results ? { results: x.results.map((r) => ({ slot: r.seat, seat: r.driver === 'bot' ? null : r.seat, name: nameOf(r.seat, drivers.get(r.seat) ?? r.driver), score: r.score, bot: r.driver === 'bot', ...(r.driver === 'ai' ? { agent: true } : {}), place: r.place })) } : {}),
|
|
475
528
|
} });
|
|
476
529
|
} else if (x.t === 'ask') o.send({ t: 'decide', n: x.n, state: x.state, questions: x.questions });
|
|
477
530
|
else if (x.t === 'goalDone') agents?.done(x.seat, x.ok);
|
|
@@ -508,6 +561,7 @@ export function createHost(o: HostOptions): Host {
|
|
|
508
561
|
for (const p of presses) values[p] = true;
|
|
509
562
|
inputs.set(seat, { values, claim: q.claim });
|
|
510
563
|
}
|
|
564
|
+
if (roundRebasePending) publishRebase(now);
|
|
511
565
|
phase = 'step';
|
|
512
566
|
core.step(inputs, guideBeat);
|
|
513
567
|
phase = 'frames';
|
|
@@ -590,7 +644,14 @@ export function createHost(o: HostOptions): Host {
|
|
|
590
644
|
const due = dueOf(core.tick + 1);
|
|
591
645
|
if (now + 0.5 < due) break;
|
|
592
646
|
// The overrun check: a tick that starts more than a period late blames the tick that held it up.
|
|
593
|
-
if (now - due > period) {
|
|
647
|
+
if (now - due > period) {
|
|
648
|
+
stats.late += 1;
|
|
649
|
+
if (o.enforceOverrun) blameLate(now, period);
|
|
650
|
+
// A browser cannot say whose fault a late timer is. Up to a tenth of a second late (two periods, if that
|
|
651
|
+
// is longer) it catches up as the server does: a 20-tick room on a timer every 70 ms, or a 60-tick room on
|
|
652
|
+
// one every 37 ms, runs at its rate. Later than that its clock runs slow, and a slow host yields.
|
|
653
|
+
else if (now - due > Math.max(2 * period, 100)) { base = { ms: now - period, tick: core.tick }; publishRebase(now); }
|
|
654
|
+
}
|
|
594
655
|
// Blamed since its last tick began: a run of slow ticks begins (when the tick blamed did), or goes on.
|
|
595
656
|
if (blamed) { if (overSince === null) { overSince = lastStart; overTicks = 0; overBlamed = 0; } overBlamed += 1; }
|
|
596
657
|
blamed = false;
|
|
@@ -600,7 +661,7 @@ export function createHost(o: HostOptions): Host {
|
|
|
600
661
|
// A room whose ticks have run slow for five seconds of the clock ends, and the log names the handler that used the most units.
|
|
601
662
|
else if (now - overSince >= OVERRUN_MS) { end('overrun', { worst: core.stats.worst, maxUnits: core.stats.maxUnits, seconds: Math.round((now - overSince) / 100) / 10 }); return; }
|
|
602
663
|
}
|
|
603
|
-
recent.push({ who: self, at: now });
|
|
664
|
+
if (o.enforceOverrun) recent.push({ who: self, at: now });
|
|
604
665
|
if (recent.length > 8) recent.shift();
|
|
605
666
|
lastStart = now;
|
|
606
667
|
tick(now);
|
|
@@ -608,7 +669,7 @@ export function createHost(o: HostOptions): Host {
|
|
|
608
669
|
}
|
|
609
670
|
// Far behind (the isolate was busy, or the machine slept): the clock runs slow. It never jumps to catch up.
|
|
610
671
|
// The tick after a slip starts on time by the new clock, so the tick that ran last is judged here, before the slip hides it.
|
|
611
|
-
if (running && o.clock.now() - dueOf(core.tick + 1) > 4 * period) { const now = o.clock.now(); blameLate(now, period); base = { ms: now, tick: core.tick }; stats.slips += 1; }
|
|
672
|
+
if (running && o.clock.now() - dueOf(core.tick + 1) > 4 * period) { const now = o.clock.now(); if (o.enforceOverrun) blameLate(now, period); base = { ms: now, tick: core.tick }; stats.slips += 1; publishRebase(now); }
|
|
612
673
|
} catch (error) {
|
|
613
674
|
// Thrown outside any tick (the clock, the blame): a failed turn of the loop, counted like a failed tick.
|
|
614
675
|
fault('wake', error);
|
|
@@ -629,6 +690,7 @@ export function createHost(o: HostOptions): Host {
|
|
|
629
690
|
const caps = c.kinds.some((k) => k.think) ? ['skill'] : [];
|
|
630
691
|
if (c.kinds.some((k) => k.guide && k.think)) caps.push('agents');
|
|
631
692
|
if (caps.length) o.send({ t: 'caps', caps });
|
|
693
|
+
if (saved) { handle([core.round()], base.ms); }
|
|
632
694
|
log({ ev: 'host-start', game: o.game, tick: core.tick, epoch, tickHz });
|
|
633
695
|
} catch (error) { fault('start', error); } finally { keepTime(); }
|
|
634
696
|
}
|
|
@@ -647,11 +709,24 @@ export function createHost(o: HostOptions): Host {
|
|
|
647
709
|
// The room resumes at the tick it paused on: no tick is skipped and no timer fires for the gap.
|
|
648
710
|
base = { ms: o.clock.now() + (firstTick ? o.startDelayMs ?? 0 : 0), tick: core.tick };
|
|
649
711
|
overSince = null; blamed = false; failingSince = null; lastStart = base.ms;
|
|
712
|
+
handle([core.round()], base.ms);
|
|
650
713
|
for (const q of queues.values()) q.lastFrameTick = core.tick;
|
|
651
714
|
log({ ev: 'host-resume', game: o.game, tick: core.tick });
|
|
652
715
|
o.onResume?.();
|
|
653
716
|
} catch (error) { fault('resume', error); } finally { keepTime(); }
|
|
654
717
|
}
|
|
718
|
+
/** What a relay keeps for whoever joins next, said again: the round with its results, the roster, the shared state, the capabilities. */
|
|
719
|
+
function announce(): void {
|
|
720
|
+
if (ended) return;
|
|
721
|
+
try {
|
|
722
|
+
const caps = c.kinds.some((k) => k.think) ? ['skill'] : [];
|
|
723
|
+
if (c.kinds.some((k) => k.guide && k.think)) caps.push('agents');
|
|
724
|
+
if (caps.length) o.send({ t: 'caps', caps });
|
|
725
|
+
rosterText = '';
|
|
726
|
+
handle([core.round()], o.clock.now());
|
|
727
|
+
o.send({ t: 'state', k: 'shared', d: core.shared() });
|
|
728
|
+
} catch (error) { fault('announce', error); }
|
|
729
|
+
}
|
|
655
730
|
function stop(): void {
|
|
656
731
|
running = false; paused = false; ended = true;
|
|
657
732
|
disarm();
|
|
@@ -691,7 +766,7 @@ export function createHost(o: HostOptions): Host {
|
|
|
691
766
|
get running() { return running; },
|
|
692
767
|
get paused() { return paused; },
|
|
693
768
|
get people() { return people(); },
|
|
694
|
-
frame, start, pause, resume, stop,
|
|
769
|
+
frame, start, pause, resume, stop, announce,
|
|
695
770
|
tickNow: () => {
|
|
696
771
|
if (ended) return;
|
|
697
772
|
let now = lastStart;
|
package/rules/math.ts
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* =============================================================================
|
|
14
14
|
*/
|
|
15
15
|
import { brand, charge, own, put } from './guard.ts';
|
|
16
|
+
import { num } from './pack.ts';
|
|
16
17
|
|
|
17
18
|
export interface Vec3 { readonly x: number; readonly y: number; readonly z: number }
|
|
18
19
|
|
|
@@ -264,3 +265,18 @@ export function sweepMap(map: MapShapes, body: unknown, delta: unknown, radius:
|
|
|
264
265
|
put(body, 'pos', at);
|
|
265
266
|
return Object.freeze({ at, normal: v(h.nx, h.ny, 0) });
|
|
266
267
|
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* What a browser hands the game's `move` as `ctx`: the tick, the step, the public tunables, this maths and the static
|
|
271
|
+
* map. One function, so that the browser (view.ts) and the person the build check plays hold the same one. The
|
|
272
|
+
* server's own (core.ts `moveCtx`) is made beside the rest of its world, and charges as that does.
|
|
273
|
+
*/
|
|
274
|
+
export function moveContext(o: { tick: () => number; tickHz: number; tune: unknown; map: MapShapes; name: string; spots: Readonly<Record<string, readonly unknown[]>>; radius: () => number; dims: number }): unknown {
|
|
275
|
+
const none = Object.freeze([]);
|
|
276
|
+
return brand(Object.freeze({
|
|
277
|
+
get tick() { return o.tick(); }, dt: 1 / o.tickHz, tune: o.tune, math,
|
|
278
|
+
// As the server's `ctx.ticks` reads it: a plain number, or nothing.
|
|
279
|
+
ticks: (seconds: unknown): number => { const s = num(seconds); const n = Math.round(s * o.tickHz); return s > 0 && Number.isFinite(n) ? Math.max(1, n) : 0; },
|
|
280
|
+
map: brand(Object.freeze({ name: o.name, spot: (name: string) => (own(o.spots, name) as readonly unknown[] | undefined)?.[0], spots: (name: string) => own(o.spots, name) ?? none, sweep: (body: unknown, delta: unknown) => sweepMap(o.map, body, delta, o.radius(), o.dims) })),
|
|
281
|
+
}));
|
|
282
|
+
}
|
package/rules/pack.ts
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
*/
|
|
20
20
|
import { cellsOf } from './rules.ts';
|
|
21
21
|
import type { Field, FieldList, Vec3 } from './rules.ts';
|
|
22
|
-
import { charge, keyCount, own, refusedName } from './guard.ts';
|
|
22
|
+
import { G, charge, keyCount, own, refusedName } from './guard.ts';
|
|
23
23
|
|
|
24
24
|
const RANGE: Record<string, [number, number]> = { u8: [0, 255], u16: [0, 65535], u32: [0, 4294967295], i8: [-128, 127], i16: [-32768, 32767], i32: [-2147483648, 2147483647], tick: [0, 4294967295], ticks: [0, 4294967295] };
|
|
25
25
|
/** `f.fix`: steps of 1/4096, to plus or minus 2^31 (a tick count with a fraction fits for the life of any room). */
|
|
@@ -45,6 +45,14 @@ const fr = Math.fround;
|
|
|
45
45
|
* whatever was handed in, and `est` says in a few steps how much that will be, so the
|
|
46
46
|
* caller charges the running handler before the work is done.
|
|
47
47
|
*
|
|
48
|
+
* WHAT IT CHANGED IS SAID, TO WHOEVER ASKS. A value that does not fit is stored as something else, here and nowhere
|
|
49
|
+
* else: a number that is not one becomes 0, a list is cut to its size. A room does that in silence, as it always has, and
|
|
50
|
+
* a handler is never stopped for it. The build check wants to know, so while a handler runs for a room that was given a
|
|
51
|
+
* listener (`G.note`, set by core.ts `run`), each such change is told to it with the field's name (`trail`) and what
|
|
52
|
+
* was done (`Adjusted`). With no listener nothing is built, counted or charged: one test of `G.note` and no more.
|
|
53
|
+
* Not told, because nothing is lost that a game would not mean: rounding (a fraction in a whole number, a number to a
|
|
54
|
+
* 32-bit float), an absent value taking the zero of its type, and a property a struct does not declare.
|
|
55
|
+
*
|
|
48
56
|
* What comes out is plain data, frozen: a list, a map and a struct are new frozen objects, a vector is a frozen
|
|
49
57
|
* `{ x, y, z }`. Stored state is therefore read-only everywhere, and a query result or an event can hand it out as it
|
|
50
58
|
* is, with no copy. A handler that changes a list in place is handed a copy to change (`thaw`), which is held to its
|
|
@@ -67,8 +75,39 @@ const DIRS = new WeakSet<object>();
|
|
|
67
75
|
/** Lists, maps and structs this file made, each with the declaration it was held to: the same value under the same declaration is not read twice. */
|
|
68
76
|
const MADE = new WeakMap<object, Field>();
|
|
69
77
|
const INIT = new WeakMap<Field, { v: unknown }>();
|
|
70
|
-
|
|
71
|
-
|
|
78
|
+
/*
|
|
79
|
+
* ONE ZERO. JavaScript has two, and the save and every frame are JSON, which writes both as `0`. A room that kept `-0`
|
|
80
|
+
* would divide by it one way before a restart and the other way after, and a browser would never have seen it at all. So
|
|
81
|
+
* every number that enters state has `+ 0` added, which changes `-0` to `0` and nothing else.
|
|
82
|
+
*/
|
|
83
|
+
const mk = (x: number, y: number, z: number): Vec3 => { const p = Object.freeze({ x: x + 0, y: y + 0, z: z + 0 }); VECS.add(p); return p; };
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* What was done to a written value that did not fit:
|
|
87
|
+
* nan not a number at all (NaN, or a text, a list, nothing a number can be read from): stored as 0
|
|
88
|
+
* infinite an infinite number where a fraction or a vector belongs: stored as 0
|
|
89
|
+
* range a whole number outside its range, an infinite one included: held to the nearest end of the range
|
|
90
|
+
* text a text longer than declared: cut
|
|
91
|
+
* list a list longer than declared: cut
|
|
92
|
+
* map a map with more keys than declared: the keys past its size dropped
|
|
93
|
+
* key a map key over 32 characters, or a name every object has: dropped
|
|
94
|
+
* kind something that is not a list where a list belongs, or not a plain object where a map or a struct does: stored empty
|
|
95
|
+
*/
|
|
96
|
+
export type Adjusted = 'nan' | 'infinite' | 'range' | 'text' | 'list' | 'map' | 'key' | 'kind';
|
|
97
|
+
/** The field being held to its type, outermost first (`['runner.fields', 'bag', 3]`), kept only while somebody listens. */
|
|
98
|
+
const trail: (string | number)[] = [];
|
|
99
|
+
/** Name what is about to be held to its type (core.ts, before each `coerce` of something the rules wrote); with no name, forget it. */
|
|
100
|
+
export function naming(...parts: (string | number)[]): void { if (G.note !== null) { trail.length = 0; for (const p of parts) trail.push(p); } }
|
|
101
|
+
function note(what: Adjusted, written: unknown): void {
|
|
102
|
+
let at = '';
|
|
103
|
+
for (const p of trail) at += typeof p === 'number' ? `[${p}]` : at ? `.${p}` : p;
|
|
104
|
+
(G.note as NonNullable<typeof G.note>)(what, at, said(written));
|
|
105
|
+
}
|
|
106
|
+
/** What the rules wrote where a list, a map or a struct belongs and is none. Absent (`undefined`, `null`) is the empty one, and is not said. */
|
|
107
|
+
const wrongKind = (v: unknown): void => { if (G.note !== null && v !== undefined && v !== null) note('kind', v instanceof Map ? 'a Map' : v instanceof Set ? 'a Set' : v); };
|
|
108
|
+
/** A number that is to be 0 because it is not finite: said, when it was written and not merely absent. */
|
|
109
|
+
function zeroed(v: unknown, x: number): 0 { if (G.note !== null && v !== undefined) note(x === Infinity || x === -Infinity ? 'infinite' : 'nan', v); return 0; }
|
|
110
|
+
const f32 = (a: unknown): number => { const n = num(a); const x = fr(n); return Number.isFinite(x) ? x : zeroed(a, n !== n ? n : x); };
|
|
72
111
|
|
|
73
112
|
export const ZERO: Vec3 = mk(0, 0, 0);
|
|
74
113
|
export const AHEAD: Vec3 = mk(1, 0, 0);
|
|
@@ -78,7 +117,9 @@ DIRS.add(AHEAD);
|
|
|
78
117
|
export function vec3(v: unknown, dims: number): Vec3 {
|
|
79
118
|
if (v === null || typeof v !== 'object') return ZERO;
|
|
80
119
|
if (VECS.has(v)) { const p = v as Vec3; return dims === 2 && p.z !== 0 ? mk(p.x, p.y, 0) : p; }
|
|
81
|
-
return mk(f32(own(v, 'x')), f32(own(v, 'y')), dims === 2 ? 0 : f32(own(v, 'z')));
|
|
120
|
+
if (G.note === null) return mk(f32(own(v, 'x')), f32(own(v, 'y')), dims === 2 ? 0 : f32(own(v, 'z')));
|
|
121
|
+
trail.push('x'); const x = f32(own(v, 'x')); trail[trail.length - 1] = 'y'; const y = f32(own(v, 'y')); trail[trail.length - 1] = 'z'; const z = dims === 2 ? 0 : f32(own(v, 'z')); trail.pop();
|
|
122
|
+
return mk(x, y, z);
|
|
82
123
|
}
|
|
83
124
|
/** A unit vector on the ground plane, as 32-bit floats. Too short to have a direction: +x. One that is already a unit vector is kept as it is, so a direction packs and unpacks to itself. */
|
|
84
125
|
export function dir(v: unknown, dims: number): Vec3 {
|
|
@@ -99,24 +140,31 @@ const made = <T extends object>(fd: Field, o: T): T => { Object.freeze(o); MADE.
|
|
|
99
140
|
export function coerce(fd: Field, v: unknown, dims: number): unknown {
|
|
100
141
|
switch (fd.t) {
|
|
101
142
|
case 'bit': case 'press': return v === true || v === 1;
|
|
102
|
-
case 'fix': { const n = Math.round(
|
|
143
|
+
case 'fix': { const x = num(v); const n = Math.round(x * FIX_STEP) / FIX_STEP; return Number.isFinite(n) ? Math.max(-FIX_MAX, Math.min(FIX_MAX, n)) + 0 : zeroed(v, x); }
|
|
103
144
|
case 'vec3': return vec3(v, dims);
|
|
104
145
|
case 'dir': return dir(v, dims);
|
|
105
146
|
case 'ref': return typeof v === 'string' ? v.slice(0, 24) : '';
|
|
106
|
-
case 'text':
|
|
147
|
+
case 'text': {
|
|
148
|
+
const text = typeof v === 'string' ? v : typeof v === 'number' || typeof v === 'boolean' ? String(v) : '';
|
|
149
|
+
if (G.note !== null && text.length > (fd.max as number)) note('text', `${text.length} characters for a size of ${fd.max}`);
|
|
150
|
+
return text.slice(0, fd.max);
|
|
151
|
+
}
|
|
107
152
|
case 'list': {
|
|
108
|
-
if (!Array.isArray(v)) return made(fd, []);
|
|
153
|
+
if (!Array.isArray(v)) { wrongKind(v); return made(fd, []); }
|
|
109
154
|
if (MADE.get(v) === fd) return v;
|
|
110
155
|
// An array's length is its own data property. No more than the declared size is read, however long the list is.
|
|
111
156
|
const n = Math.min(v.length, fd.max as number);
|
|
112
157
|
const out = new Array(n);
|
|
113
|
-
for (let i = 0; i < n; i += 1) out[i] = coerce(fd.of as Field, own(v, i), dims);
|
|
158
|
+
if (G.note === null) { for (let i = 0; i < n; i += 1) out[i] = coerce(fd.of as Field, own(v, i), dims); return made(fd, out); }
|
|
159
|
+
if (v.length > n) note('list', `${v.length} entries for a size of ${fd.max}`);
|
|
160
|
+
for (let i = 0; i < n; i += 1) { trail.push(i); out[i] = coerce(fd.of as Field, own(v, i), dims); trail.pop(); }
|
|
114
161
|
return made(fd, out);
|
|
115
162
|
}
|
|
116
163
|
case 'map': {
|
|
117
164
|
const out: Record<string, unknown> = Object.create(null);
|
|
118
|
-
if (v === null || typeof v !== 'object') return made(fd, out);
|
|
165
|
+
if (v === null || typeof v !== 'object') { wrongKind(v); return made(fd, out); }
|
|
119
166
|
if (MADE.get(v) === fd) return v;
|
|
167
|
+
if (G.note !== null && (v instanceof Map || v instanceof Set)) wrongKind(v);
|
|
120
168
|
// The one cost here that the declaration does not bound: how many keys the value handed in has. The running handler
|
|
121
169
|
// pays for each: before they are listed when the count is known (a module's constant), after when the handler made
|
|
122
170
|
// the value itself (and so has already paid a unit and more for every key).
|
|
@@ -126,23 +174,28 @@ export function coerce(fd: Field, v: unknown, dims: number): unknown {
|
|
|
126
174
|
if (known === undefined) charge(16 * keys.length);
|
|
127
175
|
let n = 0;
|
|
128
176
|
for (const key of keys) {
|
|
129
|
-
if (n >= (fd.max as number)) break;
|
|
130
|
-
if (key.length > 32 || refusedName(key)) continue;
|
|
131
|
-
out[key] = coerce(fd.of as Field, own(v, key), dims);
|
|
177
|
+
if (n >= (fd.max as number)) { if (G.note !== null) note('map', `${keys.length} keys for a size of ${fd.max}`); break; }
|
|
178
|
+
if (key.length > 32 || refusedName(key)) { if (G.note !== null) note('key', key); continue; }
|
|
179
|
+
if (G.note === null) out[key] = coerce(fd.of as Field, own(v, key), dims);
|
|
180
|
+
else { trail.push(key); out[key] = coerce(fd.of as Field, own(v, key), dims); trail.pop(); }
|
|
181
|
+
n += 1;
|
|
132
182
|
}
|
|
133
183
|
return made(fd, out);
|
|
134
184
|
}
|
|
135
185
|
case 'struct': {
|
|
136
186
|
if (v !== null && typeof v === 'object' && MADE.get(v) === fd) return v;
|
|
137
187
|
const out: Record<string, unknown> = {};
|
|
138
|
-
for (const [key, sub] of Object.entries(fd.fields ?? {})) out[key] = coerce(sub, own(v, key), dims);
|
|
188
|
+
if (G.note === null) { for (const [key, sub] of Object.entries(fd.fields ?? {})) out[key] = coerce(sub, own(v, key), dims); return made(fd, out); }
|
|
189
|
+
if (typeof v !== 'object' || v instanceof Map || v instanceof Set) wrongKind(v);
|
|
190
|
+
for (const [key, sub] of Object.entries(fd.fields ?? {})) { trail.push(key); out[key] = coerce(sub, own(v, key), dims); trail.pop(); }
|
|
139
191
|
return made(fd, out);
|
|
140
192
|
}
|
|
141
193
|
default: {
|
|
142
194
|
const [lo, hi] = RANGE[fd.t];
|
|
143
195
|
const x = num(v);
|
|
144
196
|
const n = Math.round(x);
|
|
145
|
-
|
|
197
|
+
if (G.note !== null && v !== undefined) { if (x !== x) note('nan', v); else if (n < lo || n > hi) note('range', v); }
|
|
198
|
+
return Number.isFinite(n) ? Math.max(lo, Math.min(hi, n)) + 0 : x === Infinity ? hi : x === -Infinity ? lo : 0;
|
|
146
199
|
}
|
|
147
200
|
}
|
|
148
201
|
}
|
|
@@ -189,12 +242,35 @@ export function thawFields(list: FieldList, values: Record<string, unknown>): Re
|
|
|
189
242
|
/** The value a field starts with: its `init`, else the zero of its type. Made once for each declaration, and shared: it is frozen. */
|
|
190
243
|
export function initOf(fd: Field, dims: number): unknown {
|
|
191
244
|
let hit = INIT.get(fd);
|
|
192
|
-
if (!hit) {
|
|
245
|
+
if (!hit) {
|
|
246
|
+
// A declaration is read here, by whichever handler needs it first: it is not that handler's write, so nobody is told.
|
|
247
|
+
const listener = G.note; G.note = null;
|
|
248
|
+
try { hit = { v: coerce(fd, fd.init, dims) }; } finally { G.note = listener; }
|
|
249
|
+
INIT.set(fd, hit);
|
|
250
|
+
}
|
|
193
251
|
return hit.v;
|
|
194
252
|
}
|
|
195
253
|
/** Whether a field's value is changed in place (a list, a map, a struct), so a handler is handed a copy of it and the runtime holds the copy to its type again when the handler ends. */
|
|
196
254
|
export const mutable = (fd: Field): boolean => fd.t === 'list' || fd.t === 'map' || fd.t === 'struct';
|
|
197
255
|
|
|
256
|
+
/** A body as `move` is handed it. */
|
|
257
|
+
export interface MoveBody { pos: Vec3; vel: Vec3; heading: Vec3; grounded: boolean; motion: Record<string, unknown> }
|
|
258
|
+
/**
|
|
259
|
+
* One step of a body by the game's own guarded `move`, as a browser takes it for its own body (rules/view.ts) and as the
|
|
260
|
+
* build check takes it when it plays that browser: counted against `quota` as the server counts a handler, and what it
|
|
261
|
+
* left rounded to 32-bit floats and held to the declared shapes as the server does it, so both hold the same numbers.
|
|
262
|
+
* `used` is the units it took. What it throws goes to `failed` (the budget's own stop too) and the body is still read.
|
|
263
|
+
*/
|
|
264
|
+
export function stepMove(fn: (body: MoveBody, input: unknown, ctx: unknown) => void, body: MoveBody, input: Readonly<Record<string, unknown>>, ctx: unknown, quota: number, motion: FieldList, dims: number, failed: (error: unknown) => void): MoveBody & { used: number } {
|
|
265
|
+
G.left = quota;
|
|
266
|
+
try { fn(body, input, ctx); } catch (error) { failed(error); }
|
|
267
|
+
const used = quota - (G.left > 0 ? G.left : 0);
|
|
268
|
+
G.left = Infinity;
|
|
269
|
+
naming('body', 'pos'); const pos = vec3(body.pos, dims); naming('body', 'vel'); const vel = vec3(body.vel, dims); naming('body', 'heading'); const heading = dir(body.heading, dims);
|
|
270
|
+
naming('body', 'motion'); const held = thawFields(motion, coerceFields(motion, body.motion, dims)); naming();
|
|
271
|
+
return { pos, vel, heading, grounded: body.grounded === true, motion: held, used };
|
|
272
|
+
}
|
|
273
|
+
|
|
198
274
|
/** How many values `pack` has packed since this was last set to 0: the core reads it to charge a tick for the state it sends. */
|
|
199
275
|
export const packed = { n: 0 };
|
|
200
276
|
|
|
@@ -205,7 +281,7 @@ export function pack(fd: Field, v: unknown, dims: number): unknown {
|
|
|
205
281
|
case 'bit': case 'press': return v ? 1 : 0;
|
|
206
282
|
case 'vec3': case 'dir': { const p = v as Vec3; return dims === 2 ? [p.x, p.y] : [p.x, p.y, p.z]; }
|
|
207
283
|
case 'list': return (v as unknown[]).map((x) => pack(fd.of as Field, x, dims));
|
|
208
|
-
case 'map': return Object.keys(v as object).
|
|
284
|
+
case 'map': return Object.keys(v as object).map((key) => [key, pack(fd.of as Field, (v as Record<string, unknown>)[key], dims)]);
|
|
209
285
|
case 'struct': return Object.entries(fd.fields ?? {}).map(([key, sub]) => pack(sub, (v as Record<string, unknown>)[key], dims));
|
|
210
286
|
default: return v;
|
|
211
287
|
}
|
|
@@ -242,7 +318,8 @@ export function initFields(list: FieldList, dims: number): Record<string, unknow
|
|
|
242
318
|
export function coerceFields(list: FieldList, values: unknown, dims: number): Record<string, unknown> {
|
|
243
319
|
const v = values !== null && typeof values === 'object' ? values : null;
|
|
244
320
|
const out: Record<string, unknown> = {};
|
|
245
|
-
for (const [name, fd] of list) { const x = v ? own(v, name) : undefined; out[name] = x === undefined ? initOf(fd, dims) : coerce(fd, x, dims); }
|
|
321
|
+
if (G.note === null) { for (const [name, fd] of list) { const x = v ? own(v, name) : undefined; out[name] = x === undefined ? initOf(fd, dims) : coerce(fd, x, dims); } return out; }
|
|
322
|
+
for (const [name, fd] of list) { const x = v ? own(v, name) : undefined; if (x === undefined) out[name] = initOf(fd, dims); else { trail.push(name); out[name] = coerce(fd, x, dims); trail.pop(); } }
|
|
246
323
|
return out;
|
|
247
324
|
}
|
|
248
325
|
|
package/rules/rules.ts
CHANGED
|
@@ -26,6 +26,13 @@ import { brand, deepFreeze, own, plainData } from './guard.ts';
|
|
|
26
26
|
|
|
27
27
|
/** The version of the rules contract this runtime runs. Today's netplay contract is version 1. */
|
|
28
28
|
export const RULES_CONTRACT = 2;
|
|
29
|
+
/**
|
|
30
|
+
* The revision of a saved room (core.ts `SavedCore`). It is raised whenever a save written before would be read
|
|
31
|
+
* differently now. It is part of every game's state hash (lib/rules-check.mjs `stateHash`), so a room saved by an
|
|
32
|
+
* earlier revision starts a fresh match instead of being restored wrongly. 2: a map keeps its keys in JavaScript's own
|
|
33
|
+
* order (1 sorted them), and a round or a match asked to end on the tick of the save is carried.
|
|
34
|
+
*/
|
|
35
|
+
export const SAVE_REVISION = 2;
|
|
29
36
|
/** How far a query, a ray or an area event reaches, in metres. */
|
|
30
37
|
export const REACH_M = 64;
|
|
31
38
|
/** The most entities one room holds. A spawn past it throws in the handler that asked. */
|
|
@@ -108,18 +115,16 @@ export const cellsOfList = (list: FieldList): number => { let n = 0; for (const
|
|
|
108
115
|
/* ------------------------------------------------------------------ what a module declares */
|
|
109
116
|
|
|
110
117
|
export interface Vec3 { readonly x: number; readonly y: number; readonly z: number }
|
|
111
|
-
/**
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
export type
|
|
116
|
-
export type
|
|
117
|
-
export type Handler = (world: World, self: Self, e?: any) => void;
|
|
118
|
-
export type RoomHandler = (world: World, e?: any) => void;
|
|
118
|
+
/** Public faces are specialised by the build from declarations. Internal dispatch accepts compiled tables. */
|
|
119
|
+
export type { World, Self, MoveBody, MoveContext, ReadonlyState } from './types.ts';
|
|
120
|
+
type RuntimeWorld = Record<string, any>;
|
|
121
|
+
type RuntimeSelf = Record<string, any>;
|
|
122
|
+
export type Handler = (world: RuntimeWorld, self: RuntimeSelf, e?: any) => void;
|
|
123
|
+
export type RoomHandler = (world: RuntimeWorld, e?: any) => void;
|
|
119
124
|
export type MoveFn = (body: any, input: any, ctx: any) => void;
|
|
120
125
|
|
|
121
126
|
export interface BodyDef { shape: 'circle' | 'sphere' | 'capsule' | 'box'; radius: number; height?: number; maxSpeed: number; sweep?: boolean; move?: 'owner' }
|
|
122
|
-
export interface GuideDef { view: (world:
|
|
127
|
+
export interface GuideDef { view: (world: RuntimeWorld, self: RuntimeSelf) => unknown; floor?: (world: RuntimeWorld, self: RuntimeSelf, view: any) => unknown }
|
|
123
128
|
export interface EntityDef {
|
|
124
129
|
player?: true | { away?: 'neutral' | 'think'; leave?: 'despawn' | 'bot' };
|
|
125
130
|
fields?: Fields;
|
|
@@ -128,7 +133,7 @@ export interface EntityDef {
|
|
|
128
133
|
body?: BodyDef;
|
|
129
134
|
guide?: GuideDef;
|
|
130
135
|
tick?: Handler;
|
|
131
|
-
think?: (world:
|
|
136
|
+
think?: (world: RuntimeWorld, self: RuntimeSelf) => Record<string, unknown>;
|
|
132
137
|
on?: Record<string, Handler>;
|
|
133
138
|
commands?: Record<string, Handler>;
|
|
134
139
|
onRoom?: Record<string, Handler>;
|
|
@@ -294,6 +299,8 @@ export interface KindTable {
|
|
|
294
299
|
}
|
|
295
300
|
export interface Compiled {
|
|
296
301
|
contract: 2;
|
|
302
|
+
/** `SAVE_REVISION`, so that what hashes these declarations hashes it too. */
|
|
303
|
+
save: number;
|
|
297
304
|
dims: 2 | 3;
|
|
298
305
|
kinds: KindTable[];
|
|
299
306
|
kindOf: Record<string, KindTable>;
|
|
@@ -437,7 +444,7 @@ export function compileRules(def: RulesDef, env: CompileEnv = {}): Compiled {
|
|
|
437
444
|
const view = fieldList(d.shapes?.view, 'shapes.view');
|
|
438
445
|
|
|
439
446
|
const move = d.move as (Record<string, MoveFn> & Record<string, unknown>) | undefined;
|
|
440
|
-
if (move !== undefined && (typeof move !== 'object' || move[MOVE] !== true)) throw new Error('move is what move.ts exports: `export const move = defineMove({ … })`');
|
|
447
|
+
if (move !== undefined && (typeof move !== 'object' || (move as Record<string, unknown>)[MOVE] !== true)) throw new Error('move is what move.ts exports: `export const move = defineMove({ … })`');
|
|
441
448
|
|
|
442
449
|
const kinds: KindTable[] = [];
|
|
443
450
|
const kindOf: Record<string, KindTable> = {};
|
|
@@ -506,12 +513,13 @@ export function compileRules(def: RulesDef, env: CompileEnv = {}): Compiled {
|
|
|
506
513
|
if (players.length && typeof room.join !== 'function') throw new Error('room.join(ctx, player) says where a player\'s body starts: return { kind, at }');
|
|
507
514
|
const asks: Compiled['asks'] = {};
|
|
508
515
|
for (const [key, a] of Object.entries(d.asks ?? {})) {
|
|
509
|
-
if (!a || typeof a.floor !== 'function'
|
|
516
|
+
if (!a || typeof a.floor !== 'function') throw new Error(`asks.${key} needs a local floor(state) function that returns every declared question's pick; for example floor(state) { return { advance: true }; }`);
|
|
517
|
+
if (!a.questions || typeof a.questions !== 'object') throw new Error(`asks.${key}.questions is required; for example { advance: { type: 'noul', instructions: 'Should the party advance?' } }`);
|
|
510
518
|
asks[key] = { ...a, stateFields: fieldList(a.state, `asks.${key}.state`) };
|
|
511
519
|
}
|
|
512
520
|
const seats = Math.max(1, Math.min(SEATS_MAX, Math.floor(Number(env.seats)) || 8));
|
|
513
521
|
return {
|
|
514
|
-
contract: RULES_CONTRACT, dims, kinds, kindOf, events, commands, effects, effectNames: Object.keys(effects),
|
|
522
|
+
contract: RULES_CONTRACT, save: SAVE_REVISION, dims, kinds, kindOf, events, commands, effects, effectNames: Object.keys(effects),
|
|
515
523
|
shared: fieldList(d.shared, 'shared'), view, rounds, bots: Math.min(keep, seats), start: room.start ?? null, join: room.join ?? null, roomOn, asks,
|
|
516
524
|
tune, publicTune, map, settings, seats,
|
|
517
525
|
};
|