@eigeninteractive/testkit 0.6.0 → 0.7.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/README.md CHANGED
@@ -14,6 +14,32 @@ import gameModule from "../../src/module/index.js";
14
14
  twinFixtureTests(gameModule, new URL("../../src/module/fixtures/", import.meta.url));
15
15
  ```
16
16
 
17
+ A fixture case declares its `kind`:
18
+
19
+ | kind | what it pins |
20
+ |---|---|
21
+ | `action` | `applyAction`, and the actor's post-action observation |
22
+ | `initialState` | the opening envelope, drawn from the version-0 stream |
23
+ | `lifecycle` | `applyLifecycle` for a timeout, a resign, or an account purge |
24
+ | `transcript` | a whole match, replayed through the real kernel |
25
+ | `rng` | recorded `deriveRng` draws, compared exactly |
26
+ | `playerLimits`, `ratingPool`, `botSeatable` | the predicates |
27
+
28
+ The format itself is documented at the top of `src/twin-fixtures.ts`, which is
29
+ the normative description both runners follow.
30
+
31
+ `rng` cases are generated rather than written, because their values are the
32
+ kernel's own and the Dart port has to reproduce them bit for bit:
33
+
34
+ ```ts
35
+ import { rngFixtureCase, writeRngFixture } from "@eigeninteractive/testkit";
36
+
37
+ writeRngFixture("src/module/fixtures/v1/rng.json", [
38
+ rngFixtureCase({ name: "version 0", seed: "twin-fixtures", version: 0, count: 16 }),
39
+ rngFixtureCase({ name: "seat 1's bot stream", seed: "twin-fixtures", version: 3, seat: 1, count: 16 }),
40
+ ]);
41
+ ```
42
+
17
43
  `eigen-contract` imports the default `src/module/index.ts`, validates every
18
44
  `fixtures/v<N>/*.json` against the registered version, and emits
19
45
  `game-contract.json`. Use `eigen-contract --check` in CI.
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { n as emitConfiguredGameContract, t as checkConfiguredGameContract } from "./contract-command-aCqO5CyJ.js";
2
+ import { n as emitConfiguredGameContract, t as checkConfiguredGameContract } from "./contract-command-Dn5ANpqD.js";
3
3
  import { parseArgs } from "node:util";
4
4
  //#region src/contract-cli.ts
5
5
  try {
@@ -3,6 +3,7 @@ import { basename, relative, resolve } from "node:path";
3
3
  import { pathToFileURL } from "node:url";
4
4
  import { tsImport } from "tsx/esm/api";
5
5
  import { IllegalMoveError, assertPortableSchema } from "@eigeninteractive/rules";
6
+ import { GameBugError, assertForfeitPending, commit, deriveRng, isRejected } from "@eigeninteractive/kernel";
6
7
  import Rand from "rand-seed";
7
8
  import { it } from "vitest";
8
9
  //#region src/twin-fixtures.ts
@@ -14,9 +15,9 @@ import { it } from "vitest";
14
15
  *
15
16
  * One fixture file per concern lives beside the version units at
16
17
  * `<fixturesRoot>/v<N>/*.json` and is consumed by BOTH sides: this module
17
- * runs each case against the TS unit (schemas + `applyAction` +
18
- * `computeObservation` + the two predicates), while the Dart runner runs the
19
- * same file against the Dart twin (generated payload parsing,
18
+ * runs each case against the TS unit (schemas + the four hooks + the two
19
+ * predicates, and whole matches through the real kernel), while the Dart
20
+ * runner runs the same file against the Dart twin (generated payload parsing,
20
21
  * `isValidAction`, `previewAction`, predicate twins). A behavioral divergence
21
22
  * then fails one side's CI instead of degrading UX in production.
22
23
  *
@@ -55,7 +56,58 @@ import { it } from "vitest";
55
56
  * { "kind": "ratingPool", "name": "...", "access": "public",
56
57
  * "minPlayers": 2, "maxPlayers": 2, "config": {}, "expected": "blitz" },
57
58
  * { "kind": "botSeatable", "name": "...", "gameConfig": {},
58
- * "botConfig": {}, "expected": false }
59
+ * "botConfig": {}, "expected": false },
60
+ * {
61
+ * "kind": "initialState",
62
+ * "name": "a fresh board seats both players",
63
+ * "config": { ... },
64
+ * "playerCount": 2,
65
+ * "rngSeed": "any string", // optional, default "twin-fixtures"
66
+ * "expected": { // the envelope block the action case
67
+ * "state": { ... }, // records, minus `valid`
68
+ * "pending": [0, 1],
69
+ * "outcome": null
70
+ * }
71
+ * },
72
+ * {
73
+ * "kind": "lifecycle",
74
+ * "name": "a forfeit hands the match to the other seat",
75
+ * "config": { ... },
76
+ * "state": { ... }, // applyLifecycle input
77
+ * "pending": [0, 1],
78
+ * "type": "forfeit", // timeout | forfeit | autoForfeit
79
+ * "playerIndex": 0, // required for forfeit/autoForfeit,
80
+ * // forbidden for timeout
81
+ * "participantCount": 2, // optional, default 2
82
+ * "rngSeed": "any string", // optional, default "twin-fixtures"
83
+ * "expected": { "state": { ... }, "pending": [], "outcome": [ ... ] }
84
+ * },
85
+ * {
86
+ * "kind": "transcript",
87
+ * "name": "a best-of-one match played to its finish",
88
+ * "config": { ... },
89
+ * "playerCount": 2,
90
+ * "seed": "the game's base rng seed",
91
+ * "transitions": [
92
+ * { "kind": "game", "playerIndex": 0, "data": { ... } },
93
+ * { "kind": "lifecycle", "type": "forfeit", "playerIndex": 1 }
94
+ * ],
95
+ * "expected": {
96
+ * "version": 3, // the final committed state version
97
+ * "status": "finished", // active | finished
98
+ * "state": { ... }, // optional, as above
99
+ * "pending": [],
100
+ * "outcome": [ ... ]
101
+ * }
102
+ * },
103
+ * {
104
+ * "kind": "rng",
105
+ * "name": "the stream initialState draws from",
106
+ * "seed": "twin-fixtures",
107
+ * "version": 0,
108
+ * "seat": 1, // optional; present ⇒ the bot stream
109
+ * "draws": [0.123, 0.456] // 1..64 values, compared exactly
110
+ * }
59
111
  * ]
60
112
  * }
61
113
  * ```
@@ -66,6 +118,34 @@ import { it } from "vitest";
66
118
  * state to it, and a Dart `previewAction` that returns non-null must predict
67
119
  * it, so the two sides are compared through one recorded value.
68
120
  *
121
+ * ## What each kind records
122
+ *
123
+ * `action`, `playerLimits`, `ratingPool` and `botSeatable` pin one hook or
124
+ * predicate. The four kinds below exist for offline play (architecture
125
+ * decision 0012): a device runs the Dart twin through a Dart port of the
126
+ * kernel, so the pieces the online client never had to reproduce — the
127
+ * opening state, the lifecycle hooks, a whole match, and the random stream
128
+ * itself — each need a recorded behavior of their own.
129
+ *
130
+ * - `initialState` runs `initialState` with `deriveRng(rngSeed, 0)`, the
131
+ * version-0 stream the engine's `start` commit derives, and checks the
132
+ * returned envelope exactly as the action case does.
133
+ * - `lifecycle` runs `applyLifecycle` with the `data` payload the kernel
134
+ * builds (`{type:"timeout"}`, or `{type, playerIndex}`), then applies the
135
+ * kernel's forfeit guard: a forfeit must remove its own seat from pending.
136
+ * Like the action case, and unlike `initialState`, it draws from the raw
137
+ * `rngSeed` stream: a standalone hook case has no version to derive from.
138
+ * - `transcript` replays an ordered match through the REAL kernel `commit()`,
139
+ * so every engine guard, the pending/version bookkeeping, and the
140
+ * per-transition `deriveRng(seed, version)` streams all participate. It is
141
+ * the case a Dart local kernel has to reproduce move for move, on the
142
+ * untimed table and with the human/bot roster {@link evaluateTranscript}
143
+ * documents.
144
+ * - `rng` records raw `deriveRng` draws. Generated from the TypeScript kernel
145
+ * by {@link rngFixtureCase} / {@link writeRngFixture} and compared with
146
+ * `===`, no tolerance: the Dart port is required to be bit-identical, and a
147
+ * port that is merely close is a port that desynchronizes one draw later.
148
+ *
69
149
  * Wire it up in a game-owned test file running under plain-Node vitest:
70
150
  *
71
151
  * ```ts
@@ -75,6 +155,20 @@ import { it } from "vitest";
75
155
  * twinFixtureTests(gameModule, new URL("../src/module/fixtures/", import.meta.url));
76
156
  * ```
77
157
  */
