@homie-rocks/studio 0.35.0 → 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.
Files changed (45) hide show
  1. package/CHANGELOG.md +37 -1
  2. package/agents/agents.ts +8 -6
  3. package/bin/homie-studio.mjs +9 -6
  4. package/lab/lab.ts +15 -0
  5. package/lib/build.mjs +20 -15
  6. package/lib/dev.mjs +16 -1
  7. package/lib/lab-check.mjs +18 -3
  8. package/lib/lab.mjs +21 -7
  9. package/lib/mcp-tools.mjs +2 -1
  10. package/lib/rules-build.mjs +97 -49
  11. package/lib/rules-check-worker.mjs +405 -0
  12. package/lib/rules-check.mjs +69 -0
  13. package/lib/rules-compiler.mjs +30 -0
  14. package/lib/rules-guard.mjs +26 -7
  15. package/lib/rules-types.mjs +167 -0
  16. package/lib/shop-catalog.mjs +20 -1
  17. package/lib/shop-links.mjs +3 -1
  18. package/lib/shop.mjs +14 -8
  19. package/lib/standalone-files.mjs +2 -2
  20. package/lib/standalone.mjs +6 -2
  21. package/lib/stripe-connect.mjs +18 -7
  22. package/lib/template-history.json +83 -0
  23. package/lib/typecheck.mjs +52 -3
  24. package/netplay/NETPLAY.md +123 -6
  25. package/netplay/netplay.ts +289 -53
  26. package/netplay/rules-output.mjs +85 -0
  27. package/package.json +3 -2
  28. package/rules/browser.ts +100 -0
  29. package/rules/core.ts +159 -58
  30. package/rules/guard.ts +11 -4
  31. package/rules/host.ts +90 -15
  32. package/rules/math.ts +16 -0
  33. package/rules/pack.ts +94 -17
  34. package/rules/rules.ts +21 -13
  35. package/rules/types.ts +63 -0
  36. package/rules/view.ts +36 -33
  37. package/standalone/shell/shell.js +8 -6
  38. package/worker/brain.mjs +2 -1
  39. package/worker/hosted.mjs +1 -1
  40. package/worker/index.mjs +7 -11
  41. package/worker/limits.mjs +41 -0
  42. package/worker/pages.mjs +4 -2
  43. package/worker/room.mjs +136 -63
  44. package/worker/stripe.mjs +2 -2
  45. 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 (Array.isArray(saved?.guideViews) ? saved.guideViews : []).filter((v) => Array.isArray(v) && v.length === 2).slice(0, c.seats)) if (v && core.bodyOf(seat)?.id === v.id) guideViews.set(seat, { id: v.id, at: typeof v.at === 'number' && Number.isFinite(v.at) ? Math.max(0, Math.min(core.tick, v.at)) : 0, value: coerceFields(c.view, v.value, c.dims) });
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
- core.seatJoin({ seat, driver, owner: `p${Number.isInteger(p.occ) ? p.occ : typeof p.id === 'string' ? p.id.slice(0, 40) : seat}` });
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) { stats.late += 1; blameLate(now, 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
- const mk = (x: number, y: number, z: number): Vec3 => { const p = Object.freeze({ x, y, z }); VECS.add(p); return p; };
71
- const f32 = (a: unknown): number => { const x = fr(num(a)); return Number.isFinite(x) ? x : 0; };
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(num(v) * FIX_STEP) / FIX_STEP; return Number.isFinite(n) ? Math.max(-FIX_MAX, Math.min(FIX_MAX, n)) : 0; }
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': return (typeof v === 'string' ? v : typeof v === 'number' || typeof v === 'boolean' ? String(v) : '').slice(0, fd.max);
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); n += 1;
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
- return Number.isFinite(n) ? Math.max(lo, Math.min(hi, n)) : x === Infinity ? hi : x === -Infinity ? lo : 0;
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) { hit = { v: coerce(fd, fd.init, dims) }; INIT.set(fd, 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).sort().map((key) => [key, pack(fd.of as Field, (v as Record<string, unknown>)[key], dims)]);
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
- * What handlers are handed. The build's type check of rules (always on from the release that ships the build check)
113
- * narrows these per kind from the declarations; until then they are deliberately loose.
114
- */
115
- export type World = Record<string, any>;
116
- export type Self = Record<string, any>;
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: World, self: Self) => unknown; floor?: (world: World, self: Self, view: any) => unknown }
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: World, self: Self) => Record<string, unknown>;
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' || !a.questions || typeof a.questions !== 'object') throw new Error(`asks.${key} is { state, questions, floor(state) }`);
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
  };