@joystyk/testing 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 (67) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +189 -2
  3. package/dist/builders.d.ts +59 -0
  4. package/dist/builders.js +129 -0
  5. package/dist/builders.js.map +1 -0
  6. package/dist/clock.d.ts +30 -0
  7. package/dist/clock.js +58 -0
  8. package/dist/clock.js.map +1 -0
  9. package/dist/deep.d.ts +25 -0
  10. package/dist/deep.js +105 -0
  11. package/dist/deep.js.map +1 -0
  12. package/dist/engine-conformance.d.ts +166 -0
  13. package/dist/engine-conformance.js +995 -0
  14. package/dist/engine-conformance.js.map +1 -0
  15. package/dist/fixtures/dice-race.d.ts +41 -0
  16. package/dist/fixtures/dice-race.js +153 -0
  17. package/dist/fixtures/dice-race.js.map +1 -0
  18. package/dist/fixtures/draw-play.d.ts +54 -0
  19. package/dist/fixtures/draw-play.js +188 -0
  20. package/dist/fixtures/draw-play.js.map +1 -0
  21. package/dist/fixtures/drift.d.ts +34 -0
  22. package/dist/fixtures/drift.js +109 -0
  23. package/dist/fixtures/drift.js.map +1 -0
  24. package/dist/fixtures/guards.d.ts +11 -0
  25. package/dist/fixtures/guards.js +16 -0
  26. package/dist/fixtures/guards.js.map +1 -0
  27. package/dist/fixtures/hint-puzzle.d.ts +47 -0
  28. package/dist/fixtures/hint-puzzle.js +129 -0
  29. package/dist/fixtures/hint-puzzle.js.map +1 -0
  30. package/dist/fixtures/index.d.ts +6 -0
  31. package/dist/fixtures/index.js +7 -0
  32. package/dist/fixtures/nim.d.ts +34 -0
  33. package/dist/fixtures/nim.js +141 -0
  34. package/dist/fixtures/nim.js.map +1 -0
  35. package/dist/fixtures/sealed-bids.d.ts +46 -0
  36. package/dist/fixtures/sealed-bids.js +141 -0
  37. package/dist/fixtures/sealed-bids.js.map +1 -0
  38. package/dist/index.d.ts +13 -0
  39. package/dist/index.js +14 -0
  40. package/dist/replay.d.ts +58 -0
  41. package/dist/replay.js +110 -0
  42. package/dist/replay.js.map +1 -0
  43. package/dist/report.d.ts +68 -0
  44. package/dist/report.js +104 -0
  45. package/dist/report.js.map +1 -0
  46. package/dist/rng.d.ts +18 -0
  47. package/dist/rng.js +25 -0
  48. package/dist/rng.js.map +1 -0
  49. package/dist/schema.d.ts +13 -0
  50. package/dist/schema.js +21 -0
  51. package/dist/schema.js.map +1 -0
  52. package/dist/session-ports.d.ts +16 -0
  53. package/dist/session-ports.js +32 -0
  54. package/dist/session-ports.js.map +1 -0
  55. package/dist/simulation-conformance.d.ts +71 -0
  56. package/dist/simulation-conformance.js +312 -0
  57. package/dist/simulation-conformance.js.map +1 -0
  58. package/dist/storage.d.ts +29 -0
  59. package/dist/storage.js +63 -0
  60. package/dist/storage.js.map +1 -0
  61. package/dist/vitest.d.ts +32 -0
  62. package/dist/vitest.js +45 -0
  63. package/dist/vitest.js.map +1 -0
  64. package/dist/waiting.d.ts +58 -0
  65. package/dist/waiting.js +97 -0
  66. package/dist/waiting.js.map +1 -0
  67. package/package.json +49 -11
package/CHANGELOG.md ADDED
@@ -0,0 +1,31 @@
1
+ # @joystyk/testing
2
+
3
+ ## 0.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - bb095c7: Share canonical IPv4 and IPv6 /64 rate-limit keys across Node services. Keep room
8
+ initialization diagnostics on the server behind a client-safe reference, and expire old
9
+ message-rate strikes. Extend hidden-information conformance checks with transition-aware
10
+ perturbations so private events remain testable when recipients gain cards.
11
+
12
+ ### Patch Changes
13
+
14
+ - Updated dependencies [bb095c7]
15
+ - Updated dependencies [bb095c7]
16
+ - @joystyk/contracts@0.2.0
17
+ - @joystyk/session@0.2.0
18
+ - @joystyk/kit@0.2.0
19
+
20
+ ## 0.1.0
21
+
22
+ ### Minor Changes
23
+
24
+ - 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.
25
+
26
+ ### Patch Changes
27
+
28
+ - Updated dependencies
29
+ - @joystyk/contracts@0.1.0
30
+ - @joystyk/kit@0.1.0
31
+ - @joystyk/session@0.1.0
package/README.md CHANGED
@@ -1,5 +1,192 @@
1
1
  # @joystyk/testing
2
2
 
3
- The first Joystyk library release is not yet available. This placeholder reserves the package name.
3
+ Dev-time helpers that games and platform packages use from their own test files. The heart
4
+ of the package is the pair of **conformance suites** that check the
5
+ game contract (Joystyk host reference): a game opts in
6
+ with one short test file, and the suite plays hundreds of seeded random games against its
7
+ engine or simulation. Around them sit the fakes and builders that make other tests quick to
8
+ write: a test RNG, a fake clock, a fake storage port, replay and undo helpers, and fixture
9
+ builders.
4
10
 
