@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,124 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { PithyError } from "@pithy-sh/core/src/error/pithyError";
5
+ import type { MessageParams } from "@pithy-sh/core/src/i18n/catalog";
6
+
7
+ /**
8
+ * `@pithy-sh/multiplayer` throw sugar. The `multiplayer/*` codes live in core's closed `KitErrorPayload`
9
+ * union (CLAUDE.md §Errors: capabilities add their codes to the one union); these subclasses are the
10
+ * package-local vehicles that set one of those members — the same pattern as `@pithy-sh/leaderboard` and
11
+ * `@pithy-sh/media`. Runtime code in this package throws one of these, never a plain `new Error`.
12
+ */
13
+
14
+ /** Variable parts each subclass accepts; `code`/`status` are fixed by the subclass. */
15
+ interface MultiplayerErrorArgs {
16
+ /** Override the public, safe-to-expose message. */
17
+ message?: string;
18
+ /** A remediation hint (CLI action line). */
19
+ action?: string;
20
+ /** Internal context for logs + audit. Never serialized to clients. */
21
+ detail?: string;
22
+ /**
23
+ * Values a translating client interpolates into its own wording for this code. Client-facing, so —
24
+ * unlike `action` and `detail` — these cross the boundary with `message`.
25
+ */
26
+ params?: MessageParams;
27
+ }
28
+
29
+ export class MultiplayerGameNotFoundError extends PithyError {
30
+ constructor(args: MultiplayerErrorArgs = {}, options?: { cause?: unknown }) {
31
+ super(
32
+ {
33
+ code: "multiplayer/game_not_found",
34
+ status: 404,
35
+ message: args.message ?? "That game does not exist.",
36
+ action: args.action ?? "Check the game key against the `games` list in pithy.config.ts.",
37
+ detail: args.detail,
38
+ params: args.params,
39
+ },
40
+ options,
41
+ );
42
+ }
43
+ }
44
+
45
+ export class MultiplayerSessionNotFoundError extends PithyError {
46
+ constructor(args: MultiplayerErrorArgs = {}, options?: { cause?: unknown }) {
47
+ super(
48
+ {
49
+ code: "multiplayer/session_not_found",
50
+ status: 404,
51
+ message: args.message ?? "That session does not exist.",
52
+ action: args.action ?? "Create a session first, then share its id with the other player.",
53
+ detail: args.detail,
54
+ params: args.params,
55
+ },
56
+ options,
57
+ );
58
+ }
59
+ }
60
+
61
+ export class MultiplayerNotAMemberError extends PithyError {
62
+ constructor(args: MultiplayerErrorArgs = {}, options?: { cause?: unknown }) {
63
+ super(
64
+ {
65
+ code: "multiplayer/not_a_member",
66
+ status: 403,
67
+ message: args.message ?? "You are not a member of this session.",
68
+ action: args.action ?? "Join the session before acting in it.",
69
+ detail: args.detail,
70
+ params: args.params,
71
+ },
72
+ options,
73
+ );
74
+ }
75
+ }
76
+
77
+ export class MultiplayerSessionFullError extends PithyError {
78
+ constructor(args: MultiplayerErrorArgs = {}, options?: { cause?: unknown }) {
79
+ super(
80
+ {
81
+ code: "multiplayer/session_full",
82
+ status: 409,
83
+ message: args.message ?? "This session is full.",
84
+ action: args.action ?? "Create a new session — this one already has both players.",
85
+ detail: args.detail,
86
+ params: args.params,
87
+ },
88
+ options,
89
+ );
90
+ }
91
+ }
92
+
93
+ export class MultiplayerInvalidTransitionError extends PithyError {
94
+ constructor(args: MultiplayerErrorArgs = {}, options?: { cause?: unknown }) {
95
+ super(
96
+ {
97
+ code: "multiplayer/invalid_transition",
98
+ status: 409,
99
+ message: args.message ?? "That action is not allowed right now.",
100
+ action:
101
+ args.action ?? "Check the session's phase before acting — you may have already committed, or it may be over.",
102
+ detail: args.detail,
103
+ params: args.params,
104
+ },
105
+ options,
106
+ );
107
+ }
108
+ }
109
+
110
+ export class MultiplayerInvalidMoveError extends PithyError {
111
+ constructor(args: MultiplayerErrorArgs = {}, options?: { cause?: unknown }) {
112
+ super(
113
+ {
114
+ code: "multiplayer/invalid_move",
115
+ status: 400,
116
+ message: args.message ?? "That move is not allowed.",
117
+ action: args.action ?? "Commit the exact number of distinct moves the game requires, each from its move set.",
118
+ detail: args.detail,
119
+ params: args.params,
120
+ },
121
+ options,
122
+ );
123
+ }
124
+ }
@@ -0,0 +1,20 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { battleGame } from "./games/battle";
5
+ import { connectNGame } from "./games/connectN";
6
+ import { crapsGame } from "./games/craps";
7
+ import { registerGameModel } from "./model";
8
+
9
+ /**
10
+ * Registers the built-in example games — imported for its side effect by the capability and the Durable
11
+ * Object, so all three `kind`s resolve without either one importing the games directly. A bare
12
+ * `import "../game/builtins"` is a side-effect import and is never tree-shaken away.
13
+ *
14
+ * These are *example games* built on the reusable pattern helpers (`simultaneous`, `turnBased`,
15
+ * `wageringTable`) — they show how to layer a game, and an adopter adds their own the same way, with
16
+ * `registerGameModel(myGame)` at worker load.
17
+ */
18
+ export const BUILT_IN_GAMES = [battleGame, connectNGame, crapsGame];
19
+
20
+ for (const game of BUILT_IN_GAMES) registerGameModel(game);
@@ -0,0 +1,89 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { D1Database } from "@cloudflare/workers-types";
5
+
6
+ /**
7
+ * A ledger operation a game model asks the session to perform — the wager seam.
8
+ *
9
+ * A model's `apply`/`resolve` are pure: they cannot touch a database. So instead of moving money
10
+ * themselves, they *declare* the movements as effects, and the Durable Object settles them through
11
+ * `@pithy-sh/ledger` after the transition. That keeps the model deterministic (a requirement for
12
+ * replay and provable fairness) while still letting a game hold a stake, capture a loss, or pay a win.
13
+ *
14
+ * Every effect carries a `ref` — the ledger's idempotency key. Because a model is deterministic, a replayed
15
+ * transition re-emits effects with the *same* refs, so applying them twice is a no-op: a payout pays once.
16
+ * Build refs stably from `ctx.sessionId` and the game's own state (`${sessionId}:round-3:alice:stake`).
17
+ */
18
+ export type LedgerEffect =
19
+ | {
20
+ readonly op: "credit";
21
+ readonly userId: string;
22
+ readonly currency: string;
23
+ readonly amount: number;
24
+ readonly ref: string;
25
+ readonly memo?: string;
26
+ }
27
+ | {
28
+ readonly op: "debit";
29
+ readonly userId: string;
30
+ readonly currency: string;
31
+ readonly amount: number;
32
+ readonly ref: string;
33
+ readonly memo?: string;
34
+ }
35
+ | {
36
+ readonly op: "hold";
37
+ readonly userId: string;
38
+ readonly currency: string;
39
+ readonly amount: number;
40
+ readonly ref: string;
41
+ }
42
+ | { readonly op: "release"; readonly ref: string }
43
+ | { readonly op: "capture"; readonly ref: string; readonly amount?: number; readonly memo?: string }
44
+ | {
45
+ readonly op: "transfer";
46
+ readonly from: string;
47
+ readonly to: string;
48
+ readonly currency: string;
49
+ readonly amount: number;
50
+ readonly ref: string;
51
+ readonly memo?: string;
52
+ };
53
+
54
+ /**
55
+ * Settle a model's declared effects. `@pithy-sh/ledger` is an *optional* peer, loaded by dynamic import
56
+ * only when a game actually emits effects — a game with no wagering never touches it, and a deployment
57
+ * without the ledger never resolves the import. Applied **before** the DO commits the new game state
58
+ * (see the DO): a hold that a player cannot cover throws here, so the wagering action is rejected and the
59
+ * state never advances.
60
+ */
61
+ export async function applyLedgerEffects(d1: D1Database, effects: readonly LedgerEffect[]): Promise<void> {
62
+ if (effects.length === 0) return;
63
+ const { openLedger } = await import("@pithy-sh/ledger/src/ledger");
64
+ const ledger = openLedger(d1);
65
+ for (const effect of effects) {
66
+ switch (effect.op) {
67
+ case "credit":
68
+ await ledger.credit(effect.userId, effect.currency, effect.amount, effect.ref, { memo: effect.memo });
69
+ break;
70
+ case "debit":
71
+ await ledger.debit(effect.userId, effect.currency, effect.amount, effect.ref, { memo: effect.memo });
72
+ break;
73
+ case "hold":
74
+ await ledger.hold(effect.userId, effect.currency, effect.amount, effect.ref);
75
+ break;
76
+ case "release":
77
+ await ledger.release(effect.ref);
78
+ break;
79
+ case "capture":
80
+ await ledger.capture(effect.ref, { amount: effect.amount, memo: effect.memo });
81
+ break;
82
+ case "transfer":
83
+ await ledger.transfer(effect.from, effect.to, effect.currency, effect.amount, effect.ref, {
84
+ memo: effect.memo,
85
+ });
86
+ break;
87
+ }
88
+ }
89
+ }
@@ -0,0 +1,186 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { z } from "zod";
5
+ import { MultiplayerInvalidMoveError } from "../../error/errors";
6
+ import type { ModelOutcome } from "../model";
7
+ import { simultaneous } from "../patterns/simultaneous";
8
+
9
+ /**
10
+ * Battle — the flagship **simultaneous** game (built on the {@link simultaneous} pattern).
11
+ *
12
+ * Each player secretly picks offensive and defensive moves; when everyone has, the server resolves them
13
+ * together. An offensive move scores its power unless *any* opponent's chosen defense blocks it — which is
14
+ * what makes hidden state load-bearing, and generalizes cleanly from a 2-player duel to an N-player
15
+ * free-for-all. The whole game is a config (the move catalogs) plus a scoring function; the simultaneous
16
+ * lifecycle and the hidden-state boundary come from the pattern.
17
+ */
18
+
19
+ export const OffenseMove = z
20
+ .object({
21
+ name: z
22
+ .string()
23
+ .min(1)
24
+ .describe("The move's stable name — what a player names in their submission, and what a defense blocks."),
25
+ power: z
26
+ .number()
27
+ .nonnegative()
28
+ .describe("Points this move scores against opponents when it lands (i.e. no opponent blocked it)."),
29
+ })
30
+ .describe("One offensive move a player may pick — a name and the points it scores if unblocked.");
31
+ export type OffenseMove = z.infer<typeof OffenseMove>;
32
+
33
+ export const DefenseMove = z
34
+ .object({
35
+ name: z.string().min(1).describe("The move's stable name — what a player names in their submission."),
36
+ blocks: z
37
+ .string()
38
+ .min(1)
39
+ .describe(
40
+ "The offensive move name this defense neutralizes. Must reference an offensive move that exists in this game.",
41
+ ),
42
+ })
43
+ .describe("One defensive move a player may pick — a name and the offensive move it blocks.");
44
+ export type DefenseMove = z.infer<typeof DefenseMove>;
45
+
46
+ export const BattleConfig = z
47
+ .object({
48
+ offense: z
49
+ .object({
50
+ pick: z.number().int().min(0).describe("Exactly how many distinct offensive moves a player picks."),
51
+ moves: z.array(OffenseMove).describe("The offensive catalog a player picks from."),
52
+ })
53
+ .describe("The offensive moves each player picks, and how many."),
54
+ defense: z
55
+ .object({
56
+ pick: z.number().int().min(0).describe("Exactly how many distinct defensive moves a player picks."),
57
+ moves: z.array(DefenseMove).describe("The defensive catalog a player picks from."),
58
+ })
59
+ .describe("The defensive moves each player picks, and how many. Set `pick: 0` for an offense-only game."),
60
+ })
61
+ .describe("The battle game's rules — the offensive and defensive move catalogs and pick counts.")
62
+ .check((ctx) => {
63
+ const { offense, defense } = ctx.value;
64
+ const offenseNames = offense.moves.map((m) => m.name);
65
+ const uniqueOffense = new Set(offenseNames);
66
+ if (uniqueOffense.size !== offenseNames.length)
67
+ ctx.issues.push({
68
+ code: "custom",
69
+ input: ctx.value,
70
+ path: ["offense", "moves"],
71
+ message: "Duplicate offensive move names.",
72
+ });
73
+ if (offense.pick > uniqueOffense.size)
74
+ ctx.issues.push({
75
+ code: "custom",
76
+ input: ctx.value,
77
+ path: ["offense", "pick"],
78
+ message: `Asks for ${offense.pick} offensive moves but only ${uniqueOffense.size} exist.`,
79
+ });
80
+ const defenseNames = defense.moves.map((m) => m.name);
81
+ const uniqueDefense = new Set(defenseNames);
82
+ if (uniqueDefense.size !== defenseNames.length)
83
+ ctx.issues.push({
84
+ code: "custom",
85
+ input: ctx.value,
86
+ path: ["defense", "moves"],
87
+ message: "Duplicate defensive move names.",
88
+ });
89
+ if (defense.pick > uniqueDefense.size)
90
+ ctx.issues.push({
91
+ code: "custom",
92
+ input: ctx.value,
93
+ path: ["defense", "pick"],
94
+ message: `Asks for ${defense.pick} defensive moves but only ${uniqueDefense.size} exist.`,
95
+ });
96
+ for (let i = 0; i < defense.moves.length; i++) {
97
+ const move = defense.moves[i] as DefenseMove;
98
+ if (!uniqueOffense.has(move.blocks))
99
+ ctx.issues.push({
100
+ code: "custom",
101
+ input: ctx.value,
102
+ path: ["defense", "moves", i, "blocks"],
103
+ message: `Defense "${move.name}" blocks "${move.blocks}", which is not an offensive move.`,
104
+ });
105
+ }
106
+ });
107
+ export type BattleConfig = z.output<typeof BattleConfig>;
108
+
109
+ /** One player's secret picks — the submission a battle player commits. */
110
+ export const Move = z
111
+ .object({
112
+ offense: z
113
+ .array(z.string())
114
+ .describe("The offensive move names picked — exactly `offense.pick`, distinct, from the set."),
115
+ defense: z
116
+ .array(z.string())
117
+ .describe("The defensive move names picked — exactly `defense.pick`, distinct, from the set."),
118
+ })
119
+ .describe("One player's secret battle picks.");
120
+ export type Move = z.infer<typeof Move>;
121
+
122
+ /** Validate one pick list: exact count, distinct, and every name from the allowed set. Throws otherwise. */
123
+ function assertPicks(kind: "offensive" | "defensive", picks: string[], pick: number, allowed: Set<string>): void {
124
+ if (picks.length !== pick)
125
+ throw new MultiplayerInvalidMoveError({
126
+ message: `Pick exactly ${pick} ${kind} move${pick === 1 ? "" : "s"}.`,
127
+ detail: `Expected ${pick} ${kind} moves, got ${picks.length}.`,
128
+ });
129
+ if (new Set(picks).size !== picks.length)
130
+ throw new MultiplayerInvalidMoveError({
131
+ message: `Your ${kind} moves must be distinct.`,
132
+ detail: `Duplicate ${kind} moves.`,
133
+ });
134
+ for (const name of picks) {
135
+ if (!allowed.has(name))
136
+ throw new MultiplayerInvalidMoveError({
137
+ message: `"${name}" is not one of this game's ${kind} moves.`,
138
+ detail: `${kind} move "${name}" not in set.`,
139
+ });
140
+ }
141
+ }
142
+
143
+ /** Validate a battle submission against the config. Throws {@link MultiplayerInvalidMoveError} on any violation. */
144
+ export function validateMove(config: BattleConfig, move: Move): void {
145
+ assertPicks("offensive", move.offense, config.offense.pick, new Set(config.offense.moves.map((m) => m.name)));
146
+ assertPicks("defensive", move.defense, config.defense.pick, new Set(config.defense.moves.map((m) => m.name)));
147
+ }
148
+
149
+ /** Score every player's picks: an offense scores unless any opponent's defense blocks it; highest wins, ties draw. */
150
+ export function scoreBattle(
151
+ config: BattleConfig,
152
+ submissions: Record<string, Move>,
153
+ players: readonly string[],
154
+ ): ModelOutcome {
155
+ const power = new Map(config.offense.moves.map((m) => [m.name, m.power]));
156
+ const blocksByDefense = new Map(config.defense.moves.map((d) => [d.name, d.blocks]));
157
+ const scoreFor = (me: string): number => {
158
+ const blocked = new Set<string>();
159
+ for (const other of players) {
160
+ if (other === me) continue;
161
+ for (const name of submissions[other]?.defense ?? []) {
162
+ const blocks = blocksByDefense.get(name);
163
+ if (blocks) blocked.add(blocks);
164
+ }
165
+ }
166
+ return (submissions[me]?.offense ?? []).reduce(
167
+ (sum, move) => sum + (blocked.has(move) ? 0 : (power.get(move) ?? 0)),
168
+ 0,
169
+ );
170
+ };
171
+ const scores: Record<string, number> = {};
172
+ for (const player of players) scores[player] = scoreFor(player);
173
+ const top = Math.max(...players.map((p) => scores[p] as number));
174
+ const leaders = players.filter((p) => scores[p] === top);
175
+ const draw = leaders.length !== 1;
176
+ return { scores, winnerUserId: draw ? null : (leaders[0] as string), draw };
177
+ }
178
+
179
+ export const battleGame = simultaneous<BattleConfig, Move>({
180
+ kind: "battle",
181
+ config: BattleConfig,
182
+ submission: Move,
183
+ minPlayers: 2,
184
+ validate: (config, move) => validateMove(config, move),
185
+ score: (ctx, submissions) => scoreBattle(ctx.config, submissions, ctx.players),
186
+ });
@@ -0,0 +1,127 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { z } from "zod";
5
+ import { MultiplayerInvalidMoveError } from "../../error/errors";
6
+ import type { ModelOutcome } from "../model";
7
+ import { turnBased } from "../patterns/turnBased";
8
+
9
+ /**
10
+ * Connect-N — the flagship **turn-based** game (built on the {@link turnBased} pattern).
11
+ *
12
+ * Players take turns claiming empty cells; the first to line up `connect` cells in a row — horizontal,
13
+ * vertical, or diagonal — wins. It is a whole family in one config: tic-tac-toe is `3×3 connect 3`, Connect
14
+ * Four is `7×6 connect 4`, gomoku is `15×15 connect 5`. The whole game is a config plus how a move changes
15
+ * the board and how a win is detected; the turn order and advancement come from the pattern.
16
+ */
17
+
18
+ export const ConnectNConfig = z
19
+ .object({
20
+ rows: z.number().int().min(1).describe("Board height in cells."),
21
+ cols: z.number().int().min(1).describe("Board width in cells."),
22
+ connect: z
23
+ .number()
24
+ .int()
25
+ .min(2)
26
+ .describe("How many of a player's cells in a line (row, column, or diagonal) wins."),
27
+ })
28
+ .describe("The connect-n game's rules — board size and the line length that wins.")
29
+ .check((ctx) => {
30
+ const { rows, cols, connect } = ctx.value;
31
+ if (connect > Math.max(rows, cols))
32
+ ctx.issues.push({
33
+ code: "custom",
34
+ input: ctx.value,
35
+ path: ["connect"],
36
+ message: `connect ${connect} is longer than the board's ${Math.max(rows, cols)} — no line could ever win.`,
37
+ });
38
+ });
39
+ export type ConnectNConfig = z.output<typeof ConnectNConfig>;
40
+
41
+ /** The board state (turn tracking is the turn-based pattern's, not here). */
42
+ export const BoardState = z
43
+ .object({
44
+ board: z
45
+ .array(z.array(z.string().nullable()))
46
+ .describe("Row-major grid; each cell is the user id that claimed it, or null."),
47
+ moves: z.number().int().describe("How many cells have been claimed — the board is full at rows×cols."),
48
+ })
49
+ .describe("The connect-n board.");
50
+ export type BoardState = z.infer<typeof BoardState>;
51
+
52
+ /** One move: the cell to claim. */
53
+ export const GridMove = z
54
+ .object({
55
+ row: z.number().int().describe("The 0-indexed row of the cell to claim."),
56
+ col: z.number().int().describe("The 0-indexed column of the cell to claim."),
57
+ })
58
+ .describe("A connect-n move — the cell a player claims on their turn.");
59
+ export type GridMove = z.infer<typeof GridMove>;
60
+
61
+ /** The user id who owns a winning line of `connect`, or null. Scans right, down, and both diagonals. */
62
+ export function findWinner(board: (string | null)[][], connect: number): string | null {
63
+ const rows = board.length;
64
+ const cols = board[0]?.length ?? 0;
65
+ const directions = [
66
+ [0, 1],
67
+ [1, 0],
68
+ [1, 1],
69
+ [1, -1],
70
+ ] as const;
71
+ for (let r = 0; r < rows; r++) {
72
+ for (let c = 0; c < cols; c++) {
73
+ const owner = board[r]?.[c];
74
+ if (!owner) continue;
75
+ for (const [dr, dc] of directions) {
76
+ let run = 1;
77
+ while (run < connect && board[r + dr * run]?.[c + dc * run] === owner) run++;
78
+ if (run >= connect) return owner;
79
+ }
80
+ }
81
+ }
82
+ return null;
83
+ }
84
+
85
+ export const connectNGame = turnBased<ConnectNConfig, BoardState, GridMove>({
86
+ kind: "connect-n",
87
+ config: ConnectNConfig,
88
+ game: BoardState,
89
+ move: GridMove,
90
+ minPlayers: 2,
91
+
92
+ start: (ctx) => ({
93
+ board: Array.from({ length: ctx.config.rows }, () =>
94
+ Array.from({ length: ctx.config.cols }, () => null as string | null),
95
+ ),
96
+ moves: 0,
97
+ }),
98
+
99
+ play(ctx, state, playerId, move) {
100
+ const { row, col } = move;
101
+ if (row < 0 || row >= ctx.config.rows || col < 0 || col >= ctx.config.cols) {
102
+ throw new MultiplayerInvalidMoveError({
103
+ message: "That cell is off the board.",
104
+ detail: `(${row}, ${col}) outside ${ctx.config.rows}×${ctx.config.cols}.`,
105
+ });
106
+ }
107
+ if (state.board[row]?.[col]) {
108
+ throw new MultiplayerInvalidMoveError({
109
+ message: "That cell is already taken.",
110
+ detail: `(${row}, ${col}) is claimed by ${state.board[row]?.[col]}.`,
111
+ });
112
+ }
113
+ const board = state.board.map((r) => [...r]);
114
+ (board[row] as (string | null)[])[col] = playerId;
115
+ return { game: { board, moves: state.moves + 1 } };
116
+ },
117
+
118
+ isEnd: (ctx, state) =>
119
+ findWinner(state.board, ctx.config.connect) !== null || state.moves >= ctx.config.rows * ctx.config.cols,
120
+
121
+ score(ctx, state): ModelOutcome {
122
+ const winner = findWinner(state.board, ctx.config.connect);
123
+ const scores: Record<string, number> = {};
124
+ for (const player of ctx.players) scores[player] = player === winner ? 1 : 0;
125
+ return { scores, winnerUserId: winner, draw: winner === null };
126
+ },
127
+ });