@homie-rocks/studio 0.32.1 → 0.33.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.
Files changed (66) hide show
  1. package/CHANGELOG.md +57 -1
  2. package/README.md +24 -6
  3. package/agents/agents.ts +57 -24
  4. package/bin/homie-studio.mjs +3 -3
  5. package/lib/build.mjs +64 -9
  6. package/lib/cf-usage.mjs +76 -0
  7. package/lib/cloudflare.mjs +25 -2
  8. package/lib/dev.mjs +45 -4
  9. package/lib/lab-check.mjs +3 -2
  10. package/lib/mcp-tools.mjs +1 -1
  11. package/lib/mcp.mjs +1 -1
  12. package/lib/office.mjs +7 -0
  13. package/lib/perf.mjs +8 -3
  14. package/lib/player-image.mjs +40 -0
  15. package/lib/rules-build.mjs +184 -0
  16. package/lib/rules-guard.mjs +770 -0
  17. package/lib/scaffold.mjs +43 -16
  18. package/lib/shop-catalog.mjs +10 -9
  19. package/lib/shop.mjs +32 -15
  20. package/lib/studio.mjs +21 -1
  21. package/lib/template-history.json +50 -0
  22. package/lib/upgrade.mjs +19 -15
  23. package/lib/version.mjs +2 -7
  24. package/netplay/NETPLAY.md +249 -4
  25. package/netplay/netplay.ts +55 -13
  26. package/package.json +23 -1
  27. package/rules/core.ts +1250 -0
  28. package/rules/guard.ts +419 -0
  29. package/rules/host.ts +707 -0
  30. package/rules/math.ts +266 -0
  31. package/rules/pack.ts +292 -0
  32. package/rules/rules.ts +544 -0
  33. package/rules/view.ts +447 -0
  34. package/shop/SELLING.md +14 -16
  35. package/shop/SHOP.md +119 -30
  36. package/shop/shop.ts +6 -8
  37. package/site/SITE.md +119 -2
  38. package/starters/coin-dash/game.json +11 -0
  39. package/starters/coin-dash/index.html +17 -0
  40. package/starters/coin-dash/map/main.json +16 -0
  41. package/starters/coin-dash/src/move.ts +27 -0
  42. package/starters/coin-dash/src/rules.ts +86 -0
  43. package/starters/coin-dash/src/view.ts +106 -0
  44. package/starters/coin-dash/tunables.json +5 -0
  45. package/starters/ember-vale/index.html +11 -0
  46. package/worker/brain.mjs +8 -7
  47. package/worker/chat-page.mjs +1 -1
  48. package/worker/embed.mjs +135 -0
  49. package/worker/hosted.mjs +56 -0
  50. package/worker/index.mjs +413 -22
  51. package/worker/office-page.mjs +1 -0
  52. package/worker/office.mjs +24 -7
  53. package/worker/pages.mjs +189 -43
  54. package/worker/players.mjs +10 -6
  55. package/worker/referrals.mjs +192 -57
  56. package/worker/room-store.mjs +54 -0
  57. package/worker/room.mjs +294 -119
  58. package/worker/saves-shell.mjs +7 -1
  59. package/worker/schema.mjs +4 -3
  60. package/worker/shop-page.mjs +103 -37
  61. package/worker/shop-rules.mjs +156 -117
  62. package/worker/shop-store.mjs +70 -9
  63. package/worker/shop.mjs +275 -143
  64. package/worker/site.mjs +26 -5
  65. package/worker/stripe.mjs +9 -6
  66. package/worker/version.mjs +1 -1
