@homie-rocks/studio 0.45.2 → 0.46.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/host.ts CHANGED
@@ -47,7 +47,7 @@ import { AGENT_RULES, useAgents, type Agents, type AgentsSaved, type Vocabulary
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';
50
- import { coerce, coerceFields, num, own, fromBytes, initFields, toBytes, unpackVec } from './pack.ts';
50
+ import { coerce, coerceFields, num, own, fromBytes, initFields, toBytes, unpackVec, unpackFields } from './pack.ts';
51
51
  import type { Compiled, KindTable, Vec3 } from './rules.ts';
52
52
 
53
53
  export interface HostClock {
@@ -56,8 +56,13 @@ export interface HostClock {
56
56
  setTimer(fn: () => void, ms: number): unknown;
57
57
  clearTimer(handle: unknown): void;
58
58
  }
59
+ export interface CharacterLease {
60
+ account: string; lease: number; seat: number; character: import('./rules.ts').Character | null; returning?: boolean;
61
+ }
59
62
  export interface HostOptions {
60
63
  game: string;
64
+ record?(owner:string,key:string,value:Record<string,unknown>):void;
65
+ characterFor?(peer: Record<string, unknown>): CharacterLease | null;
61
66
  /** The build check compares a movement step before any tick handler can change it. */
62
67
  moved?: NonNullable<Parameters<typeof createCore>[1]>['moved'];
63
68
  /** Told of every handler as it ends, and what it threw if it threw (core.ts `observe`): the build check listens. */
@@ -73,7 +78,7 @@ export interface HostOptions {
73
78
  send(frame: Record<string, unknown>, text?: string): void;
74
79
  clock: HostClock;
75
80
  /** Synchronous save: the caller's storage output gate holds this tick's outgoing frames until confirmed. */
76
- store?: { save(bytes: Uint8Array): void };
81
+ store?: { save(bytes: Uint8Array, commitment?: { force: boolean; seconds: number; tickHz: number; fields: { kinds: number[][]; shared: number[] } }): void };
77
82
  /** Table recovery reserves this epoch in storage before starting this host. Ordinary deterministic restores omit it. */
78
83
  restoreEpoch?: number;
79
84
  startDelayMs?: number;
@@ -94,12 +99,18 @@ export interface HostOptions {
94
99
  onEnd?(why: string, facts: Record<string, unknown>): void;
95
100
  }
96
101
  export interface Host {
102
+ readonly characters: ReadonlyMap<string, CharacterLease>;
103
+ characterReturned(account: string, lease: number): void;
104
+ releaseCharacter(account: string): void;
105
+ reserveCharacter(lease: CharacterLease): void;
97
106
  readonly tick: number;
98
107
  readonly epoch: number;
99
108
  readonly running: boolean;
100
109
  readonly paused: boolean;
101
110
  readonly people: number;
102
111
  readonly viewRadiusM: number | null;
112
+ readonly privateDelivery: boolean;
113
+ projectSnapshot(snap: any, seat: number | null): any;
103
114
  readonly viewSettings: Compiled['settings']['view'];
104
115
  frame(m: Record<string, unknown>): void;
105
116
  /** Start the tick loop (the first person has joined). */
@@ -108,7 +119,7 @@ export interface Host {
108
119
  resume(): void;
109
120
  /** Publish the round, the roster, the shared state and the capabilities again (a browser host's relay restarted). */
110
121
  announce(): void;
111
- /** Stop for good: the room has ended. */
122
+ /** Stop for good: the room has ended. A lasting world whose storage is healthy checkpoints first, and that write can throw. */
112
123
  stop(): void;
113
124
  /** 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. */
114
125
  tickNow(): void;
@@ -118,8 +129,9 @@ export interface Host {
118
129
  readonly core: Core;
119
130
  }
120
131
 
121
- interface Entry { at: number; values: Record<string, unknown>; presses: string[]; claim: StepInput['claim'] }
132
+ interface Entry { at: number; values: Record<string, unknown>; presses: (string | [string, number])[]; claim: StepInput['claim'] }
122
133
  interface SeatIn {
134
+ pulses?: [string,number][];
123
135
  held: Record<string, unknown>; claim: StepInput['claim']; entries: Entry[];
124
136
  /** The newest stamp this seat has sent, the stamps not yet acknowledged, and the newest acknowledged. */
125
137
  newest: number; stamps: number[]; ack: number;
@@ -160,19 +172,33 @@ function blameLate(now: number, period: number): void {
160
172
 
161
173
  export function createHost(o: HostOptions): Host {
162
174
  const c = o.compiled;
175
+ const persistent = c.settings.lifetime === 'persistent';
176
+ // Keep even effects and agent requests behind the same commit as their tick.
177
+ // Match rooms retain their existing save frequency and output behaviour.
178
+ const publish = o.send;
179
+ let pendingOutput: [Record<string, unknown>, string | undefined][] | null = null;
180
+ if (persistent) o = { ...o, send: (frame, text) => {
181
+ if (pendingOutput) pendingOutput.push([frame, text]); else publish(frame, text);
182
+ } };
183
+
163
184
  const period = 1000 / c.settings.tickHz;
164
185
  const tickHz = c.settings.tickHz;
165
186
  const lateTicks = Math.ceil(tickHz / 4);
166
187
  // A log that throws takes nothing down with it.
167
188
  const log = (line: Record<string, unknown>): void => { try { o.log?.(line); } catch { /* nothing to say it with */ } };
168
189
  const random = o.random ?? Math.random;
169
- const saved = o.restore ? fromBytes(o.restore) as { v: number; core: SavedCore; names: [number, string][]; agents?: AgentsSaved; guideViews?: [number, { id: string; at: number; value: Record<string, unknown> }][]; queues: [number, Record<string, unknown>, StepInput['claim'], number, number][]; inputs?: [number, SeatIn][] } : null;
190
+ const saved = o.restore ? fromBytes(o.restore) as { v: number; characters?: CharacterLease[]; core: SavedCore; names: [number, string][]; agents?: AgentsSaved; guideViews?: [number, { id: string; at: number; value: Record<string, unknown> }][]; queues: [number, Record<string, unknown>, StepInput['claim'], number, number][]; inputs?: [number, SeatIn][] } : null;
170
191
  if (o.restore && (!saved || typeof saved !== 'object' || Array.isArray(saved))) throw new Error('saved host is not an object');
171
192
  if (saved) {
172
193
  const check = (ok: unknown): void => { if (!ok) throw new Error('saved host inputs are invalid'); };
173
194
  const uint = (n: unknown): boolean => Number.isSafeInteger(n) && (n as number) >= 0;
174
- check(Object.keys(saved).every(k => ['v', 'core', 'names', 'agents', 'guideViews', 'queues', 'inputs'].includes(k)));
195
+ check(Object.keys(saved).every(k => ['v', 'core', 'names', 'agents', 'guideViews', 'queues', 'inputs', 'characters'].includes(k)));
175
196
  check(saved.core && Array.isArray(saved.names) && Array.isArray(saved.queues));
197
+ if (saved.characters !== undefined) {
198
+ check(c.settings.characters && Array.isArray(saved.characters));
199
+ check(new Set(saved.characters.map(c => c.account)).size === saved.characters.length);
200
+ for (const held of saved.characters) check(typeof held.account === 'string' && /^[A-Za-z0-9_-]{1,64}$/.test(held.account) && uint(held.lease) && held.lease > 0 && Number.isInteger(held.seat) && held.seat >= -1 && held.seat < c.seats && (held.returning === undefined || typeof held.returning === 'boolean'));
201
+ }
176
202
  if (saved.agents !== undefined) {
177
203
  const tables = ['goals', 'asks', 'avoid', 'sayAt', 'viewAt', 'floorAt', 'askAt'];
178
204
  check(saved.agents && Object.keys(saved.agents).every(k => tables.includes(k)));
@@ -225,17 +251,34 @@ export function createHost(o: HostOptions): Host {
225
251
  for (const [seat, q] of saved.inputs) {
226
252
  check(uint(seat) && seat < c.seats && q && valuesOk(seat, q.held) && claimOk(q.claim) && uint(q.newest) && uint(q.ack) && uint(q.lastFrameTick) && Number.isInteger(q.lead) && q.lead >= -127 && q.lead <= 127);
227
253
  check(Array.isArray(q.stamps) && q.stamps.length <= 256 && q.stamps.every(uint) && Array.isArray(q.entries) && q.entries.length <= c.settings.tickHz + 1);
228
- for (const e of q.entries) check(uint(e.at) && valuesOk(seat, e.values) && claimOk(e.claim) && Array.isArray(e.presses) && e.presses.every((p) => c.kinds.some((k) => k.input.some(([name, fd]) => name === p && fd.t === 'press'))));
254
+ if(q.pulses!==undefined)check(Array.isArray(q.pulses)&&q.pulses.length<=16&&q.pulses.every(p=>Array.isArray(p)&&p.length===2&&c.kinds.some(k=>k.input.some(([n,f])=>n===p[0]&&f.t==='pulse'))&&Number.isInteger(p[1])&&p[1]>0&&p[1]<=255));
255
+ for (const e of q.entries) check(uint(e.at) && valuesOk(seat, e.values) && claimOk(e.claim) && Array.isArray(e.presses) && e.presses.every((p) => c.kinds.some((k) => k.input.some(([name, fd]) => (typeof p === 'string' ? name === p && fd.t === 'press' : Array.isArray(p) && p.length === 2 && name === p[0] && fd.t === 'pulse' && Number.isInteger(p[1]) && p[1] > 0 && p[1] <= 255)))));
229
256
  }
230
257
  }
231
258
  }
232
259
  if (saved && saved.v !== 1) throw new Error('this save was written by another version of the runtime');
233
- const core = createCore(c, { moved: o.moved, 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 });
260
+ const measuredRtt = new Map<number, number>();
261
+ const characters = new Map<string, CharacterLease>((saved?.characters ?? []).map(c => [c.account, c.returning ? { ...c, seat: -1 } : c]));
262
+ const characterOptions = c.settings.characters ? {
263
+ characterForSeat: (seat: number) => [...characters.values()].find(c => c.seat === seat && !c.returning)?.character,
264
+ characterSaved: (seat: number, character: import('./rules.ts').Character) => {
265
+ const held = [...characters.values()].find(c => c.seat === seat);
266
+ if (held) {
267
+ held.character = character;
268
+ // Once removed, a delayed return must never follow a new occupant of this seat.
269
+ if (held.returning) held.seat = -1;
270
+ }
271
+ },
272
+ } : {};
273
+ const core = createCore(c, { ...characterOptions, rewindTicks: seat => Math.ceil(((measuredRtt.get(seat) ?? 0) + 150 + period) / period), label: (seat,driver)=>nameOf(seat,driver), moved: o.moved, 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 });
234
274
  let epoch = core.epoch;
235
275
  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);
236
276
  let lastSaveAt = o.clock.now();
237
277
  let saveTick = core.tick;
238
278
  let forceSave = false;
279
+ let storeBroken = false;
280
+ /** Which state fields the game declared durable, by kind and for the shared state: what a lasting world journals at once. */
281
+ const durableFields = { kinds: c.kinds.map(k => k.fields.flatMap(([,f],i) => f.durable ? [i] : [])), shared: c.shared.flatMap(([,f],i) => f.durable ? [i] : []) };
239
282
  let saveFailures = 0;
240
283
  let retrySaveAt = 0;
241
284
  let roundPhase = saved?.core.round[1] ?? 1;
@@ -367,7 +410,7 @@ export function createHost(o: HostOptions): Host {
367
410
  // A restored room keeps each seat's held input, its newest stamp and its acknowledgement.
368
411
  for (const [seat, held, claim, newest, ack] of saved?.queues ?? []) queues.set(seat, { held, claim, entries: [], newest, stamps: [], ack, lead: 127, lastFrameTick: core.tick });
369
412
  for (const [seat, q] of saved?.inputs ?? []) queues.set(seat, q);
370
- const neutral = (kind: KindTable): Record<string, unknown> => { const v = initFields(kind.input, c.dims); for (const [name, fd] of kind.input) if (fd.t === 'press') v[name] = false; return v; };
413
+ const neutral = (kind: KindTable): Record<string, unknown> => { const v = initFields(kind.input, c.dims); for (const [name, fd] of kind.input) if (fd.t === 'press' || fd.t === 'pulse') v[name] = fd.t === 'press' ? false : 0; return v; };
371
414
  function queueOf(seat: number, kind: KindTable): SeatIn {
372
415
  let q = queues.get(seat);
373
416
  if (!q) { q = { held: neutral(kind), claim: null, entries: [], newest: 0, stamps: [], ack: 0, lead: 127, lastFrameTick: core.tick }; queues.set(seat, q); }
@@ -405,11 +448,11 @@ export function createHost(o: HostOptions): Host {
405
448
  if (j > K + tickHz) { stats.dropped += 1; break; }
406
449
  q.newest = j;
407
450
  const values: Record<string, unknown> = {};
408
- const presses: string[] = [];
451
+ const presses: (string | [string, number])[] = [];
409
452
  let i = 1;
410
453
  for (const [name, fd] of kind.input) {
411
454
  // Input values are held to their declared types, whatever was sent.
412
- if (fd.t === 'press') { values[name] = false; if (raw[i] === 1) presses.push(name); } else values[name] = coerce(fd, raw[i], c.dims);
455
+ if (fd.t === 'press') { values[name] = false; if (raw[i] === 1) presses.push(name); } else if (fd.t === 'pulse') { values[name] = 0; } else values[name] = coerce(fd, raw[i], c.dims);
413
456
  i += 1;
414
457
  }
415
458
  const claim = kind.body?.owner && raw.length >= i + 9 ? { pos: unpackVec([raw[i], raw[i + 1], raw[i + 2]], c.dims), vel: unpackVec([raw[i + 3], raw[i + 4], raw[i + 5]], c.dims), heading: unpackVec([raw[i + 6], raw[i + 7], raw[i + 8]], c.dims), r } : null;
@@ -437,6 +480,7 @@ export function createHost(o: HostOptions): Host {
437
480
  }
438
481
  function onFrame(m: Record<string, unknown>): void {
439
482
  switch (m.t) {
483
+ case 'latency': if(Number.isInteger(m.seat) && typeof m.ms==='number' && Number.isFinite(m.ms) && m.ms>=0) measuredRtt.set(m.seat as number,Math.min(m.ms,2000)); return;
440
484
  case 'vocabulary': setAgents((m.vocab ?? null) as Vocabulary | null); return;
441
485
  case 'decided': core.answer(String(m.n ?? ''), m); return;
442
486
  case 'in': return onIn(m);
@@ -452,7 +496,16 @@ export function createHost(o: HostOptions): Host {
452
496
  // The relay numbers every stay in a seat (`occ`): the same number is the same holder, back.
453
497
  const owner = `p${Number.isInteger(p.occ) ? p.occ : typeof p.id === 'string' ? p.id.slice(0, 40) : seat}`;
454
498
  const previous = core.bodies().find((s) => s.seat === seat);
455
- core.seatJoin({ seat, driver, owner });
499
+ const grant = driver === 'person' ? o.characterFor?.(p as Record<string, unknown>) : null;
500
+ if (c.settings.characters && driver === 'person' && !grant && o.characterFor) throw new Error('Character lease required');
501
+ let character = grant?.character;
502
+ if (grant) {
503
+ const held = characters.get(grant.account);
504
+ if (held && (held.lease !== grant.lease || held.returning || held.seat >= 0 && held.seat !== seat)) throw new Error('Character return is still pending');
505
+ character = held ? core.character(held.seat) ?? held.character : grant.character;
506
+ characters.set(grant.account, { ...grant, character: character ?? null, seat });
507
+ }
508
+ core.seatJoin({ seat, driver, owner, ...(grant ? { character } : {}) });
456
509
  core.seatAway(seat, false);
457
510
  if (!previous || previous.driver !== driver || previous.owner !== owner) queues.delete(seat);
458
511
  rosterText = '';
@@ -464,7 +517,13 @@ export function createHost(o: HostOptions): Host {
464
517
  for (const [id, p] of peers) if (p.seat === m.seat) peers.delete(id);
465
518
  present.delete(m.seat as number);
466
519
  queues.delete(m.seat as number);
467
- core.seatAway(m.seat as number, true);
520
+ const held = [...characters.values()].find(c => c.seat === m.seat && !c.returning);
521
+ if (held) {
522
+ held.character = core.character(held.seat) ?? held.character;
523
+ held.returning = true;
524
+ core.seatLeave(held.seat);
525
+ tick(o.clock.now());
526
+ } else core.seatAway(m.seat as number, true);
468
527
  if (running && people() === 0) pause();
469
528
  return;
470
529
  }
@@ -474,7 +533,10 @@ export function createHost(o: HostOptions): Host {
474
533
  for (const [id, p] of peers) if (p.seat === m.seat) peers.delete(id);
475
534
  present.delete(m.seat as number);
476
535
  queues.delete(m.seat as number);
536
+ const held = [...characters.values()].find(c => c.seat === m.seat && !c.returning);
537
+ if (held) { held.character = core.character(held.seat) ?? held.character; held.returning = true; }
477
538
  core.seatLeave(m.seat as number);
539
+ if (held) tick(o.clock.now());
478
540
  rosterText = '';
479
541
  return;
480
542
  }
@@ -486,6 +548,12 @@ export function createHost(o: HostOptions): Host {
486
548
  return;
487
549
  }
488
550
  if (agents && (kind === 'agent:do' || /^ask:/.test(kind) || (SPEECH.test(kind) && present.get(m.from as number) === false))) { if (agentInbox.length < 128) agentInbox.push(m); return; }
551
+ if(kind === 'pulse') {
552
+ const data=m.d,body=core.bodyOf(m.from as number);
553
+ if(!body || !hasHands(m.from as number,body.driver) || !Array.isArray(data) || data.length!==3 || data[0]!==epoch || typeof data[1]!=='string' || !body.kind.input.some(([name,fd])=>name===data[1]&&fd.t==='pulse'))return;
554
+ const value=coerce({t:'pulse'},data[2],c.dims) as number,q=queueOf(m.from as number,body.kind);q.pulses??=[];
555
+ if(value && q.pulses.length<16)q.pulses.push([data[1],value]);return;
556
+ }
489
557
  if (kind === 'cmd') { const d = m.d as unknown[]; if (Array.isArray(d) && typeof d[0] === 'string') core.command(m.from as number, d[0], d[1]); return; }
490
558
  // Speech keeps its payload, except the identity fields only the host may attach.
491
559
  if (SPEECH.test(kind)) o.send({ t: 'ev', from: m.from, k: kind, d: cleanSpeech(m.d) });
@@ -528,6 +596,14 @@ export function createHost(o: HostOptions): Host {
528
596
  for (const x of out) {
529
597
  if (x.t === 'round') {
530
598
  if (x.phase === 'over') forceSave = true;
599
+ if(!retime && x.results && c.records && o.record){
600
+ const declaration=c.records, shared=unpackFields(c.shared,core.shared(),c.dims), snapshot=core.snapshot();
601
+ for(const result of x.results){if(result.driver!=='person')continue;const row=(snapshot[1] as any[]).find(e=>e[0]===result.id);if(!row)continue;const kind=c.kinds[row[1]],fields=unpackFields(kind.fields,row[7],c.dims);if(declaration.eligible&&!fields[declaration.eligible])continue;
602
+ const value:Record<string,unknown>={id:String(shared[declaration.identity]),endedAt:Math.round(now),place:result.place,won:result.place===1&&x.results.filter(r=>r.place===1).length===1};
603
+ for(const [name,source] of Object.entries(declaration.fields))value[name]=Math.round(Number(fields[source]));
604
+ o.record(String(row[12]),declaration.key,value);
605
+ }
606
+ }
531
607
  const ms = (t: number): number => Math.round(now + (t - core.tick) * period);
532
608
  const drivers = new Map(core.bodies().map((b) => [b.seat, b.driver]));
533
609
  o.send({ t: 'round', ...(retime ? { retime: true } : {}), round: {
@@ -556,7 +632,7 @@ export function createHost(o: HostOptions): Host {
556
632
  for (const [seat, q] of queues) {
557
633
  const body = core.bodyOf(seat);
558
634
  if (!body) continue;
559
- const presses: string[] = [];
635
+ const presses: (string | [string, number])[] = [];
560
636
  while (q.entries.length && q.entries[0].at <= t) {
561
637
  const e = q.entries.shift() as Entry;
562
638
  q.held = e.values; q.claim = e.claim;
@@ -566,7 +642,8 @@ export function createHost(o: HostOptions): Host {
566
642
  if (t - q.lastFrameTick > tickHz && !q.entries.length) { q.held = neutral(body.kind); q.claim = null; }
567
643
  while (q.stamps.length && q.stamps[0] <= t) q.ack = q.stamps.shift() as number;
568
644
  const values = { ...q.held };
569
- for (const p of presses) values[p] = true;
645
+ for (const p of presses) { if (typeof p === 'string') values[p] = true; else values[p[0]] = p[1]; }
646
+ const pulse=q.pulses?.shift();if(pulse)values[pulse[0]]=pulse[1];
570
647
  inputs.set(seat, { values, claim: q.claim });
571
648
  }
572
649
  if (roundRebasePending) publishRebase(now);
@@ -612,7 +689,17 @@ export function createHost(o: HostOptions): Host {
612
689
  function tick(now: number): void {
613
690
  const cut = core.stats.ticksCut;
614
691
  let faulted = false;
615
- try { tickOnce(); } catch (error) { faulted = true; fault(`tick ${core.tick} (${phase})`, error); }
692
+ if (persistent) pendingOutput = [];
693
+ try {
694
+ tickOnce();
695
+ const committed = pendingOutput; pendingOutput = null;
696
+ if (committed && !ended) for (const [frame, text] of committed) publish(frame, text);
697
+ } catch (error) {
698
+ pendingOutput = null;
699
+ faulted = true; fault(`tick ${core.tick} (${phase})`, error);
700
+ // Never continue from uncommitted memory or retire the last good save.
701
+ if (persistent) { stop(); o.onEnd?.('storage-or-tick-failed', { tick: core.tick }); return; }
702
+ }
616
703
  if (ended) return;
617
704
  if (!faulted && core.stats.ticksCut === cut) { failingSince = null; return; }
618
705
  if (failingSince === null) failingSince = now;
@@ -708,7 +795,7 @@ export function createHost(o: HostOptions): Host {
708
795
  // A pending timer would keep the object in memory and billed: the pause clears it.
709
796
  disarm();
710
797
  log({ ev: 'host-pause', game: o.game, tick: core.tick });
711
- try { checkpoint(true); o.onPause?.(); } catch (error) { fault('onPause', error); }
798
+ try { checkpoint(true); o.onPause?.(); } catch (error) { fault('onPause', error); if (persistent) { stop(); o.onEnd?.('storage-or-tick-failed', { tick: core.tick }); } }
712
799
  }
713
800
  function resume(): void {
714
801
  if (running || ended || !paused) return;
@@ -750,9 +837,20 @@ export function createHost(o: HostOptions): Host {
750
837
  }
751
838
 
752
839
  function save(): Uint8Array {
753
- return toBytes({ v: 1, core: core.save(), guideViews: [...guideViews], ...(agents ? { agents: agents.save() } : savedAgents ? { agents: savedAgents } : {}), names: [...names].filter(([seat]) => !core.bodies().some(b => b.seat === seat && b.owner === 'reserved')), queues: [...queues].map(([seat, q]) => [seat, q.held, q.claim, q.newest, q.ack]), inputs: [...queues] });
840
+ return toBytes({ v: 1, ...(characters.size ? { characters: [...characters.values()] } : {}), core: core.save(), guideViews: [...guideViews], ...(agents ? { agents: agents.save() } : savedAgents ? { agents: savedAgents } : {}), names: [...names].filter(([seat]) => !core.bodies().some(b => b.seat === seat && b.owner === 'reserved')), queues: [...queues].map(([seat, q]) => [seat, q.held, q.claim, q.newest, q.ack]), inputs: [...queues] });
754
841
  }
755
842
  function checkpoint(force = false): void {
843
+ if (persistent) {
844
+ // An empty match break or an unjoined reservation has no body-removal callback.
845
+ for (const held of characters.values()) if (held.returning) held.seat = -1;
846
+ if (!o.store) return; // Local checks/offline simulation have no durable account.
847
+ // A write that threw leaves memory ahead of storage: nothing later may checkpoint it over the committed world.
848
+ storeBroken = true;
849
+ o.store.save(save(), { force, seconds: c.settings.durability.movementSeconds, tickHz, fields: durableFields });
850
+ storeBroken = false;
851
+ saveTick = core.tick; lastSaveAt = o.clock.now(); forceSave = false;
852
+ return;
853
+ }
756
854
  const seconds = c.settings.durability.movementSeconds;
757
855
  // Catch-up ticks can cover a second of play in less than a second of wall time. Bound both clocks.
758
856
  if (!o.store || !force && (core.tick === saveTick || o.clock.now() - lastSaveAt < seconds * 1000 && core.tick - saveTick < seconds * tickHz)) return;
@@ -769,6 +867,26 @@ export function createHost(o: HostOptions): Host {
769
867
  }
770
868
 
771
869
  return {
870
+ get privateDelivery() { return c.kinds.some(k => k.collider || k.fields.some(([,f]) => f.visibility)); },
871
+ projectSnapshot(snap, seat) {
872
+ return {...snap,d:[snap.d[0],snap.d[1].map((row: any[]) => {
873
+ const k=c.kinds[row[1]], own=seat!==null && row[9]===seat && row[10]!==1;
874
+ if(!k.fields.some(([,f])=>f.visibility))return row;
875
+ const fields=row[7].map((v: unknown,i: number)=>{const visibility=k.fields[i][1].visibility;return !visibility || visibility==='owner'&&own || visibility==='results'&&snap.d[0][1]===0 ? v : null;});
876
+ return [...row.slice(0,7),fields,...row.slice(8)];
877
+ }),...snap.d.slice(2)]};
878
+ },
879
+ get characters() { return characters; },
880
+ reserveCharacter(lease) { if (!characters.has(lease.account)) characters.set(lease.account, { ...lease }); },
881
+ releaseCharacter(account) {
882
+ const held = characters.get(account);
883
+ if (!held || held.returning || ended) return;
884
+ held.character = core.character(held.seat) ?? held.character; held.returning = true;
885
+ present.delete(held.seat); queues.delete(held.seat); names.delete(held.seat);
886
+ if (held.seat >= 0) core.seatLeave(held.seat); tick(o.clock.now());
887
+ if (running && people() === 0) pause();
888
+ },
889
+ characterReturned(account, lease) { if (characters.get(account)?.returning && characters.get(account)?.lease === lease) characters.delete(account); },
772
890
  get viewSettings() { return c.settings.view; },
773
891
  get viewRadiusM() { return c.settings.view?.radiusM ?? null; },
774
892
  get tick() { return core.tick; },
@@ -776,7 +894,9 @@ export function createHost(o: HostOptions): Host {
776
894
  get running() { return running; },
777
895
  get paused() { return paused; },
778
896
  get people() { return people(); },
779
- frame, start, pause, resume, stop, announce,
897
+ frame, start, pause, resume, announce,
898
+ // An orderly stop of a lasting world folds what moved since the last checkpoint. One that follows a failed write, or arrives during one, writes nothing.
899
+ stop() { try { if (persistent && !ended && !storeBroken) checkpoint(true); } finally { stop(); } },
780
900
  tickNow: () => {
781
901
  if (ended) return;
782
902
  let now = lastStart;
@@ -69,7 +69,7 @@ export function snapshotEncoder(keyframeTicks, chained = false) {
69
69
  for (let i = 1; i < entity.length; i++) if (rowText(entity)[i] !== before[i]) { mask |= 1 << i; values.push(entity[i]); }
70
70
  if (mask) changed.push([id, mask, values]);
71
71
  }
72
- const out = { ...snap, d: { base: base.k, round: snap.d[0], ...(snap.d.length > 2 ? { collision: snap.d[2] } : {}), changed, removed: [...rows.keys()].filter(id => !present.has(id)), ...(chained ? { chain: true } : {}), ...(own ? { own } : {}) } };
72
+ const out = { ...snap, d: { base: base.k, round: snap.d[0], ...(snap.d.length > 2 && JSON.stringify(snap.d[2]) !== JSON.stringify(base.d[2]) ? { collision: snap.d[2] } : {}), changed, removed: [...rows.keys()].filter(id => !present.has(id)), ...(chained ? { chain: true } : {}), ...(own ? { own } : {}) } };
73
73
  if (chained) { base = snap; rows = new Map(snap.d[1].map(e => [e[0], rowText(e)])); }
74
74
  return out;
75
75
  },
@@ -103,7 +103,7 @@ export function snapshotDecoder() {
103
103
  const visible = new Map(rows);
104
104
  for (const id of d.removed) visible.delete(id);
105
105
  for (const e of d.own) visible.set(e[0], e);
106
- return { ...snap, d: [d.round, [...visible.values()], ...(d.collision === undefined ? [] : [d.collision])] };
106
+ return { ...snap, d: [d.round, [...visible.values()], ...(d.collision === undefined ? base.d.slice(2) : [d.collision])] };
107
107
  }
108
108
  const next = new Map(rows);
109
109
  for (const id of d.removed) next.delete(id);
@@ -122,7 +122,7 @@ export function snapshotDecoder() {
122
122
  next.set(entity[0], entity);
123
123
  }
124
124
  for (const e of d.own ?? []) next.set(e[0], e);
125
- const decoded = { ...snap, d: [d.round, [...next.values()], ...(d.collision === undefined ? [] : [d.collision])] };
125
+ const decoded = { ...snap, d: [d.round, [...next.values()], ...(d.collision === undefined ? base.d.slice(2) : [d.collision])] };
126
126
  if (d.chain) { base = decoded; rows = next; }
127
127
  return decoded;
128
128
  },
package/rules/live.ts CHANGED
@@ -1,22 +1,25 @@
1
+ import { rayQuery, raySnapshot, type QueryTarget } from './query.ts';
1
2
  import { overlapsTerrain } from './terrain.ts';
2
3
  import { nearbyMap } from './map-index.ts';
3
4
  /** Declared collision geometry, shared by authority and prediction. Entity fields
4
5
  * own its lifetime and dimensions; this projection contains no gameplay state. */
5
- import type { Vec3, ColliderDef } from './rules.ts';
6
+ import type { Vec3, ColliderDef, QueryDef } from './rules.ts';
6
7
  import type { MapShapes } from './math.ts';
7
8
  import { charge, own } from './guard.ts';
8
9
  import { castMap3, solidAt, type BodyShape } from './collision.ts';
9
10
  import { sweepMap, rayBox, rayCircle } from './math.ts';
10
11
 
11
- export type ColliderRow = [string, string, number, number, number, number, number, number];
12
+ export type ColliderRow = [string, string, number, number, number, number, number, number, {kind: string; query?: QueryDef; fields: Record<string, number | string | boolean>}?];
12
13
  export type CollisionRevision = [number, number, ColliderRow[]];
13
- export function colliderRow(id: string, at: Vec3, body: BodyShape, def: ColliderDef, fields: Record<string, unknown>): ColliderRow | null {
14
+ export function colliderRow(id: string, at: Vec3, body: BodyShape, def: ColliderDef, fields: Record<string, unknown>, kind?: string, query?: QueryDef): ColliderRow | null {
14
15
  const opts = def === true ? {} : def;
15
16
  if (opts.enabled && fields[opts.enabled] !== true) return null;
16
17
  const size = opts.size ? fields[opts.size] as Vec3 : null;
17
18
  const x = size ? size.x : body.radius * 2, y = size ? size.y : body.radius * 2, z = size ? size.z : body.height || body.radius * 2;
18
19
  if (![x, y, z].every(Number.isFinite) || x <= 0 || y <= 0 || z < 0) return null;
19
- return [id, size ? 'box' : body.shape, at.x, at.y, at.z, x, y, z];
20
+ const row: ColliderRow = [id, size ? 'box' : body.shape, at.x, at.y, at.z, x, y, z];
21
+ if(kind) row.push({kind, ...(query?{query}:{}), fields:Object.fromEntries((opts.fields ?? []).filter(name=>['number','string','boolean'].includes(typeof fields[name])).map(name=>[name,fields[name]])) as Record<string,number|string|boolean>});
22
+ return row;
20
23
  }
21
24
  export function colliderSolid(row: ColliderRow): ReturnType<typeof solidAt> {
22
25
  const [, shape, x, y, z, w, d, h] = row;
@@ -27,6 +30,10 @@ export function castCollider2(row: ColliderRow, p: Vec3, d: Vec3, radius: number
27
30
  const solid = colliderSolid(row);
28
31
  return row[1] === 'box' ? rayBox(p.x,p.y,d.x,d.y,solid.min,solid.max,radius) : rayCircle(p.x,p.y,d.x,d.y,row[2],row[3],row[5]/2+radius);
29
32
  }
33
+ const rowsOf = new WeakMap<object, readonly ColliderRow[]>();
34
+ export function collisionTargets(map: MapShapes): QueryTarget[] {
35
+ return (rowsOf.get(map) ?? []).map(row=>({id:row[0],kind:row[8]?.kind??'',query:row[8]?.query,fields:row[8]?.fields??{},geometry:true,solid:colliderSolid(row),at:{x:row[2],y:row[3],z:row[4]}}));
36
+ }
30
37
  export function collisionMap(map: MapShapes, rows: readonly ColliderRow[]): MapShapes {
31
38
  if (!rows.length) return map;
32
39
  const boxes: any[] = [], circles: any[] = [], spheres: any[] = [], capsules: any[] = [];
@@ -37,7 +44,9 @@ export function collisionMap(map: MapShapes, rows: readonly ColliderRow[]): MapS
37
44
  else if (shape === 'sphere') spheres.push({ at: { x, y, z: z + w / 2 }, r: w / 2, id } as any);
38
45
  else capsules.push({ at, r: w / 2, height: h, id } as any);
39
46
  }
40
- return { bounds: map.bounds, staticMap: map, boxes, circles, spheres, capsules };
47
+ const combined = { bounds: map.bounds, staticMap: map, boxes, circles, spheres, capsules };
48
+ rowsOf.set(combined, rows);
49
+ return combined;
41
50
  }
42
51
  /** Latest known revision at the requested tick. Unknown future edits are never
43
52
  * guessed: a later snapshot rebases the body and replays its pending inputs. */
@@ -45,14 +54,17 @@ export function revisionAt(history: readonly CollisionRevision[], tick: number):
45
54
  for (let i = history.length - 1; i >= 0; i--) if (history[i][1] <= tick) return history[i];
46
55
  return undefined;
47
56
  }
48
- export function collisionQueries(map: () => MapShapes, shape: () => BodyShape, dims: number): object {
57
+ export function collisionQueries(map: () => MapShapes, shape: () => BodyShape, dims: number, self: () => string | undefined = () => undefined): object {
49
58
  const position = (body: unknown): Vec3 => {
50
59
  const p = own(body, 'pos');
51
60
  const n = (k: string): number => { const v = own(p, k); return typeof v === 'number' && Number.isFinite(v) ? Math.fround(v) : 0; };
52
61
  return { x: n('x'), y: n('y'), z: n('z') };
53
62
  };
54
63
  return {
55
- sweep: (body: unknown, delta: unknown) => sweepMap(map(), body, delta, shape().radius, dims, shape()),
64
+ ray: (from: unknown, direction: unknown, max: unknown, options?: unknown) => {const m=map();return rayQuery(m.staticMap??m,collisionTargets(m),dims,from,direction,max,options,self())[0];},
65
+ rays: (options?: unknown) => {const m=map();return raySnapshot(m.staticMap??m,collisionTargets(m),dims,options,self());},
66
+ rayAll: (from: unknown, direction: unknown, max: unknown, options?: unknown) => {const m=map();return rayQuery(m.staticMap??m,collisionTargets(m),dims,from,direction,max,options,self(),true);},
67
+ sweep: (body: unknown, delta: unknown, options?: unknown) => sweepMap(map(), body, delta, shape().radius, dims, shape(), options),
56
68
  support: (body: unknown, distance: unknown = 0.002) => {
57
69
  charge(20);
58
70
  const p = position(body), m = map();
@@ -46,6 +46,12 @@ export function nearbyMap(map: MapShapes, p:Vec3, d:Vec3, radius:number, height:
46
46
  function visit(n:Node){if(!touches(n))return;if(n.entries){for(const e of n.entries)if(touches(e))found.push(e);}else{visit(n.left!);visit(n.right!);}}
47
47
  visit(root);charge(found.length*Math.ceil(Math.log2(found.length+1)));
48
48
  found.sort((a,b)=>a.order-b.order);for(const e of found)out[e.key].push(e.value);
49
- if(parent)for(const key of ['boxes','circles','spheres','capsules','heightTiles'] as const)for(const v of map[key]??[]){charge(12);out[key].push(v);}
49
+ if(parent)for(const key of ['boxes','circles','spheres','capsules','heightTiles'] as const)for(const raw of map[key]??[]){
50
+ const v:any=raw;let min:Vec3,max:Vec3;
51
+ if(key==='boxes'){min=v.min;max=v.max;}
52
+ else if(key==='heightTiles'){min={...v.at,z:v.at.z+Math.min(v.base??Infinity,...v.heights)};max={x:v.at.x+v.size.x,y:v.at.y+v.size.y,z:v.at.z+Math.max(...v.heights)};}
53
+ else{min={x:v.at.x-v.r,y:v.at.y-v.r,z:key==='circles'?-Infinity:key==='capsules'?v.at.z:v.at.z-v.r};max={x:v.at.x+v.r,y:v.at.y+v.r,z:key==='circles'?Infinity:key==='capsules'?v.at.z+v.height:v.at.z+v.r};}
54
+ if(touches({min,max}))out[key].push(raw);
55
+ }
50
56
  return out;
51
57
  }
package/rules/math.ts CHANGED
@@ -14,7 +14,7 @@ import { collisionQueries } from './live.ts';
14
14
  * Every call is charged 4 units to the running handler's budget (guard.ts), beside what any guarded call costs.
15
15
  * =============================================================================
16
16
  */
17
- import { castMap3, restsOnMap, type BodyShape } from './collision.ts';
17
+ import { castMap3, castSolid, restsOnMap, type BodyShape } from './collision.ts';
18
18
  import { brand, charge, own, put } from './guard.ts';
19
19
  import { num } from './pack.ts';
20
20
 
@@ -237,12 +237,24 @@ function rayBounds(px: number, py: number, dx: number, dy: number, b: { min: Vec
237
237
  return best;
238
238
  }
239
239
  /** A circle (or a point, r 0) moved along d against the static map: the first thing in the way, and how many shapes were tested. */
240
- export function castMap(map: MapShapes, px: number, py: number, dx: number, dy: number, r: number): { hit: Hit | null; tested: number } {
240
+ export function castMap(map: MapShapes, px: number, py: number, dx: number, dy: number, r: number, hits?: Hit[], includeInside = false, square = false): { hit: Hit | null; tested: number } {
241
241
  map = nearbyMap(map, {x:px,y:py,z:0}, {x:dx,y:dy,z:0}, r, 0, 2);
242
242
  charge(16 + 4 * (map.boxes.length + map.circles.length));
243
243
  let best: Hit | null = rayBounds(Math.max(map.bounds.min.x + r, Math.min(map.bounds.max.x - r, px)), Math.max(map.bounds.min.y + r, Math.min(map.bounds.max.y - r, py)), dx, dy, map.bounds, r);
244
- for (const b of map.boxes) { const h = rayBox(px, py, dx, dy, b.min, b.max, r); if (h && (!best || h.t < best.t)) best = { ...h, id: (b as any).id }; }
245
- for (const c of map.circles) { const h = rayCircle(px, py, dx, dy, c.at.x, c.at.y, c.r + r); if (h && (!best || h.t < best.t)) best = { ...h, id: (c as any).id }; }
244
+ if (hits && best) hits.push(best);
245
+ const take=(h: Hit | null,id?:string): void => {if(!h)return;const hit={...h,...(id?{id}:{})};if(hits)hits.push(hit);if(!best||h.t<best.t)best=hit;};
246
+ const length=Math.sqrt(dx*dx+dy*dy)||1;
247
+ const inside: Hit={t:0,nx:-dx/length,ny:-dy/length};
248
+ for (const b of map.boxes) {
249
+ const min=square?{x:b.min.x-r,y:b.min.y-r,z:0}:b.min,max=square?{x:b.max.x+r,y:b.max.y+r,z:0}:b.max;
250
+ const embedded=includeInside&&px>min.x&&px<max.x&&py>min.y&&py<max.y;
251
+ take(embedded?inside:rayBox(px,py,dx,dy,min,max,square?0:r),(b as {id?:string}).id);
252
+ }
253
+ for (const c of map.circles) {
254
+ const embedded=includeInside&&(px-c.at.x)*(px-c.at.x)+(py-c.at.y)*(py-c.at.y)<(c.r+r)*(c.r+r);
255
+ const h=square?castSolid({min:{x:px-r,y:py-r,z:0},max:{x:px+r,y:py+r,z:1},r:0},{x:dx,y:dy,z:0},{min:{x:c.at.x,y:c.at.y,z:-1},max:{x:c.at.x,y:c.at.y,z:2},r:c.r},includeInside):embedded?inside:rayCircle(px,py,dx,dy,c.at.x,c.at.y,c.r+r);
256
+ take(h,(c as {id?:string}).id);
257
+ }
246
258
  return { hit: best, tested: 4 + map.boxes.length + map.circles.length };
247
259
  }
248
260
 
@@ -251,7 +263,7 @@ export function castMap(map: MapShapes, px: number, py: number, dx: number, dy:
251
263
  * and a browser both move a body with this, so they agree to the last bit. Returns nothing, or `{ at, normal }`.
252
264
  * The 2D path charges 20 units and 4 per shape; 3D also charges each convex cast and terrain tile. With `dims` 2 a body always rests on the ground.
253
265
  */
254
- export function sweepMap(map: MapShapes, body: unknown, delta: unknown, radius: number, dims: number, shape: BodyShape = { shape: 'sphere', radius, height: 2 * radius }): unknown {
266
+ export function sweepMap(map: MapShapes, body: unknown, delta: unknown, radius: number, dims: number, shape: BodyShape = { shape: 'sphere', radius, height: 2 * radius }, options?: unknown): unknown {
255
267
  // Charged before the cast, for every shape of the map it may test.
256
268
  charge(20);
257
269
  const fr = Math.fround;
@@ -267,7 +279,7 @@ export function sweepMap(map: MapShapes, body: unknown, delta: unknown, radius:
267
279
  const length = Math.sqrt(dx * dx + dy * dy + d.z * d.z);
268
280
  const t = h ? Math.max(0, h.t - (length > 0 ? SKIN / length : 0)) : 1;
269
281
  const at = v(fr(px + dx * t), fr(py + dy * t), fr(p.z + d.z * t));
270
- put(body, 'pos', at); put(body, 'grounded', restsOnMap(map, at, shape));
282
+ put(body, 'pos', at); if (own(options, 'ground') !== false) put(body, 'grounded', restsOnMap(map, at, shape));
271
283
  return h ? Object.freeze({ at, normal: v(h.nx, h.ny, h.nz), ...(h.id ? { entity: h.id } : {}) }) : undefined;
272
284
  }
273
285
  const { hit: h } = castMap(map, px, py, dx, dy, radius);
@@ -285,9 +297,9 @@ export function sweepMap(map: MapShapes, body: unknown, delta: unknown, radius:
285
297
  * map. One function, so that the browser (view.ts) and the person the build check plays hold the same one. The
286
298
  * server's own (core.ts `moveCtx`) is made beside the rest of its world, and charges as that does.
287
299
  */
288
- export function moveContext(o: { tick: () => number; tickHz: number; tune: unknown; map: MapShapes; name: string; spots: Readonly<Record<string, readonly unknown[]>>; radius: () => number; shape?: () => BodyShape; dims: number; geometry?: () => MapShapes }): unknown {
300
+ export function moveContext(o: { tick: () => number; tickHz: number; tune: unknown; map: MapShapes; name: string; spots: Readonly<Record<string, readonly unknown[]>>; radius: () => number; shape?: () => BodyShape; dims: number; geometry?: () => MapShapes; self?: () => string | undefined }): unknown {
289
301
  const none = Object.freeze([]);
290
- const queries = collisionQueries(() => o.geometry?.() ?? o.map, () => o.shape?.() ?? {shape: 'sphere', radius: o.radius(), height: 2 * o.radius()}, o.dims);
302
+ const queries = collisionQueries(() => o.geometry?.() ?? o.map, () => o.shape?.() ?? {shape: 'sphere', radius: o.radius(), height: 2 * o.radius()}, o.dims, o.self);
291
303
  return brand(Object.freeze({
292
304
  get tick() { return o.tick(); }, dt: 1 / o.tickHz, tune: o.tune, math,
293
305
  // As the server's `ctx.ticks` reads it: a plain number, or nothing.
@@ -0,0 +1,69 @@
1
+ /** Deterministic routing over an authored, baked walking graph. Full searches
2
+ * either reach the goal or return no path; never silently truncate a route. */
3
+ import {own,charge} from './guard.ts';
4
+ import type {Solid} from './collision.ts';
5
+ import {castSolid} from './collision.ts';
6
+ export type RouteNode = readonly [number,number,number,readonly number[]];
7
+ const graphs = new WeakMap<object,readonly RouteNode[]>();
8
+ const incoming = new WeakMap<object,readonly (readonly number[])[]>();
9
+ function graphOf(value: unknown, billing=true): readonly RouteNode[] {
10
+ if(!Array.isArray(value)||value.length>8192)throw new Error('route graph has at most 8192 nodes');
11
+ if(billing)charge(value.length*12);const cached=graphs.get(value);if(cached)return cached;
12
+ const nodes:RouteNode[]=[];
13
+ for(let i=0;i<value.length;i++){
14
+ const n=own(value,i),links=own(n,3),x=own(n,0),y=own(n,1),z=own(n,2);
15
+ if(![x,y,z].every(v=>typeof v==='number'&&Number.isFinite(v))||!Array.isArray(links)||links.length>16)throw new Error('route nodes are [x,y,z,links], at most 16 neighbours');
16
+ const adjacent:number[]=[];for(let j=0;j<links.length;j++){const id=own(links,j);if(typeof id!=='number'||!Number.isInteger(id)||id<0||id>=value.length)throw new Error('route links name graph nodes');adjacent.push(id);}
17
+ nodes.push(Object.freeze([x as number,y as number,z as number,Object.freeze(adjacent)]));
18
+ }
19
+ const result=Object.freeze(nodes);const reverse:number[][]=nodes.map(()=>[]);nodes.forEach((n,i)=>{for(const next of n[3])reverse[next].push(i);});incoming.set(result,reverse);if(Object.isFrozen(value)&&value.every(n=>Object.isFrozen(n)&&Object.isFrozen(own(n,3))))graphs.set(value,result);return result;
20
+ }
21
+ export const prepareGraph=(value:unknown):readonly RouteNode[]=>graphOf(value,false);
22
+ export function routeGraph(value: unknown, origin: unknown, destination: unknown, solids: readonly Solid[], radius: number, height: number, prepared=false): readonly number[] {
23
+ const nodes=prepared?value as readonly RouteNode[]:graphOf(value);if(prepared)charge(16);
24
+ if(!Number.isInteger(origin)||!Number.isInteger(destination)||Number(origin)<0||Number(destination)<0||Number(origin)>=nodes.length||Number(destination)>=nodes.length)throw new Error('route endpoints name graph nodes');
25
+ let start=Number(origin),goal=Number(destination),end=nodes[goal];
26
+ const buckets=new Map<string,Solid[]>();
27
+ for(const solid of solids){const minX=Math.floor((solid.min.x-solid.r-radius)/4),maxX=Math.floor((solid.max.x+solid.r+radius)/4),minY=Math.floor((solid.min.y-solid.r-radius)/4),maxY=Math.floor((solid.max.y+solid.r+radius)/4);
28
+ if((maxX-minX+1)*(maxY-minY+1)>65536)throw new Error('route obstacle spans too many graph cells');
29
+ for(let x=minX;x<=maxX;x++)for(let y=minY;y<=maxY;y++){charge(2);const key=x+','+y;const rows=buckets.get(key)??[];rows.push(solid);buckets.set(key,rows);}
30
+ }
31
+ const blocked=(a:RouteNode,b:RouteNode)=>{
32
+ const from={min:{x:a[0]-radius,y:a[1]-radius,z:a[2]+.05},max:{x:a[0]+radius,y:a[1]+radius,z:a[2]+height},r:0},delta={x:b[0]-a[0],y:b[1]-a[1],z:b[2]-a[2]};
33
+ const candidates=new Set<Solid>();
34
+ for(let x=Math.floor(Math.min(a[0],b[0])/4);x<=Math.floor(Math.max(a[0],b[0])/4);x++)for(let y=Math.floor(Math.min(a[1],b[1])/4);y<=Math.floor(Math.max(a[1],b[1])/4);y++){charge(1);for(const solid of buckets.get(x+','+y)??[])candidates.add(solid);}
35
+ for(const s of candidates){charge(2);if(Math.max(a[0],b[0])+radius<s.min.x-s.r||Math.min(a[0],b[0])-radius>s.max.x+s.r||Math.max(a[1],b[1])+radius<s.min.y-s.r||Math.min(a[1],b[1])-radius>s.max.y+s.r||Math.max(a[2],b[2])+height<s.min.z-s.r||Math.min(a[2],b[2])+.05>s.max.z+s.r)continue;if(s.r===0){
36
+ charge(24);let near=0,far=1,hit=true;
37
+ for(const axis of ['x','y','z'] as const){const low=s.min[axis]-from.max[axis],high=s.max[axis]-from.min[axis],speed=delta[axis];if(!speed){if(low>=0||high<=0){hit=false;break;}}else{let a=low/speed,b=high/speed;if(a>b)[a,b]=[b,a];near=Math.max(near,a);far=Math.min(far,b);if(near>far){hit=false;break;}}}
38
+ if(hit&&far>=0)return true;
39
+ }else if(castSolid(from,delta,s,true))return true;}
40
+ return false;
41
+ };
42
+ const endpoint=(id:number,limit:number)=>{
43
+ if(!blocked(nodes[id],nodes[id]))return id;
44
+ const p=nodes[id];let best=-1,score=Infinity;
45
+ for(let i=0;i<nodes.length;i++){charge(4);const q=nodes[i],distance=Math.hypot(q[0]-p[0],q[1]-p[1]);const cost=distance+Math.abs(q[2]-p[2])*2;if(distance>limit||cost>=score||!q[3].length||blocked(q,q))continue;best=i;score=cost;}
46
+ return best;
47
+ };
48
+ start=endpoint(start,4);goal=endpoint(goal,Infinity);if(start<0||goal<0)return Object.freeze([]);end=nodes[goal];
49
+ type Entry={id:number;f:number;order:number};let sequence=0;
50
+ const before=(a:Entry,b:Entry)=>a.f<b.f||a.f===b.f&&a.order<b.order;
51
+ const push=(heap:Entry[],id:number,f:number)=>{const item={id,f,order:sequence++};let i=heap.length;heap.push(item);while(i){const p=(i-1)>>1;if(!before(item,heap[p]))break;heap[i]=heap[p];i=p;}heap[i]=item;};
52
+ const pop=(heap:Entry[])=>{const first=heap[0],last=heap.pop()!;if(heap.length){let i=0;while(i*2+1<heap.length){let j=i*2+1;if(j+1<heap.length&&before(heap[j+1],heap[j]))j++;if(!before(heap[j],last))break;heap[i]=heap[j];i=j;}heap[i]=last;}return first.id;};
53
+ const originPoint=nodes[start],potential=(id:number)=>(Math.hypot(nodes[id][0]-end[0],nodes[id][1]-end[1])-Math.hypot(nodes[id][0]-originPoint[0],nodes[id][1]-originPoint[1]))/2;
54
+ const heaps:Entry[][]=[[],[]],costs=[new Map([[start,0]]),new Map([[goal,0]])],parents=[new Map<number,number>(),new Map<number,number>()],closed=[new Set<number>(),new Set<number>()],reverse=incoming.get(nodes)!;
55
+ push(heaps[0],start,potential(start));push(heaps[1],goal,-potential(goal));let best=Infinity,meet=-1;
56
+ // Balanced bidirectional A*: either frontier can prove disconnection. No
57
+ // expansion limit, and every returned route connects the two endpoints.
58
+ while(heaps[0].length&&heaps[1].length){
59
+ if(heaps[0][0].f+heaps[1][0].f>=best)break;
60
+ const side=heaps[0][0].f<=heaps[1][0].f?0:1,other=1-side,current=pop(heaps[side]);charge(12);if(closed[side].has(current))continue;closed[side].add(current);
61
+ const cost=costs[side].get(current)!,opposite=costs[other].get(current);if(opposite!==undefined&&cost+opposite<best){best=cost+opposite;meet=current;}
62
+ for(const next of side?reverse[current]:nodes[current][3]){charge(4);if(closed[side].has(next))continue;const a=nodes[side?next:current],b=nodes[side?current:next];if(blocked(a,b))continue;
63
+ const score=cost+Math.hypot(b[0]-a[0],b[1]-a[1])+Math.max(0,b[2]-a[2])*.3;if(score>=(costs[side].get(next)??Infinity))continue;
64
+ costs[side].set(next,score);parents[side].set(next,current);push(heaps[side],next,score+(side?-potential(next):potential(next)));
65
+ const otherCost=costs[other].get(next);if(otherCost!==undefined&&score+otherCost<best){best=score+otherCost;meet=next;}
66
+ }
67
+ }
68
+ if(meet<0)return Object.freeze([]);const path=[meet];while(path[0]!==start)path.unshift(parents[0].get(path[0])!);while(path[path.length-1]!==goal)path.push(parents[1].get(path[path.length-1])!);charge(path.length);return Object.freeze(path);
69
+ }
package/rules/pack.ts CHANGED
@@ -21,7 +21,7 @@ import { cellsOf } from './rules.ts';
21
21
  import type { Field, FieldList, Vec3 } from './rules.ts';
22
22
  import { G, charge, keyCount, own, refusedName } from './guard.ts';
23
23
 
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] };
24
+ const RANGE: Record<string, [number, number]> = { pulse: [0, 255], 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). */
26
26
  export const FIX_STEP = 4096;
27
27
  const FIX_MAX = 2147483648;
@@ -262,7 +262,7 @@ export interface MoveBody { pos: Vec3; vel: Vec3; heading: Vec3; grounded: boole
262
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
263
  */
264
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;
265
+ G.queryEpoch++; G.left = quota;
266
266
  const bag = coerceFields(motion, body.motion, dims);
267
267
  const target: Record<string, unknown> = {};
268
268
  const work: Record<string, unknown> = {};