@eigeninteractive/testkit 0.5.2 → 0.6.1

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.
@@ -1,2 +1 @@
1
- #!/usr/bin/env node
2
- export {};
1
+ export {}
@@ -1,34 +1,33 @@
1
1
  #!/usr/bin/env node
2
- import {
3
- checkConfiguredGameContract,
4
- emitConfiguredGameContract
5
- } from "./chunk-BQIY636F.js";
6
-
7
- // src/contract-cli.ts
2
+ import { n as emitConfiguredGameContract, t as checkConfiguredGameContract } from "./contract-command-aCqO5CyJ.js";
8
3
  import { parseArgs } from "node:util";
4
+ //#region src/contract-cli.ts
9
5
  try {
10
- const { values } = parseArgs({
11
- strict: true,
12
- options: {
13
- check: { type: "boolean" },
14
- help: { type: "boolean", short: "h" }
15
- }
16
- });
17
- if (values.help) {
18
- console.log(`Usage: eigen-contract [options]
6
+ const { values } = parseArgs({
7
+ strict: true,
8
+ options: {
9
+ check: { type: "boolean" },
10
+ help: {
11
+ type: "boolean",
12
+ short: "h"
13
+ }
14
+ }
15
+ });
16
+ if (values.help) console.log(`Usage: eigen-contract [options]
19
17
 
20
18
  Emit game-contract.json from the configured game module and fixtures.
21
19
 
22
20
  Options:
23
21
  --check Fail when the committed contract is stale
24
22
  -h, --help Show this help`);
25
- } else {
26
- if (values.check) await checkConfiguredGameContract();
27
- else await emitConfiguredGameContract();
28
- }
23
+ else if (values.check) await checkConfiguredGameContract();
24
+ else await emitConfiguredGameContract();
29
25
  } catch (error) {
30
- const message = error instanceof Error ? error.message : String(error);
31
- console.error(`eigen-contract: ${message}`);
32
- process.exitCode = 1;
26
+ const message = error instanceof Error ? error.message : String(error);
27
+ console.error(`eigen-contract: ${message}`);
28
+ process.exitCode = 1;
33
29
  }
30
+ //#endregion
31
+ export {};
32
+
34
33
  //# sourceMappingURL=contract-cli.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/contract-cli.ts"],"sourcesContent":["#!/usr/bin/env node\nimport { parseArgs } from \"node:util\";\nimport { checkConfiguredGameContract, emitConfiguredGameContract } from \"./contract-command.js\";\n\ntry {\n const { values } = parseArgs({\n strict: true,\n options: {\n check: { type: \"boolean\" },\n help: { type: \"boolean\", short: \"h\" },\n },\n });\n if (values.help) {\n console.log(`Usage: eigen-contract [options]\n\nEmit game-contract.json from the configured game module and fixtures.\n\nOptions:\n --check Fail when the committed contract is stale\n -h, --help Show this help`);\n } else {\n if (values.check) await checkConfiguredGameContract();\n else await emitConfiguredGameContract();\n }\n} catch (error) {\n const message = error instanceof Error ? error.message : String(error);\n console.error(`eigen-contract: ${message}`);\n process.exitCode = 1;\n}\n"],"mappings":";;;;;;;AACA,SAAS,iBAAiB;AAG1B,IAAI;AACF,QAAM,EAAE,OAAO,IAAI,UAAU;AAAA,IAC3B,QAAQ;AAAA,IACR,SAAS;AAAA,MACP,OAAO,EAAE,MAAM,UAAU;AAAA,MACzB,MAAM,EAAE,MAAM,WAAW,OAAO,IAAI;AAAA,IACtC;AAAA,EACF,CAAC;AACD,MAAI,OAAO,MAAM;AACf,YAAQ,IAAI;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,8BAMc;AAAA,EAC5B,OAAO;AACL,QAAI,OAAO,MAAO,OAAM,4BAA4B;AAAA,QAC/C,OAAM,2BAA2B;AAAA,EACxC;AACF,SAAS,OAAO;AACd,QAAM,UAAU,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK;AACrE,UAAQ,MAAM,mBAAmB,OAAO,EAAE;AAC1C,UAAQ,WAAW;AACrB;","names":[]}
1
+ {"version":3,"file":"contract-cli.js","names":[],"sources":["../src/contract-cli.ts"],"sourcesContent":["#!/usr/bin/env node\nimport { parseArgs } from \"node:util\";\nimport { checkConfiguredGameContract, emitConfiguredGameContract } from \"./contract-command.js\";\n\ntry {\n const { values } = parseArgs({\n strict: true,\n options: {\n check: { type: \"boolean\" },\n help: { type: \"boolean\", short: \"h\" },\n },\n });\n if (values.help) {\n console.log(`Usage: eigen-contract [options]\n\nEmit game-contract.json from the configured game module and fixtures.\n\nOptions:\n --check Fail when the committed contract is stale\n -h, --help Show this help`);\n } else {\n if (values.check) await checkConfiguredGameContract();\n else await emitConfiguredGameContract();\n }\n} catch (error) {\n const message = error instanceof Error ? error.message : String(error);\n console.error(`eigen-contract: ${message}`);\n process.exitCode = 1;\n}\n"],"mappings":";;;;AAIA,IAAI;CACF,MAAM,EAAE,WAAW,UAAU;EAC3B,QAAQ;EACR,SAAS;GACP,OAAO,EAAE,MAAM,UAAU;GACzB,MAAM;IAAE,MAAM;IAAW,OAAO;GAAI;EACtC;CACF,CAAC;CACD,IAAI,OAAO,MACT,QAAQ,IAAI;;;;;;8BAMc;MAE1B,IAAI,OAAO,OAAO,MAAM,4BAA4B;MAC/C,MAAM,2BAA2B;AAE1C,SAAS,OAAO;CACd,MAAM,UAAU,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK;CACrE,QAAQ,MAAM,mBAAmB,SAAS;CAC1C,QAAQ,WAAW;AACrB"}
@@ -0,0 +1,542 @@
1
+ import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
2
+ import { basename, relative, resolve } from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+ import { tsImport } from "tsx/esm/api";
5
+ import { IllegalMoveError, assertPortableSchema } from "@eigeninteractive/rules";
6
+ import Rand from "rand-seed";
7
+ import { it } from "vitest";
8
+ //#region src/twin-fixtures.ts
9
+ /**
10
+ * Twin-drift fixture runner: the TS half of the shared JSON fixtures that
11
+ * keep a version unit's TS and Dart `GameRules` twins in sync. The JSON
12
+ * fixture format is shared verbatim with the Dart runner
13
+ * (`lib/testing/twin_fixtures.dart`): one file, two consumers.
14
+ *
15
+ * One fixture file per concern lives beside the version units at
16
+ * `<fixturesRoot>/v<N>/*.json` and is consumed by BOTH sides: this module
17
+ * runs each case against the TS unit (schemas + `applyAction` +
18
+ * `computeObservation` + the two predicates), while the Dart runner runs the
19
+ * same file against the Dart twin (generated payload parsing,
20
+ * `isValidAction`, `previewAction`, predicate twins). A behavioral divergence
21
+ * then fails one side's CI instead of degrading UX in production.
22
+ *
23
+ * ## Fixture file format
24
+ *
25
+ * ```jsonc
26
+ * {
27
+ * "schemaVersion": 1,
28
+ * "cases": [
29
+ * {
30
+ * "kind": "action",
31
+ * "name": "seat 0 marks an empty cell",
32
+ * "config": {},
33
+ * "state": { ... }, // TS: applyAction input
34
+ * "obs": { ... }, // Dart-only; defaults to `state`
35
+ * "pending": [0],
36
+ * "playerIndex": 0,
37
+ * "participantCount": 2, // optional, default 2
38
+ * "rngSeed": "any string", // optional, default "twin-fixtures"
39
+ * "action": { ... },
40
+ * "expected": {
41
+ * "valid": true, // false ⇒ TS IllegalMoveError,
42
+ * // Dart isValidAction false
43
+ * "state": { ... }, // optional, TS envelope.state
44
+ * "pending": [1], // optional, TS envelope.pendingPlayers
45
+ * "outcome": [ ... ], // optional, TS envelope.outcome
46
+ * // (null asserts the game is ongoing)
47
+ * "observation": { ... } // optional, the actor's post-action
48
+ * // view: TS computeObservation slice
49
+ * // data; Dart previewAction (when the
50
+ * // game implements optimism)
51
+ * }
52
+ * },
53
+ * { "kind": "playerLimits", "name": "...", "config": {},
54
+ * "expected": { "minPlayers": 2, "maxPlayers": 2 } },
55
+ * { "kind": "ratingPool", "name": "...", "access": "public",
56
+ * "minPlayers": 2, "maxPlayers": 2, "config": {}, "expected": "blitz" },
57
+ * { "kind": "botSeatable", "name": "...", "gameConfig": {},
58
+ * "botConfig": {}, "expected": false }
59
+ * ]
60
+ * }
61
+ * ```
62
+ *
63
+ * The `state`/`obs` split exists for hidden-info games (a seat's observation
64
+ * is not the state); perfect-info games omit `obs`. `expected.observation` is
65
+ * the shared behavioral anchor: the TS side must project the post-action
66
+ * state to it, and a Dart `previewAction` that returns non-null must predict
67
+ * it, so the two sides are compared through one recorded value.
68
+ *
69
+ * Wire it up in a game-owned test file running under plain-Node vitest:
70
+ *
71
+ * ```ts
72
+ * import { twinFixtureTests } from "@eigeninteractive/testkit";
73
+ * import gameModule from "../src/module/index.js";
74
+ *
75
+ * twinFixtureTests(gameModule, new URL("../src/module/fixtures/", import.meta.url));
76
+ * ```
77
+ */
78
+ function describe(value) {
79
+ if (value === null) return "null";
80
+ if (Array.isArray(value)) return "an array";
81
+ return typeof value;
82
+ }
83
+ function fail(where, expected, got) {
84
+ throw new Error(`${where}: expected ${expected}, got ${describe(got)}`);
85
+ }
86
+ function asObject(where, v) {
87
+ if (typeof v !== "object" || v === null || Array.isArray(v)) fail(where, "an object", v);
88
+ return v;
89
+ }
90
+ function asString(where, v) {
91
+ if (typeof v !== "string") fail(where, "a string", v);
92
+ return v;
93
+ }
94
+ function asNumber(where, v) {
95
+ if (typeof v !== "number" || !Number.isFinite(v)) fail(where, "a finite number", v);
96
+ return v;
97
+ }
98
+ function asBoolean(where, v) {
99
+ if (typeof v !== "boolean") fail(where, "a boolean", v);
100
+ return v;
101
+ }
102
+ function asNumberArray(where, v) {
103
+ if (!Array.isArray(v)) fail(where, "an array of numbers", v);
104
+ return v.map((n, i) => asNumber(`${where}[${i}]`, n));
105
+ }
106
+ /** Applies `read` only when the key is present and non-null; absent and
107
+ * explicit null both mean "not specified" for every optional fixture field. */
108
+ function optional(where, v, read) {
109
+ return v === void 0 || v === null ? void 0 : read(where, v);
110
+ }
111
+ function parseActionCase(where, raw) {
112
+ const expectedRaw = asObject(`${where}.expected`, raw.expected);
113
+ const expected = {
114
+ 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
+ observation: optional(`${where}.expected.observation`, expectedRaw.observation, asObject)
118
+ };
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
+ return {
125
+ kind: "action",
126
+ name: asString(`${where}.name`, raw.name),
127
+ config: asObject(`${where}.config`, raw.config),
128
+ state: asObject(`${where}.state`, raw.state),
129
+ obs: optional(`${where}.obs`, raw.obs, asObject),
130
+ pending: asNumberArray(`${where}.pending`, raw.pending),
131
+ playerIndex: asNumber(`${where}.playerIndex`, raw.playerIndex),
132
+ participantCount: optional(`${where}.participantCount`, raw.participantCount, asNumber),
133
+ rngSeed: optional(`${where}.rngSeed`, raw.rngSeed, asString),
134
+ action: asObject(`${where}.action`, raw.action),
135
+ expected
136
+ };
137
+ }
138
+ function parseRatingPoolCase(where, raw) {
139
+ const access = asString(`${where}.access`, raw.access);
140
+ if (access !== "public" && access !== "private" && access !== "friends") throw new Error(`${where}.access: expected one of public | private | friends, got ${JSON.stringify(access)}`);
141
+ return {
142
+ kind: "ratingPool",
143
+ name: asString(`${where}.name`, raw.name),
144
+ access,
145
+ turnSeconds: optional(`${where}.turnSeconds`, raw.turnSeconds, asNumber) ?? null,
146
+ budgetSeconds: optional(`${where}.budgetSeconds`, raw.budgetSeconds, asNumber) ?? null,
147
+ incrementSeconds: optional(`${where}.incrementSeconds`, raw.incrementSeconds, asNumber) ?? null,
148
+ minPlayers: asNumber(`${where}.minPlayers`, raw.minPlayers),
149
+ maxPlayers: asNumber(`${where}.maxPlayers`, raw.maxPlayers),
150
+ config: asObject(`${where}.config`, raw.config),
151
+ expected: raw.expected === null ? null : asString(`${where}.expected`, raw.expected)
152
+ };
153
+ }
154
+ function parsePlayerLimitsCase(where, raw) {
155
+ const expected = asObject(`${where}.expected`, raw.expected);
156
+ return {
157
+ kind: "playerLimits",
158
+ name: asString(`${where}.name`, raw.name),
159
+ config: asObject(`${where}.config`, raw.config),
160
+ expected: {
161
+ minPlayers: asNumber(`${where}.expected.minPlayers`, expected.minPlayers),
162
+ maxPlayers: asNumber(`${where}.expected.maxPlayers`, expected.maxPlayers)
163
+ }
164
+ };
165
+ }
166
+ function parseBotSeatableCase(where, raw) {
167
+ return {
168
+ kind: "botSeatable",
169
+ name: asString(`${where}.name`, raw.name),
170
+ gameConfig: asObject(`${where}.gameConfig`, raw.gameConfig),
171
+ botConfig: asObject(`${where}.botConfig`, raw.botConfig),
172
+ expected: asBoolean(`${where}.expected`, raw.expected)
173
+ };
174
+ }
175
+ /** Validate one fixture file's parsed JSON, or throw naming the offending
176
+ * file, case, and field. Exported so a repo can lint its fixtures without
177
+ * running them. */
178
+ function parseTwinFixtureFile(path, json) {
179
+ const root = asObject(path, json);
180
+ const schemaVersion = asNumber(`${path}.schemaVersion`, root.schemaVersion);
181
+ if (!Array.isArray(root.cases)) fail(`${path}.cases`, "an array", root.cases);
182
+ return {
183
+ schemaVersion,
184
+ cases: root.cases.map((raw, i) => {
185
+ const indexed = `${path}.cases[${i}]`;
186
+ const obj = asObject(indexed, raw);
187
+ const where = typeof obj.name === "string" ? `${indexed} (${obj.name})` : indexed;
188
+ switch (obj.kind) {
189
+ case "action": return parseActionCase(where, obj);
190
+ case "playerLimits": return parsePlayerLimitsCase(where, obj);
191
+ case "ratingPool": return parseRatingPoolCase(where, obj);
192
+ case "botSeatable": return parseBotSeatableCase(where, obj);
193
+ default: throw new Error(`${where}.kind: expected one of action | playerLimits | ratingPool | botSeatable, got ${JSON.stringify(obj.kind)}`);
194
+ }
195
+ })
196
+ };
197
+ }
198
+ /** Run one fixture case against a rules unit, returning failure descriptions
199
+ * (empty ⇒ the case passes). Pure; the file-reading test registrar is
200
+ * {@link twinFixtureTests}. */
201
+ function evaluateTwinCase(rules, kase) {
202
+ switch (kase.kind) {
203
+ case "action": return evaluateAction(rules, kase);
204
+ case "playerLimits": return evaluatePlayerLimits(rules, kase);
205
+ case "ratingPool": return evaluateRatingPool(rules, kase);
206
+ case "botSeatable": return evaluateBotSeatable(rules, kase);
207
+ default: return [`unknown case kind "${kase.kind}", expected action | playerLimits | ratingPool | botSeatable`];
208
+ }
209
+ }
210
+ /** Register one vitest test per fixture case found under `fixturesRoot`
211
+ * (layout: `<root>/v<N>/*.json`). Call at the top level of a test module
212
+ * running in a Node environment. */
213
+ function twinFixtureTests(gameModule, fixturesRoot) {
214
+ for (const filePath of fixtureFiles$1(fixturesRoot)) {
215
+ const fixture = parseTwinFixtureFile(filePath, JSON.parse(readFileSync(filePath, "utf8")));
216
+ const rules = gameModule.versions[fixture.schemaVersion];
217
+ for (const kase of fixture.cases) it(`twin v${fixture.schemaVersion}: ${kase.name}`, () => {
218
+ if (!rules) throw new Error(`gameModule ships no rules unit for schemaVersion ${fixture.schemaVersion} (fixture: ${filePath})`);
219
+ const failures = evaluateTwinCase(rules, kase);
220
+ if (failures.length) throw new Error(`\n${failures.join("\n")}`);
221
+ });
222
+ }
223
+ }
224
+ function* fixtureFiles$1(root) {
225
+ const dirs = readdirSync(root, { withFileTypes: true }).filter((e) => e.isDirectory()).sort((a, b) => a.name.localeCompare(b.name));
226
+ for (const dir of dirs) {
227
+ const files = readdirSync(`${dir.parentPath}/${dir.name}`, { withFileTypes: true }).filter((e) => e.isFile() && e.name.endsWith(".json")).sort((a, b) => a.name.localeCompare(b.name));
228
+ for (const file of files) yield `${file.parentPath}/${file.name}`;
229
+ }
230
+ }
231
+ function evaluateAction(rules, kase) {
232
+ const failures = [];
233
+ const config = parseWith(rules, "config", kase.config, failures);
234
+ const state = parseWith(rules, "state", kase.state, failures);
235
+ const action = parseWith(rules, "action", kase.action, failures);
236
+ if (config === void 0 || state === void 0 || action === void 0) return failures;
237
+ if (!deepEquals(action, kase.action)) failures.push(`action schema does not preserve the fixture action, parsed to ${JSON.stringify(action)}`);
238
+ const envelope = applyFixtureAction(rules, kase, config, state, action);
239
+ if (typeof envelope === "string") {
240
+ if (envelope) failures.push(envelope);
241
+ return failures;
242
+ }
243
+ checkEnvelope(rules, kase, envelope, failures);
244
+ if (kase.expected.observation !== void 0) checkObservation(rules, kase, envelope, config, action, failures);
245
+ return failures;
246
+ }
247
+ /** Invoke `applyAction` and reconcile with `expected.valid`. Returns the
248
+ * envelope on a valid accepted move, an error string on failure, or "" when
249
+ * an expected-illegal move was correctly rejected (nothing left to check). */
250
+ function applyFixtureAction(rules, kase, config, state, action) {
251
+ let envelope;
252
+ try {
253
+ envelope = rules.applyAction({
254
+ state,
255
+ pending: kase.pending,
256
+ data: action,
257
+ playerIndex: kase.playerIndex,
258
+ rng: new Rand(kase.rngSeed ?? "twin-fixtures"),
259
+ config
260
+ });
261
+ } catch (error) {
262
+ if (!(error instanceof IllegalMoveError)) return `applyAction threw a non-IllegalMoveError: ${error}`;
263
+ return kase.expected.valid ? `applyAction rejected a move the fixture expects to be valid: ${error.message}` : "";
264
+ }
265
+ return kase.expected.valid ? envelope : "applyAction accepted a move the fixture expects to be illegal";
266
+ }
267
+ function checkEnvelope(rules, kase, envelope, failures) {
268
+ if (validate(rules, "state", envelope.state) === void 0) failures.push("applyAction returned state that violates its own schema");
269
+ const expected = kase.expected;
270
+ if (expected.state !== void 0 && !deepEquals(envelope.state, expected.state)) failures.push(`envelope.state mismatch, got ${JSON.stringify(envelope.state)}`);
271
+ if (expected.pending !== void 0 && !deepEquals(envelope.pendingPlayers, expected.pending)) failures.push(`envelope.pendingPlayers mismatch, got ${JSON.stringify(envelope.pendingPlayers)}`);
272
+ if ("outcome" in expected && !deepEquals(envelope.outcome ?? null, expected.outcome ?? null)) failures.push(`envelope.outcome mismatch, got ${JSON.stringify(envelope.outcome ?? null)}`);
273
+ }
274
+ function checkObservation(rules, kase, envelope, config, action, failures) {
275
+ let slice;
276
+ try {
277
+ slice = rules.computeObservation({
278
+ state: envelope.state,
279
+ pending: envelope.pendingPlayers,
280
+ playerIndex: kase.playerIndex,
281
+ participantCount: kase.participantCount ?? 2,
282
+ config,
283
+ cause: {
284
+ kind: "game",
285
+ data: action,
286
+ playerIndex: kase.playerIndex
287
+ },
288
+ isReplay: false
289
+ });
290
+ } catch (error) {
291
+ failures.push(`computeObservation threw: ${error}`);
292
+ return;
293
+ }
294
+ if (!deepEquals(slice.data, kase.expected.observation)) failures.push(`actor's observation mismatch, got ${JSON.stringify(slice.data)}`);
295
+ }
296
+ function evaluateRatingPool(rules, kase) {
297
+ const failures = [];
298
+ const config = parseWith(rules, "config", kase.config, failures);
299
+ if (config === void 0) return failures;
300
+ const pool = rules.ratingPool({
301
+ access: kase.access,
302
+ turnSeconds: kase.turnSeconds ?? null,
303
+ budgetSeconds: kase.budgetSeconds ?? null,
304
+ incrementSeconds: kase.incrementSeconds ?? null,
305
+ minPlayers: kase.minPlayers,
306
+ maxPlayers: kase.maxPlayers,
307
+ config
308
+ });
309
+ if (pool !== kase.expected) failures.push(`ratingPool returned ${JSON.stringify(pool)}, fixture expects ${JSON.stringify(kase.expected)}`);
310
+ return failures;
311
+ }
312
+ function evaluatePlayerLimits(rules, kase) {
313
+ const failures = [];
314
+ const config = parseWith(rules, "config", kase.config, failures);
315
+ if (config === void 0) return failures;
316
+ const limits = rules.playerLimits({ config });
317
+ if (limits.minPlayers !== kase.expected.minPlayers || limits.maxPlayers !== kase.expected.maxPlayers) failures.push(`playerLimits returned ${limits.minPlayers}-${limits.maxPlayers}, fixture expects ${kase.expected.minPlayers}-${kase.expected.maxPlayers}`);
318
+ return failures;
319
+ }
320
+ function evaluateBotSeatable(rules, kase) {
321
+ const failures = [];
322
+ const gameConfig = parseWith(rules, "config", kase.gameConfig, failures);
323
+ if (gameConfig === void 0) return failures;
324
+ const seatable = rules.botSeatable({
325
+ gameConfig,
326
+ botConfig: kase.botConfig
327
+ });
328
+ if (seatable !== kase.expected) failures.push(`botSeatable returned ${seatable}, fixture expects ${kase.expected}`);
329
+ return failures;
330
+ }
331
+ /** Run one of the unit's Standard Schemas synchronously, returning the parsed
332
+ * value or undefined on failure (issues written to the second return slot). */
333
+ function validate(rules, which, value, issues) {
334
+ const result = rules.schemas[which]["~standard"].validate(value);
335
+ if (result instanceof Promise) {
336
+ issues?.push(`${which} schema validated asynchronously; must be sync`);
337
+ return;
338
+ }
339
+ if (result.issues) {
340
+ issues?.push(result.issues.map((i) => {
341
+ const path = i.path?.map((p) => typeof p === "object" ? String(p.key) : String(p)).join(".");
342
+ return path ? `${path}: ${i.message}` : i.message;
343
+ }).join("; "));
344
+ return;
345
+ }
346
+ return result.value;
347
+ }
348
+ /** Parse a fixture payload through one of the unit's schemas, recording a
349
+ * failure (and returning undefined) when it does not conform. */
350
+ function parseWith(rules, which, value, failures) {
351
+ const issues = [];
352
+ const parsed = validate(rules, which, value, issues);
353
+ if (parsed === void 0) failures.push(`fixture ${which} fails the TS ${which} schema: ${issues.join("; ")}`);
354
+ return parsed;
355
+ }
356
+ /** Structural JSON equality. Object keys with `undefined` values count as
357
+ * absent (matching how schema libraries model optional fields); array order
358
+ * matters. */
359
+ function deepEquals(a, b) {
360
+ if (a === void 0 || a === null) return b === void 0 || b === null;
361
+ if (b === void 0 || b === null) return false;
362
+ if (Array.isArray(a) || Array.isArray(b)) {
363
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
364
+ return a.every((item, i) => deepEquals(item, b[i]));
365
+ }
366
+ if (typeof a === "object" || typeof b === "object") {
367
+ if (typeof a !== "object" || typeof b !== "object") return false;
368
+ return [.../* @__PURE__ */ new Set([...Object.keys(a), ...Object.keys(b)])].every((k) => deepEquals(a[k], b[k]));
369
+ }
370
+ return a === b;
371
+ }
372
+ //#endregion
373
+ //#region src/game-contract.ts
374
+ /**
375
+ * Emit the language-neutral contract a game's Flutter repository consumes.
376
+ *
377
+ * The authoritative inputs live with the TypeScript rules: the four payload
378
+ * schemas and the shared behavioral fixtures. The output is one deterministic
379
+ * JSON file so it can be copied, attached to a release, or fetched by checksum
380
+ * without assuming any repository layout.
381
+ */
382
+ /** Current format of the language-neutral contract consumed by EigenInteractive's Dart generator. */
383
+ const GAME_CONTRACT_FORMAT_VERSION = 1;
384
+ /**
385
+ * Sort object keys so the same schemas always emit the same bytes.
386
+ *
387
+ * Ordering is by UTF-16 code unit — plain `Array#sort` — which is what RFC 8785
388
+ * specifies and what `tool/check-contracts.mjs` uses to digest a manifest. It is
389
+ * *not* `localeCompare`: that collates case-insensitively, so a `$defs` entry
390
+ * named `Move` sorts before `additionalProperties` under one rule and after it
391
+ * under the other. Generated JSON Schema is full of capitalized `$defs` names, so
392
+ * the two orders genuinely disagree, and a digest computed over one would never
393
+ * match a document written by the other.
394
+ */
395
+ function canonical(value) {
396
+ if (Array.isArray(value)) return value.map(canonical);
397
+ if (value !== null && typeof value === "object") return Object.fromEntries(Object.entries(value).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([key, item]) => [key, canonical(item)]));
398
+ return value;
399
+ }
400
+ /**
401
+ * Emit one payload schema as portable draft-2020-12 JSON Schema.
402
+ *
403
+ * Always the **output** direction. Zod's input schema omits
404
+ * `additionalProperties: false` on objects, because on input a permissive object
405
+ * still strips unknown keys rather than rejecting them — but the contract is what
406
+ * the Dart side generates a validator from, and an open object there means the two
407
+ * halves disagree about an unknown key. Schemas are required to be transform-free
408
+ * (see `GameSchemas`), so the two directions describe the same values and the
409
+ * output one is simply the honest document.
410
+ *
411
+ * The result is then checked against the portable profile, because emission is
412
+ * where an unportable schema can still be fixed cheaply. Skipping this check is
413
+ * how the profile came to be violated in the first place.
414
+ */
415
+ function jsonSchema(schema, label) {
416
+ const options = { target: "draft-2020-12" };
417
+ let emitted;
418
+ try {
419
+ emitted = canonical(schema["~standard"].jsonSchema.output(options));
420
+ } catch (error) {
421
+ throw new Error(`${label} cannot emit its type as draft-2020-12 JSON Schema`, { cause: error });
422
+ }
423
+ assertPortableSchema(emitted, label);
424
+ return emitted;
425
+ }
426
+ function fixtureFiles(root) {
427
+ const found = [];
428
+ const visit = (directory) => {
429
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
430
+ const path = resolve(directory, entry.name);
431
+ if (entry.isDirectory()) visit(path);
432
+ else if (entry.isFile() && entry.name.endsWith(".json")) found.push(path);
433
+ }
434
+ };
435
+ visit(root);
436
+ return found.sort();
437
+ }
438
+ function fixtures(fixturesRoot, versions) {
439
+ if (fixturesRoot === void 0) return [];
440
+ const root = fixturesRoot instanceof URL ? fixturesRoot.pathname : resolve(fixturesRoot);
441
+ return fixtureFiles(root).map((path) => {
442
+ const document = JSON.parse(readFileSync(path, "utf8"));
443
+ const fixture = parseTwinFixtureFile(path, document);
444
+ const fixturePath = relative(root, path).split("\\").join("/");
445
+ const directory = fixturePath.split("/")[0];
446
+ const match = /^v([1-9]\d*)$/.exec(directory ?? "");
447
+ if (match === null) throw new Error(`${fixturePath}: fixtures must live under a v<N>/ directory`);
448
+ const pathVersion = Number(match[1]);
449
+ if (pathVersion !== fixture.schemaVersion) throw new Error(`${fixturePath}: directory v${pathVersion} disagrees with schemaVersion ${fixture.schemaVersion}`);
450
+ if (!versions.has(String(fixture.schemaVersion))) throw new Error(`${fixturePath}: gameModule ships no rules unit for schemaVersion ${fixture.schemaVersion}`);
451
+ return {
452
+ path: fixturePath,
453
+ document: canonical(document)
454
+ };
455
+ });
456
+ }
457
+ /** Build a deterministic in-memory contract without touching the filesystem. */
458
+ function buildGameContract(options) {
459
+ const game = options.game.trim();
460
+ if (game.length === 0) throw new Error("game contract name must not be empty");
461
+ const versions = Object.fromEntries(Object.entries(options.gameModule.versions).sort(([a], [b]) => Number(a) - Number(b)).map(([version, rules]) => [version, { schemas: {
462
+ state: jsonSchema(rules.schemas.state, `v${version} state`),
463
+ observation: jsonSchema(rules.schemas.observation, `v${version} observation`),
464
+ action: jsonSchema(rules.schemas.action, `v${version} action`),
465
+ config: jsonSchema(rules.schemas.config, `v${version} config`)
466
+ } }]));
467
+ if (Object.keys(versions).length === 0) throw new Error("game contract must contain at least one schema version");
468
+ return {
469
+ formatVersion: 1,
470
+ game,
471
+ versions,
472
+ fixtures: fixtures(options.fixturesRoot, new Set(Object.keys(versions)))
473
+ };
474
+ }
475
+ /** Render one deterministic, newline-terminated contract document. */
476
+ function gameContractJson(options) {
477
+ return `${JSON.stringify(buildGameContract(options), null, 2)}\n`;
478
+ }
479
+ /** Emit one deterministic, newline-terminated `game-contract.json`. */
480
+ function emitGameContract(options) {
481
+ const output = options.output instanceof URL ? options.output : resolve(options.output);
482
+ writeFileSync(output, gameContractJson(options));
483
+ }
484
+ /** Fail when an emitted contract is missing or differs from its inputs. */
485
+ function checkGameContract(options) {
486
+ const output = options.output instanceof URL ? options.output : resolve(options.output);
487
+ if (!existsSync(output) || readFileSync(output, "utf8") !== gameContractJson(options)) throw new Error(`${output.toString()} is stale; run eigen-contract`);
488
+ }
489
+ /** A useful default filename for scripts that accept an output directory. */
490
+ const gameContractFilename = (game) => `${basename(game).toLowerCase().replaceAll(/[^a-z0-9]+/g, "-")}-game-contract.json`;
491
+ //#endregion
492
+ //#region src/contract-command.ts
493
+ function configuredString(value, fallback, field) {
494
+ if (value === void 0) return fallback;
495
+ if (typeof value !== "string" || value.trim().length === 0) throw new Error(`package.json eigen.${field} must be a non-empty string`);
496
+ return value;
497
+ }
498
+ const isGameModule = (value) => value !== null && typeof value === "object" && "versions" in value && value.versions !== null && typeof value.versions === "object";
499
+ /**
500
+ * Emit a contract from the conventional game package layout.
501
+ *
502
+ * The package default-exports its GameModule from `src/module/index.ts` and
503
+ * declares `{ "eigen": { "game": "…" } }` in package.json. Paths can be
504
+ * overridden under the same `eigen` object.
505
+ */
506
+ async function configuredGameContractOptions(root) {
507
+ const config = JSON.parse(readFileSync(resolve(root, "package.json"), "utf8")).eigen ?? {};
508
+ const game = configuredString(config.game, "", "game");
509
+ if (game.length === 0) throw new Error("package.json must declare an EigenInteractive game name, e.g. \"eigen\": { \"game\": \"Chess\" }");
510
+ const modulePath = configuredString(config.module, "src/module/index.ts", "module");
511
+ const fixturesPath = configuredString(config.fixtures, "src/module/fixtures", "fixtures");
512
+ const contractPath = configuredString(config.contract, "game-contract.json", "contract");
513
+ const imported = await tsImport(pathToFileURL(resolve(root, modulePath)).href, import.meta.url);
514
+ if (!isGameModule(imported.default)) throw new Error(`${modulePath} must default-export an EigenInteractive GameModule`);
515
+ return {
516
+ game,
517
+ gameModule: imported.default,
518
+ fixturesRoot: resolve(root, fixturesPath),
519
+ output: resolve(root, contractPath)
520
+ };
521
+ }
522
+ /**
523
+ * Emits `game-contract.json` from an EigenInteractive package's conventional layout.
524
+ *
525
+ * This is the programmatic form of the `eigen-contract` executable. Most
526
+ * games should invoke the executable through their package script.
527
+ */
528
+ async function emitConfiguredGameContract(root = process.cwd()) {
529
+ emitGameContract(await configuredGameContractOptions(root));
530
+ }
531
+ /**
532
+ * Fails when the conventionally configured contract is absent or stale.
533
+ *
534
+ * Use this in CI through `eigen-contract --check`.
535
+ */
536
+ async function checkConfiguredGameContract(root = process.cwd()) {
537
+ checkGameContract(await configuredGameContractOptions(root));
538
+ }
539
+ //#endregion
540
+ export { checkGameContract as a, gameContractJson as c, parseTwinFixtureFile as d, twinFixtureTests as f, buildGameContract as i, deepEquals as l, emitConfiguredGameContract as n, emitGameContract as o, GAME_CONTRACT_FORMAT_VERSION as r, gameContractFilename as s, checkConfiguredGameContract as t, evaluateTwinCase as u };
541
+
542
+ //# sourceMappingURL=contract-command-aCqO5CyJ.js.map