@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/rules/core.ts CHANGED
@@ -42,8 +42,8 @@ import { argsWhy, type Vocabulary } from '../agents/agents.ts';
42
42
  import { BudgetError, G, brand, charge, deepFreeze, plainData } from './guard.ts';
43
43
  import { SKIN, castMap, exact, math, rayCircle, sweepMap } from './math.ts';
44
44
  import type { Hit } from './math.ts';
45
- import { AHEAD, ZERO, coerce, coerceFields, dir, est, estFields, initFields, mutable, num, own, packEntity, packFields, packVec, packed, said, thaw, unpackFields, unpackVec, vec3 } from './pack.ts';
46
- import { ENTITY_MAX, QUEUE_MAX, REACH_M, cellsOf } from './rules.ts';
45
+ import { AHEAD, ZERO, coerce, coerceFields, dir, est, estFields, initFields, mutable, num, own, packEntity, packFields, packVec, packed, said, thaw, naming, unpackFields, unpackVec, vec3 } from './pack.ts';
46
+ import { ENTITY_MAX, QUEUE_MAX, REACH_M, SAVE_REVISION, cellsOf } from './rules.ts';
47
47
  import type { Compiled, Field, FieldList, KindTable, Vec3 } from './rules.ts';
48
48
 
49
49
  /*
@@ -63,6 +63,8 @@ export const SNAP = 4;
63
63
  export const SEND = 16;
64
64
  /** A handler that threw: what catching it and writing it down costs the tick. */
65
65
  export const THROWN = 512;
66
+ /** The most effects (`world.emit`) one tick takes. */
67
+ export const FX_MAX = 256;
66
68
  /** The most area events (`world.sendArea`) one tick takes. */
67
69
  export const AREAS_MAX = 64;
68
70
  /** What the log names when a room ends because of what it holds, not because of a handler. */