158
+ /** The stream a case draws from when it names no `rngSeed`. */
159
+ const DEFAULT_RNG_SEED = "twin-fixtures";
160
+ /** Every `kind` a fixture case may declare, in the order the error messages
161
+ * and the evaluator switch list them. */
162
+ const CASE_KINDS = [
163
+ "action",
164
+ "playerLimits",
165
+ "ratingPool",
166
+ "botSeatable",
167
+ "initialState",
168
+ "lifecycle",
169
+ "transcript",
170
+ "rng"
171
+ ];
78
172
  function describe(value) {
79
173
  if (value === null) return "null";
80
174
  if (Array.isArray(value)) return "an array";
@@ -103,24 +197,38 @@ function asNumberArray(where, v) {
103
197
  if (!Array.isArray(v)) fail(where, "an array of numbers", v);
104
198
  return v.map((n, i) => asNumber(`${where}[${i}]`, n));
105
199
  }
200
+ /** A seat index, a seat count, or a state version: the fixture format has no
201
+ * fractional or negative counts anywhere. */
202
+ function asIndex(where, v) {
203
+ const n = asNumber(where, v);
204
+ if (!Number.isInteger(n) || n < 0) throw new Error(`${where}: expected a non-negative integer, got ${JSON.stringify(v)}`);
205
+ return n;
206
+ }
106
207
  /** Applies `read` only when the key is present and non-null; absent and
107
208
  * explicit null both mean "not specified" for every optional fixture field. */
108
209
  function optional(where, v, read) {
109
210
  return v === void 0 || v === null ? void 0 : read(where, v);
110
211
  }
212
+ /** The `expected` block every hook-running case shares. */
213
+ function parseExpectedEnvelope(where, raw) {
214
+ const expected = {
215
+ state: optional(`${where}.state`, raw.state, asObject),
216
+ pending: optional(`${where}.pending`, raw.pending, asNumberArray)
217
+ };
218
+ if ("outcome" in raw) {
219
+ const outcome = raw.outcome;
220
+ if (outcome !== null && !Array.isArray(outcome)) fail(`${where}.outcome`, "an array or null", outcome);
221
+ expected.outcome = outcome;
222
+ }
223
+ return expected;
224
+ }
111
225
  function parseActionCase(where, raw) {
112
226
  const expectedRaw = asObject(`${where}.expected`, raw.expected);
113
227
  const expected = {
228
+ ...parseExpectedEnvelope(`${where}.expected`, expectedRaw),
114
229
  valid: asBoolean(`${where}.expected.valid`, expectedRaw.valid),
115
- state: optional(`${where}.expected.state`, expectedRaw.state, asObject),
116
- pending: optional(`${where}.expected.pending`, expectedRaw.pending, asNumberArray),
117
230
  observation: optional(`${where}.expected.observation`, expectedRaw.observation, asObject)
118
231
  };
119
- if ("outcome" in expectedRaw) {
120
- const outcome = expectedRaw.outcome;
121
- if (outcome !== null && !Array.isArray(outcome)) fail(`${where}.expected.outcome`, "an array or null", outcome);
122
- expected.outcome = outcome;
123
- }
124
232
  return {
125
233
  kind: "action",
126
234
  name: asString(`${where}.name`, raw.name),
@@ -172,6 +280,90 @@ function parseBotSeatableCase(where, raw) {
172
280
  expected: asBoolean(`${where}.expected`, raw.expected)
173
281
  };
174
282
  }
283
+ function parseInitialStateCase(where, raw) {
284
+ return {
285
+ kind: "initialState",
286
+ name: asString(`${where}.name`, raw.name),
287
+ config: asObject(`${where}.config`, raw.config),
288
+ playerCount: asIndex(`${where}.playerCount`, raw.playerCount),
289
+ rngSeed: optional(`${where}.rngSeed`, raw.rngSeed, asString),
290
+ expected: parseExpectedEnvelope(`${where}.expected`, asObject(`${where}.expected`, raw.expected))
291
+ };
292
+ }
293
+ function parseLifecycleCase(where, raw) {
294
+ const type = asString(`${where}.type`, raw.type);
295
+ if (type !== "timeout" && type !== "forfeit" && type !== "autoForfeit") throw new Error(`${where}.type: expected one of timeout | forfeit | autoForfeit, got ${JSON.stringify(raw.type)}`);
296
+ const playerIndex = optional(`${where}.playerIndex`, raw.playerIndex, asIndex);
297
+ if (type === "timeout" && playerIndex !== void 0) throw new Error(`${where}.playerIndex: a timeout carries no seat (its victims are "pending"); remove it`);
298
+ if (type !== "timeout" && playerIndex === void 0) throw new Error(`${where}.playerIndex: a ${type} must name the forfeiting seat`);
299
+ return {
300
+ kind: "lifecycle",
301
+ name: asString(`${where}.name`, raw.name),
302
+ config: asObject(`${where}.config`, raw.config),
303
+ state: asObject(`${where}.state`, raw.state),
304
+ pending: asNumberArray(`${where}.pending`, raw.pending),
305
+ type,
306
+ playerIndex,
307
+ participantCount: optional(`${where}.participantCount`, raw.participantCount, asNumber),
308
+ rngSeed: optional(`${where}.rngSeed`, raw.rngSeed, asString),
309
+ expected: parseExpectedEnvelope(`${where}.expected`, asObject(`${where}.expected`, raw.expected))
310
+ };
311
+ }
312
+ function parseTranscriptTransition(where, raw) {
313
+ const obj = asObject(where, raw);
314
+ switch (obj.kind) {
315
+ case "game": return {
316
+ kind: "game",
317
+ playerIndex: asIndex(`${where}.playerIndex`, obj.playerIndex),
318
+ data: asObject(`${where}.data`, obj.data)
319
+ };
320
+ case "lifecycle": {
321
+ const type = asString(`${where}.type`, obj.type);
322
+ if (type !== "forfeit") throw new Error(`${where}.type: expected forfeit, got ${JSON.stringify(obj.type)}`);
323
+ return {
324
+ kind: "lifecycle",
325
+ type,
326
+ playerIndex: asIndex(`${where}.playerIndex`, obj.playerIndex)
327
+ };
328
+ }
329
+ default: throw new Error(`${where}.kind: expected one of game | lifecycle, got ${JSON.stringify(obj.kind)}`);
330
+ }
331
+ }
332
+ function parseTranscriptCase(where, raw) {
333
+ const expectedRaw = asObject(`${where}.expected`, raw.expected);
334
+ const status = asString(`${where}.expected.status`, expectedRaw.status);
335
+ if (status !== "active" && status !== "finished") throw new Error(`${where}.expected.status: expected one of active | finished, got ${JSON.stringify(expectedRaw.status)}`);
336
+ if (!Array.isArray(raw.transitions)) fail(`${where}.transitions`, "an array", raw.transitions);
337
+ return {
338
+ kind: "transcript",
339
+ name: asString(`${where}.name`, raw.name),
340
+ config: asObject(`${where}.config`, raw.config),
341
+ playerCount: asIndex(`${where}.playerCount`, raw.playerCount),
342
+ seed: asString(`${where}.seed`, raw.seed),
343
+ transitions: raw.transitions.map((transition, i) => parseTranscriptTransition(`${where}.transitions[${i}]`, transition)),
344
+ expected: {
345
+ ...parseExpectedEnvelope(`${where}.expected`, expectedRaw),
346
+ version: asIndex(`${where}.expected.version`, expectedRaw.version),
347
+ status
348
+ }
349
+ };
350
+ }
351
+ /** The draw count one rng case may record. One is enough to catch a wrong
352
+ * seeding; the cap keeps a fixture file a document rather than a data dump,
353
+ * and a stream that agrees for 64 draws does not diverge at 65. */
354
+ const RNG_DRAW_LIMIT = 64;
355
+ function parseRngCase(where, raw) {
356
+ if (!Array.isArray(raw.draws)) fail(`${where}.draws`, "an array of numbers", raw.draws);
357
+ if (raw.draws.length === 0 || raw.draws.length > RNG_DRAW_LIMIT) throw new Error(`${where}.draws: expected between 1 and ${RNG_DRAW_LIMIT} draws, got ${raw.draws.length}`);
358
+ return {
359
+ kind: "rng",
360
+ name: asString(`${where}.name`, raw.name),
361
+ seed: asString(`${where}.seed`, raw.seed),
362
+ version: asIndex(`${where}.version`, raw.version),
363
+ seat: optional(`${where}.seat`, raw.seat, asIndex),
364
+ draws: asNumberArray(`${where}.draws`, raw.draws)
365
+ };
366
+ }
175
367
  /** Validate one fixture file's parsed JSON, or throw naming the offending
176
368
  * file, case, and field. Exported so a repo can lint its fixtures without
177
369
  * running them. */
@@ -190,21 +382,34 @@ function parseTwinFixtureFile(path, json) {
190
382
  case "playerLimits": return parsePlayerLimitsCase(where, obj);
191
383
  case "ratingPool": return parseRatingPoolCase(where, obj);
192
384
  case "botSeatable": return parseBotSeatableCase(where, obj);
193
- default: throw new Error(`${where}.kind: expected one of action | playerLimits | ratingPool | botSeatable, got ${JSON.stringify(obj.kind)}`);
385
+ case "initialState": return parseInitialStateCase(where, obj);
386
+ case "lifecycle": return parseLifecycleCase(where, obj);
387
+ case "transcript": return parseTranscriptCase(where, obj);
388
+ case "rng": return parseRngCase(where, obj);
389
+ default: throw new Error(`${where}.kind: expected one of ${CASE_KINDS.join(" | ")}, got ${JSON.stringify(obj.kind)}`);
194
390
  }
195
391
  })
