@eigeninteractive/testkit 0.6.1 → 0.8.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 +122 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/package.json +5 -5
- package/src/index.ts +9 -0
- package/src/twin-fixtures.ts +592 -31
- package/dist/contract-command-aCqO5CyJ.js.map +0 -1
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.
|
package/dist/contract-cli.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { n as emitConfiguredGameContract, t as checkConfiguredGameContract } from "./contract-command-
|
|
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 +
|
|
18
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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,
|
|
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-
|
|
978
|
+
//# sourceMappingURL=contract-command-Dn5ANpqD.js.map
|