@homie-rocks/studio 0.46.0 → 0.46.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +105 -0
  2. package/README.md +3 -2
  3. package/docs/rooms-milestone-2-notes.md +345 -0
  4. package/guides/game/RULES.md +115 -5
  5. package/guides/game/SKILL.md +5 -0
  6. package/lib/build-identity.mjs +43 -0
  7. package/lib/cloudflare.mjs +13 -11
  8. package/lib/crowd-cloud.mjs +88 -0
  9. package/lib/crowd-diagnostics.mjs +73 -0
  10. package/lib/crowd-load.mjs +603 -0
  11. package/lib/crowd-probe/README.md +61 -0
  12. package/lib/crowd-probe/client.mjs +96 -0
  13. package/lib/crowd-probe/worker.mjs +72 -0
  14. package/lib/crowd-probe/wrangler.jsonc +12 -0
  15. package/lib/crowd-summary.mjs +140 -0
  16. package/lib/crowd-worker.mjs +496 -0
  17. package/lib/rules-check-worker.mjs +1 -1
  18. package/lib/rules-check.mjs +2 -2
  19. package/lib/rules-types.mjs +1 -1
  20. package/lib/scaffold.mjs +5 -5
  21. package/lib/template-history.json +84 -0
  22. package/netplay/netplay.ts +19 -6
  23. package/package.json +4 -2
  24. package/rules/core.ts +29 -4
  25. package/rules/host.ts +119 -21
  26. package/rules/interest.mjs +320 -51
  27. package/rules/pack.ts +1 -0
  28. package/rules/rules.ts +29 -8
  29. package/rules/timing.mjs +20 -0
  30. package/rules/types.ts +5 -0
  31. package/rules/view.ts +229 -25
  32. package/saves/SAVES.md +83 -3
  33. package/saves/saves.ts +2 -0
  34. package/worker/build-id.mjs +2 -0
  35. package/worker/gate.mjs +436 -53
  36. package/worker/index.mjs +92 -10
  37. package/worker/office-page.mjs +11 -0
  38. package/worker/office.mjs +27 -0
  39. package/worker/players.mjs +13 -15
  40. package/worker/room.mjs +113 -19
  41. package/worker/saves-shell.mjs +22 -2
  42. package/worker/saves.mjs +282 -19
  43. package/worker/version.mjs +1 -1
  44. package/worker/world-wire.mjs +110 -0
@@ -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. */
@@ -1679,7 +1682,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1679
1682
  else if (m.t === 'state' && typeof m.k === 'string') applyState(m.k, m.d);
1680
1683
  else if (m.t === 'round') { const incoming = m.round as RoundInfo; roundInfo = m.retime && roundInfo?.n === incoming.n ? { ...roundInfo, ...incoming } : incoming; if (!m.retime) { emit('round', roundInfo); post?.({ what: 'round', round: roundInfo }); } }
1681
1684
  else if (m.t === 'roster' && (offline || !connected)) { slots = m.slots as Slot[]; emit('roster', slots); post?.({ what: 'roster', slots }); }
1682
- else if (m.t === 'ev' && (m.to === undefined || m.to === seat || m.to === id)) emit('event', { k: String(m.k), d: m.d, from: typeof m.from === 'number' ? m.from : null });
1685
+ else if (m.t === 'ev' && (m.to === undefined || m.to === seat || m.to === id || (m.to === -1 && seat === null))) emit('event', { k: String(m.k), d: m.d, from: typeof m.from === 'number' ? m.from : null });
1683
1686
  }
1684
1687
  let role: Role = 'replica';
1685
1688
  let roleKnown = false;
@@ -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.46.0",
3
+ "version": "0.46.3",
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
@@ -69,6 +69,10 @@ export const SEND = 16;
69
69
  export const THROWN = 512;
70
70
  /** The most effects (`world.emit`) one tick takes. */
71
71
  export const FX_MAX = 256;
72
+ /** The most entities one effect's audience names (`world.emit(effect, at, data, { to })`). */
73
+ export const FX_TO_MAX = 32;
74
+ /** Who may perceive an effect: within so many metres of where it happened (-1: no distance), the entities named, and where it happened. */
75
+ export type FxAudience = [within: number, to: string[], from: [number, number, number] | null];
72
76
  /** The most area events (`world.sendArea`) one tick takes. */
