@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.
Files changed (51) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +147 -2
  3. package/dist/canonical.d.ts +10 -0
  4. package/dist/canonical.js +30 -0
  5. package/dist/canonical.js.map +1 -0
  6. package/dist/core.d.ts +3 -0
  7. package/dist/core.js +1879 -0
  8. package/dist/core.js.map +1 -0
  9. package/dist/index.d.ts +6 -0
  10. package/dist/index.js +7 -0
  11. package/dist/names.d.ts +19 -0
  12. package/dist/names.js +28 -0
  13. package/dist/names.js.map +1 -0
  14. package/dist/node/host.d.ts +67 -0
  15. package/dist/node/host.js +323 -0
  16. package/dist/node/host.js.map +1 -0
  17. package/dist/node/index.d.ts +6 -0
  18. package/dist/node/index.js +7 -0
  19. package/dist/node/jobs.d.ts +48 -0
  20. package/dist/node/jobs.js +176 -0
  21. package/dist/node/jobs.js.map +1 -0
  22. package/dist/node/registry.d.ts +32 -0
  23. package/dist/node/registry.js +43 -0
  24. package/dist/node/registry.js.map +1 -0
  25. package/dist/node/server.d.ts +59 -0
  26. package/dist/node/server.js +577 -0
  27. package/dist/node/server.js.map +1 -0
  28. package/dist/node/store.d.ts +53 -0
  29. package/dist/node/store.js +245 -0
  30. package/dist/node/store.js.map +1 -0
  31. package/dist/node/support.d.ts +67 -0
  32. package/dist/node/support.js +126 -0
  33. package/dist/node/support.js.map +1 -0
  34. package/dist/policy.d.ts +15 -0
  35. package/dist/policy.js +41 -0
  36. package/dist/policy.js.map +1 -0
  37. package/dist/protocol/index.d.ts +43 -0
  38. package/dist/protocol/index.js +48 -0
  39. package/dist/protocol/index.js.map +1 -0
  40. package/dist/protocol/schemas.d.ts +792 -0
  41. package/dist/protocol/schemas.js +332 -0
  42. package/dist/protocol/schemas.js.map +1 -0
  43. package/dist/protocol/types.d.ts +279 -0
  44. package/dist/protocol/types.js +1 -0
  45. package/dist/record.d.ts +173 -0
  46. package/dist/record.js +181 -0
  47. package/dist/record.js.map +1 -0
  48. package/dist/types.d.ts +374 -0
  49. package/dist/types.js +15 -0
  50. package/dist/types.js.map +1 -0
  51. 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 first Joystyk library release is not yet available. This placeholder reserves the package name.
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
- Source: https://github.com/joystyk-games/game-libraries
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
@@ -0,0 +1,3 @@
1
+ import { type RoomCore, type RoomCoreOptions } from './types.js';
2
+ /** Binds the core to one engine at one rules version. */
3
+ export declare const createRoomCore: (options: RoomCoreOptions) => RoomCore;