@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
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
|
+
});
|