73
77
  export const AREAS_MAX = 64;
74
78
  /** What the log names when a room ends because of what it holds, not because of a handler. */
@@ -85,7 +89,7 @@ export interface StepInput { values: Record<string, unknown>; claim?: { pos: Vec
85
89
  export interface ResultRow { seat: number; id: string; driver: Driver; score: number; place: number }
86
90
  export type CoreOut =
87
91
  | { t: 'round'; n: number; phase: 'live' | 'over'; endsAt: number; startedAt: number; results?: ResultRow[] }
88
- | { t: 'fx'; tick: number; list: [number, unknown, unknown][] }
92
+ | { t: 'fx'; tick: number; list: [number, unknown, unknown, FxAudience?][] }
89
93
  | { t: 'ask'; n: string; state: unknown; questions: Record<string, unknown> }
90
94
  | { t: 'goalDone'; seat: number; ok: boolean }
91
95
  | { t: 'seats' }
@@ -162,6 +166,8 @@ export interface Core {
162
166
  /** The current round, for a host rebasing wall-clock deadlines after a pause or restore. */
163
167
  round(): Extract<CoreOut, { t: 'round' }>;
164
168
  bodies(): Body[];
169
+ /** Every person's body with where it stands: who an effect with an audience reaches (0.46.3). */
170
+ listeners(): { seat: number; id: string; pos: Vec3 }[];
165
171
  /** The body in a seat, as the host runtime needs it for the input protocol. */
166
172
  character(seat: number): import('./rules.ts').Character | null;
167
173
  bodyOf(seat: number): { id: string; kind: KindTable; r: number; away: boolean; driver: Driver } | null;
@@ -235,7 +241,7 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
235
241
  let sharedROVer = -1;
236
242
  const sharedChanged = (): void => { sharedDirty = true; sharedVer += 1; };
237
243
  let out: CoreOut[] = [];
238
- let fx: [number, unknown, unknown][] = [];
244
+ let fx: [number, unknown, unknown, FxAudience?][] = [];
239
245
  /** What is left of the running tick's budget. A room's first `room.start` runs before any tick, with a whole one. */
240
246
  let left = c.settings.budget.tick;
241
247
  let trips = 0;
@@ -527,6 +533,8 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
527
533
  const world = brand({} as Record<string, unknown>);
528
534
  const getters: Record<string, () => unknown> = {
529
535
  tick: () => tick, dt: () => dt, round: () => roundApi, shared: sharedView, level: () => policy.level, levelMax: () => policy.levelMax, guideLevel: () => policy.guideLevel ?? policy.level, guideSeats: () => policy.guideSeats ?? EMPTY_GUIDE_SEATS, kids: () => Boolean(policy.kids), levelSet: () => Boolean(policy.levelSet),
536
+ // What the running handler may still spend (0.46.3): a handler that plans can stop short of its share instead of being stopped.
537
+ unitsLeft: () => Math.max(0, Math.min(2147483647, Math.floor(G.left))),
530
538
  stage: () => stage, tune: () => c.tune, math: () => math, map: () => mapApi,
531
539
  };
532
540
  for (const [name, get] of Object.entries(getters)) Object.defineProperty(world, name, { get, enumerable: true });
@@ -594,14 +602,30 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
594
602
  const wait = w >= 1 ? Math.min(w, 0x7fffffff) : 1;
595
603
  push({ due: tick + wait, to: cx.ent ? cx.ent.id : '', kind: cx.ent ? 'ev' : 'room', ev: ev as string, data: shapeData(c.events, ev, 'the event', data), at: tick });
596
604
  },
597
- emit: (effect: unknown, at: unknown, data?: unknown): void => {
605
+ emit: (effect: unknown, at: unknown, data?: unknown, audience?: unknown): void => {
598
606
  charge(10);
599
607
  if (cx.scope !== 'ent' && cx.scope !== 'room') throw new Error('world.emit is for handlers');
600
608
  const i = typeof effect === 'string' ? c.effectNames.indexOf(effect) : -1;
601
609
  if (i < 0) throw new Error(`the effect "${said(effect)}" is not declared in shapes.effects`);
602
610
  if (fx.length >= FX_MAX) { G.note?.('effect', '', said(effect)); return; }
603
611
  const shape = c.effects[c.effectNames[i]];
604
- fx.push([i, typeof at === 'string' ? at.slice(0, 24) : packVec(V(at), dims), packFields(shape, shapeData(c.effects, effect, 'the effect', data), dims)]);
612
+ const row: [number, unknown, unknown, FxAudience?] = [i, typeof at === 'string' ? at.slice(0, 24) : packVec(V(at), dims), packFields(shape, shapeData(c.effects, effect, 'the effect', data), dims)];
613
+ // An audience (0.46.3): who may perceive this effect. Without one it is public, as before.
614
+ if (audience !== undefined && audience !== null) {
615
+ charge(8);
616
+ const to = own(audience, 'to'), within = own(audience, 'within');
617
+ const ids: string[] = [];
618
+ if (typeof to === 'string') ids.push(to.slice(0, 24));
619
+ else if (Array.isArray(to)) { if (to.length > FX_TO_MAX) throw new Error(`an effect's audience names at most ${FX_TO_MAX} entities`); for (let n = 0; n < to.length; n++) { const id = to[n]; if (typeof id === 'string') ids.push(id.slice(0, 24)); } }
620
+ else if (to !== undefined) throw new Error('an effect\'s audience is { to: id | [ids], within: metres }');
621
+ if (within !== undefined && (typeof within !== 'number' || !Number.isFinite(within) || within < 0)) throw new Error('an effect\'s audience `within` is a distance in metres');
622
+ if (typeof to === 'undefined' && within === undefined) throw new Error('an effect\'s audience is { to: id | [ids], within: metres }');
623
+ // The place it is heard from: where it was emitted, or where the entity it names stands now.
624
+ const e = typeof at === 'string' ? ents.get(at.slice(0, 24)) : undefined;
625
+ const from = typeof at === 'string' ? (e && !e.dead ? e.pos : null) : V(at);
626
+ row.push([typeof within === 'number' ? within : -1, ids, from ? [from.x, from.y, from.z] : null]);
627
+ }
628
+ fx.push(row);
605
629
  },
606
630
  spawn: (kind: unknown, at: unknown, fields?: unknown): string => {
607
631
  charge(10);
@@ -1484,6 +1508,7 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
1484
1508
  shared: () => packFields(c.shared, shared, dims),
1485
1509
  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; },
1486
1510
  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 })),
