@eigeninteractive/testkit 0.5.1 → 0.6.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/dist/contract-cli.d.ts +1 -2
- package/dist/contract-cli.js +21 -22
- package/dist/contract-cli.js.map +1 -1
- package/dist/contract-command-aCqO5CyJ.js +542 -0
- package/dist/contract-command-aCqO5CyJ.js.map +1 -0
- package/dist/index.d.ts +159 -8
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +33 -55
- package/dist/index.js.map +1 -1
- package/dist/inspect-cli.d.ts +1 -16
- package/dist/inspect-cli.js +169 -153
- package/dist/inspect-cli.js.map +1 -1
- package/dist/local-state-CnO-Oi-R.js +467 -0
- package/dist/local-state-CnO-Oi-R.js.map +1 -0
- package/dist/local-state.d.ts +188 -224
- package/dist/local-state.d.ts.map +1 -0
- package/dist/local-state.js +2 -11
- package/package.json +10 -9
- package/src/contract-cli.ts +29 -0
- package/src/contract-command.ts +78 -0
- package/src/game-contract.ts +205 -0
- package/src/index.ts +53 -0
- package/src/inspect-cli.ts +231 -0
- package/{dist/kernel-scenarios.d.ts → src/kernel-scenarios.ts} +41 -5
- package/src/local-state.ts +638 -0
- package/src/twin-fixtures.ts +533 -0
- package/dist/chunk-BQIY636F.js +0 -483
- package/dist/chunk-BQIY636F.js.map +0 -1
- package/dist/chunk-YJE4AGHJ.js +0 -391
- package/dist/chunk-YJE4AGHJ.js.map +0 -1
- package/dist/contract-command.d.ts +0 -13
- package/dist/game-contract.d.ts +0 -58
- package/dist/local-state.js.map +0 -1
- package/dist/twin-fixtures.d.ts +0 -145
|
@@ -0,0 +1,533 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Twin-drift fixture runner: the TS half of the shared JSON fixtures that
|
|
3
|
+
* keep a version unit's TS and Dart `GameRules` twins in sync. The JSON
|
|
4
|
+
* fixture format is shared verbatim with the Dart runner
|
|
5
|
+
* (`lib/testing/twin_fixtures.dart`): one file, two consumers.
|
|
6
|
+
*
|
|
7
|
+
* One fixture file per concern lives beside the version units at
|
|
8
|
+
* `<fixturesRoot>/v<N>/*.json` and is consumed by BOTH sides: this module
|
|
9
|
+
* runs each case against the TS unit (schemas + `applyAction` +
|
|
10
|
+
* `computeObservation` + the two predicates), while the Dart runner runs the
|
|
11
|
+
* same file against the Dart twin (generated payload parsing,
|
|
12
|
+
* `isValidAction`, `previewAction`, predicate twins). A behavioral divergence
|
|
13
|
+
* then fails one side's CI instead of degrading UX in production.
|
|
14
|
+
*
|
|
15
|
+
* ## Fixture file format
|
|
16
|
+
*
|
|
17
|
+
* ```jsonc
|
|
18
|
+
* {
|
|
19
|
+
* "schemaVersion": 1,
|
|
20
|
+
* "cases": [
|
|
21
|
+
* {
|
|
22
|
+
* "kind": "action",
|
|
23
|
+
* "name": "seat 0 marks an empty cell",
|
|
24
|
+
* "config": {},
|
|
25
|
+
* "state": { ... }, // TS: applyAction input
|
|
26
|
+
* "obs": { ... }, // Dart-only; defaults to `state`
|
|
27
|
+
* "pending": [0],
|
|
28
|
+
* "playerIndex": 0,
|
|
29
|
+
* "participantCount": 2, // optional, default 2
|
|
30
|
+
* "rngSeed": "any string", // optional, default "twin-fixtures"
|
|
31
|
+
* "action": { ... },
|
|
32
|
+
* "expected": {
|
|
33
|
+
* "valid": true, // false ⇒ TS IllegalMoveError,
|
|
34
|
+
* // Dart isValidAction false
|
|
35
|
+
* "state": { ... }, // optional, TS envelope.state
|
|
36
|
+
* "pending": [1], // optional, TS envelope.pendingPlayers
|
|
37
|
+
* "outcome": [ ... ], // optional, TS envelope.outcome
|
|
38
|
+
* // (null asserts the game is ongoing)
|
|
39
|
+
* "observation": { ... } // optional, the actor's post-action
|
|
40
|
+
* // view: TS computeObservation slice
|
|
41
|
+
* // data; Dart previewAction (when the
|
|
42
|
+
* // game implements optimism)
|
|
43
|
+
* }
|
|
44
|
+
* },
|
|
45
|
+
* { "kind": "playerLimits", "name": "...", "config": {},
|
|
46
|
+
* "expected": { "minPlayers": 2, "maxPlayers": 2 } },
|
|
47
|
+
* { "kind": "ratingPool", "name": "...", "access": "public",
|
|
48
|
+
* "minPlayers": 2, "maxPlayers": 2, "config": {}, "expected": "blitz" },
|
|
49
|
+
* { "kind": "botSeatable", "name": "...", "gameConfig": {},
|
|
50
|
+
* "botConfig": {}, "expected": false }
|
|
51
|
+
* ]
|
|
52
|
+
* }
|
|
53
|
+
* ```
|
|
54
|
+
*
|
|
55
|
+
* The `state`/`obs` split exists for hidden-info games (a seat's observation
|
|
56
|
+
* is not the state); perfect-info games omit `obs`. `expected.observation` is
|
|
57
|
+
* the shared behavioral anchor: the TS side must project the post-action
|
|
58
|
+
* state to it, and a Dart `previewAction` that returns non-null must predict
|
|
59
|
+
* it, so the two sides are compared through one recorded value.
|
|
60
|
+
*
|
|
61
|
+
* Wire it up in a game-owned test file running under plain-Node vitest:
|
|
62
|
+
*
|
|
63
|
+
* ```ts
|
|
64
|
+
* import { twinFixtureTests } from "@eigeninteractive/testkit";
|
|
65
|
+
* import gameModule from "../src/module/index.js";
|
|
66
|
+
*
|
|
67
|
+
* twinFixtureTests(gameModule, new URL("../src/module/fixtures/", import.meta.url));
|
|
68
|
+
* ```
|
|
69
|
+
*/
|
|
70
|
+
|
|
71
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
72
|
+
import { type Envelope, type GameAccess, type GameModule, type GameRules, IllegalMoveError, type Json, type JsonObject, type ObservationSlice, type OutcomeEntry } from "@eigeninteractive/rules";
|
|
73
|
+
import Rand from "rand-seed";
|
|
74
|
+
import { it } from "vitest";
|
|
75
|
+
|
|
76
|
+
/** One fixture file: cases targeting one `schemaVersion` unit. */
|
|
77
|
+
export interface TwinFixtureFile {
|
|
78
|
+
schemaVersion: number;
|
|
79
|
+
cases: TwinFixtureCase[];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** A game-action case: exercises schemas, `applyAction`, and (through
|
|
83
|
+
* `expected.observation`) `computeObservation` for the acting seat. */
|
|
84
|
+
export interface ActionCase {
|
|
85
|
+
kind: "action";
|
|
86
|
+
name: string;
|
|
87
|
+
config: JsonObject;
|
|
88
|
+
state: JsonObject;
|
|
89
|
+
/** Dart-side observation payload; unused here (defaults to `state`). */
|
|
90
|
+
obs?: JsonObject;
|
|
91
|
+
pending: number[];
|
|
92
|
+
playerIndex: number;
|
|
93
|
+
participantCount?: number;
|
|
94
|
+
rngSeed?: string;
|
|
95
|
+
action: JsonObject;
|
|
96
|
+
expected: {
|
|
97
|
+
valid: boolean;
|
|
98
|
+
state?: JsonObject;
|
|
99
|
+
pending?: number[];
|
|
100
|
+
outcome?: OutcomeEntry[] | null;
|
|
101
|
+
observation?: JsonObject;
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** A `ratingPool` predicate case. Omitted timing fields mean null. */
|
|
106
|
+
export interface RatingPoolCase {
|
|
107
|
+
kind: "ratingPool";
|
|
108
|
+
name: string;
|
|
109
|
+
access: GameAccess;
|
|
110
|
+
turnSeconds?: number | null;
|
|
111
|
+
budgetSeconds?: number | null;
|
|
112
|
+
incrementSeconds?: number | null;
|
|
113
|
+
minPlayers: number;
|
|
114
|
+
maxPlayers: number;
|
|
115
|
+
config: JsonObject;
|
|
116
|
+
expected: string | null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** A `playerLimits` case: the seats one config may be played with. */
|
|
120
|
+
export interface PlayerLimitsCase {
|
|
121
|
+
kind: "playerLimits";
|
|
122
|
+
name: string;
|
|
123
|
+
config: JsonObject;
|
|
124
|
+
expected: { minPlayers: number; maxPlayers: number };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** A `botSeatable` predicate case. */
|
|
128
|
+
export interface BotSeatableCase {
|
|
129
|
+
kind: "botSeatable";
|
|
130
|
+
name: string;
|
|
131
|
+
gameConfig: JsonObject;
|
|
132
|
+
botConfig: JsonObject;
|
|
133
|
+
expected: boolean;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export type TwinFixtureCase = ActionCase | PlayerLimitsCase | RatingPoolCase | BotSeatableCase;
|
|
137
|
+
|
|
138
|
+
// ── Fixture validation ────────────────────────────────────────────────────────
|
|
139
|
+
//
|
|
140
|
+
// The case types above are compile-time only; the JSON they describe arrives
|
|
141
|
+
// at runtime from a hand-written file. Asserting `JSON.parse(...) as
|
|
142
|
+
// TwinFixtureFile` would make every field a lie the moment a fixture is
|
|
143
|
+
// mistyped, and because most fields flow straight into a comparison, the
|
|
144
|
+
// symptom would be a confusing `undefined` diff attributed to the game's
|
|
145
|
+
// rules rather than to the fixture. These parsers close that gap: a
|
|
146
|
+
// malformed fixture fails at LOAD, naming the file, the case, and the field.
|
|
147
|
+
//
|
|
148
|
+
// Hand-written rather than schema-library-backed on purpose: `@eigeninteractive/rules`
|
|
149
|
+
// describes an implementor's schemas with `StandardSchemaV1` precisely so the
|
|
150
|
+
// engine never mandates a validation library, and the Dart twin runner is
|
|
151
|
+
// deliberately framework-free. This is ~80 lines and keeps both true.
|
|
152
|
+
|
|
153
|
+
function describe(value: unknown): string {
|
|
154
|
+
if (value === null) return "null";
|
|
155
|
+
if (Array.isArray(value)) return "an array";
|
|
156
|
+
return typeof value;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function fail(where: string, expected: string, got: unknown): never {
|
|
160
|
+
throw new Error(`${where}: expected ${expected}, got ${describe(got)}`);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function asObject(where: string, v: unknown): JsonObject {
|
|
164
|
+
if (typeof v !== "object" || v === null || Array.isArray(v)) fail(where, "an object", v);
|
|
165
|
+
return v as JsonObject;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function asString(where: string, v: unknown): string {
|
|
169
|
+
if (typeof v !== "string") fail(where, "a string", v);
|
|
170
|
+
return v;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function asNumber(where: string, v: unknown): number {
|
|
174
|
+
if (typeof v !== "number" || !Number.isFinite(v)) fail(where, "a finite number", v);
|
|
175
|
+
return v;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function asBoolean(where: string, v: unknown): boolean {
|
|
179
|
+
if (typeof v !== "boolean") fail(where, "a boolean", v);
|
|
180
|
+
return v;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function asNumberArray(where: string, v: unknown): number[] {
|
|
184
|
+
if (!Array.isArray(v)) fail(where, "an array of numbers", v);
|
|
185
|
+
return v.map((n, i) => asNumber(`${where}[${i}]`, n));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Applies `read` only when the key is present and non-null; absent and
|
|
189
|
+
* explicit null both mean "not specified" for every optional fixture field. */
|
|
190
|
+
function optional<T>(where: string, v: unknown, read: (where: string, v: unknown) => T): T | undefined {
|
|
191
|
+
return v === undefined || v === null ? undefined : read(where, v);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function parseActionCase(where: string, raw: JsonObject): ActionCase {
|
|
195
|
+
const expectedRaw = asObject(`${where}.expected`, raw.expected);
|
|
196
|
+
const expected: ActionCase["expected"] = {
|
|
197
|
+
valid: asBoolean(`${where}.expected.valid`, expectedRaw.valid),
|
|
198
|
+
state: optional(`${where}.expected.state`, expectedRaw.state, asObject),
|
|
199
|
+
pending: optional(`${where}.expected.pending`, expectedRaw.pending, asNumberArray),
|
|
200
|
+
observation: optional(`${where}.expected.observation`, expectedRaw.observation, asObject),
|
|
201
|
+
};
|
|
202
|
+
// `outcome` is three-valued: absent (unchecked), null (asserts the game is
|
|
203
|
+
// ongoing), or a list. `checkEnvelope` distinguishes absent from null with
|
|
204
|
+
// an `in` test, so the key must only be set when the fixture set it.
|
|
205
|
+
if ("outcome" in expectedRaw) {
|
|
206
|
+
const outcome = expectedRaw.outcome;
|
|
207
|
+
if (outcome !== null && !Array.isArray(outcome)) fail(`${where}.expected.outcome`, "an array or null", outcome);
|
|
208
|
+
expected.outcome = outcome as OutcomeEntry[] | null;
|
|
209
|
+
}
|
|
210
|
+
return {
|
|
211
|
+
kind: "action",
|
|
212
|
+
name: asString(`${where}.name`, raw.name),
|
|
213
|
+
config: asObject(`${where}.config`, raw.config),
|
|
214
|
+
state: asObject(`${where}.state`, raw.state),
|
|
215
|
+
obs: optional(`${where}.obs`, raw.obs, asObject),
|
|
216
|
+
pending: asNumberArray(`${where}.pending`, raw.pending),
|
|
217
|
+
playerIndex: asNumber(`${where}.playerIndex`, raw.playerIndex),
|
|
218
|
+
participantCount: optional(`${where}.participantCount`, raw.participantCount, asNumber),
|
|
219
|
+
rngSeed: optional(`${where}.rngSeed`, raw.rngSeed, asString),
|
|
220
|
+
action: asObject(`${where}.action`, raw.action),
|
|
221
|
+
expected,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function parseRatingPoolCase(where: string, raw: JsonObject): RatingPoolCase {
|
|
226
|
+
const access = asString(`${where}.access`, raw.access);
|
|
227
|
+
if (access !== "public" && access !== "private" && access !== "friends") {
|
|
228
|
+
throw new Error(`${where}.access: expected one of public | private | friends, got ${JSON.stringify(access)}`);
|
|
229
|
+
}
|
|
230
|
+
return {
|
|
231
|
+
kind: "ratingPool",
|
|
232
|
+
name: asString(`${where}.name`, raw.name),
|
|
233
|
+
access,
|
|
234
|
+
turnSeconds: optional(`${where}.turnSeconds`, raw.turnSeconds, asNumber) ?? null,
|
|
235
|
+
budgetSeconds: optional(`${where}.budgetSeconds`, raw.budgetSeconds, asNumber) ?? null,
|
|
236
|
+
incrementSeconds: optional(`${where}.incrementSeconds`, raw.incrementSeconds, asNumber) ?? null,
|
|
237
|
+
minPlayers: asNumber(`${where}.minPlayers`, raw.minPlayers),
|
|
238
|
+
maxPlayers: asNumber(`${where}.maxPlayers`, raw.maxPlayers),
|
|
239
|
+
config: asObject(`${where}.config`, raw.config),
|
|
240
|
+
expected: raw.expected === null ? null : asString(`${where}.expected`, raw.expected),
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function parsePlayerLimitsCase(where: string, raw: JsonObject): PlayerLimitsCase {
|
|
245
|
+
const expected = asObject(`${where}.expected`, raw.expected);
|
|
246
|
+
return {
|
|
247
|
+
kind: "playerLimits",
|
|
248
|
+
name: asString(`${where}.name`, raw.name),
|
|
249
|
+
config: asObject(`${where}.config`, raw.config),
|
|
250
|
+
expected: {
|
|
251
|
+
minPlayers: asNumber(`${where}.expected.minPlayers`, expected.minPlayers),
|
|
252
|
+
maxPlayers: asNumber(`${where}.expected.maxPlayers`, expected.maxPlayers),
|
|
253
|
+
},
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function parseBotSeatableCase(where: string, raw: JsonObject): BotSeatableCase {
|
|
258
|
+
return {
|
|
259
|
+
kind: "botSeatable",
|
|
260
|
+
name: asString(`${where}.name`, raw.name),
|
|
261
|
+
gameConfig: asObject(`${where}.gameConfig`, raw.gameConfig),
|
|
262
|
+
botConfig: asObject(`${where}.botConfig`, raw.botConfig),
|
|
263
|
+
expected: asBoolean(`${where}.expected`, raw.expected),
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Validate one fixture file's parsed JSON, or throw naming the offending
|
|
268
|
+
* file, case, and field. Exported so a repo can lint its fixtures without
|
|
269
|
+
* running them. */
|
|
270
|
+
export function parseTwinFixtureFile(path: string, json: unknown): TwinFixtureFile {
|
|
271
|
+
const root = asObject(path, json);
|
|
272
|
+
const schemaVersion = asNumber(`${path}.schemaVersion`, root.schemaVersion);
|
|
273
|
+
if (!Array.isArray(root.cases)) fail(`${path}.cases`, "an array", root.cases);
|
|
274
|
+
const cases = root.cases.map((raw, i) => {
|
|
275
|
+
// Prefer the case's own name in the location once we can read it: a
|
|
276
|
+
// fixture author finds "cases[3] (seat 0 wins)" faster than an index.
|
|
277
|
+
const indexed = `${path}.cases[${i}]`;
|
|
278
|
+
const obj = asObject(indexed, raw);
|
|
279
|
+
const where = typeof obj.name === "string" ? `${indexed} (${obj.name})` : indexed;
|
|
280
|
+
switch (obj.kind) {
|
|
281
|
+
case "action":
|
|
282
|
+
return parseActionCase(where, obj);
|
|
283
|
+
case "playerLimits":
|
|
284
|
+
return parsePlayerLimitsCase(where, obj);
|
|
285
|
+
case "ratingPool":
|
|
286
|
+
return parseRatingPoolCase(where, obj);
|
|
287
|
+
case "botSeatable":
|
|
288
|
+
return parseBotSeatableCase(where, obj);
|
|
289
|
+
default:
|
|
290
|
+
throw new Error(`${where}.kind: expected one of action | playerLimits | ratingPool | botSeatable, got ${JSON.stringify(obj.kind)}`);
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
return { schemaVersion, cases };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Run one fixture case against a rules unit, returning failure descriptions
|
|
297
|
+
* (empty ⇒ the case passes). Pure; the file-reading test registrar is
|
|
298
|
+
* {@link twinFixtureTests}. */
|
|
299
|
+
export function evaluateTwinCase(rules: GameRules, kase: TwinFixtureCase): string[] {
|
|
300
|
+
switch (kase.kind) {
|
|
301
|
+
case "action":
|
|
302
|
+
return evaluateAction(rules, kase);
|
|
303
|
+
case "playerLimits":
|
|
304
|
+
return evaluatePlayerLimits(rules, kase);
|
|
305
|
+
case "ratingPool":
|
|
306
|
+
return evaluateRatingPool(rules, kase);
|
|
307
|
+
case "botSeatable":
|
|
308
|
+
return evaluateBotSeatable(rules, kase);
|
|
309
|
+
default:
|
|
310
|
+
return [`unknown case kind "${(kase as { kind: string }).kind}", expected action | playerLimits | ratingPool | botSeatable`];
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** Register one vitest test per fixture case found under `fixturesRoot`
|
|
315
|
+
* (layout: `<root>/v<N>/*.json`). Call at the top level of a test module
|
|
316
|
+
* running in a Node environment. */
|
|
317
|
+
export function twinFixtureTests(gameModule: GameModule, fixturesRoot: string | URL): void {
|
|
318
|
+
for (const filePath of fixtureFiles(fixturesRoot)) {
|
|
319
|
+
const fixture = parseTwinFixtureFile(filePath, JSON.parse(readFileSync(filePath, "utf8")));
|
|
320
|
+
const rules = gameModule.versions[fixture.schemaVersion];
|
|
321
|
+
for (const kase of fixture.cases) {
|
|
322
|
+
it(`twin v${fixture.schemaVersion}: ${kase.name}`, () => {
|
|
323
|
+
if (!rules) {
|
|
324
|
+
throw new Error(`gameModule ships no rules unit for schemaVersion ${fixture.schemaVersion} (fixture: ${filePath})`);
|
|
325
|
+
}
|
|
326
|
+
const failures = evaluateTwinCase(rules, kase);
|
|
327
|
+
if (failures.length) throw new Error(`\n${failures.join("\n")}`);
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function* fixtureFiles(root: string | URL): Generator<string> {
|
|
334
|
+
const dirs = readdirSync(root, { withFileTypes: true })
|
|
335
|
+
.filter((e) => e.isDirectory())
|
|
336
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
337
|
+
for (const dir of dirs) {
|
|
338
|
+
const files = readdirSync(`${dir.parentPath}/${dir.name}`, {
|
|
339
|
+
withFileTypes: true,
|
|
340
|
+
})
|
|
341
|
+
.filter((e) => e.isFile() && e.name.endsWith(".json"))
|
|
342
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
343
|
+
for (const file of files) yield `${file.parentPath}/${file.name}`;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// ── Case evaluators ───────────────────────────────────────────────────────────
|
|
348
|
+
|
|
349
|
+
function evaluateAction(rules: GameRules, kase: ActionCase): string[] {
|
|
350
|
+
const failures: string[] = [];
|
|
351
|
+
const config = parseWith(rules, "config", kase.config, failures);
|
|
352
|
+
const state = parseWith(rules, "state", kase.state, failures);
|
|
353
|
+
const action = parseWith(rules, "action", kase.action, failures);
|
|
354
|
+
if (config === undefined || state === undefined || action === undefined) {
|
|
355
|
+
return failures;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// The parsed action must be the fixture action: a schema that strips or
|
|
359
|
+
// defaults fields the twin relies on is itself drift.
|
|
360
|
+
if (!deepEquals(action, kase.action)) {
|
|
361
|
+
failures.push(`action schema does not preserve the fixture action, parsed to ${JSON.stringify(action)}`);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const envelope = applyFixtureAction(rules, kase, config, state, action);
|
|
365
|
+
if (typeof envelope === "string") {
|
|
366
|
+
if (envelope) failures.push(envelope);
|
|
367
|
+
return failures;
|
|
368
|
+
}
|
|
369
|
+
checkEnvelope(rules, kase, envelope, failures);
|
|
370
|
+
if (kase.expected.observation !== undefined) {
|
|
371
|
+
checkObservation(rules, kase, envelope, config, action, failures);
|
|
372
|
+
}
|
|
373
|
+
return failures;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** Invoke `applyAction` and reconcile with `expected.valid`. Returns the
|
|
377
|
+
* envelope on a valid accepted move, an error string on failure, or "" when
|
|
378
|
+
* an expected-illegal move was correctly rejected (nothing left to check). */
|
|
379
|
+
function applyFixtureAction(rules: GameRules, kase: ActionCase, config: JsonObject, state: JsonObject, action: JsonObject): Envelope | string {
|
|
380
|
+
let envelope: Envelope;
|
|
381
|
+
try {
|
|
382
|
+
envelope = rules.applyAction({
|
|
383
|
+
state,
|
|
384
|
+
pending: kase.pending,
|
|
385
|
+
data: action,
|
|
386
|
+
playerIndex: kase.playerIndex,
|
|
387
|
+
rng: new Rand(kase.rngSeed ?? "twin-fixtures"),
|
|
388
|
+
config,
|
|
389
|
+
});
|
|
390
|
+
} catch (error) {
|
|
391
|
+
if (!(error instanceof IllegalMoveError)) {
|
|
392
|
+
return `applyAction threw a non-IllegalMoveError: ${error}`;
|
|
393
|
+
}
|
|
394
|
+
return kase.expected.valid ? `applyAction rejected a move the fixture expects to be valid: ${error.message}` : "";
|
|
395
|
+
}
|
|
396
|
+
return kase.expected.valid ? envelope : "applyAction accepted a move the fixture expects to be illegal";
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function checkEnvelope(rules: GameRules, kase: ActionCase, envelope: Envelope, failures: string[]): void {
|
|
400
|
+
if (validate(rules, "state", envelope.state) === undefined) {
|
|
401
|
+
failures.push("applyAction returned state that violates its own schema");
|
|
402
|
+
}
|
|
403
|
+
const expected = kase.expected;
|
|
404
|
+
if (expected.state !== undefined && !deepEquals(envelope.state, expected.state)) {
|
|
405
|
+
failures.push(`envelope.state mismatch, got ${JSON.stringify(envelope.state)}`);
|
|
406
|
+
}
|
|
407
|
+
if (expected.pending !== undefined && !deepEquals(envelope.pendingPlayers, expected.pending)) {
|
|
408
|
+
failures.push(`envelope.pendingPlayers mismatch, got ${JSON.stringify(envelope.pendingPlayers)}`);
|
|
409
|
+
}
|
|
410
|
+
if ("outcome" in expected && !deepEquals(envelope.outcome ?? null, expected.outcome ?? null)) {
|
|
411
|
+
failures.push(`envelope.outcome mismatch, got ${JSON.stringify(envelope.outcome ?? null)}`);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function checkObservation(rules: GameRules, kase: ActionCase, envelope: Envelope, config: JsonObject, action: JsonObject, failures: string[]): void {
|
|
416
|
+
let slice: ObservationSlice;
|
|
417
|
+
try {
|
|
418
|
+
slice = rules.computeObservation({
|
|
419
|
+
state: envelope.state,
|
|
420
|
+
pending: envelope.pendingPlayers,
|
|
421
|
+
playerIndex: kase.playerIndex,
|
|
422
|
+
participantCount: kase.participantCount ?? 2,
|
|
423
|
+
config,
|
|
424
|
+
cause: { kind: "game", data: action, playerIndex: kase.playerIndex },
|
|
425
|
+
isReplay: false,
|
|
426
|
+
});
|
|
427
|
+
} catch (error) {
|
|
428
|
+
failures.push(`computeObservation threw: ${error}`);
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
if (!deepEquals(slice.data, kase.expected.observation as Json)) {
|
|
432
|
+
failures.push(`actor's observation mismatch, got ${JSON.stringify(slice.data)}`);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function evaluateRatingPool(rules: GameRules, kase: RatingPoolCase): string[] {
|
|
437
|
+
const failures: string[] = [];
|
|
438
|
+
const config = parseWith(rules, "config", kase.config, failures);
|
|
439
|
+
if (config === undefined) return failures;
|
|
440
|
+
const pool = rules.ratingPool({
|
|
441
|
+
access: kase.access,
|
|
442
|
+
turnSeconds: kase.turnSeconds ?? null,
|
|
443
|
+
budgetSeconds: kase.budgetSeconds ?? null,
|
|
444
|
+
incrementSeconds: kase.incrementSeconds ?? null,
|
|
445
|
+
minPlayers: kase.minPlayers,
|
|
446
|
+
maxPlayers: kase.maxPlayers,
|
|
447
|
+
config,
|
|
448
|
+
});
|
|
449
|
+
if (pool !== kase.expected) {
|
|
450
|
+
failures.push(`ratingPool returned ${JSON.stringify(pool)}, fixture expects ${JSON.stringify(kase.expected)}`);
|
|
451
|
+
}
|
|
452
|
+
return failures;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function evaluatePlayerLimits(rules: GameRules, kase: PlayerLimitsCase): string[] {
|
|
456
|
+
const failures: string[] = [];
|
|
457
|
+
const config = parseWith(rules, "config", kase.config, failures);
|
|
458
|
+
if (config === undefined) return failures;
|
|
459
|
+
const limits = rules.playerLimits({ config });
|
|
460
|
+
if (limits.minPlayers !== kase.expected.minPlayers || limits.maxPlayers !== kase.expected.maxPlayers) {
|
|
461
|
+
failures.push(`playerLimits returned ${limits.minPlayers}-${limits.maxPlayers}, fixture expects ${kase.expected.minPlayers}-${kase.expected.maxPlayers}`);
|
|
462
|
+
}
|
|
463
|
+
return failures;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function evaluateBotSeatable(rules: GameRules, kase: BotSeatableCase): string[] {
|
|
467
|
+
const failures: string[] = [];
|
|
468
|
+
const gameConfig = parseWith(rules, "config", kase.gameConfig, failures);
|
|
469
|
+
if (gameConfig === undefined) return failures;
|
|
470
|
+
const seatable = rules.botSeatable({
|
|
471
|
+
gameConfig,
|
|
472
|
+
botConfig: kase.botConfig,
|
|
473
|
+
});
|
|
474
|
+
if (seatable !== kase.expected) {
|
|
475
|
+
failures.push(`botSeatable returned ${seatable}, fixture expects ${kase.expected}`);
|
|
476
|
+
}
|
|
477
|
+
return failures;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
481
|
+
|
|
482
|
+
/** Run one of the unit's Standard Schemas synchronously, returning the parsed
|
|
483
|
+
* value or undefined on failure (issues written to the second return slot). */
|
|
484
|
+
function validate(rules: GameRules, which: "config" | "state" | "action", value: unknown, issues?: string[]): JsonObject | undefined {
|
|
485
|
+
const result = rules.schemas[which]["~standard"].validate(value);
|
|
486
|
+
if (result instanceof Promise) {
|
|
487
|
+
issues?.push(`${which} schema validated asynchronously; must be sync`);
|
|
488
|
+
return undefined;
|
|
489
|
+
}
|
|
490
|
+
if (result.issues) {
|
|
491
|
+
issues?.push(
|
|
492
|
+
result.issues
|
|
493
|
+
.map((i) => {
|
|
494
|
+
const path = i.path?.map((p) => (typeof p === "object" ? String(p.key) : String(p))).join(".");
|
|
495
|
+
return path ? `${path}: ${i.message}` : i.message;
|
|
496
|
+
})
|
|
497
|
+
.join("; "),
|
|
498
|
+
);
|
|
499
|
+
return undefined;
|
|
500
|
+
}
|
|
501
|
+
return result.value as JsonObject;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/** Parse a fixture payload through one of the unit's schemas, recording a
|
|
505
|
+
* failure (and returning undefined) when it does not conform. */
|
|
506
|
+
function parseWith(rules: GameRules, which: "config" | "state" | "action", value: JsonObject, failures: string[]): JsonObject | undefined {
|
|
507
|
+
const issues: string[] = [];
|
|
508
|
+
const parsed = validate(rules, which, value, issues);
|
|
509
|
+
if (parsed === undefined) {
|
|
510
|
+
failures.push(`fixture ${which} fails the TS ${which} schema: ${issues.join("; ")}`);
|
|
511
|
+
}
|
|
512
|
+
return parsed;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/** Structural JSON equality. Object keys with `undefined` values count as
|
|
516
|
+
* absent (matching how schema libraries model optional fields); array order
|
|
517
|
+
* matters. */
|
|
518
|
+
export function deepEquals(a: Json | undefined, b: Json | undefined): boolean {
|
|
519
|
+
if (a === undefined || a === null) return b === undefined || b === null;
|
|
520
|
+
if (b === undefined || b === null) return false;
|
|
521
|
+
if (Array.isArray(a) || Array.isArray(b)) {
|
|
522
|
+
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) {
|
|
523
|
+
return false;
|
|
524
|
+
}
|
|
525
|
+
return a.every((item, i) => deepEquals(item, b[i]));
|
|
526
|
+
}
|
|
527
|
+
if (typeof a === "object" || typeof b === "object") {
|
|
528
|
+
if (typeof a !== "object" || typeof b !== "object") return false;
|
|
529
|
+
const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
|
|
530
|
+
return [...keys].every((k) => deepEquals((a as JsonObject)[k], (b as JsonObject)[k]));
|
|
531
|
+
}
|
|
532
|
+
return a === b;
|
|
533
|
+
}
|