@pithy-sh/multiplayer 0.1.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.
@@ -0,0 +1,604 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { DurableObject } from "cloudflare:workers";
5
+ import type { D1Database } from "@cloudflare/workers-types";
6
+ import { clientError } from "@pithy-sh/core/src/error/client";
7
+ import { InternalError, messageOf, PithyError, ValidationError } from "@pithy-sh/core/src/error/pithyError";
8
+ import type { Logger } from "@pithy-sh/core/src/logger/logger";
9
+ import { createWorkerLogger } from "@pithy-sh/core/src/logger/worker";
10
+ import type { MultiplayerResult } from "../data/result";
11
+ import { resultStore } from "../data/store";
12
+ import { multiplayerDatabase } from "../data/tables";
13
+ import "../game/builtins";
14
+ import {
15
+ MultiplayerGameNotFoundError,
16
+ MultiplayerInvalidTransitionError,
17
+ MultiplayerNotAMemberError,
18
+ MultiplayerSessionFullError,
19
+ MultiplayerSessionNotFoundError,
20
+ } from "../error/errors";
21
+ import { applyLedgerEffects } from "../game/effects";
22
+ import { type GameContext, type GameModel, resolveModel } from "../game/model";
23
+ import { createRngState, type RandomSource, randomSource } from "../game/random";
24
+ import { RPC_ERROR_PREFIX, USER_HEADER } from "./protocol";
25
+ import { type GameSnapshot, isTerminal, SessionMeta, type SessionOutcome, type SessionView } from "./state";
26
+
27
+ /** The Worker env a session DO reads — the app `DB` database it writes its terminal result to. */
28
+ export interface MultiplayerSessionEnv {
29
+ DB: D1Database;
30
+ }
31
+
32
+ const META_KEY = "meta";
33
+ const OUTCOME_KEY = "outcome";
34
+ const MODEL_STATE_KEY = "modelState";
35
+
36
+ /**
37
+ * Any publish failure as a `PithyError`, so the log record carries a typed payload — code, message, and the
38
+ * cause text in `detail` — instead of a stringified throw. A `PithyError` from the leaderboard keeps its own
39
+ * code; a bare D1 error (a missing table, a busy database) has no meaningful public code, so it becomes
40
+ * `core/internal` (CLAUDE.md §Errors).
41
+ */
42
+ function publishFailure(error: unknown): PithyError {
43
+ if (error instanceof PithyError) return error;
44
+ return new InternalError({ message: "The leaderboard publish failed.", detail: messageOf(error) }, { cause: error });
45
+ }
46
+
47
+ /** A message a client sends over the WebSocket — take an action, or ask for the current state. */
48
+ interface ClientMessage {
49
+ type: "action" | "state";
50
+ payload?: unknown;
51
+ }
52
+
53
+ /**
54
+ * One error, as a frame on a player's socket. Every error the socket sends is built here, and the payload
55
+ * goes through core's `clientError` — the same projection the HTTP codec encodes through, because a socket
56
+ * carries the error to the same browser an HTTP body would. `action` and `detail` are an operator's, and
57
+ * this transport has no operator on the other end of it.
58
+ *
59
+ * A `PithyError` is required rather than a loose shape on purpose: the frame cannot be hand-written, so
60
+ * there is no second definition of what a client may read (#344).
61
+ */
62
+ function errorFrame(error: PithyError): string {
63
+ return JSON.stringify({ type: "error", error: clientError(error.payload) });
64
+ }
65
+
66
+ /**
67
+ * One authoritative game session — Pithy's first Durable Object, and entirely game-agnostic.
68
+ *
69
+ * The session owns everything a relay cannot: membership bound to an authenticated user id (never
70
+ * client-asserted), a lifecycle, hidden per-player state, an alarm-driven deadline, a durable D1 result, and
71
+ * a one-way leaderboard publish. What a *game* is — how a move validates, how state advances, when it ends,
72
+ * who wins, and what each player may see — lives entirely behind a {@link GameModel} resolved from the
73
+ * registry by the game's `kind`. Swapping `commit-reveal` for `sequential`, or an adopter's own model,
74
+ * changes nothing in this object.
75
+ *
76
+ * Platform discipline (the acceptance criteria): the WebSocket Hibernation API only (never `ws.accept()`);
77
+ * no timers (the deadline is an alarm at an absolute ms-epoch time); in-memory state reset on wake (every
78
+ * handler reads storage fresh); and idempotent terminal transitions (a retry re-reads a terminal phase and
79
+ * no-ops; the D1 write is a no-op on conflict).
80
+ */
81
+ export class MultiplayerSession extends DurableObject<MultiplayerSessionEnv> {
82
+ /**
83
+ * The session's own logger. `c.var.log` belongs to a `fetch` — a DO method called over RPC, and an alarm
84
+ * that fires with no caller at all, have no request to take one from — so the object builds its own, the
85
+ * way the support inbound handler does. The name scopes every record to this capability.
86
+ */
87
+ private readonly log: Logger = createWorkerLogger({ name: "multiplayer:session" });
88
+
89
+ /** The DO's own id is the session id — stable, unguessable, and the D1 result's natural key. */
90
+ private get sessionId(): string {
91
+ return this.ctx.id.toString();
92
+ }
93
+
94
+ private async loadMeta(): Promise<SessionMeta | undefined> {
95
+ const raw = await this.ctx.storage.get(META_KEY);
96
+ return raw === undefined ? undefined : SessionMeta.parse(raw);
97
+ }
98
+
99
+ private async loadOutcome(): Promise<SessionOutcome | null> {
100
+ const raw = await this.ctx.storage.get(OUTCOME_KEY);
101
+ return raw === undefined ? null : (raw as SessionOutcome);
102
+ }
103
+
104
+ /** Resolve the game's model, or fail if its `kind` isn't registered (a stored game whose model was removed). */
105
+ private model(meta: SessionMeta): GameModel {
106
+ const model = resolveModel(meta.game.kind);
107
+ if (!model) {
108
+ throw new MultiplayerGameNotFoundError({
109
+ detail: `Session ${this.sessionId}: no model for kind "${meta.game.kind}".`,
110
+ });
111
+ }
112
+ return model;
113
+ }
114
+
115
+ /**
116
+ * The context a model gets — its validated config, the roster, the clock, and a seeded RNG — plus the
117
+ * `rng` handle the DO reads back to persist the advanced cursor. A model that draws randomness advances
118
+ * the cursor; the caller stores `meta.rng.cursor = rng.spent()` so the stream never repeats.
119
+ */
120
+ private makeContext(
121
+ meta: SessionMeta,
122
+ model: GameModel,
123
+ ): { ctx: GameContext<unknown>; rng: RandomSource & { spent(): number } } {
124
+ const rng = randomSource(meta.rng);
125
+ const ctx: GameContext<unknown> = {
126
+ sessionId: this.sessionId,
127
+ config: model.config.parse(meta.game.rules),
128
+ players: meta.members,
129
+ now: Date.now(),
130
+ random: rng,
131
+ };
132
+ return { ctx, rng };
133
+ }
134
+
135
+ /** The persisted model state, validated on read, or undefined before play begins. */
136
+ private async loadModelState(model: GameModel): Promise<unknown> {
137
+ const raw = await this.ctx.storage.get(MODEL_STATE_KEY);
138
+ return raw === undefined ? undefined : model.state.parse(raw);
139
+ }
140
+
141
+ /** The redacted view for one player — the only shape that ever leaves the object. */
142
+ private async snapshot(userId: string): Promise<SessionView> {
143
+ const meta = await this.loadMeta();
144
+ if (!meta) throw new MultiplayerSessionNotFoundError({ detail: `Session ${this.sessionId} has no metadata.` });
145
+ const model = this.model(meta);
146
+ const outcome = await this.loadOutcome();
147
+ const modelState = await this.loadModelState(model);
148
+ const terminal = isTerminal(meta.phase);
149
+ const state =
150
+ modelState === undefined ? null : model.redact(this.makeContext(meta, model).ctx, modelState, userId, terminal);
151
+ return {
152
+ sessionId: meta.sessionId,
153
+ gameKey: meta.game.key,
154
+ kind: meta.game.kind,
155
+ phase: meta.phase,
156
+ players: meta.members,
157
+ outcome,
158
+ state,
159
+ // Commit always; reveal the seed only once terminal, so a player can verify every draw after the fact.
160
+ fairness: { seedHash: meta.rng.seedHash, seed: terminal ? meta.rng.seed : null },
161
+ };
162
+ }
163
+
164
+ // --- RPC surface: the authenticated Hono handler calls these, passing the AuthContext user id ---
165
+
166
+ /**
167
+ * Run an RPC method body, converting a thrown `PithyError` into a bare Error whose message carries the
168
+ * JSON payload (behind {@link RPC_ERROR_PREFIX}) so it survives the RPC boundary. The route revives it;
169
+ * the WebSocket handler calls the internal `apply*` methods directly and keeps the real `PithyError`.
170
+ */
171
+ private async guard<T>(run: () => Promise<T>): Promise<T> {
172
+ try {
173
+ return await run();
174
+ } catch (error) {
175
+ if (error instanceof PithyError) throw new Error(RPC_ERROR_PREFIX + JSON.stringify(error.payload));
176
+ throw error;
177
+ }
178
+ }
179
+
180
+ /** Create the session (RPC entry). */
181
+ async create(game: GameSnapshot, creatorUserId: string): Promise<SessionView> {
182
+ return this.guard(() => this.applyCreate(game, creatorUserId));
183
+ }
184
+
185
+ /** Join the session (RPC entry). */
186
+ async join(userId: string): Promise<SessionView> {
187
+ return this.guard(() => this.applyJoin(userId));
188
+ }
189
+
190
+ /** Take a game action (RPC entry) — a commit, a move, whatever the game's model defines. */
191
+ async action(userId: string, payload: unknown): Promise<SessionView> {
192
+ return this.guard(() => this.applyAction(userId, payload));
193
+ }
194
+
195
+ /** Leave a table seat (RPC entry, table mode). */
196
+ async leave(userId: string): Promise<SessionView> {
197
+ return this.guard(() => this.applyLeave(userId));
198
+ }
199
+
200
+ /** Close a table (RPC entry, table mode). */
201
+ async close(userId: string): Promise<SessionView> {
202
+ return this.guard(() => this.applyClose(userId));
203
+ }
204
+
205
+ /** The redacted view for the authenticated player (RPC entry). */
206
+ async view(userId: string): Promise<SessionView> {
207
+ return this.guard(() => this.snapshot(userId));
208
+ }
209
+
210
+ /** The durable result once the session is terminal, or null while it is still live. */
211
+ async result(): Promise<MultiplayerResult | null> {
212
+ return (await resultStore(multiplayerDatabase(this.env.DB)).get(this.sessionId)) ?? null;
213
+ }
214
+
215
+ /**
216
+ * The user ids with a live (hibernatable) WebSocket right now — read from each socket's attachment, not
217
+ * memory, so it is correct across hibernation. Presence, and the seam a test uses to prove the Hibernation
218
+ * API is in play.
219
+ */
220
+ connectedUserIds(): string[] {
221
+ return this.ctx
222
+ .getWebSockets()
223
+ .map((ws) => (ws.deserializeAttachment() as { userId?: string } | null)?.userId)
224
+ .filter((id): id is string => Boolean(id));
225
+ }
226
+
227
+ /** Create the session. Called once, by the authenticated creator, who becomes its first member. */
228
+ private async applyCreate(game: GameSnapshot, creatorUserId: string): Promise<SessionView> {
229
+ if (await this.loadMeta()) {
230
+ throw new MultiplayerInvalidTransitionError({ detail: `Session ${this.sessionId} already exists.` });
231
+ }
232
+ const meta: SessionMeta = {
233
+ sessionId: this.sessionId,
234
+ game,
235
+ // A table opens for play immediately (the creator takes the first seat); a match waits for its roster.
236
+ phase: game.mode === "table" ? "active" : "open",
237
+ members: [creatorUserId],
238
+ createdAt: Date.now(),
239
+ deadline: null,
240
+ // Mint the provably-fair seed now and commit its hash, before any player can act.
241
+ rng: await createRngState(),
242
+ };
243
+ if (game.mode === "table") {
244
+ // Initialize the table's model state now, with the creator seated.
245
+ const model = this.model(meta);
246
+ const { ctx, rng } = this.makeContext(meta, model);
247
+ await this.ctx.storage.put(MODEL_STATE_KEY, model.init(ctx));
248
+ meta.rng.cursor = rng.spent();
249
+ }
250
+ await this.ctx.storage.put(META_KEY, meta);
251
+ return this.snapshot(creatorUserId);
252
+ }
253
+
254
+ /**
255
+ * Join as the authenticated player. Idempotent for an existing member. When the roster fills, the model's
256
+ * initial state is built, the phase advances to `active`, and — if the game sets a `turnTimeoutMs` — the
257
+ * deadline alarm is armed.
258
+ */
259
+ private async applyJoin(userId: string): Promise<SessionView> {
260
+ const meta = await this.loadMeta();
261
+ if (!meta) throw new MultiplayerSessionNotFoundError({ detail: `Join: session ${this.sessionId} not found.` });
262
+ if (meta.members.includes(userId)) return this.snapshot(userId);
263
+ if (isTerminal(meta.phase)) {
264
+ throw new MultiplayerInvalidTransitionError({ detail: `Join: session ${this.sessionId} is ${meta.phase}.` });
265
+ }
266
+ if (meta.members.length >= meta.game.players) {
267
+ throw new MultiplayerSessionFullError({
268
+ detail: `Join: session ${this.sessionId} already has ${meta.game.players} ${meta.game.mode === "table" ? "seats" : "players"}.`,
269
+ });
270
+ }
271
+
272
+ meta.members.push(userId);
273
+ if (meta.game.mode === "table") {
274
+ // A table is already active; the player takes an open seat and the model deals them in.
275
+ const model = this.model(meta);
276
+ const { ctx, rng } = this.makeContext(meta, model);
277
+ if (model.onJoin) {
278
+ const state = await this.loadModelState(model);
279
+ const seated = model.onJoin(ctx, state, userId);
280
+ await applyLedgerEffects(this.env.DB, seated.effects ?? []);
281
+ await this.ctx.storage.put(MODEL_STATE_KEY, seated.state);
282
+ }
283
+ meta.rng.cursor = rng.spent();
284
+ } else if (meta.members.length === meta.game.players) {
285
+ // A match's roster just filled — start play.
286
+ const model = this.model(meta);
287
+ const { ctx, rng } = this.makeContext(meta, model);
288
+ const modelState = model.init(ctx);
289
+ meta.rng.cursor = rng.spent(); // persist any randomness init drew (a shuffle, an opening roll)
290
+ await this.ctx.storage.put(MODEL_STATE_KEY, modelState);
291
+ meta.phase = "active";
292
+ if (meta.game.turnTimeoutMs !== null) {
293
+ meta.deadline = Date.now() + meta.game.turnTimeoutMs;
294
+ await this.ctx.storage.setAlarm(meta.deadline);
295
+ }
296
+ }
297
+ await this.ctx.storage.put(META_KEY, meta);
298
+ await this.broadcast();
299
+ return this.snapshot(userId);
300
+ }
301
+
302
+ /**
303
+ * Apply the authenticated player's action. Legal only while `active` and only for a member; the game's
304
+ * model validates the action itself (an illegal move never persists). When the model reports the game
305
+ * complete, the session resolves.
306
+ */
307
+ private async applyAction(userId: string, payload: unknown): Promise<SessionView> {
308
+ const meta = await this.loadMeta();
309
+ if (!meta) throw new MultiplayerSessionNotFoundError({ detail: `Action: session ${this.sessionId} not found.` });
310
+ if (!meta.members.includes(userId)) {
311
+ throw new MultiplayerNotAMemberError({ detail: `Action: ${userId} is not a member of ${this.sessionId}.` });
312
+ }
313
+ if (meta.phase !== "active") {
314
+ const reason = meta.phase === "open" ? "still waiting for players" : `over (${meta.phase})`;
315
+ throw new MultiplayerInvalidTransitionError({
316
+ message: `You can't act — the session is ${reason}.`,
317
+ detail: `Action: session ${this.sessionId} is in phase ${meta.phase}.`,
318
+ });
319
+ }
320
+
321
+ const model = this.model(meta);
322
+ const { ctx, rng } = this.makeContext(meta, model);
323
+ const state = await this.loadModelState(model);
324
+ const { state: next, effects } = model.apply(ctx, state, userId, payload);
325
+
326
+ // Settle the action's ledger effects (a bet's hold) BEFORE committing the new state: a hold the player
327
+ // cannot cover throws here, so the action is rejected and the game state never advances.
328
+ await applyLedgerEffects(this.env.DB, effects ?? []);
329
+ await this.ctx.storage.put(MODEL_STATE_KEY, next);
330
+ meta.rng.cursor = rng.spent(); // persist any randomness the action drew (a dice roll, a card draw)
331
+
332
+ // A match ends when the model reports the game complete. A table never ends on a completed round — the
333
+ // model loops rounds internally and settles each via effects; the table ends only on close or empty.
334
+ if (meta.game.mode === "match" && model.isComplete(ctx, next)) {
335
+ const resolved = model.resolve(ctx, next);
336
+ await applyLedgerEffects(this.env.DB, resolved.effects ?? []); // final payouts
337
+ // resolve persists meta (with the advanced cursor) as it commits the terminal state.
338
+ await this.resolve(meta, resolved.outcome);
339
+ } else {
340
+ await this.ctx.storage.put(META_KEY, meta);
341
+ }
342
+ await this.broadcast();
343
+ return this.snapshot(userId);
344
+ }
345
+
346
+ /**
347
+ * Leave a table seat (table mode only). The model settles the leaver out (releasing their open holds via
348
+ * effects); when the last player leaves, the table closes. Idempotent for a non-member.
349
+ */
350
+ private async applyLeave(userId: string): Promise<SessionView> {
351
+ const meta = await this.loadMeta();
352
+ if (!meta) throw new MultiplayerSessionNotFoundError({ detail: `Leave: session ${this.sessionId} not found.` });
353
+ if (meta.game.mode !== "table") {
354
+ throw new MultiplayerInvalidTransitionError({
355
+ message: "You can only leave a table.",
356
+ detail: `Leave on a ${meta.game.mode} session.`,
357
+ });
358
+ }
359
+ if (isTerminal(meta.phase) || !meta.members.includes(userId)) return this.snapshot(userId);
360
+
361
+ meta.members = meta.members.filter((member) => member !== userId);
362
+ const model = this.model(meta);
363
+ const { ctx, rng } = this.makeContext(meta, model); // ctx.players already excludes the leaver
364
+ if (model.onLeave) {
365
+ const state = await this.loadModelState(model);
366
+ const settled = model.onLeave(ctx, state, userId);
367
+ await applyLedgerEffects(this.env.DB, settled.effects ?? []);
368
+ await this.ctx.storage.put(MODEL_STATE_KEY, settled.state);
369
+ }
370
+ meta.rng.cursor = rng.spent();
371
+
372
+ if (meta.members.length === 0) {
373
+ await this.commitTerminal(meta, {
374
+ status: "resolved",
375
+ scores: null,
376
+ winnerUserId: null,
377
+ draw: false,
378
+ resolvedAt: Date.now(),
379
+ });
380
+ } else {
381
+ await this.ctx.storage.put(META_KEY, meta);
382
+ }
383
+ await this.broadcast();
384
+ return this.snapshot(userId);
385
+ }
386
+
387
+ /** Close a table (table mode only), ending the session. Idempotent once terminal. */
388
+ private async applyClose(userId: string): Promise<SessionView> {
389
+ const meta = await this.loadMeta();
390
+ if (!meta) throw new MultiplayerSessionNotFoundError({ detail: `Close: session ${this.sessionId} not found.` });
391
+ if (meta.game.mode !== "table") {
392
+ throw new MultiplayerInvalidTransitionError({
393
+ message: "You can only close a table.",
394
+ detail: `Close on a ${meta.game.mode} session.`,
395
+ });
396
+ }
397
+ if (!meta.members.includes(userId)) {
398
+ throw new MultiplayerNotAMemberError({ detail: `Close: ${userId} is not at table ${this.sessionId}.` });
399
+ }
400
+ if (isTerminal(meta.phase)) return this.snapshot(userId);
401
+ await this.commitTerminal(meta, {
402
+ status: "resolved",
403
+ scores: null,
404
+ winnerUserId: null,
405
+ draw: false,
406
+ resolvedAt: Date.now(),
407
+ });
408
+ await this.broadcast();
409
+ return this.snapshot(userId);
410
+ }
411
+
412
+ // --- Resolution and abandonment: the terminal transitions, both idempotent ---
413
+
414
+ /** Resolve a complete game: persist the outcome (result-first), then publish to the leaderboard (best-effort). */
415
+ private async resolve(
416
+ meta: SessionMeta,
417
+ outcome: { scores: Record<string, number>; winnerUserId: string | null; draw: boolean },
418
+ ): Promise<void> {
419
+ if (isTerminal(meta.phase)) return;
420
+ const resolved: SessionOutcome = { status: "resolved", ...outcome, resolvedAt: Date.now() };
421
+ await this.commitTerminal(meta, resolved);
422
+ // Best-effort, and last: a leaderboard publish must never fail the action that resolved the game, the
423
+ // same way the audit-emit seam is non-fatal by contract (CLAUDE.md §Security).
424
+ await this.publishResult(meta, resolved);
425
+ }
426
+
427
+ /** Abandon a session whose action deadline passed. Terminal, unscored, idempotent. */
428
+ private async abandon(meta: SessionMeta): Promise<void> {
429
+ if (isTerminal(meta.phase)) return;
430
+ await this.commitTerminal(meta, {
431
+ status: "abandoned",
432
+ scores: null,
433
+ winnerUserId: null,
434
+ draw: false,
435
+ resolvedAt: Date.now(),
436
+ });
437
+ }
438
+
439
+ /**
440
+ * Persist a terminal outcome, durable-result-first: the D1 write (idempotent on `sessionId`) comes before
441
+ * the phase flips to terminal, so a transient D1 failure leaves the session live and retryable rather than
442
+ * a `resolved` session with no recoverable result row.
443
+ */
444
+ private async commitTerminal(meta: SessionMeta, outcome: SessionOutcome): Promise<void> {
445
+ await this.writeResult(meta, outcome);
446
+ meta.phase = outcome.status;
447
+ meta.deadline = null;
448
+ await this.ctx.storage.put(META_KEY, meta);
449
+ await this.ctx.storage.put(OUTCOME_KEY, outcome);
450
+ await this.ctx.storage.deleteAlarm();
451
+ }
452
+
453
+ /** Write the terminal result to D1 — idempotent on sessionId, so a retry never duplicates it. */
454
+ private async writeResult(meta: SessionMeta, outcome: SessionOutcome): Promise<void> {
455
+ await resultStore(multiplayerDatabase(this.env.DB)).write({
456
+ id: 0,
457
+ sessionId: meta.sessionId,
458
+ gameKey: meta.game.key,
459
+ status: outcome.status,
460
+ players: meta.members,
461
+ scores: outcome.scores,
462
+ winnerUserId: outcome.winnerUserId,
463
+ draw: outcome.draw,
464
+ createdAt: new Date(meta.createdAt),
465
+ resolvedAt: new Date(outcome.resolvedAt),
466
+ });
467
+ }
468
+
469
+ /**
470
+ * Publish a resolved result to the leaderboard, one-way and best-effort — only when the game configured a
471
+ * board. Loaded by dynamic import so `@pithy-sh/leaderboard` stays an optional peer.
472
+ */
473
+ private async publishResult(meta: SessionMeta, outcome: SessionOutcome): Promise<void> {
474
+ const leaderboard = meta.game.leaderboard;
475
+ if (!leaderboard || outcome.status !== "resolved" || outcome.scores === null) return;
476
+ try {
477
+ const { publishResultToLeaderboard } = await import("../publish/leaderboard");
478
+ await publishResultToLeaderboard(this.env.DB, leaderboard, {
479
+ members: meta.members,
480
+ winnerUserId: outcome.winnerUserId,
481
+ draw: outcome.draw,
482
+ at: new Date(outcome.resolvedAt),
483
+ });
484
+ } catch (error) {
485
+ // A board misconfigured (or leaderboard not installed) must not fail a resolved session. The result is
486
+ // already durable in D1; only the leaderboard entry is lost. Lost, and never retried — so this is a
487
+ // failure someone has to see and fix, not a degraded path that heals itself. It logs at `error`, and
488
+ // this record is the only trace it ever happened.
489
+ this.log.error("leaderboard publish failed", {
490
+ session: meta.sessionId,
491
+ board: leaderboard.board,
492
+ error: publishFailure(error),
493
+ });
494
+ }
495
+ }
496
+
497
+ // --- Alarm: the action deadline. Single persisted schedule, absolute ms-epoch, idempotent handler ---
498
+
499
+ override async alarm(): Promise<void> {
500
+ const meta = await this.loadMeta();
501
+ // Idempotent: a terminal session (or one with no armed deadline) is a no-op. Cloudflare fires an alarm
502
+ // only at or after its scheduled time, so reaching here means the deadline has passed.
503
+ if (!meta || isTerminal(meta.phase) || meta.deadline === null) return;
504
+ const model = this.model(meta);
505
+ const { ctx } = this.makeContext(meta, model);
506
+ const state = await this.loadModelState(model);
507
+ if (state !== undefined && model.isComplete(ctx, state)) {
508
+ // The final action raced the deadline and won — resolve rather than abandon.
509
+ const resolved = model.resolve(ctx, state);
510
+ await applyLedgerEffects(this.env.DB, resolved.effects ?? []);
511
+ await this.resolve(meta, resolved.outcome);
512
+ } else {
513
+ await this.abandon(meta);
514
+ }
515
+ await this.broadcast();
516
+ }
517
+
518
+ // --- WebSocket surface: hibernation-safe live play ---
519
+
520
+ /**
521
+ * Accept a member's WebSocket via the Hibernation API. The upgrade is forwarded by the authenticated Hono
522
+ * handler, which sets {@link USER_HEADER} to the AuthContext user id — the DO trusts that server-set header
523
+ * and never a client-supplied id. The identity is stashed with `serializeAttachment` so it survives
524
+ * hibernation.
525
+ */
526
+ override async fetch(request: Request): Promise<Response> {
527
+ if (request.headers.get("upgrade") !== "websocket") {
528
+ return new Response("Expected a WebSocket upgrade.", { status: 426 });
529
+ }
530
+ const userId = request.headers.get(USER_HEADER);
531
+ if (!userId) return new Response("Missing authenticated user.", { status: 401 });
532
+
533
+ const meta = await this.loadMeta();
534
+ if (!meta) return new Response("No such session.", { status: 404 });
535
+ if (!meta.members.includes(userId)) return new Response("Not a member of this session.", { status: 403 });
536
+
537
+ const pair = new WebSocketPair();
538
+ const [client, server] = [pair[0], pair[1]];
539
+ // Hibernation API — never `server.accept()`, which would pin the object in memory.
540
+ this.ctx.acceptWebSocket(server, [userId]);
541
+ // Tiny attachment, well under the 16,384-byte ceiling — the reconnection/resume identity.
542
+ server.serializeAttachment({ userId, sessionId: this.sessionId });
543
+ server.send(JSON.stringify(await this.snapshot(userId)));
544
+ return new Response(null, { status: 101, webSocket: client });
545
+ }
546
+
547
+ override async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> {
548
+ const attachment = ws.deserializeAttachment() as { userId?: string } | null;
549
+ const userId = attachment?.userId;
550
+ if (!userId) {
551
+ ws.send(errorFrame(new MultiplayerNotAMemberError({ message: "Unidentified socket." })));
552
+ return;
553
+ }
554
+ let parsed: ClientMessage;
555
+ try {
556
+ parsed = JSON.parse(typeof message === "string" ? message : new TextDecoder().decode(message)) as ClientMessage;
557
+ } catch (error) {
558
+ ws.send(errorFrame(new ValidationError({ message: "Message must be JSON.", detail: messageOf(error) })));
559
+ return;
560
+ }
561
+
562
+ try {
563
+ if (parsed.type === "action") {
564
+ // Call the internal method directly — no RPC boundary here, so it throws a real PithyError.
565
+ await this.applyAction(userId, parsed.payload);
566
+ } else if (parsed.type === "state") {
567
+ ws.send(JSON.stringify(await this.snapshot(userId)));
568
+ }
569
+ } catch (error) {
570
+ ws.send(
571
+ errorFrame(
572
+ error instanceof PithyError
573
+ ? error
574
+ : new InternalError({ message: "Something went wrong.", detail: messageOf(error) }, { cause: error }),
575
+ ),
576
+ );
577
+ }
578
+ }
579
+
580
+ override async webSocketClose(ws: WebSocket): Promise<void> {
581
+ // The attachment is lost on close; a reconnecting player re-upgrades and gets a fresh snapshot. Nothing
582
+ // authoritative lives on the socket, so there is nothing to persist here.
583
+ try {
584
+ ws.close();
585
+ } catch {
586
+ // Already closing — ignore.
587
+ }
588
+ }
589
+
590
+ /** Push each connected member their own redacted view — after any state change. */
591
+ private async broadcast(): Promise<void> {
592
+ const sockets = this.ctx.getWebSockets();
593
+ if (sockets.length === 0) return;
594
+ for (const ws of sockets) {
595
+ const attachment = ws.deserializeAttachment() as { userId?: string } | null;
596
+ if (!attachment?.userId) continue;
597
+ try {
598
+ ws.send(JSON.stringify(await this.snapshot(attachment.userId)));
599
+ } catch {
600
+ // A dead socket — skip it; the next message or reconnect will resync.
601
+ }
602
+ }
603
+ }
604
+ }
@@ -0,0 +1,27 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ /**
5
+ * The wire contract between the multiplayer routes and the session Durable Object: the header the routes
6
+ * set, and the sentinel an error crosses the RPC boundary behind. Two strings, and both sides need them.
7
+ *
8
+ * They live here rather than in `durableObject.ts` because that module imports `cloudflare:workers` and so
9
+ * resolves in workerd and nowhere else. The routes are reached from `capability.ts`, which is reached from
10
+ * the package entry point, which is what `pithy add multiplayer` writes into an adopter's `pithy.config.ts`
11
+ * — a file every Node-side CLI command loads. Importing two constants out of the DO module put the entire
12
+ * Durable Object chain on that path and broke `pithy upgrade` for any project composing multiplayer (#172).
13
+ *
14
+ * Kept free of every runtime-only import for that reason. Mirrors `@pithy-sh/matchmaking`'s
15
+ * `presence/protocol.ts`, which splits its own presence header out for exactly the same reason.
16
+ */
17
+
18
+ /** The header the authenticated Hono handler sets when forwarding a WebSocket upgrade to the DO. */
19
+ export const USER_HEADER = "x-pithy-user-id";
20
+
21
+ /**
22
+ * Marks an Error whose message carries a JSON-encoded `ErrorPayload`. A `PithyError` thrown inside a DO does
23
+ * not survive the RPC boundary — the runtime strips a custom Error subclass down to a bare Error, but a
24
+ * plain Error's **message** is preserved. So `guard` encodes the payload into the message behind this
25
+ * sentinel, and `callSession` in the routes decodes it back into a real `PithyError`.
26
+ */
27
+ export const RPC_ERROR_PREFIX = "pithy-error:";