1511
+ listeners: () => playerBodies().filter((e) => e.driver === 'person').map((e) => ({ seat: e.seat, id: e.id, pos: e.pos })),
1487
1512
  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; },
1488
1513
  drain: () => { const o = out; out = []; return o; },
1489
1514
  save,
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
  * =============================================================================
@@ -112,6 +113,8 @@ export interface Host {
112
113
  readonly privateDelivery: boolean;
113
114
  projectSnapshot(snap: any, seat: number | null): any;
114
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;
115
118
  frame(m: Record<string, unknown>): void;
116
119
  /** Start the tick loop (the first person has joined). */
117
120
  start(): void;
@@ -129,9 +132,12 @@ export interface Host {
129
132
  readonly core: Core;
130
133
  }
131
134
 
132
- interface Entry { at: number; values: Record<string, unknown>; presses: (string | [string, number])[]; claim: StepInput['claim'] }
135
+ interface Entry { at: number; values: Record<string, unknown>; presses: (string | [string, number])[]; claim: StepInput['claim']; /** How many ticks before its own tick it arrived (0.46.3): the time the input itself waited here. */ early?: number }
133
136
  interface SeatIn {
134
- pulses?: [string,number][];
137
+ /** A valued pulse, and the tick its browser predicted it on (0: none given, act at once). */
138
+ pulses?: [string,number,number?,number?][];
139
+ /** The wait of the entry now held, in ticks (see Entry.early). */
140
+ early?: number;
135
141
  held: Record<string, unknown>; claim: StepInput['claim']; entries: Entry[];
136
142
  /** The newest stamp this seat has sent, the stamps not yet acknowledged, and the newest acknowledged. */
137
143
  newest: number; stamps: number[]; ack: number;
@@ -158,7 +164,7 @@ const SPEECH = /^(?:say|chat|emote)/i;
158
164
  /**
159
165
  * The overrun check's memory: the last few ticks that started in this isolate, whichever rooms' they were, each with
160
166
  * when it started. Time inside a Worker stands still while code runs, so a tick cannot time itself; but the start of
161
- * the next tick, in any room, shows how long it took. A tick that starts more than a period late blames the newest
167
+ * the next tick, in any room, gives a scheduling interval (including other work). A late callback suspects the newest
162
168
  * tick here that took more than a period: the one just before it, or, when that one was quick and was itself held up,
163
169
  * the one before that.
164
170
  */
@@ -251,7 +257,7 @@ export function createHost(o: HostOptions): Host {
251
257
  for (const [seat, q] of saved.inputs) {
252
258
  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);
253
259
  check(Array.isArray(q.stamps) && q.stamps.length <= 256 && q.stamps.every(uint) && Array.isArray(q.entries) && q.entries.length <= c.settings.tickHz + 1);
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));
260
+ if(q.pulses!==undefined)check(Array.isArray(q.pulses)&&q.pulses.length<=16&&q.pulses.every(p=>Array.isArray(p)&&(p.length===2||p.length>=3&&p.length<=4&&uint(p[2])&&(p[3]===undefined||uint(p[3])))&&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
261
  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)))));
