@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.
- package/LICENSE +21 -0
- package/README.md +17 -0
- package/docs/costs.md +41 -0
- package/package.json +65 -0
- package/pithy.manifest.json +41 -0
- package/src/capability.ts +102 -0
- package/src/cloudflare-test.d.ts +16 -0
- package/src/config/config.ts +185 -0
- package/src/data/result.ts +49 -0
- package/src/data/store.ts +47 -0
- package/src/data/tables.ts +27 -0
- package/src/error/errors.ts +124 -0
- package/src/game/builtins.ts +20 -0
- package/src/game/effects.ts +89 -0
- package/src/game/games/battle.ts +186 -0
- package/src/game/games/connectN.ts +127 -0
- package/src/game/games/craps.ts +182 -0
- package/src/game/model.ts +154 -0
- package/src/game/patterns/simultaneous.ts +98 -0
- package/src/game/patterns/turnBased.ts +101 -0
- package/src/game/patterns/wageringTable.ts +193 -0
- package/src/game/random.ts +101 -0
- package/src/http/guard.ts +27 -0
- package/src/http/routes.ts +254 -0
- package/src/http/schemas.ts +50 -0
- package/src/index.ts +83 -0
- package/src/migrations/0001_results.ts +52 -0
- package/src/publish/leaderboard.ts +59 -0
- package/src/seeds/example.ts +54 -0
- package/src/session/durableObject.ts +604 -0
- package/src/session/protocol.ts +27 -0
- package/src/session/state.ts +117 -0
- package/src/session/testWorker.ts +18 -0
- package/src/version.generated.ts +16 -0
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { MultiplayerInvalidTransitionError } from "../../error/errors";
|
|
6
|
+
import type { LedgerEffect } from "../effects";
|
|
7
|
+
import type { GameContext, GameModel } from "../model";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The **wagering-table** pattern — the base helper for banked casino games: a persistent table where
|
|
11
|
+
* players place bets on a random event, and the house is the counterparty. Craps, roulette, sic bo.
|
|
12
|
+
*
|
|
13
|
+
* The helper owns the wagering plumbing that every such game shares: the **bet book** (tracking pending
|
|
14
|
+
* bets), the ledger **holds** placed when a bet lands, and the **settlement** (release/capture/credit) when
|
|
15
|
+
* a bet resolves — plus the persistent-table lifecycle (it never ends on a round; leaving returns a
|
|
16
|
+
* player's open bets). A game built on it supplies only the game-specific parts: what a valid bet is, who
|
|
17
|
+
* may trigger the random event, and how that event decides each pending bet. It never touches a hold ref or
|
|
18
|
+
* a ledger effect directly.
|
|
19
|
+
*
|
|
20
|
+
* Use it with `mode: "table"` and pair it with `@pithy-sh/ledger`.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** A bet the helper is holding: the player, the ledger hold `ref`, the staked `amount`, and game-specific `data`. */
|
|
24
|
+
export interface PendingBet {
|
|
25
|
+
userId: string;
|
|
26
|
+
ref: string;
|
|
27
|
+
amount: number;
|
|
28
|
+
data: unknown;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A game's decision on a pending bet after a random event: won `payout`, lost, or pushed (stake returned). */
|
|
32
|
+
export interface BetDecision {
|
|
33
|
+
ref: string;
|
|
34
|
+
result: "win" | "lose" | "push";
|
|
35
|
+
payout: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface WageringTableSpec<Config, Round, BetInput> {
|
|
39
|
+
/** The game's `kind` — its registry key. */
|
|
40
|
+
kind: string;
|
|
41
|
+
/** The game's config schema (the `rules` block). */
|
|
42
|
+
config: z.ZodType<Config>;
|
|
43
|
+
/** The schema for the round-specific state (a craps come-out/point, a roulette between-spins). */
|
|
44
|
+
round: z.ZodType<Round>;
|
|
45
|
+
/** The schema for a bet's input (the body of a `bet` action). */
|
|
46
|
+
bet: z.ZodType<BetInput>;
|
|
47
|
+
minPlayers?: number;
|
|
48
|
+
maxPlayers?: number;
|
|
49
|
+
/** The ledger currency bets are held and paid in. */
|
|
50
|
+
currency: (config: Config) => string;
|
|
51
|
+
/** The initial round state. */
|
|
52
|
+
startRound: (ctx: GameContext<Config>) => Round;
|
|
53
|
+
/** Validate a bet against the round and the player's existing bets; return the stake to hold plus `data` to store with it. Throw on illegal. */
|
|
54
|
+
placeBet: (
|
|
55
|
+
ctx: GameContext<Config>,
|
|
56
|
+
round: Round,
|
|
57
|
+
playerId: string,
|
|
58
|
+
bet: BetInput,
|
|
59
|
+
bets: readonly PendingBet[],
|
|
60
|
+
) => { amount: number; data: unknown };
|
|
61
|
+
/** The member allowed to trigger the random event now (a shooter), or null to allow anyone. */
|
|
62
|
+
eventDriver?: (ctx: GameContext<Config>, round: Round) => string | null;
|
|
63
|
+
/** Run the random event (roll, spin): advance the round and decide each pending bet. The helper settles them. */
|
|
64
|
+
runEvent: (
|
|
65
|
+
ctx: GameContext<Config>,
|
|
66
|
+
round: Round,
|
|
67
|
+
bets: readonly PendingBet[],
|
|
68
|
+
) => { round: Round; decisions: BetDecision[] };
|
|
69
|
+
/** Optional view of the round; the helper adds the bet book around it. */
|
|
70
|
+
view?: (ctx: GameContext<Config>, round: Round, viewerId: string) => unknown;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
type TableState<Round> = { round: Round; bets: PendingBet[]; betSeq: number; lastDecisions: BetDecision[] };
|
|
74
|
+
|
|
75
|
+
/** Build a {@link GameModel} for a banked wagering table from its {@link WageringTableSpec}. */
|
|
76
|
+
export function wageringTable<Config, Round, BetInput>(
|
|
77
|
+
spec: WageringTableSpec<Config, Round, BetInput>,
|
|
78
|
+
): GameModel<Config, TableState<Round>> {
|
|
79
|
+
const PendingBetSchema = z
|
|
80
|
+
.object({
|
|
81
|
+
userId: z.string().describe("The player who placed the bet."),
|
|
82
|
+
ref: z.string().describe("The ledger hold ref."),
|
|
83
|
+
amount: z.number().int().describe("The staked amount."),
|
|
84
|
+
data: z.unknown().describe("Game-specific bet data."),
|
|
85
|
+
})
|
|
86
|
+
.describe("A pending bet holding a stake.");
|
|
87
|
+
const state = z
|
|
88
|
+
.object({
|
|
89
|
+
round: spec.round.describe("The round-specific state."),
|
|
90
|
+
bets: z.array(PendingBetSchema).describe("Pending bets, each holding its stake."),
|
|
91
|
+
betSeq: z.number().int().describe("A monotonic counter making each bet's hold ref unique."),
|
|
92
|
+
lastDecisions: z
|
|
93
|
+
.array(z.object({ ref: z.string(), result: z.enum(["win", "lose", "push"]), payout: z.number() }))
|
|
94
|
+
.describe("How the last event decided each bet."),
|
|
95
|
+
})
|
|
96
|
+
.describe(`The ${spec.kind} table's state — the round, the bet book, and the last event's decisions.`);
|
|
97
|
+
const action = z
|
|
98
|
+
.discriminatedUnion("kind", [
|
|
99
|
+
z
|
|
100
|
+
.object({ kind: z.literal("bet").describe("Place a bet."), bet: spec.bet.describe("The bet.") })
|
|
101
|
+
.describe("Place a bet."),
|
|
102
|
+
z
|
|
103
|
+
.object({ kind: z.literal("event").describe("Trigger the random event (roll/spin).") })
|
|
104
|
+
.describe("Trigger the event."),
|
|
105
|
+
])
|
|
106
|
+
.describe("A wagering-table action — place a bet, or trigger the event.");
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
kind: spec.kind,
|
|
110
|
+
config: spec.config,
|
|
111
|
+
state,
|
|
112
|
+
minPlayers: spec.minPlayers ?? 1,
|
|
113
|
+
maxPlayers: spec.maxPlayers,
|
|
114
|
+
|
|
115
|
+
init: (ctx) => ({ round: spec.startRound(ctx), bets: [], betSeq: 0, lastDecisions: [] }),
|
|
116
|
+
|
|
117
|
+
apply(ctx, current, playerId, rawAction) {
|
|
118
|
+
const parsed = action.parse(rawAction);
|
|
119
|
+
|
|
120
|
+
if (parsed.kind === "bet") {
|
|
121
|
+
const { amount, data } = spec.placeBet(ctx, current.round, playerId, parsed.bet as BetInput, current.bets);
|
|
122
|
+
const ref = `${ctx.sessionId}:bet:${current.betSeq}`;
|
|
123
|
+
const effects: LedgerEffect[] = [
|
|
124
|
+
{ op: "hold", userId: playerId, currency: spec.currency(ctx.config), amount, ref },
|
|
125
|
+
];
|
|
126
|
+
return {
|
|
127
|
+
state: {
|
|
128
|
+
...current,
|
|
129
|
+
betSeq: current.betSeq + 1,
|
|
130
|
+
bets: [...current.bets, { userId: playerId, ref, amount, data }],
|
|
131
|
+
},
|
|
132
|
+
effects,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// An event — check the driver (shooter), then run it and settle the decided bets.
|
|
137
|
+
const driver = spec.eventDriver?.(ctx, current.round);
|
|
138
|
+
if (driver !== undefined && driver !== null && driver !== playerId) {
|
|
139
|
+
throw new MultiplayerInvalidTransitionError({
|
|
140
|
+
message: "It is not your turn to trigger the event.",
|
|
141
|
+
detail: `${playerId} is not the driver (${driver}).`,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
const { round, decisions } = spec.runEvent(ctx, current.round, current.bets);
|
|
145
|
+
const decided = new Map(decisions.map((d) => [d.ref, d]));
|
|
146
|
+
const currency = spec.currency(ctx.config);
|
|
147
|
+
const effects: LedgerEffect[] = [];
|
|
148
|
+
for (const bet of current.bets) {
|
|
149
|
+
const decision = decided.get(bet.ref);
|
|
150
|
+
if (!decision) continue; // no decision — the bet carries to the next event
|
|
151
|
+
if (decision.result === "lose") {
|
|
152
|
+
effects.push({ op: "capture", ref: bet.ref });
|
|
153
|
+
} else {
|
|
154
|
+
effects.push({ op: "release", ref: bet.ref }); // win or push: return the stake
|
|
155
|
+
if (decision.result === "win" && decision.payout > 0) {
|
|
156
|
+
effects.push({
|
|
157
|
+
op: "credit",
|
|
158
|
+
userId: bet.userId,
|
|
159
|
+
currency,
|
|
160
|
+
amount: decision.payout,
|
|
161
|
+
ref: `${bet.ref}:win`,
|
|
162
|
+
memo: `${spec.kind} win`,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
const bets = current.bets.filter((b) => !decided.has(b.ref));
|
|
168
|
+
return { state: { round, bets, betSeq: current.betSeq, lastDecisions: decisions }, effects };
|
|
169
|
+
},
|
|
170
|
+
|
|
171
|
+
isComplete: () => false, // a table runs until closed or emptied (the session's table lifecycle)
|
|
172
|
+
resolve: () => ({ outcome: { scores: {}, winnerUserId: null, draw: false } }),
|
|
173
|
+
|
|
174
|
+
onLeave(_ctx, current, playerId) {
|
|
175
|
+
// Return the leaver's held stakes and drop their bets.
|
|
176
|
+
const effects: LedgerEffect[] = current.bets
|
|
177
|
+
.filter((b) => b.userId === playerId)
|
|
178
|
+
.map((b) => ({ op: "release", ref: b.ref }));
|
|
179
|
+
return { state: { ...current, bets: current.bets.filter((b) => b.userId !== playerId) }, effects };
|
|
180
|
+
},
|
|
181
|
+
|
|
182
|
+
redact(ctx, current, viewerId) {
|
|
183
|
+
const driver = spec.eventDriver?.(ctx, current.round) ?? null;
|
|
184
|
+
return {
|
|
185
|
+
...(spec.view ? { round: spec.view(ctx, current.round, viewerId) } : { round: current.round }),
|
|
186
|
+
driver,
|
|
187
|
+
yourTurnToTrigger: driver === null || driver === viewerId,
|
|
188
|
+
bets: current.bets.map((b) => ({ userId: b.userId, amount: b.amount, data: b.data })),
|
|
189
|
+
lastDecisions: current.lastDecisions,
|
|
190
|
+
};
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Server-authoritative randomness for game models — the primitive a house cannot let a client control.
|
|
8
|
+
*
|
|
9
|
+
* A session mints a random **seed** at creation (128 bits of `crypto.getRandomValues`) and stores it. From
|
|
10
|
+
* that seed a **deterministic** stream of draws is derived: given the seed and a cursor, every value is
|
|
11
|
+
* fixed and reproducible. The Durable Object advances the cursor as a model draws and persists it, so the
|
|
12
|
+
* stream never repeats and survives hibernation.
|
|
13
|
+
*
|
|
14
|
+
* Determinism is what makes it **provably fair**. The seed's SHA-256 hash is committed up front (shown to
|
|
15
|
+
* every player before a single die is rolled) and the seed itself is revealed when the session ends. An
|
|
16
|
+
* auditor hashes the revealed seed to check it against the commitment, then replays this exact algorithm to
|
|
17
|
+
* verify every roll — so the house cannot have chosen or re-rolled an outcome. It is also why models must
|
|
18
|
+
* draw randomness only in `init`/`apply` (the transitions the DO persists), never in `resolve`/`redact`.
|
|
19
|
+
*/
|
|
20
|
+
export interface RandomSource {
|
|
21
|
+
/** The next draw as a float in [0, 1). */
|
|
22
|
+
next(): number;
|
|
23
|
+
/** The next draw as an integer in [minInclusive, maxInclusive] — a die is `int(1, 6)`. */
|
|
24
|
+
int(minInclusive: number, maxInclusive: number): number;
|
|
25
|
+
/** The next draw as a uniformly-chosen element of `items`. */
|
|
26
|
+
pick<T>(items: readonly T[]): T;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The persisted RNG state: the seed, its committed hash, and how many values have been drawn. */
|
|
30
|
+
export const RngState = z
|
|
31
|
+
.object({
|
|
32
|
+
seed: z
|
|
33
|
+
.string()
|
|
34
|
+
.describe(
|
|
35
|
+
"The session's random seed (hex). Secret until the session is terminal, then revealed for verification.",
|
|
36
|
+
),
|
|
37
|
+
seedHash: z
|
|
38
|
+
.string()
|
|
39
|
+
.describe("SHA-256 of the seed (hex) — the fairness commitment, shown to players before any draw."),
|
|
40
|
+
cursor: z
|
|
41
|
+
.number()
|
|
42
|
+
.int()
|
|
43
|
+
.describe("How many values have been drawn from the stream. Advances as models draw; persisted by the DO."),
|
|
44
|
+
})
|
|
45
|
+
.describe("A session's provably-fair RNG state — seed, its commitment, and the stream position.");
|
|
46
|
+
export type RngState = z.infer<typeof RngState>;
|
|
47
|
+
|
|
48
|
+
/** Derive a 32-bit numeric base from the hex seed (an xmur3-style string hash). */
|
|
49
|
+
function seedToBase(seed: string): number {
|
|
50
|
+
let h = 1779033703 ^ seed.length;
|
|
51
|
+
for (let i = 0; i < seed.length; i++) {
|
|
52
|
+
h = Math.imul(h ^ seed.charCodeAt(i), 3432918353);
|
|
53
|
+
h = (h << 13) | (h >>> 19);
|
|
54
|
+
}
|
|
55
|
+
return h >>> 0;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A counter-based draw: a deterministic float in [0, 1) for a given base and counter (a splitmix32 mix). */
|
|
59
|
+
function draw(base: number, counter: number): number {
|
|
60
|
+
let t = (base + Math.imul(counter, 0x9e3779b9)) >>> 0;
|
|
61
|
+
t ^= t >>> 15;
|
|
62
|
+
t = Math.imul(t, 0x2c1b3c6d) >>> 0;
|
|
63
|
+
t ^= t >>> 12;
|
|
64
|
+
t = Math.imul(t, 0x297a2d39) >>> 0;
|
|
65
|
+
t ^= t >>> 15;
|
|
66
|
+
return (t >>> 0) / 4294967296;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Bytes → lowercase hex. */
|
|
70
|
+
function toHex(bytes: Uint8Array): string {
|
|
71
|
+
let out = "";
|
|
72
|
+
for (const b of bytes) out += b.toString(16).padStart(2, "0");
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Mint a fresh RNG state: 128 crypto-random bits of seed, its SHA-256 commitment, cursor at zero. Async
|
|
78
|
+
* because the hash is (`crypto.subtle.digest`); called once, at session creation.
|
|
79
|
+
*/
|
|
80
|
+
export async function createRngState(): Promise<RngState> {
|
|
81
|
+
const seedBytes = crypto.getRandomValues(new Uint8Array(16));
|
|
82
|
+
const seed = toHex(seedBytes);
|
|
83
|
+
const digest = await crypto.subtle.digest("SHA-256", seedBytes);
|
|
84
|
+
return { seed, seedHash: toHex(new Uint8Array(digest)), cursor: 0 };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* A {@link RandomSource} over an RNG state, plus `spent()` — the cursor after the draws taken, which the DO
|
|
89
|
+
* reads back to persist. The source is a closure over a local cursor; nothing mutates the passed state.
|
|
90
|
+
*/
|
|
91
|
+
export function randomSource(state: RngState): RandomSource & { spent(): number } {
|
|
92
|
+
const base = seedToBase(state.seed);
|
|
93
|
+
let cursor = state.cursor;
|
|
94
|
+
const next = () => draw(base, cursor++);
|
|
95
|
+
return {
|
|
96
|
+
next,
|
|
97
|
+
int: (min, max) => min + Math.floor(next() * (max - min + 1)),
|
|
98
|
+
pick: (items) => items[Math.floor(next() * items.length)] as (typeof items)[number],
|
|
99
|
+
spent: () => cursor,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { PithyHonoEnv } from "@pithy-sh/core/src/capability/capability";
|
|
5
|
+
import { UnauthorizedError } from "@pithy-sh/core/src/error/pithyError";
|
|
6
|
+
import type { MiddlewareHandler } from "hono";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The one route guard: every multiplayer route requires an authenticated player.
|
|
10
|
+
*
|
|
11
|
+
* It depends on the core `AuthContext` seam and nothing else — `@pithy-sh/auth` populates `c.var.auth`,
|
|
12
|
+
* and multiplayer never reaches into its internals (CLAUDE.md §HTTP). Without auth installed `c.var.auth`
|
|
13
|
+
* is null and every route is denied, which is the correct failure: membership binds to an authenticated
|
|
14
|
+
* user id, so a session with no stable player identity cannot exist. There is no public multiplayer
|
|
15
|
+
* surface, because there is no such thing as an anonymous member of an authoritative session.
|
|
16
|
+
*/
|
|
17
|
+
export function requireAuth(): MiddlewareHandler<PithyHonoEnv> {
|
|
18
|
+
return async (c, next) => {
|
|
19
|
+
if (!c.var.auth) {
|
|
20
|
+
throw new UnauthorizedError({
|
|
21
|
+
message: "Authentication required.",
|
|
22
|
+
action: "Sign in and retry with a valid session or bearer token.",
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
await next();
|
|
26
|
+
};
|
|
27
|
+
}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { DurableObjectNamespace, DurableObjectStub } from "@cloudflare/workers-types";
|
|
5
|
+
import { zValidator } from "@hono/zod-validator";
|
|
6
|
+
import type { PithyHonoEnv } from "@pithy-sh/core/src/capability/capability";
|
|
7
|
+
import { ErrorPayload } from "@pithy-sh/core/src/error/payload";
|
|
8
|
+
import { InternalError, PithyError } from "@pithy-sh/core/src/error/pithyError";
|
|
9
|
+
import { validationHook } from "@pithy-sh/core/src/http/validation";
|
|
10
|
+
import type { Context, Hono } from "hono";
|
|
11
|
+
import { type ResolvedGame, resolveGame } from "../config/config";
|
|
12
|
+
import { MultiplayerGameNotFoundError, MultiplayerSessionNotFoundError } from "../error/errors";
|
|
13
|
+
// Type-only, so it erases: the DO module imports `cloudflare:workers` and must never land on the value
|
|
14
|
+
// import graph the adopter's `pithy.config.ts` pulls in. The two constants both sides need live in the
|
|
15
|
+
// pure `session/protocol` module for the same reason (#172).
|
|
16
|
+
import type { MultiplayerSession } from "../session/durableObject";
|
|
17
|
+
import { RPC_ERROR_PREFIX, USER_HEADER } from "../session/protocol";
|
|
18
|
+
import type { GameSnapshot } from "../session/state";
|
|
19
|
+
import { requireAuth } from "./guard";
|
|
20
|
+
import { MultiplayerGameParams, MultiplayerSessionParams } from "./schemas";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The multiplayer routes, their declared verification strategies, and what they accept. Every route is
|
|
24
|
+
* `bearer | session` — gated by {@link requireAuth} — because membership binds to an authenticated user id
|
|
25
|
+
* and there is no public session surface:
|
|
26
|
+
*
|
|
27
|
+
* | Method | Path | Strategy | Validates |
|
|
28
|
+
* |--------|----------------------------------|-------------------|---------------------------------|
|
|
29
|
+
* | POST | /multiplayer/games/:game | bearer \| session | param `MultiplayerGameParams` |
|
|
30
|
+
* | POST | /multiplayer/sessions/:id/join | bearer \| session | param `MultiplayerSessionParams` |
|
|
31
|
+
* | POST | /multiplayer/sessions/:id/action | bearer \| session | param `MultiplayerSessionParams` |
|
|
32
|
+
* | POST | /multiplayer/sessions/:id/leave | bearer \| session | param `MultiplayerSessionParams` |
|
|
33
|
+
* | POST | /multiplayer/sessions/:id/close | bearer \| session | param `MultiplayerSessionParams` |
|
|
34
|
+
* | GET | /multiplayer/sessions/:id/result | bearer \| session | param `MultiplayerSessionParams` |
|
|
35
|
+
* | GET | /multiplayer/sessions/:id/socket | bearer \| session | param `MultiplayerSessionParams` |
|
|
36
|
+
* | GET | /multiplayer/sessions/:id | bearer \| session | param `MultiplayerSessionParams` |
|
|
37
|
+
*
|
|
38
|
+
* Validators run **after** {@link requireAuth}, so an unauthenticated caller is still denied 401 whatever it
|
|
39
|
+
* sends. The param schemas bound a segment's shape only: resolving a game key stays in the handler (an
|
|
40
|
+
* unknown game is still a 404), and judging a session id stays in {@link sessionStub} (an unparseable id is
|
|
41
|
+
* still a 404).
|
|
42
|
+
*
|
|
43
|
+
* **No route declares a json schema, including the one route that reads a body.** An action is whatever the
|
|
44
|
+
* game's model defines — a commit for a commit-reveal game, a cell for a sequential grid — and the route
|
|
45
|
+
* forwards its JSON body untouched to the model, which is the only thing that knows its shape. There is no
|
|
46
|
+
* shape for this capability to impose, and a `zValidator("json", …)` would still change what a caller may
|
|
47
|
+
* send: it rejects an empty body under a JSON content-type with a 400, where a body-less action legitimately
|
|
48
|
+
* reaches a model that takes no payload today. So the body is read off the raw request instead, with
|
|
49
|
+
* the same "unreadable body means no payload" fallback — the model, not multiplayer, decides what is legal.
|
|
50
|
+
* The join, leave, and close routes read no body at all, so validating one on them would only 400 the
|
|
51
|
+
* body-less request their clients already send.
|
|
52
|
+
*
|
|
53
|
+
* The authenticated user id comes from the core `AuthContext` seam (`c.var.auth.userId`) and is passed to the
|
|
54
|
+
* Durable Object on every call — the DO never trusts a client-supplied id. The WebSocket upgrade is forwarded
|
|
55
|
+
* to the DO with that id set on {@link USER_HEADER}, a server-set header the DO trusts.
|
|
56
|
+
*/
|
|
57
|
+
export interface MultiplayerRoutesOptions {
|
|
58
|
+
/** The games, validated against their models at assembly (each carries parsed `rules`). */
|
|
59
|
+
games: readonly ResolvedGame[];
|
|
60
|
+
basePath?: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The `SESSIONS` Durable Object namespace binding, typed to the session class. */
|
|
64
|
+
type SessionsNamespace = DurableObjectNamespace<MultiplayerSession>;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The authenticated player's id. `requireAuth` runs before every handler, so `c.var.auth` is set — this
|
|
68
|
+
* reads it defensively (throwing rather than asserting non-null) so a misordered middleware surfaces as a
|
|
69
|
+
* clear error, never a crash.
|
|
70
|
+
*/
|
|
71
|
+
function userId(c: Context<PithyHonoEnv>): string {
|
|
72
|
+
const auth = c.var.auth;
|
|
73
|
+
if (!auth) {
|
|
74
|
+
throw new InternalError({ detail: "requireAuth() must run before a multiplayer handler reads the user id." });
|
|
75
|
+
}
|
|
76
|
+
return auth.userId;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Read the `SESSIONS` binding off the env, or fail with a configuration error naming the missing binding. */
|
|
80
|
+
function sessionsBinding(c: Context<PithyHonoEnv>): SessionsNamespace {
|
|
81
|
+
const binding = (c.env as Record<string, unknown>).SESSIONS as SessionsNamespace | undefined;
|
|
82
|
+
if (!binding) {
|
|
83
|
+
throw new InternalError({
|
|
84
|
+
message: "Multiplayer is not configured.",
|
|
85
|
+
action: "Bind a Durable Object namespace named SESSIONS in wrangler.jsonc.",
|
|
86
|
+
detail: "The multiplayer capability requires a `SESSIONS` durable_object binding; none was present on env.",
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
return binding;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Run a Durable Object RPC call, reviving a `PithyError` the DO threw.
|
|
94
|
+
*
|
|
95
|
+
* A `PithyError` thrown inside a DO does not cross the RPC boundary as itself — the runtime strips it to a
|
|
96
|
+
* bare Error. So the DO's `guard` encodes the payload as JSON into the Error message behind
|
|
97
|
+
* {@link RPC_ERROR_PREFIX} (a message is the one thing the boundary preserves), and this decodes it back
|
|
98
|
+
* into a real `PithyError` so `pithyErrorHandler` maps it to the same status and body a direct throw would
|
|
99
|
+
* — a `not_a_member` commit stays a 403, not a 500. Anything else propagates untouched (and 500s, correctly).
|
|
100
|
+
*/
|
|
101
|
+
async function callSession<T>(run: () => Promise<T>): Promise<T> {
|
|
102
|
+
try {
|
|
103
|
+
return await run();
|
|
104
|
+
} catch (error) {
|
|
105
|
+
if (error instanceof PithyError) throw error;
|
|
106
|
+
const message = (error as { message?: unknown } | null)?.message;
|
|
107
|
+
if (typeof message === "string" && message.startsWith(RPC_ERROR_PREFIX)) {
|
|
108
|
+
try {
|
|
109
|
+
const parsed = ErrorPayload.safeParse(JSON.parse(message.slice(RPC_ERROR_PREFIX.length)));
|
|
110
|
+
if (parsed.success) throw new PithyError(parsed.data, { cause: error });
|
|
111
|
+
} catch (reviveError) {
|
|
112
|
+
// A revived PithyError is the intended result — rethrow it. Only a genuinely malformed envelope
|
|
113
|
+
// (JSON.parse failure) falls through to the original error below, rather than a 500 from here.
|
|
114
|
+
if (reviveError instanceof PithyError) throw reviveError;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
throw error;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Resolve a session stub from a path id, mapping an unparseable id to a 404 rather than a 500. */
|
|
122
|
+
function sessionStub(namespace: SessionsNamespace, rawId: string): DurableObjectStub<MultiplayerSession> {
|
|
123
|
+
let id: ReturnType<SessionsNamespace["idFromString"]>;
|
|
124
|
+
try {
|
|
125
|
+
id = namespace.idFromString(rawId);
|
|
126
|
+
} catch (cause) {
|
|
127
|
+
throw new MultiplayerSessionNotFoundError({ detail: `"${rawId}" is not a valid session id.` }, { cause });
|
|
128
|
+
}
|
|
129
|
+
return namespace.get(id);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Build the config snapshot the DO stores from a validated game — nullable fields normalized for storage. */
|
|
133
|
+
function snapshot(game: ResolvedGame): GameSnapshot {
|
|
134
|
+
return {
|
|
135
|
+
key: game.key,
|
|
136
|
+
kind: game.kind,
|
|
137
|
+
mode: game.mode,
|
|
138
|
+
players: game.players,
|
|
139
|
+
turnTimeoutMs: game.turnTimeoutMs ?? null,
|
|
140
|
+
leaderboard: game.leaderboard ?? null,
|
|
141
|
+
rules: game.rules,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function registerMultiplayerRoutes(options: MultiplayerRoutesOptions): (app: Hono<PithyHonoEnv>) => void {
|
|
146
|
+
const base = options.basePath ?? "/multiplayer";
|
|
147
|
+
const { games } = options;
|
|
148
|
+
|
|
149
|
+
return (app) => {
|
|
150
|
+
// Create a session for a configured game. The creator is its first member.
|
|
151
|
+
app.post(
|
|
152
|
+
`${base}/games/:game`,
|
|
153
|
+
requireAuth(),
|
|
154
|
+
zValidator("param", MultiplayerGameParams, validationHook),
|
|
155
|
+
async (c) => {
|
|
156
|
+
const gameKey = c.req.valid("param").game;
|
|
157
|
+
const game = resolveGame(games, gameKey);
|
|
158
|
+
if (!game) throw new MultiplayerGameNotFoundError({ detail: `No game "${gameKey}" is configured.` });
|
|
159
|
+
|
|
160
|
+
const namespace = sessionsBinding(c);
|
|
161
|
+
const id = namespace.newUniqueId();
|
|
162
|
+
const stub = namespace.get(id);
|
|
163
|
+
const view = await callSession(() => stub.create(snapshot(game), userId(c)));
|
|
164
|
+
return c.json(view, 201);
|
|
165
|
+
},
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
app.post(
|
|
169
|
+
`${base}/sessions/:id/join`,
|
|
170
|
+
requireAuth(),
|
|
171
|
+
zValidator("param", MultiplayerSessionParams, validationHook),
|
|
172
|
+
async (c) => {
|
|
173
|
+
const stub = sessionStub(sessionsBinding(c), c.req.valid("param").id);
|
|
174
|
+
const view = await callSession(() => stub.join(userId(c)));
|
|
175
|
+
return c.json(view, 200);
|
|
176
|
+
},
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
app.post(
|
|
180
|
+
`${base}/sessions/:id/action`,
|
|
181
|
+
requireAuth(),
|
|
182
|
+
zValidator("param", MultiplayerSessionParams, validationHook),
|
|
183
|
+
async (c) => {
|
|
184
|
+
const stub = sessionStub(sessionsBinding(c), c.req.valid("param").id);
|
|
185
|
+
// The action payload is the one thing this capability does not shape — it belongs to the game's
|
|
186
|
+
// model (see the docblock), so it is read raw and forwarded. Read off `c.req.raw` rather than
|
|
187
|
+
// declared as a json validator so a body-less action still reaches a model that takes no payload,
|
|
188
|
+
// instead of becoming a 400.
|
|
189
|
+
const body = await c.req.raw.json().catch(() => undefined);
|
|
190
|
+
const view = await callSession(() => stub.action(userId(c), body));
|
|
191
|
+
return c.json(view, 200);
|
|
192
|
+
},
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
// Table mode: take or leave a seat, or close the table. Neither reads a body.
|
|
196
|
+
app.post(
|
|
197
|
+
`${base}/sessions/:id/leave`,
|
|
198
|
+
requireAuth(),
|
|
199
|
+
zValidator("param", MultiplayerSessionParams, validationHook),
|
|
200
|
+
async (c) => {
|
|
201
|
+
const stub = sessionStub(sessionsBinding(c), c.req.valid("param").id);
|
|
202
|
+
return c.json(await callSession(() => stub.leave(userId(c))), 200);
|
|
203
|
+
},
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
app.post(
|
|
207
|
+
`${base}/sessions/:id/close`,
|
|
208
|
+
requireAuth(),
|
|
209
|
+
zValidator("param", MultiplayerSessionParams, validationHook),
|
|
210
|
+
async (c) => {
|
|
211
|
+
const stub = sessionStub(sessionsBinding(c), c.req.valid("param").id);
|
|
212
|
+
return c.json(await callSession(() => stub.close(userId(c))), 200);
|
|
213
|
+
},
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
app.get(
|
|
217
|
+
`${base}/sessions/:id/result`,
|
|
218
|
+
requireAuth(),
|
|
219
|
+
zValidator("param", MultiplayerSessionParams, validationHook),
|
|
220
|
+
async (c) => {
|
|
221
|
+
const stub = sessionStub(sessionsBinding(c), c.req.valid("param").id);
|
|
222
|
+
const result = await callSession(() => stub.result());
|
|
223
|
+
if (!result) throw new MultiplayerSessionNotFoundError({ detail: "The session has no terminal result yet." });
|
|
224
|
+
return c.json(result, 200);
|
|
225
|
+
},
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
// The WebSocket upgrade — forwarded to the DO with the authenticated id set on the trusted header. The
|
|
229
|
+
// DO answers a non-upgrade request with 426, so the check lives there, once.
|
|
230
|
+
app.get(
|
|
231
|
+
`${base}/sessions/:id/socket`,
|
|
232
|
+
requireAuth(),
|
|
233
|
+
zValidator("param", MultiplayerSessionParams, validationHook),
|
|
234
|
+
async (c) => {
|
|
235
|
+
const stub = sessionStub(sessionsBinding(c), c.req.valid("param").id);
|
|
236
|
+
const forwarded = new Request(c.req.raw);
|
|
237
|
+
forwarded.headers.set(USER_HEADER, userId(c));
|
|
238
|
+
return stub.fetch(forwarded) as unknown as Response;
|
|
239
|
+
},
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
// Registered last so `/sessions/:id/*` static suffixes above are matched first.
|
|
243
|
+
app.get(
|
|
244
|
+
`${base}/sessions/:id`,
|
|
245
|
+
requireAuth(),
|
|
246
|
+
zValidator("param", MultiplayerSessionParams, validationHook),
|
|
247
|
+
async (c) => {
|
|
248
|
+
const stub = sessionStub(sessionsBinding(c), c.req.valid("param").id);
|
|
249
|
+
const view = await callSession(() => stub.view(userId(c)));
|
|
250
|
+
return c.json(view, 200);
|
|
251
|
+
},
|
|
252
|
+
);
|
|
253
|
+
};
|
|
254
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The HTTP-boundary shapes for the multiplayer routes. Declared on the route line with
|
|
8
|
+
* `zValidator(target, Schema, validationHook)`, so a malformed segment is a `validation/invalid_input` 400
|
|
9
|
+
* before any handler runs, never an unbounded string handed to a config lookup or a Durable Object id parse.
|
|
10
|
+
*
|
|
11
|
+
* Both are **param** schemas, and both are shape checks rather than existence checks. Resolving a game key
|
|
12
|
+
* against the configured games stays in the handler (an unknown game is still a `multiplayer/game_not_found`
|
|
13
|
+
* 404), and deciding whether a session id names a real Durable Object stays in `idFromString` (an unparseable
|
|
14
|
+
* id is still a `multiplayer/session_not_found` 404).
|
|
15
|
+
*
|
|
16
|
+
* There is deliberately no body schema here. The one route that reads a body — `POST /sessions/:id/action` —
|
|
17
|
+
* forwards it untouched to the game's model, which is the only thing that knows an action's shape; see the
|
|
18
|
+
* routes docblock.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A game key is a URL path segment, so it is lowercase, digits, and dashes — the same shape
|
|
23
|
+
* `MultiplayerGame.key` enforces at config assembly. A key that cannot be configured cannot resolve, so
|
|
24
|
+
* bounding the segment here rejects it a step earlier without narrowing what already works.
|
|
25
|
+
*/
|
|
26
|
+
const GAME_KEY = /^[a-z0-9][a-z0-9-]*$/;
|
|
27
|
+
|
|
28
|
+
export const MultiplayerGameParams = z
|
|
29
|
+
.object({
|
|
30
|
+
game: z
|
|
31
|
+
.string()
|
|
32
|
+
.max(64)
|
|
33
|
+
.regex(GAME_KEY, "A game key is lowercase, digits, and dashes — it is a URL path segment.")
|
|
34
|
+
.describe("The configured game a session is created for. Resolved against the games in the handler."),
|
|
35
|
+
})
|
|
36
|
+
.describe("The path params of a game-scoped multiplayer route.");
|
|
37
|
+
export type MultiplayerGameParams = z.output<typeof MultiplayerGameParams>;
|
|
38
|
+
|
|
39
|
+
export const MultiplayerSessionParams = z
|
|
40
|
+
.object({
|
|
41
|
+
id: z
|
|
42
|
+
.string()
|
|
43
|
+
.min(1)
|
|
44
|
+
.max(128)
|
|
45
|
+
.describe(
|
|
46
|
+
"The session's Durable Object id. Bounded, not parsed: a real id is 64 hex characters, but the id a client sent is judged by `idFromString`, which answers an unparseable one with a 404. A hex regex here would make that a 400 and leave the handler's catch unreachable.",
|
|
47
|
+
),
|
|
48
|
+
})
|
|
49
|
+
.describe("The path params of a session-scoped multiplayer route.");
|
|
50
|
+
export type MultiplayerSessionParams = z.output<typeof MultiplayerSessionParams>;
|