@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,117 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { z } from "zod";
5
+ import { MultiplayerLeaderboard } from "../config/config";
6
+ import { RngState } from "../game/random";
7
+
8
+ /**
9
+ * A session's lifecycle phase — game-agnostic. `open` and `active` are live; `resolved` and `abandoned` are
10
+ * terminal and never transition again.
11
+ *
12
+ * open → created, waiting for the roster to fill.
13
+ * active → the roster is full and play is underway (players committing, or taking turns).
14
+ * resolved → the game reached a terminal position and the server computed a result. Terminal.
15
+ * abandoned → the action deadline passed with the game unfinished. Terminal.
16
+ */
17
+ export const SessionPhase = z
18
+ .enum(["open", "active", "resolved", "abandoned"])
19
+ .describe("The session's lifecycle phase. `open`/`active` are live; `resolved`/`abandoned` are terminal.");
20
+ export type SessionPhase = z.infer<typeof SessionPhase>;
21
+
22
+ /** True when a phase is terminal — no further transitions are allowed from it. */
23
+ export function isTerminal(phase: SessionPhase): boolean {
24
+ return phase === "resolved" || phase === "abandoned";
25
+ }
26
+
27
+ /**
28
+ * A snapshot of a game's resolved config, taken at session creation and stored in the DO. The session
29
+ * resolves against this snapshot and never reads live app config again, so a later config edit cannot
30
+ * change a session already in flight. `rules` is the model-specific block, kept opaque here (the game's
31
+ * model validates and interprets it) — the session infrastructure treats every game the same.
32
+ */
33
+ export const GameSnapshot = z
34
+ .object({
35
+ key: z.string().describe("The game key this session plays."),
36
+ kind: z.string().describe("The game model's `kind` — the DO resolves the model from the registry by this."),
37
+ mode: z
38
+ .enum(["match", "table"])
39
+ .describe("`match` (fill → one game → end) or `table` (long-lived, many rounds, join/leave between)."),
40
+ players: z.number().int().describe("Match mode: the exact roster. Table mode: the maximum seats."),
41
+ turnTimeoutMs: z.number().int().nullable().describe("The commit/turn deadline in ms, or null for no deadline."),
42
+ leaderboard: MultiplayerLeaderboard.nullable().describe("The leaderboard publish target, or null."),
43
+ rules: z
44
+ .unknown()
45
+ .describe("The model-specific rules block — interpreted by the game's model, opaque to the session."),
46
+ })
47
+ .describe("A snapshot of a game's resolved config, stored in the session's Durable Object.");
48
+ export type GameSnapshot = z.infer<typeof GameSnapshot>;
49
+
50
+ /**
51
+ * A session's authoritative metadata, persisted in the Durable Object's own storage — the single source of
52
+ * truth, read fresh on each request (the DO holds nothing important in memory). Game-specific state lives
53
+ * separately under the model's own storage key.
54
+ */
55
+ export const SessionMeta = z
56
+ .object({
57
+ sessionId: z.string().describe("The Durable Object id (hex) this session is addressed by."),
58
+ game: GameSnapshot.describe("The game's config snapshot — model, roster size, rules, deadline, leaderboard."),
59
+ phase: SessionPhase.describe("The session's current lifecycle phase."),
60
+ members: z
61
+ .array(z.string())
62
+ .describe("The authenticated user ids of the players, in join order. Never client-asserted."),
63
+ createdAt: z.number().int().describe("When the session was created, ms-epoch."),
64
+ deadline: z
65
+ .number()
66
+ .int()
67
+ .nullable()
68
+ .describe(
69
+ "The action deadline as an absolute ms-epoch time, or null when the session waits indefinitely. Enforced by the DO alarm.",
70
+ ),
71
+ rng: RngState.describe(
72
+ "The session's provably-fair RNG — the seed models draw from, its commitment, and the stream position.",
73
+ ),
74
+ })
75
+ .describe("A session's authoritative metadata, persisted in Durable Object storage.");
76
+ export type SessionMeta = z.infer<typeof SessionMeta>;
77
+
78
+ /** The resolved outcome persisted alongside a terminal session (mirrors the D1 result row's payload). */
79
+ export const SessionOutcome = z
80
+ .object({
81
+ status: z.enum(["resolved", "abandoned"]).describe("The terminal state."),
82
+ scores: z.record(z.string(), z.number()).nullable().describe("Each player's score, or null when abandoned."),
83
+ winnerUserId: z.string().nullable().describe("The winner's user id, or null on a draw or abandonment."),
84
+ draw: z.boolean().describe("Whether the session ended level."),
85
+ resolvedAt: z.number().int().describe("When the session reached its terminal state, ms-epoch."),
86
+ })
87
+ .describe("The resolved outcome of a terminal session.");
88
+ export type SessionOutcome = z.infer<typeof SessionOutcome>;
89
+
90
+ /**
91
+ * What one player sees of a session — the game-agnostic envelope. The session fields (phase, players,
92
+ * outcome) are the same for every game; `state` is the model-specific view the game's `redact` produced for
93
+ * this viewer, and it is where hidden state is enforced (an opponent's secret is absent until the reveal).
94
+ */
95
+ export const SessionView = z
96
+ .object({
97
+ sessionId: z.string().describe("The session id."),
98
+ gameKey: z.string().describe("The game being played."),
99
+ kind: z.string().describe("The game model this session uses."),
100
+ phase: SessionPhase.describe("The session's current phase."),
101
+ players: z.array(z.string()).describe("Every member's user id, in join order."),
102
+ outcome: SessionOutcome.nullable().describe("The result, present only once the session is terminal."),
103
+ state: z.unknown().describe("The model-specific view for this viewer, redacted by the game's model."),
104
+ fairness: z
105
+ .object({
106
+ seedHash: z
107
+ .string()
108
+ .describe("The RNG seed's SHA-256 commitment — shown from the start, so a player can verify fairness later."),
109
+ seed: z
110
+ .string()
111
+ .nullable()
112
+ .describe("The RNG seed itself — null until the session is terminal, then revealed to verify every draw."),
113
+ })
114
+ .describe("The provably-fair commitment (and, once terminal, the reveal) for this session's randomness."),
115
+ })
116
+ .describe("One player's view of a session — the game-agnostic envelope around a model-specific, redacted state.");
117
+ export type SessionView = z.infer<typeof SessionView>;
@@ -0,0 +1,18 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { MultiplayerSession } from "./durableObject";
5
+
6
+ /**
7
+ * The worker entry the Workers-runtime test project points `main` at, so Miniflare can register the
8
+ * `SESSIONS` Durable Object namespace against the real class. It re-exports `MultiplayerSession` exactly
9
+ * as an adopter's own worker does (`export { MultiplayerSession } from "@pithy-sh/multiplayer/src/session/durableObject"`),
10
+ * plus an inert `fetch` the pool requires. Excluded from coverage — it is test scaffolding, not shipped code.
11
+ */
12
+ export { MultiplayerSession };
13
+
14
+ export default {
15
+ fetch(): Response {
16
+ return new Response("multiplayer test worker", { status: 200 });
17
+ },
18
+ };
@@ -0,0 +1,16 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ // GENERATED by scripts/stampVersions.ts — do not edit by hand. Regenerate with `bun run stamp-versions`.
5
+ //
6
+ // A Worker cannot read its own package.json, so this is how @pithy-sh/multiplayer knows its own version at
7
+ // runtime. The capability attaches it, and `GET /control-plane/manifest` reports it per capability —
8
+ // which is what answers "should this project upgrade" and "is this customer exposed to what we just
9
+ // fixed". Those questions are only answerable per module, because a project composes some capabilities
10
+ // and not others.
11
+
12
+ /** This package's npm name — the join key against a release feed. */
13
+ export const PACKAGE_NAME = "@pithy-sh/multiplayer";
14
+
15
+ /** This package's version, stamped from its own package.json at generation time. */
16
+ export const PACKAGE_VERSION = "0.1.0";