256
262
  }
257
263
  }
@@ -270,7 +276,26 @@ export function createHost(o: HostOptions): Host {
270
276
  }
271
277
  },
272
278
  } : {};
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 });
279
+ /*
280
+ * How far back a person's ray may look (0.46.3). A shot is judged where the shooter saw the target, so the rewind is
281
+ * the age of that view when the input was RECEIVED, plus the time the input then waited for its tick:
282
+ *
283
+ * the view's age the relay's own measurement of this connection's round trip (a low percentile over a window of
284
+ * acknowledgements carried by the input frames themselves, worker/room.mjs), plus 150 ms of
285
+ * presentation delay. A client cannot bring the measurement under its real latency. It can
286
+ * pretend to be slower, by holding back what it sends, and then its input is late too.
287
+ * the wait whole ticks between an entry's arrival and the tick it was stamped for (at least one, at most a
288
+ * quarter of a second): tied to when the server received the input, not to what the client says.
289
+ *
290
+ * `room.rewindSeconds` is the game's hard cap on the sum. Without it the cap is `room.historySeconds`, as before.
291
+ */
292
+ const rewindCap = c.rewindSeconds !== undefined ? Math.ceil(c.rewindSeconds * tickHz) : null;
293
+ const rewindTicks = (seat: number): number => {
294
+ const waited = Math.max(1, Math.min(lateTicks, queues.get(seat)?.early ?? 1));
295
+ const need = Math.ceil(((measuredRtt.get(seat) ?? 0) + 150) / period) + waited;
296
+ return rewindCap === null ? need : Math.min(rewindCap, need);
297
+ };
298
+ const core = createCore(c, { ...characterOptions, rewindTicks, 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 });
274
299
  let epoch = core.epoch;
275
300
  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);
276
301
  let lastSaveAt = o.clock.now();
