@homie-rocks/studio 0.45.3 → 0.46.2

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.
@@ -529,6 +529,7 @@ export interface NetStats {
529
529
  offline: boolean;
530
530
  rtt: number | null;
531
531
  offset: number;
532
+ telemetrySource: 'server' | 'browser' | 'offline' | 'disconnected';
532
533
  snapHzIn: number;
533
534
  snapHzOut: number;
534
535
  inputHzIn: number;
@@ -550,6 +551,8 @@ export interface NetStats {
550
551
  stateKeys: number;
551
552
  peers: number;
552
553
  reconnects: number;
554
+ /** Most recent loss, retained after recovery so a brief reconnect is diagnosable. */
555
+ lastDisconnect?: { at: number; why: string; code: number | null; reason: string | null; silentMs: number };
553
556
  /** Revision 9: times a link that was up went down (a reconnect that worked on the first knock counts once). */
554
557
  drops?: number;
555
558
  /** Revision 9: where this browser stands with its room. */
@@ -1695,6 +1698,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1695
1698
  let ws: WebSocketLike | null = null;
1696
1699
  let attempt = 0;
1697
1700
  let reconnects = 0;
1701
+ let lastDisconnect: NetStats['lastDisconnect'];
1698
1702
  let drops = 0;
1699
1703
  let heartbeats = 0;
1700
1704
  /** My welcome gave me back the seat my token named (the same player), and a newer build is live (section 23). */
@@ -1777,6 +1781,9 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1777
1781
  let playableSent = false;
1778
1782
  let autoArmed = false;
1779
1783
  let snapSeen = false;
1784
+ /** The newest tick this socket has been sent (rules games): said on inputs and pings as `g`, so a Gate that
1785
+ * finds this player far behind the room sends it fewer, fresher frames instead of every one late. */
1786
+ let gotTick: number | null = null;
1780
1787
  let loadingAt = 0;
1781
1788
  const createdAt = (typeof performance !== 'undefined' ? performance.now() : Date.now());
1782
1789
  const arrived: ArrivalInfo = { mode: arrival, by: null, playableMs: null, explicitMs: null, lateMs: null };
@@ -2619,6 +2626,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2619
2626
  if (wasOffline && rulesGame) rulesNotice('Connection restored. Joining the online round.');
2620
2627
  offline = false;
2621
2628
  connected = true;
2629
+ gotTick = null;
2622
2630
  downSince = 0;
2623
2631
  attempt = 0;
2624
2632
  if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
@@ -2764,6 +2772,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2764
2772
  if (Array.isArray(m['c'])) s.c = m['c'] as ControlWire[];
2765
2773
  if (m['hb'] === 1) s.hb = 1;
2766
2774
  if (typeof m['e'] === 'number') s.e = m['e'];
2775
+ if (!local && rulesGame) gotTick = s.k;
2767
2776
  if (acceptSnap(s, bytes, true, local)) { emit('snapshot', s); if (!snapSeen) { snapSeen = true; autoPlayable(); } }
2768
2777
  }
2769
2778
 
@@ -2881,7 +2890,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2881
2890
  pingsOut.set(c, c);
2882
2891
  if (pingsOut.size > 16) { const first = pingsOut.keys().next().value; if (first !== undefined) pingsOut.delete(first); }
2883
2892
  // A hidden host's yield is behind its checkpoint in the sender: the relay hears of the hidden tab after both.
2884
- raw({ t: 'ping', c, hid: hidden() && !rulesSender?.handing, ...(rulesGame ? { readySpeed } : {}) });
2893
+ raw({ t: 'ping', c, hid: hidden() && !rulesSender?.handing, ...(rulesGame ? { readySpeed } : {}), ...(gotTick !== null ? { g: gotTick } : {}) });
2885
2894
  }
2886
2895
 
2887
2896
  function sendCheckpoint(): void {
@@ -2939,12 +2948,13 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2939
2948
  function statsNow(): NetStats {
2940
2949
  const t = wall();
2941
2950
  return {
2951
+ telemetrySource: offline ? 'offline' : !connected ? 'disconnected' : opts.rulesHost?.mode === 'server' ? 'server' : 'browser',
2942
2952
  role, seat, host, connected, offline, rtt, offset: Math.round(offset),
2943
2953
  snapHzIn: snapIn.hz(t), snapHzOut: snapOut.hz(t), inputHzIn: inputIn.hz(t), inputHzOut: inputOut.hz(t), idleInputsSkipped: idleSkipped,
2944
2954
  lastSnapBytes, maxSnapBytes, bytesInPerS: perSecond(bytesIn), bytesOutPerS: perSecond(bytesOut),
2945
2955
  interpDelay: Math.round(interpDelay()), snapAgeP90: Math.round(ageP90), starvedPct: +starvedPct.toFixed(3), rejectedSnaps,
2946
2956
  owned: api.owned, pending: sentHist.length, stateKeys: stateMap.size,
2947
- peers: peers.size, reconnects, drops, link, heartbeats, promotions, round: roundInfo ? roundInfo.n : null,
2957
+ peers: peers.size, reconnects, lastDisconnect, drops, link, heartbeats, promotions, round: roundInfo ? roundInfo.n : null,
2948
2958
  decides: { asked: decideStats.asked, ai: decideStats.ai, local: decideStats.local, floor: decideStats.floor, msP50: [...decideStats.ms].sort((x, y) => x - y)[Math.floor(decideStats.ms.length / 2)] ?? null },
2949
2959
  };
2950
2960
  }
@@ -2980,8 +2990,10 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2980
2990
  else paintLink();
2981
2991
  }
