@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 +26 -0
- package/dist/contract-cli.js +1 -1
- package/dist/{contract-command-aCqO5CyJ.js → contract-command-Dn5ANpqD.js} +459 -23
- package/dist/contract-command-Dn5ANpqD.js.map +1 -0
- package/dist/index.d.ts +134 -21
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/local-state-CnO-Oi-R.js.map +1 -1
- package/dist/local-state.d.ts +12 -13
- package/dist/local-state.d.ts.map +1 -1
- package/package.json +7 -7
- package/src/index.ts +9 -0
- package/src/twin-fixtures.ts +592 -31
- package/dist/contract-command-aCqO5CyJ.js.map +0 -1
package/src/twin-fixtures.ts
CHANGED
|
@@ -6,9 +6,9 @@
|
|
|
6
6
|
*
|
|
7
7
|
* One fixture file per concern lives beside the version units at
|
|
8
8
|
* `<fixturesRoot>/v<N>/*.json` and is consumed by BOTH sides: this module
|
|
9
|
-
* runs each case against the TS unit (schemas +
|
|
10
|
-
*
|
|
11
|
-
* same file against the Dart twin (generated payload parsing,
|
|
9
|
+
* runs each case against the TS unit (schemas + the four hooks + the two
|
|
10
|
+
* predicates, and whole matches through the real kernel), while the Dart
|
|
11
|
+
* runner runs the same file against the Dart twin (generated payload parsing,
|
|
12
12
|
* `isValidAction`, `previewAction`, predicate twins). A behavioral divergence
|
|
13
13
|
* then fails one side's CI instead of degrading UX in production.
|
|
14
14
|
*
|
|
@@ -47,7 +47,58 @@
|
|
|
47
47
|
* { "kind": "ratingPool", "name": "...", "access": "public",
|
|
48
48
|
* "minPlayers": 2, "maxPlayers": 2, "config": {}, "expected": "blitz" },
|
|
49
49
|
* { "kind": "botSeatable", "name": "...", "gameConfig": {},
|
|
50
|
-
* "botConfig": {}, "expected": false }
|
|
50
|
+
* "botConfig": {}, "expected": false },
|
|
51
|
+
* {
|
|
52
|
+
* "kind": "initialState",
|
|
53
|
+
* "name": "a fresh board seats both players",
|
|
54
|
+
* "config": { ... },
|
|
55
|
+
* "playerCount": 2,
|
|
56
|
+
* "rngSeed": "any string", // optional, default "twin-fixtures"
|
|
57
|
+
* "expected": { // the envelope block the action case
|
|
58
|
+
* "state": { ... }, // records, minus `valid`
|
|
59
|
+
* "pending": [0, 1],
|
|
60
|
+
* "outcome": null
|
|
61
|
+
* }
|
|
62
|
+
* },
|
|
63
|
+
* {
|
|
64
|
+
* "kind": "lifecycle",
|
|
65
|
+
* "name": "a forfeit hands the match to the other seat",
|
|
66
|
+
* "config": { ... },
|
|
67
|
+
* "state": { ... }, // applyLifecycle input
|
|
68
|
+
* "pending": [0, 1],
|
|
69
|
+
* "type": "forfeit", // timeout | forfeit | autoForfeit
|
|
70
|
+
* "playerIndex": 0, // required for forfeit/autoForfeit,
|
|
71
|
+
* // forbidden for timeout
|
|
72
|
+
* "participantCount": 2, // optional, default 2
|
|
73
|
+
* "rngSeed": "any string", // optional, default "twin-fixtures"
|
|
74
|
+
* "expected": { "state": { ... }, "pending": [], "outcome": [ ... ] }
|
|
75
|
+
* },
|
|
76
|
+
* {
|
|
77
|
+
* "kind": "transcript",
|
|
78
|
+
* "name": "a best-of-one match played to its finish",
|
|
79
|
+
* "config": { ... },
|
|
80
|
+
* "playerCount": 2,
|
|
81
|
+
* "seed": "the game's base rng seed",
|
|
82
|
+
* "transitions": [
|
|
83
|
+
* { "kind": "game", "playerIndex": 0, "data": { ... } },
|
|
84
|
+
* { "kind": "lifecycle", "type": "forfeit", "playerIndex": 1 }
|
|
85
|
+
* ],
|
|
86
|
+
* "expected": {
|
|
87
|
+
* "version": 3, // the final committed state version
|
|
88
|
+
* "status": "finished", // active | finished
|
|
89
|
+
* "state": { ... }, // optional, as above
|
|
90
|
+
* "pending": [],
|
|
91
|
+
* "outcome": [ ... ]
|
|
92
|
+
* }
|
|
93
|
+
* },
|
|
94
|
+
* {
|
|
95
|
+
* "kind": "rng",
|
|
96
|
+
* "name": "the stream initialState draws from",
|
|
97
|
+
* "seed": "twin-fixtures",
|
|
98
|
+
* "version": 0,
|
|
99
|
+
* "seat": 1, // optional; present ⇒ the bot stream
|
|
100
|
+
* "draws": [0.123, 0.456] // 1..64 values, compared exactly
|
|
101
|
+
* }
|
|
51
102
|
* ]
|
|
52
103
|
* }
|
|
53
104
|
* ```
|
|
@@ -58,6 +109,34 @@
|
|
|
58
109
|
* state to it, and a Dart `previewAction` that returns non-null must predict
|
|
59
110
|
* it, so the two sides are compared through one recorded value.
|
|
60
111
|
*
|
|
112
|
+
* ## What each kind records
|
|
113
|
+
*
|
|
114
|
+
* `action`, `playerLimits`, `ratingPool` and `botSeatable` pin one hook or
|
|
115
|
+
* predicate. The four kinds below exist for offline play (architecture
|
|
116
|
+
* decision 0012): a device runs the Dart twin through a Dart port of the
|
|
117
|
+
* kernel, so the pieces the online client never had to reproduce — the
|
|
118
|
+
* opening state, the lifecycle hooks, a whole match, and the random stream
|
|
119
|
+
* itself — each need a recorded behavior of their own.
|
|
120
|
+
*
|
|
121
|
+
* - `initialState` runs `initialState` with `deriveRng(rngSeed, 0)`, the
|
|
122
|
+
* version-0 stream the engine's `start` commit derives, and checks the
|
|
123
|
+
* returned envelope exactly as the action case does.
|
|
124
|
+
* - `lifecycle` runs `applyLifecycle` with the `data` payload the kernel
|
|
125
|
+
* builds (`{type:"timeout"}`, or `{type, playerIndex}`), then applies the
|
|
126
|
+
* kernel's forfeit guard: a forfeit must remove its own seat from pending.
|
|
127
|
+
* Like the action case, and unlike `initialState`, it draws from the raw
|
|
128
|
+
* `rngSeed` stream: a standalone hook case has no version to derive from.
|
|
129
|
+
* - `transcript` replays an ordered match through the REAL kernel `commit()`,
|
|
130
|
+
* so every engine guard, the pending/version bookkeeping, and the
|
|
131
|
+
* per-transition `deriveRng(seed, version)` streams all participate. It is
|
|
132
|
+
* the case a Dart local kernel has to reproduce move for move, on the
|
|
133
|
+
* untimed table and with the human/bot roster {@link evaluateTranscript}
|
|
134
|
+
* documents.
|
|
135
|
+
* - `rng` records raw `deriveRng` draws. Generated from the TypeScript kernel
|
|
136
|
+
* by {@link rngFixtureCase} / {@link writeRngFixture} and compared with
|
|
137
|
+
* `===`, no tolerance: the Dart port is required to be bit-identical, and a
|
|
138
|
+
* port that is merely close is a port that desynchronizes one draw later.
|
|
139
|
+
*
|
|
61
140
|
* Wire it up in a game-owned test file running under plain-Node vitest:
|
|
62
141
|
*
|
|
63
142
|
* ```ts
|
|
@@ -68,17 +147,31 @@
|
|
|
68
147
|
* ```
|
|
69
148
|
*/
|
|
70
149
|
|
|
71
|
-
import { readdirSync, readFileSync } from "node:fs";
|
|
72
|
-
import {
|
|
150
|
+
import { readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
151
|
+
import { assertForfeitPending, type CommitInput, type CommitPlan, commit, deriveRng, GameBugError, type GameRow, type Intent, isRejected, type Rejected, type Seat } from "@eigeninteractive/kernel";
|
|
152
|
+
import { type Envelope, type GameAccess, type GameModule, type GameRules, IllegalMoveError, type Json, type JsonObject, type LifecycleAction, type LifecycleType, type ObservationSlice, type OutcomeEntry, type Rng } from "@eigeninteractive/rules";
|
|
73
153
|
import Rand from "rand-seed";
|
|
74
154
|
import { it } from "vitest";
|
|
75
155
|
|
|
156
|
+
/** The stream a case draws from when it names no `rngSeed`. */
|
|
157
|
+
const DEFAULT_RNG_SEED = "twin-fixtures";
|
|
158
|
+
|
|
76
159
|
/** One fixture file: cases targeting one `schemaVersion` unit. */
|
|
77
160
|
export interface TwinFixtureFile {
|
|
78
161
|
schemaVersion: number;
|
|
79
162
|
cases: TwinFixtureCase[];
|
|
80
163
|
}
|
|
81
164
|
|
|
165
|
+
/** What a case may assert about the envelope a hook (or a whole transcript)
|
|
166
|
+
* produced. Every field is optional: a fixture pins what it means to pin.
|
|
167
|
+
* `outcome` is three-valued — absent leaves the outcome unchecked, `null`
|
|
168
|
+
* asserts the game is ongoing, a list asserts it ended exactly so. */
|
|
169
|
+
export interface ExpectedEnvelope {
|
|
170
|
+
state?: JsonObject;
|
|
171
|
+
pending?: number[];
|
|
172
|
+
outcome?: OutcomeEntry[] | null;
|
|
173
|
+
}
|
|
174
|
+
|
|
82
175
|
/** A game-action case: exercises schemas, `applyAction`, and (through
|
|
83
176
|
* `expected.observation`) `computeObservation` for the acting seat. */
|
|
84
177
|
export interface ActionCase {
|
|
@@ -93,11 +186,8 @@ export interface ActionCase {
|
|
|
93
186
|
participantCount?: number;
|
|
94
187
|
rngSeed?: string;
|
|
95
188
|
action: JsonObject;
|
|
96
|
-
expected: {
|
|
189
|
+
expected: ExpectedEnvelope & {
|
|
97
190
|
valid: boolean;
|
|
98
|
-
state?: JsonObject;
|
|
99
|
-
pending?: number[];
|
|
100
|
-
outcome?: OutcomeEntry[] | null;
|
|
101
191
|
observation?: JsonObject;
|
|
102
192
|
};
|
|
103
193
|
}
|
|
@@ -133,7 +223,73 @@ export interface BotSeatableCase {
|
|
|
133
223
|
expected: boolean;
|
|
134
224
|
}
|
|
135
225
|
|
|
136
|
-
|
|
226
|
+
/** The opening transition: what `initialState` returns for one config and
|
|
227
|
+
* seat count, drawing from the version-0 stream a `start` commit derives. */
|
|
228
|
+
export interface InitialStateCase {
|
|
229
|
+
kind: "initialState";
|
|
230
|
+
name: string;
|
|
231
|
+
config: JsonObject;
|
|
232
|
+
playerCount: number;
|
|
233
|
+
rngSeed?: string;
|
|
234
|
+
expected: ExpectedEnvelope;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** An `applyLifecycle` case: one engine-driven transition (a turn that ran
|
|
238
|
+
* out, a resign, a purged account) resolved against a stated position. */
|
|
239
|
+
export interface LifecycleCase {
|
|
240
|
+
kind: "lifecycle";
|
|
241
|
+
name: string;
|
|
242
|
+
config: JsonObject;
|
|
243
|
+
state: JsonObject;
|
|
244
|
+
pending: number[];
|
|
245
|
+
type: LifecycleType;
|
|
246
|
+
/** The forfeiting seat. Required for `forfeit`/`autoForfeit`; a `timeout`
|
|
247
|
+
* carries no seat (its victims are `pending`), so it must be omitted. */
|
|
248
|
+
playerIndex?: number;
|
|
249
|
+
participantCount?: number;
|
|
250
|
+
rngSeed?: string;
|
|
251
|
+
expected: ExpectedEnvelope;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** One transition of a {@link TranscriptCase}: a seat's move, or a resign. */
|
|
255
|
+
export type TranscriptTransition = { kind: "game"; playerIndex: number; data: JsonObject } | { kind: "lifecycle"; type: "forfeit"; playerIndex: number };
|
|
256
|
+
|
|
257
|
+
/** A whole match, replayed through the real kernel from a stated base seed.
|
|
258
|
+
* The one case that pins the *engine's* bookkeeping — versions, pending
|
|
259
|
+
* hand-off, the per-transition RNG streams — rather than a single hook. */
|
|
260
|
+
export interface TranscriptCase {
|
|
261
|
+
kind: "transcript";
|
|
262
|
+
name: string;
|
|
263
|
+
config: JsonObject;
|
|
264
|
+
playerCount: number;
|
|
265
|
+
/** The game's base RNG seed: every transition's stream derives from it. */
|
|
266
|
+
seed: string;
|
|
267
|
+
transitions: TranscriptTransition[];
|
|
268
|
+
expected: ExpectedEnvelope & {
|
|
269
|
+
version: number;
|
|
270
|
+
status: "active" | "finished";
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Recorded draws from one derived RNG stream. Generated by
|
|
275
|
+
* {@link rngFixtureCase}, never hand-written: the values ARE the TypeScript
|
|
276
|
+
* kernel's, and the Dart port has to reproduce them bit for bit. */
|
|
277
|
+
export interface RngCase {
|
|
278
|
+
kind: "rng";
|
|
279
|
+
name: string;
|
|
280
|
+
seed: string;
|
|
281
|
+
/** The state version the stream belongs to (`deriveRng`'s second arg). */
|
|
282
|
+
version: number;
|
|
283
|
+
/** Present ⇒ the bot stream for that seat, keyed `"<seed>:bot<seat>"`. */
|
|
284
|
+
seat?: number;
|
|
285
|
+
draws: number[];
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export type TwinFixtureCase = ActionCase | PlayerLimitsCase | RatingPoolCase | BotSeatableCase | InitialStateCase | LifecycleCase | TranscriptCase | RngCase;
|
|
289
|
+
|
|
290
|
+
/** Every `kind` a fixture case may declare, in the order the error messages
|
|
291
|
+
* and the evaluator switch list them. */
|
|
292
|
+
const CASE_KINDS = ["action", "playerLimits", "ratingPool", "botSeatable", "initialState", "lifecycle", "transcript", "rng"] as const;
|
|
137
293
|
|
|
138
294
|
// ── Fixture validation ────────────────────────────────────────────────────────
|
|
139
295
|
//
|
|
@@ -185,28 +341,46 @@ function asNumberArray(where: string, v: unknown): number[] {
|
|
|
185
341
|
return v.map((n, i) => asNumber(`${where}[${i}]`, n));
|
|
186
342
|
}
|
|
187
343
|
|
|
344
|
+
/** A seat index, a seat count, or a state version: the fixture format has no
|
|
345
|
+
* fractional or negative counts anywhere. */
|
|
346
|
+
function asIndex(where: string, v: unknown): number {
|
|
347
|
+
const n = asNumber(where, v);
|
|
348
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
349
|
+
throw new Error(`${where}: expected a non-negative integer, got ${JSON.stringify(v)}`);
|
|
350
|
+
}
|
|
351
|
+
return n;
|
|
352
|
+
}
|
|
353
|
+
|
|
188
354
|
/** Applies `read` only when the key is present and non-null; absent and
|
|
189
355
|
* explicit null both mean "not specified" for every optional fixture field. */
|
|
190
356
|
function optional<T>(where: string, v: unknown, read: (where: string, v: unknown) => T): T | undefined {
|
|
191
357
|
return v === undefined || v === null ? undefined : read(where, v);
|
|
192
358
|
}
|
|
193
359
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
const expected:
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
pending: optional(`${where}.expected.pending`, expectedRaw.pending, asNumberArray),
|
|
200
|
-
observation: optional(`${where}.expected.observation`, expectedRaw.observation, asObject),
|
|
360
|
+
/** The `expected` block every hook-running case shares. */
|
|
361
|
+
function parseExpectedEnvelope(where: string, raw: JsonObject): ExpectedEnvelope {
|
|
362
|
+
const expected: ExpectedEnvelope = {
|
|
363
|
+
state: optional(`${where}.state`, raw.state, asObject),
|
|
364
|
+
pending: optional(`${where}.pending`, raw.pending, asNumberArray),
|
|
201
365
|
};
|
|
202
366
|
// `outcome` is three-valued: absent (unchecked), null (asserts the game is
|
|
203
367
|
// ongoing), or a list. `checkEnvelope` distinguishes absent from null with
|
|
204
368
|
// an `in` test, so the key must only be set when the fixture set it.
|
|
205
|
-
if ("outcome" in
|
|
206
|
-
const outcome =
|
|
207
|
-
if (outcome !== null && !Array.isArray(outcome)) fail(`${where}.
|
|
369
|
+
if ("outcome" in raw) {
|
|
370
|
+
const outcome = raw.outcome;
|
|
371
|
+
if (outcome !== null && !Array.isArray(outcome)) fail(`${where}.outcome`, "an array or null", outcome);
|
|
208
372
|
expected.outcome = outcome as OutcomeEntry[] | null;
|
|
209
373
|
}
|
|
374
|
+
return expected;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function parseActionCase(where: string, raw: JsonObject): ActionCase {
|
|
378
|
+
const expectedRaw = asObject(`${where}.expected`, raw.expected);
|
|
379
|
+
const expected: ActionCase["expected"] = {
|
|
380
|
+
...parseExpectedEnvelope(`${where}.expected`, expectedRaw),
|
|
381
|
+
valid: asBoolean(`${where}.expected.valid`, expectedRaw.valid),
|
|
382
|
+
observation: optional(`${where}.expected.observation`, expectedRaw.observation, asObject),
|
|
383
|
+
};
|
|
210
384
|
return {
|
|
211
385
|
kind: "action",
|
|
212
386
|
name: asString(`${where}.name`, raw.name),
|
|
@@ -264,6 +438,116 @@ function parseBotSeatableCase(where: string, raw: JsonObject): BotSeatableCase {
|
|
|
264
438
|
};
|
|
265
439
|
}
|
|
266
440
|
|
|
441
|
+
function parseInitialStateCase(where: string, raw: JsonObject): InitialStateCase {
|
|
442
|
+
return {
|
|
443
|
+
kind: "initialState",
|
|
444
|
+
name: asString(`${where}.name`, raw.name),
|
|
445
|
+
config: asObject(`${where}.config`, raw.config),
|
|
446
|
+
playerCount: asIndex(`${where}.playerCount`, raw.playerCount),
|
|
447
|
+
rngSeed: optional(`${where}.rngSeed`, raw.rngSeed, asString),
|
|
448
|
+
expected: parseExpectedEnvelope(`${where}.expected`, asObject(`${where}.expected`, raw.expected)),
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function parseLifecycleCase(where: string, raw: JsonObject): LifecycleCase {
|
|
453
|
+
const type = asString(`${where}.type`, raw.type);
|
|
454
|
+
if (type !== "timeout" && type !== "forfeit" && type !== "autoForfeit") {
|
|
455
|
+
throw new Error(`${where}.type: expected one of timeout | forfeit | autoForfeit, got ${JSON.stringify(raw.type)}`);
|
|
456
|
+
}
|
|
457
|
+
// A timeout resolves whoever is pending and carries no seat, so a
|
|
458
|
+
// `playerIndex` on one is not a harmless extra: it is a fixture author
|
|
459
|
+
// expecting a seat the hook will never be told about.
|
|
460
|
+
const playerIndex = optional(`${where}.playerIndex`, raw.playerIndex, asIndex);
|
|
461
|
+
if (type === "timeout" && playerIndex !== undefined) {
|
|
462
|
+
throw new Error(`${where}.playerIndex: a timeout carries no seat (its victims are "pending"); remove it`);
|
|
463
|
+
}
|
|
464
|
+
if (type !== "timeout" && playerIndex === undefined) {
|
|
465
|
+
throw new Error(`${where}.playerIndex: a ${type} must name the forfeiting seat`);
|
|
466
|
+
}
|
|
467
|
+
return {
|
|
468
|
+
kind: "lifecycle",
|
|
469
|
+
name: asString(`${where}.name`, raw.name),
|
|
470
|
+
config: asObject(`${where}.config`, raw.config),
|
|
471
|
+
state: asObject(`${where}.state`, raw.state),
|
|
472
|
+
pending: asNumberArray(`${where}.pending`, raw.pending),
|
|
473
|
+
type,
|
|
474
|
+
playerIndex,
|
|
475
|
+
participantCount: optional(`${where}.participantCount`, raw.participantCount, asNumber),
|
|
476
|
+
rngSeed: optional(`${where}.rngSeed`, raw.rngSeed, asString),
|
|
477
|
+
expected: parseExpectedEnvelope(`${where}.expected`, asObject(`${where}.expected`, raw.expected)),
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function parseTranscriptTransition(where: string, raw: unknown): TranscriptTransition {
|
|
482
|
+
const obj = asObject(where, raw);
|
|
483
|
+
switch (obj.kind) {
|
|
484
|
+
case "game":
|
|
485
|
+
return {
|
|
486
|
+
kind: "game",
|
|
487
|
+
playerIndex: asIndex(`${where}.playerIndex`, obj.playerIndex),
|
|
488
|
+
data: asObject(`${where}.data`, obj.data),
|
|
489
|
+
};
|
|
490
|
+
case "lifecycle": {
|
|
491
|
+
// Only `forfeit` is transcriptable: a timeout needs an expired clock and
|
|
492
|
+
// a local game is untimed, and `autoForfeit` is an account purge, which
|
|
493
|
+
// no transcript of a played match can contain.
|
|
494
|
+
const type = asString(`${where}.type`, obj.type);
|
|
495
|
+
if (type !== "forfeit") {
|
|
496
|
+
throw new Error(`${where}.type: expected forfeit, got ${JSON.stringify(obj.type)}`);
|
|
497
|
+
}
|
|
498
|
+
return {
|
|
499
|
+
kind: "lifecycle",
|
|
500
|
+
type,
|
|
501
|
+
playerIndex: asIndex(`${where}.playerIndex`, obj.playerIndex),
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
default:
|
|
505
|
+
throw new Error(`${where}.kind: expected one of game | lifecycle, got ${JSON.stringify(obj.kind)}`);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function parseTranscriptCase(where: string, raw: JsonObject): TranscriptCase {
|
|
510
|
+
const expectedRaw = asObject(`${where}.expected`, raw.expected);
|
|
511
|
+
const status = asString(`${where}.expected.status`, expectedRaw.status);
|
|
512
|
+
if (status !== "active" && status !== "finished") {
|
|
513
|
+
throw new Error(`${where}.expected.status: expected one of active | finished, got ${JSON.stringify(expectedRaw.status)}`);
|
|
514
|
+
}
|
|
515
|
+
if (!Array.isArray(raw.transitions)) fail(`${where}.transitions`, "an array", raw.transitions);
|
|
516
|
+
return {
|
|
517
|
+
kind: "transcript",
|
|
518
|
+
name: asString(`${where}.name`, raw.name),
|
|
519
|
+
config: asObject(`${where}.config`, raw.config),
|
|
520
|
+
playerCount: asIndex(`${where}.playerCount`, raw.playerCount),
|
|
521
|
+
seed: asString(`${where}.seed`, raw.seed),
|
|
522
|
+
transitions: raw.transitions.map((transition, i) => parseTranscriptTransition(`${where}.transitions[${i}]`, transition)),
|
|
523
|
+
expected: {
|
|
524
|
+
...parseExpectedEnvelope(`${where}.expected`, expectedRaw),
|
|
525
|
+
version: asIndex(`${where}.expected.version`, expectedRaw.version),
|
|
526
|
+
status,
|
|
527
|
+
},
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/** The draw count one rng case may record. One is enough to catch a wrong
|
|
532
|
+
* seeding; the cap keeps a fixture file a document rather than a data dump,
|
|
533
|
+
* and a stream that agrees for 64 draws does not diverge at 65. */
|
|
534
|
+
const RNG_DRAW_LIMIT = 64;
|
|
535
|
+
|
|
536
|
+
function parseRngCase(where: string, raw: JsonObject): RngCase {
|
|
537
|
+
if (!Array.isArray(raw.draws)) fail(`${where}.draws`, "an array of numbers", raw.draws);
|
|
538
|
+
if (raw.draws.length === 0 || raw.draws.length > RNG_DRAW_LIMIT) {
|
|
539
|
+
throw new Error(`${where}.draws: expected between 1 and ${RNG_DRAW_LIMIT} draws, got ${raw.draws.length}`);
|
|
540
|
+
}
|
|
541
|
+
return {
|
|
542
|
+
kind: "rng",
|
|
543
|
+
name: asString(`${where}.name`, raw.name),
|
|
544
|
+
seed: asString(`${where}.seed`, raw.seed),
|
|
545
|
+
version: asIndex(`${where}.version`, raw.version),
|
|
546
|
+
seat: optional(`${where}.seat`, raw.seat, asIndex),
|
|
547
|
+
draws: asNumberArray(`${where}.draws`, raw.draws),
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
|
|
267
551
|
/** Validate one fixture file's parsed JSON, or throw naming the offending
|
|
268
552
|
* file, case, and field. Exported so a repo can lint its fixtures without
|
|
269
553
|
* running them. */
|
|
@@ -286,8 +570,16 @@ export function parseTwinFixtureFile(path: string, json: unknown): TwinFixtureFi
|
|
|
286
570
|
return parseRatingPoolCase(where, obj);
|
|
287
571
|
case "botSeatable":
|
|
288
572
|
return parseBotSeatableCase(where, obj);
|
|
573
|
+
case "initialState":
|
|
574
|
+
return parseInitialStateCase(where, obj);
|
|
575
|
+
case "lifecycle":
|
|
576
|
+
return parseLifecycleCase(where, obj);
|
|
577
|
+
case "transcript":
|
|
578
|
+
return parseTranscriptCase(where, obj);
|
|
579
|
+
case "rng":
|
|
580
|
+
return parseRngCase(where, obj);
|
|
289
581
|
default:
|
|
290
|
-
throw new Error(`${where}.kind: expected one of
|
|
582
|
+
throw new Error(`${where}.kind: expected one of ${CASE_KINDS.join(" | ")}, got ${JSON.stringify(obj.kind)}`);
|
|
291
583
|
}
|
|
292
584
|
});
|
|
293
585
|
return { schemaVersion, cases };
|
|
@@ -295,8 +587,13 @@ export function parseTwinFixtureFile(path: string, json: unknown): TwinFixtureFi
|
|
|
295
587
|
|
|
296
588
|
/** Run one fixture case against a rules unit, returning failure descriptions
|
|
297
589
|
* (empty ⇒ the case passes). Pure; the file-reading test registrar is
|
|
298
|
-
* {@link twinFixtureTests}.
|
|
299
|
-
|
|
590
|
+
* {@link twinFixtureTests}.
|
|
591
|
+
*
|
|
592
|
+
* `schemaVersion` is the version the case targets. It never selects
|
|
593
|
+
* behavior — the caller already resolved `rules` from it — and only labels
|
|
594
|
+
* the engine guard messages a `lifecycle` or `transcript` case can provoke;
|
|
595
|
+
* {@link twinFixtureTests} passes the fixture file's. */
|
|
596
|
+
export function evaluateTwinCase(rules: GameRules, kase: TwinFixtureCase, schemaVersion = 1): string[] {
|
|
300
597
|
switch (kase.kind) {
|
|
301
598
|
case "action":
|
|
302
599
|
return evaluateAction(rules, kase);
|
|
@@ -306,8 +603,16 @@ export function evaluateTwinCase(rules: GameRules, kase: TwinFixtureCase): strin
|
|
|
306
603
|
return evaluateRatingPool(rules, kase);
|
|
307
604
|
case "botSeatable":
|
|
308
605
|
return evaluateBotSeatable(rules, kase);
|
|
606
|
+
case "initialState":
|
|
607
|
+
return evaluateInitialState(rules, kase);
|
|
608
|
+
case "lifecycle":
|
|
609
|
+
return evaluateLifecycle(rules, kase, schemaVersion);
|
|
610
|
+
case "transcript":
|
|
611
|
+
return evaluateTranscript(rules, kase, schemaVersion);
|
|
612
|
+
case "rng":
|
|
613
|
+
return evaluateRng(kase);
|
|
309
614
|
default:
|
|
310
|
-
return [`unknown case kind "${(kase as { kind: string }).kind}", expected
|
|
615
|
+
return [`unknown case kind "${(kase as { kind: string }).kind}", expected ${CASE_KINDS.join(" | ")}`];
|
|
311
616
|
}
|
|
312
617
|
}
|
|
313
618
|
|
|
@@ -323,7 +628,7 @@ export function twinFixtureTests(gameModule: GameModule, fixturesRoot: string |
|
|
|
323
628
|
if (!rules) {
|
|
324
629
|
throw new Error(`gameModule ships no rules unit for schemaVersion ${fixture.schemaVersion} (fixture: ${filePath})`);
|
|
325
630
|
}
|
|
326
|
-
const failures = evaluateTwinCase(rules, kase);
|
|
631
|
+
const failures = evaluateTwinCase(rules, kase, fixture.schemaVersion);
|
|
327
632
|
if (failures.length) throw new Error(`\n${failures.join("\n")}`);
|
|
328
633
|
});
|
|
329
634
|
}
|
|
@@ -366,7 +671,7 @@ function evaluateAction(rules: GameRules, kase: ActionCase): string[] {
|
|
|
366
671
|
if (envelope) failures.push(envelope);
|
|
367
672
|
return failures;
|
|
368
673
|
}
|
|
369
|
-
checkEnvelope(rules, kase, envelope, failures);
|
|
674
|
+
checkEnvelope(rules, "applyAction", kase.expected, envelope, failures);
|
|
370
675
|
if (kase.expected.observation !== undefined) {
|
|
371
676
|
checkObservation(rules, kase, envelope, config, action, failures);
|
|
372
677
|
}
|
|
@@ -384,7 +689,7 @@ function applyFixtureAction(rules: GameRules, kase: ActionCase, config: JsonObje
|
|
|
384
689
|
pending: kase.pending,
|
|
385
690
|
data: action,
|
|
386
691
|
playerIndex: kase.playerIndex,
|
|
387
|
-
rng:
|
|
692
|
+
rng: hookRng(kase.rngSeed),
|
|
388
693
|
config,
|
|
389
694
|
});
|
|
390
695
|
} catch (error) {
|
|
@@ -396,11 +701,22 @@ function applyFixtureAction(rules: GameRules, kase: ActionCase, config: JsonObje
|
|
|
396
701
|
return kase.expected.valid ? envelope : "applyAction accepted a move the fixture expects to be illegal";
|
|
397
702
|
}
|
|
398
703
|
|
|
399
|
-
|
|
704
|
+
/** The stream a standalone hook case draws from. Seeded with the fixture's
|
|
705
|
+
* `rngSeed` verbatim rather than through `deriveRng`, because a single hook
|
|
706
|
+
* case stands outside any game: it has no committed version to derive a
|
|
707
|
+
* stream from. The `initialState` case is the exception — a game's opening
|
|
708
|
+
* transition is always version 0 — and a `transcript` case, which runs the
|
|
709
|
+
* real kernel, gets the real per-version streams. */
|
|
710
|
+
function hookRng(rngSeed: string | undefined): Rng {
|
|
711
|
+
return new Rand(rngSeed ?? DEFAULT_RNG_SEED);
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/** Compare a hook's envelope with what a case recorded, and re-validate the
|
|
715
|
+
* state it returned exactly as the engine does before committing it. */
|
|
716
|
+
function checkEnvelope(rules: GameRules, hook: string, expected: ExpectedEnvelope, envelope: Envelope, failures: string[]): void {
|
|
400
717
|
if (validate(rules, "state", envelope.state) === undefined) {
|
|
401
|
-
failures.push(
|
|
718
|
+
failures.push(`${hook} returned state that violates its own schema`);
|
|
402
719
|
}
|
|
403
|
-
const expected = kase.expected;
|
|
404
720
|
if (expected.state !== undefined && !deepEquals(envelope.state, expected.state)) {
|
|
405
721
|
failures.push(`envelope.state mismatch, got ${JSON.stringify(envelope.state)}`);
|
|
406
722
|
}
|
|
@@ -477,6 +793,251 @@ function evaluateBotSeatable(rules: GameRules, kase: BotSeatableCase): string[]
|
|
|
477
793
|
return failures;
|
|
478
794
|
}
|
|
479
795
|
|
|
796
|
+
function evaluateInitialState(rules: GameRules, kase: InitialStateCase): string[] {
|
|
797
|
+
const failures: string[] = [];
|
|
798
|
+
const config = parseWith(rules, "config", kase.config, failures);
|
|
799
|
+
if (config === undefined) return failures;
|
|
800
|
+
let envelope: Envelope;
|
|
801
|
+
try {
|
|
802
|
+
envelope = rules.initialState({
|
|
803
|
+
config,
|
|
804
|
+
// Exactly what a `start` commit passes: the opening transition commits
|
|
805
|
+
// as version 0, so its stream is the version-0 one.
|
|
806
|
+
rng: deriveRng(kase.rngSeed ?? DEFAULT_RNG_SEED, 0),
|
|
807
|
+
playerCount: kase.playerCount,
|
|
808
|
+
});
|
|
809
|
+
} catch (error) {
|
|
810
|
+
failures.push(`initialState threw: ${error}`);
|
|
811
|
+
return failures;
|
|
812
|
+
}
|
|
813
|
+
checkEnvelope(rules, "initialState", kase.expected, envelope, failures);
|
|
814
|
+
return failures;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
function evaluateLifecycle(rules: GameRules, kase: LifecycleCase, schemaVersion: number): string[] {
|
|
818
|
+
const failures: string[] = [];
|
|
819
|
+
const config = parseWith(rules, "config", kase.config, failures);
|
|
820
|
+
const state = parseWith(rules, "state", kase.state, failures);
|
|
821
|
+
if (config === undefined || state === undefined) return failures;
|
|
822
|
+
|
|
823
|
+
// Built exactly as `commit()` builds it, because the payload is the hook's
|
|
824
|
+
// only word on which seat is leaving: a `timeout` names none and resolves
|
|
825
|
+
// the whole pending set, and the parser already refused any other pairing.
|
|
826
|
+
const data: LifecycleAction = kase.type === "timeout" ? { type: "timeout" } : { type: kase.type, playerIndex: kase.playerIndex as number };
|
|
827
|
+
|
|
828
|
+
let envelope: Envelope;
|
|
829
|
+
try {
|
|
830
|
+
envelope = rules.applyLifecycle({
|
|
831
|
+
state,
|
|
832
|
+
pending: kase.pending,
|
|
833
|
+
type: kase.type,
|
|
834
|
+
data,
|
|
835
|
+
rng: hookRng(kase.rngSeed),
|
|
836
|
+
config,
|
|
837
|
+
});
|
|
838
|
+
} catch (error) {
|
|
839
|
+
failures.push(`applyLifecycle threw: ${error}`);
|
|
840
|
+
return failures;
|
|
841
|
+
}
|
|
842
|
+
checkEnvelope(rules, "applyLifecycle", kase.expected, envelope, failures);
|
|
843
|
+
if (data.type !== "timeout") {
|
|
844
|
+
// The engine's own guard, run here rather than restated: a forfeit that
|
|
845
|
+
// leaves its seat pending is a game bug the kernel would refuse to commit,
|
|
846
|
+
// and a fixture recording one would record an unreachable behavior for the
|
|
847
|
+
// Dart twin to reproduce.
|
|
848
|
+
try {
|
|
849
|
+
assertForfeitPending(data.playerIndex, envelope, schemaVersion);
|
|
850
|
+
} catch (error) {
|
|
851
|
+
failures.push(`${error instanceof Error ? error.message : error}`);
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
return failures;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
/**
|
|
858
|
+
* Replay an ordered match through the real kernel.
|
|
859
|
+
*
|
|
860
|
+
* The table is the one a local (offline) game plays on, and the conventions
|
|
861
|
+
* below are a contract with the Dart local kernel that replays the same file:
|
|
862
|
+
*
|
|
863
|
+
* - untimed and unrated (`turnSeconds`, `budgetSeconds`, `incrementSeconds`
|
|
864
|
+
* all null), so no deadline is ever armed and the clock is never read;
|
|
865
|
+
* - a roster of `playerCount` identified seats: seat 0 is the human
|
|
866
|
+
* `user-0`, every other seat is the bot `bot-<seat>`;
|
|
867
|
+
* - the match opens with a `start` intent carrying the case's `seed`, so
|
|
868
|
+
* every transition draws from `deriveRng(seed, version)`;
|
|
869
|
+
* - each transition commits at `expectedVersion` = the current version (a
|
|
870
|
+
* device is the only actor, so it is never stale), with `actor` `"user"`
|
|
871
|
+
* for seat 0 and `"bot"` for the rest.
|
|
872
|
+
*
|
|
873
|
+
* A rejection or a broken invariant fails the case naming the transition that
|
|
874
|
+
* produced it: an engine that refuses the transcript is as much a divergence
|
|
875
|
+
* as a wrong final state.
|
|
876
|
+
*/
|
|
877
|
+
function evaluateTranscript(rules: GameRules, kase: TranscriptCase, schemaVersion: number): string[] {
|
|
878
|
+
const failures: string[] = [];
|
|
879
|
+
// `commit()` parses the config itself; parsing here too reports a bad
|
|
880
|
+
// fixture config as a fixture problem rather than as an engine throw.
|
|
881
|
+
if (parseWith(rules, "config", kase.config, failures) === undefined) return failures;
|
|
882
|
+
|
|
883
|
+
const game: GameRow = {
|
|
884
|
+
status: "ready",
|
|
885
|
+
schemaVersion,
|
|
886
|
+
config: kase.config,
|
|
887
|
+
turnSeconds: null,
|
|
888
|
+
budgetSeconds: null,
|
|
889
|
+
incrementSeconds: null,
|
|
890
|
+
rated: false,
|
|
891
|
+
ratingPool: null,
|
|
892
|
+
};
|
|
893
|
+
const roster: Seat[] = Array.from({ length: kase.playerCount }, (_, seat) => (seat === 0 ? { playerIndex: 0, userId: "user-0", botId: null, type: "human" } : { playerIndex: seat, userId: null, botId: `bot-${seat}`, type: "bot" }));
|
|
894
|
+
|
|
895
|
+
let plan = runCommit({ game, state: null, roster, intent: { kind: "start", seed: kase.seed }, now: TRANSCRIPT_NOW, rules });
|
|
896
|
+
if (typeof plan === "string") return [`start: ${plan}`];
|
|
897
|
+
game.status = "active";
|
|
898
|
+
|
|
899
|
+
for (const [index, transition] of kase.transitions.entries()) {
|
|
900
|
+
const intent: Intent =
|
|
901
|
+
transition.kind === "game"
|
|
902
|
+
? {
|
|
903
|
+
kind: "action",
|
|
904
|
+
seat: transition.playerIndex,
|
|
905
|
+
expectedVersion: plan.nextState.version,
|
|
906
|
+
data: transition.data,
|
|
907
|
+
actor: transition.playerIndex === 0 ? "user" : "bot",
|
|
908
|
+
}
|
|
909
|
+
: { kind: "lifecycle", type: "forfeit", seat: transition.playerIndex };
|
|
910
|
+
const result = runCommit({ game, state: plan.nextState, roster, intent, now: TRANSCRIPT_NOW, rules });
|
|
911
|
+
if (typeof result === "string") return [`transitions[${index}] (${describeTransition(transition)}): ${result}`];
|
|
912
|
+
plan = result;
|
|
913
|
+
// A finished game refuses everything that follows, so a transcript with a
|
|
914
|
+
// move after its last one fails on that move rather than silently here.
|
|
915
|
+
if (plan.outcomes !== null) game.status = "finished";
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
const status = plan.outcomes === null ? "active" : "finished";
|
|
919
|
+
if (plan.nextState.version !== kase.expected.version) {
|
|
920
|
+
failures.push(`the match committed as version ${plan.nextState.version}, fixture expects ${kase.expected.version}`);
|
|
921
|
+
}
|
|
922
|
+
if (status !== kase.expected.status) {
|
|
923
|
+
failures.push(`the match is ${status}, fixture expects ${kase.expected.status}`);
|
|
924
|
+
}
|
|
925
|
+
checkEnvelope(rules, "the final transition", kase.expected, { state: plan.nextState.state, pendingPlayers: plan.nextState.pending, ...(plan.outcomes === null ? {} : { outcome: plan.outcomes }) }, failures);
|
|
926
|
+
return failures;
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
/** The commit instant every transcript transition carries. An untimed game
|
|
930
|
+
* never reads it, and a fixed value keeps the case a pure function of the
|
|
931
|
+
* transcript. */
|
|
932
|
+
const TRANSCRIPT_NOW = 0;
|
|
933
|
+
|
|
934
|
+
/** One kernel commit, with both failure species rendered as a string: a
|
|
935
|
+
* rejection (a value) and a broken invariant (a throw) are equally a
|
|
936
|
+
* transcript that does not replay. */
|
|
937
|
+
function runCommit(input: CommitInput): CommitPlan | string {
|
|
938
|
+
let result: CommitPlan | Rejected;
|
|
939
|
+
try {
|
|
940
|
+
result = commit(input);
|
|
941
|
+
} catch (error) {
|
|
942
|
+
if (error instanceof GameBugError) return `the engine refused the hook result: ${error.message}`;
|
|
943
|
+
return `commit threw: ${error}`;
|
|
944
|
+
}
|
|
945
|
+
if (isRejected(result)) return `the engine rejected the intent (${result.code}): ${result.message}`;
|
|
946
|
+
return result;
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
function describeTransition(transition: TranscriptTransition): string {
|
|
950
|
+
return transition.kind === "game" ? `seat ${transition.playerIndex} plays ${JSON.stringify(transition.data)}` : `seat ${transition.playerIndex} forfeits`;
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
function evaluateRng(kase: RngCase): string[] {
|
|
954
|
+
const rng = rngStream(kase.seed, kase.version, kase.seat);
|
|
955
|
+
const failures: string[] = [];
|
|
956
|
+
for (const [index, expected] of kase.draws.entries()) {
|
|
957
|
+
const draw = rng.next();
|
|
958
|
+
// Exact equality, deliberately: a port that is merely close has already
|
|
959
|
+
// diverged, and the next draw off a shared stream proves it loudly.
|
|
960
|
+
if (draw !== expected) failures.push(`draw[${index}] is ${draw}, fixture records ${expected}`);
|
|
961
|
+
}
|
|
962
|
+
return failures;
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
/** The stream an {@link RngCase} describes: a transition's own stream, or,
|
|
966
|
+
* when the case names a seat, the bot stream the Durable Object derives for
|
|
967
|
+
* that seat before running its brain. */
|
|
968
|
+
function rngStream(seed: string, version: number, seat: number | undefined): Rng {
|
|
969
|
+
return seat === undefined ? deriveRng(seed, version) : deriveRng(`${seed}:bot${seat}`, version);
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
// ── Generating rng fixtures ───────────────────────────────────────────────────
|
|
973
|
+
//
|
|
974
|
+
// An `rng` case is the one kind nobody writes by hand: its values are whatever
|
|
975
|
+
// the TypeScript kernel's generator produces, and the point of recording them
|
|
976
|
+
// is that the Dart port has to agree digit for digit. So the testkit generates
|
|
977
|
+
// them, and a game re-runs the generator when it wants more coverage.
|
|
978
|
+
|
|
979
|
+
/** What stream to record, and how much of it. */
|
|
980
|
+
export interface RngFixtureCaseOptions {
|
|
981
|
+
/** The case name, used as the test name on both sides. */
|
|
982
|
+
name: string;
|
|
983
|
+
/** The game's base seed. */
|
|
984
|
+
seed: string;
|
|
985
|
+
/** The state version whose stream to record. */
|
|
986
|
+
version: number;
|
|
987
|
+
/** Record the bot stream for this seat instead of the transition's own. */
|
|
988
|
+
seat?: number;
|
|
989
|
+
/** How many draws to record (1 to 64). */
|
|
990
|
+
count: number;
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
/** Record one derived stream from the real kernel as a fixture case. */
|
|
994
|
+
export function rngFixtureCase(options: RngFixtureCaseOptions): RngCase {
|
|
995
|
+
const { name, seed, version, seat, count } = options;
|
|
996
|
+
if (!Number.isInteger(count) || count < 1 || count > RNG_DRAW_LIMIT) {
|
|
997
|
+
throw new Error(`rngFixtureCase(${JSON.stringify(name)}): count must be an integer between 1 and ${RNG_DRAW_LIMIT}, got ${count}`);
|
|
998
|
+
}
|
|
999
|
+
const rng = rngStream(seed, version, seat);
|
|
1000
|
+
return {
|
|
1001
|
+
kind: "rng",
|
|
1002
|
+
name,
|
|
1003
|
+
seed,
|
|
1004
|
+
version,
|
|
1005
|
+
...(seat === undefined ? {} : { seat }),
|
|
1006
|
+
draws: Array.from({ length: count }, () => rng.next()),
|
|
1007
|
+
};
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
/**
|
|
1011
|
+
* Write a fixture file of generated `rng` cases.
|
|
1012
|
+
*
|
|
1013
|
+
* The `schemaVersion` is read from the `v<N>/` directory in `path`, the same
|
|
1014
|
+
* rule `eigen-contract` enforces over every fixture file, so the two can
|
|
1015
|
+
* never be written out of agreement. The document is validated before it is
|
|
1016
|
+
* written: a generator that emits something the runners would refuse to load
|
|
1017
|
+
* should fail at the generator.
|
|
1018
|
+
*
|
|
1019
|
+
* The output is plain two-space JSON. Only the values mean anything to either
|
|
1020
|
+
* runner, so a repo that formats JSON should run its formatter afterwards.
|
|
1021
|
+
*
|
|
1022
|
+
* ```ts
|
|
1023
|
+
* import { rngFixtureCase, writeRngFixture } from "@eigeninteractive/testkit";
|
|
1024
|
+
*
|
|
1025
|
+
* writeRngFixture("src/module/fixtures/v1/rng.json", [
|
|
1026
|
+
* rngFixtureCase({ name: "version 0", seed: "twin-fixtures", version: 0, count: 16 }),
|
|
1027
|
+
* rngFixtureCase({ name: "seat 1's bot stream", seed: "twin-fixtures", version: 3, seat: 1, count: 16 }),
|
|
1028
|
+
* ]);
|
|
1029
|
+
* ```
|
|
1030
|
+
*/
|
|
1031
|
+
export function writeRngFixture(path: string, cases: RngCase[]): void {
|
|
1032
|
+
const match = /(?:^|\/)v([1-9]\d*)\/[^/]+$/.exec(path.split("\\").join("/"));
|
|
1033
|
+
if (match === null) {
|
|
1034
|
+
throw new Error(`${path}: an rng fixture must be written into a v<N>/ directory, which names the schemaVersion it targets`);
|
|
1035
|
+
}
|
|
1036
|
+
const document = { schemaVersion: Number(match[1]), cases };
|
|
1037
|
+
parseTwinFixtureFile(path, document);
|
|
1038
|
+
writeFileSync(path, `${JSON.stringify(document, null, 2)}\n`);
|
|
1039
|
+
}
|
|
1040
|
+
|
|
480
1041
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
481
1042
|
|
|
482
1043
|
/** Run one of the unit's Standard Schemas synchronously, returning the parsed
|