@@ -395,6 +420,7 @@ export function createHost(o: HostOptions): Host {
395
420
  /** Where in a tick the runtime is, for the log line of a fault. */
396
421
  let phase = '';
397
422
  let faultLoggedAt = -Infinity;
423
+ const timing = stageTimings();
398
424
  const stats = { ticks: 0, late: 0, slips: 0, since: 0, ins: 0, dropped: 0, lateEntries: 0, loggedAt: 0, faults: 0, lastFault: '' };
399
425
  /** Something was thrown in the runtime itself: it is counted, and said in the log at most once a second. */
400
426
  function fault(where: string, error: unknown): void {
@@ -411,9 +437,21 @@ export function createHost(o: HostOptions): Host {
411
437
  for (const [seat, held, claim, newest, ack] of saved?.queues ?? []) queues.set(seat, { held, claim, entries: [], newest, stamps: [], ack, lead: 127, lastFrameTick: core.tick });
412
438
  for (const [seat, q] of saved?.inputs ?? []) queues.set(seat, q);
413
439
  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; };
440
+ /*
441
+ * `f.held()` inputs that arrive late (0.46.3). A held value is how much of ONE tick a control was down, so the sum over
442
+ * a tap's ticks is the tap. When an entry misses its tick, that tick has already run on the value before it (a
443
+ * release that arrives late ran one more whole tick of movement), and applying the entry a tick later as well would
444
+ * count the tap twice over. So the difference between what the missed ticks ran on and what they were meant to run
445
+ * on is owed, and paid on the next ticks, inside the range the control reads in. The body then ends where the
446
+ * browser predicted it, however the frames fell. Kept beside the queues and not saved: a restart forgets a debt.
447
+ */
448
+ const heldNames = new Map<KindTable, string[]>();
449
+ const heldOf = (kind: KindTable): string[] => { let names = heldNames.get(kind); if (!names) { names = kind.input.filter(([, fd]) => fd.t === 'held').map(([name]) => name); heldNames.set(kind, names); } return names; };
450
+ const pulsesTaken = new Map<number, number>();
451
+ const heldBook = new Map<number, { ran: [number, Record<string, number>][]; debt: Record<string, number>; until: number }>();
414
452
  function queueOf(seat: number, kind: KindTable): SeatIn {
415
453
  let q = queues.get(seat);
416
- if (!q) { q = { held: neutral(kind), claim: null, entries: [], newest: 0, stamps: [], ack: 0, lead: 127, lastFrameTick: core.tick }; queues.set(seat, q); }
454
+ if (!q) { q = { held: neutral(kind), claim: null, entries: [], newest: 0, stamps: [], ack: 0, lead: 127, lastFrameTick: core.tick }; queues.set(seat, q); pulsesTaken.delete(seat); }
417
455
  return q;
418
456
  }
419
457
  const people = (): number => { let n = 0; for (const person of present.values()) if (person) n += 1; return n; };
@@ -457,9 +495,11 @@ export function createHost(o: HostOptions): Host {
457
495
  }
458
496
  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;
459
497
  q.stamps.push(j);
460
- if (j > K) { q.entries.push({ at: j, values, presses, claim }); continue; }
498
+ if (j > K) { q.entries.push({ at: j, values, presses, claim, early: Math.min(lateTicks, j - K) }); continue; }
461
499
  // Late: its values hold from the next tick, and its presses fire there if that is within a quarter of a second of its stamp.
462
500
  stats.lateEntries += 1;
501
+ const book = heldBook.get(seat as number);
502
+ if (book) for (const row of book.ran) if (row[0] >= j) { for (const name of heldOf(kind)) { const meant = Number(values[name]) || 0; book.debt[name] = (book.debt[name] ?? 0) + meant - (row[1][name] ?? 0); row[1][name] = meant; } book.until = K + 1 + lateTicks; }
463
503
  const at = (K + 1) >>> 0;
464
504
  const keep = at - j <= lateTicks ? presses : [];
465
505
  const same = q.entries.find((x) => x.at === at);
@@ -550,9 +590,15 @@ export function createHost(o: HostOptions): Host {
550
590
  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
591
  if(kind === 'pulse') {
552
592
  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;
593
+ if(!body || !hasHands(m.from as number,body.driver) || !Array.isArray(data) || data.length<3 || data.length>5 || data[0]!==epoch || typeof data[1]!=='string' || !body.kind.input.some(([name,fd])=>name===data[1]&&fd.t==='pulse'))return;
554
594
  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;
595
+ // The browser names the tick it predicted the pulse on (0.46.3): the server acts on it in that tick, as it does for a
596
+ // stamped input, so what was predicted is what the tick's snapshot holds. A tick already run, or none: at once.
597
+ const at=Number.isInteger(data[3])&&(data[3] as number)>0?Math.min(data[3] as number,0xffffffff):0;
598
+ // Its number in this browser's own count (0.46.3): the control row says the newest one acted on, so a browser knows
599
+ // whether a pulse it predicted has happened yet, however late its message was.
600
+ const n=Number.isInteger(data[4])&&(data[4] as number)>0?Math.min(data[4] as number,0xffffffff):0;
601
+ if(value && q.pulses.length<16)q.pulses.push(n?[data[1],value,at,n]:at?[data[1],value,at]:[data[1],value]);return;
556
602
  }
557
603
  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; }
558
604
  // Speech keeps its payload, except the identity fields only the host may attach.
@@ -612,10 +658,27 @@ export function createHost(o: HostOptions): Host {
612
658
  } });