5
- Source: https://github.com/joystyk-games/game-libraries
11
+ ## npm package
12
+
13
+ The first `0.1.0` release is being prepared; it has not been published. Once available:
14
+
15
+ ```sh
16
+ npm install @joystyk/testing
17
+ ```
18
+
19
+ Node consumers require Node 24.15 or later. Browser entry points require a modern
20
+ browser and should be loaded in the browser. The root entry integrates with Vitest
21
+ 4.1.11; use it inside a Vitest test. Fixtures can also be imported separately.
22
+
23
+ | Import | Format |
24
+ | --- | --- |
25
+ | `@joystyk/testing` | ES module and TypeScript declarations |
26
+ | `@joystyk/testing/fixtures` | ES module and TypeScript declarations |
27
+
28
+ Only the entries listed above are public API. Installed packages contain compiled ESM,
29
+ type declarations and runtime assets. Repository-relative links describe the Joystyk
30
+ workspace and are not needed to use the npm package.
31
+
32
+ It depends on `@joystyk/contracts`, `@joystyk/kit` and `@joystyk/session` (whose runner
33
+ replay it cross-checks against), and on `vitest` as a peer. Never import it from production
34
+ code.
35
+
36
+ ## Conformance for a discrete engine
37
+
38
+ ```ts
39
+ // games/nim/engine/conformance.test.ts
40
+ import { runEngineConformance } from '@joystyk/testing';
41
+ import { engine, migrations, perturbHidden } from './index.ts';
42
+
43
+ runEngineConformance(engine, {
44
+ name: 'nim',
45
+ configs: [{ heap: 5 }, { heap: 21 }],
46
+ seatCounts: [2],
47
+ // Candidate commands for a seat: all legal moves, or a sample. Illegal ones are fine;
48
+ // the engine's answer decides. Without this the suite asks engine.ai for moves.
49
+ commands: (state, seat) => [1, 2, 3].map((count) => ({ type: 'take', count })),
50
+ perturbHidden, // games with hidden information; see below
51
+ invariants: (state) => state.heap >= 0 || 'the heap is negative',
52
+ saves: { version: 2, migrations, fixtures: [/* one stored state per shipped save version */] },
53
+ });
54
+ ```
55
+
56
+ `runEngineConformance` registers a `describe` block with one `it` per check, so a failure
57
+ names the rule it broke. The games are played once, in the first case to run, and every
58
+ case reads the shared report. A check that doesn't apply (no AI, no hidden information) is
59
+ reported as skipped, with the reason.
60
+
61
+ Each failure prints what broke, the game's seed, the number of inputs before it, the input
62
+ being checked, and the replay of every accepted input so far:
63
+
64
+ ```text
65
+ [no-leak] … : failed, 14 failures
66
+ #1 viewFor(state, 0) changed when only hidden information changed: $.others[1][0].id: "q6y31" vs "brh76"
67
+ at: setup 0, game 3, seed "conformance/0/3", after 4 inputs
68
+ rerun only this game: add `only: { setup: 0, game: 3 }` to the options
69
+ replay: {"layer":2,"gameId":"test-game",…,"inputs":[…]}
70
+ ```
71
+
72
+ Add the `only` option to replay just that game, or pass the printed replay to
73
+ `replayEngine(engine, replay)` to reach the failing state in a debugger.
74
+
75
+ ### What it checks
76
+
77
+ | Check | What fails it |
78
+ | --- | --- |
79
+ | `determinism` | The same seed and inputs give different states, beats, jobs, barriers, assistance, outcomes or random state; or `apply` answers the same arguments twice in two ways |
80
+ | `replay` | The recorded log, after a JSON round trip, doesn't reproduce the final state, outcome and barriers, through this package's replay and through the session runner's |
81
+ | `json` | A config, state, view, redacted event, command, job request, decision key or outcome isn't JSON (the message names the path, e.g. `-0 at $.lean`) |
82
+ | `schemas` | A value fails the engine's own schema; or a command, state, config or job schema validates asynchronously, which runners can't wait for |
83
+ | `no-leak` | `viewFor` or `decisionKey` changes when `perturbHidden` changes only what that viewer can't see, for every seat and the spectator; `eventFor` tells a viewer different things about two transitions that look identical to that viewer; or `perturbHidden` never changes anything |
84
+ | `rejections` | `apply` throws, mutates its (frozen) input, or rejects without a JSON `GameError` |
85
+ | `turns` | A command from a seat outside `pending()` (including seats that don't exist) is accepted; `pending()` lists unknown or repeated seats, or isn't empty after the outcome; a `timeout` or `seat` input (when `platformInputs` sends them) is refused while the game is in progress |
86
+ | `resign` | A `resign` from any seat, in turn or out of turn, is refused while the game is in progress |
87
+ | `invariants` | The `invariants` hook returns `false` or a message, or throws, on any state a game reaches |
88
+ | `ai` | `ai.choose`, at any level and for any pending seat, throws or picks a command the engine rejects |
89
+ | `saves` | A state saved at a random point doesn't survive JSON and `schemas.state` unchanged, or a game restored from it ends differently; a save fixture is missing for a shipped version, or doesn't migrate and validate |
90
+ | `jobs` | A job outcome for an id never requested, already answered or superseded is accepted; an awaited job accepts a value but not a failure, or the reverse; a job id repeats; a job kind is undeclared or has no fake runner; a payload or the fake runner's value fails its schema |
91
+ | `stale` | While a seat's `decisionKey` stays the same, the set of commands the engine accepts from it changes, so the room would let a stale command through |
92
+ | `undo` | Undo by replay doesn't return to the state before the human's previous decision, steps back past an `undoBarrier`, or refuses although no barrier is in the way; or a barrier differs from the spec's `undoBarrier` rule |
93
+ | `assist` | A transition's `assist` isn't `'hint'`, `'check'` or `'reveal'`, differs from the spec's `assist` rule, or isn't reproduced by replay |
94
+ | `session-hooks` | A transition sets `startsClock` without the engine declaring `clockStart: 'transition'`, or sets it twice in one game; or `worthSaving` throws, mutates its state, answers something other than a boolean, or answers differently for the same state or its JSON copy. Skipped when the engine declares neither hook |
95
+ | `outcome` | A game has no outcome after `maxSteps` inputs; nobody can move although the game isn't over; the outcome is malformed (every seat placed once, places from 1, no platform-written fields); a finished game accepts a command or a resignation |
96
+
97
+ The suite deep-freezes every state and input it hands the engine, and restores the random
98
+ state after every trial, as the session runner does. It tries each candidate input on a
99
+ copy of the generator before committing one, so trials never change the game.
100
+
101
+ ### Options
102
+
103
+ | Option | Default | Meaning |
104
+ | --- | --- | --- |
105
+ | `setups` or `configs` × `seatCounts` | required | How games start: `{ config, seats }`, seats as a count or `SeatInfo`s |
106
+ | `commands` | `engine.ai` | Candidate commands for a seat in a state |
107
+ | `perturbHidden` | none (no-leak skipped) | `(state, viewer, rng) => state` changing only what `viewer` can't see |
108
+ | `perturbTransition` | none | Optional `(state, viewer, rng, transition) => state` retry helper: preserve current knowledge, what the viewer learns in `transition.states`, and cards required by `transition.input`; vary everything still hidden |
109
+ | `invariants` | none | `(state) => void \| boolean \| string` |
110
+ | `games`, `maxSteps`, `seed` | 12, 400, `"conformance"` | Games per setup, inputs per game, base seed |
111
+ | `requireOutcome` | true | Fail games that don't finish in `maxSteps` |
112
+ | `platformInputs` | `{ resign: true }` | Also send `timeout` and `seat` inputs with `{ timeout: true, seatChanges: true }` |
113
+ | `jobs`, `jobFailureRate` | none, 0.1 | Fake job runners by kind, `(payload, rng) => value`, and how often a job fails instead |
114
+ | `undoBarrier`, `assist` | none | The spec's rules: which inputs are barriers, and which assistance each input is |
115
+ | `saves` | round trip only | `{ version, firstVersion?, migrations, fixtures }` |
116
+ | `only` | all games | `{ setup, game }`, to rerun one reported game |
117
+ | `timeoutMs` | 300 000 | Per vitest case (`runEngineConformance` and `runSimulationConformance`) |
118
+
119
+ `checkEngine(engine, options)` runs the same suite without vitest and returns a
120
+ `ConformanceReport`; `formatReport` turns it into text and `expectConformance` throws unless
121
+ it passed. Use it to assert on the report itself, as this package's own tests do.
122
+
123
+ ## Conformance for a fixed-step simulation
124
+
125
+ ```ts
126
+ import { runSimulationConformance } from '@joystyk/testing';
127
+
128
+ runSimulationConformance(simulation, {
129
+ configs: [{ length: 40, limit: 600 }],
130
+ ticks: 1000,
131
+ inputs: (state, tick, rng) => (rng.int(0, 5) === 0 ? [{ type: 'nudge', dir: 1 }] : []),
132
+ requireOutcome: true,
133
+ invariants: (state) => state.pos >= 0,
134
+ });
135
+ ```
136
+
137
+ | Check | What fails it |
138
+ | --- | --- |
139
+ | `determinism` | A second run with the same seed and tick inputs gives different results or random state |
140
+ | `replay` | The tick log, after a JSON round trip, doesn't reproduce the final state and outcome, here or through the session scheduler's replay |
141
+ | `checkpoint` | Saving at a tick, restoring through JSON and `schemas.state`, and running on gives a different end than running straight through |
142
+ | `json`, `schemas` | A config, state, input, event or outcome isn't JSON or fails its schema |
143
+ | `purity` | `step` throws, mutates its frozen input, or answers the same state, inputs and generator in two ways |
144
+ | `invariants` | The hook fails on a state |
145
+ | `outcome` | A malformed outcome, or none within `ticks` when `requireOutcome` is set |
146
+ | `budget` | The mean step exceeds `stepBudgetMs` (off unless set; budgets belong on a reference machine) |
147
+
148
+ ## Helpers
149
+
150
+ | Helper | Use |
151
+ | --- | --- |
152
+ | `testRng(...parts)`, `testSeed(...parts)`, `testSeeds(base, n)`, `cloneRng(rng)` | Seeded kit generators for tests, namespaced so they never equal a production seed |
153
+ | `createFakeClock(start)` | `now`, `setTimer`, `setTimeout`, `advance`, `advanceTo`, `runAll`; the shape of the room server's `Clock` port |
154
+ | `manualFrames()` | A fixed-step runner's `frames` port that runs only when the test says: `run(n)`, `advance(n)` and `until(condition, what)`, which counts game time in frames (testing under load (Joystyk host reference)) |
155
+ | `waitUntil(condition, what)`, `pause(ms)`, `nextTask()`, `WAIT_BUDGET_MS` | Waits on a condition with a budget only a real failure exhausts, instead of a sleep |
156
+ | `createFakeStorage(initial)` | The contract's `GameStorage` in memory, storing JSON text, with `entries`, `calls` and `failNext(op)` |
157
+ | `runnerClock(fake)`, `createMemorySaveStore()` | The session runner's `Clock` on a fake clock, and a save slot in memory with `slot`, `writes` and `clears`, to play a game through `createLocalSession` |
158
+ | `replayEngine`, `replaySimulation` | Replay a log and report each input's barrier, assistance and whether a human sent it |
159
+ | `undoByReplay(engine, replay)` | Undo as the local runner does it: back to the start of the human's previous decision, refusing to cross a barrier, with the assistance record (`'undo'` included) |
160
+ | `seats`, `commandInput`, `resignInput`, `timeoutInput`, `seatInput`, `jobResult`, `jobFailure` | Fixture builders for seats and inputs |
161
+ | `engineReplay`, `simulationReplay`, `saveEnvelope`, `saveFixture` | Replays, saves and save fixtures with test defaults |
162
+ | `guardSchema(guard)`, `anySchema()`, `validate(schema, value)` | Standard Schemas from type guards, and running any schema |
163
+ | `firstDifference`, `deepEqual`, `deepFreeze`, `jsonRoundTrip` | What the suites use to compare and protect values |
164
+
165
+ ## Fixtures
166
+
167
+ `@joystyk/testing/fixtures` holds small, correct games, each with conformance options that
168
+ pass, for platform tests (runners, rooms, the two-client harness) that need a real engine:
169
+
170
+ | Fixture | Layer | Exercises |
171
+ | --- | --- | --- |
172
+ | `nim` | 2 | Public state, runner-driven AI, saves with a migration chain |
173
+ | `drawPlay` | 2 | Hidden hands and deck, opaque card ids, redacted events, two to four seats |
174
+ | `sealedBids` | 2 | Simultaneous moves, a custom `decisionKey`, hidden choices |
175
+ | `diceRace` | 2 | Jobs: requests, awaited ids and failures; solo and two seats |
176
+ | `hintPuzzle` | 2 | Solo hidden information, undo barriers, `assist: 'hint'`, and both session hooks (`clockStart`, `worthSaving`) |
177
+ | `drift` | 3 | A fixed-step simulation with seeded wind and scripted inputs |
178
+
179
+ ## Tests
180
+
181
+ - `fixtures.test.ts` runs both suites through the vitest wrappers on every fixture, then
182
+ asserts from the reports that each fixture exercised the checks it was built for.
183
+ - `broken.test.ts` runs the suites on deliberately broken fixtures (`broken.test-util.ts`),
184
+ one flaw each, and asserts that the right check fires: leaking ids, spectator views,
185
+ events and decision keys; non-deterministic, mutating and throwing engines; `undefined`
186
+ and `-0` in state; asynchronous and disagreeing schemas; wrong-turn acceptance; refused
187
+ and late resignations; endless and stuck games; illegal AI moves; frozen decision keys;
188
+ lost cards; missing barriers and wrong assistance; a clock started twice or undeclared,
189
+ and an impure or mutating `worthSaving`; job ids accepted, refused or reused;
190
+ missing and broken save fixtures; and the simulation equivalents. It also proves that a
191
+ reported seed and replay reproduce the failure.
192
+ - `helpers.test.ts` covers the RNG, clock, storage, builders, deep helpers and undo.
@@ -0,0 +1,59 @@
1
+ import type { EngineInput, EngineReplay, Json, Replay, RngState, SaveEnvelope, SeatChange, SeatId, SeatInfo, SimulationReplay, TickInput, Validator } from '@joystyk/contracts';
2
+ /**
3
+ * `count` human seats with ids 0 … count − 1 and stable names. Pass `ai` to make some seats
4
+ * AI seats: `seats(4, { ai: [1, 2, 3], aiLevel: 'easy' })`.
5
+ */
6
+ export declare const seats: (count: number, options?: {
7
+ readonly ai?: readonly SeatId[];
8
+ readonly aiLevel?: string;
9
+ }) => SeatInfo[];
10
+ /** A `command` input. */
11
+ export declare const commandInput: <C>(seat: SeatId, command: C) => EngineInput<C>;
12
+ /** A `resign` input. */
13
+ export declare const resignInput: <C = never>(seat: SeatId) => EngineInput<C>;
14
+ /** A `timeout` input. */
15
+ export declare const timeoutInput: <C = never>(seat: SeatId) => EngineInput<C>;
16
+ /** A `seat` input. */
17
+ export declare const seatInput: <C = never>(seat: SeatId, change: SeatChange) => EngineInput<C>;
18
+ /** A `job` input carrying a successful outcome (without presentation, as the runner feeds it). */
19
+ export declare const jobResult: <C = never>(jobId: string, value: Json) => EngineInput<C>;
20
+ /** A `job` input carrying a failed outcome. */
21
+ export declare const jobFailure: <C = never>(jobId: string, reason?: "timeout" | "failed") => EngineInput<C>;
22
+ /**
23
+ * A Standard Schema from a type guard. Enough for fixtures and tests; real games use a schema
24
+ * library (Zod 4 by default, D-22).
25
+ */
26
+ export declare const guardSchema: <T>(guard: (value: unknown) => value is T, message?: string) => Validator<T>;
27
+ /** A Standard Schema that accepts anything as `T`. Use it only where the test isn't about schemas. */
28
+ export declare const anySchema: <T>() => Validator<T>;
29
+ /** A save fixture: the stored state of one shipped save version. */
30
+ export type SaveFixture = {
31
+ /** A label for reports, e.g. "v1 mid-game". */
32
+ readonly name: string;
33
+ readonly saveVersion: number;
34
+ readonly state: Json;
35
+ };
36
+ /** A save fixture, for the `saves` option of the engine suite. */
37
+ export declare const saveFixture: (name: string, saveVersion: number, state: Json) => SaveFixture;
38
+ /** A save envelope with test defaults for everything but the state and the random state. */
39
+ export declare const saveEnvelope: (fields: Pick<SaveEnvelope, "state" | "rng"> & Partial<SaveEnvelope>) => SaveEnvelope;
40
+ /** A Layer 2 replay with test defaults. */
41
+ export declare const engineReplay: <C extends Json = Json, Cfg extends Json = Json>(fields: {
42
+ readonly config: Cfg;
43
+ readonly seats: readonly SeatInfo[];
44
+ readonly seed: RngState;
45
+ readonly inputs: readonly EngineInput<C>[];
46
+ readonly gameId?: string;
47
+ readonly rulesVersion?: number;
48
+ }) => EngineReplay<C, Cfg>;
49
+ /** A Layer 3 replay with test defaults. */
50
+ export declare const simulationReplay: <I extends Json = Json, Cfg extends Json = Json>(fields: {
51
+ readonly config: Cfg;
52
+ readonly seed: RngState;
53
+ readonly inputs: readonly TickInput<I>[];
54
+ readonly endTick: number;
55
+ readonly gameId?: string;
56
+ readonly rulesVersion?: number;
57
+ }) => SimulationReplay<I, Cfg>;
58
+ /** Narrows a replay by layer. */
59
+ export declare const isEngineReplay: (replay: Replay) => replay is EngineReplay;
@@ -0,0 +1,129 @@
1
+ //#region src/builders.ts
2
+ var NAMES = [
3
+ "Ana",
4
+ "Ben",
5
+ "Cleo",
6
+ "Dev",
7
+ "Eli",
8
+ "Fay",
9
+ "Gus",
10
+ "Hana"
11
+ ];
12
+ /**
13
+ * `count` human seats with ids 0 … count − 1 and stable names. Pass `ai` to make some seats
14
+ * AI seats: `seats(4, { ai: [1, 2, 3], aiLevel: 'easy' })`.
15
+ */
16
+ var seats = (count, options = {}) => Array.from({ length: count }, (_, id) => {
17
+ const name = NAMES[id] ?? `Seat ${id + 1}`;
18
+ if (!(options.ai ?? []).includes(id)) return {
19
+ id,
20
+ name,
21
+ controller: "human"
22
+ };
23
+ return options.aiLevel === void 0 ? {
24
+ id,
25
+ name: `${name} (AI)`,
26
+ controller: "ai"
27
+ } : {
28
+ id,
29
+ name: `${name} (AI)`,
30
+ controller: "ai",
31
+ aiLevel: options.aiLevel
32
+ };
33
+ });
34
+ /** A `command` input. */
35
+ var commandInput = (seat, command) => ({
36
+ kind: "command",
37
+ seat,
38
+ command
39
+ });
40
+ /** A `resign` input. */
41
+ var resignInput = (seat) => ({
42
+ kind: "resign",
43
+ seat
44
+ });
45
+ /** A `timeout` input. */
46
+ var timeoutInput = (seat) => ({
47
+ kind: "timeout",
48
+ seat
49
+ });
50
+ /** A `seat` input. */
51
+ var seatInput = (seat, change) => ({
52
+ kind: "seat",
53
+ seat,
54
+ change
55
+ });
56
+ /** A `job` input carrying a successful outcome (without presentation, as the runner feeds it). */
57
+ var jobResult = (jobId, value) => ({
58
+ kind: "job",
59
+ jobId,
60
+ outcome: {
61
+ ok: true,
62
+ value
63
+ }
64
+ });
65
+ /** A `job` input carrying a failed outcome. */
66
+ var jobFailure = (jobId, reason = "failed") => ({
67
+ kind: "job",
68
+ jobId,
69
+ outcome: {
70
+ ok: false,
71
+ reason
72
+ }
73
+ });
74
+ /**
75
+ * A Standard Schema from a type guard. Enough for fixtures and tests; real games use a schema
76
+ * library (Zod 4 by default, D-22).
77
+ */
78
+ var guardSchema = (guard, message = "Invalid value.") => ({ "~standard": {
79
+ version: 1,
80
+ vendor: "joystyk-testing",
81
+ validate: (value) => guard(value) ? { value } : { issues: [{ message }] }
82
+ } });
83
+ /** A Standard Schema that accepts anything as `T`. Use it only where the test isn't about schemas. */
84
+ var anySchema = () => ({ "~standard": {
85
+ version: 1,
86
+ vendor: "joystyk-testing",
87
+ validate: (value) => ({ value })
88
+ } });
89
+ /** A save fixture, for the `saves` option of the engine suite. */
90
+ var saveFixture = (name, saveVersion, state) => ({
91
+ name,
92
+ saveVersion,
93
+ state
94
+ });
95
+ /** A save envelope with test defaults for everything but the state and the random state. */
96
+ var saveEnvelope = (fields) => ({
97
+ gameId: "test-game",
98
+ build: "0.0.0-test",
99
+ rulesVersion: 1,
100
+ saveVersion: 1,
101
+ seats: seats(2),
102
+ ...fields
103
+ });
104
+ /** A Layer 2 replay with test defaults. */
105
+ var engineReplay = (fields) => ({
106
+ layer: 2,
107
+ gameId: fields.gameId ?? "test-game",
108
+ rulesVersion: fields.rulesVersion ?? 1,
109
+ config: fields.config,
110
+ seats: fields.seats,
111
+ seed: fields.seed,
112
+ inputs: fields.inputs
113
+ });
114
+ /** A Layer 3 replay with test defaults. */
115
+ var simulationReplay = (fields) => ({
116
+ layer: 3,
117
+ gameId: fields.gameId ?? "test-game",
118
+ rulesVersion: fields.rulesVersion ?? 1,
119
+ config: fields.config,
120
+ seed: fields.seed,
121
+ inputs: fields.inputs,
122
+ endTick: fields.endTick
123
+ });
124
+ /** Narrows a replay by layer. */
125
+ var isEngineReplay = (replay) => replay.layer === 2;
126
+ //#endregion
127
+ export { anySchema, commandInput, engineReplay, guardSchema, isEngineReplay, jobFailure, jobResult, resignInput, saveEnvelope, saveFixture, seatInput, seats, simulationReplay, timeoutInput };
128
+
129
+ //# sourceMappingURL=builders.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"builders.js","names":[],"sources":["../src/builders.ts"],"sourcesContent":["// Fixture builders: seats, inputs, schemas, saves and replays, without boilerplate.\n\nimport type {\n EngineInput,\n EngineReplay,\n Json,\n JobOutcome,\n Replay,\n RngState,\n SaveEnvelope,\n SeatChange,\n SeatId,\n SeatInfo,\n SimulationReplay,\n StandardSchemaV1,\n TickInput,\n Validator,\n} from '@joystyk/contracts';\n\nconst NAMES = ['Ana', 'Ben', 'Cleo', 'Dev', 'Eli', 'Fay', 'Gus', 'Hana'];\n\n/**\n * `count` human seats with ids 0 … count − 1 and stable names. Pass `ai` to make some seats\n * AI seats: `seats(4, { ai: [1, 2, 3], aiLevel: 'easy' })`.\n */\nexport const seats = (\n count: number,\n options: { readonly ai?: readonly SeatId[]; readonly aiLevel?: string } = {},\n): SeatInfo[] =>\n Array.from({ length: count }, (_, id): SeatInfo => {\n const name = NAMES[id] ?? `Seat ${id + 1}`;\n if (!(options.ai ?? []).includes(id)) return { id, name, controller: 'human' };\n return options.aiLevel === undefined\n ? { id, name: `${name} (AI)`, controller: 'ai' }\n : { id, name: `${name} (AI)`, controller: 'ai', aiLevel: options.aiLevel };\n });\n\n/** A `command` input. */\nexport const commandInput = <C>(seat: SeatId, command: C): EngineInput<C> => ({ kind: 'command', seat, command });\n\n/** A `resign` input. */\nexport const resignInput = <C = never>(seat: SeatId): EngineInput<C> => ({ kind: 'resign', seat });\n\n/** A `timeout` input. */\nexport const timeoutInput = <C = never>(seat: SeatId): EngineInput<C> => ({ kind: 'timeout', seat });\n\n/** A `seat` input. */\nexport const seatInput = <C = never>(seat: SeatId, change: SeatChange): EngineInput<C> => ({\n kind: 'seat',\n seat,\n change,\n});\n\n/** A `job` input carrying a successful outcome (without presentation, as the runner feeds it). */\nexport const jobResult = <C = never>(jobId: string, value: Json): EngineInput<C> => ({\n kind: 'job',\n jobId,\n outcome: { ok: true, value },\n});\n\n/** A `job` input carrying a failed outcome. */\nexport const jobFailure = <C = never>(jobId: string, reason: 'timeout' | 'failed' = 'failed'): EngineInput<C> => ({\n kind: 'job',\n jobId,\n outcome: { ok: false, reason } satisfies JobOutcome,\n});\n\n/**\n * A Standard Schema from a type guard. Enough for fixtures and tests; real games use a schema\n * library (Zod 4 by default, D-22).\n */\nexport const guardSchema = <T>(guard: (value: unknown) => value is T, message = 'Invalid value.'): Validator<T> => ({\n '~standard': {\n version: 1,\n vendor: 'joystyk-testing',\n validate: (value): StandardSchemaV1.Result<T> => (guard(value) ? { value } : { issues: [{ message }] }),\n },\n});\n\n/** A Standard Schema that accepts anything as `T`. Use it only where the test isn't about schemas. */\nexport const anySchema = <T>(): Validator<T> => ({\n '~standard': { version: 1, vendor: 'joystyk-testing', validate: (value) => ({ value: value as T }) },\n});\n\n/** A save fixture: the stored state of one shipped save version. */\nexport type SaveFixture = {\n /** A label for reports, e.g. \"v1 mid-game\". */\n readonly name: string;\n readonly saveVersion: number;\n readonly state: Json;\n};\n\n/** A save fixture, for the `saves` option of the engine suite. */\nexport const saveFixture = (name: string, saveVersion: number, state: Json): SaveFixture => ({\n name,\n saveVersion,\n state,\n});\n\n/** A save envelope with test defaults for everything but the state and the random state. */\nexport const saveEnvelope = (fields: Pick<SaveEnvelope, 'state' | 'rng'> & Partial<SaveEnvelope>): SaveEnvelope => ({\n gameId: 'test-game',\n build: '0.0.0-test',\n rulesVersion: 1,\n saveVersion: 1,\n seats: seats(2),\n ...fields,\n});\n\n/** A Layer 2 replay with test defaults. */\nexport const engineReplay = <C extends Json = Json, Cfg extends Json = Json>(fields: {\n readonly config: Cfg;\n readonly seats: readonly SeatInfo[];\n readonly seed: RngState;\n readonly inputs: readonly EngineInput<C>[];\n readonly gameId?: string;\n readonly rulesVersion?: number;\n}): EngineReplay<C, Cfg> => ({\n layer: 2,\n gameId: fields.gameId ?? 'test-game',\n rulesVersion: fields.rulesVersion ?? 1,\n config: fields.config,\n seats: fields.seats,\n seed: fields.seed,\n inputs: fields.inputs,\n});\n\n/** A Layer 3 replay with test defaults. */\nexport const simulationReplay = <I extends Json = Json, Cfg extends Json = Json>(fields: {\n readonly config: Cfg;\n readonly seed: RngState;\n readonly inputs: readonly TickInput<I>[];\n readonly endTick: number;\n readonly gameId?: string;\n readonly rulesVersion?: number;\n}): SimulationReplay<I, Cfg> => ({\n layer: 3,\n gameId: fields.gameId ?? 'test-game',\n rulesVersion: fields.rulesVersion ?? 1,\n config: fields.config,\n seed: fields.seed,\n inputs: fields.inputs,\n endTick: fields.endTick,\n});\n\n/** Narrows a replay by layer. */\nexport const isEngineReplay = (replay: Replay): replay is EngineReplay => replay.layer === 2;\n"],"mappings":";AAmBA,IAAM,QAAQ;CAAC;CAAO;CAAO;CAAQ;CAAO;CAAO;CAAO;CAAO;AAAM;;;;;AAMvE,IAAa,SACX,OACA,UAA0E,CAAC,MAE3E,MAAM,KAAK,EAAE,QAAQ,MAAM,IAAI,GAAG,OAAiB;CACjD,MAAM,OAAO,MAAM,OAAO,QAAQ,KAAK;CACvC,IAAI,EAAE,QAAQ,MAAM,CAAC,EAAA,CAAG,SAAS,EAAE,GAAG,OAAO;EAAE;EAAI;EAAM,YAAY;CAAQ;CAC7E,OAAO,QAAQ,YAAY,KAAA,IACvB;EAAE;EAAI,MAAM,GAAG,KAAK;EAAQ,YAAY;CAAK,IAC7C;EAAE;EAAI,MAAM,GAAG,KAAK;EAAQ,YAAY;EAAM,SAAS,QAAQ;CAAQ;AAC7E,CAAC;;AAGH,IAAa,gBAAmB,MAAc,aAAgC;CAAE,MAAM;CAAW;CAAM;AAAQ;;AAG/G,IAAa,eAA0B,UAAkC;CAAE,MAAM;CAAU;AAAK;;AAGhG,IAAa,gBAA2B,UAAkC;CAAE,MAAM;CAAW;AAAK;;AAGlG,IAAa,aAAwB,MAAc,YAAwC;CACzF,MAAM;CACN;CACA;AACF;;AAGA,IAAa,aAAwB,OAAe,WAAiC;CACnF,MAAM;CACN;CACA,SAAS;EAAE,IAAI;EAAM;CAAM;AAC7B;;AAGA,IAAa,cAAyB,OAAe,SAA+B,cAA8B;CAChH,MAAM;CACN;CACA,SAAS;EAAE,IAAI;EAAO;CAAO;AAC/B;;;;;AAMA,IAAa,eAAkB,OAAuC,UAAU,sBAAoC,EAClH,aAAa;CACX,SAAS;CACT,QAAQ;CACR,WAAW,UAAuC,MAAM,KAAK,IAAI,EAAE,MAAM,IAAI,EAAE,QAAQ,CAAC,EAAE,QAAQ,CAAC,EAAE;AACvG,EACF;;AAGA,IAAa,mBAAoC,EAC/C,aAAa;CAAE,SAAS;CAAG,QAAQ;CAAmB,WAAW,WAAW,EAAS,MAAW;AAAG,EACrG;;AAWA,IAAa,eAAe,MAAc,aAAqB,WAA8B;CAC3F;CACA;CACA;AACF;;AAGA,IAAa,gBAAgB,YAAuF;CAClH,QAAQ;CACR,OAAO;CACP,cAAc;CACd,aAAa;CACb,OAAO,MAAM,CAAC;CACd,GAAG;AACL;;AAGA,IAAa,gBAAgE,YAOhD;CAC3B,OAAO;CACP,QAAQ,OAAO,UAAU;CACzB,cAAc,OAAO,gBAAgB;CACrC,QAAQ,OAAO;CACf,OAAO,OAAO;CACd,MAAM,OAAO;CACb,QAAQ,OAAO;AACjB;;AAGA,IAAa,oBAAoE,YAOhD;CAC/B,OAAO;CACP,QAAQ,OAAO,UAAU;CACzB,cAAc,OAAO,gBAAgB;CACrC,QAAQ,OAAO;CACf,MAAM,OAAO;CACb,QAAQ,OAAO;CACf,SAAS,OAAO;AAClB;;AAGA,IAAa,kBAAkB,WAA2C,OAAO,UAAU"}
@@ -0,0 +1,30 @@
1
+ /** A scheduled callback; cancelling it twice, or after it fired, is harmless. */
2
+ export type TimerHandle = {
3
+ cancel(): void;
4
+ };
5
+ /**
6
+ * A manual clock with timers. It has the shape of the room server's `Clock` port
7
+ * (`now` and `setTimer`, room-server.md section 4.1) plus controls for tests.
8
+ */
9
+ export interface FakeClock {
10
+ /** Current time in milliseconds. */
11
+ now(): number;
12
+ /** Calls `fire` once the clock reaches `at`. A time in the past fires on the next advance. */
13
+ setTimer(at: number, fire: () => void): TimerHandle;
14
+ /** Calls `fire` after `delayMs` more milliseconds. */
15
+ setTimeout(fire: () => void, delayMs: number): TimerHandle;
16
+ /**
17
+ * Moves time forward by `ms`, firing due timers in time order (ties in the order they were
18
+ * set). The clock reads each timer's own time while it fires, and timers set by a callback
19
+ * fire in the same call if they fall due. Returns how many fired.
20
+ */
21
+ advance(ms: number): number;
22
+ /** Like `advance`, to an absolute time. Moving backwards throws. */
23
+ advanceTo(time: number): number;
24
+ /** Advances to each pending timer in turn until none is left. Throws after `limit` firings. */
25
+ runAll(limit?: number): number;
26
+ /** How many timers are waiting. */
27
+ pendingTimers(): number;
28
+ }
29
+ /** A fake clock starting at `start` milliseconds (default 0). */
30
+ export declare const createFakeClock: (start?: number) => FakeClock;
package/dist/clock.js ADDED
@@ -0,0 +1,58 @@
1
+ //#region src/clock.ts
2
+ /** A fake clock starting at `start` milliseconds (default 0). */
3
+ var createFakeClock = (start = 0) => {
4
+ let time = start;
5
+ let order = 0;
6
+ let timers = [];
7
+ const setTimer = (at, fire) => {
8
+ if (!Number.isFinite(at)) throw new RangeError(`Timer time must be finite, got ${at}.`);
9
+ const timer = {
10
+ at,
11
+ order: order += 1,
12
+ fire,
13
+ cancelled: false
14
+ };
15
+ timers.push(timer);
16
+ return { cancel: () => {
17
+ timer.cancelled = true;
18
+ } };
19
+ };
20
+ const nextDue = (until) => {
21
+ timers = timers.filter((t) => !t.cancelled);
22
+ let best;
23
+ for (const t of timers) if (t.at <= until && (best === void 0 || t.at < best.at || t.at === best.at && t.order < best.order)) best = t;
24
+ return best;
25
+ };
26
+ const advanceTo = (target) => {
27
+ if (target < time) throw new RangeError(`The fake clock can't go back from ${time} to ${target}.`);
28
+ let fired = 0;
29
+ for (let timer = nextDue(target); timer !== void 0; timer = nextDue(target)) {
30
+ timer.cancelled = true;
31
+ time = Math.max(time, timer.at);
32
+ fired += 1;
33
+ timer.fire();
34
+ }
35
+ time = target;
36
+ return fired;
37
+ };
38
+ return {
39
+ now: () => time,
40
+ setTimer,
41
+ setTimeout: (fire, delayMs) => setTimer(time + delayMs, fire),
42
+ advance: (ms) => advanceTo(time + ms),
43
+ advanceTo,
44
+ runAll: (limit = 1e4) => {
45
+ let fired = 0;
46
+ for (let timer = nextDue(Infinity); timer !== void 0; timer = nextDue(Infinity)) {
47
+ if (fired >= limit) throw new Error(`runAll fired ${limit} timers and timers remain; a timer probably reschedules itself.`);
48
+ fired += advanceTo(Math.max(time, timer.at));
49
+ }
50
+ return fired;
51
+ },
52
+ pendingTimers: () => timers.filter((t) => !t.cancelled).length
53
+ };
54
+ };
55
+ //#endregion
56
+ export { createFakeClock };
57
+
58
+ //# sourceMappingURL=clock.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"clock.js","names":[],"sources":["../src/clock.ts"],"sourcesContent":["// A fake clock: time moves only when a test says so.\n\n/** A scheduled callback; cancelling it twice, or after it fired, is harmless. */\nexport type TimerHandle = { cancel(): void };\n\n/**\n * A manual clock with timers. It has the shape of the room server's `Clock` port\n * (`now` and `setTimer`, room-server.md section 4.1) plus controls for tests.\n */\nexport interface FakeClock {\n /** Current time in milliseconds. */\n now(): number;\n /** Calls `fire` once the clock reaches `at`. A time in the past fires on the next advance. */\n setTimer(at: number, fire: () => void): TimerHandle;\n /** Calls `fire` after `delayMs` more milliseconds. */\n setTimeout(fire: () => void, delayMs: number): TimerHandle;\n /**\n * Moves time forward by `ms`, firing due timers in time order (ties in the order they were\n * set). The clock reads each timer's own time while it fires, and timers set by a callback\n * fire in the same call if they fall due. Returns how many fired.\n */\n advance(ms: number): number;\n /** Like `advance`, to an absolute time. Moving backwards throws. */\n advanceTo(time: number): number;\n /** Advances to each pending timer in turn until none is left. Throws after `limit` firings. */\n runAll(limit?: number): number;\n /** How many timers are waiting. */\n pendingTimers(): number;\n}\n\ntype Timer = { readonly at: number; readonly order: number; readonly fire: () => void; cancelled: boolean };\n\n/** A fake clock starting at `start` milliseconds (default 0). */\nexport const createFakeClock = (start = 0): FakeClock => {\n let time = start;\n let order = 0;\n let timers: Timer[] = [];\n\n const setTimer = (at: number, fire: () => void): TimerHandle => {\n if (!Number.isFinite(at)) throw new RangeError(`Timer time must be finite, got ${at}.`);\n const timer: Timer = { at, order: (order += 1), fire, cancelled: false };\n timers.push(timer);\n return {\n cancel: () => {\n timer.cancelled = true;\n },\n };\n };\n\n const nextDue = (until: number): Timer | undefined => {\n timers = timers.filter((t) => !t.cancelled);\n let best: Timer | undefined;\n for (const t of timers) {\n if (t.at <= until && (best === undefined || t.at < best.at || (t.at === best.at && t.order < best.order)))\n best = t;\n }\n return best;\n };\n\n const advanceTo = (target: number): number => {\n if (target < time) throw new RangeError(`The fake clock can't go back from ${time} to ${target}.`);\n let fired = 0;\n for (let timer = nextDue(target); timer !== undefined; timer = nextDue(target)) {\n timer.cancelled = true;\n time = Math.max(time, timer.at);\n fired += 1;\n timer.fire();\n }\n time = target;\n return fired;\n };\n\n return {\n now: () => time,\n setTimer,\n setTimeout: (fire, delayMs) => setTimer(time + delayMs, fire),\n advance: (ms) => advanceTo(time + ms),\n advanceTo,\n runAll: (limit = 10_000) => {\n let fired = 0;\n for (let timer = nextDue(Infinity); timer !== undefined; timer = nextDue(Infinity)) {\n if (fired >= limit)\n throw new Error(`runAll fired ${limit} timers and timers remain; a timer probably reschedules itself.`);\n fired += advanceTo(Math.max(time, timer.at));\n }\n return fired;\n },\n pendingTimers: () => timers.filter((t) => !t.cancelled).length,\n };\n};\n"],"mappings":";;AAiCA,IAAa,mBAAmB,QAAQ,MAAiB;CACvD,IAAI,OAAO;CACX,IAAI,QAAQ;CACZ,IAAI,SAAkB,CAAC;CAEvB,MAAM,YAAY,IAAY,SAAkC;EAC9D,IAAI,CAAC,OAAO,SAAS,EAAE,GAAG,MAAM,IAAI,WAAW,kCAAkC,GAAG,EAAE;EACtF,MAAM,QAAe;GAAE;GAAI,OAAQ,SAAS;GAAI;GAAM,WAAW;EAAM;EACvE,OAAO,KAAK,KAAK;EACjB,OAAO,EACL,cAAc;GACZ,MAAM,YAAY;EACpB,EACF;CACF;CAEA,MAAM,WAAW,UAAqC;EACpD,SAAS,OAAO,QAAQ,MAAM,CAAC,EAAE,SAAS;EAC1C,IAAI;EACJ,KAAK,MAAM,KAAK,QACd,IAAI,EAAE,MAAM,UAAU,SAAS,KAAA,KAAa,EAAE,KAAK,KAAK,MAAO,EAAE,OAAO,KAAK,MAAM,EAAE,QAAQ,KAAK,QAChG,OAAO;EAEX,OAAO;CACT;CAEA,MAAM,aAAa,WAA2B;EAC5C,IAAI,SAAS,MAAM,MAAM,IAAI,WAAW,qCAAqC,KAAK,MAAM,OAAO,EAAE;EACjG,IAAI,QAAQ;EACZ,KAAK,IAAI,QAAQ,QAAQ,MAAM,GAAG,UAAU,KAAA,GAAW,QAAQ,QAAQ,MAAM,GAAG;GAC9E,MAAM,YAAY;GAClB,OAAO,KAAK,IAAI,MAAM,MAAM,EAAE;GAC9B,SAAS;GACT,MAAM,KAAK;EACb;EACA,OAAO;EACP,OAAO;CACT;CAEA,OAAO;EACL,WAAW;EACX;EACA,aAAa,MAAM,YAAY,SAAS,OAAO,SAAS,IAAI;EAC5D,UAAU,OAAO,UAAU,OAAO,EAAE;EACpC;EACA,SAAS,QAAQ,QAAW;GAC1B,IAAI,QAAQ;GACZ,KAAK,IAAI,QAAQ,QAAQ,QAAQ,GAAG,UAAU,KAAA,GAAW,QAAQ,QAAQ,QAAQ,GAAG;IAClF,IAAI,SAAS,OACX,MAAM,IAAI,MAAM,gBAAgB,MAAM,gEAAgE;IACxG,SAAS,UAAU,KAAK,IAAI,MAAM,MAAM,EAAE,CAAC;GAC7C;GACA,OAAO;EACT;EACA,qBAAqB,OAAO,QAAQ,MAAM,CAAC,EAAE,SAAS,CAAC,CAAC;CAC1D;AACF"}
package/dist/deep.d.ts ADDED
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The first place where `a` and `b` differ, as a readable path with both values, or null when
3
+ * they are structurally equal. Numbers compare with `Object.is`, so `0` and `-0` differ, and
4
+ * a missing key differs from a key holding `undefined`.
5
+ */
6
+ export declare const firstDifference: (a: unknown, b: unknown, path?: string) => string | null;
7
+ /** True when `a` and `b` are structurally equal (see `firstDifference`). */
8
+ export declare const deepEqual: (a: unknown, b: unknown) => boolean;
9
+ /**
10
+ * Freezes `value` and everything reachable from it, then returns it. The suites freeze every
11
+ * state, command and input they hand to an engine, so an engine that mutates its input
12
+ * throws instead of silently corrupting the game.
13
+ */
14
+ export declare const deepFreeze: <T>(value: T) => T;
15
+ /**
16
+ * Why `value` isn't JSON, with the path to the first offending part, or null when it is.
17
+ * The contract's `isJson` answers yes or no; failure messages need the where and why.
18
+ */
19
+ export declare const jsonProblem: (value: unknown, path?: string, seen?: Set<object>) => string | null;
20
+ /** A JSON round trip: what a value looks like after a socket, a save or a replay file. */
21
+ export declare const jsonRoundTrip: <T>(value: T) => T;
22
+ /** A short, single-line rendering of any value for failure messages. */
23
+ export declare const show: (value: unknown) => string;
24
+ /** The message of anything thrown. */
25
+ export declare const errorMessage: (error: unknown) => string;