@homie-rocks/studio 0.45.3 → 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
@@ -56,9 +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;
61
64
  record?(owner:string,key:string,value:Record<string,unknown>):void;
65
+ characterFor?(peer: Record<string, unknown>): CharacterLease | null;
62
66
  /** The build check compares a movement step before any tick handler can change it. */
63
67
  moved?: NonNullable<Parameters<typeof createCore>[1]>['moved'];
64
68
  /** Told of every handler as it ends, and what it threw if it threw (core.ts `observe`): the build check listens. */
@@ -74,7 +78,7 @@ export interface HostOptions {
74
78
  send(frame: Record<string, unknown>, text?: string): void;
75
79
  clock: HostClock;
76
80
  /** Synchronous save: the caller's storage output gate holds this tick's outgoing frames until confirmed. */
77
- store?: { save(bytes: Uint8Array): void };
81
+ store?: { save(bytes: Uint8Array, commitment?: { force: boolean; seconds: number; tickHz: number; fields: { kinds: number[][]; shared: number[] } }): void };
78
82
  /** Table recovery reserves this epoch in storage before starting this host. Ordinary deterministic restores omit it. */
79
83
  restoreEpoch?: number;
80
84
  startDelayMs?: number;
@@ -95,6 +99,10 @@ export interface HostOptions {
95
99
  onEnd?(why: string, facts: Record<string, unknown>): void;
96
100
  }
97
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;
98
106
  readonly tick: number;
99
107
  readonly epoch: number;
100
108
  readonly running: boolean;
@@ -111,7 +119,7 @@ export interface Host {
111
119
  resume(): void;
112
120
  /** Publish the round, the roster, the shared state and the capabilities again (a browser host's relay restarted). */
113
121
  announce(): void;
114
- /** 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. */
115
123
  stop(): void;
116
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. */
117
125
  tickNow(): void;
@@ -164,19 +172,33 @@ function blameLate(now: number, period: number): void {
164
172
 
165
173
  export function createHost(o: HostOptions): Host {
166
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
+
167
184
  const period = 1000 / c.settings.tickHz;
168
185
  const tickHz = c.settings.tickHz;
169
186
  const lateTicks = Math.ceil(tickHz / 4);
170
187
  // A log that throws takes nothing down with it.
171
188
  const log = (line: Record<string, unknown>): void => { try { o.log?.(line); } catch { /* nothing to say it with */ } };
172
189
  const random = o.random ?? Math.random;
173
- 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;
174
191
  if (o.restore && (!saved || typeof saved !== 'object' || Array.isArray(saved))) throw new Error('saved host is not an object');
175
192
  if (saved) {
176
193
  const check = (ok: unknown): void => { if (!ok) throw new Error('saved host inputs are invalid'); };
177
194
  const uint = (n: unknown): boolean => Number.isSafeInteger(n) && (n as number) >= 0;
178
- 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)));
179
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
+ }
180
202
  if (saved.agents !== undefined) {
181
203
  const tables = ['goals', 'asks', 'avoid', 'sayAt', 'viewAt', 'floorAt', 'askAt'];
182
204
  check(saved.agents && Object.keys(saved.agents).every(k => tables.includes(k)));
@@ -236,12 +258,27 @@ export function createHost(o: HostOptions): Host {
236
258
  }
237
259
  if (saved && saved.v !== 1) throw new Error('this save was written by another version of the runtime');
238
260
  const measuredRtt = new Map<number, number>();
239
- const core = createCore(c, { 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 });
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 });
240
274
  let epoch = core.epoch;
241
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);
242
276
  let lastSaveAt = o.clock.now();
243
277
  let saveTick = core.tick;
244
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] : []) };
245
282
  let saveFailures = 0;
246
283
  let retrySaveAt = 0;
247
284
  let roundPhase = saved?.core.round[1] ?? 1;