package/rules/host.ts ADDED
@@ -0,0 +1,707 @@
1
+ /*
2
+ * host.ts — the host runtime: a rules module playing the host's part on the wire.
3
+ * =============================================================================
4
+ *
5
+ * Today one player's browser is a room's host: it runs the rules and the clock and sends everybody the state. The host
6
+ * runtime is that host with no browser in it. It takes a compiled rules module (rules.ts) and three things from its
7
+ * caller: a way to send a frame, a clock and a store. It has no transport in it and no Cloudflare in it, in the style of
8
+ * worker/room.mjs. Inside the `Table` (worker/index.mjs) the relay hands it parsed frames and fans out what it sends;
9
+ * in Node the tests and the build check drive it with a clock of their own.
10
+ *
11
+ * WHAT IT READS (`frame(m)`, today's wire shapes, NETPLAY.md section 5 and section 29):
12
+ * join, leave a seat's holder came, or its socket closed (the seat is held; the body is away)
13
+ * free a held seat was given up (the relay's hold ran out, or the owner removed the player)
14
+ * in a seat's input: `{ from, e, k, s, r }`, revision 10
15
+ * ev a command (`cmd`), or a player's speech, which is passed on unchanged
16
+ * policy the server's policy: whether bots fill, and the party's level dial
17
+ *
18
+ * WHAT IT SENDS: `snap` once a tick, with the control table; `round` and `roster` when they change; the keyed `state`
19
+ * channel carrying `shared`; `ev` frames for effects (`fx`) and relayed speech; `caps` once.
20
+ *
21
+ * THE INPUT PROTOCOL (rooms-milestone-1-design.md, section 4.4). A seat's input is a value that holds from one entry
22
+ * to the next, and every tick has exactly one step. An entry stamped ahead of the clock is kept for its tick; one more
23
+ * than a second ahead is dropped with the rest of its frame; a late one holds from the next tick, and its presses still
24
+ * fire there when that is within a quarter of a second of its stamp; nothing is applied in the past. A seat silent for
25
+ * a second is neutral until its next entry. More frames buy no extra step: the server runs one a tick whatever arrives.
26
+ *
27
+ * THE LIFE OF A ROOM (section 4.5). The tick loop is a timer chain, each tick timed against the moment it is due, so
28
+ * lateness does not add up. When the last person's socket closes the room pauses: the timer is cleared (a pending
29
+ * timer would keep the `Table` in memory and billed), the clock stops, and a person who returns finds the tick it
30
+ * paused on. Watchers and AI seats never keep the world ticking.
31
+ *
32
+ * THE CHAIN CANNOT BREAK. One rule holds whenever this module hands control back to its caller: A ROOM THAT IS RUNNING
33
+ * HAS EXACTLY ONE TIMER PENDING, AND A ROOM THAT IS PAUSED OR ENDED HAS NONE. A running room with no timer would stand
34
+ * still for good with its players connected and its object billed. So every way in (`wake`, `frame`, `start`,
35
+ * `resume`, `tickNow`) is wrapped: whatever is thrown inside it, by the core, by the caller's `send` or `log`, by the
36
+ * clock, is caught there, written to the log with the game and the build, and counted as a fault; and on the way out
37
+ * the timer is armed again, or, if the clock will not give one, the room is ended cleanly. A tick that faults is a
38
+ * failed tick, like one the budget cut short: a room whose every tick fails for two seconds ends
39
+ * (rooms-milestone-1-design.md, section 10), and the log says why.
40
+ *
41
+ * SECONDS ARE SECONDS. Both rules that end a failing room are measured on the clock, not in ticks: every tick failing
42
+ * for `FAIL_MS`, and every tick blamed for running slow for `OVERRUN_MS`. A room whose ticks take a second each ends
43
+ * after five of them, not after five seconds' worth of ticks at its rate.
44
+ * =============================================================================
45
+ */
46
+ import { useAgents, type Agents, type AgentsSaved, type Vocabulary } from '../agents/agents.ts';
47
+ import { DEFAULT_POLICY, type Netplay, type Peer, type Policy, type Slot } from '../netplay/netplay.ts';
48
+ import { createCore } from './core.ts';
49
+ import type { Core, CoreOut, Driver, SavedCore, StepInput } from './core.ts';
50
+ import { coerce, coerceFields, num, own, fromBytes, initFields, toBytes, unpackVec } from './pack.ts';
51
+ import type { Compiled, KindTable, Vec3 } from './rules.ts';
52
+
53
+ export interface HostClock {
54
+ /** Milliseconds. Inside a Worker this stands still while code runs. */
55
+ now(): number;
56
+ setTimer(fn: () => void, ms: number): unknown;
57
+ clearTimer(handle: unknown): void;
58
+ }
59
+ export interface HostOptions {
60
+ game: string;
61
+ /** The game's build, for the log. */
62
+ build?: string;
63
+ compiled: Compiled;
64
+ /** The deterministic build check answers asks with their floors on the next tick. */
65
+ check?: boolean;
66
+ /** One outgoing frame, for the relay to fan out. `text` is the frame already encoded, when the runtime encoded it. */
67
+ send(frame: Record<string, unknown>, text?: string): void;
68
+ clock: HostClock;
69
+ /** Synchronous save: the caller's storage output gate holds this tick's outgoing frames until confirmed. */
70
+ store?: { save(bytes: Uint8Array): void };
71
+ /** Table recovery reserves this epoch in storage before starting this host. Ordinary deterministic restores omit it. */
72
+ restoreEpoch?: number;
73
+ startDelayMs?: number;
74
+ onTick?(): void;
75
+ log?(line: Record<string, unknown>): void;
76
+ /** A number in [0, 1) for a fresh epoch and seed. */
77
+ random?(): number;
78
+ /** A room saved by `save()`, to carry on from. */
79
+ restore?: Uint8Array | null;
80
+ stage?: string;
81
+ onPause?(): void;
82
+ onResume?(): void;
83
+ /** The rules failed again and again (the budget on every tick for two seconds, or slow ticks for five): the room ends. */
84
+ onEnd?(why: string, facts: Record<string, unknown>): void;
85
+ }
86
+ export interface Host {
87
+ readonly tick: number;
88
+ readonly epoch: number;
89
+ readonly running: boolean;
90
+ readonly paused: boolean;
91
+ readonly people: number;
92
+ frame(m: Record<string, unknown>): void;
93
+ /** Start the tick loop (the first person has joined). */
94
+ start(): void;
95
+ pause(): void;
96
+ resume(): void;
97
+ /** Stop for good: the room has ended. */
98
+ stop(): void;
99
+ /** 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. */
100
+ tickNow(): void;
101
+ /** The whole room as bytes. */
102
+ save(): Uint8Array;
103
+ facts(): Record<string, unknown>;
104
+ readonly core: Core;
105
+ }
106
+
107
+ interface Entry { at: number; values: Record<string, unknown>; presses: string[]; claim: StepInput['claim'] }
108
+ interface SeatIn {
109
+ held: Record<string, unknown>; claim: StepInput['claim']; entries: Entry[];
110
+ /** The newest stamp this seat has sent, the stamps not yet acknowledged, and the newest acknowledged. */
111
+ newest: number; stamps: number[]; ack: number;
112
+ /** The smallest lead since the last snapshot, in sixteenths of a tick; 127 while no frame has arrived. */
113
+ lead: number; lastFrameTick: number;
114
+ }
115
+
116
+ /** Every tick failing (cut short by the budget, or faulted) for this long ends the room. */
117
+ export const FAIL_MS = 2000;
118
+ /** Every tick blamed for running longer than a period for this long ends the room. */
119
+ export const OVERRUN_MS = 5000;
120
+
121
+ /** Something thrown, as a line for the log, with nothing of the thrown value run to make it. */
122
+ function thrown(error: unknown): string {
123
+ if (typeof error === 'string') return error.slice(0, 200);
124
+ if (!(error instanceof Error)) return 'a value that is not an Error';
125
+ const d = Object.getOwnPropertyDescriptor(error, 'message');
126
+ return d && typeof d.value === 'string' ? d.value.slice(0, 200) : 'an Error with no message';
127
+ }
128
+
129
+ /** What a bot is called on the scoreboard, by its seat. */
130
+ export const BOT_NAMES = Object.freeze(['Pip', 'Juno', 'Moss', 'Rook', 'Fern', 'Dash', 'Echo', 'Wren', 'Zed', 'Nova', 'Bix', 'Tam', 'Lark', 'Odo', 'Kit', 'Sol']);
131
+ const SPEECH = /^(?:say|chat|emote)/i;
132
+ /**
133
+ * The overrun check's memory: the last few ticks that started in this isolate, whichever rooms' they were, each with
134
+ * when it started. Time inside a Worker stands still while code runs, so a tick cannot time itself; but the start of
135
+ * the next tick, in any room, shows how long it took. A tick that starts more than a period late blames the newest
136
+ * tick here that took more than a period: the one just before it, or, when that one was quick and was itself held up,
137
+ * the one before that.
138
+ */
139
+ const recent: { who: { blame(): void }; at: number }[] = [];
140
+ function blameLate(now: number, period: number): void {
141
+ for (let i = recent.length - 1; i >= 0; i -= 1) {
142
+ const ended = i + 1 < recent.length ? recent[i + 1].at : now;
143
+ if (ended - recent[i].at > period) { recent[i].who.blame(); return; }
144
+ }
145
+ }
146
+
147
+ export function createHost(o: HostOptions): Host {
148
+ const c = o.compiled;
149
+ const period = 1000 / c.settings.tickHz;
150
+ const tickHz = c.settings.tickHz;
151
+ const lateTicks = Math.ceil(tickHz / 4);
152
+ // A log that throws takes nothing down with it.
153
+ const log = (line: Record<string, unknown>): void => { try { o.log?.(line); } catch { /* nothing to say it with */ } };
154
+ const random = o.random ?? Math.random;
155
+ 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;
156
+ if (o.restore && (!saved || typeof saved !== 'object' || Array.isArray(saved))) throw new Error('saved host is not an object');
157
+ if (saved) {
158
+ const check = (ok: unknown): void => { if (!ok) throw new Error('saved host inputs are invalid'); };
159
+ const uint = (n: unknown): boolean => Number.isSafeInteger(n) && (n as number) >= 0;
160
+ check(saved.core && Array.isArray(saved.names) && Array.isArray(saved.queues));
161
+ check(saved.names.length <= c.seats && saved.queues.length <= c.seats);
162
+ for (const rows of [saved.names, saved.queues, ...(saved.inputs === undefined ? [] : [saved.inputs])]) {
163
+ check(Array.isArray(rows) && rows.every((row) => Array.isArray(row)));
164
+ check(new Set(rows.map((row) => row[0])).size === rows.length);
165
+ }
166
+ for (const [seat, name] of saved.names) check(uint(seat) && seat < c.seats && typeof name === 'string' && name.length <= 40 && !saved.core.seats?.some(s => s[0] === seat && s[2] === 'reserved'));
167
+ const claimOk = (claim: StepInput['claim']): boolean => claim === null || Boolean(claim && uint(claim.r) && ['pos', 'vel', 'heading'].every((key) => {
168
+ const v = own(claim, key); return v && ['x', 'y', 'z'].every((axis) => typeof own(v, axis) === 'number' && Number.isFinite(own(v, axis)));
169
+ }));
170
+ const valuesOk = (seat: number, values: unknown): boolean => {
171
+ const ent = saved.core.ents.find((w) => w[11] === seat);
172
+ const kind = ent && c.kinds[ent[2] as number];
173
+ return Boolean(kind && JSON.stringify(values) === JSON.stringify(coerceFields(kind.input, values, c.dims)));
174
+ };
175
+ for (const [seat, held, claim, newest, ack] of saved.queues) check(uint(seat) && seat < c.seats && valuesOk(seat, held) && claimOk(claim) && uint(newest) && uint(ack));
176
+ if (saved.inputs !== undefined) {
177
+ check(Array.isArray(saved.inputs) && saved.inputs.length <= c.seats);
178
+ for (const [seat, q] of saved.inputs) {
179
+ 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);
180
+ check(Array.isArray(q.stamps) && q.stamps.length <= 256 && q.stamps.every(uint) && Array.isArray(q.entries) && q.entries.length <= c.settings.tickHz + 1);
181
+ for (const e of q.entries) check(uint(e.at) && valuesOk(seat, e.values) && claimOk(e.claim) && Array.isArray(e.presses) && e.presses.every((p) => c.kinds.some((k) => k.input.some(([name, fd]) => name === p && fd.t === 'press'))));
182
+ }
183
+ }
184
+ }
185
+ if (saved && saved.v !== 1) throw new Error('this save was written by another version of the runtime');
186
+ const core = createCore(c, { seed: Math.floor(random() * 4294967296) >>> 0, epoch: (Math.floor(random() * 4294967295) >>> 0) + 1, restore: saved?.core ?? null, restoreEpoch: o.restoreEpoch, stage: o.stage, decisions: !o.check });
187
+ let epoch = core.epoch;
188
+ if (saved && o.restoreEpoch !== undefined) for (const body of core.bodies()) if (body.driver !== 'bot' && body.owner !== 'reserved') core.seatAway(body.seat, true);
189
+ let lastSaveAt = o.clock.now();
190
+ let saveTick = core.tick;
191
+ let forceSave = false;
192
+ let saveFailures = 0;
193
+ let retrySaveAt = 0;
194
+ let roundPhase = saved?.core.round[1] ?? 1;
195
+ let firstTick = true;
196
+ const names = new Map<number, string>(saved?.names ?? []);
197
+ /** Seats whose holder is here now, by seat, with whether it is a person (an AI never keeps the world ticking). */
198
+ const present = new Map<number, boolean>();
199
+ const queues = new Map<number, SeatIn>();
200
+ const peers = new Map<string, Peer>();
201
+ let policy: Policy = DEFAULT_POLICY;
202
+ let agents: Agents | null = null;
203
+ let savedAgents = saved?.agents;
204
+ const guideOwners = new Map(core.bodies().map((b) => [b.seat, `${b.id}/${b.owner}/${b.driver}`]));
205
+ const guideViews = new Map<number, { id: string; at: number; value: Record<string, unknown> }>();
206
+ for (const [seat, v] of (Array.isArray(saved?.guideViews) ? saved.guideViews : []).filter((v) => Array.isArray(v) && v.length === 2).slice(0, c.seats)) if (v && core.bodyOf(seat)?.id === v.id) guideViews.set(seat, { id: v.id, at: typeof v.at === 'number' && Number.isFinite(v.at) ? Math.max(0, Math.min(core.tick, v.at)) : 0, value: coerceFields(c.view, v.value, c.dims) });
207
+ const agentEvents = new Set<(m: any) => void>();
208
+ let agentInbox: Record<string, unknown>[] = [];
209
+ function agentSlots(brainsOnly = false): Slot[] {
210
+ if (policy.kind === 'humans-only') return [];
211
+ return core.bodies().filter((b) => { const k = c.kindOf[b.kind]; return b.driver === 'ai' && (!brainsOnly || Boolean(k.guide && k.think)); }).map((b) => {
212
+ const peer = [...peers.values()].find((p) => p.seat === b.seat && p.agent);
213
+ return { slot: b.seat, seat: peer ? b.seat : null, name: nameOf(b.seat, b.driver), bot: !peer,
214
+ agent: { seat: peer ? b.seat : null, role: peer?.agent?.role ?? (b.seat >= c.seats - policy.guides ? 'guide' : 'party'), hands: peer?.agent?.hands ?? 'host' } };
215
+ });
216
+ }
217
+ function agentView(seat: number): Record<string, unknown> {
218
+ const body = core.bodyOf(seat);
219
+ const old = guideViews.get(seat);
220
+ if (body && old?.id === body.id && core.tick - old.at < 2 * tickHz) return old.value;
221
+ const value = core.guide(seat) ?? {};
222
+ if (body) {
223
+ guideViews.set(seat, { id: body.id, at: core.tick, value });
224
+ o.send({ t: 'ev', k: 'agent:offer', d: { slot: seat, view: value } });
225
+ }
226
+ return value;
227
+ }
228
+ function setAgents(vocab: Vocabulary | null): void {
229
+ core.vocabulary(vocab);
230
+ agents?.stop();
231
+ agents = null;
232
+ if (!vocab) return;
233
+ const net = {
234
+ isHost: true, offline: false, hushed: false, get policy() { return policy; }, peers,
235
+ get slots() { return agentSlots(true); },
236
+ isAgent: (seat: number) => present.get(seat) === false,
237
+ on: (_kind: string, fn: (m: any) => void) => { agentEvents.add(fn); return () => agentEvents.delete(fn); },
238
+ send: (k: string, d: unknown, to?: number) => o.send({ t: 'ev', k, d, ...(to !== undefined ? { to } : {}) }),
239
+ } as unknown as Netplay;
240
+ agents = useAgents(net, vocab, { roles: ['guide', 'party'], manual: true, carryFloor: true, now: () => core.tick * period, restore: savedAgents,
241
+ view: agentView, decide: (v, ctx) => core.guide(ctx.slot, v),
242
+ });
243
+ savedAgents = undefined;
244
+ agents.on('goal', (d) => o.send({ t: 'ev', k: 'agent:goal', d }));
245
+ agents.on('ask', (d) => o.send({ t: 'ev', k: 'agent:ask', d }));
246
+ }
247
+ function guideBeat(): void {
248
+ if (!agents) return;
249
+ const bodies = core.bodies();
250
+ for (const b of bodies) {
251
+ const key = `${b.id}/${b.owner}/${b.driver}`;
252
+ if (guideOwners.has(b.seat) && guideOwners.get(b.seat) !== key) { agents.forget(b.seat); guideViews.delete(b.seat); }
253
+ guideOwners.set(b.seat, key);
254
+ }
255
+ for (const seat of guideOwners.keys()) if (!bodies.some((b) => b.seat === seat)) { agents.forget(seat); guideOwners.delete(seat); guideViews.delete(seat); }
256
+ for (const slot of agentSlots(true)) {
257
+ agentView(slot.slot);
258
+ const g = agents.goalOf(slot.slot);
259
+ // A saved or stale helper goal must pass the same boundary before it can delay the floor. Only the goal goes:
260
+ // the quiet rule, the open asks and a player's "leave me alone" belong to the companion, not to this goal.
261
+ if (g?.state === 'active' && !core.goal(slot.slot, { ...g, at: Math.round(g.at / period) })) agents.dropGoal(slot.slot);
262
+ }
263
+ const inbox = agentInbox; agentInbox = [];
264
+ for (const m of inbox) for (const fn of agentEvents) fn(m);
265
+ agents.tick();
266
+ for (const b of core.bodies()) {
267
+ const g = agents.goalOf(b.seat);
268
+ core.goal(b.seat, g?.state === 'active' ? { goal: g.goal, args: g.args, from: g.from, at: Math.round(g.at / period), asked: g.asked === true } : null);
269
+ }
270
+ }
271
+ let running = false;
272
+ let paused = Boolean(saved && o.restoreEpoch !== undefined);
273
+ let ended = false;
274
+ /** The pending timer, and whether there is one. `armed` is the truth: a clock may hand back any value as its handle. */
275
+ let timer: unknown = null;
276
+ let armed = false;
277
+ let base = { ms: 0, tick: 0 };
278
+ let lastSt = 0;
279
+ let rosterText = '';
280
+ let blamed = false;
281
+ /**
282
+ * The run of slow ticks this room is in: when it began (the start of the first tick blamed), and how many of the
283
+ * ticks since were blamed. A tick that takes a little longer than a period is not blamed every time: lateness has
284
+ * to pile up past a period first, and the clock's own slips take it away again. So a run goes on for as long as at
285
+ * least half the ticks in it were blamed, and is dropped as a passing hitch when fewer were.
286
+ */
287
+ let overSince: number | null = null;
288
+ let overTicks = 0;
289
+ let overBlamed = 0;
290
+ let lastStart = 0;
291
+ /** When the run of failed ticks this room is in began, or null. */
292
+ let failingSince: number | null = null;
293
+ /** Where in a tick the runtime is, for the log line of a fault. */
294
+ let phase = '';
295
+ let faultLoggedAt = -Infinity;
296
+ const stats = { ticks: 0, late: 0, slips: 0, since: 0, ins: 0, dropped: 0, lateEntries: 0, loggedAt: 0, faults: 0, lastFault: '' };
297
+ /** Something was thrown in the runtime itself: it is counted, and said in the log at most once a second. */
298
+ function fault(where: string, error: unknown): void {
299
+ stats.faults += 1;
300
+ stats.lastFault = `${where}: ${thrown(error)}`;
301
+ let now = 0;
302
+ try { now = o.clock.now(); } catch { now = faultLoggedAt + 1000; }
303
+ if (now - faultLoggedAt < 1000) return;
304
+ faultLoggedAt = now;
305
+ log({ ev: 'host-fault', game: o.game, ...(o.build ? { build: o.build } : {}), tick: core.tick, where, error: thrown(error), faults: stats.faults });
306
+ }
307
+
308
+ // A restored room keeps each seat's held input, its newest stamp and its acknowledgement.
309
+ for (const [seat, held, claim, newest, ack] of saved?.queues ?? []) queues.set(seat, { held, claim, entries: [], newest, stamps: [], ack, lead: 127, lastFrameTick: core.tick });
310
+ for (const [seat, q] of saved?.inputs ?? []) queues.set(seat, q);
311
+ const neutral = (kind: KindTable): Record<string, unknown> => { const v = initFields(kind.input, c.dims); for (const [name, fd] of kind.input) if (fd.t === 'press') v[name] = false; return v; };
312
+ function queueOf(seat: number, kind: KindTable): SeatIn {
313
+ let q = queues.get(seat);
314
+ if (!q) { q = { held: neutral(kind), claim: null, entries: [], newest: 0, stamps: [], ack: 0, lead: 127, lastFrameTick: core.tick }; queues.set(seat, q); }
315
+ return q;
316
+ }
317
+ const people = (): number => { let n = 0; for (const person of present.values()) if (person) n += 1; return n; };
318
+
319
+ /* ---------------------------------------------------------------- frames in */
320
+
321
+ function onIn(m: Record<string, unknown>): void {
322
+ const seat = m.from;
323
+ if (!Number.isInteger(seat) || m.e !== epoch || !Array.isArray(m.s)) { stats.dropped += 1; return; }
324
+ const body = core.bodyOf(seat as number);
325
+ if (!body || !hasHands(seat as number, body.driver)) return;
326
+ const kind = body.kind;
327
+ const q = queueOf(seat as number, kind);
328
+ const K = core.tick;
329
+ const k = num(m.k) >>> 0;
330
+ stats.ins += 1;
331
+ q.lastFrameTick = K;
332
+ // Lead: from this frame's arrival to the moment its first tick was due, in sixteenths of a tick. Negative is late.
333
+ const lead = Math.max(-127, Math.min(126, Math.round(((base.ms + (k - base.tick) * period - o.clock.now()) / period) * 16)));
334
+ if (lead < q.lead) q.lead = lead;
335
+ const r = num(m.r) & 0xffff;
336
+ let before = -1;
337
+ for (const raw of (m.s as unknown[]).slice(0, 64)) {
338
+ if (!Array.isArray(raw)) break;
339
+ const off = num(raw[0]);
340
+ if (!Number.isInteger(off) || off <= before || off > 4096) break;
341
+ before = off;
342
+ const j = (k + off) >>> 0;
343
+ // A duplicate: not above the newest stamp this seat has sent. It still counted as the player being there.
344
+ if (j <= q.newest) continue;
345
+ // Too early: more than a second ahead. This entry and the rest of the frame are dropped.
346
+ if (j > K + tickHz) { stats.dropped += 1; break; }
347
+ q.newest = j;
348
+ const values: Record<string, unknown> = {};
349
+ const presses: string[] = [];
350
+ let i = 1;
351
+ for (const [name, fd] of kind.input) {
352
+ // Input values are held to their declared types, whatever was sent.
353
+ if (fd.t === 'press') { values[name] = false; if (raw[i] === 1) presses.push(name); } else values[name] = coerce(fd, raw[i], c.dims);
354
+ i += 1;
355
+ }
356
+ 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;
357
+ q.stamps.push(j);
358
+ if (j > K) { q.entries.push({ at: j, values, presses, claim }); continue; }
359
+ // 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.
360
+ stats.lateEntries += 1;
361
+ const at = (K + 1) >>> 0;
362
+ const keep = at - j <= lateTicks ? presses : [];
363
+ const same = q.entries.find((x) => x.at === at);
364
+ if (same) { same.values = values; same.claim = claim; for (const p of keep) if (!same.presses.includes(p)) same.presses.push(p); } else { q.entries.push({ at, values, presses: keep, claim }); q.entries.sort((a, b) => a.at - b.at); }
365
+ }
366
+ if (q.stamps.length > 256) q.stamps.splice(0, q.stamps.length - 256);
367
+ }
368
+
369
+ function frame(m: Record<string, unknown>): void {
370
+ if (ended || !m || typeof m.t !== 'string') return;
371
+ // A frame that throws is dropped and counted; the room goes on, and its timer is as it should be on the way out.
372
+ try { onFrame(m); } catch (error) { fault(`frame ${m.t}`, error); } finally { keepTime(); }
373
+ }
374
+ function cleanSpeech(data: unknown): unknown {
375
+ if (!data || typeof data !== 'object' || Array.isArray(data)) return data ?? null;
376
+ const { ai, slot, seat, ...rest } = data as Record<string, unknown>;
377
+ return rest;
378
+ }
379
+ function onFrame(m: Record<string, unknown>): void {
380
+ switch (m.t) {
381
+ case 'vocabulary': setAgents((m.vocab ?? null) as Vocabulary | null); return;
382
+ case 'decided': core.answer(String(m.n ?? ''), m); return;
383
+ case 'in': return onIn(m);
384
+ case 'join': {
385
+ const p = m.peer as { seat?: unknown; name?: unknown; agent?: unknown; occ?: unknown; id?: unknown } | null;
386
+ if (!p || !Number.isInteger(p.seat)) return;
387
+ const seat = p.seat as number;
388
+ for (const [id, old] of peers) if (old.seat === seat) peers.delete(id);
389
+ peers.set(String(p.id), p as Peer);
390
+ const driver: Driver = p.agent ? 'ai' : 'person';
391
+ names.set(seat, typeof p.name === 'string' ? p.name.slice(0, 40) : '');
392
+ present.set(seat, driver === 'person');
393
+ // The relay numbers every stay in a seat (`occ`): the same number is the same holder, back.
394
+ core.seatJoin({ seat, driver, owner: `p${Number.isInteger(p.occ) ? p.occ : typeof p.id === 'string' ? p.id.slice(0, 40) : seat}` });
395
+ core.seatAway(seat, false);
396
+ queues.delete(seat);
397
+ rosterText = '';
398
+ if (people() > 0) { if (!running && !paused) start(); else if (paused) resume(); }
399
+ return;
400
+ }
401
+ case 'leave': {
402
+ if (!Number.isInteger(m.seat) || !present.has(m.seat as number)) return;
403
+ for (const [id, p] of peers) if (p.seat === m.seat) peers.delete(id);
404
+ present.delete(m.seat as number);
405
+ queues.delete(m.seat as number);
406
+ core.seatAway(m.seat as number, true);
407
+ if (running && people() === 0) pause();
408
+ return;
409
+ }
410
+ case 'free': {
411
+ if (!Number.isInteger(m.seat)) return;
412
+ names.delete(m.seat as number);
413
+ for (const [id, p] of peers) if (p.seat === m.seat) peers.delete(id);
414
+ present.delete(m.seat as number);
415
+ queues.delete(m.seat as number);
416
+ core.seatLeave(m.seat as number);
417
+ rosterText = '';
418
+ return;
419
+ }
420
+ case 'ev': {
421
+ // What a player sends is read as what it is: a kind or a command's name that is not a text is no kind and no command.
422
+ const kind = typeof m.k === 'string' ? m.k : '';
423
+ if (!Number.isInteger(m.from)) {
424
+ if (m.from === null && SPEECH.test(kind)) o.send({ t: 'ev', from: null, k: kind, d: cleanSpeech(m.d) });
425
+ return;
426
+ }
427
+ 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; }
428
+ 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; }
429
+ // Speech keeps its payload, except the identity fields only the host may attach.
430
+ if (SPEECH.test(kind)) o.send({ t: 'ev', from: m.from, k: kind, d: cleanSpeech(m.d) });
431
+ return;
432
+ }
433
+ case 'policy': {
434
+ const p = m.policy as { bots?: unknown; level?: unknown; levelMax?: unknown; skill?: { level?: unknown } } | null;
435
+ if (p) {
436
+ const raw = m.policy as Partial<Policy>;
437
+ const previous = policy;
438
+ const count = (v: unknown): number => typeof v === 'number' && Number.isFinite(v) ? Math.max(0, Math.min(c.seats - 1, Math.floor(v))) : 0;
439
+ const kind = raw.kind === 'hybrid' || raw.kind === 'beginner' || raw.kind === 'humans-only' ? raw.kind : 'open';
440
+ policy = { ...DEFAULT_POLICY, kind, bots: raw.bots === 'off' ? 'off' : 'fill',
441
+ aiSeats: kind === 'hybrid' || kind === 'beginner' ? count(raw.aiSeats) : 0, guides: kind === 'beginner' ? count(raw.guides) : 0,
442
+ brain: typeof raw.brain === 'string' && ['script', 'off', 'workers-ai', 'owner-key'].includes(raw.brain) ? raw.brain : 'script',
443
+ speech: raw.speech === 'off' || raw.speech === 'lines' ? raw.speech : 'game' };
444
+ if (previous.kind !== policy.kind || previous.aiSeats !== policy.aiSeats || previous.guides !== policy.guides) rosterText = '';
445
+ }
446
+ if (p) core.setPolicy({ bots: p.bots === 'off' ? 'off' : 'fill', level: num(own(own(p, 'skill'), 'level') ?? own(p, 'level')), levelMax: num(own(p, 'levelMax')), reserved: policy.kind === 'hybrid' || policy.kind === 'beginner' ? policy.aiSeats + policy.guides : 0 });
447
+ return;
448
+ }
449
+ default: return;
450
+ }
451
+ }
452
+
453
+ /* ---------------------------------------------------------------- frames out */
454
+
455
+ const hasHands = (seat: number, driver: Driver): boolean => driver === 'person' || (driver === 'ai' && [...peers.values()].some((p) => p.seat === seat && p.agent?.hands === 'self'));
456
+ const nameOf = (seat: number, driver: Driver): string => (driver === 'bot' || (driver === 'ai' && (core.bodies().find((b) => b.seat === seat)?.owner === 'reserved' || !names.has(seat))) ? BOT_NAMES[seat % BOT_NAMES.length] : names.get(seat) || `Player ${seat + 1}`);
457
+ function sendRoster(): void {
458
+ const guides = new Map(agentSlots().map((s) => [s.slot, s.agent]));
459
+ const slots = core.bodies().sort((a, b) => a.seat - b.seat).map((b) => ({ slot: b.seat, seat: b.driver === 'bot' ? null : b.seat, name: nameOf(b.seat, b.driver), bot: b.driver === 'bot', ...(guides.has(b.seat) ? { agent: guides.get(b.seat) } : {}) }));
460
+ const text = JSON.stringify(slots);
461
+ if (text === rosterText) return;
462
+ rosterText = text;
463
+ o.send({ t: 'roster', slots });
464
+ }
465
+ function handle(out: CoreOut[], now: number): void {
466
+ let roster = false;
467
+ for (const x of out) {
468
+ if (x.t === 'round') {
469
+ if (x.phase === 'over') forceSave = true;
470
+ const ms = (t: number): number => Math.round(now + (t - core.tick) * period);
471
+ const drivers = new Map(core.bodies().map((b) => [b.seat, b.driver]));
472
+ o.send({ t: 'round', round: {
473
+ n: x.n, phase: x.phase, startedAt: ms(x.startedAt), endsAt: x.endsAt ? ms(x.endsAt) : ms(core.tick + 86_400 * tickHz),
474
+ ...(x.results ? { results: x.results.map((r) => ({ slot: r.seat, seat: r.driver === 'bot' ? null : r.seat, name: nameOf(r.seat, drivers.get(r.seat) ?? r.driver), score: r.score, bot: r.driver === 'bot', ...(r.driver === 'ai' ? { agent: true } : {}), place: r.place })) } : {}),
475
+ } });
476
+ } else if (x.t === 'ask') o.send({ t: 'decide', n: x.n, state: x.state, questions: x.questions });
477
+ else if (x.t === 'goalDone') agents?.done(x.seat, x.ok);
478
+ else if (x.t === 'fx') o.send({ t: 'ev', from: null, k: 'fx', d: [x.tick, x.list] });
479
+ else if (x.t === 'seats') roster = true;
480
+ else if (x.t === 'shared') o.send({ t: 'state', k: 'shared', d: core.shared() });
481
+ 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 });
482
+ }
483
+ if (roster || !rosterText) sendRoster();
484
+ }
485
+
486
+ /* ---------------------------------------------------------------- the tick */
487
+
488
+ function tickOnce(): void {
489
+ if (firstTick) firstTick = false;
490
+ o.onTick?.();
491
+ phase = 'inputs';
492
+ const now = o.clock.now();
493
+ const t = (core.tick + 1) >>> 0;
494
+ const inputs = new Map<number, StepInput>();
495
+ for (const [seat, q] of queues) {
496
+ const body = core.bodyOf(seat);
497
+ if (!body) continue;
498
+ const presses: string[] = [];
499
+ while (q.entries.length && q.entries[0].at <= t) {
500
+ const e = q.entries.shift() as Entry;
501
+ q.held = e.values; q.claim = e.claim;
502
+ if (e.at === t) for (const p of e.presses) presses.push(p);
503
+ }
504
+ // Silence: no frame for a second. Its input is neutral until the next entry.
505
+ if (t - q.lastFrameTick > tickHz && !q.entries.length) { q.held = neutral(body.kind); q.claim = null; }
506
+ while (q.stamps.length && q.stamps[0] <= t) q.ack = q.stamps.shift() as number;
507
+ const values = { ...q.held };
508
+ for (const p of presses) values[p] = true;
509
+ inputs.set(seat, { values, claim: q.claim });
510
+ }
511
+ phase = 'step';
512
+ core.step(inputs, guideBeat);
513
+ phase = 'frames';
514
+ const out = core.drain();
515
+ handle(out, now);
516
+ if (ended) return;
517
+ phase = 'snapshot';
518
+ const data = core.snapshot();
519
+ const nextPhase = (data[0] as number[])[1];
520
+ if (roundPhase === 1 && nextPhase === 0) forceSave = true;
521
+ roundPhase = nextPhase;
522
+ checkpoint(forceSave);
523
+ // The control table: one row a seat, [seat, r, ack, lead].
524
+ const rows: number[][] = [];
525
+ for (const b of core.bodies()) {
526
+ if (!hasHands(b.seat, b.driver)) continue;
527
+ const q = queues.get(b.seat);
528
+ rows.push([b.seat, b.r, q ? q.ack : 0, q && q.lead !== 127 ? q.lead : -128]);
529
+ if (q) q.lead = 127;
530
+ }
531
+ // A snapshot is stamped with the moment its tick was due, so ticks run in a burst to catch up are still a tick
532
+ // apart to whoever interpolates between them. The stamp always rises: a replica drops one that does not.
533
+ lastSt = Math.max(lastSt + 1, Math.round(Math.min(now, dueOf(core.tick))));
534
+ const snap = { t: 'snap', from: null, e: epoch, k: core.tick, st: lastSt, d: data, c: rows };
535
+ o.send(snap, JSON.stringify(snap));
536
+ stats.ticks += 1;
537
+ stats.since += 1;
538
+ if (now - stats.loggedAt >= 10_000) {
539
+ // Tick figures, one line every ten seconds, never a line a tick.
540
+ const s = core.stats;
541
+ 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 } : {}) });
542
+ stats.loggedAt = now; stats.since = 0; stats.late = 0; stats.ins = 0; stats.lateEntries = 0;
543
+ }
544
+ }
545
+ /**
546
+ * One tick, whatever happens in it. Anything thrown is caught here, so a tick never takes the timer chain down with
547
+ * it. A tick that faulted, or that the budget cut short, is a failed tick; when every tick has failed for `FAIL_MS`
548
+ * of the clock the room ends, and the log names the handler (or the fault).
549
+ */
550
+ function tick(now: number): void {
551
+ const cut = core.stats.ticksCut;
552
+ let faulted = false;
553
+ try { tickOnce(); } catch (error) { faulted = true; fault(`tick ${core.tick} (${phase})`, error); }
554
+ if (ended) return;
555
+ if (!faulted && core.stats.ticksCut === cut) { failingSince = null; return; }
556
+ if (failingSince === null) failingSince = now;
557
+ if (now - failingSince < FAIL_MS) return;
558
+ if (faulted) end('fault', { error: stats.lastFault, faults: stats.faults });
559
+ else { const f = core.stats.failing; const dot = f.indexOf('.'); end('budget', { kind: dot < 0 ? f : f.slice(0, dot), handler: dot < 0 ? '' : f.slice(dot + 1) }); }
560
+ }
561
+ const dueOf = (t: number): number => base.ms + (t - base.tick) * period;
562
+ function arm(): void {
563
+ if (!running || armed) return;
564
+ timer = o.clock.setTimer(wake, Math.max(0, dueOf(core.tick + 1) - o.clock.now()));
565
+ armed = true;
566
+ }
567
+ function disarm(): void {
568
+ if (!armed) return;
569
+ armed = false;
570
+ try { o.clock.clearTimer(timer); } catch (error) { fault('clearTimer', error); }
571
+ timer = null;
572
+ }
573
+ /**
574
+ * The rule of this module, put right on every way out: a running room has a timer pending, a room that is not
575
+ * running has none. A clock that will not give a timer cannot keep a room: the room ends, cleanly, and says so.
576
+ */
577
+ function keepTime(): void {
578
+ if (!running) { disarm(); return; }
579
+ if (armed) return;
580
+ try { arm(); } catch (error) { fault('setTimer', error); end('clock', { error: stats.lastFault }); }
581
+ }
582
+ function wake(): void {
583
+ armed = false;
584
+ timer = null;
585
+ if (!running) return;
586
+ try {
587
+ let n = 0;
588
+ while (running && n < 4) {
589
+ const now = o.clock.now();
590
+ const due = dueOf(core.tick + 1);
591
+ if (now + 0.5 < due) break;
592
+ // The overrun check: a tick that starts more than a period late blames the tick that held it up.
593
+ if (now - due > period) { stats.late += 1; blameLate(now, period); }
594
+ // Blamed since its last tick began: a run of slow ticks begins (when the tick blamed did), or goes on.
595
+ if (blamed) { if (overSince === null) { overSince = lastStart; overTicks = 0; overBlamed = 0; } overBlamed += 1; }
596
+ blamed = false;
597
+ if (overSince !== null) {
598
+ overTicks += 1;
599
+ if (2 * overBlamed < overTicks) overSince = null;
600
+ // 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.
601
+ else if (now - overSince >= OVERRUN_MS) { end('overrun', { worst: core.stats.worst, maxUnits: core.stats.maxUnits, seconds: Math.round((now - overSince) / 100) / 10 }); return; }
602
+ }
603
+ recent.push({ who: self, at: now });
604
+ if (recent.length > 8) recent.shift();
605
+ lastStart = now;
606
+ tick(now);
607
+ n += 1;
608
+ }
609
+ // Far behind (the isolate was busy, or the machine slept): the clock runs slow. It never jumps to catch up.
610
+ // The tick after a slip starts on time by the new clock, so the tick that ran last is judged here, before the slip hides it.
611
+ if (running && o.clock.now() - dueOf(core.tick + 1) > 4 * period) { const now = o.clock.now(); blameLate(now, period); base = { ms: now, tick: core.tick }; stats.slips += 1; }
612
+ } catch (error) {
613
+ // Thrown outside any tick (the clock, the blame): a failed turn of the loop, counted like a failed tick.
614
+ fault('wake', error);
615
+ if (failingSince === null) failingSince = lastStart;
616
+ let now = lastStart + FAIL_MS;
617
+ try { now = o.clock.now(); } catch { /* a clock that cannot be read: the room has failed for as long as the rule needs */ }
618
+ if (now - failingSince >= FAIL_MS) end('fault', { error: stats.lastFault, faults: stats.faults });
619
+ } finally { keepTime(); }
620
+ }
621
+ const self = { blame: (): void => { blamed = true; } };
622
+
623
+ function start(): void {
624
+ if (running || ended) return;
625
+ running = true; paused = false;
626
+ try {
627
+ base = { ms: o.clock.now() + (firstTick ? o.startDelayMs ?? 0 : 0), tick: core.tick };
628
+ overSince = null; blamed = false; failingSince = null; lastStart = base.ms;
629
+ const caps = c.kinds.some((k) => k.think) ? ['skill'] : [];
630
+ if (c.kinds.some((k) => k.guide && k.think)) caps.push('agents');
631
+ if (caps.length) o.send({ t: 'caps', caps });
632
+ log({ ev: 'host-start', game: o.game, tick: core.tick, epoch, tickHz });
633
+ } catch (error) { fault('start', error); } finally { keepTime(); }
634
+ }
635
+ function pause(): void {
636
+ if (!running) return;
637
+ running = false; paused = true;
638
+ // A pending timer would keep the object in memory and billed: the pause clears it.
639
+ disarm();
640
+ log({ ev: 'host-pause', game: o.game, tick: core.tick });
641
+ try { checkpoint(true); o.onPause?.(); } catch (error) { fault('onPause', error); }
642
+ }
643
+ function resume(): void {
644
+ if (running || ended || !paused) return;
645
+ running = true; paused = false;
646
+ try {
647
+ // The room resumes at the tick it paused on: no tick is skipped and no timer fires for the gap.
648
+ base = { ms: o.clock.now() + (firstTick ? o.startDelayMs ?? 0 : 0), tick: core.tick };
649
+ overSince = null; blamed = false; failingSince = null; lastStart = base.ms;
650
+ for (const q of queues.values()) q.lastFrameTick = core.tick;
651
+ log({ ev: 'host-resume', game: o.game, tick: core.tick });
652
+ o.onResume?.();
653
+ } catch (error) { fault('resume', error); } finally { keepTime(); }
654
+ }
655
+ function stop(): void {
656
+ running = false; paused = false; ended = true;
657
+ disarm();
658
+ agents?.stop();
659
+ for (let i = recent.length - 1; i >= 0; i -= 1) if (recent[i].who === self) recent.splice(i, 1);
660
+ }
661
+ function end(why: string, facts: Record<string, unknown>): void {
662
+ if (ended) return;
663
+ // Stopped first: whatever the log or the caller's `onEnd` do, the room is over and holds no timer.
664
+ stop();
665
+ log({ ev: 'host-ended', game: o.game, ...(o.build ? { build: o.build } : {}), why, tick: core.tick, ...facts });
666
+ try { o.onEnd?.(why, facts); } catch (error) { fault('onEnd', error); }
667
+ }
668
+
669
+ function save(): Uint8Array {
670
+ 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] });
671
+ }
672
+ function checkpoint(force = false): void {
673
+ const seconds = c.settings.durability.movementSeconds;
674
+ // Catch-up ticks can cover a second of play in less than a second of wall time. Bound both clocks.
675
+ if (!o.store || !force && (core.tick === saveTick || o.clock.now() - lastSaveAt < seconds * 1000 && core.tick - saveTick < seconds * tickHz)) return;
676
+ if (o.clock.now() < retrySaveAt) return;
677
+ try { o.store.save(save()); }
678
+ catch (error) {
679
+ saveFailures += 1;
680
+ retrySaveAt = o.clock.now() + Math.min(60_000, 1000 * 2 ** Math.min(saveFailures - 1, 6));
681
+ log({ ev: 'persist-failed', game: o.game, build: o.build, error: thrown(error), retryAt: retrySaveAt });
682
+ return;
683
+ }
684
+ saveFailures = 0; retrySaveAt = 0;
685
+ saveTick = core.tick; lastSaveAt = o.clock.now(); forceSave = false;
686
+ }
687
+
688
+ return {
689
+ get tick() { return core.tick; },
690
+ get epoch() { return epoch; },
691
+ get running() { return running; },
692
+ get paused() { return paused; },
693
+ get people() { return people(); },
694
+ frame, start, pause, resume, stop,
695
+ tickNow: () => {
696
+ if (ended) return;
697
+ let now = lastStart;
698
+ try { now = o.clock.now(); tick(now); } catch (error) { fault('tickNow', error); } finally { keepTime(); }
699
+ },
700
+ save,
701
+ facts: () => ({ tick: core.tick, epoch, running, paused, ended, armed, people: people(), tickHz, ...stats, core: { ...core.stats } }),
702
+ core,
703
+ };
704
+ }
705
+
706
+ /** An owner-moved body's claim as an `in` entry carries it, after the input fields: nine numbers (for callers that build frames by hand: the bot client, the tests). */
707
+ export const claimOf = (p: Vec3, v: Vec3, h: Vec3): number[] => [p.x, p.y, p.z, v.x, v.y, v.z, h.x, h.y, h.z];