@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,182 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { MultiplayerInvalidMoveError, MultiplayerInvalidTransitionError } from "../../error/errors";
|
|
6
|
+
import { type BetDecision, type PendingBet, wageringTable } from "../patterns/wageringTable";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Craps — the flagship **wagering-table** game (built on the {@link wageringTable} pattern).
|
|
10
|
+
*
|
|
11
|
+
* The pattern owns the wagering plumbing: the bet book, the ledger holds placed when a bet lands, the
|
|
12
|
+
* settlement when a bet resolves, and the persistent-table lifecycle. Craps supplies only the game: what a
|
|
13
|
+
* valid bet is, who may roll (the shooter), and how a roll decides each pending bet. The house is the
|
|
14
|
+
* counterparty for wins and losses — off the players' ledger — so how you fund and book the house edge is
|
|
15
|
+
* yours, and Pithy takes no position on whether the chips map to money.
|
|
16
|
+
*
|
|
17
|
+
* The bets are the three that define craps' structure — a subset, not the whole layout:
|
|
18
|
+
* - **Pass line** — placed on the come-out. Wins on a come-out 7 or 11, or when the shooter makes the point;
|
|
19
|
+
* loses on a come-out 2, 3, or 12, or on a seven-out. Even money.
|
|
20
|
+
* - **Don't Pass** — the opposite. Wins on a come-out 2 or 3 (12 pushes), or on a seven-out; loses on a
|
|
21
|
+
* come-out 7 or 11, or when the point is made. Even money.
|
|
22
|
+
* - **Field** — a one-roll bet: wins on 2, 3, 4, 9, 10, 11, 12 (2 and 12 pay double), loses on 5, 6, 7, 8.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export const CrapsConfig = z
|
|
26
|
+
.object({
|
|
27
|
+
currency: z
|
|
28
|
+
.string()
|
|
29
|
+
.min(1)
|
|
30
|
+
.describe("The ledger currency bets and payouts are denominated in (from the ledger capability's `currencies`)."),
|
|
31
|
+
minBet: z.number().int().min(1).default(1).describe("The smallest bet allowed, in the currency's minor unit."),
|
|
32
|
+
maxBet: z.number().int().min(1).optional().describe("The largest bet allowed, or omit for no cap."),
|
|
33
|
+
})
|
|
34
|
+
.describe("The craps table's rules — the betting currency and the bet size limits.")
|
|
35
|
+
.check((ctx) => {
|
|
36
|
+
if (ctx.value.maxBet !== undefined && ctx.value.maxBet < ctx.value.minBet)
|
|
37
|
+
ctx.issues.push({
|
|
38
|
+
code: "custom",
|
|
39
|
+
input: ctx.value,
|
|
40
|
+
path: ["maxBet"],
|
|
41
|
+
message: `maxBet ${ctx.value.maxBet} is below minBet ${ctx.value.minBet}.`,
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
export type CrapsConfig = z.output<typeof CrapsConfig>;
|
|
45
|
+
|
|
46
|
+
export const CrapsBetType = z
|
|
47
|
+
.enum(["pass", "dont-pass", "field"])
|
|
48
|
+
.describe("A craps bet: `pass`, `dont-pass`, or `field`.");
|
|
49
|
+
export type CrapsBetType = z.infer<typeof CrapsBetType>;
|
|
50
|
+
|
|
51
|
+
/** A craps bet input — the type and the stake. */
|
|
52
|
+
export const CrapsBet = z
|
|
53
|
+
.object({ type: CrapsBetType.describe("Which bet."), amount: z.number().int().describe("The stake.") })
|
|
54
|
+
.describe("A craps bet.");
|
|
55
|
+
export type CrapsBet = z.infer<typeof CrapsBet>;
|
|
56
|
+
|
|
57
|
+
/** The craps round state — the come-out/point phase, the point, the shooter, and the last roll. */
|
|
58
|
+
export const CrapsRound = z
|
|
59
|
+
.object({
|
|
60
|
+
phase: z.enum(["come-out", "point"]).describe("`come-out` (no point yet) or `point` (rolling to make the point)."),
|
|
61
|
+
point: z.number().int().nullable().describe("The established point (4,5,6,8,9,10), or null on the come-out."),
|
|
62
|
+
shooterIndex: z
|
|
63
|
+
.number()
|
|
64
|
+
.int()
|
|
65
|
+
.describe("Index into the roster of the current shooter — the only player who may roll."),
|
|
66
|
+
lastRoll: z
|
|
67
|
+
.tuple([z.number(), z.number()])
|
|
68
|
+
.nullable()
|
|
69
|
+
.describe("The two dice of the last roll, or null before the first."),
|
|
70
|
+
})
|
|
71
|
+
.describe("The craps round state.");
|
|
72
|
+
export type CrapsRound = z.infer<typeof CrapsRound>;
|
|
73
|
+
|
|
74
|
+
/** The field bet's one-roll outcome for a dice sum: a win multiplier (1 or 2), or null to lose. */
|
|
75
|
+
function fieldWin(sum: number): number | null {
|
|
76
|
+
if (sum === 2 || sum === 12) return 2;
|
|
77
|
+
if (sum === 3 || sum === 4 || sum === 9 || sum === 10 || sum === 11) return 1;
|
|
78
|
+
return null; // 5,6,7,8 lose
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Resolve a pass/don't-pass bet against a roll in a phase. win/lose/push, or null if it carries. */
|
|
82
|
+
function passOutcome(
|
|
83
|
+
type: "pass" | "dont-pass",
|
|
84
|
+
phase: "come-out" | "point",
|
|
85
|
+
point: number | null,
|
|
86
|
+
sum: number,
|
|
87
|
+
): "win" | "lose" | "push" | null {
|
|
88
|
+
if (phase === "come-out") {
|
|
89
|
+
if (sum === 7 || sum === 11) return type === "pass" ? "win" : "lose";
|
|
90
|
+
if (sum === 2 || sum === 3) return type === "pass" ? "lose" : "win";
|
|
91
|
+
if (sum === 12) return type === "pass" ? "lose" : "push";
|
|
92
|
+
return null; // point established; the bet carries
|
|
93
|
+
}
|
|
94
|
+
if (sum === point) return type === "pass" ? "win" : "lose";
|
|
95
|
+
if (sum === 7) return type === "pass" ? "lose" : "win"; // seven-out
|
|
96
|
+
return null; // no decision; the bet carries
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export const crapsGame = wageringTable<CrapsConfig, CrapsRound, CrapsBet>({
|
|
100
|
+
kind: "craps",
|
|
101
|
+
config: CrapsConfig,
|
|
102
|
+
round: CrapsRound,
|
|
103
|
+
bet: CrapsBet,
|
|
104
|
+
minPlayers: 1,
|
|
105
|
+
currency: (config) => config.currency,
|
|
106
|
+
|
|
107
|
+
startRound: () => ({ phase: "come-out", point: null, shooterIndex: 0, lastRoll: null }),
|
|
108
|
+
|
|
109
|
+
placeBet(ctx, round, playerId, bet, bets) {
|
|
110
|
+
if (bet.amount < ctx.config.minBet || (ctx.config.maxBet !== undefined && bet.amount > ctx.config.maxBet)) {
|
|
111
|
+
throw new MultiplayerInvalidMoveError({
|
|
112
|
+
message: `Bet must be between ${ctx.config.minBet} and ${ctx.config.maxBet ?? "∞"}.`,
|
|
113
|
+
detail: `Bet ${bet.amount} outside limits.`,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
if ((bet.type === "pass" || bet.type === "dont-pass") && round.phase !== "come-out") {
|
|
117
|
+
throw new MultiplayerInvalidTransitionError({
|
|
118
|
+
message: "Pass and don't-pass bets are only allowed on the come-out.",
|
|
119
|
+
detail: `Phase ${round.phase}.`,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
if (bets.some((b) => b.userId === playerId && (b.data as { type: string }).type === bet.type)) {
|
|
123
|
+
throw new MultiplayerInvalidTransitionError({
|
|
124
|
+
message: `You already have a ${bet.type} bet pending.`,
|
|
125
|
+
detail: `Duplicate ${bet.type} for ${playerId}.`,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
return { amount: bet.amount, data: { type: bet.type } };
|
|
129
|
+
},
|
|
130
|
+
|
|
131
|
+
// Only the shooter may roll. Clamp the index in case the roster shrank since it was set.
|
|
132
|
+
eventDriver: (ctx, round) => ctx.players[round.shooterIndex % Math.max(1, ctx.players.length)] ?? null,
|
|
133
|
+
|
|
134
|
+
runEvent(ctx, round, bets) {
|
|
135
|
+
const d1 = ctx.random.int(1, 6);
|
|
136
|
+
const d2 = ctx.random.int(1, 6);
|
|
137
|
+
const sum = d1 + d2;
|
|
138
|
+
|
|
139
|
+
const decisions: BetDecision[] = [];
|
|
140
|
+
for (const bet of bets) {
|
|
141
|
+
const type = (bet.data as { type: CrapsBetType }).type;
|
|
142
|
+
if (type === "field") {
|
|
143
|
+
const w = fieldWin(sum);
|
|
144
|
+
decisions.push({ ref: bet.ref, result: w === null ? "lose" : "win", payout: w === null ? 0 : bet.amount * w });
|
|
145
|
+
} else {
|
|
146
|
+
const result = passOutcome(type, round.phase, round.point, sum);
|
|
147
|
+
if (result !== null) decisions.push({ ref: bet.ref, result, payout: result === "win" ? bet.amount : 0 });
|
|
148
|
+
// null → the pass/don't-pass bet carries (no decision)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Advance the round based on the pass-line outcome.
|
|
153
|
+
let phase = round.phase;
|
|
154
|
+
let point = round.point;
|
|
155
|
+
let shooterIndex = round.shooterIndex;
|
|
156
|
+
if (round.phase === "come-out") {
|
|
157
|
+
if (sum === 4 || sum === 5 || sum === 6 || sum === 8 || sum === 9 || sum === 10) {
|
|
158
|
+
phase = "point";
|
|
159
|
+
point = sum;
|
|
160
|
+
}
|
|
161
|
+
} else if (sum === point) {
|
|
162
|
+
phase = "come-out";
|
|
163
|
+
point = null; // point made — same shooter
|
|
164
|
+
} else if (sum === 7) {
|
|
165
|
+
phase = "come-out";
|
|
166
|
+
point = null;
|
|
167
|
+
shooterIndex = ctx.players.length > 0 ? (round.shooterIndex + 1) % ctx.players.length : 0; // seven-out passes the dice
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return { round: { phase, point, shooterIndex, lastRoll: [d1, d2] as [number, number] }, decisions };
|
|
171
|
+
},
|
|
172
|
+
|
|
173
|
+
view: (ctx, round) => ({
|
|
174
|
+
phase: round.phase,
|
|
175
|
+
point: round.point,
|
|
176
|
+
shooter: ctx.players[round.shooterIndex % Math.max(1, ctx.players.length)] ?? null,
|
|
177
|
+
lastRoll: round.lastRoll,
|
|
178
|
+
}),
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
/** Re-exported so a caller (or a test) can reference a pending craps bet's shape. */
|
|
182
|
+
export type { PendingBet as CrapsPendingBet };
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { z } from "zod";
|
|
5
|
+
import type { LedgerEffect } from "./effects";
|
|
6
|
+
import type { RandomSource } from "./random";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The game-model seam — the one contract that keeps the session infrastructure generic.
|
|
10
|
+
*
|
|
11
|
+
* A `MultiplayerSession` Durable Object knows nothing about any particular game: membership, the lifecycle,
|
|
12
|
+
* hidden state, the alarm-driven deadline, the durable D1 result, the leaderboard publish, and the CLI DO
|
|
13
|
+
* wiring are all game-agnostic. Everything a specific game *is* — how a move validates, how state advances,
|
|
14
|
+
* when it ends, who wins, and what each player is allowed to see — lives behind a {@link GameModel}, looked
|
|
15
|
+
* up by `kind` from the {@link resolveModel registry}. The two built-ins ({@link commitRevealModel} and
|
|
16
|
+
* {@link sequentialModel}) are the two fundamental turn-based shapes — simultaneous and sequential — and an
|
|
17
|
+
* adopter registers their own with {@link registerGameModel}.
|
|
18
|
+
*
|
|
19
|
+
* The seam is code, not data: a model's *logic* is imported into the worker bundle, while only its *state*
|
|
20
|
+
* (a serializable, Zod-validated blob) lives in DO storage. That is what lets the DO — which is constructed
|
|
21
|
+
* by the runtime and cannot be handed a closure — delegate to a model it never imported directly.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** The resolved result of a game, in the model-neutral shape the session records and publishes. */
|
|
25
|
+
export interface ModelOutcome {
|
|
26
|
+
/** Each player's final score, keyed by user id. */
|
|
27
|
+
scores: Record<string, number>;
|
|
28
|
+
/** The winning player's user id, or null on a draw. */
|
|
29
|
+
winnerUserId: string | null;
|
|
30
|
+
/** Whether the game ended level. */
|
|
31
|
+
draw: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** What `apply` returns: the next state, and any ledger effects the DO should settle (a bet's hold). */
|
|
35
|
+
export interface ApplyResult<State> {
|
|
36
|
+
/** The next game state. */
|
|
37
|
+
state: State;
|
|
38
|
+
/** Ledger operations this transition implies — placed a bet, settled a round. Omit for a game with no wagering. */
|
|
39
|
+
effects?: readonly LedgerEffect[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Wrap a bare next-state as an {@link ApplyResult} — the shape a non-wagering model returns. */
|
|
43
|
+
export function nextState<State>(state: State, effects?: readonly LedgerEffect[]): ApplyResult<State> {
|
|
44
|
+
return effects ? { state, effects } : { state };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** What `resolve` returns: the final outcome, and any ledger effects the terminal result implies (payouts). */
|
|
48
|
+
export interface ResolveResult {
|
|
49
|
+
/** The final outcome — scores, a winner, or a draw. */
|
|
50
|
+
outcome: ModelOutcome;
|
|
51
|
+
/** Ledger operations the result implies — capture losing stakes, pay the winner. Omit for a game with no wagering. */
|
|
52
|
+
effects?: readonly LedgerEffect[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** What a model gets on every call: its validated config, the roster in join/turn order, a clock, and RNG. */
|
|
56
|
+
export interface GameContext<Config> {
|
|
57
|
+
/** The session's id (the DO id, hex). Use it to build stable, idempotent ledger-effect refs. */
|
|
58
|
+
sessionId: string;
|
|
59
|
+
/** The game's model-specific config (the `rules` block), already validated by {@link GameModel.config}. */
|
|
60
|
+
config: Config;
|
|
61
|
+
/** The session's members, in join order — also the turn order for sequential games. */
|
|
62
|
+
players: readonly string[];
|
|
63
|
+
/** The current time as ms-epoch, supplied by the DO (never read from a clock inside a model). */
|
|
64
|
+
now: number;
|
|
65
|
+
/**
|
|
66
|
+
* Server-authoritative, provably-fair randomness — the seeded stream the DO advances and persists. Draw
|
|
67
|
+
* from it only in `init` and `apply` (the transitions the DO commits); `resolve` and `redact` must stay
|
|
68
|
+
* pure, or a replay would diverge. A model with no chance (tic-tac-toe) simply never touches it.
|
|
69
|
+
*/
|
|
70
|
+
random: RandomSource;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* One game model — a pluggable implementation of a game's rules over the shared session lifecycle.
|
|
75
|
+
*
|
|
76
|
+
* `Config` is the model's declarative config (the game's `rules` block); `State` is the authoritative game
|
|
77
|
+
* state the DO persists between actions. Both are Zod-validated: `config` at assembly, `state` on every
|
|
78
|
+
* read from DO storage (defense in depth against a corrupt blob).
|
|
79
|
+
*/
|
|
80
|
+
export interface GameModel<Config = unknown, State = unknown> {
|
|
81
|
+
/** The discriminator that matches a game config's `kind`. Unique per registered model. */
|
|
82
|
+
kind: string;
|
|
83
|
+
/** Validates and types the game's model-specific `rules` block. */
|
|
84
|
+
config: z.ZodType<Config>;
|
|
85
|
+
/** Validates and types the persisted game state — round-tripped through DO storage. */
|
|
86
|
+
state: z.ZodType<State>;
|
|
87
|
+
/** The fewest players this model supports (roster size). Defaults to 2 when omitted. */
|
|
88
|
+
minPlayers?: number;
|
|
89
|
+
/** The most players this model supports, or undefined for no upper bound. */
|
|
90
|
+
maxPlayers?: number;
|
|
91
|
+
/** The initial game state, built when the roster fills and play begins. */
|
|
92
|
+
init(ctx: GameContext<Config>): State;
|
|
93
|
+
/**
|
|
94
|
+
* Validate and apply one player's action, returning the next state (and any ledger effects it implies —
|
|
95
|
+
* a placed bet's hold, a settled round's payouts). Throws a `PithyError` (`multiplayer/invalid_move` or
|
|
96
|
+
* `multiplayer/invalid_transition`) when the action is illegal. A rejected action — or one whose effects
|
|
97
|
+
* the ledger cannot settle (a hold a player cannot cover) — is never persisted. May return a bare `State`
|
|
98
|
+
* for a game with no wagering.
|
|
99
|
+
*/
|
|
100
|
+
apply(ctx: GameContext<Config>, state: State, playerId: string, action: unknown): ApplyResult<State>;
|
|
101
|
+
/** Whether the game has reached a terminal position (all committed, someone won, the board is full…). */
|
|
102
|
+
isComplete(ctx: GameContext<Config>, state: State): boolean;
|
|
103
|
+
/**
|
|
104
|
+
* The outcome once {@link isComplete} holds — scores, a winner, or a draw — and any ledger effects the
|
|
105
|
+
* terminal result implies (final payouts).
|
|
106
|
+
*/
|
|
107
|
+
resolve(ctx: GameContext<Config>, state: State): ResolveResult;
|
|
108
|
+
/**
|
|
109
|
+
* The model-specific view one player is allowed to see. This is the hidden-state boundary: redact an
|
|
110
|
+
* opponent's secret information here, and reveal it only when `revealed` is true (the session is
|
|
111
|
+
* terminal). A fully-open game (a visible board) returns the same view to everyone.
|
|
112
|
+
*/
|
|
113
|
+
redact(ctx: GameContext<Config>, state: State, viewerId: string, revealed: boolean): unknown;
|
|
114
|
+
/**
|
|
115
|
+
* Table mode only: a player took a seat mid-session. Optional — react to the seating (a buy-in, dealing
|
|
116
|
+
* them in next round) and declare any ledger effects. `ctx.players` already includes the joiner. Omit for
|
|
117
|
+
* a match-mode game, or a table game that needs no per-join bookkeeping.
|
|
118
|
+
*/
|
|
119
|
+
onJoin?(ctx: GameContext<Config>, state: State, playerId: string): ApplyResult<State>;
|
|
120
|
+
/**
|
|
121
|
+
* Table mode only: a player left their seat mid-session. Optional — settle them out (release their open
|
|
122
|
+
* holds, cash out) and declare ledger effects. `ctx.players` already excludes the leaver. Omit if leaving
|
|
123
|
+
* needs no bookkeeping.
|
|
124
|
+
*/
|
|
125
|
+
onLeave?(ctx: GameContext<Config>, state: State, playerId: string): ApplyResult<State>;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The registry: game `kind` → its model. Populated with the built-ins and any {@link registerGameModel} adds. */
|
|
129
|
+
const registry = new Map<string, GameModel>();
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Register a game model, making its `kind` resolvable by every session. Called at module load — the worker
|
|
133
|
+
* entry and the DO share one isolate, so a model registered when the worker imports is present by the time
|
|
134
|
+
* the DO handles a request. Re-registering the same `kind` replaces it (last write wins), which is what
|
|
135
|
+
* lets an adopter override a built-in.
|
|
136
|
+
*/
|
|
137
|
+
export function registerGameModel(model: GameModel): void {
|
|
138
|
+
registry.set(model.kind, model);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** The model for a `kind`, or undefined if none is registered. */
|
|
142
|
+
export function resolveModel(kind: string): GameModel | undefined {
|
|
143
|
+
return registry.get(kind);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Every registered model's `kind`, for config validation and error messages. */
|
|
147
|
+
export function registeredKinds(): string[] {
|
|
148
|
+
return [...registry.keys()];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** The fewest / most players a model allows, with the shared default of 2 and no upper bound. */
|
|
152
|
+
export function playerBounds(model: GameModel): { min: number; max: number } {
|
|
153
|
+
return { min: model.minPlayers ?? 2, max: model.maxPlayers ?? Number.POSITIVE_INFINITY };
|
|
154
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
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 { GameContext, GameModel, ModelOutcome, ResolveResult } from "../model";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The **simultaneous** pattern — the base helper for games where every player submits a hidden choice at
|
|
10
|
+
* the same time and the server resolves them together once all are in. Rock-paper-scissors, a sealed-bid
|
|
11
|
+
* auction, a battle of secret moves, a hidden vote.
|
|
12
|
+
*
|
|
13
|
+
* It is "simultaneous" in the game-theory sense (players choose without seeing each other), enforced by a
|
|
14
|
+
* *trusted server* holding the plaintext submissions until everyone has committed — which is the model this
|
|
15
|
+
* package provides. (It is not a cryptographic commit-reveal protocol; a trustless hash-commit-then-reveal
|
|
16
|
+
* scheme would be a separate variant.)
|
|
17
|
+
*
|
|
18
|
+
* A game built on this helper supplies only what is game-specific: the shape of a submission, any extra
|
|
19
|
+
* validation, and how to score all submissions. The helper owns the lifecycle — collect one submission per
|
|
20
|
+
* player, reject a second, resolve when the last lands — and the hidden-state boundary (your own submission
|
|
21
|
+
* is visible; everyone else's is hidden until the game is terminal).
|
|
22
|
+
*/
|
|
23
|
+
export interface SimultaneousSpec<Config, Submission> {
|
|
24
|
+
/** The game's `kind` — its registry key. */
|
|
25
|
+
kind: string;
|
|
26
|
+
/** The game's config schema (the `rules` block). */
|
|
27
|
+
config: z.ZodType<Config>;
|
|
28
|
+
/** The schema for one player's hidden submission — the body of their action. */
|
|
29
|
+
submission: z.ZodType<Submission>;
|
|
30
|
+
/** The fewest / most players (defaults to 2 / no cap). */
|
|
31
|
+
minPlayers?: number;
|
|
32
|
+
maxPlayers?: number;
|
|
33
|
+
/** Extra validation of a submission beyond its schema (e.g. "exactly 3 distinct moves"). Throw a `PithyError`. */
|
|
34
|
+
validate?: (config: Config, submission: Submission, ctx: GameContext<Config>) => void;
|
|
35
|
+
/** Resolve every player's submission into an outcome (and optional ledger effects) once all have submitted. */
|
|
36
|
+
score: (ctx: GameContext<Config>, submissions: Record<string, Submission>) => ResolveResult | ModelOutcome;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The persisted state of a simultaneous game: each player's hidden submission, keyed by user id. */
|
|
40
|
+
type SimultaneousState<Submission> = { submissions: Record<string, Submission> };
|
|
41
|
+
|
|
42
|
+
/** Build a {@link GameModel} for a simultaneous game from its {@link SimultaneousSpec}. */
|
|
43
|
+
export function simultaneous<Config, Submission>(
|
|
44
|
+
spec: SimultaneousSpec<Config, Submission>,
|
|
45
|
+
): GameModel<Config, SimultaneousState<Submission>> {
|
|
46
|
+
const state = z
|
|
47
|
+
.object({
|
|
48
|
+
submissions: z.record(z.string(), spec.submission).describe("Each player's hidden submission, keyed by user id."),
|
|
49
|
+
})
|
|
50
|
+
.describe(`The ${spec.kind} game's persisted submissions.`);
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
kind: spec.kind,
|
|
54
|
+
config: spec.config,
|
|
55
|
+
state,
|
|
56
|
+
minPlayers: spec.minPlayers,
|
|
57
|
+
maxPlayers: spec.maxPlayers,
|
|
58
|
+
|
|
59
|
+
init: () => ({ submissions: {} }),
|
|
60
|
+
|
|
61
|
+
apply(ctx, current, playerId, action) {
|
|
62
|
+
if (current.submissions[playerId] !== undefined) {
|
|
63
|
+
throw new MultiplayerInvalidTransitionError({
|
|
64
|
+
message: "You have already submitted.",
|
|
65
|
+
detail: `${playerId} already submitted in ${spec.kind}.`,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
const submission = spec.submission.parse(action);
|
|
69
|
+
spec.validate?.(ctx.config, submission, ctx);
|
|
70
|
+
return { state: { submissions: { ...current.submissions, [playerId]: submission } } };
|
|
71
|
+
},
|
|
72
|
+
|
|
73
|
+
isComplete: (ctx, current) => ctx.players.every((player) => current.submissions[player] !== undefined),
|
|
74
|
+
|
|
75
|
+
resolve(ctx, current) {
|
|
76
|
+
const result = spec.score(ctx, current.submissions);
|
|
77
|
+
return "outcome" in result ? result : { outcome: result };
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
// The hidden-state boundary: your submission is always visible; opponents' are hidden until the reveal.
|
|
81
|
+
redact(ctx, current, viewerId, revealed) {
|
|
82
|
+
return {
|
|
83
|
+
you: {
|
|
84
|
+
userId: viewerId,
|
|
85
|
+
submitted: current.submissions[viewerId] !== undefined,
|
|
86
|
+
submission: current.submissions[viewerId] ?? null,
|
|
87
|
+
},
|
|
88
|
+
opponents: ctx.players
|
|
89
|
+
.filter((member) => member !== viewerId)
|
|
90
|
+
.map((member) => ({
|
|
91
|
+
userId: member,
|
|
92
|
+
submitted: current.submissions[member] !== undefined,
|
|
93
|
+
submission: revealed ? (current.submissions[member] ?? null) : null,
|
|
94
|
+
})),
|
|
95
|
+
};
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
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, ModelOutcome, ResolveResult } from "../model";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The **turn-based** pattern — the base helper for games where players act one at a time, in order, against
|
|
11
|
+
* a shared state. Tic-tac-toe, Connect Four, chess, checkers, a card game's tricks.
|
|
12
|
+
*
|
|
13
|
+
* The helper owns turn order and advancement: it tracks whose turn it is, rejects a move out of turn, and
|
|
14
|
+
* rotates to the next player after each move. A game built on it supplies only the game-specific parts — the
|
|
15
|
+
* shared state, how a move changes it, when the game ends, and who won. The turn plumbing is not the game's
|
|
16
|
+
* to write.
|
|
17
|
+
*/
|
|
18
|
+
export interface TurnBasedSpec<Config, Game, Move> {
|
|
19
|
+
/** The game's `kind` — its registry key. */
|
|
20
|
+
kind: string;
|
|
21
|
+
/** The game's config schema (the `rules` block). */
|
|
22
|
+
config: z.ZodType<Config>;
|
|
23
|
+
/** The schema for the game-specific shared state (turn tracking is the helper's, not this). */
|
|
24
|
+
game: z.ZodType<Game>;
|
|
25
|
+
/** The schema for one move (the body of a player's action). */
|
|
26
|
+
move: z.ZodType<Move>;
|
|
27
|
+
/** The fewest / most players (defaults to 2 / no cap). */
|
|
28
|
+
minPlayers?: number;
|
|
29
|
+
maxPlayers?: number;
|
|
30
|
+
/** The initial game state when play begins. */
|
|
31
|
+
start: (ctx: GameContext<Config>) => Game;
|
|
32
|
+
/** Apply the current player's validated move, returning the next game state (+ optional ledger effects). Throw a `PithyError` on an illegal move. */
|
|
33
|
+
play: (
|
|
34
|
+
ctx: GameContext<Config>,
|
|
35
|
+
game: Game,
|
|
36
|
+
playerId: string,
|
|
37
|
+
move: Move,
|
|
38
|
+
) => { game: Game; effects?: readonly LedgerEffect[] } | Game;
|
|
39
|
+
/** Whether the game has ended (someone won, the board is full…). */
|
|
40
|
+
isEnd: (ctx: GameContext<Config>, game: Game) => boolean;
|
|
41
|
+
/** The outcome once {@link isEnd} holds. */
|
|
42
|
+
score: (ctx: GameContext<Config>, game: Game) => ResolveResult | ModelOutcome;
|
|
43
|
+
/** Optional game-specific view; the helper adds the turn info around it. Defaults to the raw game state. */
|
|
44
|
+
view?: (ctx: GameContext<Config>, game: Game, viewerId: string) => unknown;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The persisted state of a turn-based game: whose turn (as a roster index) and the game-specific state. */
|
|
48
|
+
type TurnBasedState<Game> = { turnIndex: number; game: Game };
|
|
49
|
+
|
|
50
|
+
/** Build a {@link GameModel} for a turn-based game from its {@link TurnBasedSpec}. */
|
|
51
|
+
export function turnBased<Config, Game, Move>(
|
|
52
|
+
spec: TurnBasedSpec<Config, Game, Move>,
|
|
53
|
+
): GameModel<Config, TurnBasedState<Game>> {
|
|
54
|
+
const state = z
|
|
55
|
+
.object({
|
|
56
|
+
turnIndex: z.number().int().describe("Index into the roster of whose turn it is."),
|
|
57
|
+
game: spec.game.describe("The game-specific shared state."),
|
|
58
|
+
})
|
|
59
|
+
.describe(`The ${spec.kind} game's state — the turn pointer and the shared position.`);
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
kind: spec.kind,
|
|
63
|
+
config: spec.config,
|
|
64
|
+
state,
|
|
65
|
+
minPlayers: spec.minPlayers,
|
|
66
|
+
maxPlayers: spec.maxPlayers,
|
|
67
|
+
|
|
68
|
+
init: (ctx) => ({ turnIndex: 0, game: spec.start(ctx) }),
|
|
69
|
+
|
|
70
|
+
apply(ctx, current, playerId, action) {
|
|
71
|
+
if (playerId !== ctx.players[current.turnIndex]) {
|
|
72
|
+
throw new MultiplayerInvalidTransitionError({
|
|
73
|
+
message: "It is not your turn.",
|
|
74
|
+
detail: `${playerId} moved out of turn (turn is ${ctx.players[current.turnIndex]}).`,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
const move = spec.move.parse(action);
|
|
78
|
+
const played = spec.play(ctx, current.game, playerId, move);
|
|
79
|
+
const game = played !== null && typeof played === "object" && "game" in played ? played.game : (played as Game);
|
|
80
|
+
const effects = played !== null && typeof played === "object" && "effects" in played ? played.effects : undefined;
|
|
81
|
+
const turnIndex = (current.turnIndex + 1) % ctx.players.length;
|
|
82
|
+
return { state: { turnIndex, game }, effects };
|
|
83
|
+
},
|
|
84
|
+
|
|
85
|
+
isComplete: (ctx, current) => spec.isEnd(ctx, current.game),
|
|
86
|
+
|
|
87
|
+
resolve(ctx, current) {
|
|
88
|
+
const result = spec.score(ctx, current.game);
|
|
89
|
+
return "outcome" in result ? result : { outcome: result };
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
redact(ctx, current, viewerId) {
|
|
93
|
+
const turn = ctx.players[current.turnIndex] ?? null;
|
|
94
|
+
return {
|
|
95
|
+
turn,
|
|
96
|
+
yourTurn: turn === viewerId,
|
|
97
|
+
...(spec.view ? { game: spec.view(ctx, current.game, viewerId) } : { game: current.game }),
|
|
98
|
+
};
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|