@@ -459,7 +496,16 @@ export function createHost(o: HostOptions): Host {
459
496
  // The relay numbers every stay in a seat (`occ`): the same number is the same holder, back.
460
497
  const owner = `p${Number.isInteger(p.occ) ? p.occ : typeof p.id === 'string' ? p.id.slice(0, 40) : seat}`;
461
498
  const previous = core.bodies().find((s) => s.seat === seat);
462
- 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 } : {}) });
463
509
  core.seatAway(seat, false);
464
510
  if (!previous || previous.driver !== driver || previous.owner !== owner) queues.delete(seat);
465
511
  rosterText = '';
@@ -471,7 +517,13 @@ export function createHost(o: HostOptions): Host {
471
517
  for (const [id, p] of peers) if (p.seat === m.seat) peers.delete(id);
472
518
  present.delete(m.seat as number);
473
519
  queues.delete(m.seat as number);
474
- 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);
475
527
  if (running && people() === 0) pause();
476
528
  return;
477
529
  }
@@ -481,7 +533,10 @@ export function createHost(o: HostOptions): Host {
481
533
  for (const [id, p] of peers) if (p.seat === m.seat) peers.delete(id);
482
534
  present.delete(m.seat as number);
483
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; }
484
538
  core.seatLeave(m.seat as number);
539
+ if (held) tick(o.clock.now());
485
540
  rosterText = '';
486
541
  return;
487
542
  }
@@ -634,7 +689,17 @@ export function createHost(o: HostOptions): Host {
634
689
  function tick(now: number): void {
635
690
  const cut = core.stats.ticksCut;
636
691
  let faulted = false;
637
- 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
+ }
638
703
  if (ended) return;
639
704
  if (!faulted && core.stats.ticksCut === cut) { failingSince = null; return; }
640
705
  if (failingSince === null) failingSince = now;
@@ -730,7 +795,7 @@ export function createHost(o: HostOptions): Host {
730
795
  // A pending timer would keep the object in memory and billed: the pause clears it.
731
796
  disarm();
732
797
  log({ ev: 'host-pause', game: o.game, tick: core.tick });
733
- 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 }); } }
734
799
  }
735
800
  function resume(): void {
736
801
  if (running || ended || !paused) return;
@@ -772,9 +837,20 @@ export function createHost(o: HostOptions): Host {
772
837
  }
773
838
 
774
839
  function save(): Uint8Array {
775
- 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] });
776
841
  }
777
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
+ }
778
854
  const seconds = c.settings.durability.movementSeconds;
779
855
  // Catch-up ticks can cover a second of play in less than a second of wall time. Bound both clocks.
780
856
  if (!o.store || !force && (core.tick === saveTick || o.clock.now() - lastSaveAt < seconds * 1000 && core.tick - saveTick < seconds * tickHz)) return;
@@ -800,6 +876,17 @@ export function createHost(o: HostOptions): Host {
800
876
  return [...row.slice(0,7),fields,...row.slice(8)];
801
877
  }),...snap.d.slice(2)]};
802
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); },
803
890
  get viewSettings() { return c.settings.view; },
804
891
  get viewRadiusM() { return c.settings.view?.radiusM ?? null; },
805
892
  get tick() { return core.tick; },
