@joystyk/rooms 0.0.0-stage → 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 (53) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/LICENSE +21 -0
  3. package/NOTICE +36 -0
  4. package/README.md +149 -2
  5. package/dist/canonical.d.ts +10 -0
  6. package/dist/canonical.js +30 -0
  7. package/dist/canonical.js.map +1 -0
  8. package/dist/core.d.ts +3 -0
  9. package/dist/core.js +1879 -0
  10. package/dist/core.js.map +1 -0
  11. package/dist/index.d.ts +6 -0
  12. package/dist/index.js +7 -0
  13. package/dist/names.d.ts +19 -0
  14. package/dist/names.js +28 -0
  15. package/dist/names.js.map +1 -0
  16. package/dist/node/host.d.ts +67 -0
  17. package/dist/node/host.js +323 -0
  18. package/dist/node/host.js.map +1 -0
  19. package/dist/node/index.d.ts +6 -0
  20. package/dist/node/index.js +7 -0
  21. package/dist/node/jobs.d.ts +48 -0
  22. package/dist/node/jobs.js +176 -0
  23. package/dist/node/jobs.js.map +1 -0
  24. package/dist/node/registry.d.ts +32 -0
  25. package/dist/node/registry.js +43 -0
  26. package/dist/node/registry.js.map +1 -0
  27. package/dist/node/server.d.ts +59 -0
  28. package/dist/node/server.js +577 -0
  29. package/dist/node/server.js.map +1 -0
  30. package/dist/node/store.d.ts +53 -0
  31. package/dist/node/store.js +245 -0
  32. package/dist/node/store.js.map +1 -0
  33. package/dist/node/support.d.ts +67 -0
  34. package/dist/node/support.js +126 -0
  35. package/dist/node/support.js.map +1 -0
  36. package/dist/policy.d.ts +15 -0
  37. package/dist/policy.js +41 -0
  38. package/dist/policy.js.map +1 -0
  39. package/dist/protocol/index.d.ts +43 -0
  40. package/dist/protocol/index.js +48 -0
  41. package/dist/protocol/index.js.map +1 -0
  42. package/dist/protocol/schemas.d.ts +792 -0
  43. package/dist/protocol/schemas.js +332 -0
  44. package/dist/protocol/schemas.js.map +1 -0
  45. package/dist/protocol/types.d.ts +279 -0
  46. package/dist/protocol/types.js +1 -0
  47. package/dist/record.d.ts +173 -0
  48. package/dist/record.js +181 -0
  49. package/dist/record.js.map +1 -0
  50. package/dist/types.d.ts +374 -0
  51. package/dist/types.js +15 -0
  52. package/dist/types.js.map +1 -0
  53. package/package.json +62 -3
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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Joystyk contributors
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/NOTICE ADDED
@@ -0,0 +1,36 @@
1
+ Third-party material in the Joystyk library release
2
+
3
+ This notice preserves third-party attribution. Original Joystyk code in the 17
4
+ platform libraries under packages/ uses the MIT license in packages/LICENSE.
5
+ That choice does not license the private applications, games or other repository
6
+ material, and does not replace the third-party terms below.
7
+
8
+ @joystyk/contracts
9
+ ------------------
10
+ Standard Schema v1 type definitions from @standard-schema/spec 1.1.0:
11
+ https://github.com/standard-schema/standard-schema
12
+ https://standardschema.dev
13
+ Copyright (c) 2024 Colin McDonnell. MIT License.
14
+ The complete license and source/change notes accompany the distributed
15
+ dist/standard-schema.d.ts definitions. Changes: individually exported declarations
16
+ and formatting; the declarations themselves are unchanged.
17
+
18
+ @joystyk/identity and @joystyk/content
19
+ -----------------------------------
20
+ Word blocklists derived from List of Dirty, Naughty, Obscene, and Otherwise Bad
21
+ Words, copyright Shutterstock, Inc. and contributors:
22
+ https://github.com/LDNOOBW/List-of-Dirty-Naughty-Obscene-and-Otherwise-Bad-Words
23
+ Licensed under Creative Commons Attribution 4.0 International:
24
+ https://creativecommons.org/licenses/by/4.0/
25
+ Identity uses the English list at commit
26
+ 5faf2ba42d7b1c0977169ec3611df25a3c08eb13. Changes include reducing or dropping
27
+ phrases, dropping words with everyday meanings, substituting stems for inflections
28
+ and adding entries. Identity's README and content's lists/blocklist.txt retain
29
+ their source-specific provenance and changes.
30
+
31
+ @joystyk/content
32
+ ---------------
33
+ SCOWL-derived common, vocabulary and ranked word lists use SCOWL 2020.12.07.
34
+ Their complete source licenses and attribution accompany the package in
35
+ lists/SCOWL-COPYRIGHT. Word-list transformations and build provenance are described
36
+ in the package README. Preserve that copyright file when redistributing the lists.
package/README.md CHANGED
@@ -1,3 +1,150 @@
1
- # Temporary Holding Version
1
+ # @joystyk/rooms
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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.
8
+
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;