613
659
  } else if (x.t === 'ask') o.send({ t: 'decide', n: x.n, state: x.state, questions: x.questions });
614
660
  else if (x.t === 'goalDone') agents?.done(x.seat, x.ok);
615
- else if (x.t === 'fx') o.send({ t: 'ev', from: null, k: 'fx', d: [x.tick, x.list] });
661
+ else if (x.t === 'fx') {
662
+ // Effects with an audience (0.46.3) go to who may perceive them: one frame a person whose body is named or in
663
+ // range, with the tick's public effects in the order they were emitted. A watcher (no seat) and a seat with no
664
+ // body perceive the room: every effect with a range, never one addressed only `to` somebody.
665
+ // A tick with no audience at all is one frame for everybody, as before.
666
+ if (!x.list.some((row) => row[3])) o.send({ t: 'ev', from: null, k: 'fx', d: [x.tick, x.list] });
667
+ else {
668
+ const all = x.list.map((row) => [row[0], row[1], row[2]]);
669
+ const bodies = new Map(core.listeners().map((l) => [l.seat, l]));
670
+ const watching = all.filter((_, n) => { const a = x.list[n][3]; return !a || a[0] >= 0; });
671
+ for (const seat of present.keys()) {
672
+ const l = bodies.get(seat);
673
+ const mine = !l ? watching : all.filter((_, n) => { const a = x.list[n][3]; return !a || a[1].includes(l.id) || (a[0] >= 0 && a[2] !== null && Math.hypot(a[2][0] - l.pos.x, a[2][1] - l.pos.y, a[2][2] - l.pos.z) <= a[0]); });
674
+ if (mine.length) o.send({ t: 'ev', from: null, k: 'fx', d: [x.tick, mine], to: seat });
675
+ }
676
+ if (watching.length) o.send({ t: 'ev', from: null, k: 'fx', d: [x.tick, watching], to: -1 });
677
+ }
678
+ }
616
679
  else if (x.t === 'seats') roster = true;
617
680
  else if (x.t === 'shared') o.send({ t: 'state', k: 'shared', d: core.shared() });
618
- else if (x.t === 'epoch') { forceSave = true; epoch = x.epoch; queues.clear(); roster = true; } else if (x.t === 'fail') end('budget', { kind: x.kind, handler: x.handler });
681
+ else if (x.t === 'epoch') { forceSave = true; epoch = x.epoch; queues.clear(); heldBook.clear(); pulsesTaken.clear(); roster = true; } else if (x.t === 'fail') end('budget', { kind: x.kind, handler: x.handler });
619
682
  }
620
683
  if (roster || !rosterText) sendRoster();
621
684
  }