2982
2992
 
2983
- function lost(sock: WebSocketLike, why: string): void {
2993
+ function lost(sock: WebSocketLike, why: string, event?: unknown): void {
2984
2994
  if (ws !== sock) return;
2995
+ const detail = event && typeof event === 'object' ? event as { code?: unknown; reason?: unknown } : null;
2996
+ lastDisconnect = { at: Date.now(), why, code: typeof detail?.code === 'number' ? detail.code : null, reason: typeof detail?.reason === 'string' ? detail.reason.slice(0, 160) : null, silentMs: Math.max(0, wall() - lastMsgAt) };
2985
2997
  ws = null;
2986
2998
  sock.onclose = null; sock.onmessage = null; sock.onerror = null; sock.onopen = null;
2987
2999
  if (why !== 'closed') { try { sock.close(4000, why); } catch { /* gone */ } }
@@ -3011,13 +3023,14 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
3011
3023
  lastMsgAt = wall();
3012
3024
  raw({
3013
3025
  t: 'hello', rules: rulesGame, v: NETPLAY_VERSION, rev: NETPLAY_REVISION, token, name: name || undefined, device, want, canHost: asAgent && asAgent.hands === 'host' ? false : canHost, game: opts.game, max: opts.maxPlayers,
3026
+ ...(rulesGame ? { coast: 1 } : {}),
3014
3027
  ...(watching ? { watch: true } : {}), ...(caps.size ? { caps: [...caps] } : {}), ...(asAgent ? { agent: asAgent } : {}),
3015
3028
  // Revision 9 (an older relay ignores both): the game's revision and what this build can do.
3016
3029
  ...(version ? { ver: version } : {}), ...(features.length ? { feat: features } : {}),
3017
3030
  });
3018
3031
  };
3019
3032
  sock.onmessage = (ev) => onMessage(ev.data);
3020
- sock.onclose = () => lost(sock, 'closed');
3033
+ sock.onclose = (event) => lost(sock, 'closed', event);
3021
3034
  // Browsers follow an error with close; Node's WebSocket, on a refused connection, never does.
3022
3035
  sock.onerror = () => lost(sock, 'error');
3023
3036
  }
@@ -3415,7 +3428,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
3415
3428
  if (seat === null || !entries.length) return false;
3416
3429
  if (rulesHost && offline) { rulesHost.frame({ t: 'in', from: seat, e, k, s: entries, r }); return true; }
3417
3430
  if ((!rulesGame && role === 'host') || offline || !connected) return false;
3418
- if (!raw({ t: 'in', e, k, s: entries, r })) return false;
3431
+ if (!raw({ t: 'in', e, k, s: entries, r, ...(gotTick !== null ? { g: gotTick } : {}) })) return false;
3419
3432
  inputOut.hit(wall());
3420
3433
  return true;
3421
3434
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homie-rocks/studio",
3
- "version": "0.45.3",
3
+ "version": "0.46.2",
4
4
  "description": "A studio for games, music and video on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, a port toolkit that makes existing single-player web games multiplayer, the site Worker with public rooms (Table/Lobby Durable Objects and D1, all on the free Workers plan, no payment method), a site with the hub's sections (Home, Games, Music, Videos, Rooms, Posts with feeds) in the studio's own look, an epic landing page for every game, big songs and videos in the studio's own R2 once it has storage (uploaded, checked by SHA-256, served at the same addresses), deploy, and the homie.rocks directory.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -95,6 +95,7 @@
95
95
  }
96
96
  },
97
97
  "files": [
98
+ "docs/",
98
99
  "bin/",
99
100
  "lib/",
100
101
  "netplay/",
@@ -158,10 +159,11 @@
158
159
  "three": "0.185.1",
159
160
  "typescript": "5.9.3",
160
161
  "viem": "2.57.4",
162
+ "ws": "8.22.0",
161
163
  "zod": "4.3.6"
162
164
  },
163
165
  "scripts": {
164
166
  "test": "node --test --test-concurrency=2 --test-timeout=1800000 test/*.test.mjs",
165
- "prepack": "node ../../scripts/authoring-guides.mjs --check"
167
+ "prepack": "node lib/build-identity.mjs --check && node ../../scripts/authoring-guides.mjs --check"
166
168
  }
167
169
  }
package/rules/core.ts CHANGED
@@ -79,7 +79,7 @@ export const IGNORE_MAX = 16;
79
79
  export const ANSWER_MAX = 256;
80
80
 
