@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/src/index.ts ADDED
@@ -0,0 +1,83 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ /**
5
+ * The package entrypoint — the surface `pithy add multiplayer` wires into `pithy.config.ts`. Deliberately
6
+ * narrow: the capability factory, its config and options types, the game-model seam, and the session shapes
7
+ * an app renders. Every other module is imported by deep path (`@pithy-sh/multiplayer/src/...`); this is the
8
+ * documented contract, not a barrel over the package.
9
+ *
10
+ * **The `MultiplayerSession` Durable Object is deliberately not here.** It imports `cloudflare:workers`,
11
+ * which resolves in workerd and nowhere else, and this module is what an adopter's `pithy.config.ts`
12
+ * imports — a file loaded by every Node-side CLI command. Re-exporting the class from here put the whole
13
+ * Durable Object chain on that path and broke `pithy upgrade` for any project composing multiplayer (#172).
14
+ * The factory and the DO are two things with two runtimes, and this entry point carries only the first.
15
+ *
16
+ * The adopter's worker still exports the class, from its own module:
17
+ *
18
+ * ```ts
19
+ * export { MultiplayerSession } from "@pithy-sh/multiplayer/src/session/durableObject";
20
+ * ```
21
+ *
22
+ * — which is what wrangler's `class_name` resolves against, and `pithy add multiplayer` writes that line
23
+ * for you, beside the binding and the class migration tag (#428). To ship a custom game model, call
24
+ * `registerGameModel(myModel)` in the worker entry.
25
+ */
26
+
27
+ export {
28
+ isMultiplayerCapability,
29
+ MULTIPLAYER_MIGRATION_ORDER,
30
+ MULTIPLAYER_SESSION_CLASS,
31
+ MULTIPLAYER_SESSION_MODULE,
32
+ MULTIPLAYER_SESSIONS_BINDING,
33
+ type MultiplayerCapability,
34
+ type MultiplayerOptions,
35
+ multiplayer,
36
+ } from "./capability";
37
+ export {
38
+ MultiplayerConfig,
39
+ type MultiplayerConfigInput,
40
+ MultiplayerGame,
41
+ MultiplayerLeaderboard,
42
+ type ResolvedGame,
43
+ resolveGame,
44
+ validateGames,
45
+ } from "./config/config";
46
+ export { MultiplayerResult, SessionResultStatus } from "./data/result";
47
+ export { BUILT_IN_GAMES } from "./game/builtins";
48
+ export type { LedgerEffect } from "./game/effects";
49
+ // Example games, each built on a reusable pattern helper.
50
+ export {
51
+ BattleConfig,
52
+ battleGame,
53
+ DefenseMove,
54
+ Move,
55
+ OffenseMove,
56
+ scoreBattle,
57
+ validateMove,
58
+ } from "./game/games/battle";
59
+ export { BoardState, ConnectNConfig, connectNGame, findWinner, GridMove } from "./game/games/connectN";
60
+ export { CrapsBet, CrapsBetType, CrapsConfig, CrapsRound, crapsGame } from "./game/games/craps";
61
+ // The game-model seam and its registry — the extension point for custom games.
62
+ export {
63
+ type ApplyResult,
64
+ type GameContext,
65
+ type GameModel,
66
+ type ModelOutcome,
67
+ nextState,
68
+ type ResolveResult,
69
+ registeredKinds,
70
+ registerGameModel,
71
+ resolveModel,
72
+ } from "./game/model";
73
+ // The pattern helpers — layer a new game on one of these.
74
+ export { type SimultaneousSpec, simultaneous } from "./game/patterns/simultaneous";
75
+ export { type TurnBasedSpec, turnBased } from "./game/patterns/turnBased";
76
+ export {
77
+ type BetDecision,
78
+ type PendingBet,
79
+ type WageringTableSpec,
80
+ wageringTable,
81
+ } from "./game/patterns/wageringTable";
82
+ export { createRngState, type RandomSource, RngState, randomSource } from "./game/random";
83
+ export { GameSnapshot, isTerminal, type SessionOutcome, SessionPhase, SessionView } from "./session/state";
@@ -0,0 +1,52 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { Kysely } from "kysely";
5
+ import type { Migration } from "kysely/migration";
6
+
7
+ /**
8
+ * The multiplayer results table — the durable record a terminal session writes.
9
+ *
10
+ * camelCase identifiers; `CamelCasePlugin` snake-cases them in the DDL. `down` is the tested inverse.
11
+ *
12
+ * There is no session/membership/commit table on purpose: live session state lives in the Durable
13
+ * Object's own storage, which is where authority and hidden state belong. D1 holds only what must outlive
14
+ * the object and be queryable beside the adopter's tables — the result.
15
+ */
16
+ export const multiplayer_0001_results: Migration = {
17
+ up: async (db: Kysely<unknown>): Promise<void> => {
18
+ await db.schema
19
+ .createTable("pithyMultiplayerResults")
20
+ // Plain INTEGER PRIMARY KEY (a rowid alias), not autoincrement — the id is internal and never
21
+ // exposed, so the never-reuse-a-deleted-id guarantee autoincrement adds is not worth its cost.
22
+ .addColumn("id", "integer", (c) => c.primaryKey())
23
+ .addColumn("sessionId", "text", (c) => c.notNull().unique())
24
+ .addColumn("gameKey", "text", (c) => c.notNull())
25
+ .addColumn("status", "text", (c) => c.notNull())
26
+ .addColumn("players", "text", (c) => c.notNull())
27
+ .addColumn("scores", "text")
28
+ .addColumn("winnerUserId", "text")
29
+ .addColumn("draw", "integer", (c) => c.notNull().defaultTo(0))
30
+ .addColumn("createdAt", "integer", (c) => c.notNull())
31
+ .addColumn("resolvedAt", "integer", (c) => c.notNull())
32
+ .execute();
33
+
34
+ // Results are browsed by game ("recent battles") and by winner ("this player's wins"), so both get an
35
+ // index. The unique constraint on sessionId already covers by-session lookups.
36
+ await db.schema
37
+ .createIndex("pithyMultiplayerResultsGameIdx")
38
+ .on("pithyMultiplayerResults")
39
+ .columns(["gameKey", "resolvedAt"])
40
+ .execute();
41
+ await db.schema
42
+ .createIndex("pithyMultiplayerResultsWinnerIdx")
43
+ .on("pithyMultiplayerResults")
44
+ .columns(["winnerUserId"])
45
+ .execute();
46
+ },
47
+ down: async (db: Kysely<unknown>): Promise<void> => {
48
+ await db.schema.dropIndex("pithyMultiplayerResultsWinnerIdx").execute();
49
+ await db.schema.dropIndex("pithyMultiplayerResultsGameIdx").execute();
50
+ await db.schema.dropTable("pithyMultiplayerResults").execute();
51
+ },
52
+ };
@@ -0,0 +1,59 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { D1Database } from "@cloudflare/workers-types";
5
+ import { LeaderboardBoard } from "@pithy-sh/leaderboard/src/config/config";
6
+ import { leaderboardDatabase } from "@pithy-sh/leaderboard/src/data/tables";
7
+ import { entryStore } from "@pithy-sh/leaderboard/src/entry/store";
8
+ import { ALL_TIME_WINDOW, windowKeyAt } from "@pithy-sh/leaderboard/src/window/schedule";
9
+ import type { MultiplayerLeaderboard } from "../config/config";
10
+
11
+ /** What resolution hands the publisher — who played, who won, and when. */
12
+ export interface PublishInput {
13
+ /** The session's members, in join order. */
14
+ members: readonly string[];
15
+ /** The winner's user id, or null on a draw. */
16
+ winnerUserId: string | null;
17
+ /** Whether the session was a draw. */
18
+ draw: boolean;
19
+ /** When the session resolved — the instant the window is computed from and the score is stamped with. */
20
+ at: Date;
21
+ }
22
+
23
+ /**
24
+ * Publish a resolved session's result to a `@pithy-sh/leaderboard` board — one-way, and only ever loaded
25
+ * by dynamic import from the DO so leaderboard stays an optional peer.
26
+ *
27
+ * This is the composition seam the capability exists to demonstrate: a session's authority ends at its
28
+ * result, and that result flows *into* the leaderboard's own submit path — the same `INSERT … ON CONFLICT`
29
+ * upsert a score submission uses — rather than reimplementing ranking. Leaderboard never depends on
30
+ * multiplayer; the arrow runs one direction only.
31
+ *
32
+ * The board's coordinates (direction, aggregation, window) are carried on the game's `leaderboard` config
33
+ * and must match the leaderboard board's own definition — they decide how the awarded points fold in. A
34
+ * points board is `sum`/`desc` by default: each session adds the winner's, loser's, or draw points to a
35
+ * running total.
36
+ */
37
+ export async function publishResultToLeaderboard(
38
+ d1: D1Database,
39
+ config: MultiplayerLeaderboard,
40
+ input: PublishInput,
41
+ ): Promise<void> {
42
+ const board = LeaderboardBoard.parse({
43
+ key: config.board,
44
+ direction: config.direction,
45
+ aggregation: config.aggregation,
46
+ window: config.window,
47
+ });
48
+ const windowKey = config.window ? windowKeyAt(config.window, input.at) : ALL_TIME_WINDOW;
49
+ const store = entryStore(leaderboardDatabase(d1));
50
+
51
+ for (const userId of input.members) {
52
+ const points = input.draw
53
+ ? config.points.draw
54
+ : userId === input.winnerUserId
55
+ ? config.points.win
56
+ : config.points.loss;
57
+ await store.submit(board, windowKey, userId, points, input.at, true);
58
+ }
59
+ }
@@ -0,0 +1,54 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { EXAMPLE_ADA, EXAMPLE_ALAN, EXAMPLE_GRACE } from "@pithy-sh/core/src/seed/exampleIdentities";
5
+ import { d1SeedGroup, defineSeed, type SeedSet } from "@pithy-sh/core/src/seed/seed";
6
+ import { MultiplayerResult } from "../data/result";
7
+ import { MULTIPLAYER_RESULTS_TABLE } from "../data/tables";
8
+
9
+ /**
10
+ * Where the example set sorts among the whole project's seed registry. It runs after `auth` (100),
11
+ * whose example seeds the users this result belongs to, so the owning identities exist first — the
12
+ * order encodes that dependency, exactly like the migration registry. It sits after leaderboard's own
13
+ * example (200), matching migration order (multiplayer sits after leaderboard, at 500). It need not
14
+ * line up with {@link MULTIPLAYER_MIGRATION_ORDER} (a different registry, composed separately by
15
+ * `pithy seed`).
16
+ */
17
+ const MULTIPLAYER_EXAMPLE_SEED_ORDER = 300;
18
+
19
+ const now = () => new Date();
20
+
21
+ /**
22
+ * A single resolved demo match: Ada wins a `demo` session over Grace and Alan — the same shared cast
23
+ * `auth` seeds and `leaderboard`/`ledger` also reference. The row is authored as the durable *result*
24
+ * a session writes once it reaches its terminal state — the natural shape `pithy_multiplayer_results`
25
+ * stores, not a live session (which lives only in the Durable Object while play is underway). Composed
26
+ * in only when the project turns on `seed.includeExamples` (`pithy.config.ts`), and only for `dev` and
27
+ * `staging` — an example fixture never targets production, regardless of that setting.
28
+ */
29
+ export const multiplayerExampleSeed: SeedSet = defineSeed({
30
+ name: "example",
31
+ order: MULTIPLAYER_EXAMPLE_SEED_ORDER,
32
+ environments: ["dev", "staging"],
33
+ example: true,
34
+ d1: [
35
+ d1SeedGroup("app", MULTIPLAYER_RESULTS_TABLE, MultiplayerResult, [
36
+ {
37
+ id: 1,
38
+ sessionId: "example000000000000000000000001",
39
+ gameKey: "demo",
40
+ status: "resolved",
41
+ players: [EXAMPLE_ADA.id, EXAMPLE_GRACE.id, EXAMPLE_ALAN.id],
42
+ scores: {
43
+ [EXAMPLE_ADA.id]: 300,
44
+ [EXAMPLE_GRACE.id]: 250,
45
+ [EXAMPLE_ALAN.id]: 200,
46
+ },
47
+ winnerUserId: EXAMPLE_ADA.id,
48
+ draw: false,
49
+ createdAt: now(),
50
+ resolvedAt: now(),
51
+ },
52
+ ]),
53
+ ],
54
+ });