@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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Pithy
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# @pithy-sh/multiplayer
|
|
2
|
+
|
|
3
|
+
Authoritative, turn-based multiplayer sessions on Cloudflare. The server holds the game state no client can be trusted with, resolves it, and writes a durable result to your own D1.
|
|
4
|
+
|
|
5
|
+
Sessions infrastructure plus pluggable game models. The session is the same for every game; what a *game* is lives behind a `GameModel`. Three example games ship, and you can register your own.
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
pithy add multiplayer
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
**Documentation: [pithy.sh/docs/capabilities/multiplayer](https://pithy.sh/docs/capabilities/multiplayer).** Overview, adding it, using it, and the reference: sessions, the game-model seam, hidden state, randomness.
|
|
12
|
+
|
|
13
|
+
_Everything else is on the site. `pithy.sh/docs` is canonical — new prose goes there, not here._
|
|
14
|
+
|
|
15
|
+
## License
|
|
16
|
+
|
|
17
|
+
MIT — adopter-side app value, the same as `@pithy-sh/auth` and `@pithy-sh/leaderboard`. The root `LICENSE` covers it.
|
package/docs/costs.md
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# Multiplayer costs
|
|
2
|
+
|
|
3
|
+
_The reader's version of this page is [pithy.sh/docs/capabilities/multiplayer](https://pithy.sh/docs/capabilities/multiplayer). This copy ships in the package because `packages/multiplayer/scripts/costModel.ts` sends an adopter to it by name._
|
|
4
|
+
|
|
5
|
+
Rates as of **2026-07-16**. Cloudflare's [Durable Objects pricing](https://developers.cloudflare.com/durable-objects/platform/pricing/) is the authority — this page explains how a session maps onto it, and carries the two caveats that make a naive estimate wrong. When the two disagree, Cloudflare is right.
|
|
6
|
+
|
|
7
|
+
A multiplayer session is one Durable Object. You pay for three things: requests, duration, and storage. The shape of the bill is dominated by one fact — **a session waiting on a player's turn hibernates, and a hibernating object bills no duration.** Turn-based, asynchronous play is the quadrant where a Durable Object is genuinely excellent, and it is the only quadrant this capability serves.
|
|
8
|
+
|
|
9
|
+
## The 20:1 WebSocket caveat
|
|
10
|
+
|
|
11
|
+
This is the number that makes a headline estimate wrong, so it comes first.
|
|
12
|
+
|
|
13
|
+
Cloudflare bills WebSocket *messages* as requests, but applies a **20:1 ratio for billing**: *"for compute requests billing-only, a 20:1 ratio is applied to incoming WebSocket messages to factor in smaller messages for real-time communication."* Twenty inbound WebSocket messages bill as one request.
|
|
14
|
+
|
|
15
|
+
A cost model built off the headline $0.15 per million request rate, counting every WebSocket frame as a request, **overstates a chatty session by up to 20×.** Getting this wrong is a brand risk, not a rounding error — Pithy publishes honest cost models, so a figure here that ignored the 20:1 ratio would be worse than no figure.
|
|
16
|
+
|
|
17
|
+
A turn-based session is not chatty. A few players, a handful of messages each — a create, a join, an action per turn, a resolve. Even before the 20:1 discount, a whole session is a few dozen billable events, whatever the game model.
|
|
18
|
+
|
|
19
|
+
## Duration bills the full 128 MB, and only while awake
|
|
20
|
+
|
|
21
|
+
Duration is `GB-s = seconds × 128 MB ÷ 1 GB`. The 128 MB is fixed: **you pay for the full allocation regardless of how little memory the session actually uses**, so per-object duration cost is memory-independent. There is no saving to chase in a smaller state.
|
|
22
|
+
|
|
23
|
+
The saving to chase is hibernation. Duration is billed *"while the Durable Object is actively running or is idle in memory but unable to hibernate."* A session that has both players' commits and is waiting on nothing resolves and goes terminal. A session waiting on a slow player **hibernates between messages and bills no duration** — which is why this capability uses the WebSocket Hibernation API, alarms instead of timers, and holds nothing important in memory. A single `setInterval` anywhere would forfeit all of it: timers prevent hibernation entirely, pinning the object in memory and billing duration continuously.
|
|
24
|
+
|
|
25
|
+
This is also why real-time action netcode is out of scope. A fixed high-rate tick loop must wake constantly, so it never hibernates and bills duration the whole match — the exact cost this capability is designed to avoid.
|
|
26
|
+
|
|
27
|
+
## Storage
|
|
28
|
+
|
|
29
|
+
Session state lives in the object's SQLite-backed storage. Rows are billed exactly as D1 rows, and each object caps at 10 GB. A duel's state is tiny — metadata, two commits, one outcome — and the object is a natural place to let it expire; the durable *result* is a single row in your app's D1 (`pithy_multiplayer_results`), joinable beside your own tables.
|
|
30
|
+
|
|
31
|
+
## Cheap idle sessions are a platform primitive, not a Pithy feature
|
|
32
|
+
|
|
33
|
+
An idle, hibernating session costing effectively nothing is a property of Durable Objects — anyone building on a DO gets it. It is not a Pithy differentiator, and this page does not claim it as one. What Pithy adds is the wiring (the DO binding, the class migration tag, the per-environment namespaces) and the authority (hidden state, commit-reveal resolution) on top — not the idle economics.
|
|
34
|
+
|
|
35
|
+
## Run the model
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
bun run --filter @pithy-sh/multiplayer costs
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
`scripts/costModel.ts` estimates a monthly bill from a session volume and a per-session message count, applying the 20:1 ratio and the free tiers. It is a planning aid, not a quote — Cloudflare's live pricing is the authority, and the free tiers and rates move.
|
package/package.json
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pithy-sh/multiplayer",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/pithy-sh/pithy.git",
|
|
8
|
+
"directory": "packages/multiplayer"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"src",
|
|
12
|
+
"pithy.manifest.json",
|
|
13
|
+
"docs",
|
|
14
|
+
"!src/**/*.test.*"
|
|
15
|
+
],
|
|
16
|
+
"type": "module",
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=22"
|
|
19
|
+
},
|
|
20
|
+
"exports": {
|
|
21
|
+
"./src/*": "./src/*.ts"
|
|
22
|
+
},
|
|
23
|
+
"scripts": {
|
|
24
|
+
"build": "tsc -p tsconfig.json --noEmit false --outDir dist",
|
|
25
|
+
"typecheck": "tsc -p tsconfig.json",
|
|
26
|
+
"test": "vitest run",
|
|
27
|
+
"test:node": "vitest run --project=node",
|
|
28
|
+
"test:workers": "vitest run --project=workers",
|
|
29
|
+
"costs": "bun scripts/costModel.ts",
|
|
30
|
+
"clean": "rm -rf dist .turbo",
|
|
31
|
+
"reset": "bun run clean && rm -rf node_modules"
|
|
32
|
+
},
|
|
33
|
+
"peerDependencies": {
|
|
34
|
+
"@pithy-sh/leaderboard": "workspace:*",
|
|
35
|
+
"@pithy-sh/ledger": "workspace:*"
|
|
36
|
+
},
|
|
37
|
+
"peerDependenciesMeta": {
|
|
38
|
+
"@pithy-sh/leaderboard": {
|
|
39
|
+
"optional": true
|
|
40
|
+
},
|
|
41
|
+
"@pithy-sh/ledger": {
|
|
42
|
+
"optional": true
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
"dependencies": {
|
|
46
|
+
"@cloudflare/workers-types": "^5.20260729.1",
|
|
47
|
+
"@hono/zod-validator": "^0.9.0",
|
|
48
|
+
"@pithy-sh/core": "workspace:*",
|
|
49
|
+
"hono": "^4.13.2",
|
|
50
|
+
"kysely": "^0.29.0",
|
|
51
|
+
"zod": "^4.0.0"
|
|
52
|
+
},
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"@cloudflare/vitest-plugin": "^1.0.0",
|
|
55
|
+
"@pithy-sh/leaderboard": "workspace:*",
|
|
56
|
+
"@pithy-sh/ledger": "workspace:*",
|
|
57
|
+
"@pithy-sh/tsconfig": "workspace:*",
|
|
58
|
+
"@types/node": "^22.15.0",
|
|
59
|
+
"@vitest/coverage-v8": "^4.1.0",
|
|
60
|
+
"kysely-d1": "^0.4.0",
|
|
61
|
+
"typescript": "^7.0.2",
|
|
62
|
+
"vitest": "^4.1.0",
|
|
63
|
+
"wrangler": "^4.115.0"
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "multiplayer",
|
|
3
|
+
"package": "@pithy-sh/multiplayer",
|
|
4
|
+
"requiredBindings": [
|
|
5
|
+
{
|
|
6
|
+
"type": "durable_object",
|
|
7
|
+
"name": "SESSIONS",
|
|
8
|
+
"className": "MultiplayerSession",
|
|
9
|
+
"classModule": "@pithy-sh/multiplayer/src/session/durableObject"
|
|
10
|
+
},
|
|
11
|
+
{ "type": "d1", "name": "DB" }
|
|
12
|
+
],
|
|
13
|
+
"peerCapabilities": [],
|
|
14
|
+
"optionalCapabilities": ["auth", "leaderboard", "ledger"],
|
|
15
|
+
"migrationNamespace": "multiplayer",
|
|
16
|
+
"whenToEnable": "Run authoritative, turn-based multiplayer sessions — the server holds the game state no client can be trusted with, resolves it, and writes a durable result to your own D1. This is the one thing a relay cannot do. Games are pluggable game models: three example games ship — `battle` (secret simultaneous moves), `connect-n` (turn-based grid: tic-tac-toe, Connect Four, gomoku), and `craps` (a wagering table) — each built on a reusable pattern helper (`simultaneous`, `turnBased`, `wageringTable`) you can layer your own game on, and you register games with `registerGameModel`. Supports N players. It also provides a wagering stack: provably-fair server-side randomness (a committed-then-revealed seed feeding a deterministic dice/shuffle stream), persistent `table` sessions where players buy in and cash out between rounds, and ledger-settled bets (a bet holds a stake, a resolution captures a loss or pays a win) — pair it with `@pithy-sh/ledger`. Pithy's first Durable Object — the CLI wires the DO binding and its class migration tag for you, across dev, staging, production, and preview branches, which no library does. It is NOT rooms, chat, presence, or real-time action netcode: Cloudflare's PartyServer already ships those. It takes no position on whether wagered units map to money, or the regulation that implies — that is yours. Sessions bind to an authenticated user, so add auth too — without it every route is denied.",
|
|
17
|
+
"scaffold": [
|
|
18
|
+
"Add a `multiplayer({ games: [...] })` block to pithy.config.ts and define at least one game.",
|
|
19
|
+
"Give each game a `key`, a `kind` (`battle`, `connect-n`, or `craps`, or a game you registered), an optional `mode` (`match` default, or `table` for a persistent multi-round table), an optional `players` count, and a `rules` block. A `battle`'s rules are its move catalogs; a `connect-n`'s are `{ rows, cols, connect }`; a `craps`'s are `{ currency, minBet, maxBet }`.",
|
|
20
|
+
"The CLI has already written the Durable Object export into your worker entry: `export { MultiplayerSession } from \"@pithy-sh/multiplayer/src/session/durableObject\";` — wrangler's `class_name` resolves against your worker's `main`. No hand-editing.",
|
|
21
|
+
"To ship a game the examples don't cover, layer it on the closest pattern helper (`simultaneous`/`turnBased`/`wageringTable`) or the raw `GameModel` seam, and call `registerGameModel(myGame)` in your worker entry before the DO handles requests.",
|
|
22
|
+
"The CLI has already added the `SESSIONS` durable_object binding and the `MultiplayerSession` class migration tag (`new_sqlite_classes`) to wrangler.jsonc for every environment. No hand-editing.",
|
|
23
|
+
"Bind a D1 database named DB in wrangler.jsonc — the same app database your other capabilities use.",
|
|
24
|
+
"Run `pithy migrate` to create pithy_multiplayer_results.",
|
|
25
|
+
"Add `@pithy-sh/auth` if it is not already installed. Sessions bind to an authenticated user; without auth every route is denied.",
|
|
26
|
+
"Optionally set a game's `leaderboard: { board, points }` to publish results to a `@pithy-sh/leaderboard` board. Install leaderboard and configure the matching board.",
|
|
27
|
+
"Read packages/multiplayer/docs/costs.md before production: a hibernating session waiting on a turn bills nothing, but the 20:1 WebSocket-message ratio and 128 MB duration billing shape the bill of an active session."
|
|
28
|
+
],
|
|
29
|
+
"configOptions": [
|
|
30
|
+
{
|
|
31
|
+
"key": "games",
|
|
32
|
+
"default": [{ "key": "tic-tac-toe", "kind": "connect-n", "rules": { "rows": 3, "cols": 3, "connect": 3 } }],
|
|
33
|
+
"describe": "Every game this app runs. Replace this example — it is tic-tac-toe, the smallest game that actually plays: the built-in `connect-n` model on a 3x3 board where three in a line wins, two players, no wagering and no ledger. `kind` names a model (`battle`, `connect-n`, `craps`, or one you register with `registerGameModel`) and `rules` is that model's own block. At least one game is required."
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"key": "basePath",
|
|
37
|
+
"default": "/multiplayer",
|
|
38
|
+
"describe": "Where the multiplayer routes mount. Sessions are created at POST `<basePath>/games/:game`, joined and played (`/action`) under `<basePath>/sessions/:id`."
|
|
39
|
+
}
|
|
40
|
+
]
|
|
41
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { BindingSpecInput } from "@pithy-sh/core/src/capability/bindings";
|
|
5
|
+
import { type Capability, defineCapability } from "@pithy-sh/core/src/capability/capability";
|
|
6
|
+
import type { Migration } from "kysely/migration";
|
|
7
|
+
import "./game/builtins";
|
|
8
|
+
import { MultiplayerConfig, type MultiplayerConfigInput, validateGames } from "./config/config";
|
|
9
|
+
import { multiplayerTables } from "./data/tables";
|
|
10
|
+
import { registerMultiplayerRoutes } from "./http/routes";
|
|
11
|
+
import { multiplayer_0001_results } from "./migrations/0001_results";
|
|
12
|
+
import { multiplayerExampleSeed } from "./seeds/example";
|
|
13
|
+
import { PACKAGE_VERSION } from "./version.generated";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Where multiplayer's migrations sort in the app database. Unique per database; the registry composes
|
|
17
|
+
* keys like `0500_multiplayer_0001_results`. Sits after leaderboard (400).
|
|
18
|
+
*/
|
|
19
|
+
export const MULTIPLAYER_MIGRATION_ORDER = 500;
|
|
20
|
+
|
|
21
|
+
/** The Durable Object namespace binding name, and the class it is backed by — the CLI wires both. */
|
|
22
|
+
export const MULTIPLAYER_SESSIONS_BINDING = "SESSIONS";
|
|
23
|
+
export const MULTIPLAYER_SESSION_CLASS = "MultiplayerSession";
|
|
24
|
+
/**
|
|
25
|
+
* The module the class is exported from — what the CLI writes the Worker entry's re-export against.
|
|
26
|
+
*
|
|
27
|
+
* Its own module, never `src/index`: the entry point is what an adopter's `pithy.config.ts` imports, and
|
|
28
|
+
* a Durable Object on that path imports `cloudflare:workers` and takes every Node-side command down with
|
|
29
|
+
* it (#172).
|
|
30
|
+
*/
|
|
31
|
+
export const MULTIPLAYER_SESSION_MODULE = "@pithy-sh/multiplayer/src/session/durableObject";
|
|
32
|
+
|
|
33
|
+
export type MultiplayerOptions = MultiplayerConfigInput & {
|
|
34
|
+
/** Mount the routes somewhere other than `/multiplayer`. */
|
|
35
|
+
basePath?: string;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export interface MultiplayerCapability extends Capability {
|
|
39
|
+
multiplayerConfig: MultiplayerConfig;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The multiplayer capability: authoritative, turn-based, two-player commit-reveal sessions.
|
|
44
|
+
*
|
|
45
|
+
* Fully optional. Config, migrations, routes, and bindings arrive only on `pithy add multiplayer`, and
|
|
46
|
+
* `pithy remove multiplayer` is the clean inverse — including the Durable Object binding and its class
|
|
47
|
+
* migration tag.
|
|
48
|
+
*
|
|
49
|
+
* This is Pithy's first Durable Object. Live session state — membership, hidden commits, the alarm-driven
|
|
50
|
+
* commit deadline — lives in the DO's own storage, where authority and hidden state belong. D1 holds only
|
|
51
|
+
* the durable *result*, joinable beside the adopter's own tables. The one thing a relay cannot do — hold
|
|
52
|
+
* state neither client can read and resolve it — is the whole reason the capability exists.
|
|
53
|
+
*
|
|
54
|
+
* `dependsOn` is deliberately empty. Auth is a seam, not a peer: the routes read `c.var.auth` through
|
|
55
|
+
* core's `AuthContext`, so without `@pithy-sh/auth` every route is denied rather than open. Leaderboard is
|
|
56
|
+
* an *optional* one-way sink: a game may publish its result to a board, loaded by dynamic import so
|
|
57
|
+
* `@pithy-sh/leaderboard` is never a hard dependency.
|
|
58
|
+
*/
|
|
59
|
+
export function multiplayer(options: MultiplayerOptions = { games: [] }): MultiplayerCapability {
|
|
60
|
+
const { basePath, ...configInput } = options;
|
|
61
|
+
// Parse the game set at assembly, then validate each game against its model — an unknown `kind`, a roster
|
|
62
|
+
// outside the model's bounds, or an invalid `rules` block fails on deploy, not on the first session.
|
|
63
|
+
const resolved = MultiplayerConfig.parse(configInput);
|
|
64
|
+
const games = validateGames(resolved);
|
|
65
|
+
|
|
66
|
+
const migrations: Record<string, Migration> = { "0001_results": multiplayer_0001_results };
|
|
67
|
+
|
|
68
|
+
const requiredBindings: BindingSpecInput[] = [
|
|
69
|
+
{
|
|
70
|
+
type: "durable_object",
|
|
71
|
+
name: MULTIPLAYER_SESSIONS_BINDING,
|
|
72
|
+
className: MULTIPLAYER_SESSION_CLASS,
|
|
73
|
+
classModule: MULTIPLAYER_SESSION_MODULE,
|
|
74
|
+
},
|
|
75
|
+
{ type: "d1", name: "DB" },
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
const capability = defineCapability({
|
|
79
|
+
name: "multiplayer",
|
|
80
|
+
// The package version this capability ships at, stamped by `scripts/stampVersions.ts` — a Worker
|
|
81
|
+
// cannot read its own package.json. Reported per capability by the control-plane manifest.
|
|
82
|
+
version: PACKAGE_VERSION,
|
|
83
|
+
requiredBindings,
|
|
84
|
+
config: MultiplayerConfig,
|
|
85
|
+
databases: {
|
|
86
|
+
app: {
|
|
87
|
+
binding: "DB",
|
|
88
|
+
tables: multiplayerTables(),
|
|
89
|
+
migrationOrder: MULTIPLAYER_MIGRATION_ORDER,
|
|
90
|
+
migrations,
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
routes: registerMultiplayerRoutes({ games, basePath }),
|
|
94
|
+
seeds: [multiplayerExampleSeed],
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
return Object.assign(capability, { multiplayerConfig: resolved });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function isMultiplayerCapability(capability: Capability): capability is MultiplayerCapability {
|
|
101
|
+
return capability.name === "multiplayer" && "multiplayerConfig" in capability;
|
|
102
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
/// <reference types="@cloudflare/vitest-plugin/types" />
|
|
5
|
+
|
|
6
|
+
// Bindings the Workers-runtime test project provides to `*.workers.test.ts`, matching the Miniflare
|
|
7
|
+
// config in `vitest.workers.config.ts`: the `SESSIONS` Durable Object namespace the sessions live in, and
|
|
8
|
+
// the app `DB` database the `pithy_multiplayer_*` tables live in. `cloudflare:test` types its `env` as
|
|
9
|
+
// `Cloudflare.Env`, so test bindings are declared by augmenting that interface.
|
|
10
|
+
declare namespace Cloudflare {
|
|
11
|
+
interface Env {
|
|
12
|
+
DB: D1Database;
|
|
13
|
+
// Typed with the session class so `env.SESSIONS.get(id)` yields a stub with the RPC methods.
|
|
14
|
+
SESSIONS: DurableObjectNamespace<import("./session/durableObject").MultiplayerSession>;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { MultiplayerGameNotFoundError, MultiplayerInvalidMoveError } from "../error/errors";
|
|
6
|
+
import { type GameModel, playerBounds, registeredKinds, resolveModel } from "../game/model";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The multiplayer capability's config — the thin, user-owned surface in `pithy.config.ts`. Every field is
|
|
10
|
+
* `.describe()`d: the descriptions feed the self-documenting CLI (CLAUDE.md §Config).
|
|
11
|
+
*
|
|
12
|
+
* A game is generic. It names a **model** by `kind` — one of the two fundamental turn-based shapes,
|
|
13
|
+
* `commit-reveal` (simultaneous, hidden) or `sequential` (turn-order, shared state), or a model an adopter
|
|
14
|
+
* registered — and carries that model's own `rules` block. The session infrastructure (membership,
|
|
15
|
+
* lifecycle, hidden state, alarms, the durable result, the leaderboard publish) is the same for every game;
|
|
16
|
+
* only the `rules` and the model behind them differ. That is the whole point: the package is authoritative
|
|
17
|
+
* turn-based *sessions*, and the game models are pluggable.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** A game key is a URL path segment (`/multiplayer/games/<key>`), so it is kebab-case and lowercase. */
|
|
21
|
+
const GAME_KEY_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
|
|
22
|
+
|
|
23
|
+
export const MultiplayerLeaderboard = z
|
|
24
|
+
.object({
|
|
25
|
+
board: z
|
|
26
|
+
.string()
|
|
27
|
+
.min(1)
|
|
28
|
+
.describe(
|
|
29
|
+
"The `@pithy-sh/leaderboard` board key a resolved session publishes to. Must match a board configured in the leaderboard capability, and the leaderboard capability must be installed — publishing is one-way, so multiplayer depends on leaderboard, never the reverse.",
|
|
30
|
+
),
|
|
31
|
+
window: z
|
|
32
|
+
.string()
|
|
33
|
+
.optional()
|
|
34
|
+
.describe(
|
|
35
|
+
"A UTC CRON expression for the board's window (`0 0 * * *` daily), matching the leaderboard board's own `window`. Omit for an all-time board. Publishing writes into the window open at resolution time.",
|
|
36
|
+
),
|
|
37
|
+
direction: z
|
|
38
|
+
.enum(["asc", "desc"])
|
|
39
|
+
.default("desc")
|
|
40
|
+
.describe("The board's sort direction, matching the leaderboard board — `desc` = highest points win."),
|
|
41
|
+
aggregation: z
|
|
42
|
+
.enum(["best", "latest", "sum"])
|
|
43
|
+
.default("sum")
|
|
44
|
+
.describe(
|
|
45
|
+
"How the board folds repeat results, matching the leaderboard board. `sum` accumulates points across sessions.",
|
|
46
|
+
),
|
|
47
|
+
points: z
|
|
48
|
+
.object({
|
|
49
|
+
win: z.number().describe("Points the winner is awarded."),
|
|
50
|
+
draw: z.number().describe("Points each player is awarded on a draw."),
|
|
51
|
+
loss: z.number().describe("Points a non-winner is awarded — often 0, but a participation point is fine."),
|
|
52
|
+
})
|
|
53
|
+
.describe("The points each outcome awards, folded into the board by its aggregation."),
|
|
54
|
+
})
|
|
55
|
+
.describe("Optional one-way publish of a session's result to a leaderboard board.");
|
|
56
|
+
export type MultiplayerLeaderboard = z.output<typeof MultiplayerLeaderboard>;
|
|
57
|
+
|
|
58
|
+
export const MultiplayerGame = z
|
|
59
|
+
.object({
|
|
60
|
+
key: z
|
|
61
|
+
.string()
|
|
62
|
+
.regex(GAME_KEY_PATTERN, "A game key is lowercase, digits, and dashes — it is a URL path segment.")
|
|
63
|
+
.describe(
|
|
64
|
+
"The game's stable id, unique across the app. It is a URL path segment and the session's game reference.",
|
|
65
|
+
),
|
|
66
|
+
kind: z
|
|
67
|
+
.string()
|
|
68
|
+
.min(1)
|
|
69
|
+
.describe(
|
|
70
|
+
"Which game to run: a built-in example (`battle` — simultaneous secret moves; `connect-n` — turn-based grid, i.e. tic-tac-toe/Connect Four/gomoku; `craps` — a wagering table) or a game registered with `registerGameModel`. The three built-ins are built on reusable pattern helpers (`simultaneous`, `turnBased`, `wageringTable`) that you can layer your own game on.",
|
|
71
|
+
),
|
|
72
|
+
mode: z
|
|
73
|
+
.enum(["match", "table"])
|
|
74
|
+
.default("match")
|
|
75
|
+
.describe(
|
|
76
|
+
"The session lifecycle. `match` (default): a fixed roster fills, plays one game to a result, and ends — a duel, a hand of a card game. `table`: a long-lived session that runs many rounds; players join and leave *between* rounds (a buy-in, a cash-out), the model settles each round, and the table stays open until it is closed or empty — a poker or craps table.",
|
|
77
|
+
),
|
|
78
|
+
players: z
|
|
79
|
+
.number()
|
|
80
|
+
.int()
|
|
81
|
+
.min(2)
|
|
82
|
+
.default(2)
|
|
83
|
+
.describe(
|
|
84
|
+
"In `match` mode, exactly how many players a session needs before play begins (default 2). In `table` mode, the maximum number of seats. The chosen model constrains the range.",
|
|
85
|
+
),
|
|
86
|
+
turnTimeoutMs: z
|
|
87
|
+
.number()
|
|
88
|
+
.int()
|
|
89
|
+
.min(1000)
|
|
90
|
+
.optional()
|
|
91
|
+
.describe(
|
|
92
|
+
"How long, in ms, players have to act once play begins before the session is abandoned. Enforced by a Durable Object alarm — never a timer, which would forfeit hibernation. Omit to let a session wait indefinitely (it hibernates and costs nothing while it waits).",
|
|
93
|
+
),
|
|
94
|
+
leaderboard: MultiplayerLeaderboard.optional().describe(
|
|
95
|
+
"If set, a resolved session publishes its result to this leaderboard board. Requires `@pithy-sh/leaderboard`.",
|
|
96
|
+
),
|
|
97
|
+
rules: z
|
|
98
|
+
.unknown()
|
|
99
|
+
.describe(
|
|
100
|
+
"The model-specific rules block. Its shape is defined by the game's `kind` and validated against that model's schema at assembly (a `commit-reveal` game's move catalog, a `sequential` game's board size, …). Fails on deploy, not on the first move.",
|
|
101
|
+
),
|
|
102
|
+
})
|
|
103
|
+
.describe("One game definition — a model reference (`kind`), a roster size, and that model's `rules` block.");
|
|
104
|
+
export type MultiplayerGame = z.output<typeof MultiplayerGame>;
|
|
105
|
+
|
|
106
|
+
export const MultiplayerConfig = z
|
|
107
|
+
.object({
|
|
108
|
+
games: z
|
|
109
|
+
.array(MultiplayerGame)
|
|
110
|
+
.min(1, "A multiplayer capability with no games does nothing — configure at least one.")
|
|
111
|
+
.describe("Every game this app runs. Games are config, not database rows."),
|
|
112
|
+
})
|
|
113
|
+
.describe("Configuration for the multiplayer capability — the game set, each naming a pluggable game model.")
|
|
114
|
+
.check((ctx) => {
|
|
115
|
+
const keys = ctx.value.games.map((g) => g.key);
|
|
116
|
+
const duplicates = [...new Set(keys.filter((key, i) => keys.indexOf(key) !== i))];
|
|
117
|
+
if (duplicates.length > 0) {
|
|
118
|
+
ctx.issues.push({
|
|
119
|
+
code: "custom",
|
|
120
|
+
input: ctx.value,
|
|
121
|
+
path: ["games"],
|
|
122
|
+
message: `Duplicate game keys: ${duplicates.join(", ")}. Two games sharing a key would collide.`,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
export type MultiplayerConfig = z.output<typeof MultiplayerConfig>;
|
|
127
|
+
export type MultiplayerConfigInput = z.input<typeof MultiplayerConfig>;
|
|
128
|
+
|
|
129
|
+
/** A game with its `rules` validated against its model — the shape the DO and routes carry. */
|
|
130
|
+
export interface ResolvedGame extends Omit<MultiplayerGame, "rules"> {
|
|
131
|
+
/** The model-specific rules, parsed by the model's schema. */
|
|
132
|
+
rules: unknown;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Validate every game against its model: the `kind` must resolve to a registered model, the roster size
|
|
137
|
+
* must fall within the model's player bounds, and the `rules` block must parse against the model's schema.
|
|
138
|
+
* Runs once at assembly (the capability factory), so a typo or an out-of-range roster fails on deploy, not
|
|
139
|
+
* on the first session. Returns the games with their `rules` parsed.
|
|
140
|
+
*/
|
|
141
|
+
export function validateGames(config: MultiplayerConfig): ResolvedGame[] {
|
|
142
|
+
return config.games.map((game) => {
|
|
143
|
+
const model = resolveModel(game.kind);
|
|
144
|
+
if (!model) {
|
|
145
|
+
throw new MultiplayerGameNotFoundError({
|
|
146
|
+
message: `Game "${game.key}" uses unknown model "${game.kind}".`,
|
|
147
|
+
action: `Use one of: ${registeredKinds().join(", ") || "(none registered)"}, or register a model with registerGameModel().`,
|
|
148
|
+
detail: `No game model registered for kind "${game.kind}".`,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
assertPlayerCount(game, model);
|
|
152
|
+
const rules = parseRules(game, model);
|
|
153
|
+
return { ...game, rules };
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** The roster size must fall within the model's supported range. */
|
|
158
|
+
function assertPlayerCount(game: MultiplayerGame, model: GameModel): void {
|
|
159
|
+
const { min, max } = playerBounds(model);
|
|
160
|
+
if (game.players < min || game.players > max) {
|
|
161
|
+
const upper = max === Number.POSITIVE_INFINITY ? "" : `–${max}`;
|
|
162
|
+
throw new MultiplayerInvalidMoveError({
|
|
163
|
+
message: `Game "${game.key}" sets ${game.players} players, but "${model.kind}" supports ${min}${upper}.`,
|
|
164
|
+
detail: `Model "${model.kind}" player bounds [${min}, ${max}] exclude ${game.players}.`,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Parse the `rules` block against the model's schema, surfacing a Zod failure as a config error. */
|
|
170
|
+
function parseRules(game: MultiplayerGame, model: GameModel): unknown {
|
|
171
|
+
const parsed = model.config.safeParse(game.rules);
|
|
172
|
+
if (!parsed.success) {
|
|
173
|
+
throw new MultiplayerInvalidMoveError({
|
|
174
|
+
message: `Game "${game.key}" has invalid rules for model "${model.kind}".`,
|
|
175
|
+
action: "Fix the game's `rules` block in pithy.config.ts.",
|
|
176
|
+
detail: `Rules failed validation: ${parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ")}.`,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
return parsed.data;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** The game with this key, or undefined. Game keys come from config, so an unknown key is a 404. */
|
|
183
|
+
export function resolveGame(games: readonly ResolvedGame[], key: string): ResolvedGame | undefined {
|
|
184
|
+
return games.find((game) => game.key === key);
|
|
185
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { SQLiteBoolean, SQLiteDate, sqliteJson } from "@pithy-sh/core/src/data/codecs";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
|
|
7
|
+
/** The set of terminal states a session's result row records. A session ends resolved or abandoned. */
|
|
8
|
+
export const SessionResultStatus = z
|
|
9
|
+
.enum(["resolved", "abandoned"])
|
|
10
|
+
.describe(
|
|
11
|
+
"The terminal state this result records: `resolved` (a winner or draw) or `abandoned` (timed out or quit).",
|
|
12
|
+
);
|
|
13
|
+
export type SessionResultStatus = z.infer<typeof SessionResultStatus>;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* One session's durable result — the row in `pithy_multiplayer_results`. Written exactly once, when a
|
|
17
|
+
* session reaches a terminal state; the session's authority lives in its Durable Object, but the *record*
|
|
18
|
+
* of what happened outlives the object here in D1, joinable against the adopter's own tables.
|
|
19
|
+
*
|
|
20
|
+
* `z.output` is the app shape (Dates, booleans, parsed JSON); `z.input` is the SQLite row (ms-epoch, 0|1,
|
|
21
|
+
* JSON strings). All JS↔SQLite conversion runs through the core codecs (CLAUDE.md §Data layer) — no raw
|
|
22
|
+
* `0/1`, epoch, `new Date()`, or `JSON.stringify` in query code.
|
|
23
|
+
*
|
|
24
|
+
* `sessionId` is the Durable Object id (hex), unique per session, and the natural key a result is
|
|
25
|
+
* addressed by — `id` is an internal autoincrement never exposed.
|
|
26
|
+
*/
|
|
27
|
+
export const MultiplayerResult = z
|
|
28
|
+
.object({
|
|
29
|
+
id: z.number().int().describe("Autoincrement primary key. Internal only; results are addressed by sessionId."),
|
|
30
|
+
sessionId: z.string().describe("The Durable Object id (hex) of the session this result belongs to. Unique."),
|
|
31
|
+
gameKey: z.string().describe("The game this session played, from `games` in pithy.config.ts."),
|
|
32
|
+
status: SessionResultStatus.describe("The terminal state — resolved or abandoned."),
|
|
33
|
+
players: sqliteJson(z.array(z.string())).describe(
|
|
34
|
+
"The authenticated user ids of the session's members, in join order.",
|
|
35
|
+
),
|
|
36
|
+
scores: sqliteJson(z.record(z.string(), z.number()))
|
|
37
|
+
.nullable()
|
|
38
|
+
.describe("Each player's final score, keyed by user id. Null on an abandoned session, which is never scored."),
|
|
39
|
+
winnerUserId: z
|
|
40
|
+
.string()
|
|
41
|
+
.nullable()
|
|
42
|
+
.describe("The winning player's user id. Null on a draw, and null on an abandoned session."),
|
|
43
|
+
draw: SQLiteBoolean.describe("Whether the session ended level. False on an abandoned session."),
|
|
44
|
+
createdAt: SQLiteDate.describe("When the session was created."),
|
|
45
|
+
resolvedAt: SQLiteDate.describe("When the session reached its terminal state and this row was written."),
|
|
46
|
+
})
|
|
47
|
+
.describe("One session's durable result — the row in `pithy_multiplayer_results`.");
|
|
48
|
+
export type MultiplayerResult = z.output<typeof MultiplayerResult>;
|
|
49
|
+
export type MultiplayerResultRow = z.input<typeof MultiplayerResult>;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { MultiplayerResult } from "./result";
|
|
5
|
+
import { MULTIPLAYER_RESULTS_TABLE, type MultiplayerDatabase } from "./tables";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Reads and writes over `pithy_multiplayer_results`.
|
|
9
|
+
*
|
|
10
|
+
* Round-trip rule (CLAUDE.md §Data layer): read with `MultiplayerResult.parse` (decode), write with
|
|
11
|
+
* `MultiplayerResult.encode` (encode). No raw `0/1`, epoch, or `JSON.stringify` below.
|
|
12
|
+
*/
|
|
13
|
+
export interface ResultStore {
|
|
14
|
+
/**
|
|
15
|
+
* Persist a terminal session's result. Idempotent on `sessionId`: an at-least-once alarm retry or a
|
|
16
|
+
* double-resolve writes the row once and no more, so a session can never record two conflicting results.
|
|
17
|
+
*/
|
|
18
|
+
write(result: MultiplayerResult): Promise<void>;
|
|
19
|
+
/** The result for a session, or undefined if it has not reached a terminal state. */
|
|
20
|
+
get(sessionId: string): Promise<MultiplayerResult | undefined>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function resultStore(db: MultiplayerDatabase): ResultStore {
|
|
24
|
+
return {
|
|
25
|
+
async write(result) {
|
|
26
|
+
const row = MultiplayerResult.encode({ ...result, id: 0 }) as Record<string, unknown>;
|
|
27
|
+
// `id` is a rowid alias — let SQLite assign it rather than inserting the placeholder above.
|
|
28
|
+
delete row.id;
|
|
29
|
+
await db
|
|
30
|
+
.insertInto(MULTIPLAYER_RESULTS_TABLE)
|
|
31
|
+
// biome-ignore lint/suspicious/noExplicitAny: the encoded row is the schema's `z.input` side; Kysely's insert type is derived from it.
|
|
32
|
+
.values(row as any)
|
|
33
|
+
// A terminal session writes its result exactly once; a retry must be a no-op, not a duplicate or an error.
|
|
34
|
+
.onConflict((oc) => oc.column("sessionId").doNothing())
|
|
35
|
+
.execute();
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
async get(sessionId) {
|
|
39
|
+
const row = await db
|
|
40
|
+
.selectFrom(MULTIPLAYER_RESULTS_TABLE)
|
|
41
|
+
.selectAll()
|
|
42
|
+
.where("sessionId", "=", sessionId)
|
|
43
|
+
.executeTakeFirst();
|
|
44
|
+
return row ? MultiplayerResult.parse(row) : undefined;
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { D1Database } from "@cloudflare/workers-types";
|
|
5
|
+
import { createDatabase, type DatabaseSchema } from "@pithy-sh/core/src/data/db";
|
|
6
|
+
import type { Kysely } from "kysely";
|
|
7
|
+
import type { z } from "zod";
|
|
8
|
+
import { MultiplayerResult } from "./result";
|
|
9
|
+
|
|
10
|
+
/** The results table. `CamelCasePlugin` snake-cases it to `pithy_multiplayer_results` in the DDL. */
|
|
11
|
+
export const MULTIPLAYER_RESULTS_TABLE = "pithyMultiplayerResults";
|
|
12
|
+
|
|
13
|
+
/** The multiplayer tables map — just the durable result log; live session state lives in the Durable Object. */
|
|
14
|
+
export function multiplayerTables(): Record<string, z.ZodObject> {
|
|
15
|
+
return { [MULTIPLAYER_RESULTS_TABLE]: MultiplayerResult };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The typed Kysely database over the multiplayer tables. */
|
|
19
|
+
export type MultiplayerTables = {
|
|
20
|
+
[MULTIPLAYER_RESULTS_TABLE]: typeof MultiplayerResult;
|
|
21
|
+
};
|
|
22
|
+
export type MultiplayerDatabase = Kysely<DatabaseSchema<MultiplayerTables>>;
|
|
23
|
+
|
|
24
|
+
/** Build the multiplayer database from the `DB` binding (CamelCasePlugin installed). */
|
|
25
|
+
export function multiplayerDatabase(d1: D1Database): MultiplayerDatabase {
|
|
26
|
+
return createDatabase(d1, { [MULTIPLAYER_RESULTS_TABLE]: MultiplayerResult }) as unknown as MultiplayerDatabase;
|
|
27
|
+
}
|