81
81
  export type Driver = 'person' | 'bot' | 'ai';
82
- export interface SeatInfo { seat: number; driver: Driver; owner: string }
82
+ export interface SeatInfo { seat: number; driver: Driver; owner: string; character?: import('./rules.ts').Character | null }
83
83
  /** One seat's step for a tick: its input values (already held to their types) and, for an owner-moved body, its claim. */
84
84
  export interface StepInput { values: Record<string, unknown>; claim?: { pos: Vec3; vel: Vec3; heading: Vec3; r: number } | null }
85
85
  export interface ResultRow { seat: number; id: string; driver: Driver; score: number; place: number }
@@ -163,6 +163,7 @@ export interface Core {
163
163
  round(): Extract<CoreOut, { t: 'round' }>;
164
164
  bodies(): Body[];
165
165
  /** The body in a seat, as the host runtime needs it for the input protocol. */
166
+ character(seat: number): import('./rules.ts').Character | null;
166
167
  bodyOf(seat: number): { id: string; kind: KindTable; r: number; away: boolean; driver: Driver } | null;
167
168
  /** What happened since the last call: rounds, effects, a changed roster, changed shared state, a new epoch, a failure. */
168
169
  drain(): CoreOut[];
@@ -183,7 +184,7 @@ export interface Core {
183
184
  * `noted`: told, as it happens, of a value the rules wrote that the runtime changed to make it fit, or dropped (pack.ts
184
185
  * `Adjusted`; also `effect`, `think` and `decision`): the handler, what was done, the field's name and what was written.
185
186
  */
186
- export function createCore(c: Compiled, opts: { rewindTicks?: (seat: number)=>number; label?: (seat:number,driver:Driver)=>string; moved?: (kind: string, tick: number, input: Readonly<Record<string, unknown>>, before: import('./pack.ts').MoveBody, after: import('./pack.ts').MoveBody, geometry: import('./math.ts').MapShapes, id: string) => void; 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 {
187
+ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number) => import('./rules.ts').Character | null | undefined; characterSaved?: (seat: number, character: import('./rules.ts').Character) => void; rewindTicks?: (seat: number)=>number; label?: (seat:number,driver:Driver)=>string; moved?: (kind: string, tick: number, input: Readonly<Record<string, unknown>>, before: import('./pack.ts').MoveBody, after: import('./pack.ts').MoveBody, geometry: import('./math.ts').MapShapes, id: string) => void; 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 {
187
188
  const dims = c.dims;
188
189
  const tickHz = c.settings.tickHz;
189
190
  const dt = 1 / tickHz;
@@ -838,7 +839,7 @@ export function createCore(c: Compiled, opts: { rewindTicks?: (seat: number)=>nu
838
839
  return rows;
839
840
  }
840
841
  function joinBody(info: SeatInfo): Ent | null {
841
- const player = Object.freeze({ seat: info.seat, driver: info.driver, owner: info.owner });
842
+ const player = Object.freeze({ seat: info.seat, driver: info.driver, owner: info.owner, ...(info.character !== undefined ? { character: deepFreeze(info.character) } : {}) });
842
843
  let made: Ent | null = null;
843
844
  run('room', 'join', null, 'join', () => {
844
845
  // What room.join returns is read by its own data properties, and its fields are charged before they are held to their shapes.
@@ -864,6 +865,7 @@ export function createCore(c: Compiled, opts: { rewindTicks?: (seat: number)=>nu
864
865
  } else {
865
866
  if (e) {
866
867
  if (e.kind.on.leave) run(e.kind.name, 'on.leave', e, 'ent', () => e.kind.on.leave(world, e.self, Object.freeze({})));
868
+ opts.characterSaved?.(e.seat, JSON.parse(JSON.stringify({ kind: e.kind.name, at: e.pos, heading: e.heading, fields: e.f, motion: e.m })));
867
869
  e.dead = true; ents.delete(e.id);
868
870
  }
869
871
  seats.delete(s.seat);
@@ -975,7 +977,7 @@ export function createCore(c: Compiled, opts: { rewindTicks?: (seat: number)=>nu
975
977
  if (tick < restartAt) return;
976
978
  startMatch();
977
979
  // Seats stay through a finished match: every holder's body is made again, as if it had just joined.
978
- for (const s of [...seats.values()]) { seats.delete(s.seat); if (s.driver !== 'bot') ops.push({ op: 'join', info: { seat: s.seat, driver: s.driver, owner: s.owner } }, ...(s.away ? [{ op: 'away' as const, seat: s.seat, away: true }] : [])); }
980
+ for (const s of [...seats.values()]) { seats.delete(s.seat); if (s.driver !== 'bot') ops.push({ op: 'join', info: { seat: s.seat, driver: s.driver, owner: s.owner, ...(opts.characterForSeat ? { character: opts.characterForSeat(s.seat) } : {}) } }, ...(s.away ? [{ op: 'away' as const, seat: s.seat, away: true }] : [])); }
979
981
  return;
980
982
  }
981
983
  if (restartAsked) { restartAsked = false; endAsked = false; startRound(); return; }
@@ -992,7 +994,10 @@ export function createCore(c: Compiled, opts: { rewindTicks?: (seat: number)=>nu
992
994
  function finishMatch(): void {
993
995
  finishing = false; restartAsked = false;
994
996
  out.push({ t: 'round', n: round.n, phase: 'over', endsAt: tick + Math.max(3, ticks(c.rounds ? c.rounds.breakSeconds : 0)), startedAt: round.startedAt, results: results() });
995
- for (const e of ents.values()) e.dead = true;
997
+ for (const e of ents.values()) {
998
+ if (e.kind.player) opts.characterSaved?.(e.seat, JSON.parse(JSON.stringify({ kind: e.kind.name, at: e.pos, heading: e.heading, fields: e.f, motion: e.m })));
999
+ e.dead = true;
1000
+ }
996
1001
  ents.clear(); spawns = []; queue = []; pendingAsks = []; areas = []; fx = [];
997
1002
  for (const s of seats.values()) s.id = null;
998
1003
  shared = initFields(c.shared, dims); sharedChanged();
@@ -1456,7 +1461,7 @@ export function createCore(c: Compiled, opts: { rewindTicks?: (seat: number)=>nu
1456
1461
  if (!floorCall && value) guideViews.set(seat, value);
1457
1462
  return value;
1458
1463
  },
1459
- seatJoin: (info) => { ops.push({ op: 'join', info: { seat: info.seat, driver: info.driver, owner: String(info.owner ?? '').slice(0, 128) } }); },
1464
+ seatJoin: (info) => { ops.push({ op: 'join', info: { seat: info.seat, driver: info.driver, owner: String(info.owner ?? '').slice(0, 128), ...(info.character !== undefined ? { character: info.character } : {}) } }); },
1460
1465
  seatAway: (seat, away) => { ops.push({ op: 'away', seat, away }); },
1461
1466
  seatLeave: (seat) => { ops.push({ op: 'leave', seat }); },
1462
1467
  setPolicy: (p) => { policy = normalizeCorePolicy(p); },
@@ -1477,6 +1482,7 @@ export function createCore(c: Compiled, opts: { rewindTicks?: (seat: number)=>nu
1477
1482
  return [[round.n, round.phase === 'live' ? 1 : 0, round.endsAt], list, ...(collision ? [collision] : [])];
1478
1483
  },
1479
1484
  shared: () => packFields(c.shared, shared, dims),
1485
+ character: (seat) => { const e = playerBodies().find(e => e.seat === seat); return e ? JSON.parse(JSON.stringify({ kind: e.kind.name, at: e.pos, heading: e.heading, fields: e.f, motion: e.m })) : null; },
1480
1486
  bodies: () => playerBodies().map((e) => ({ seat: e.seat, id: e.id, kind: e.kind.name, driver: e.driver, owner: e.owner, away: e.away, score: e.kind.score ? Number(e.f[e.kind.score]) || 0 : 0, r: e.r })),
1481
1487
  bodyOf: (seat) => { const s = seats.get(seat); const e = s?.id ? ents.get(s.id) : null; return e ? { id: e.id, kind: e.kind, r: e.r, away: e.away, driver: e.driver } : null; },
1482
1488
  drain: () => { const o = out; out = []; return o; },
package/rules/host.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { stageTimings } from './timing.mjs';
1
2
  /*
2
3
  * host.ts — the host runtime: a rules module playing the host's part on the wire.
3
4
  * =============================================================================
@@ -56,9 +57,13 @@ export interface HostClock {
56
57
  setTimer(fn: () => void, ms: number): unknown;
57
58
  clearTimer(handle: unknown): void;
58
59
  }
60
+ export interface CharacterLease {
61
+ account: string; lease: number; seat: number; character: import('./rules.ts').Character | null; returning?: boolean;
62
+ }
59
63
  export interface HostOptions {
60
64
  game: string;
61
65
  record?(owner:string,key:string,value:Record<string,unknown>):void;
66
+ characterFor?(peer: Record<string, unknown>): CharacterLease | null;
62
67
  /** The build check compares a movement step before any tick handler can change it. */
63
68
  moved?: NonNullable<Parameters<typeof createCore>[1]>['moved'];
64
69
  /** Told of every handler as it ends, and what it threw if it threw (core.ts `observe`): the build check listens. */
@@ -74,7 +79,7 @@ export interface HostOptions {
74
79
  send(frame: Record<string, unknown>, text?: string): void;
75
80
  clock: HostClock;
76
81
  /** Synchronous save: the caller's storage output gate holds this tick's outgoing frames until confirmed. */
77
- store?: { save(bytes: Uint8Array): void };
82
+ store?: { save(bytes: Uint8Array, commitment?: { force: boolean; seconds: number; tickHz: number; fields: { kinds: number[][]; shared: number[] } }): void };
78
83
  /** Table recovery reserves this epoch in storage before starting this host. Ordinary deterministic restores omit it. */
79
84
  restoreEpoch?: number;
80
85
  startDelayMs?: number;
@@ -95,6 +100,10 @@ export interface HostOptions {
95
100
  onEnd?(why: string, facts: Record<string, unknown>): void;
96
101
  }
97
102
  export interface Host {
103
+ readonly characters: ReadonlyMap<string, CharacterLease>;
104
+ characterReturned(account: string, lease: number): void;
105
+ releaseCharacter(account: string): void;
106
+ reserveCharacter(lease: CharacterLease): void;
98
107
  readonly tick: number;
99
108
  readonly epoch: number;
100
109
  readonly running: boolean;
@@ -104,6 +113,8 @@ export interface Host {
104
113
  readonly privateDelivery: boolean;
105
114
  projectSnapshot(snap: any, seat: number | null): any;
106
115
  readonly viewSettings: Compiled['settings']['view'];
116
+ /** The studio's own pace on player messages (`room.rates`), or null: Homie sets none. */
117
+ readonly rates: Record<string, number> | null;
107
118
  frame(m: Record<string, unknown>): void;
108
119
  /** Start the tick loop (the first person has joined). */
109
120
  start(): void;
@@ -111,7 +122,7 @@ export interface Host {
111
122
  resume(): void;
112
123
  /** Publish the round, the roster, the shared state and the capabilities again (a browser host's relay restarted). */
113
124
  announce(): void;
114
- /** Stop for good: the room has ended. */
125
+ /** Stop for good: the room has ended. A lasting world whose storage is healthy checkpoints first, and that write can throw. */
115
126
  stop(): void;
116
127
  /** 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
128
  tickNow(): void;
@@ -150,7 +161,7 @@ const SPEECH = /^(?:say|chat|emote)/i;
150
161
  /**
151
162
  * The overrun check's memory: the last few ticks that started in this isolate, whichever rooms' they were, each with
152
163
  * when it started. Time inside a Worker stands still while code runs, so a tick cannot time itself; but the start of
153
- * the next tick, in any room, shows how long it took. A tick that starts more than a period late blames the newest
164
+ * the next tick, in any room, gives a scheduling interval (including other work). A late callback suspects the newest
154
165
  * tick here that took more than a period: the one just before it, or, when that one was quick and was itself held up,
155
166
  * the one before that.
156
167
  */
@@ -164,19 +175,33 @@ function blameLate(now: number, period: number): void {
164
175
 
165
176
  export function createHost(o: HostOptions): Host {
166
177
  const c = o.compiled;
178
+ const persistent = c.settings.lifetime === 'persistent';
179
+ // Keep even effects and agent requests behind the same commit as their tick.
180
+ // Match rooms retain their existing save frequency and output behaviour.
181
+ const publish = o.send;
182
+ let pendingOutput: [Record<string, unknown>, string | undefined][] | null = null;
183
+ if (persistent) o = { ...o, send: (frame, text) => {
184
+ if (pendingOutput) pendingOutput.push([frame, text]); else publish(frame, text);
185
+ } };
186
+
167
187
  const period = 1000 / c.settings.tickHz;
168
188
  const tickHz = c.settings.tickHz;
169
189
  const lateTicks = Math.ceil(tickHz / 4);
170
190
  // A log that throws takes nothing down with it.
171
191
  const log = (line: Record<string, unknown>): void => { try { o.log?.(line); } catch { /* nothing to say it with */ } };
172
192
  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;
193
+ 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
194
  if (o.restore && (!saved || typeof saved !== 'object' || Array.isArray(saved))) throw new Error('saved host is not an object');
175
195
  if (saved) {
176
196
  const check = (ok: unknown): void => { if (!ok) throw new Error('saved host inputs are invalid'); };
177
197
  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)));
198
+ check(Object.keys(saved).every(k => ['v', 'core', 'names', 'agents', 'guideViews', 'queues', 'inputs', 'characters'].includes(k)));
179
199
  check(saved.core && Array.isArray(saved.names) && Array.isArray(saved.queues));
200
+ if (saved.characters !== undefined) {
201
+ check(c.settings.characters && Array.isArray(saved.characters));
202
+ check(new Set(saved.characters.map(c => c.account)).size === saved.characters.length);
203
+ 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'));
204
+ }
180
205
  if (saved.agents !== undefined) {
181
206
  const tables = ['goals', 'asks', 'avoid', 'sayAt', 'viewAt', 'floorAt', 'askAt'];
182
207
  check(saved.agents && Object.keys(saved.agents).every(k => tables.includes(k)));
@@ -236,12 +261,27 @@ export function createHost(o: HostOptions): Host {
236
261
  }
237
262
  if (saved && saved.v !== 1) throw new Error('this save was written by another version of the runtime');
238
263
  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 });
264
+ const characters = new Map<string, CharacterLease>((saved?.characters ?? []).map(c => [c.account, c.returning ? { ...c, seat: -1 } : c]));
265
+ const characterOptions = c.settings.characters ? {
266
+ characterForSeat: (seat: number) => [...characters.values()].find(c => c.seat === seat && !c.returning)?.character,
267
+ characterSaved: (seat: number, character: import('./rules.ts').Character) => {
268
+ const held = [...characters.values()].find(c => c.seat === seat);
269
+ if (held) {
270
+ held.character = character;
271
+ // Once removed, a delayed return must never follow a new occupant of this seat.
272
+ if (held.returning) held.seat = -1;
273
+ }
274
+ },
275
+ } : {};
276
+ 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
277
  let epoch = core.epoch;
241
278
  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
279
  let lastSaveAt = o.clock.now();
243
280
  let saveTick = core.tick;
244
281
  let forceSave = false;
282
+ let storeBroken = false;
283
+ /** Which state fields the game declared durable, by kind and for the shared state: what a lasting world journals at once. */
284
+ 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
285
  let saveFailures = 0;
246
286
  let retrySaveAt = 0;
247
287
  let roundPhase = saved?.core.round[1] ?? 1;
@@ -358,6 +398,7 @@ export function createHost(o: HostOptions): Host {
358
398
  /** Where in a tick the runtime is, for the log line of a fault. */
359
399
  let phase = '';
360
400
  let faultLoggedAt = -Infinity;
401
+ const timing = stageTimings();
361
402
  const stats = { ticks: 0, late: 0, slips: 0, since: 0, ins: 0, dropped: 0, lateEntries: 0, loggedAt: 0, faults: 0, lastFault: '' };
362
403
  /** Something was thrown in the runtime itself: it is counted, and said in the log at most once a second. */
363
404
  function fault(where: string, error: unknown): void {
@@ -459,7 +500,16 @@ export function createHost(o: HostOptions): Host {
459
500
  // The relay numbers every stay in a seat (`occ`): the same number is the same holder, back.
460
501
  const owner = `p${Number.isInteger(p.occ) ? p.occ : typeof p.id === 'string' ? p.id.slice(0, 40) : seat}`;
461
502
  const previous = core.bodies().find((s) => s.seat === seat);
462
- core.seatJoin({ seat, driver, owner });
503
+ const grant = driver === 'person' ? o.characterFor?.(p as Record<string, unknown>) : null;
504
+ if (c.settings.characters && driver === 'person' && !grant && o.characterFor) throw new Error('Character lease required');
505
+ let character = grant?.character;
506
+ if (grant) {
507
+ const held = characters.get(grant.account);
508
+ if (held && (held.lease !== grant.lease || held.returning || held.seat >= 0 && held.seat !== seat)) throw new Error('Character return is still pending');
509
+ character = held ? core.character(held.seat) ?? held.character : grant.character;
510
+ characters.set(grant.account, { ...grant, character: character ?? null, seat });
511
+ }
512
+ core.seatJoin({ seat, driver, owner, ...(grant ? { character } : {}) });
463
513
  core.seatAway(seat, false);
464
514
  if (!previous || previous.driver !== driver || previous.owner !== owner) queues.delete(seat);
465
515
  rosterText = '';
@@ -471,7 +521,13 @@ export function createHost(o: HostOptions): Host {
471
521
  for (const [id, p] of peers) if (p.seat === m.seat) peers.delete(id);
472
522
  present.delete(m.seat as number);
473
523
  queues.delete(m.seat as number);
474
- core.seatAway(m.seat as number, true);
524
+ const held = [...characters.values()].find(c => c.seat === m.seat && !c.returning);
525
+ if (held) {
526
+ held.character = core.character(held.seat) ?? held.character;
527
+ held.returning = true;
528
+ core.seatLeave(held.seat);
529
+ tick(o.clock.now());
530
+ } else core.seatAway(m.seat as number, true);
475
531
  if (running && people() === 0) pause();
476
532
  return;
477
533
  }
@@ -481,7 +537,10 @@ export function createHost(o: HostOptions): Host {
481
537
  for (const [id, p] of peers) if (p.seat === m.seat) peers.delete(id);
482
538
  present.delete(m.seat as number);
483
539
  queues.delete(m.seat as number);
540
+ const held = [...characters.values()].find(c => c.seat === m.seat && !c.returning);
541
+ if (held) { held.character = core.character(held.seat) ?? held.character; held.returning = true; }
484
542
  core.seatLeave(m.seat as number);
543
+ if (held) tick(o.clock.now());
485
544
  rosterText = '';
486
545
  return;
487
546
  }
@@ -593,17 +652,26 @@ export function createHost(o: HostOptions): Host {
593
652
  }
594
653
  if (roundRebasePending) publishRebase(now);
595
654
  phase = 'step';
655
+ const rulesAt = timing.now();
596
656
  core.step(inputs, guideBeat);
657
+ timing.record('rules', rulesAt);
597
658
  phase = 'frames';
659
+ const framesAt = timing.now();
598
660
  const out = core.drain();
599
661
  handle(out, now);
662
+ timing.record('frames', framesAt);
600
663
  if (ended) return;
601
664
  phase = 'snapshot';
665
+ const snapshotAt = timing.now();
602
666
  const data = core.snapshot();
667
+ timing.record('snapshot', snapshotAt);
603
668
  const nextPhase = (data[0] as number[])[1];
604
669
  if (roundPhase === 1 && nextPhase === 0) forceSave = true;
605
670
  roundPhase = nextPhase;
671
+ const checkpointAt = timing.now();
606
672
  checkpoint(forceSave);
673
+ timing.record('checkpoint', checkpointAt);
674
+ const encodeAt = timing.now();
607
675
  // The control table: one row a seat, [seat, r, ack, lead].
608
676
  const rows: number[][] = [];
609
677
  for (const b of core.bodies()) {
@@ -616,13 +684,17 @@ export function createHost(o: HostOptions): Host {
616
684
  // apart to whoever interpolates between them. The stamp always rises: a replica drops one that does not.
617
685
  lastSt = Math.max(lastSt + 1, Math.round(Math.min(now, dueOf(core.tick))));
618
686
  const snap = { t: 'snap', from: null, e: epoch, k: core.tick, st: lastSt, d: data, c: rows };
619
- o.send(snap, JSON.stringify(snap));
687
+ const encoded = JSON.stringify(snap);
688
+ timing.record('encode', encodeAt);
689
+ const sendAt = timing.now();
690
+ o.send(snap, encoded);
691
+ timing.record('send', sendAt);
620
692
  stats.ticks += 1;
621
693
  stats.since += 1;
622
694
  if (now - stats.loggedAt >= 10_000) {
623
695
  // Tick figures, one line every ten seconds, never a line a tick.
624
696
  const s = core.stats;
625
- log({ ev: 'ticks', game: o.game, ...(o.build ? { build: o.build } : {}), tick: core.tick, ticks: stats.since, late: stats.late, slips: stats.slips, ins: stats.ins, lateEntries: stats.lateEntries, dropped: stats.dropped, errors: s.errors, budgetStops: s.budgetStops, cut: s.ticksCut, maxUnits: s.maxUnits, worst: s.worst, maxTickUnits: s.maxTickUnits, ...(s.lost ? { lost: s.lost } : {}), ...(stats.faults ? { faults: stats.faults, lastFault: stats.lastFault } : {}), ...(s.lastError ? { lastError: s.lastError } : {}) });
697
+ log({ ev: 'ticks', timing: timing.facts(), game: o.game, ...(o.build ? { build: o.build } : {}), tick: core.tick, ticks: stats.since, late: stats.late, slips: stats.slips, ins: stats.ins, lateEntries: stats.lateEntries, dropped: stats.dropped, errors: s.errors, budgetStops: s.budgetStops, cut: s.ticksCut, maxUnits: s.maxUnits, worst: s.worst, maxTickUnits: s.maxTickUnits, ...(s.lost ? { lost: s.lost } : {}), ...(stats.faults ? { faults: stats.faults, lastFault: stats.lastFault } : {}), ...(s.lastError ? { lastError: s.lastError } : {}) });
626
698
  stats.loggedAt = now; stats.since = 0; stats.late = 0; stats.ins = 0; stats.lateEntries = 0;
627
699
  }
628
700
  }
@@ -632,9 +704,21 @@ export function createHost(o: HostOptions): Host {
632
704
  * of the clock the room ends, and the log names the handler (or the fault).
633
705
  */
634
706
  function tick(now: number): void {
707
+ const tickAt = timing.now();
635
708
  const cut = core.stats.ticksCut;
636
709
  let faulted = false;
637
- try { tickOnce(); } catch (error) { faulted = true; fault(`tick ${core.tick} (${phase})`, error); }
710
+ if (persistent) pendingOutput = [];
711
+ try {
712
+ tickOnce();
713
+ const committed = pendingOutput; pendingOutput = null;
714
+ if (committed && !ended) for (const [frame, text] of committed) publish(frame, text);
715
+ } catch (error) {
716
+ pendingOutput = null;
717
+ faulted = true; fault(`tick ${core.tick} (${phase})`, error);
718
+ // Never continue from uncommitted memory or retire the last good save.
719
+ if (persistent) { timing.record('tick', tickAt); stop(); o.onEnd?.('storage-or-tick-failed', { tick: core.tick }); return; }
720
+ }
721
+ timing.record('tick', tickAt);
638
722
  if (ended) return;
639
723
  if (!faulted && core.stats.ticksCut === cut) { failingSince = null; return; }
640
724
  if (failingSince === null) failingSince = now;
@@ -688,8 +772,13 @@ export function createHost(o: HostOptions): Host {
688
772
  if (overSince !== null) {
689
773
  overTicks += 1;
690
774
  if (2 * overBlamed < overTicks) overSince = null;
691
- // A room whose ticks have run slow for five seconds of the clock ends, and the log names the handler that used the most units.
692
- else if (now - overSince >= OVERRUN_MS) { end('overrun', { worst: core.stats.worst, maxUnits: core.stats.maxUnits, seconds: Math.round((now - overSince) / 100) / 10 }); return; }
775
+ // Late callbacks can catch up at the requested rate. End only a
776
+ // blamed room that also loses sustained throughput, not a healthy
777
+ // 20 Hz room whose callbacks arrive in pairs during admission/I/O.
778
+ else if (now - overSince >= OVERRUN_MS) {
779
+ if (overTicks * period < (now - overSince) * 0.9) { end('overrun', { worst: core.stats.worst, maxUnits: core.stats.maxUnits, seconds: Math.round((now - overSince) / 100) / 10 }); return; }
780
+ overSince = null;
781
+ }
693
782
  }
694
783
  if (o.enforceOverrun) recent.push({ who: self, at: now });
695
784
  if (recent.length > 8) recent.shift();
@@ -730,7 +819,7 @@ export function createHost(o: HostOptions): Host {
730
819
  // A pending timer would keep the object in memory and billed: the pause clears it.
731
820
  disarm();
732
821
  log({ ev: 'host-pause', game: o.game, tick: core.tick });
733
- try { checkpoint(true); o.onPause?.(); } catch (error) { fault('onPause', error); }
822
+ try { checkpoint(true); o.onPause?.(); } catch (error) { fault('onPause', error); if (persistent) { stop(); o.onEnd?.('storage-or-tick-failed', { tick: core.tick }); } }
734
823
  }
735
824
  function resume(): void {
736
825
  if (running || ended || !paused) return;
@@ -772,9 +861,21 @@ export function createHost(o: HostOptions): Host {
772
861
  }
773
862
 
774
863
  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] });
864
+ const reserved = new Set(core.bodies().filter(b => b.owner === 'reserved').map(b => b.seat));
865
+ 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]) => !reserved.has(seat)), queues: [...queues].map(([seat, q]) => [seat, q.held, q.claim, q.newest, q.ack]), inputs: [...queues] });
776
866
  }
777
867
  function checkpoint(force = false): void {
868
+ if (persistent) {
869
+ // An empty match break or an unjoined reservation has no body-removal callback.
870
+ for (const held of characters.values()) if (held.returning) held.seat = -1;
871
+ if (!o.store) return; // Local checks/offline simulation have no durable account.
872
+ // A write that threw leaves memory ahead of storage: nothing later may checkpoint it over the committed world.
873
+ storeBroken = true;
874
+ o.store.save(save(), { force, seconds: c.settings.durability.movementSeconds, tickHz, fields: durableFields });
875
+ storeBroken = false;
876
+ saveTick = core.tick; lastSaveAt = o.clock.now(); forceSave = false;
877
+ return;
878
+ }
778
879
  const seconds = c.settings.durability.movementSeconds;
779
880
  // Catch-up ticks can cover a second of play in less than a second of wall time. Bound both clocks.
780
881
  if (!o.store || !force && (core.tick === saveTick || o.clock.now() - lastSaveAt < seconds * 1000 && core.tick - saveTick < seconds * tickHz)) return;
@@ -800,21 +901,35 @@ export function createHost(o: HostOptions): Host {
800
901
  return [...row.slice(0,7),fields,...row.slice(8)];
801
902
  }),...snap.d.slice(2)]};
802
903
  },
904
+ get characters() { return characters; },
905
+ reserveCharacter(lease) { if (!characters.has(lease.account)) characters.set(lease.account, { ...lease }); },
906
+ releaseCharacter(account) {
907
+ const held = characters.get(account);
908
+ if (!held || held.returning || ended) return;
909
+ held.character = core.character(held.seat) ?? held.character; held.returning = true;
910
+ present.delete(held.seat); queues.delete(held.seat); names.delete(held.seat);
911
+ if (held.seat >= 0) core.seatLeave(held.seat); tick(o.clock.now());
912
+ if (running && people() === 0) pause();
913
+ },
914
+ characterReturned(account, lease) { if (characters.get(account)?.returning && characters.get(account)?.lease === lease) characters.delete(account); },
803
915
  get viewSettings() { return c.settings.view; },
916
+ get rates() { return c.settings.rates ?? null; },
804
917
  get viewRadiusM() { return c.settings.view?.radiusM ?? null; },
805
918
  get tick() { return core.tick; },
806
919
  get epoch() { return epoch; },
807
920
  get running() { return running; },
808
921
  get paused() { return paused; },
809
922
  get people() { return people(); },
810
- frame, start, pause, resume, stop, announce,
923
+ frame, start, pause, resume, announce,
924
+ // 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.
925
+ stop() { try { if (persistent && !ended && !storeBroken) checkpoint(true); } finally { stop(); } },
811
926
  tickNow: () => {
812
927
  if (ended) return;
813
928
  let now = lastStart;
814
929
  try { now = o.clock.now(); tick(now); } catch (error) { fault('tickNow', error); } finally { keepTime(); }
815
930
  },
816
931
  save,
817
- facts: () => ({ tick: core.tick, epoch, running, paused, ended, armed, people: people(), tickHz, ...stats, core: { ...core.stats } }),
932
+ facts: () => ({ timing: timing.facts(), tick: core.tick, epoch, running, paused, ended, armed, people: people(), tickHz, ...stats, core: { ...core.stats } }),
818
933
  core,
819
934
  };
820
935
  }