@@ -807,7 +894,9 @@ export function createHost(o: HostOptions): Host {
807
894
  get running() { return running; },
808
895
  get paused() { return paused; },
809
896
  get people() { return people(); },
810
- 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(); } },
811
900
  tickNow: () => {
812
901
  if (ended) return;
813
902
  let now = lastStart;
package/rules/rules.ts CHANGED
@@ -61,6 +61,7 @@ export interface Field {
61
61
  readonly init?: unknown;
62
62
  /** On one integer field of a player's body: this is the score a round's results rank by. */
63
63
  readonly score?: true;
64
+ readonly durable?: boolean;
64
65
  readonly visibility?: 'owner' | 'server' | 'results';
65
66
  /** `text`: the most characters. `list` and `map`: the most entries. */
66
67
  readonly max?: number;
@@ -69,11 +70,11 @@ export interface Field {
69
70
  /** `struct`: its fields, in order. */
70
71
  readonly fields?: Readonly<Record<string, Field>>;
71
72
  }
72
- export interface FieldOptions { init?: unknown; score?: boolean; visibility?: 'owner' | 'server' | 'results' }
73
+ export interface FieldOptions { durable?: boolean; init?: unknown; score?: boolean; visibility?: 'owner' | 'server' | 'results' }
73
74
  export type Fields = Readonly<Record<string, Field>>;
74
75
 
75
76
  const made = (t: FieldKind, o: FieldOptions | undefined, extra: Partial<Field> = {}): Field =>
76
- Object.freeze({ t, ...extra, ...(o?.visibility ? {visibility:o.visibility} : {}), ...(o && o.init !== undefined ? { init: o.init } : {}), ...(o && o.score === true ? { score: true as const } : {}) });
77
+ Object.freeze({ t, ...extra, ...(o?.durable !== undefined ? {durable:o.durable} : {}), ...(o?.visibility ? {visibility:o.visibility} : {}), ...(o && o.init !== undefined ? { init: o.init } : {}), ...(o && o.score === true ? { score: true as const } : {}) });
77
78
  const plain = (t: FieldKind) => (o?: FieldOptions): Field => made(t, o);
78
79
 
79
80
  /**
@@ -167,7 +168,7 @@ export interface RulesDef {
167
168
  records?: RoundRecords;
168
169
  historySeconds?: number;
169
170
  start?: RoomHandler;
170
- join?: (ctx: any, player: { seat: number; driver: 'person' | 'bot' | 'ai'; owner: string }) => { kind: string; at: Vec3; heading?: Vec3; fields?: Record<string, unknown>; motion?: Record<string, unknown> };
171
+ join?: (ctx: any, player: { seat: number; driver: 'person' | 'bot' | 'ai'; owner: string; character?: Character | null }) => { kind: string; at: Vec3; heading?: Vec3; fields?: Record<string, unknown>; motion?: Record<string, unknown> };
171
172
  on?: Record<string, RoomHandler>;
172
173
  };
173
174
  asks?: Record<string, AskDef>;
@@ -189,9 +190,14 @@ export function defineMove<M extends Record<string, MoveFn>>(def: M): M {
189
190
 
190
191
  /* ------------------------------------------------------------------ settings (game.json "room") */
191
192
 
193
+ export interface Character { kind: string; at: Vec3; heading: Vec3; fields: Record<string, unknown>; motion: Record<string, unknown> }
194
+
192
195
  export interface RoomSettings {
193
196
  host: 'server' | 'browser';
194
197
  offline: boolean;
198
+ /** Persistent rooms retain their committed world until explicitly replaced. */
199
+ lifetime?: 'match' | 'persistent';
200
+ characters?: boolean;
195
201
  tickHz: number;
196
202
  inputHz: number;
197
203
  /** Omit the radius to send the whole room. No implicit visibility or bandwidth cap. */
@@ -232,12 +238,16 @@ export function roomSettings(raw: unknown): { settings: RoomSettings; problems:
232
238
  if (!Number.isFinite(n) || n < 0) { problems.push(`"room.${name}" is a number, zero or more; using its default`); return d; }
233
239
  return n;
234
240
  };
235
- const known = new Set(['host', 'offline', 'tickHz', 'inputHz', 'durability', 'budget', 'predict', 'view']);
241
+ const known = new Set(['host', 'offline', 'tickHz', 'inputHz', 'durability', 'budget', 'predict', 'view', 'lifetime', 'characters']);
236
242
  for (const key of Object.keys(r)) if (!known.has(key)) problems.push(`"room.${key}" is not a setting (host, offline, tickHz, inputHz, durability, budget, predict, view)`);
237
243
  let host: 'server' | 'browser' = 'server';
238
244
  if (r.host !== undefined) { if (r.host === 'server' || r.host === 'browser') host = r.host; else problems.push('"room.host" is "server" or "browser"; using "server"'); }
239
- let offline = true;
240
- if (r.offline !== undefined) { if (typeof r.offline === 'boolean') offline = r.offline; else problems.push('"room.offline" is true or false; using true'); }
245
+ if (r.lifetime !== undefined && !['match', 'persistent'].includes(r.lifetime)) problems.push('"room.lifetime" is "match" or "persistent"');
246
+ if (r.lifetime === 'persistent' && host !== 'server') problems.push('"room.lifetime: persistent" needs room.host "server"');
247
+ if (r.characters !== undefined && typeof r.characters !== 'boolean') problems.push('"room.characters" is true or false');
248
+ if (r.characters === true && r.lifetime !== 'persistent') problems.push('"room.characters" needs room.lifetime "persistent"');
249
+ let offline = r.lifetime !== 'persistent';
250
+ if (r.offline !== undefined) { if (typeof r.offline === 'boolean') offline = r.offline; else problems.push(`"room.offline" is true or false; using ${offline}`); }
241
251
  const tickHz = whole('tickHz', r.tickHz, 1, 60, 20);
242
252
  const inputHz = whole('inputHz', r.inputHz, 1, tickHz, tickHz);
243
253
  if (host === 'browser' && r.view?.radiusM != null) problems.push('"room.view.radiusM" needs room.host "server"; browser hosting sends the whole room');
@@ -248,9 +258,9 @@ export function roomSettings(raw: unknown): { settings: RoomSettings; problems:
248
258
  const p = r.predict && typeof r.predict === 'object' ? r.predict : {};
249
259
  return {
250
260
  settings: {
251
- host, offline, tickHz, inputHz,
261
+ host, offline, tickHz, inputHz, ...(r.lifetime === 'persistent' ? { lifetime: 'persistent' as const } : {}), ...(r.characters === true ? { characters: true } : {}),
252
262
  view: { radiusM: r.view?.radiusM === null ? null : num('view.radiusM', r.view?.radiusM, null), ...(r.view?.precisionM !== undefined ? { precisionM: num('view.precisionM', r.view.precisionM, 0) as number } : {}), ...(r.view?.nearM !== undefined ? { nearM: num('view.nearM', r.view.nearM, null) } : {}), ...(r.view?.farHz !== undefined ? { farHz: whole('view.farHz', r.view.farHz, 1, tickHz, tickHz) } : {}) },
253
- durability: { movementSeconds: whole('durability.movementSeconds', r.durability?.movementSeconds, 1, 60, 1) },
263
+ durability: { movementSeconds: whole('durability.movementSeconds', r.durability?.movementSeconds, 1, 60, r.lifetime === 'persistent' ? 30 : 1) },
254
264
  budget: { tick: Math.max(1, Math.floor(num('budget.tick', r.budget?.tick, budgetFor(tickHz)) as number)) },
255
265
  predict: { ...(p.idleHold === true ? {idleHold:true} : {}), catchM: num('predict.catchM', p.catchM, null), catchUp: num('predict.catchUp', p.catchUp, 1.25) as number, snapM: num('predict.snapM', p.snapM, null), blendMs: num('predict.blendMs', p.blendMs, 100) as number, interpMs: num('predict.interpMs', p.interpMs, null) },
256
266
  },
@@ -403,6 +413,7 @@ function cleanField(fd: unknown, where: string, inInput: boolean): Field {
403
413
  const init = own(x, 'init');
404
414
  return Object.freeze({
405
415
  t: x.t,
416
+ ...(x.durable ? {durable:true} : {}),
406
417
  ...(x.visibility ? {visibility:x.visibility} : {}),
407
418
  ...(x.t === 'text' || x.t === 'list' || x.t === 'map' ? { max: x.max } : {}),
408
419
  ...(x.t === 'list' || x.t === 'map' ? { of: cleanField(x.of, `${where} (its entries)`, false) } : {}),
@@ -416,6 +427,7 @@ function checkField(fd: unknown, where: string, inInput: boolean): void {
416
427
  if (!x || typeof x !== 'object' || typeof x.t !== 'string' || !TYPES.has(x.t)) throw new Error(`${where} is not a field type: declare it with f (f.u16(), f.vec3(), f.list(f.ref(), 8)…)`);
417
428
  if ((x.t === 'press' || x.t === 'pulse') && !inInput) throw new Error(`${where}: f.press() is for input only (a button that fires once)`);
418
429
  if (inInput && !(INTS.has(x.t) || x.t === 'bit' || x.t === 'press' || x.t === 'pulse' || x.t === 'fix')) throw new Error(`${where}: an input field is a whole number, f.fix(), f.bit() or f.press()`);
430
+ if (x.durable !== undefined && (typeof x.durable !== 'boolean' || inInput || !/^(entities\.[^.]+\.fields|shared)\.[^.]+$/.test(where))) throw new Error(`${where}: durable belongs on a top-level state field`);
419
431
  if (x.visibility !== undefined && !/^entities\.[^.]+\.fields\.[^.]+$/.test(where)) throw new Error(`${where}: visibility belongs on a top-level entity field`);
420
432
  if (x.visibility !== undefined && (!['owner','server','results'].includes(x.visibility) || inInput)) throw new Error(`${where}: visibility is owner, server or results on state fields only`);
421
433
  if (x.t === 'text' && !(Number.isInteger(x.max) && (x.max as number) >= 1 && (x.max as number) <= 4096)) throw new Error(`${where}: f.text(max) needs its largest length, 1 to 4096 characters`);
@@ -489,6 +501,7 @@ export function compileRules(def: RulesDef, env: CompileEnv = {}): Compiled {
489
501
  const dims = d.space?.dims;
490
502
  if (dims !== 2 && dims !== 3) throw new Error('rules name their space: space: { dims: 2 } or { dims: 3 }');
491
503
  const settings = env.settings ?? ROOM_DEFAULTS;
504
+ if (settings.characters && Object.values(d.entities ?? {}).some(e => typeof e.player === 'object' && e.player.leave === 'bot')) throw new Error('Account characters return on leaving; player.leave must be despawn');
492
505
  const map = env.map ?? compileMap({ bounds: { min: [-32, -32], max: [32, 32] } });
493
506
  if (dims === 2 && (map.spheres.length || map.capsules.length || map.heightTiles.length)) throw new Error('spheres, capsules and height tiles need space.dims: 3');
494
507
  if (dims === 3 && map.bounds.max.z <= map.bounds.min.z) throw new Error('a 3D map needs bounds with positive height');
@@ -0,0 +1,41 @@
1
+ /** Characters belong to existing player account IDs. Only trusted room code calls this store.
2
+ * A lease never expires: a delayed room cannot spend a second copy after a timeout.
3
+ * The existing Table namespace supplies per-account objects; no new database or hand-entered ID.
4
+ */
5
+ export function characterStore(storage) {
6
+ const sql = storage.sql;
7
+ sql.exec('CREATE TABLE IF NOT EXISTS characters (game TEXT PRIMARY KEY, data TEXT NOT NULL)');
8
+ const read = game => JSON.parse(sql.exec('SELECT data FROM characters WHERE game = ?', game).toArray()[0]?.data ?? 'null');
9
+ const write = (game, value) => sql.exec('INSERT INTO characters(game,data) VALUES (?,?) ON CONFLICT(game) DO UPDATE SET data=excluded.data', game, JSON.stringify(value));
10
+ return {
11
+ read,
12
+ acquire(game, holder, schema) {
13
+ return storage.transactionSync(() => {
14
+ const row = read(game) ?? { lease: 0, holder: null, character: null, schema };
15
+ if (row.schema !== schema) return { ok: false, error: 'character-schema-changed' };
16
+ if (row.holder && row.holder !== holder) return { ok: false, error: 'character-held', holder: row.holder };
17
+ if (!row.holder) { row.holder = holder; row.lease += 1; write(game, row); }
18
+ return { ok: true, ...row };
19
+ });
20
+ },
21
+ return(game, holder, lease, character) {
22
+ return storage.transactionSync(() => {
23
+ const row = read(game);
24
+ if (!row || lease > row.lease) throw new Error('Unknown character lease');
25
+ // Lost acknowledgement or an older room: success means retire that outbox,
26
+ // never apply an old return to a newer lease.
27
+ if (lease < row.lease || !row.holder) return { ok: true };
28
+ if (row.holder !== holder) throw new Error('Wrong character holder');
29
+ write(game, { ...row, holder: null, character });
30
+ return { ok: true };
31
+ });
32
+ },
33
+ };
34
+ }
35
+
36
+ export async function characterRequest(env, account, body) {
37
+ const object = env.TABLE.get(env.TABLE.idFromName(`__player/${account}`));
38
+ const response = await object.fetch('https://player/__character', { method: 'POST', body: JSON.stringify(body) });
39
+ if (!response.ok) throw new Error('Character storage unavailable');
40
+ return response.json();
41
+ }
@@ -53,3 +53,19 @@ ALTER TABLE customer_offers ADD COLUMN expires_at INTEGER NOT NULL DEFAULT 0;
53
53
  CREATE INDEX customer_offers_expiry ON customer_offers(expires_at);
54
54
  ${FUNCTIONS_MIGRATION.slice(FUNCTIONS_MIGRATION.indexOf('CREATE TRIGGER')).replace(/CREATE TRIGGER (\w+)/g, 'DROP TRIGGER IF EXISTS $1; CREATE TRIGGER $1').replace(/ON (shop_orders|purchase_orders|meta|mcp_audit)\n?WHEN /g, 'ON $1 WHEN ').replace(/WHEN OLD.paid_at/g, "WHEN EXISTS (SELECT 1 FROM function_subscriptions WHERE type='order.paid') AND OLD.paid_at").replace(/WHEN OLD.status/g, "WHEN EXISTS (SELECT 1 FROM function_subscriptions WHERE type='order.refunded') AND OLD.status").replace(/ON app_records BEGIN/g, "ON app_records WHEN EXISTS (SELECT 1 FROM function_subscriptions WHERE type='record.changed') BEGIN").replace(/WHEN NEW.key/g, "WHEN EXISTS (SELECT 1 FROM function_subscriptions WHERE type='payment.refunded') AND NEW.key").replace(/WHEN NEW.outcome/g, "WHEN EXISTS (SELECT 1 FROM function_subscriptions WHERE type='tool.called') AND NEW.outcome").replace(/INTO studio_events(?= VALUES|\n SELECT)/g,'INTO studio_events(id,type,data,at)')}
55
55
  `;
56
+
57
+ // SQLite may evaluate AND operands in either order. Guard parsing itself: meta also
58
+ // holds plain strings, including the key used to sign player room tickets.
59
+ export const FUNCTIONS_META_MIGRATION_FILE = '0020_function_meta.sql';
60
+ export const FUNCTIONS_META_MIGRATION = ['INSERT', 'UPDATE'].map(op => `
61
+ DROP TRIGGER IF EXISTS money_function_refund_${op.toLowerCase()};
62
+ CREATE TRIGGER money_function_refund_${op.toLowerCase()} AFTER ${op} ON meta
63
+ WHEN CASE WHEN NEW.key LIKE 'shop-money:%:refund:%' AND json_valid(NEW.value)
64
+ THEN EXISTS (SELECT 1 FROM function_subscriptions WHERE type='payment.refunded')
65
+ AND json_extract(NEW.value,'$.status')='succeeded'
66
+ AND NOT EXISTS (SELECT 1 FROM studio_events WHERE id='refund:' || json_extract(NEW.value,'$.id'))
67
+ ELSE 0 END BEGIN
68
+ INSERT OR IGNORE INTO studio_events(id,type,data,at)
69
+ SELECT 'refund:' || json_extract(NEW.value,'$.id'),'payment.refunded',
70
+ json_object('refund',json_extract(NEW.value,'$.id'),'payment',json_extract(NEW.value,'$.payment_intent'),'amount',json_extract(NEW.value,'$.amount'),'currency',json_extract(NEW.value,'$.currency')),json_extract(NEW.value,'$.at')*1000;
71
+ END;`).join('\n');