@@ -635,49 +698,75 @@ export function createHost(o: HostOptions): Host {
635
698
  const presses: (string | [string, number])[] = [];
636
699
  while (q.entries.length && q.entries[0].at <= t) {
637
700
  const e = q.entries.shift() as Entry;
638
- q.held = e.values; q.claim = e.claim;
701
+ q.held = e.values; q.claim = e.claim; q.early = e.early ?? 1;
639
702
  if (e.at === t) for (const p of e.presses) presses.push(p);
640
703
  }
641
704
  // Silence: no frame for a second. Its input is neutral until the next entry.
642
705
  if (t - q.lastFrameTick > tickHz && !q.entries.length) { q.held = neutral(body.kind); q.claim = null; }
643
706
  while (q.stamps.length && q.stamps[0] <= t) q.ack = q.stamps.shift() as number;
644
707
  const values = { ...q.held };
708
+ const heldFields = heldOf(body.kind);
709
+ if (heldFields.length) {
710
+ let book = heldBook.get(seat);
711
+ if (!book) { book = { ran: [], debt: {}, until: 0 }; heldBook.set(seat, book); }
712
+ const level: Record<string, number> = {};
713
+ for (const name of heldFields) level[name] = Number(values[name]) || 0;
714
+ book.ran.push([t, level]); if (book.ran.length > lateTicks + 1) book.ran.shift();
715
+ if (book.until >= t) for (const name of heldFields) { const owed = book.debt[name]; if (owed) { const range = Math.max(1, Math.abs(level[name])), want = level[name] + owed, paid = Math.max(-range, Math.min(range, want)); values[name] = paid; book.debt[name] = want - paid; } }
716
+ else book.debt = {};
717
+ }
645
718
  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];
719
+ // One pulse a tick, in order: the head waits for its own tick when that is no more than a quarter of a second ahead.
720
+ const head=q.pulses?.[0];if(head&&!((head[2]??0)>t&&(head[2] as number)-t<=Math.ceil(tickHz/4))){q.pulses!.shift();values[head[0]]=head[1];if(head[3])pulsesTaken.set(seat,head[3]);}
647
721
  inputs.set(seat, { values, claim: q.claim });
648
722
  }
649
723
  if (roundRebasePending) publishRebase(now);
650
724
  phase = 'step';
725
+ const rulesAt = timing.now();
651
726
  core.step(inputs, guideBeat);
727
+ timing.record('rules', rulesAt);
652
728
  phase = 'frames';
729
+ const framesAt = timing.now();
653
730
  const out = core.drain();
654
731
  handle(out, now);
732
+ timing.record('frames', framesAt);
655
733
  if (ended) return;
656
734
  phase = 'snapshot';
735
+ const snapshotAt = timing.now();
657
736
  const data = core.snapshot();
737
+ timing.record('snapshot', snapshotAt);
658
738
  const nextPhase = (data[0] as number[])[1];
659
739
  if (roundPhase === 1 && nextPhase === 0) forceSave = true;
660
740
  roundPhase = nextPhase;
741
+ const checkpointAt = timing.now();
661
742
  checkpoint(forceSave);
743
+ timing.record('checkpoint', checkpointAt);
744
+ const encodeAt = timing.now();
662
745
  // The control table: one row a seat, [seat, r, ack, lead].
663
746
  const rows: number[][] = [];
664
747
  for (const b of core.bodies()) {
665
748
  if (!hasHands(b.seat, b.driver)) continue;
666
749
  const q = queues.get(b.seat);
667
- rows.push([b.seat, b.r, q ? q.ack : 0, q && q.lead !== 127 ? q.lead : -128]);
750
+ // A fifth number only for a seat that has sent a numbered pulse: the newest one acted on.
751
+ const taken = pulsesTaken.get(b.seat);
752
+ rows.push(taken === undefined ? [b.seat, b.r, q ? q.ack : 0, q && q.lead !== 127 ? q.lead : -128] : [b.seat, b.r, q ? q.ack : 0, q && q.lead !== 127 ? q.lead : -128, taken]);
668
753
  if (q) q.lead = 127;
669
754
  }
670
755
  // A snapshot is stamped with the moment its tick was due, so ticks run in a burst to catch up are still a tick
671
756
  // apart to whoever interpolates between them. The stamp always rises: a replica drops one that does not.
672
757
  lastSt = Math.max(lastSt + 1, Math.round(Math.min(now, dueOf(core.tick))));
673
758
  const snap = { t: 'snap', from: null, e: epoch, k: core.tick, st: lastSt, d: data, c: rows };
674
- o.send(snap, JSON.stringify(snap));
759
+ const encoded = JSON.stringify(snap);
760
+ timing.record('encode', encodeAt);
761
+ const sendAt = timing.now();
762
+ o.send(snap, encoded);
763
+ timing.record('send', sendAt);
675
764
  stats.ticks += 1;