@@ -107,7 +109,7 @@ interface Seat { seat: number; driver: Driver; owner: string; id: string | null;
107
109
  interface Ctx { scope: 'none' | 'ent' | 'room' | 'join' | 'move'; ent: Ent | null }
108
110
  const NO_SPOTS: readonly Vec3[] = Object.freeze([]);
109
111
  /** A list, map or struct a handler was handed a copy of: where it is stored, and the copy to hold to its shape when the handler ends. */
110
- interface Touched { bag: Record<string, unknown>; name: string; fd: Field; work: Record<string, unknown>; shared: boolean }
112
+ interface Touched { bag: Record<string, unknown>; name: string; fd: Field; work: Record<string, unknown>; shared: boolean; held: (fd: Field, v: unknown, name: string) => unknown }
111
113
 
112
114
  /** How many values a stored list, map or struct holds now: what copying it for a handler costs. */
113
115
  function sizeOf(fd: Field, v: unknown): number {
@@ -127,11 +129,11 @@ function errorText(error: unknown): string {
127
129
 
128
130
  /** Everything a room is, as plain JSON (pack.ts `toBytes` makes it bytes). The shape is this runtime's own and may change between releases. */
129
131
  export interface SavedCore {
130
- v: 1; tick: number; epoch: number; rng: number; nextId: number; seq: number;
132
+ v: number; tick: number; epoch: number; rng: number; nextId: number; seq: number;
131
133
  round: [number, number, number, number]; overAt: number; match: [number, number]; trips: number;
132
- shared: unknown[]; policy: CorePolicy;
133
- asks?: PendingAsk[];
134
- guideViews?: [number, Record<string, unknown>][];
134
+ shared: unknown[]; policy: CorePolicy; intent?: [number, number];
135
+ asks: PendingAsk[];
136
+ guideViews: [number, Record<string, unknown>][];
135
137
  ents: unknown[][]; spawns: unknown[][]; seats: [number, string, string, string | null, number][]; queue: unknown[][]; areas: unknown[][]; ops: unknown[];
136
138
  }
137
139
 
@@ -151,6 +153,8 @@ export interface Core {
151
153
  /** The room's public state, packed: `[[n, phase, endsAt], entities]`. */
152
154
  snapshot(): unknown[];
153
155
  shared(): unknown[];
156
+ /** The current round, for a host rebasing wall-clock deadlines after a pause or restore. */
157
+ round(): Extract<CoreOut, { t: 'round' }>;
154
158
  bodies(): Body[];
155
159
  /** The body in a seat, as the host runtime needs it for the input protocol. */
156
160
  bodyOf(seat: number): { id: string; kind: KindTable; r: number; away: boolean; driver: Driver } | null;
@@ -161,12 +165,19 @@ export interface Core {
161
165
  readonly world: object;
162
166
  /**
163
167
  * `failing`: the handler the budget last stopped. `lost`: area events dropped because the room's queue was full.
168
+ * `held`: owner-moved claims cut short because they came further than `body.maxSpeed` allows.
164
169
  * `tickUnits`: what the last tick used, the state it sends included.
165
170
  */
166
- stats: { handlers: number; errors: number; budgetStops: number; skipped: number; ticksCut: number; maxUnits: number; worst: string; lastError: string; failing: string; lost: number; tickUnits: number; maxTickUnits: number };
171
+ stats: { handlers: number; errors: number; budgetStops: number; skipped: number; ticksCut: number; maxUnits: number; worst: string; lastError: string; failing: string; lost: number; held: number; tickUnits: number; maxTickUnits: number };
167
172
  }
168
173
 
169
- export function createCore(c: Compiled, opts: { seed?: number; epoch?: number; restore?: SavedCore | null; restoreEpoch?: number; stage?: string; decisions?: boolean } = {}): Core {
174
+ /**
175
+ * `observe` and `noted` are the build check's; a room passes neither, and then nothing here costs or changes anything.
176
+ * `observe`: told of every handler as it ends, and what it threw if it threw.
177
+ * `noted`: told, as it happens, of a value the rules wrote that the runtime changed to make it fit, or dropped (pack.ts
178
+ * `Adjusted`; also `effect`, `think` and `decision`): the handler, what was done, the field's name and what was written.
179
+ */
180
+ export function createCore(c: Compiled, opts: { observe?: (kind: string, handler: string, error?: string) => void; noted?: (kind: string, handler: string, what: string, at: string, written: string) => void; seed?: number; epoch?: number; restore?: SavedCore | null; restoreEpoch?: number; stage?: string; decisions?: boolean } = {}): Core {
170
181
  if (c.dims !== 2) throw new Error('this release runs rules with space.dims: 2; bodies with height (dims: 3) arrive in a later one');
171
182
  const dims = c.dims;
172
183
  const tickHz = c.settings.tickHz;
@@ -176,6 +187,7 @@ export function createCore(c: Compiled, opts: { seed?: number; epoch?: number; r
176
187
  /** What `move`, `think`, `join` and `start` are given each once the tick's budget is gone: all of them together, in a full room, use half a budget more. */
177
188
  const floor = Math.max(1, Math.floor(c.settings.budget.tick / (4 * ENTITY_MAX)));
178
189
  const stage = String(opts.stage ?? '');
190
+ const noted = opts.noted ?? null;
179
191
 
180
192
  let tick = 0;
181
193
  let pendingAsks: PendingAsk[] = [];
@@ -203,7 +215,8 @@ export function createCore(c: Compiled, opts: { seed?: number; epoch?: number; r
203
215
  let policy: CorePolicy = { reserved: 0, bots: 'fill', level: 3, levelMax: 5 };
204
216
  function normalizeCorePolicy(p: Partial<CorePolicy>): CorePolicy {
205
217
  const levelMax = Math.max(1, Math.min(5, Math.floor(num(own(p, 'levelMax') ?? policy.levelMax)) || 5));
206
- return { reserved: Math.max(0, Math.min(c.seats - 1, Math.floor(num(own(p, 'reserved') ?? policy.reserved)) || 0)), bots: p.bots === 'off' ? 'off' : p.bots === 'fill' ? 'fill' : policy.bots, levelMax, level: Math.max(1, Math.min(levelMax, Math.floor(num(own(p, 'level') ?? policy.level)) || 3)) };
218
+ // The keys in the order the room starts with them: a save is compared byte for byte with the save of the room rebuilt from it.
219
+ return { reserved: Math.max(0, Math.min(c.seats - 1, Math.floor(num(own(p, 'reserved') ?? policy.reserved)) || 0)), bots: p.bots === 'off' ? 'off' : p.bots === 'fill' ? 'fill' : policy.bots, level: Math.max(1, Math.min(levelMax, Math.floor(num(own(p, 'level') ?? policy.level)) || 3)), levelMax };
207
220
  }
208
221
  let shared: Record<string, unknown> = initFields(c.shared, dims);
209
222
  let sharedRO: Readonly<Record<string, unknown>> = Object.freeze({});
@@ -219,7 +232,7 @@ export function createCore(c: Compiled, opts: { seed?: number; epoch?: number; r
219
232
  let trips = 0;
220
233
  let cut = false;
221
234
  let cx: Ctx = { scope: 'none', ent: null };
222
- const stats = { handlers: 0, errors: 0, budgetStops: 0, skipped: 0, ticksCut: 0, maxUnits: 0, worst: '', lastError: '', failing: '', lost: 0, tickUnits: 0, maxTickUnits: 0 };
235
+ const stats = { handlers: 0, errors: 0, budgetStops: 0, skipped: 0, ticksCut: 0, maxUnits: 0, worst: '', lastError: '', failing: '', lost: 0, held: 0, tickUnits: 0, maxTickUnits: 0 };
223
236
  /** The lists, maps and structs the running handler was handed copies of. */
224
237
  let touched: Touched[] = [];
225
238
  /** Events set aside while phase 3 delivers, so the queue's cap counts them. */
@@ -242,12 +255,14 @@ export function createCore(c: Compiled, opts: { seed?: number; epoch?: number; r
242
255
  * first read in a handler makes a copy the handler may change in place, charged by what the stored value holds, and
243
256
  * `settle` holds that copy to its declaration when the handler ends.
244
257
  */
245
- function typed(bag: Record<string, unknown>, list: FieldList, target: object, isShared = false): void {
258
+ function typed(bag: Record<string, unknown>, list: FieldList, target: object, where: string, isShared = false): void {
246
259
  const work: Record<string, unknown> = {};
260
+ /** The value as the field holds it. For a listener the field is named first (`runner.fields`, `score`); a room has none. */
261
+ const held = (fd: Field, v: unknown, name: string): unknown => { if (G.note === null) return coerce(fd, v, dims); naming(where, name); const out = coerce(fd, v, dims); naming(); return out; };
247
262
  for (const [name, fd] of list) {
248
263
  if (!mutable(fd)) {
249
264
  const cost = cellsOf(fd) === 1 ? SET : COPY * cellsOf(fd);
250
- Object.defineProperty(target, name, { get: () => bag[name], set: (v) => { charge(cost); bag[name] = coerce(fd, v, dims); if (isShared) sharedChanged(); }, enumerable: true });
265
+ Object.defineProperty(target, name, { get: () => bag[name], set: (v) => { charge(cost); bag[name] = held(fd, v, name); if (isShared) sharedChanged(); }, enumerable: true });
251
266
  continue;
252
267
  }
253
268
  Object.defineProperty(target, name, {
@@ -256,11 +271,11 @@ export function createCore(c: Compiled, opts: { seed?: number; epoch?: number; r
256
271
  if (w === undefined) {
257
272
  charge(COPY * sizeOf(fd, bag[name]));
258
273
  w = work[name] = thaw(fd, bag[name]);
259
- touched.push({ bag, name, fd, work, shared: isShared });
274
+ touched.push({ bag, name, fd, work, shared: isShared, held });
260
275
  }
261
276
  return w;
262
277
  },
263
- set: (v) => { charge(COPY * est(fd, v)); bag[name] = coerce(fd, v, dims); work[name] = undefined; if (isShared) sharedChanged(); },
278
+ set: (v) => { charge(COPY * est(fd, v)); bag[name] = held(fd, v, name); work[name] = undefined; if (isShared) sharedChanged(); },
264
279
  enumerable: true,
265
280
  });
266
281
  }
@@ -281,7 +296,9 @@ export function createCore(c: Compiled, opts: { seed?: number; epoch?: number; r
281
296
  const w = p.work[p.name];
282
297
  if (w === undefined) continue; // assigned whole after it was read: the assignment stored it
283
298
  charge(COPY * est(p.fd, w));
284
- p.bag[p.name] = coerce(p.fd, w, dims);
299
+ // Changed in place, so held to its type only now: a listener is told so, since the line is no longer the write's.
300
+ if (G.note === null) p.bag[p.name] = coerce(p.fd, w, dims);
301
+ else { const told = G.note; G.note = (what, at, written) => told(`${what} in place`, at, written); try { p.bag[p.name] = p.held(p.fd, w, p.name); } finally { G.note = told; } }
285
302
  p.work[p.name] = undefined;
286
303
  if (p.shared) sharedChanged();
287
304
  }
@@ -292,16 +309,16 @@ export function createCore(c: Compiled, opts: { seed?: number; epoch?: number; r
292
309
  function makeSelf(e: Ent): void {
293
310
  const ro = (get: () => unknown): PropertyDescriptor => ({ get, enumerable: true });
294
311
  const mself = {};
295
- typed(e.m, e.kind.motion, mself);
312
+ typed(e.m, e.kind.motion, mself, `${e.kind.name}.motion`);
296
313
  Object.preventExtensions(mself);
297
314
  const self = {};
298
315
  Object.defineProperties(self, {
299
316
  id: ro(() => e.id), kind: ro(() => e.kind.name), pos: ro(() => e.pos), grounded: ro(() => e.grounded), motion: ro(() => mself), input: ro(() => e.input),
300
- vel: { get: () => e.vel, set: (v) => { charge(4 * COPY); e.vel = V(v); }, enumerable: true },
301
- heading: { get: () => e.heading, set: (v) => { charge(4 * COPY); e.heading = dir(v, dims); }, enumerable: true },
317
+ vel: { get: () => e.vel, set: (v) => { charge(4 * COPY); naming(e.kind.name, 'vel'); e.vel = V(v); naming(); }, enumerable: true },
318
+ heading: { get: () => e.heading, set: (v) => { charge(4 * COPY); naming(e.kind.name, 'heading'); e.heading = dir(v, dims); naming(); }, enumerable: true },
302
319
  });
303
320
  if (e.kind.player) Object.defineProperties(self, { seat: ro(() => e.seat), owner: ro(() => e.owner), driver: ro(() => e.driver), away: ro(() => e.away), goal: ro(() => e.goal) });
304
- typed(e.f, e.kind.fields, self);
321
+ typed(e.f, e.kind.fields, self, `${e.kind.name}.fields`);
305
322
  Object.preventExtensions(self);
306
323
  e.self = self; e.mself = mself;
307
324
  }
@@ -322,10 +339,12 @@ export function createCore(c: Compiled, opts: { seed?: number; epoch?: number; r
322
339
  function makeEnt(kind: KindTable, at: unknown, why: 'join' | 'spawn', init: { fields?: unknown; motion?: unknown; heading?: unknown } = {}, player: SeatInfo | null = null): Ent {
323
340
  if (ents.size + spawns.length >= ENTITY_MAX) throw new Error(`a room holds at most ${ENTITY_MAX} entities`);
324
341
  const n = nextId; nextId += 1;
342
+ naming(kind.name, 'fields'); const f = coerceFields(kind.fields, init.fields, dims);
343
+ naming(kind.name, 'motion'); const m = coerceFields(kind.motion, init.motion, dims); naming();
325
344
  const e: Ent = {
326
345
  id: `e${n.toString(36)}`, n, kind, pos: clampIn(V(at), kind.body?.radius ?? 0), vel: ZERO, heading: init.heading === undefined ? AHEAD : dir(init.heading, dims), grounded: true,
327
346
  r: 0, born: tick, arrived: false, why, dead: false,
328
- f: coerceFields(kind.fields, init.fields, dims), m: coerceFields(kind.motion, init.motion, dims),
347
+ f, m,
329
348
  seat: player ? player.seat : -1, owner: player ? player.owner : '', driver: player ? player.driver : 'bot', away: false, goal: null,
330
349
  input: Object.freeze(initFields(kind.input, dims)), allow: 0, cmds: [], self: {}, mself: {},
331
350
  };
@@ -366,8 +385,10 @@ export function createCore(c: Compiled, opts: { seed?: number; epoch?: number; r
366
385
  const limit = E.stackTraceLimit;
367
386
  E.stackTraceLimit = 0;
368
387
  G.left = quota;
388
+ // The build check is told of every written value that had to be changed to fit (pack.ts), with the handler it was in.
389
+ if (noted) G.note = (what, at, written) => noted(kind, handler, what, at, written);
390
+ let bad = false; let thrown: unknown;
369
391
  try {
370
- let bad = false; let thrown: unknown;
371
392
  try { fn(); } catch (error) { bad = true; thrown = error; }
372
393
  // Settled whether or not the handler threw: what it changed in place before it stopped is kept, when it can still pay for it.
373
394
  try { settle(); } catch (error) { if (!bad) { bad = true; thrown = error; } }
@@ -375,6 +396,7 @@ export function createCore(c: Compiled, opts: { seed?: number; epoch?: number; r
375
396
  } finally {
376
397
  const used = quota - (G.left > 0 ? G.left : 0);
377
398
  G.left = Infinity;
399
+ G.note = null;
378
400
  E.stackTraceLimit = limit;
379
401
  cx = before;
380
402
  touched = [];
@@ -382,6 +404,7 @@ export function createCore(c: Compiled, opts: { seed?: number; epoch?: number; r
382
404
  stats.handlers += 1;
383
405
  if (used > stats.maxUnits) { stats.maxUnits = used; stats.worst = `${kind}.${handler}`; }
384
406
  }
407
+ opts.observe?.(kind, handler, bad ? errorText(thrown) : undefined);
385
408
  return true;
386
409
  }
387
410
  let failing: { kind: string; handler: string } = { kind: '', handler: '' };
@@ -409,7 +432,8 @@ export function createCore(c: Compiled, opts: { seed?: number; epoch?: number; r
409
432
  const shape = typeof name === 'string' && Object.hasOwn(table, name) ? table[name] : null;
410
433
  if (!shape) throw new Error(`${what} "${said(name)}" is not declared in shapes`);
411
434
  charge(COPY * estFields(shape, data));
412
- return Object.freeze(coerceFields(shape, data, dims));
435
+ naming(name as string); const held = Object.freeze(coerceFields(shape, data, dims)); naming();
436
+ return held;
413
437
  };
414
438
  const sender = (): { from: number; fromId: string } => (cx.ent ? { from: cx.ent.n, fromId: cx.ent.id } : { from: 0, fromId: '' });
415
439
  /** One event or timer onto the queue. A handler's send past the room's cap throws in that handler; the runtime's own (`own`: a round turning, an answer) is always taken. */
@@ -443,7 +467,7 @@ export function createCore(c: Compiled, opts: { seed?: number; epoch?: number; r
443
467
  const sharedBag: Record<string, unknown> = {};
444
468
  const sharedRW = {};
445
469
  for (const [name] of c.shared) Object.defineProperty(sharedBag, name, { get: () => shared[name], set: (v) => { shared[name] = v; }, enumerable: true });
446
- typed(sharedBag, c.shared, sharedRW, true);
470
+ typed(sharedBag, c.shared, sharedRW, 'shared', true);
447
471
  Object.preventExtensions(sharedRW);
448
472
  const sharedView = (): Readonly<Record<string, unknown>> => {
449
473
  if (cx.scope === 'room') return sharedRW;
@@ -509,7 +533,7 @@ export function createCore(c: Compiled, opts: { seed?: number; epoch?: number; r
509
533
  if (cx.scope !== 'ent' && cx.scope !== 'room') throw new Error('world.emit is for handlers');
510
534
  const i = typeof effect === 'string' ? c.effectNames.indexOf(effect) : -1;
511
535
  if (i < 0) throw new Error(`the effect "${said(effect)}" is not declared in shapes.effects`);
512
- if (fx.length >= 256) return;
536
+ if (fx.length >= FX_MAX) { G.note?.('effect', '', said(effect)); return; }
513
537
  const shape = c.effects[c.effectNames[i]];
514
538
  fx.push([i, typeof at === 'string' ? at.slice(0, 24) : packVec(V(at), dims), packFields(shape, shapeData(c.effects, effect, 'the effect', data), dims)]);
515
539
  },
@@ -611,7 +635,7 @@ export function createCore(c: Compiled, opts: { seed?: number; epoch?: number; r
611
635
  const who = cx.ent ? cx.ent.id : '';
612
636
  if (pendingAsks.some((q) => q.name === name) || queue.some((q) => q.ev === 'answer' && (q.data as { ask?: string })?.ask === name)) return false;
613
637
  charge(COPY * estFields(a.stateFields, state));
614
- const value = deepFreeze(coerceFields(a.stateFields, state, dims));
638
+ naming('asks', name as string, 'state'); const value = deepFreeze(coerceFields(a.stateFields, state, dims)); naming();
615
639
  if (JSON.stringify(value).length > 2048) return false;
616
640
  const n = `a${epoch.toString(36)}-${(++seq).toString(36)}`;
617
641
  pendingAsks.push({ n, name: name as string, who, at: tick, state: value });
@@ -639,6 +663,14 @@ export function createCore(c: Compiled, opts: { seed?: number; epoch?: number; r
639
663
  const fn = c.roomOn[ev];
640
664
  if (fn) run('room', `on.${ev}`, null, 'room', () => fn(world, data));
641
665
  };
666
+ function validPicks(questions: Record<string, unknown>, value: unknown): boolean {
667
+ return Boolean(value && typeof value === 'object' && !Array.isArray(value) && Object.entries(questions).every(([id, raw]) => {
668
+ const q = raw as any; const v = own(value, id);
669
+ if (q.type === 'noul' || q.type === 'yes-no') return typeof v === 'boolean';
670
+ if (q.type === 'score') return typeof v === 'number' && Number.isInteger(v) && v >= 0 && v < q.criteria.length;
671
+ return typeof v === 'string' && (Array.isArray(q.criteria) ? q.criteria.includes(v) : Object.hasOwn(q.criteria, v));
672
+ }));
673
+ }
642
674
  function deliver(item: Item): boolean {
643
675
  if (item.kind === 'room') {
644
676
  const fn = c.roomOn[item.ev];
@@ -897,15 +929,19 @@ export function createCore(c: Compiled, opts: { seed?: number; epoch?: number; r
897
929
  run(e?.kind.name ?? 'room', `asks.${ask.name}.floor`, e ?? null, 'none', () => {
898
930
  charge(2 * ANSWER_MAX);
899
931
  const r = ask.result;
900
- const valid = (p: any): boolean => Boolean(p && typeof p === 'object' && Object.entries(a.questions).every(([id, raw]) => {
901
- const q = raw as any; const v = p[id];
902
- if (q.type === 'noul' || q.type === 'yes-no') return typeof v === 'boolean';
903
- if (q.type === 'score') return typeof v === 'number' && Number.isInteger(v) && v >= 0 && v <= q.criteria.length - 1;
904
- return typeof v === 'string' && (Array.isArray(q.criteria) ? q.criteria.includes(v) : Object.hasOwn(q.criteria, v));
905
- }));
932
+ const valid = (p: unknown): boolean => validPicks(a.questions, p);
906
933
  const ai = r?.ok === true && valid(r.picks);
907
934
  const picks = plainData(ai ? r.picks : a.floor(ask.state), { n: ANSWER_MAX });
908
- if (!valid(picks)) throw new Error(`asks.${ask.name}.floor must answer every declared question`);
935
+ if (!valid(picks)) {
936
+ const details = Object.entries(a.questions).map(([id, raw]) => {
937
+ const q = raw as any; const value = (picks as any)?.[id];
938
+ const allowed = q.type === 'score' ? `an integer from 0 through ${q.criteria.length - 1}`
939
+ : q.type === 'noul' || q.type === 'yes-no' ? 'true or false'
940
+ : `one of ${JSON.stringify(Array.isArray(q.criteria) ? q.criteria : Object.keys(q.criteria))}`;
941
+ return `${id} received ${JSON.stringify(value) ?? 'missing'}; expected ${allowed}`;
942
+ }).join('; ');
943
+ throw new Error(`asks.${ask.name}.floor must answer every declared question: ${details}`);
944
+ }
909
945
  push({ due: tick, to: ask.who, kind: e ? 'ev' : 'room', ev: 'answer', data: deepFreeze({ ask: ask.name, by: ai ? (r.by === 'local' ? 'local' : 'ai') : 'floor', picks, ...(!ai ? { why: r?.ok === true ? 'bad' : typeof r?.why === 'string' && /^[a-z][a-z0-9-]{0,31}$/.test(r.why) ? r.why : (opts.decisions ? 'slow' : 'off') } : {}) }), at: tick, builtIn: true }, true);
910
946
  answered = true;
911
947
  });
@@ -936,8 +972,13 @@ export function createCore(c: Compiled, opts: { seed?: number; epoch?: number; r
936
972
  let stepIn: Record<string, unknown> | null = null;
937
973
  run(k.name, 'think', e, 'ent', () => {
938
974
  const back: unknown = (k.think as NonNullable<KindTable['think']>)(world, e.self);
975
+ // What is not an input of this kind is not read, and the input is neutral: a room says nothing of it, a listener is told.
976
+ if (G.note !== null) {
977
+ if (!back || typeof back !== 'object' || Array.isArray(back)) G.note('think', '', said(back));
978
+ else for (const key of Object.keys(back)) if (!k.input.some(([name]) => name === key)) G.note('think', key, said((back as Record<string, unknown>)[key]));
979
+ }
939
980
  charge(COPY * k.input.length);
940
- stepIn = coerceFields(k.input, back, dims);
981
+ naming('input'); stepIn = coerceFields(k.input, back, dims); naming();
941
982
  }, 'body');
942
983
  e.input = Object.freeze(stepIn ?? initFields(k.input, dims));
943
984
  } else e.input = Object.freeze(initFields(k.input, dims));
@@ -948,6 +989,8 @@ export function createCore(c: Compiled, opts: { seed?: number; epoch?: number; r
948
989
  const dx = claim.pos.x - e.pos.x; const dy = claim.pos.y - e.pos.y;
949
990
  const dist = Math.sqrt(dx * dx + dy * dy);
950
991
  const go = Math.min(dist, e.allow);
992
+ // The browser ran the game's own `move` and came further than `maxSpeed` allows: the body is held back.
993
+ if (go < dist) stats.held += 1;
951
994
  e.allow -= go;
952
995
  if (dist > 0) e.pos = clampIn(V({ x: e.pos.x + (dx / dist) * go, y: e.pos.y + (dy / dist) * go, z: 0 }), body.radius);
953
996
  const sp = Math.sqrt(claim.vel.x * claim.vel.x + claim.vel.y * claim.vel.y);
@@ -963,7 +1006,11 @@ export function createCore(c: Compiled, opts: { seed?: number; epoch?: number; r
963
1006
  run(k.name, 'move', e, 'move', () => (k.move as NonNullable<KindTable['move']>)(b, e.input, moveCtx), 'body');
964
1007
  // The runtime rounds to 32-bit floats, here and in the browser, so both step from exactly the same numbers.
965
1008
  // `b` is the runtime's own object: what `move` left in it is read by its own data properties and cannot throw.
966
- e.pos = clampIn(V(own(b, 'pos')), body.radius); e.vel = V(own(b, 'vel')); e.heading = dir(own(b, 'heading'), dims); e.grounded = own(b, 'grounded') === true;
1009
+ // (A listener hears of a position that was not a number here too, as `move`'s.)
1010
+ if (noted) G.note = (what, at, written) => noted(k.name, 'move', what, at, written);
1011
+ naming('body', 'pos'); e.pos = clampIn(V(own(b, 'pos')), body.radius); naming('body', 'vel'); e.vel = V(own(b, 'vel')); naming('body', 'heading'); e.heading = dir(own(b, 'heading'), dims); naming();
1012
+ G.note = null;
1013
+ e.grounded = own(b, 'grounded') === true;
967
1014
  }
968
1015
  // Phase 2: for every entity, its commands and then its tick, starting from a different entity each tick.
969
1016
  const list = [...ents.values()];
@@ -1028,7 +1075,7 @@ export function createCore(c: Compiled, opts: { seed?: number; epoch?: number; r
1028
1075
  const e: Ent = {
1029
1076
  id: w[0], n: w[1], kind, r: w[3], born: w[4], arrived: w[5] === 1, why: w[6], dead: false, pos: unpackVec(w[7], 3), vel: unpackVec(w[8], 3), heading: unpackVec(w[9], 3), grounded: w[10] === 1,
1030
1077
  seat: w[11], driver: w[12], owner: w[13], away: w[14] === 1, goal: null, allow: w[16],
1031
- f: unpackFields(kind.fields, w[17], dims), m: unpackFields(kind.motion, w[18], dims), input: Object.freeze(unpackFields(kind.input, w[19], dims)), cmds: (w[20] as { name: string; data: unknown }[]).map((x) => ({ name: x.name, data: deepFreeze(x.data) })), self: {}, mself: {},
1078
+ f: unpackFields(kind.fields, w[17], dims), m: unpackFields(kind.motion, w[18], dims), input: Object.freeze(unpackFields(kind.input, w[19], dims)), cmds: (w[20] as { name: string; data: unknown }[]).map((x) => ({ name: x.name, data: deepFreeze(coerceFields(c.commands[x.name] ?? [], x.data, dims)) })), self: {}, mself: {},
1032
1079
  };
1033
1080
  if (kind.player) restoredGoals.set(e.seat, w[15]);
1034
1081
  makeSelf(e);
@@ -1036,8 +1083,8 @@ export function createCore(c: Compiled, opts: { seed?: number; epoch?: number; r
1036
1083
  }
1037
1084
  function save(): SavedCore {
1038
1085
  return {
1039
- v: 1, tick, epoch, rng, nextId, seq, round: [round.n, round.phase === 'live' ? 1 : 0, round.endsAt, round.startedAt], overAt, match: [playing, restartAt], trips,
1040
- shared: packFields(c.shared, shared, dims), policy: { ...policy }, asks: pendingAsks, guideViews: [...guideViews],
1086
+ v: SAVE_REVISION, tick, epoch, rng, nextId, seq, round: [round.n, round.phase === 'live' ? 1 : 0, round.endsAt, round.startedAt], overAt, match: [playing, restartAt], trips,
1087
+ shared: packFields(c.shared, shared, dims), policy: { ...policy }, intent: [endAsked ? 1 : 0, finishing ? 1 : 0], asks: pendingAsks, guideViews: [...guideViews],
1041
1088
  ents: [...ents.values()].map(saveEnt), spawns: spawns.map(saveEnt),
1042
1089
  seats: [...seats.values()].map((s) => [s.seat, s.driver, s.owner, s.id, s.away ? 1 : 0]),
1043
1090
  queue: queue.map((q) => [q.due, q.from, q.fromId, q.seq, q.to, q.kind, q.ev, q.data, q.at, q.builtIn ? 1 : 0]),
@@ -1048,22 +1095,43 @@ export function createCore(c: Compiled, opts: { seed?: number; epoch?: number; r
1048
1095
  // A persisted value is untrusted too. Reject structural damage instead of silently making a different match.
1049
1096
  function validateSave(r: SavedCore): void {
1050
1097
  const check = (ok: unknown): void => { if (!ok) throw new Error('saved rules state is invalid'); };
1051
- const uint = (v: unknown): boolean => Number.isSafeInteger(v) && (v as number) >= 0;
1098
+ const uint = (v: unknown): boolean => Number.isSafeInteger(v) && (v as number) >= 0 && (v as number) < 2 ** 40;
1052
1099
  const bit = (v: unknown): boolean => v === 0 || v === 1;
1053
1100
  const fields = (list: FieldList, value: unknown): void => {
1054
1101
  check(Array.isArray(value) && JSON.stringify(value) === JSON.stringify(packFields(list, unpackFields(list, value, dims), dims)));
1055
1102
  };
1056
- check(r.v === 1);
1103
+ check(r.v === SAVE_REVISION);
1104
+ check(Object.keys(r).every(k => ['v', 'tick', 'epoch', 'rng', 'nextId', 'seq', 'round', 'overAt', 'match', 'trips', 'shared', 'policy', 'intent', 'asks', 'guideViews', 'ents', 'spawns', 'seats', 'queue', 'areas', 'ops'].includes(k)));
1105
+ const askNames = new Set();
1106
+ check(Array.isArray(r.asks));
1107
+ for (const a of r.asks) {
1108
+ check(a && typeof a.name === 'string' && Object.hasOwn(c.asks, a.name) && !askNames.has(a.name)); askNames.add(a.name);
1109
+ check(Object.keys(a).every(k => ['n', 'name', 'who', 'at', 'state', 'result'].includes(k)));
1110
+ check(typeof a.n === 'string' && a.n.length <= 128 && typeof a.who === 'string' && a.who.length <= 128 && uint(a.at) && a.at <= r.tick);
1111
+ check(JSON.stringify(a.state) === JSON.stringify(coerceFields(c.asks[a.name].stateFields, a.state, dims)));
1112
+ if (a.result !== undefined) check(JSON.stringify(a.result) === JSON.stringify(decisionResult(a.result)));
1113
+ }
1114
+ check(Array.isArray(r.guideViews) && r.guideViews.length <= c.seats);
1115
+ const views = new Set();
1116
+ for (const row of r.guideViews) {
1117
+ check(Array.isArray(row) && row.length === 2 && uint(row[0]) && row[0] < c.seats && !views.has(row[0])); views.add(row[0]);
1118
+ check(JSON.stringify(row[1]) === JSON.stringify(coerceFields(c.view, row[1], dims)));
1119
+ }
1120
+ check(r.intent === undefined || Array.isArray(r.intent) && r.intent.length === 2 && r.intent.every(bit));
1057
1121
  for (const v of [r.tick, r.epoch, r.rng, r.nextId, r.seq, r.overAt, r.trips]) check(uint(v));
1122
+ check(r.tick < 0xffffffff);
1058
1123
  check(r.rng <= 4294967295 && r.epoch > 0 && r.nextId > 0 && r.seq > 0);
1059
1124
  check(Array.isArray(r.round) && r.round.length === 4 && r.round.every(uint) && bit(r.round[1]));
1060
- check(Array.isArray(r.match) && r.match.length === 2 && r.match.every(uint));
1125
+ check(Array.isArray(r.match) && r.match.length === 2 && r.match.every(uint) && bit(r.match[0]));
1126
+ const duration = r.round[1] === 1 ? ticks(c.rounds?.seconds ?? 0) : Math.max(3, ticks(c.rounds?.breakSeconds ?? 0));
1127
+ check(r.round[3] <= r.tick && r.round[2] <= r.tick + duration && !(r.round[1] === 1 && !duration && r.round[2] !== 0));
1061
1128
  check(r.policy && ['fill', 'off'].includes(r.policy.bots) && Number.isInteger(r.policy.level) && Number.isInteger(r.policy.levelMax) && r.policy.level >= 1 && r.policy.level <= r.policy.levelMax && r.policy.levelMax <= 5);
1062
1129
  check(Object.keys(r.policy).every((key) => ['bots', 'level', 'levelMax', 'reserved'].includes(key)));
1063
1130
  check(r.policy.reserved === undefined || uint(r.policy.reserved) && r.policy.reserved < c.seats);
1064
1131
  fields(c.shared, r.shared);
1065
1132
  check(Array.isArray(r.ents) && Array.isArray(r.spawns) && r.ents.length + r.spawns.length <= ENTITY_MAX);
1066
1133
  const ids = new Set();
1134
+ const bodySeats = new Set();
1067
1135
  for (const w of [...r.ents, ...r.spawns]) {
1068
1136
  check(Array.isArray(w) && w.length === 21);
1069
1137
  const k = c.kinds[w[2] as number];
@@ -1073,21 +1141,26 @@ export function createCore(c: Compiled, opts: { seed?: number; epoch?: number; r
1073
1141
  check(typeof w[16] === 'number' && Number.isFinite(w[16]) && w[16] >= 0);
1074
1142
  for (const i of [5, 10, 14]) check(bit(w[i]));
1075
1143
  for (const i of [7, 8, 9]) check(Array.isArray(w[i]) && (w[i] as unknown[]).length === 3 && (w[i] as unknown[]).every((n) => typeof n === 'number' && Number.isFinite(n)));
1144
+ for (const i of [7, 8, 9]) check(JSON.stringify(w[i]) === JSON.stringify(packVec(unpackVec(w[i], 3), 3)));
1145
+ const goal = w[15] as any;
1146
+ if (goal !== null) check(goal && typeof goal === 'object' && !Array.isArray(goal) && Object.keys(goal).every(k => ['goal', 'args', 'from', 'at', 'asked', 'state'].includes(k)) && typeof goal.goal === 'string' && goal.args && typeof goal.args === 'object' && !Array.isArray(goal.args) && ['brain', 'floor'].includes(goal.from) && uint(goal.at) && goal.at <= r.tick && typeof goal.asked === 'boolean' && goal.state === 'active');
1076
1147
  const pos = unpackVec(w[7] as number[], 3), bounds = c.map.bounds;
1077
1148
  check(pos.x >= bounds.min.x && pos.x <= bounds.max.x && pos.y >= bounds.min.y && pos.y <= bounds.max.y && pos.z === 0);
1078
1149
  check(Number.isInteger(w[11]) && (w[11] as number) >= -1 && (w[11] as number) < c.seats && ['person', 'bot', 'ai'].includes(w[12] as string) && typeof w[13] === 'string' && w[13].length <= 128);
1150
+ if (k.player) { check(!bodySeats.has(w[11])); bodySeats.add(w[11]); }
1079
1151
  fields(k.fields, w[17]); fields(k.motion, w[18]); fields(k.input, w[19]);
1080
1152
  check(w[6] === 'join' || w[6] === 'spawn');
1081
1153
  check(JSON.stringify(w[15]) === JSON.stringify(plainData(w[15], { n: ANSWER_MAX })));
1082
- check(Array.isArray(w[20]));
1154
+ check(Array.isArray(w[20]) && w[20].length <= 16);
1083
1155
  for (const cmd of w[20] as { name: string; data: unknown }[]) {
1084
- check(cmd && typeof cmd.name === 'string' && Object.hasOwn(c.commands, cmd.name)); const shape = c.commands[cmd.name];
1156
+ check(cmd && typeof cmd.name === 'string' && Object.hasOwn(c.commands, cmd.name) && Object.hasOwn(k.commands, cmd.name)); const shape = c.commands[cmd.name];
1085
1157
  check(JSON.stringify(cmd.data) === JSON.stringify(coerceFields(shape, cmd.data, dims)));
1086
1158
  }
1087
1159
  }
1088
1160
  check(Array.isArray(r.seats) && r.seats.length <= c.seats);
1089
1161
  const seatIds = new Set();
1090
1162
  const heldIds = new Set();
1163
+ check(r.seats.every((row) => Array.isArray(row) && row.length === 5));
1091
1164
  for (const [seat, driver, owner, id, away] of r.seats) {
1092
1165
  check(uint(seat) && seat < c.seats && !seatIds.has(seat)); seatIds.add(seat);
1093
1166
  check(['person', 'bot', 'ai'].includes(driver) && typeof owner === 'string' && owner.length <= 128 && (id === null || ids.has(id)) && bit(away));
@@ -1098,9 +1171,9 @@ export function createCore(c: Compiled, opts: { seed?: number; epoch?: number; r
1098
1171
  }
1099
1172
  }
1100
1173
  for (const body of r.ents) if (c.kinds[body[2] as number].player) check(heldIds.has(body[0]));
1101
- check(Array.isArray(r.queue) && r.queue.length <= QUEUE_MAX && Array.isArray(r.areas) && r.areas.length <= QUEUE_MAX && Array.isArray(r.ops));
1174
+ check(Array.isArray(r.queue) && r.queue.length <= QUEUE_MAX && Array.isArray(r.areas) && r.areas.length <= AREAS_MAX && Array.isArray(r.ops));
1102
1175
  for (const q of r.queue) {
1103
- check(q.length === 10 && uint(q[0]) && uint(q[1]) && typeof q[2] === 'string' && uint(q[3]) && typeof q[4] === 'string' && ['ev', 'room', 'all'].includes(q[5] as string) && typeof q[6] === 'string' && uint(q[8]) && bit(q[9]));
1176
+ check(q.length === 10 && uint(q[0]) && uint(q[1]) && typeof q[2] === 'string' && q[2].length <= 128 && uint(q[3]) && typeof q[4] === 'string' && q[4].length <= 128 && ['ev', 'room', 'all'].includes(q[5] as string) && typeof q[6] === 'string' && uint(q[8]) && bit(q[9]));
1104
1177
  check((q[3] as number) < r.seq);
1105
1178
  if (!q[9]) { check(Object.hasOwn(c.events, q[6] as string)); const shape = c.events[q[6] as string]; check(JSON.stringify(q[7]) === JSON.stringify(coerceFields(shape, q[7], dims))); }
1106
1179
  else {
@@ -1109,6 +1182,7 @@ export function createCore(c: Compiled, opts: { seed?: number; epoch?: number; r
1109
1182
  if (q[6] === 'answer') {
1110
1183
  check(typeof d.ask === 'string' && Object.hasOwn(c.asks, d.ask) && ['ai', 'local', 'floor'].includes(d.by as string) && (d.why === undefined || typeof d.why === 'string' && d.why.length <= 256));
1111
1184
  check(JSON.stringify(d.picks) === JSON.stringify(plainData(d.picks, { n: ANSWER_MAX })));
1185
+ if (!validPicks(c.asks[d.ask as string].questions, d.picks)) throw new Error('saved rules state is invalid: invalid saved answer');
1112
1186
  } else {
1113
1187
  check(uint(d.n));
1114
1188
  if (q[6] === 'roundOver') {
@@ -1147,25 +1221,27 @@ export function createCore(c: Compiled, opts: { seed?: number; epoch?: number; r
1147
1221
  }
1148
1222
  const r = opts.restore;
1149
1223
  if (r) {
1224
+ if (r.v !== SAVE_REVISION) throw new Error('this save was written by another version of the runtime');
1150
1225
  validateSave(r);
1151
- if (r.v !== 1) throw new Error('this save was written by another version of the runtime');
1152
- const askNames = new Set<string>();
1153
- pendingAsks = (Array.isArray(r.asks) ? r.asks : []).filter((a) => {
1154
- if (!a || typeof a.name !== 'string' || !Object.hasOwn(c.asks, a.name) || askNames.has(a.name) || typeof a.n !== 'string' || a.n.length > 128 || typeof a.who !== 'string' || a.who.length > 128 || !Number.isSafeInteger(a.at) || a.at < 0 || a.at > r.tick) return false;
1155
- askNames.add(a.name); return true;
1156
- }).slice(0, Object.keys(c.asks).length).map((a) => ({ n: a.n, name: a.name, who: a.who, at: a.at, state: deepFreeze(coerceFields(c.asks[a.name].stateFields, a.state, dims)), ...(a.result ? { result: decisionResult(a.result) } : {}) }));
1157
- if (opts.restoreEpoch !== undefined && (!Number.isSafeInteger(opts.restoreEpoch) || opts.restoreEpoch <= 0)) throw new Error('restored epoch is invalid');
1226
+ pendingAsks = r.asks.map(a => ({ ...a, state: deepFreeze(a.state) }));
1227
+ if ([opts.restoreEpoch].some((e) => e !== undefined && (!Number.isSafeInteger(e) || e <= 0))) throw new Error('restored epoch is invalid');
1158
1228
  tick = r.tick; epoch = opts.restoreEpoch ?? r.epoch; rng = r.rng; nextId = r.nextId; seq = r.seq; overAt = r.overAt; playing = r.match[0]; restartAt = r.match[1]; trips = r.trips;
1229
+ endAsked = r.intent?.[0] === 1; finishing = r.intent?.[1] === 1;
1159
1230
  round = { n: r.round[0], phase: r.round[1] === 1 ? 'live' : 'over', endsAt: r.round[2], startedAt: r.round[3] };
1160
1231
  refreshRound();
1161
1232
  shared = unpackFields(c.shared, r.shared, dims); sharedChanged();
1162
1233
  policy = normalizeCorePolicy(r.policy ?? {});
1163
- for (const [seat, value] of (Array.isArray(r.guideViews) ? r.guideViews : []).filter((v) => Array.isArray(v) && v.length === 2).slice(0, c.seats)) if (Number.isInteger(seat) && seat >= 0 && seat < c.seats) guideViews.set(seat, coerceFields(c.view, value, dims));
1234
+ for (const [seat, value] of r.guideViews) guideViews.set(seat, deepFreeze(value));
1164
1235
  for (const w of r.ents) { const e = loadEnt(w as any[]); ents.set(e.id, e); }
1165
1236
  spawns = r.spawns.map((w) => loadEnt(w as any[]));
1166
1237
  for (const [seat, driver, owner, id, away] of r.seats) seats.set(seat, { seat, driver: driver as Driver, owner, id, away: away === 1 });
1167
- queue = r.queue.map((q: any[]) => ({ due: q[0], from: q[1], fromId: q[2], seq: q[3], to: q[4], kind: q[5], ev: q[6], data: deepFreeze(q[7]), at: q[8], ...(q[9] === 1 ? { builtIn: true } : {}) }));
1168
- areas = r.areas.map((a: any[]) => ({ from: a[0], fromId: a[1], seq: a[2], shape: a[3], ev: a[4], data: deepFreeze(a[5]) }));
1238
+ // Checked above. Each payload is held to its shape again, so a restored one is the frozen value a live one is. A saved
1239
+ // flag cannot take a declared event past its payload's shape or make it a room announcement.
1240
+ queue = r.queue.map((q: any[]) => {
1241
+ const builtIn = q[9] === 1 && !Object.hasOwn(c.events, q[6]);
1242
+ return { due: q[0], from: q[1], fromId: q[2], seq: q[3], to: q[4], kind: q[5], ev: q[6], data: deepFreeze(builtIn ? plainData(q[7], { n: ANSWER_MAX }) : coerceFields(c.events[q[6]] ?? [], q[7], dims)), at: q[8], ...(builtIn ? { builtIn: true } : {}) };
1243
+ });
1244
+ areas = r.areas.map((a: any[]) => ({ from: a[0], fromId: a[1], seq: a[2], shape: a[3], ev: a[4], data: deepFreeze(coerceFields(c.events[a[4]] ?? [], a[5], dims)) }));
1169
1245
  ops = r.ops as typeof ops;
1170
1246
  } else startMatch();
1171
1247
 
@@ -1178,14 +1254,15 @@ export function createCore(c: Compiled, opts: { seed?: number; epoch?: number; r
1178
1254
  const def = v && typeof v.goal === 'string' && vocabulary && Object.hasOwn(vocabulary.goals, v.goal) ? vocabulary.goals[v.goal] : null;
1179
1255
  const players = playerBodies().filter((p) => p.driver === 'person' && !p.away).map((p) => p.seat);
1180
1256
  return def && !argsWhy(def.args, v.args, guideViews.get(seat) ?? null, players)
1181
- ? deepFreeze({ goal: v.goal, args: v.args ?? {}, from: v.from === 'brain' ? 'brain' : 'floor', at: Math.max(0, Math.min(tick, Math.floor(num(v.at)) || 0)), asked: v.asked === true }) : null;
1257
+ ? deepFreeze({ goal: v.goal, args: v.args ?? {}, from: v.from === 'brain' ? 'brain' : 'floor', at: Math.max(0, Math.min(tick, Math.floor(num(v.at)) || 0)), asked: v.asked === true, state: 'active' }) : null;
1182
1258
  }
1183
1259
  return {
1260
+ round: () => ({ t: 'round', ...round, ...(round.phase === 'over' && !overAt ? { results: results() } : {}) }),
1184
1261
  get tick() { return tick; },
1185
1262
  get epoch() { return epoch; },
1186
1263
  step,
1187
1264
  answer: (n, result) => { const a = pendingAsks.find((x) => x.n === n); if (a && !a.result) a.result = decisionResult(result); },
1188
- vocabulary: (value) => { vocabulary = value; for (const e of playerBodies()) e.goal = e.driver === 'ai' ? validGoal(e.seat, restoredGoals.get(e.seat) ?? e.goal) : null; restoredGoals.clear(); },
1265
+ vocabulary: (value) => { vocabulary = value; for (const e of playerBodies()) { const savedGoal = restoredGoals.get(e.seat); const goal = e.driver === 'ai' ? validGoal(e.seat, savedGoal ?? e.goal) : null; if (savedGoal != null && vocabulary && !Object.hasOwn(vocabulary.goals, String(own(savedGoal, 'goal')))) throw new Error('saved goal is invalid'); e.goal = goal; } restoredGoals.clear(); },
1189
1266
  goal: (seat, value) => {
1190
1267
  const e = playerBodies().find((x) => x.seat === seat);
1191
1268
  if (!e) return null;
@@ -1211,11 +1288,24 @@ export function createCore(c: Compiled, opts: { seed?: number; epoch?: number; r
1211
1288
  return def && players.includes(a.from) && !argsWhy(def.args, a.args, offered, players);
1212
1289
  }).map((a: any) => ({ k: a.k, args: plainData(a.args, { n: ANSWER_MAX }), from: a.from, at: Number.isFinite(a.at) ? a.at : 0 }));
1213
1290
  const goal = validGoal(seat, fv.goal);
1214
- safeView = { ...coerceFields(c.view, floorView, dims), goal: goal ? { goal: goal.goal, args: goal.args, state: ['active', 'done', 'failed'].includes((fv.goal as any)?.state) ? (fv.goal as any).state : 'active' } : null, asks };
1291
+ safeView = { ...coerceFields(c.view, floorView, dims), goal: goal ? { ...goal, state: ['active', 'done', 'failed'].includes((fv.goal as any)?.state) ? (fv.goal as any).state : 'active' } : null, asks };
1215
1292
  }
1216
1293
  const raw = floorCall ? g.floor?.(world, e.self, deepFreeze(safeView)) : g.view(world, viewOf(e));
1217
1294
  charge(COPY * (floorCall ? ANSWER_MAX : estFields(c.view, raw)));
1218
1295
  const v = floorCall ? plainData(raw, { n: ANSWER_MAX }) : coerceFields(c.view, raw, dims);
1296
+ if (floorCall && vocabulary && v && typeof v === 'object') {
1297
+ const decision = v as Record<string, unknown>;
1298
+ const players = playerBodies().filter(b => b.driver === 'person').map(b => b.seat);
1299
+ for (const [key, argKey, table] of [['goal', 'args', vocabulary.goals ?? {}], ['say', 'sayArgs', vocabulary.lines ?? {}]] as const) {
1300
+ const name = decision[key];
1301
+ if (name === undefined || name === null) continue;
1302
+ const def = typeof name === 'string' && Object.hasOwn(table, name) ? table[name] : null;
1303
+ const why = !def ? `unknown ${key} ${String(name)}; allowed names: ${Object.keys(table).join(', ') || 'none'}`
1304
+ : argsWhy(def.args, decision[argKey], guideViews.get(seat) ?? null, players);
1305
+ // Discarded whole, here and on every host: no part of a decision outside the vocabulary reaches the rules.
1306
+ if (why) { G.note?.('decision', `${key} ${said(name)}`, `${why}${def ? `; its arguments: ${Object.keys(def.args ?? {}).join(', ') || 'none'}` : ''}`); return; }
1307
+ }
1308
+ }
1219
1309
  if (v && typeof v === 'object' && !Array.isArray(v) && JSON.stringify(v).length <= 1900) value = v as Record<string, unknown>;
1220
1310
  });
1221
1311
  if (!floorCall && value) guideViews.set(seat, value);
@@ -1248,3 +1338,14 @@ export function createCore(c: Compiled, opts: { seed?: number; epoch?: number; r
1248
1338
  stats,
1249
1339
  };
1250
1340
  }
1341
+
1342
+ /** Decisions from the wire, saved events and rule floors meet the same questions. */
1343
+ function validPicks(questions: Record<string, unknown>, picks: unknown): boolean {
1344
+ if (!picks || typeof picks !== 'object' || Array.isArray(picks)) return false;
1345
+ return Object.entries(questions).every(([id, raw]) => {
1346
+ const q = raw as any; const v = (picks as Record<string, unknown>)[id];
1347
+ if (q.type === 'noul' || q.type === 'yes-no') return typeof v === 'boolean';
1348
+ if (q.type === 'score') return typeof v === 'number' && Number.isInteger(v) && v >= 0 && v < q.criteria.length;
1349
+ return typeof v === 'string' && (Array.isArray(q.criteria) ? q.criteria.includes(v) : Object.hasOwn(q.criteria, v));
1350
+ });
1351
+ }
package/rules/guard.ts CHANGED
@@ -32,8 +32,14 @@ export const REFUSED_NAMES: ReadonlySet<string> = new Set(['constructor', 'proto
32
32
  export const HOOK_NAMES: ReadonlySet<string> = new Set(['valueOf', 'toString', 'toJSON']);
33
33
  export const refusedName = (k: string): boolean => REFUSED_NAMES.has(k) || k.startsWith('toLocale');
34
34
 
35
- /** The running handler's budget. One per isolate: handlers never run at the same time. */
36
- export const G = { left: Infinity };
35
+ /**
36
+ * The running handler's budget. One per isolate: handlers never run at the same time. `file` and `line` are the line
37
+ * of the rules that ran last: the guarded module sets them as it goes, and the runtime reads them when a handler throws.
38
+ * `note` is the build check's listener for a written value that had to be changed to fit (pack.ts); a room has none.
39
+ */
40
+ export const G = { left: Infinity, file: '', line: 0, note: null as null | ((what: string, at: string, written: string) => void) };
41
+ /** The line a guarded call is on, set again after its arguments ran (they may have called into another file). */
42
+ export function l(file: string, line: number): number { G.file = file; G.line = line; return line; }
37
43
 
38
44
  /**
39
45
  * An object's own data property, read without running anything: a property that is a getter reads as absent, and so
@@ -60,7 +66,7 @@ export function put(o: object, key: string, v: unknown): void {
60
66
  */
61
67
  export function plainData(v: unknown, left: { n: number }, deep = 4, text = 256): unknown {
62
68
  left.n -= 1;
63
- if (typeof v === 'number') return Number.isFinite(v) ? v : 0;
69
+ if (typeof v === 'number') return Number.isFinite(v) ? v + 0 : 0; // `+ 0`: never -0, which the save would not carry (pack.ts, ONE ZERO)
64
70
  if (typeof v === 'boolean' || v === null) return v;
65
71
  if (typeof v === 'string') return v.slice(0, text);
66
72
  if (typeof v !== 'object' || deep <= 0 || left.n <= 0) return null;
@@ -99,7 +105,8 @@ export function charge(n: number): void {
99
105
  if (!((G.left -= n) >= 0)) throw STOP;
100
106
  }
101
107
  /** One loop turn, or one call of a function in rules. */
102
- export function t(): void {
108
+ export function t(file: string, line: number): void {
109
+ G.file = file; G.line = line;
103
110
  if (!((G.left -= 1) >= 0)) throw STOP;
104
111
  }
105
112