196
392
  };
197
393
  }
198
394
  /** Run one fixture case against a rules unit, returning failure descriptions
199
395
  * (empty ⇒ the case passes). Pure; the file-reading test registrar is
200
- * {@link twinFixtureTests}. */
201
- function evaluateTwinCase(rules, kase) {
396
+ * {@link twinFixtureTests}.
397
+ *
398
+ * `schemaVersion` is the version the case targets. It never selects
399
+ * behavior — the caller already resolved `rules` from it — and only labels
400
+ * the engine guard messages a `lifecycle` or `transcript` case can provoke;
401
+ * {@link twinFixtureTests} passes the fixture file's. */
402
+ function evaluateTwinCase(rules, kase, schemaVersion = 1) {
202
403
  switch (kase.kind) {
203
404
  case "action": return evaluateAction(rules, kase);
204
405
  case "playerLimits": return evaluatePlayerLimits(rules, kase);
205
406
  case "ratingPool": return evaluateRatingPool(rules, kase);
206
407
  case "botSeatable": return evaluateBotSeatable(rules, kase);
207
- default: return [`unknown case kind "${kase.kind}", expected action | playerLimits | ratingPool | botSeatable`];
408
+ case "initialState": return evaluateInitialState(rules, kase);
409
+ case "lifecycle": return evaluateLifecycle(rules, kase, schemaVersion);
410
+ case "transcript": return evaluateTranscript(rules, kase, schemaVersion);
411
+ case "rng": return evaluateRng(kase);
412
+ default: return [`unknown case kind "${kase.kind}", expected ${CASE_KINDS.join(" | ")}`];
208
413
  }
209
414
  }
210
415
  /** Register one vitest test per fixture case found under `fixturesRoot`
@@ -216,7 +421,7 @@ function twinFixtureTests(gameModule, fixturesRoot) {
216
421
  const rules = gameModule.versions[fixture.schemaVersion];
217
422
  for (const kase of fixture.cases) it(`twin v${fixture.schemaVersion}: ${kase.name}`, () => {
218
423
  if (!rules) throw new Error(`gameModule ships no rules unit for schemaVersion ${fixture.schemaVersion} (fixture: ${filePath})`);
219
- const failures = evaluateTwinCase(rules, kase);
424
+ const failures = evaluateTwinCase(rules, kase, fixture.schemaVersion);
220
425
  if (failures.length) throw new Error(`\n${failures.join("\n")}`);
221
426
  });
222
427
  }
@@ -240,7 +445,7 @@ function evaluateAction(rules, kase) {
240
445
  if (envelope) failures.push(envelope);
241
446
  return failures;
242
447
  }
243
- checkEnvelope(rules, kase, envelope, failures);
448
+ checkEnvelope(rules, "applyAction", kase.expected, envelope, failures);
244
449
  if (kase.expected.observation !== void 0) checkObservation(rules, kase, envelope, config, action, failures);
245
450
  return failures;
246
451
  }
@@ -255,7 +460,7 @@ function applyFixtureAction(rules, kase, config, state, action) {
255
460
  pending: kase.pending,
256
461
  data: action,
257
462
  playerIndex: kase.playerIndex,
258
- rng: new Rand(kase.rngSeed ?? "twin-fixtures"),
463
+ rng: hookRng(kase.rngSeed),
259
464
  config
260
465
  });
261
466
  } catch (error) {
@@ -264,9 +469,19 @@ function applyFixtureAction(rules, kase, config, state, action) {
264
469
  }
265
470
  return kase.expected.valid ? envelope : "applyAction accepted a move the fixture expects to be illegal";
266
471
  }
267
- function checkEnvelope(rules, kase, envelope, failures) {
268
- if (validate(rules, "state", envelope.state) === void 0) failures.push("applyAction returned state that violates its own schema");
269
- const expected = kase.expected;
472
+ /** The stream a standalone hook case draws from. Seeded with the fixture's
473
+ * `rngSeed` verbatim rather than through `deriveRng`, because a single hook
474
+ * case stands outside any game: it has no committed version to derive a
475
+ * stream from. The `initialState` case is the exception — a game's opening
476
+ * transition is always version 0 — and a `transcript` case, which runs the
477
+ * real kernel, gets the real per-version streams. */
478
+ function hookRng(rngSeed) {
479
+ return new Rand(rngSeed ?? DEFAULT_RNG_SEED);
480
+ }
481
+ /** Compare a hook's envelope with what a case recorded, and re-validate the
482
+ * state it returned exactly as the engine does before committing it. */
483
+ function checkEnvelope(rules, hook, expected, envelope, failures) {
484
+ if (validate(rules, "state", envelope.state) === void 0) failures.push(`${hook} returned state that violates its own schema`);
270
485
  if (expected.state !== void 0 && !deepEquals(envelope.state, expected.state)) failures.push(`envelope.state mismatch, got ${JSON.stringify(envelope.state)}`);
271
486
  if (expected.pending !== void 0 && !deepEquals(envelope.pendingPlayers, expected.pending)) failures.push(`envelope.pendingPlayers mismatch, got ${JSON.stringify(envelope.pendingPlayers)}`);
272
487
  if ("outcome" in expected && !deepEquals(envelope.outcome ?? null, expected.outcome ?? null)) failures.push(`envelope.outcome mismatch, got ${JSON.stringify(envelope.outcome ?? null)}`);
@@ -328,6 +543,227 @@ function evaluateBotSeatable(rules, kase) {
328
543
  if (seatable !== kase.expected) failures.push(`botSeatable returned ${seatable}, fixture expects ${kase.expected}`);
329
544
  return failures;
330
545
  }
546
+ function evaluateInitialState(rules, kase) {
547
+ const failures = [];
548
+ const config = parseWith(rules, "config", kase.config, failures);
549
+ if (config === void 0) return failures;
550
+ let envelope;
551
+ try {
552
+ envelope = rules.initialState({
553
+ config,
554
+ rng: deriveRng(kase.rngSeed ?? DEFAULT_RNG_SEED, 0),
555
+ playerCount: kase.playerCount
556
+ });
557
+ } catch (error) {
558
+ failures.push(`initialState threw: ${error}`);
559
+ return failures;
560
+ }
561
+ checkEnvelope(rules, "initialState", kase.expected, envelope, failures);
562
+ return failures;
563
+ }
564
+ function evaluateLifecycle(rules, kase, schemaVersion) {
565
+ const failures = [];
566
+ const config = parseWith(rules, "config", kase.config, failures);
567
+ const state = parseWith(rules, "state", kase.state, failures);
568
+ if (config === void 0 || state === void 0) return failures;
569
+ const data = kase.type === "timeout" ? { type: "timeout" } : {
570
+ type: kase.type,
571
+ playerIndex: kase.playerIndex
572
+ };
573
+ let envelope;
574
+ try {
575
+ envelope = rules.applyLifecycle({
576
+ state,
577
+ pending: kase.pending,
578
+ type: kase.type,
579
+ data,
580
+ rng: hookRng(kase.rngSeed),
581
+ config
582
+ });
583
+ } catch (error) {
584
+ failures.push(`applyLifecycle threw: ${error}`);
585
+ return failures;
586
+ }
587
+ checkEnvelope(rules, "applyLifecycle", kase.expected, envelope, failures);
588
+ if (data.type !== "timeout") try {
589
+ assertForfeitPending(data.playerIndex, envelope, schemaVersion);
590
+ } catch (error) {
591
+ failures.push(`${error instanceof Error ? error.message : error}`);
592
+ }
593
+ return failures;
594
+ }
595
+ /**
596
+ * Replay an ordered match through the real kernel.
597
+ *
598
+ * The table is the one a local (offline) game plays on, and the conventions
599
+ * below are a contract with the Dart local kernel that replays the same file:
600
+ *
601
+ * - untimed and unrated (`turnSeconds`, `budgetSeconds`, `incrementSeconds`
602
+ * all null), so no deadline is ever armed and the clock is never read;
603
+ * - a roster of `playerCount` identified seats: seat 0 is the human
604
+ * `user-0`, every other seat is the bot `bot-<seat>`;
605
+ * - the match opens with a `start` intent carrying the case's `seed`, so
606
+ * every transition draws from `deriveRng(seed, version)`;
607
+ * - each transition commits at `expectedVersion` = the current version (a
608
+ * device is the only actor, so it is never stale), with `actor` `"user"`
609
+ * for seat 0 and `"bot"` for the rest.
610
+ *
611
+ * A rejection or a broken invariant fails the case naming the transition that
612
+ * produced it: an engine that refuses the transcript is as much a divergence
613
+ * as a wrong final state.
614
+ */
615
+ function evaluateTranscript(rules, kase, schemaVersion) {
616
+ const failures = [];
617
+ if (parseWith(rules, "config", kase.config, failures) === void 0) return failures;
618
+ const game = {
619
+ status: "ready",
620
+ schemaVersion,
621
+ config: kase.config,
622
+ turnSeconds: null,
623
+ budgetSeconds: null,
624
+ incrementSeconds: null,
625
+ rated: false,
626
+ ratingPool: null
627
+ };
628
+ const roster = Array.from({ length: kase.playerCount }, (_, seat) => seat === 0 ? {
629
+ playerIndex: 0,
630
+ userId: "user-0",
631
+ botId: null,
632
+ type: "human"
633
+ } : {
634
+ playerIndex: seat,
635
+ userId: null,
636
+ botId: `bot-${seat}`,
637
+ type: "bot"
638
+ });
639
+ let plan = runCommit({
640
+ game,
641
+ state: null,
642
+ roster,
643
+ intent: {
644
+ kind: "start",
645
+ seed: kase.seed
646
+ },
647
+ now: TRANSCRIPT_NOW,
648
+ rules
649
+ });
650
+ if (typeof plan === "string") return [`start: ${plan}`];
651
+ game.status = "active";
652
+ for (const [index, transition] of kase.transitions.entries()) {
653
+ const intent = transition.kind === "game" ? {
654
+ kind: "action",
655
+ seat: transition.playerIndex,
656
+ expectedVersion: plan.nextState.version,
657
+ data: transition.data,
658
+ actor: transition.playerIndex === 0 ? "user" : "bot"
659
+ } : {
660
+ kind: "lifecycle",
661
+ type: "forfeit",
662
+ seat: transition.playerIndex
663
+ };
664
+ const result = runCommit({
665
+ game,
666
+ state: plan.nextState,
667
+ roster,
668
+ intent,
669
+ now: TRANSCRIPT_NOW,
670
+ rules
671
+ });
672
+ if (typeof result === "string") return [`transitions[${index}] (${describeTransition(transition)}): ${result}`];
673
+ plan = result;
674
+ if (plan.outcomes !== null) game.status = "finished";
675
+ }
676
+ const status = plan.outcomes === null ? "active" : "finished";
677
+ if (plan.nextState.version !== kase.expected.version) failures.push(`the match committed as version ${plan.nextState.version}, fixture expects ${kase.expected.version}`);
678
+ if (status !== kase.expected.status) failures.push(`the match is ${status}, fixture expects ${kase.expected.status}`);
679
+ checkEnvelope(rules, "the final transition", kase.expected, {
680
+ state: plan.nextState.state,
681
+ pendingPlayers: plan.nextState.pending,
682
+ ...plan.outcomes === null ? {} : { outcome: plan.outcomes }
683
+ }, failures);
684
+ return failures;
685
+ }
686
+ /** The commit instant every transcript transition carries. An untimed game
687
+ * never reads it, and a fixed value keeps the case a pure function of the
688
+ * transcript. */
689
+ const TRANSCRIPT_NOW = 0;
690
+ /** One kernel commit, with both failure species rendered as a string: a
691
+ * rejection (a value) and a broken invariant (a throw) are equally a
692
+ * transcript that does not replay. */
693
+ function runCommit(input) {
694
+ let result;
695
+ try {
696
+ result = commit(input);
697
+ } catch (error) {
698
+ if (error instanceof GameBugError) return `the engine refused the hook result: ${error.message}`;
699
+ return `commit threw: ${error}`;
700
+ }
701
+ if (isRejected(result)) return `the engine rejected the intent (${result.code}): ${result.message}`;
702
+ return result;
703
+ }
704
+ function describeTransition(transition) {
705
+ return transition.kind === "game" ? `seat ${transition.playerIndex} plays ${JSON.stringify(transition.data)}` : `seat ${transition.playerIndex} forfeits`;
706
+ }
707
+ function evaluateRng(kase) {
708
+ const rng = rngStream(kase.seed, kase.version, kase.seat);
709
+ const failures = [];
710
+ for (const [index, expected] of kase.draws.entries()) {
711
+ const draw = rng.next();
712
+ if (draw !== expected) failures.push(`draw[${index}] is ${draw}, fixture records ${expected}`);
713
+ }
714
+ return failures;
715
+ }
716
+ /** The stream an {@link RngCase} describes: a transition's own stream, or,
717
+ * when the case names a seat, the bot stream the Durable Object derives for
718
+ * that seat before running its brain. */
719
+ function rngStream(seed, version, seat) {
720
+ return seat === void 0 ? deriveRng(seed, version) : deriveRng(`${seed}:bot${seat}`, version);
721
+ }
722
+ /** Record one derived stream from the real kernel as a fixture case. */
723
+ function rngFixtureCase(options) {
724
+ const { name, seed, version, seat, count } = options;
725
+ if (!Number.isInteger(count) || count < 1 || count > RNG_DRAW_LIMIT) throw new Error(`rngFixtureCase(${JSON.stringify(name)}): count must be an integer between 1 and ${RNG_DRAW_LIMIT}, got ${count}`);
726
+ const rng = rngStream(seed, version, seat);
727
+ return {
728
+ kind: "rng",
729
+ name,
730
+ seed,
731
+ version,
732
+ ...seat === void 0 ? {} : { seat },
733
+ draws: Array.from({ length: count }, () => rng.next())
734
+ };
735
+ }
736
+ /**
737
+ * Write a fixture file of generated `rng` cases.
738
+ *
739
+ * The `schemaVersion` is read from the `v<N>/` directory in `path`, the same
740
+ * rule `eigen-contract` enforces over every fixture file, so the two can
741
+ * never be written out of agreement. The document is validated before it is
742
+ * written: a generator that emits something the runners would refuse to load
743
+ * should fail at the generator.
744
+ *
745
+ * The output is plain two-space JSON. Only the values mean anything to either
746
+ * runner, so a repo that formats JSON should run its formatter afterwards.
747
+ *
748
+ * ```ts
749
+ * import { rngFixtureCase, writeRngFixture } from "@eigeninteractive/testkit";
750
+ *
751
+ * writeRngFixture("src/module/fixtures/v1/rng.json", [
752
+ * rngFixtureCase({ name: "version 0", seed: "twin-fixtures", version: 0, count: 16 }),
753
+ * rngFixtureCase({ name: "seat 1's bot stream", seed: "twin-fixtures", version: 3, seat: 1, count: 16 }),
754
+ * ]);
755
+ * ```
756
+ */
757
+ function writeRngFixture(path, cases) {
758
+ const match = /(?:^|\/)v([1-9]\d*)\/[^/]+$/.exec(path.split("\\").join("/"));
759
+ if (match === null) throw new Error(`${path}: an rng fixture must be written into a v<N>/ directory, which names the schemaVersion it targets`);
760
+ const document = {
761
+ schemaVersion: Number(match[1]),
762
+ cases
763
+ };
764
+ parseTwinFixtureFile(path, document);
765
+ writeFileSync(path, `${JSON.stringify(document, null, 2)}\n`);
766
+ }
331
767
  /** Run one of the unit's Standard Schemas synchronously, returning the parsed
332
768
  * value or undefined on failure (issues written to the second return slot). */
333
769
  function validate(rules, which, value, issues) {
@@ -537,6 +973,6 @@ async function checkConfiguredGameContract(root = process.cwd()) {
537
973
  checkGameContract(await configuredGameContractOptions(root));
538
974
  }
539
975
  //#endregion
540
- export { checkGameContract as a, gameContractJson as c, parseTwinFixtureFile as d, twinFixtureTests as f, buildGameContract as i, deepEquals as l, emitConfiguredGameContract as n, emitGameContract as o, GAME_CONTRACT_FORMAT_VERSION as r, gameContractFilename as s, checkConfiguredGameContract as t, evaluateTwinCase as u };
976
+ export { checkGameContract as a, gameContractJson as c, parseTwinFixtureFile as d, rngFixtureCase as f, buildGameContract as i, deepEquals as l, writeRngFixture as m, emitConfiguredGameContract as n, emitGameContract as o, twinFixtureTests as p, GAME_CONTRACT_FORMAT_VERSION as r, gameContractFilename as s, checkConfiguredGameContract as t, evaluateTwinCase as u };
541
977
 
542
- //# sourceMappingURL=contract-command-aCqO5CyJ.js.map
978
+ //# sourceMappingURL=contract-command-Dn5ANpqD.js.map