676
765
  stats.since += 1;
677
766
  if (now - stats.loggedAt >= 10_000) {
678
767
  // Tick figures, one line every ten seconds, never a line a tick.
679
768
  const s = core.stats;
680
- 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 } : {}) });
769
+ 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 } : {}) });
681
770
  stats.loggedAt = now; stats.since = 0; stats.late = 0; stats.ins = 0; stats.lateEntries = 0;
682
771
  }
683
772
  }
@@ -687,6 +776,7 @@ export function createHost(o: HostOptions): Host {
687
776
  * of the clock the room ends, and the log names the handler (or the fault).
688
777
  */
689
778
  function tick(now: number): void {
779
+ const tickAt = timing.now();
690
780
  const cut = core.stats.ticksCut;
691
781
  let faulted = false;
692
782
  if (persistent) pendingOutput = [];
@@ -698,8 +788,9 @@ export function createHost(o: HostOptions): Host {
698
788
  pendingOutput = null;
699
789
  faulted = true; fault(`tick ${core.tick} (${phase})`, error);
700
790
  // 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; }
791
+ if (persistent) { timing.record('tick', tickAt); stop(); o.onEnd?.('storage-or-tick-failed', { tick: core.tick }); return; }
702
792
  }
793
+ timing.record('tick', tickAt);
703
794
  if (ended) return;
704
795
  if (!faulted && core.stats.ticksCut === cut) { failingSince = null; return; }
705
796
  if (failingSince === null) failingSince = now;
@@ -753,8 +844,13 @@ export function createHost(o: HostOptions): Host {
753
844
  if (overSince !== null) {
754
845
  overTicks += 1;
755
846
  if (2 * overBlamed < overTicks) overSince = null;
756
- // 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.
757
- else if (now - overSince >= OVERRUN_MS) { end('overrun', { worst: core.stats.worst, maxUnits: core.stats.maxUnits, seconds: Math.round((now - overSince) / 100) / 10 }); return; }
847
+ // Late callbacks can catch up at the requested rate. End only a
848
+ // blamed room that also loses sustained throughput, not a healthy
849
+ // 20 Hz room whose callbacks arrive in pairs during admission/I/O.
850
+ else if (now - overSince >= OVERRUN_MS) {
851
+ 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; }
852
+ overSince = null;
853
+ }
758
854
  }
759
855
  if (o.enforceOverrun) recent.push({ who: self, at: now });
760
856
  if (recent.length > 8) recent.shift();
@@ -837,7 +933,8 @@ export function createHost(o: HostOptions): Host {
837
933
  }
838
934
 
839
935
  function save(): Uint8Array {
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] });
936
+ const reserved = new Set(core.bodies().filter(b => b.owner === 'reserved').map(b => b.seat));
937
+ 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] });
841
938
  }
842
939
  function checkpoint(force = false): void {
843
940
  if (persistent) {
@@ -888,6 +985,7 @@ export function createHost(o: HostOptions): Host {
888
985
  },
889
986
  characterReturned(account, lease) { if (characters.get(account)?.returning && characters.get(account)?.lease === lease) characters.delete(account); },
890
987
  get viewSettings() { return c.settings.view; },
988
+ get rates() { return c.settings.rates ?? null; },
891
989
  get viewRadiusM() { return c.settings.view?.radiusM ?? null; },
892
990
  get tick() { return core.tick; },
893
991
  get epoch() { return epoch; },
@@ -903,7 +1001,7 @@ export function createHost(o: HostOptions): Host {
903
1001
  try { now = o.clock.now(); tick(now); } catch (error) { fault('tickNow', error); } finally { keepTime(); }
904
1002
  },
905
1003
  save,
906
- facts: () => ({ tick: core.tick, epoch, running, paused, ended, armed, people: people(), tickHz, ...stats, core: { ...core.stats } }),
1004
+ facts: () => ({ timing: timing.facts(), tick: core.tick, epoch, running, paused, ended, armed, people: people(), tickHz, ...stats, core: { ...core.stats } }),
907
1005
  core,
908
1006
  };
909
1007
  }