@joystyk/rooms 0.0.0 → 0.2.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/CHANGELOG.md +38 -0
- package/README.md +147 -2
- package/dist/canonical.d.ts +10 -0
- package/dist/canonical.js +30 -0
- package/dist/canonical.js.map +1 -0
- package/dist/core.d.ts +3 -0
- package/dist/core.js +1879 -0
- package/dist/core.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +7 -0
- package/dist/names.d.ts +19 -0
- package/dist/names.js +28 -0
- package/dist/names.js.map +1 -0
- package/dist/node/host.d.ts +67 -0
- package/dist/node/host.js +323 -0
- package/dist/node/host.js.map +1 -0
- package/dist/node/index.d.ts +6 -0
- package/dist/node/index.js +7 -0
- package/dist/node/jobs.d.ts +48 -0
- package/dist/node/jobs.js +176 -0
- package/dist/node/jobs.js.map +1 -0
- package/dist/node/registry.d.ts +32 -0
- package/dist/node/registry.js +43 -0
- package/dist/node/registry.js.map +1 -0
- package/dist/node/server.d.ts +59 -0
- package/dist/node/server.js +577 -0
- package/dist/node/server.js.map +1 -0
- package/dist/node/store.d.ts +53 -0
- package/dist/node/store.js +245 -0
- package/dist/node/store.js.map +1 -0
- package/dist/node/support.d.ts +67 -0
- package/dist/node/support.js +126 -0
- package/dist/node/support.js.map +1 -0
- package/dist/policy.d.ts +15 -0
- package/dist/policy.js +41 -0
- package/dist/policy.js.map +1 -0
- package/dist/protocol/index.d.ts +43 -0
- package/dist/protocol/index.js +48 -0
- package/dist/protocol/index.js.map +1 -0
- package/dist/protocol/schemas.d.ts +792 -0
- package/dist/protocol/schemas.js +332 -0
- package/dist/protocol/schemas.js.map +1 -0
- package/dist/protocol/types.d.ts +279 -0
- package/dist/protocol/types.js +1 -0
- package/dist/record.d.ts +173 -0
- package/dist/record.js +181 -0
- package/dist/record.js.map +1 -0
- package/dist/types.d.ts +374 -0
- package/dist/types.js +15 -0
- package/dist/types.js.map +1 -0
- package/package.json +56 -11
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# @joystyk/rooms
|
|
2
|
+
|
|
3
|
+
## 0.2.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- bb095c7: A heavy AI the job pool couldn't answer is asked again with backoff instead of playing the
|
|
8
|
+
timeout move. `ai-chosen` without a command now carries the pool's `failure`, `LiveRoom` gains
|
|
9
|
+
`aiRetry`, and `CoreLimits` gains `aiRetryMs`, `aiRetryMaxMs` and `aiAttempts`. The worker pool
|
|
10
|
+
runs each worker's jobs one at a time, counts a job's timeout from when it starts, and reports
|
|
11
|
+
`busy`, `lost` and `crashed` separately (`PoolOutcome`).
|
|
12
|
+
|
|
13
|
+
### Patch Changes
|
|
14
|
+
|
|
15
|
+
- bb095c7: Share canonical IPv4 and IPv6 /64 rate-limit keys across Node services. Keep room
|
|
16
|
+
initialization diagnostics on the server behind a client-safe reference, and expire old
|
|
17
|
+
message-rate strikes. Extend hidden-information conformance checks with transition-aware
|
|
18
|
+
perturbations so private events remain testable when recipients gain cards.
|
|
19
|
+
- Updated dependencies [bb095c7]
|
|
20
|
+
- Updated dependencies [bb095c7]
|
|
21
|
+
- Updated dependencies [bb095c7]
|
|
22
|
+
- @joystyk/contracts@0.2.0
|
|
23
|
+
- @joystyk/ops@0.2.0
|
|
24
|
+
- @joystyk/identity@0.2.0
|
|
25
|
+
- @joystyk/kit@0.2.0
|
|
26
|
+
|
|
27
|
+
## 0.1.0
|
|
28
|
+
|
|
29
|
+
### Minor Changes
|
|
30
|
+
|
|
31
|
+
- Prepare the first compiled library release: ESM and API declarations for every public entry point, runtime assets, and checks against installed npm tarballs. Games and applications remain private.
|
|
32
|
+
|
|
33
|
+
### Patch Changes
|
|
34
|
+
|
|
35
|
+
- Updated dependencies
|
|
36
|
+
- @joystyk/contracts@0.1.0
|
|
37
|
+
- @joystyk/kit@0.1.0
|
|
38
|
+
- @joystyk/identity@0.1.0
|
package/README.md
CHANGED
|
@@ -1,5 +1,150 @@
|
|
|
1
1
|
# @joystyk/rooms
|
|
2
2
|
|
|
3
|
-
The
|
|
3
|
+
The room server for every server-authoritative multiplayer game: a pure room core, the
|
|
4
|
+
versioned wire protocol, and a Node adapter that runs the core on WebSockets, SQLite, one
|
|
5
|
+
timer per room and a `worker_threads` job pool. The design is
|
|
6
|
+
room-server.md (Joystyk host reference); its "Changes from implementation"
|
|
7
|
+
section lists where this package departs from the first draft.
|
|
4
8
|
|
|
5
|
-
|
|
9
|
+
## npm package
|
|
10
|
+
|
|
11
|
+
The first `0.1.0` release is being prepared; it has not been published. Once available:
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
npm install @joystyk/rooms
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Node consumers require Node 24.15 or later. Browser entry points require a modern
|
|
18
|
+
browser and should be loaded in the browser.
|
|
19
|
+
|
|
20
|
+
| Import | Format |
|
|
21
|
+
| --- | --- |
|
|
22
|
+
| `@joystyk/rooms` | ES module and TypeScript declarations |
|
|
23
|
+
| `@joystyk/rooms/protocol` | ES module and TypeScript declarations |
|
|
24
|
+
| `@joystyk/rooms/node` | ES module and TypeScript declarations |
|
|
25
|
+
|
|
26
|
+
Only the entries listed above are public API. Installed packages contain compiled ESM,
|
|
27
|
+
type declarations and runtime assets. Repository-relative links describe the Joystyk
|
|
28
|
+
workspace and are not needed to use the npm package.
|
|
29
|
+
|
|
30
|
+
| Entry | Kind | Contents |
|
|
31
|
+
| --- | --- | --- |
|
|
32
|
+
| `@joystyk/rooms` | Pure | `createRoomCore`, the room types, the record schema and migrations, `policyFromManifest`, `admitName` |
|
|
33
|
+
| `@joystyk/rooms/protocol` | Pure (`zod/mini`) | Wire schemas and types, `PROTOCOL_VERSION`, close and rejection codes |
|
|
34
|
+
| `@joystyk/rooms/node` | Node | `createRoomServer`, `RoomHost`, `openSqliteStore`, `workerJobPool`, `createEngineRegistry` |
|
|
35
|
+
|
|
36
|
+
The `session` package, and the `p2p` package that comes after launch (host policy), may import
|
|
37
|
+
`@joystyk/rooms/protocol` and nothing else from here. The service that deploys all of this
|
|
38
|
+
is `apps/rooms` (`@joystyk/rooms-server`).
|
|
39
|
+
|
|
40
|
+
## The core
|
|
41
|
+
|
|
42
|
+
A room is a value. Everything that happens to it goes through one function:
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
import { createRoomCore } from '@joystyk/rooms';
|
|
46
|
+
|
|
47
|
+
const core = createRoomCore({ engine, pin: { id: 'hearts', build: '1.4.0', rulesVersion: 3, saveVersion: 2 }, rng: sfc32 });
|
|
48
|
+
const created = core.create({ code, policy, config, seeds, creator: { name, tokenHash } }, now);
|
|
49
|
+
let room = core.load(created.value);
|
|
50
|
+
const { room: next, effects } = core.step(room, { type: 'resume' }, now);
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
`step(room, command, now)` reads no clock, randomness, storage or network. Commands are
|
|
54
|
+
`join`, `leave`, `act`, `control` (ready, settings, fill-with-ai, remove-seat, start,
|
|
55
|
+
offer, accept, decline, withdraw, leave-seat, resign), `resync`, `timer-fired`, `job-completed`, `ai-chosen`,
|
|
56
|
+
`resume` and `close`. Effects are data for the adapter: `persist` (always first, at most
|
|
57
|
+
once), `welcome`, `send`, `drop`, `alarm`, `start-job`, `start-ai` and `log`.
|
|
58
|
+
|
|
59
|
+
What the core guarantees, each covered by the property tests in `core.property.test.ts`:
|
|
60
|
+
|
|
61
|
+
- **Deterministic.** The same room, command and `now` give deeply equal steps.
|
|
62
|
+
- **Persist before broadcast.** If the record changed, the first effect saves it; a
|
|
63
|
+
rejection changes nothing durable.
|
|
64
|
+
- **Per-seat staleness.** An action is stale only if the seat's `decisionKey` changed
|
|
65
|
+
after the revision the client saw, so simultaneous moves aren't refused.
|
|
66
|
+
- **Receipts.** A retried action ID with the same payload gets its original receipt, even
|
|
67
|
+
after a restart; the same ID with a different payload is refused.
|
|
68
|
+
- **Clocks as data.** Deadlines live in the record; whatever is due at `now` applies before
|
|
69
|
+
the command. A timer that fires early, late or twice can't apply a stale move. A room
|
|
70
|
+
runs a per-move clock or a time bank with increment (`policy.bank`); banks are charged
|
|
71
|
+
from the record alone, never for a playback hold.
|
|
72
|
+
- **The AI's stream.** Each AI decision gets a child forked from `aiRng` with the kit's
|
|
73
|
+
`forkRng`; the stream moves only when an AI move is accepted. A heavy AI
|
|
74
|
+
(`heavyAi: true`) never chooses inside a step: the core emits `start-ai`, the adapter runs
|
|
75
|
+
it on the pool, and `ai-chosen` applies only if the seat still waits on that decision.
|
|
76
|
+
- **No leaks.** Each seat receives `viewFor(state, seat)` and `eventFor(event, seat)`;
|
|
77
|
+
spectators get the `null` view.
|
|
78
|
+
- **Paused when empty.** With no player connected, no AI move, clock, offer or absence
|
|
79
|
+
policy runs. The pause is recorded (`match.pausedAt`), and when a player returns every
|
|
80
|
+
deadline moves on by its length, so no clock or bank is charged for it (host policy).
|
|
81
|
+
- **Offers.** Any seated player offers a rematch, or to end a match early; the others
|
|
82
|
+
accept or decline, and the proposer may withdraw. A rematch starts once every seated
|
|
83
|
+
player has accepted (computer seats accept on their own); an early end needs every other
|
|
84
|
+
connected player, or a minute with nobody declining, and leaves no result (host policy).
|
|
85
|
+
- **Pinned versions.** A match keeps its game build and rules version; a rematch starts on
|
|
86
|
+
the newest engine.
|
|
87
|
+
- **Filtered names.** The creator's name and every name that claims a seat pass
|
|
88
|
+
`checkDisplayName` from `@joystyk/identity/names`, the only part of identity this package
|
|
89
|
+
imports. A refused name becomes a generated one, seeded by the room's code, epoch, revision
|
|
90
|
+
and seat, so the step stays deterministic; the player gets a `name-replaced` notice that
|
|
91
|
+
says why, and `POST /api/rooms` answers with the seat's `name` and the same `notice`.
|
|
92
|
+
|
|
93
|
+
The core takes the engine's seeded generator as an argument (`rng: (state) => Rng`; the
|
|
94
|
+
service passes the kit's `restoreRng`). The adapter supplies fresh seeds and token hashes; no
|
|
95
|
+
secret ever enters the core.
|
|
96
|
+
|
|
97
|
+
## The Node adapter
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
import { createEngineRegistry, createRoomServer, openSqliteStore, workerJobPool } from '@joystyk/rooms/node';
|
|
101
|
+
|
|
102
|
+
const store = await openSqliteStore('/data/rooms.sqlite');
|
|
103
|
+
const registry = createEngineRegistry([{ pin, engine, policy, jobs: { throw: new URL('./jobs/throw.worker.ts', import.meta.url) } }], sfc32);
|
|
104
|
+
const server = createRoomServer({ store, registry, seeds, pool: workerJobPool({ workers: 1, module: registry.jobModule }), publicOrigin });
|
|
105
|
+
await server.listen(8787);
|
|
106
|
+
process.once('SIGTERM', () => void server.drain());
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
- **HTTP.** `POST /api/rooms` creates a room and returns its code with the creator's seat
|
|
110
|
+
token; `GET /api/rooms/:code` describes it; `GET /api/rooms/games` lists the games the
|
|
111
|
+
registry hosts and the protocol versions (`HostedGames`), which the launcher reads before
|
|
112
|
+
offering Online with friends; `/healthz` and `/readyz` serve Compose.
|
|
113
|
+
- **WebSocket** at `/ws`. Messages are decoded with the protocol schemas, then the game's
|
|
114
|
+
own command schema. The server answers each connection in the protocol version it joined
|
|
115
|
+
with.
|
|
116
|
+
- **Persistence.** `node:sqlite` in WAL mode. Each persist is one transaction that updates
|
|
117
|
+
the row only if its `writes` count matches, so a second writer gets `conflict` and
|
|
118
|
+
unloads. A failed write keeps the old state, sends nothing and retries. Schema migrations
|
|
119
|
+
run at start-up, a newer database is refused, and corrupt rows move to `room_archive`.
|
|
120
|
+
- **Limits.** Payload size, a message-rate token bucket, rooms per address per hour,
|
|
121
|
+
unknown-code joins per address, connections per address and in total, rooms in memory,
|
|
122
|
+
spectators per room, outgoing buffer size and a WebSocket ping heartbeat. The client address
|
|
123
|
+
is read only from a trusted proxy (an IP or CIDR range).
|
|
124
|
+
- **Jobs.** A game's `jobs/<kind>.worker.ts` default-exports `(payload) => JobOutcome`; its
|
|
125
|
+
`jobs/ai.worker.ts` (heavy AI) maps `{ view, seat, level, rng }` to a command. The pool
|
|
126
|
+
bounds queued work, times out stuck jobs from when they start (a heavy AI after
|
|
127
|
+
`aiTimeoutMs`) and restarts the worker. A heavy AI the pool couldn't answer is asked again
|
|
128
|
+
with backoff.
|
|
129
|
+
- **Drain and resume.** `drain()` refuses new work, waits for running jobs, sends
|
|
130
|
+
`closing` and closes with 1012. On start, rooms with an outstanding job load at once so
|
|
131
|
+
the job re-runs; other rooms load when someone joins.
|
|
132
|
+
- **Logs.** One JSON object per line, never with tokens, names, addresses or game state.
|
|
133
|
+
|
|
134
|
+
## Tests
|
|
135
|
+
|
|
136
|
+
| File | What it proves |
|
|
137
|
+
| --- | --- |
|
|
138
|
+
| `core.test.ts` | Seats and display names, receipts, stale and future revisions, controls, AI handover, the host, clocks, jobs, failures |
|
|
139
|
+
| `core.clocks-ai.test.ts` | Time banks, the AI stream, heavy AI |
|
|
140
|
+
| `core.pause.test.ts` | A paused table: clocks stop and resume, banks are charged only for live time, a pause survives a restart |
|
|
141
|
+
| `core.offers.test.ts` | Rematch and end-match offers: offer, accept, decline, withdraw, computer seats accepting, the one-minute end, a pause during it |
|
|
142
|
+
| `core.property.test.ts` | The invariants above, under random command sequences for five set-ups: hidden hands, AI handover, heavy AI, a time bank and a simultaneous phase |
|
|
143
|
+
| `protocol/protocol.test.ts` | Message schemas, and that every message the core sends decodes |
|
|
144
|
+
| `node/store.test.ts` | The SQLite store on a real file: conflicts, migrations, quarantine, backups |
|
|
145
|
+
| `node/host.test.ts` | Restart and resume, a failing store, two writers, jobs that survive a restart |
|
|
146
|
+
| `node/server.test.ts` | Two real WebSocket clients: join, play, reconnect, newest tab wins, spectators, a replaced name, limits, drain |
|
|
147
|
+
| `node/jobs.test.ts` | The `worker_threads` pool with real worker modules |
|
|
148
|
+
|
|
149
|
+
The test games live in `src/__tests__/fixtures.ts`: Nim (turn-based), Peek (hidden hands,
|
|
150
|
+
a job, an AI) and Pass (a simultaneous phase whose decision key ignores who has passed).
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Json } from '@joystyk/contracts';
|
|
2
|
+
/** JSON with object keys sorted, so equal values always produce the same text. */
|
|
3
|
+
export declare const canonicalJson: (value: Json) => string;
|
|
4
|
+
/**
|
|
5
|
+
* A 64-bit hash of a string as 16 hex digits (two independent 32-bit FNV-1a style lanes).
|
|
6
|
+
* Not cryptographic: it only tells a retried action from a different one with the same ID.
|
|
7
|
+
*/
|
|
8
|
+
export declare const hashText: (text: string) => string;
|
|
9
|
+
/** The receipt fingerprint of an action: canonical JSON of what the client sent, hashed. */
|
|
10
|
+
export declare const fingerprint: (value: Json) => string;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
//#region src/canonical.ts
|
|
2
|
+
/** JSON with object keys sorted, so equal values always produce the same text. */
|
|
3
|
+
var canonicalJson = (value) => {
|
|
4
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
|
5
|
+
if (Array.isArray(value)) return `[${value.map((item) => canonicalJson(item)).join(",")}]`;
|
|
6
|
+
const object = value;
|
|
7
|
+
return `{${Object.keys(object).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(object[key])}`).join(",")}}`;
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* A 64-bit hash of a string as 16 hex digits (two independent 32-bit FNV-1a style lanes).
|
|
11
|
+
* Not cryptographic: it only tells a retried action from a different one with the same ID.
|
|
12
|
+
*/
|
|
13
|
+
var hashText = (text) => {
|
|
14
|
+
let a = 2166136261;
|
|
15
|
+
let b = 16777619 ^ text.length;
|
|
16
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
17
|
+
const code = text.charCodeAt(i);
|
|
18
|
+
a = Math.imul(a ^ code, 16777619);
|
|
19
|
+
b = Math.imul(b ^ code, 1540483477);
|
|
20
|
+
b ^= b >>> 13;
|
|
21
|
+
}
|
|
22
|
+
const hex = (n) => (n >>> 0).toString(16).padStart(8, "0");
|
|
23
|
+
return hex(a) + hex(b);
|
|
24
|
+
};
|
|
25
|
+
/** The receipt fingerprint of an action: canonical JSON of what the client sent, hashed. */
|
|
26
|
+
var fingerprint = (value) => hashText(canonicalJson(value));
|
|
27
|
+
//#endregion
|
|
28
|
+
export { canonicalJson, fingerprint, hashText };
|
|
29
|
+
|
|
30
|
+
//# sourceMappingURL=canonical.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"canonical.js","names":[],"sources":["../src/canonical.ts"],"sourcesContent":["// Canonical JSON and a small pure hash, for receipt fingerprints and change detection. Pure\n// and deterministic in every JavaScript engine: integer arithmetic through Math.imul only.\nimport type { Json } from '@joystyk/contracts';\n\n/** JSON with object keys sorted, so equal values always produce the same text. */\nexport const canonicalJson = (value: Json): string => {\n if (value === null || typeof value !== 'object') return JSON.stringify(value);\n if (Array.isArray(value)) return `[${value.map((item: Json) => canonicalJson(item)).join(',')}]`;\n const object = value as { readonly [key: string]: Json };\n const keys = Object.keys(object).sort();\n return `{${keys.map((key) => `${JSON.stringify(key)}:${canonicalJson(object[key] as Json)}`).join(',')}}`;\n};\n\n/**\n * A 64-bit hash of a string as 16 hex digits (two independent 32-bit FNV-1a style lanes).\n * Not cryptographic: it only tells a retried action from a different one with the same ID.\n */\nexport const hashText = (text: string): string => {\n let a = 0x811c9dc5;\n let b = 0x01000193 ^ text.length;\n for (let i = 0; i < text.length; i += 1) {\n const code = text.charCodeAt(i);\n a = Math.imul(a ^ code, 0x01000193);\n b = Math.imul(b ^ code, 0x5bd1e995);\n b ^= b >>> 13;\n }\n const hex = (n: number): string => (n >>> 0).toString(16).padStart(8, '0');\n return hex(a) + hex(b);\n};\n\n/** The receipt fingerprint of an action: canonical JSON of what the client sent, hashed. */\nexport const fingerprint = (value: Json): string => hashText(canonicalJson(value));\n"],"mappings":";;AAKA,IAAa,iBAAiB,UAAwB;CACpD,IAAI,UAAU,QAAQ,OAAO,UAAU,UAAU,OAAO,KAAK,UAAU,KAAK;CAC5E,IAAI,MAAM,QAAQ,KAAK,GAAG,OAAO,IAAI,MAAM,KAAK,SAAe,cAAc,IAAI,CAAC,CAAC,CAAC,KAAK,GAAG,EAAE;CAC9F,MAAM,SAAS;CAEf,OAAO,IADM,OAAO,KAAK,MAAM,CAAC,CAAC,KACtB,CAAA,CAAK,KAAK,QAAQ,GAAG,KAAK,UAAU,GAAG,EAAE,GAAG,cAAc,OAAO,IAAY,GAAG,CAAC,CAAC,KAAK,GAAG,EAAE;AACzG;;;;;AAMA,IAAa,YAAY,SAAyB;CAChD,IAAI,IAAI;CACR,IAAI,IAAI,WAAa,KAAK;CAC1B,KAAK,IAAI,IAAI,GAAG,IAAI,KAAK,QAAQ,KAAK,GAAG;EACvC,MAAM,OAAO,KAAK,WAAW,CAAC;EAC9B,IAAI,KAAK,KAAK,IAAI,MAAM,QAAU;EAClC,IAAI,KAAK,KAAK,IAAI,MAAM,UAAU;EAClC,KAAK,MAAM;CACb;CACA,MAAM,OAAO,OAAuB,MAAM,EAAA,CAAG,SAAS,EAAE,CAAC,CAAC,SAAS,GAAG,GAAG;CACzE,OAAO,IAAI,CAAC,IAAI,IAAI,CAAC;AACvB;;AAGA,IAAa,eAAe,UAAwB,SAAS,cAAc,KAAK,CAAC"}
|
package/dist/core.d.ts
ADDED