@joystyk/testing 0.0.0 → 0.2.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.
Files changed (67) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +189 -2
  3. package/dist/builders.d.ts +59 -0
  4. package/dist/builders.js +129 -0
  5. package/dist/builders.js.map +1 -0
  6. package/dist/clock.d.ts +30 -0
  7. package/dist/clock.js +58 -0
  8. package/dist/clock.js.map +1 -0
  9. package/dist/deep.d.ts +25 -0
  10. package/dist/deep.js +105 -0
  11. package/dist/deep.js.map +1 -0
  12. package/dist/engine-conformance.d.ts +166 -0
  13. package/dist/engine-conformance.js +995 -0
  14. package/dist/engine-conformance.js.map +1 -0
  15. package/dist/fixtures/dice-race.d.ts +41 -0
  16. package/dist/fixtures/dice-race.js +153 -0
  17. package/dist/fixtures/dice-race.js.map +1 -0
  18. package/dist/fixtures/draw-play.d.ts +54 -0
  19. package/dist/fixtures/draw-play.js +188 -0
  20. package/dist/fixtures/draw-play.js.map +1 -0
  21. package/dist/fixtures/drift.d.ts +34 -0
  22. package/dist/fixtures/drift.js +109 -0
  23. package/dist/fixtures/drift.js.map +1 -0
  24. package/dist/fixtures/guards.d.ts +11 -0
  25. package/dist/fixtures/guards.js +16 -0
  26. package/dist/fixtures/guards.js.map +1 -0
  27. package/dist/fixtures/hint-puzzle.d.ts +47 -0
  28. package/dist/fixtures/hint-puzzle.js +129 -0
  29. package/dist/fixtures/hint-puzzle.js.map +1 -0
  30. package/dist/fixtures/index.d.ts +6 -0
  31. package/dist/fixtures/index.js +7 -0
  32. package/dist/fixtures/nim.d.ts +34 -0
  33. package/dist/fixtures/nim.js +141 -0
  34. package/dist/fixtures/nim.js.map +1 -0
  35. package/dist/fixtures/sealed-bids.d.ts +46 -0
  36. package/dist/fixtures/sealed-bids.js +141 -0
  37. package/dist/fixtures/sealed-bids.js.map +1 -0
  38. package/dist/index.d.ts +13 -0
  39. package/dist/index.js +14 -0
  40. package/dist/replay.d.ts +58 -0
  41. package/dist/replay.js +110 -0
  42. package/dist/replay.js.map +1 -0
  43. package/dist/report.d.ts +68 -0
  44. package/dist/report.js +104 -0
  45. package/dist/report.js.map +1 -0
  46. package/dist/rng.d.ts +18 -0
  47. package/dist/rng.js +25 -0
  48. package/dist/rng.js.map +1 -0
  49. package/dist/schema.d.ts +13 -0
  50. package/dist/schema.js +21 -0
  51. package/dist/schema.js.map +1 -0
  52. package/dist/session-ports.d.ts +16 -0
  53. package/dist/session-ports.js +32 -0
  54. package/dist/session-ports.js.map +1 -0
  55. package/dist/simulation-conformance.d.ts +71 -0
  56. package/dist/simulation-conformance.js +312 -0
  57. package/dist/simulation-conformance.js.map +1 -0
  58. package/dist/storage.d.ts +29 -0
  59. package/dist/storage.js +63 -0
  60. package/dist/storage.js.map +1 -0
  61. package/dist/vitest.d.ts +32 -0
  62. package/dist/vitest.js +45 -0
  63. package/dist/vitest.js.map +1 -0
  64. package/dist/waiting.d.ts +58 -0
  65. package/dist/waiting.js +97 -0
  66. package/dist/waiting.js.map +1 -0
  67. package/package.json +49 -11
@@ -0,0 +1,995 @@
1
+ import { engineReplay, seats } from "./builders.js";
2
+ import { deepEqual, deepFreeze, errorMessage, firstDifference, jsonProblem, jsonRoundTrip, show } from "./deep.js";
3
+ import { replayEngine as replayEngine$1, undoByReplay } from "./replay.js";
4
+ import { Recorder } from "./report.js";
5
+ import { cloneRng, testRng, testSeed } from "./rng.js";
6
+ import { formatIssues, validate } from "./schema.js";
7
+ import { isGameError, isJson, runMigrations } from "@joystyk/contracts";
8
+ import { restoreRng } from "@joystyk/kit";
9
+ import { replayEngine } from "@joystyk/session";
10
+ //#region src/engine-conformance.ts
11
+ /** The checks this suite runs, in report order. */
12
+ var ENGINE_CHECKS = [
13
+ {
14
+ id: "determinism",
15
+ title: "Determinism: the same seed and inputs give the same states, beats, barriers and outcomes"
16
+ },
17
+ {
18
+ id: "replay",
19
+ title: "Replay: the recorded log reproduces the final state"
20
+ },
21
+ {
22
+ id: "json",
23
+ title: "JSON safety of configs, states, views, events, commands, jobs and decision keys"
24
+ },
25
+ {
26
+ id: "schemas",
27
+ title: "Schema agreement: every value passes the engine's own schemas"
28
+ },
29
+ {
30
+ id: "no-leak",
31
+ title: "No leaks: viewFor, eventFor and decisionKey ignore what the viewer may not see"
32
+ },
33
+ {
34
+ id: "rejections",
35
+ title: "Rejected inputs change nothing: apply never throws or mutates, and rejects with a GameError"
36
+ },
37
+ {
38
+ id: "turns",
39
+ title: "Turn discipline: only pending decisions and declared untimed side commands are allowed"
40
+ },
41
+ {
42
+ id: "resign",
43
+ title: "Resignation is accepted from any seat, in turn or not, while the game is in progress"
44
+ },
45
+ {
46
+ id: "invariants",
47
+ title: "Invariants hold on every state the game reaches"
48
+ },
49
+ {
50
+ id: "ai",
51
+ title: "AI legality: ai.choose never returns a rejected command"
52
+ },
53
+ {
54
+ id: "saves",
55
+ title: "Saves: round trip, restore and continue, and the migration chain from fixtures"
56
+ },
57
+ {
58
+ id: "jobs",
59
+ title: "Job hygiene: job outcomes are accepted only while their job is awaited"
60
+ },
61
+ {
62
+ id: "stale",
63
+ title: "Staleness: while a seat's decisionKey is unchanged, the commands it may send are unchanged"
64
+ },
65
+ {
66
+ id: "undo",
67
+ title: "Undo by replay returns to the human's previous decision and never steps back past a barrier"
68
+ },
69
+ {
70
+ id: "assist",
71
+ title: "Assistance: transitions flag hints, checks and reveals, and replay reproduces the flags"
72
+ },
73
+ {
74
+ id: "session-hooks",
75
+ title: "Session hooks: startsClock is declared and set at most once a game, and worthSaving is pure"
76
+ },
77
+ {
78
+ id: "outcome",
79
+ title: "Every game reaches a valid outcome in time, and a finished game rejects commands and resignations"
80
+ }
81
+ ];
82
+ var ENGINE_ASSISTS = [
83
+ "hint",
84
+ "check",
85
+ "reveal",
86
+ "shuffle"
87
+ ];
88
+ var DEFAULTS = {
89
+ games: 12,
90
+ maxSteps: 400,
91
+ perturbations: 2,
92
+ jobFailureRate: .1,
93
+ maxFailures: 3
94
+ };
95
+ var CANDIDATE_CAP = 12;
96
+ /** Builds the setups from `setups`, or from every `configs` × `seatCounts` pair. */
97
+ var resolveSetups = (options) => {
98
+ if (options.setups !== void 0 && options.setups.length > 0) return [...options.setups];
99
+ const configs = options.configs ?? [];
100
+ const counts = options.seatCounts ?? [];
101
+ const setups = configs.flatMap((config) => counts.map((seats) => ({
102
+ config,
103
+ seats
104
+ })));
105
+ if (setups.length === 0) throw new TypeError("Engine conformance needs `setups`, or both `configs` and `seatCounts`.");
106
+ return setups;
107
+ };
108
+ var safely = (make) => {
109
+ try {
110
+ return make();
111
+ } catch {
112
+ return [];
113
+ }
114
+ };
115
+ var sample = (items, count, rng) => items.length <= count ? [...items] : rng.shuffle(items).slice(0, count);
116
+ var invariantMessage = (result) => {
117
+ if (result === void 0 || result === true) return null;
118
+ if (result === false) return "the invariant hook returned false";
119
+ return result;
120
+ };
121
+ var outcomeProblem = (outcome, seats) => {
122
+ if (![
123
+ "won",
124
+ "lost",
125
+ "draw",
126
+ "completed",
127
+ "abandoned"
128
+ ].includes(outcome.result)) return `unknown result ${show(outcome.result)}`;
129
+ if (typeof outcome.summary !== "string") return "summary must be a string";
130
+ if (outcome.elapsedMs !== void 0 || outcome.undosUsed !== void 0 || outcome.assists !== void 0) return "engines never set elapsedMs, undosUsed or assists; the runner adds them";
131
+ const placed = outcome.placements.map((p) => p.seat);
132
+ for (const p of outcome.placements) {
133
+ if (!seats.some((s) => s.id === p.seat)) return `placement for unknown seat ${show(p.seat)}`;
134
+ if (!Number.isInteger(p.place) || p.place < 1) return `seat ${p.seat} has place ${show(p.place)}; places start at 1`;
135
+ }
136
+ if (new Set(placed).size !== placed.length) return "a seat is placed twice";
137
+ if (placed.length !== seats.length) return `placements cover ${placed.length} of ${seats.length} seats; every seat gets exactly one placing`;
138
+ return null;
139
+ };
140
+ /**
141
+ * Runs the Layer 2 conformance suite against `engine` and returns a report. It never throws
142
+ * for a failing check; it throws only for unusable options. Use `runEngineConformance` to get
143
+ * one vitest case per check instead.
144
+ */
145
+ var checkEngine = async (engine, options) => {
146
+ const setups = resolveSetups(options);
147
+ if (options.commands === void 0 && engine.ai === void 0) throw new TypeError("Engine conformance needs a `commands` generator, or an engine with `ai`, to play random games.");
148
+ const rec = new Recorder(ENGINE_CHECKS, options.maxFailuresPerCheck ?? DEFAULTS.maxFailures);
149
+ const gameCount = options.games ?? DEFAULTS.games;
150
+ const maxSteps = options.maxSteps ?? DEFAULTS.maxSteps;
151
+ const requireOutcome = options.requireOutcome ?? true;
152
+ const perturbations = options.perturbations ?? DEFAULTS.perturbations;
153
+ const resignOn = options.platformInputs?.resign ?? true;
154
+ const timeoutOn = options.platformInputs?.timeout ?? false;
155
+ const seatChangesOn = options.platformInputs?.seatChanges ?? false;
156
+ const failureRate = options.jobFailureRate ?? DEFAULTS.jobFailureRate;
157
+ const baseSeed = options.seed ?? "conformance";
158
+ const gameId = options.gameId ?? "test-game";
159
+ const keyOf = (state, seat) => engine.decisionKey === void 0 ? engine.viewFor(state, seat) : engine.decisionKey(state, seat);
160
+ if (options.perturbHidden === void 0) rec.skip("no-leak", "no perturbHidden given; only right when all state is public");
161
+ if (engine.ai === void 0) rec.skip("ai", "the engine has no runner-driven ai");
162
+ if (options.invariants === void 0) rec.skip("invariants", "no invariants hook given");
163
+ if (!resignOn) rec.skip("resign", "resign is turned off in platformInputs");
164
+ if (engine.clockStart === void 0 && engine.worthSaving === void 0) rec.skip("session-hooks", "the engine declares neither clockStart nor worthSaving");
165
+ if (engine.clockStart !== void 0 && engine.clockStart !== "first-input" && engine.clockStart !== "transition") rec.fail("session-hooks", { message: `clockStart must be 'first-input' or 'transition', got ${show(engine.clockStart)}` });
166
+ const validations = [];
167
+ const asyncAllowed = /* @__PURE__ */ new Set([engine.schemas.view, engine.schemas.event]);
168
+ const checkSchema = (schema, value, what, fail) => {
169
+ if (schema === void 0) return;
170
+ rec.ran("schemas");
171
+ if (!asyncAllowed.has(schema)) {
172
+ let answer;
173
+ try {
174
+ answer = schema["~standard"].validate(value);
175
+ } catch {
176
+ answer = null;
177
+ }
178
+ if (typeof answer?.then === "function") fail(`${what}: the schema validates asynchronously; runners need synchronous command, state, config and job schemas`);
179
+ }
180
+ validations.push(validate(schema, value).then((result) => {
181
+ if (!result.ok) fail(`${what} fails its schema: ${formatIssues(result.issues)}; value ${show(value)}`);
182
+ }, (error) => fail(`${what}: the schema threw ${errorMessage(error)}`)));
183
+ };
184
+ let totalGames = 0;
185
+ let totalSteps = 0;
186
+ let perturbedChanged = 0;
187
+ let perturbedTotal = 0;
188
+ for (const [index, setup] of setups.entries()) {
189
+ rec.ran("json");
190
+ if (!isJson(setup.config)) rec.fail("json", {
191
+ message: `setup ${index}: the config isn't JSON: ${jsonProblem(setup.config)}`,
192
+ setup: index
193
+ });
194
+ checkSchema(engine.schemas.config, setup.config, `setup ${index}: the config`, (message) => rec.fail("schemas", {
195
+ message,
196
+ setup: index
197
+ }));
198
+ }
199
+ for (const [setupIndex, setup] of setups.entries()) {
200
+ const seatList = typeof setup.seats === "number" ? seats(setup.seats) : setup.seats;
201
+ const viewers = [...seatList.map((s) => s.id), null];
202
+ for (let gameIndex = 0; gameIndex < gameCount; gameIndex += 1) {
203
+ if (options.only !== void 0 && (options.only.setup !== setupIndex || options.only.game !== gameIndex)) continue;
204
+ totalGames += 1;
205
+ const seed = `${baseSeed}/${setupIndex}/${gameIndex}`;
206
+ const seedState = testSeed(seed, "engine");
207
+ const driver = testRng(seed, "driver");
208
+ const perturbRng = testRng(seed, "perturb");
209
+ const positions = [];
210
+ const steps = [];
211
+ const replayUpTo = (count) => engineReplay({
212
+ gameId,
213
+ rulesVersion: engine.rulesVersion,
214
+ config: setup.config,
215
+ seats: seatList,
216
+ seed: seedState,
217
+ inputs: steps.slice(0, count).map((s) => s.input)
218
+ });
219
+ const failure = (message, step, input) => ({
220
+ message,
221
+ setup: setupIndex,
222
+ game: gameIndex,
223
+ seed,
224
+ step,
225
+ replay: replayUpTo(step),
226
+ ...input === void 0 ? {} : { input }
227
+ });
228
+ const fail = (id, message, step, input) => rec.fail(id, failure(message, step, input));
229
+ /** Tries `input` on `state` with a copy of the generator at `rng`; nothing is committed. */
230
+ const attempt = (state, input, rng, step) => {
231
+ deepFreeze(input);
232
+ let result;
233
+ try {
234
+ result = engine.apply(state, input, restoreRng(rng));
235
+ } catch (error) {
236
+ const message = errorMessage(error);
237
+ const mutation = /read[- ]only|not extensible|frozen|Cannot (assign|add|delete|define)/i.test(message);
238
+ fail("rejections", mutation ? `apply mutated its input state or input (states are frozen): ${message}` : `apply threw instead of returning a result: ${message}`, step, input);
239
+ return {
240
+ kind: "threw",
241
+ message
242
+ };
243
+ }
244
+ rec.ran("rejections");
245
+ if (typeof result !== "object" || result === null || typeof result.ok !== "boolean") {
246
+ fail("rejections", `apply returned ${show(result)}, not a Result`, step, input);
247
+ return {
248
+ kind: "threw",
249
+ message: "not a result"
250
+ };
251
+ }
252
+ if (!result.ok) {
253
+ if (!isGameError(result.error) || !isJson(result.error)) fail("rejections", `a rejection must carry a JSON GameError with a code and a message, got ${show(result.error)}`, step, input);
254
+ return {
255
+ kind: "rejected",
256
+ code: String(result.error.code)
257
+ };
258
+ }
259
+ const transition = deepFreeze(result.value);
260
+ return {
261
+ kind: "accepted",
262
+ transition,
263
+ rngAfter: rngAfterApply(state, input, rng, transition, step)
264
+ };
265
+ };
266
+ /**
267
+ * The generator's state after applying an input. Applying twice from the same state also
268
+ * checks that `apply` is a function of (state, input, generator): the second answer must
269
+ * equal the first.
270
+ */
271
+ const rngAfterApply = (state, input, rng, first, step) => {
272
+ const again = restoreRng(rng);
273
+ try {
274
+ const second = engine.apply(state, input, again);
275
+ rec.ran("determinism");
276
+ const diff = second.ok ? firstDifference(first, second.value) : "the second try was rejected";
277
+ if (diff !== null) fail("determinism", `apply gave two different answers for the same state, input and generator: ${diff}`, step, input);
278
+ } catch (error) {
279
+ fail("determinism", `apply threw on a second try with the same arguments: ${errorMessage(error)}`, step, input);
280
+ }
281
+ return again.save();
282
+ };
283
+ const outsideCommand = (state, seat, command, rng, step) => {
284
+ const input = {
285
+ kind: "command",
286
+ seat,
287
+ command
288
+ };
289
+ if (engine.sideCommand?.(state, seat, command) !== true) {
290
+ mustReject("turns", state, input, rng, step, `a command from seat ${seat}, which isn't pending`);
291
+ return;
292
+ }
293
+ const tried = attempt(state, input, rng, step);
294
+ rec.ran("turns");
295
+ if (tried.kind !== "accepted") {
296
+ fail("turns", "declared side command was rejected", step, input);
297
+ return;
298
+ }
299
+ const after = tried.transition.state, pendingBefore = engine.pending(state);
300
+ if (JSON.stringify(engine.pending(after)) !== JSON.stringify(pendingBefore)) fail("turns", "side command changed pending decisions", step, input);
301
+ for (const actor of pendingBefore) if (JSON.stringify(engine.decisionKey?.(state, actor) ?? engine.viewFor(state, actor)) !== JSON.stringify(engine.decisionKey?.(after, actor) ?? engine.viewFor(after, actor))) fail("turns", "side command changed a pending decision key", step, input);
302
+ };
303
+ const mustReject = (id, state, input, rng, step, why) => {
304
+ rec.ran(id);
305
+ if (attempt(state, input, rng, step).kind === "accepted") fail(id, `${why}, but the engine accepted it`, step, input);
306
+ };
307
+ let started;
308
+ const initRng = restoreRng(seedState);
309
+ try {
310
+ started = engine.init(setup.config, seatList, initRng);
311
+ } catch (error) {
312
+ fail("rejections", `init threw: ${errorMessage(error)}`, 0);
313
+ continue;
314
+ }
315
+ if (!started.ok) {
316
+ fail("outcome", `init rejected setup ${setupIndex}: ${show(started.error)}`, 0);
317
+ continue;
318
+ }
319
+ let state = deepFreeze(started.value);
320
+ let rngState = initRng.save();
321
+ /** Requested jobs with no accepted outcome yet, each with its planned outcome. */
322
+ const awaited = /* @__PURE__ */ new Map();
323
+ /** Jobs answered, or superseded (the engine stopped awaiting them): their outcomes must be refused. */
324
+ const closed = [];
325
+ const requested = /* @__PURE__ */ new Set();
326
+ const resignAt = resignOn && gameIndex % 3 === 2 ? driver.int(0, 24) : -1;
327
+ /** The inputs whose transitions set `startsClock`. */
328
+ const clockStarts = [];
329
+ /** `startsClock` needs `clockStart: 'transition'`, and starts the clock at most once a game. */
330
+ const checkClockStart = (transition, step, input) => {
331
+ if (transition.restartsGame !== void 0) {
332
+ rec.ran("session-hooks");
333
+ if (typeof transition.restartsGame !== "boolean") fail("session-hooks", "restartsGame must be a boolean", step, input);
334
+ if (transition.restartsGame) {
335
+ if (!transition.undoBarrier || engine.outcome(transition.state) !== null) fail("session-hooks", "restart requires an undo barrier and an unfinished state", step, input);
336
+ clockStarts.length = 0;
337
+ }
338
+ }
339
+ const flag = transition.startsClock;
340
+ if (flag === void 0) return;
341
+ rec.ran("session-hooks");
342
+ if (typeof flag !== "boolean") return fail("session-hooks", `startsClock must be a boolean, got ${show(flag)}`, step, input);
343
+ if (!flag) return;
344
+ if (engine.clockStart !== "transition") return fail("session-hooks", "a transition sets startsClock, but the engine doesn't declare clockStart: 'transition', so runners ignore it", step, input);
345
+ if (clockStarts.length > 0) fail("session-hooks", `startsClock is set again, after input ${clockStarts[0]} set it; it starts the clock at most once a game`, step, input);
346
+ clockStarts.push(step);
347
+ };
348
+ /** `worthSaving` answers a boolean, the same one every time, for the state and for its JSON copy. */
349
+ const checkWorthSaving = (state, step) => {
350
+ const hook = engine.worthSaving;
351
+ if (hook === void 0) return;
352
+ rec.ran("session-hooks");
353
+ try {
354
+ const first = hook(state);
355
+ if (typeof first !== "boolean") return fail("session-hooks", `worthSaving returned ${show(first)}, not a boolean`, step);
356
+ const again = hook(state);
357
+ const copy = hook(deepFreeze(jsonRoundTrip(state)));
358
+ if (again !== first || copy !== first) fail("session-hooks", `worthSaving answered ${String(first)}, then ${show(again)}, then ${show(copy)} for a JSON copy of the same state; it must be pure`, step);
359
+ } catch (error) {
360
+ const message = errorMessage(error);
361
+ const mutation = /read[- ]only|not extensible|frozen|Cannot (assign|add|delete|define)/i.test(message);
362
+ fail("session-hooks", mutation ? `worthSaving mutated its state (states are frozen): ${message}` : `worthSaving threw: ${message}`, step);
363
+ }
364
+ };
365
+ /** Checks every transition the engine accepts for the game: JSON, schemas, events. */
366
+ const checkTransition = (transition, step, input) => {
367
+ rec.ran("json");
368
+ if (!isJson(transition)) fail("json", `a transition isn't JSON: ${jsonProblem(transition)}`, step, input);
369
+ checkSchema(engine.schemas.state, transition.state, "a transition state", (m) => fail("schemas", m, step, input));
370
+ for (const beat of transition.beats) {
371
+ checkSchema(engine.schemas.event, beat.event, "an event", (m) => fail("schemas", m, step, input));
372
+ if (beat.state !== void 0) checkSchema(engine.schemas.state, beat.state, "a beat state", (m) => fail("schemas", m, step, input));
373
+ for (const viewer of viewers) {
374
+ const redacted = engine.eventFor(beat.event, viewer);
375
+ if (redacted === null) continue;
376
+ rec.ran("json");
377
+ if (!isJson(redacted)) fail("json", `eventFor(event, ${viewer}) isn't JSON: ${jsonProblem(redacted)}`, step, input);
378
+ checkSchema(engine.schemas.event, redacted, `eventFor(event, ${viewer})`, (m) => fail("schemas", m, step, input));
379
+ }
380
+ }
381
+ };
382
+ /** Commits an input: the game moves on. Returns false if the engine refused it. */
383
+ const commit = (input, expected) => {
384
+ const step = steps.length;
385
+ const tried = attempt(state, input, rngState, step);
386
+ if (tried.kind !== "accepted") return false;
387
+ if (expected !== void 0) {
388
+ const diff = firstDifference(expected, tried.transition);
389
+ if (diff !== null) fail("determinism", `the same input gave a different transition when committed: ${diff}`, step, input);
390
+ }
391
+ checkTransition(tried.transition, step, input);
392
+ checkClockStart(tried.transition, step, input);
393
+ if (input.kind === "command") checkSchema(engine.schemas.command, input.command, "a command", (m) => fail("schemas", m, step, input));
394
+ if (input.kind === "job") {
395
+ awaited.delete(input.jobId);
396
+ closed.push(input.jobId);
397
+ }
398
+ for (const job of tried.transition.jobs ?? []) {
399
+ rec.ran("jobs");
400
+ if (requested.has(job.id)) fail("jobs", `job id "${job.id}" was requested twice; ids must never repeat within a game`, step + 1);
401
+ requested.add(job.id);
402
+ const spec = engine.jobs?.[job.kind];
403
+ if (spec === void 0) fail("jobs", `a transition requested job kind "${job.kind}", which engine.jobs doesn't declare`, step + 1);
404
+ else checkSchema(spec.payload, job.payload, `the payload of job "${job.id}"`, (m) => fail("schemas", m, step + 1));
405
+ rec.ran("json");
406
+ if (!isJson(job)) fail("json", `a job request isn't JSON: ${jsonProblem(job)}`, step + 1);
407
+ const runner = options.jobs?.[job.kind];
408
+ let value = null;
409
+ if (runner === void 0) fail("jobs", `no fake runner for job kind "${job.kind}"; add it to the jobs option`, step + 1);
410
+ else try {
411
+ value = runner(job.payload, driver);
412
+ if (spec !== void 0) checkSchema(spec.result, value, `the fake runner's value for job "${job.id}"`, (m) => fail("jobs", m, step + 1));
413
+ } catch (error) {
414
+ fail("jobs", `the fake runner for "${job.kind}" threw: ${errorMessage(error)}`, step + 1);
415
+ }
416
+ awaited.set(job.id, {
417
+ kind: job.kind,
418
+ value,
419
+ fails: runner === void 0 || driver.next() < failureRate
420
+ });
421
+ }
422
+ steps.push({
423
+ input,
424
+ transition: tried.transition,
425
+ rngAfter: tried.rngAfter
426
+ });
427
+ state = tried.transition.state;
428
+ rngState = tried.rngAfter;
429
+ return true;
430
+ };
431
+ for (;;) {
432
+ const step = steps.length;
433
+ let pending = [];
434
+ let outcome = null;
435
+ try {
436
+ pending = engine.pending(state);
437
+ outcome = engine.outcome(state);
438
+ } catch (error) {
439
+ fail("rejections", `pending or outcome threw: ${errorMessage(error)}`, step);
440
+ break;
441
+ }
442
+ rec.ran("turns");
443
+ const seatIds = seatList.map((s) => s.id);
444
+ if (!Array.isArray(pending) || pending.some((s) => !seatIds.includes(s)) || new Set(pending).size !== pending.length) {
445
+ fail("turns", `pending() must list distinct seat ids, got ${show(pending)}`, step);
446
+ pending = [];
447
+ }
448
+ if (outcome !== null && pending.length > 0) fail("turns", `pending() is ${show(pending)} although the game has an outcome`, step);
449
+ rec.ran("json");
450
+ if (!isJson(state)) fail("json", `the state isn't JSON: ${jsonProblem(state)}`, step);
451
+ if (outcome !== null && !isJson(outcome)) fail("json", `the outcome isn't JSON: ${jsonProblem(outcome)}`, step);
452
+ checkSchema(engine.schemas.state, state, "the state", (m) => fail("schemas", m, step));
453
+ const views = /* @__PURE__ */ new Map();
454
+ for (const viewer of viewers) {
455
+ const view = engine.viewFor(state, viewer);
456
+ views.set(viewer, view);
457
+ rec.ran("json");
458
+ if (!isJson(view)) fail("json", `viewFor(state, ${viewer}) isn't JSON: ${jsonProblem(view)}`, step);
459
+ checkSchema(engine.schemas.view, view, `viewFor(state, ${viewer})`, (m) => fail("schemas", m, step));
460
+ }
461
+ const keys = seatList.map((s) => keyOf(state, s.id));
462
+ for (const [i, key] of keys.entries()) {
463
+ rec.ran("json");
464
+ if (!isJson(key)) fail("json", `decisionKey(state, ${i}) isn't JSON: ${jsonProblem(key)}`, step);
465
+ }
466
+ if (options.invariants !== void 0) {
467
+ rec.ran("invariants");
468
+ try {
469
+ const problem = invariantMessage(options.invariants(state));
470
+ if (problem !== null) fail("invariants", `invariant violated: ${problem}`, step);
471
+ } catch (error) {
472
+ fail("invariants", `invariant violated: ${errorMessage(error)}`, step);
473
+ }
474
+ }
475
+ checkWorthSaving(state, step);
476
+ const accepted = /* @__PURE__ */ new Map();
477
+ const acceptedTransitions = /* @__PURE__ */ new Map();
478
+ if (outcome === null) for (const seat of pending) {
479
+ let candidates = [];
480
+ try {
481
+ if (options.commands !== void 0) candidates = sample(options.commands(state, seat, driver), CANDIDATE_CAP, driver);
482
+ else if (engine.ai !== void 0) {
483
+ const level = driver.int(0, engine.ai.levels.length);
484
+ candidates = [engine.ai.choose(views.get(seat), seat, engine.ai.levels[level] ?? "", cloneRng(driver))];
485
+ }
486
+ } catch (error) {
487
+ fail(options.commands === void 0 ? "ai" : "outcome", `generating commands threw: ${errorMessage(error)}`, step);
488
+ }
489
+ const ok = [];
490
+ const okTransitions = [];
491
+ for (const command of candidates) {
492
+ rec.ran("json");
493
+ if (!isJson(command)) fail("json", `a command isn't JSON: ${jsonProblem(command)}`, step);
494
+ const tried = attempt(state, {
495
+ kind: "command",
496
+ seat,
497
+ command
498
+ }, rngState, step);
499
+ if (tried.kind === "accepted") {
500
+ ok.push(command);
501
+ okTransitions.push(tried.transition);
502
+ }
503
+ }
504
+ accepted.set(seat, ok);
505
+ acceptedTransitions.set(seat, okTransitions);
506
+ }
507
+ positions.push({
508
+ state,
509
+ rng: rngState,
510
+ pending,
511
+ keys,
512
+ accepted
513
+ });
514
+ if (outcome !== null) {
515
+ rec.ran("outcome");
516
+ const problem = outcomeProblem(outcome, seatList);
517
+ if (problem !== null) fail("outcome", `invalid outcome: ${problem}; outcome ${show(outcome)}`, step);
518
+ const last = [...steps].reverse().find((s) => s.input.kind === "command")?.input;
519
+ for (const seat of seatList) {
520
+ if (last !== void 0 && last.kind === "command") mustReject("outcome", state, {
521
+ kind: "command",
522
+ seat: seat.id,
523
+ command: last.command
524
+ }, rngState, step, `a command from seat ${seat.id} after the outcome`);
525
+ if (options.commands !== void 0) {
526
+ const generate = options.commands;
527
+ for (const command of sample(safely(() => generate(state, seat.id, driver)), 3, driver)) mustReject("outcome", state, {
528
+ kind: "command",
529
+ seat: seat.id,
530
+ command
531
+ }, rngState, step, `a command from seat ${seat.id} after the outcome`);
532
+ }
533
+ mustReject("outcome", state, {
534
+ kind: "resign",
535
+ seat: seat.id
536
+ }, rngState, step, `a resign from seat ${seat.id} after the outcome`);
537
+ }
538
+ break;
539
+ }
540
+ if (step >= maxSteps) {
541
+ rec.ran("outcome");
542
+ if (requireOutcome) fail("outcome", `no outcome after ${maxSteps} inputs (raise maxSteps, or set requireOutcome: false)`, step);
543
+ break;
544
+ }
545
+ const pendingCommands = [...accepted.values()].flat();
546
+ const outsiders = [
547
+ ...seatList.map((s) => s.id).filter((id) => !pending.includes(id)),
548
+ -1,
549
+ seatList.length
550
+ ];
551
+ for (const seat of outsiders) {
552
+ for (const command of sample(pendingCommands, 2, driver)) outsideCommand(state, seat, command, rngState, step);
553
+ const generate = options.commands;
554
+ if (generate !== void 0 && seat >= 0 && seat < seatList.length) for (const command of sample(safely(() => generate(state, seat, driver)), 3, driver)) outsideCommand(state, seat, command, rngState, step);
555
+ }
556
+ if (resignOn) {
557
+ const probes = [...sample(seatList.filter((s) => !pending.includes(s.id)), 1, driver), ...sample(seatList.filter((s) => pending.includes(s.id)), 1, driver)];
558
+ for (const seat of probes) {
559
+ rec.ran("resign");
560
+ const input = {
561
+ kind: "resign",
562
+ seat: seat.id
563
+ };
564
+ const tried = attempt(state, input, rngState, step);
565
+ if (tried.kind === "rejected") {
566
+ const when = pending.includes(seat.id) ? "in turn" : "out of turn";
567
+ fail("resign", `a resign from seat ${seat.id} (${when}) was rejected with "${tried.code}"`, step, input);
568
+ } else if (tried.kind === "accepted") checkTransition(tried.transition, step, input);
569
+ }
570
+ }
571
+ if (engine.ai !== void 0) for (const seat of pending) for (const level of engine.ai.levels) {
572
+ rec.ran("ai");
573
+ let command;
574
+ try {
575
+ command = engine.ai.choose(views.get(seat), seat, level, cloneRng(driver));
576
+ } catch (error) {
577
+ fail("ai", `ai.choose threw for seat ${seat} at level "${level}": ${errorMessage(error)}`, step);
578
+ continue;
579
+ }
580
+ const input = {
581
+ kind: "command",
582
+ seat,
583
+ command
584
+ };
585
+ const tried = attempt(state, input, rngState, step);
586
+ if (tried.kind === "rejected") fail("ai", `ai.choose (level "${level}") chose a command the engine rejects with "${tried.code}"`, step, input);
587
+ }
588
+ mustReject("jobs", state, {
589
+ kind: "job",
590
+ jobId: "conformance:never-requested",
591
+ outcome: {
592
+ ok: true,
593
+ value: null
594
+ }
595
+ }, rngState, step, "a job outcome for an id no transition requested");
596
+ for (const jobId of closed.slice(-3)) mustReject("jobs", state, {
597
+ kind: "job",
598
+ jobId,
599
+ outcome: {
600
+ ok: false,
601
+ reason: "timeout"
602
+ }
603
+ }, rngState, step, `an outcome for job "${jobId}", which was already answered or superseded`);
604
+ const answerable = [];
605
+ for (const [jobId, job] of [...awaited]) {
606
+ rec.ran("jobs");
607
+ const success = {
608
+ kind: "job",
609
+ jobId,
610
+ outcome: {
611
+ ok: true,
612
+ value: job.value
613
+ }
614
+ };
615
+ const failed = {
616
+ kind: "job",
617
+ jobId,
618
+ outcome: {
619
+ ok: false,
620
+ reason: "timeout"
621
+ }
622
+ };
623
+ const a = attempt(state, success, rngState, step).kind === "accepted";
624
+ const b = attempt(state, failed, rngState, step).kind === "accepted";
625
+ if (a && b) answerable.push(jobId);
626
+ else if (a || b) {
627
+ fail("jobs", `the engine accepts a ${a ? "successful" : "failed"} outcome for job "${jobId}" but refuses a ${a ? "failed" : "successful"} one; while it awaits a job it must accept both (it decides what a failure means)`, step, a ? failed : success);
628
+ answerable.push(jobId);
629
+ } else {
630
+ awaited.delete(jobId);
631
+ closed.push(jobId);
632
+ }
633
+ }
634
+ const perturbed = [];
635
+ if (options.perturbHidden !== void 0) for (const viewer of viewers) for (let p = 0; p < perturbations; p += 1) {
636
+ let other;
637
+ try {
638
+ other = deepFreeze(options.perturbHidden(state, viewer, perturbRng));
639
+ } catch (error) {
640
+ fail("no-leak", `perturbHidden threw for viewer ${viewer}: ${errorMessage(error)}`, step);
641
+ continue;
642
+ }
643
+ rec.ran("no-leak");
644
+ perturbedTotal += 1;
645
+ if (!deepEqual(other, state)) perturbedChanged += 1;
646
+ const diff = firstDifference(views.get(viewer), engine.viewFor(other, viewer));
647
+ if (diff !== null) {
648
+ fail("no-leak", `viewFor(state, ${viewer}) changed when only hidden information changed: ${diff}`, step);
649
+ continue;
650
+ }
651
+ if (viewer !== null) {
652
+ const keyDiff = firstDifference(keys[viewer], keyOf(other, viewer));
653
+ if (keyDiff !== null) {
654
+ fail("no-leak", `decisionKey(state, ${viewer}) changed when only hidden information changed: ${keyDiff}`, step);
655
+ continue;
656
+ }
657
+ }
658
+ perturbed.push({
659
+ viewer,
660
+ state: other
661
+ });
662
+ }
663
+ const awaitedIds = answerable;
664
+ const movable = pending.filter((seat) => (accepted.get(seat) ?? []).length > 0);
665
+ let input = null;
666
+ let expected;
667
+ if (step === resignAt && seatList.length > 0) {
668
+ const outside = seatList.filter((s) => !pending.includes(s.id));
669
+ const seat = (outside.length > 0 ? outside : seatList)[driver.int(0, outside.length > 0 ? outside.length : seatList.length)];
670
+ if (seat !== void 0) input = {
671
+ kind: "resign",
672
+ seat: seat.id
673
+ };
674
+ } else if (timeoutOn && pending.length > 0 && driver.next() < .1) input = {
675
+ kind: "timeout",
676
+ seat: pending[driver.int(0, pending.length)] ?? 0
677
+ };
678
+ else if (seatChangesOn && driver.next() < .05) {
679
+ const seat = seatList[driver.int(0, seatList.length)];
680
+ if (seat !== void 0) {
681
+ const toAi = seat.controller === "human";
682
+ const level = engine.ai?.levels[0];
683
+ input = {
684
+ kind: "seat",
685
+ seat: seat.id,
686
+ change: toAi ? {
687
+ type: "controller",
688
+ controller: "ai",
689
+ name: `${seat.name} (AI)`,
690
+ ...level === void 0 ? {} : { aiLevel: level }
691
+ } : {
692
+ type: "controller",
693
+ controller: "human",
694
+ name: seat.name
695
+ }
696
+ };
697
+ }
698
+ }
699
+ if (input === null) {
700
+ const choices = movable.length + awaitedIds.length;
701
+ if (choices === 0) {
702
+ rec.ran("outcome");
703
+ fail("outcome", pending.length === 0 ? "nobody can move: pending() is empty, no job is awaited, and there is no outcome" : `no candidate command was accepted for pending seats ${show(pending)}; check the commands generator or the engine`, step);
704
+ break;
705
+ }
706
+ const pick = driver.int(0, choices);
707
+ if (pick < movable.length) {
708
+ const seat = movable[pick];
709
+ const options_ = accepted.get(seat) ?? [];
710
+ const index = driver.int(0, options_.length);
711
+ input = {
712
+ kind: "command",
713
+ seat,
714
+ command: options_[index]
715
+ };
716
+ expected = acceptedTransitions.get(seat)?.[index];
717
+ } else {
718
+ const jobId = awaitedIds[pick - movable.length];
719
+ const job = awaited.get(jobId);
720
+ input = job === void 0 || job.fails ? {
721
+ kind: "job",
722
+ jobId,
723
+ outcome: {
724
+ ok: false,
725
+ reason: "failed"
726
+ }
727
+ } : {
728
+ kind: "job",
729
+ jobId,
730
+ outcome: {
731
+ ok: true,
732
+ value: job.value
733
+ }
734
+ };
735
+ }
736
+ }
737
+ const before = state;
738
+ const beforeRng = rngState;
739
+ if (!commit(input, expected)) {
740
+ fail(input.kind === "resign" ? "resign" : input.kind === "job" ? "jobs" : input.kind === "command" ? "determinism" : "turns", `the engine rejected ${input.kind === "command" ? "a command it had just accepted from the same state and generator" : `a ${input.kind} input, which is valid whenever the game is in progress`}`, step, input);
741
+ break;
742
+ }
743
+ const committed = steps[steps.length - 1];
744
+ for (const { viewer, state: other } of perturbed) {
745
+ let tried = attempt(other, input, beforeRng, step);
746
+ const trace = (t) => ({
747
+ final: engine.viewFor(t.state, viewer),
748
+ beats: t.beats.map((b) => ({
749
+ event: engine.eventFor(b.event, viewer),
750
+ view: b.state === void 0 ? null : engine.viewFor(b.state, viewer)
751
+ })).filter((b) => b.event !== null)
752
+ });
753
+ const a = trace(committed.transition);
754
+ const comparable = (b) => deepEqual(a.final, b.final) && (a.beats.length !== b.beats.length || deepEqual(a.beats.map((x) => x.view), b.beats.map((x) => x.view)));
755
+ if (options.perturbTransition !== void 0 && (tried.kind !== "accepted" || !comparable(trace(tried.transition)))) {
756
+ let kept;
757
+ try {
758
+ kept = deepFreeze(options.perturbTransition(before, viewer, cloneRng(perturbRng), {
759
+ input,
760
+ states: [committed.transition.state, ...committed.transition.beats.flatMap((beat) => beat.state === void 0 ? [] : [beat.state])]
761
+ }));
762
+ if (deepEqual(kept, before)) continue;
763
+ if (!deepEqual(engine.viewFor(before, viewer), engine.viewFor(kept, viewer)) || viewer !== null && !deepEqual(keyOf(before, viewer), keyOf(kept, viewer))) {
764
+ fail("no-leak", `transition perturbation changed information already visible to viewer ${viewer}`, step, input);
765
+ continue;
766
+ }
767
+ } catch (error) {
768
+ fail("no-leak", `transition perturbation threw for viewer ${viewer}: ${errorMessage(error)}`, step, input);
769
+ continue;
770
+ }
771
+ tried = attempt(kept, input, beforeRng, step);
772
+ }
773
+ if (tried.kind !== "accepted") continue;
774
+ const b = trace(tried.transition);
775
+ if (!deepEqual(a.final, b.final)) continue;
776
+ if (a.beats.length === b.beats.length && !deepEqual(a.beats.map((x) => x.view), b.beats.map((x) => x.view))) continue;
777
+ rec.ran("no-leak");
778
+ const diff = firstDifference(a.beats, b.beats);
779
+ if (diff !== null) fail("no-leak", `eventFor(event, ${viewer}) differs between two transitions that look identical to that viewer: ${diff}`, step, input);
780
+ }
781
+ {
782
+ rec.ran("assist");
783
+ const got = committed.transition.assist;
784
+ if (got !== void 0 && !ENGINE_ASSISTS.includes(got)) fail("assist", `assist must be 'hint', 'check' or 'reveal' (undo is the platform's own), got ${show(got)}`, step, input);
785
+ else if (options.assist !== void 0) {
786
+ const want = options.assist(input, before, committed.transition.state) ?? void 0;
787
+ if (want !== got) fail("assist", `the spec makes this input's assistance ${show(want ?? null)}, but the transition has assist: ${show(got ?? null)}`, step, input);
788
+ }
789
+ }
790
+ if (options.undoBarrier !== void 0) {
791
+ rec.ran("undo");
792
+ const want = options.undoBarrier(input, before, committed.transition.state);
793
+ if (want !== (committed.transition.undoBarrier === true)) fail("undo", `the spec makes this input ${want ? "an undo barrier" : "undoable"}, but the transition has undoBarrier: ${String(committed.transition.undoBarrier)}`, step, input);
794
+ }
795
+ }
796
+ totalSteps += steps.length;
797
+ const final = positions[positions.length - 1];
798
+ if (final === void 0) continue;
799
+ {
800
+ rec.ran("determinism");
801
+ const rng = restoreRng(seedState);
802
+ const again = engine.init(setup.config, seatList, rng);
803
+ let ok = again.ok && deepEqual(again.value, positions[0]?.state);
804
+ if (!ok) fail("determinism", `init gave a different state for the same seed: ${again.ok ? firstDifference(positions[0]?.state, again.value) : "rejected"}`, 0);
805
+ let current = again.ok ? again.value : null;
806
+ for (const [i, s] of steps.entries()) {
807
+ if (!ok || current === null) break;
808
+ try {
809
+ const result = engine.apply(current, s.input, rng);
810
+ if (!result.ok) {
811
+ fail("determinism", `the same input was rejected on a second run: ${show(result.error)}`, i, s.input);
812
+ ok = false;
813
+ break;
814
+ }
815
+ const diff = firstDifference(s.transition, result.value) ?? firstDifference(s.rngAfter, rng.save());
816
+ if (diff !== null) {
817
+ fail("determinism", `a second run from the same seed diverged: ${diff}`, i, s.input);
818
+ ok = false;
819
+ break;
820
+ }
821
+ current = result.value.state;
822
+ const outcomeDiff = firstDifference(engine.outcome(positions[i + 1]?.state), engine.outcome(current));
823
+ if (outcomeDiff !== null) fail("determinism", `outcome differs on a second run: ${outcomeDiff}`, i + 1);
824
+ } catch (error) {
825
+ fail("determinism", `apply threw on a second run: ${errorMessage(error)}`, i, s.input);
826
+ ok = false;
827
+ }
828
+ }
829
+ }
830
+ {
831
+ rec.ran("replay");
832
+ const replay = replayUpTo(steps.length);
833
+ if (!isJson(replay)) fail("replay", "the replay isn't JSON", steps.length);
834
+ else try {
835
+ const result = replayEngine$1(engine, jsonRoundTrip(replay));
836
+ if (!result.ok) fail("replay", `the replay doesn't apply: ${show(result.error)}`, steps.length);
837
+ else {
838
+ const diff = firstDifference(final.state, result.value.state) ?? firstDifference(engine.outcome(final.state), result.value.outcome) ?? firstDifference(steps.map((s) => s.transition.undoBarrier === true), result.value.barriers);
839
+ if (diff !== null) fail("replay", `the replay ends somewhere else: ${diff}`, steps.length);
840
+ }
841
+ const runner = replayEngine(engine, jsonRoundTrip(replay));
842
+ if (!runner.ok) fail("replay", `the session runner's replay doesn't apply: ${show(runner.error)}`, steps.length);
843
+ else {
844
+ const diff = firstDifference(final.state, runner.value.state) ?? firstDifference(final.rng, runner.value.rng);
845
+ if (diff !== null) fail("replay", `the session runner's replay ends somewhere else: ${diff}`, steps.length);
846
+ }
847
+ } catch (error) {
848
+ fail("replay", `replaying threw: ${errorMessage(error)}`, steps.length);
849
+ }
850
+ }
851
+ {
852
+ rec.ran("saves");
853
+ const k = driver.int(0, positions.length);
854
+ const at = positions[k];
855
+ const envelope = {
856
+ gameId,
857
+ build: "0.0.0-test",
858
+ rulesVersion: engine.rulesVersion,
859
+ saveVersion: options.saves?.version ?? 1,
860
+ rng: at.rng,
861
+ seats: seatList,
862
+ state: at.state,
863
+ replay: replayUpTo(k)
864
+ };
865
+ if (!isJson(envelope)) fail("saves", "the save envelope isn't JSON", k);
866
+ else {
867
+ const restored = jsonRoundTrip(envelope);
868
+ const decoded = await validate(engine.schemas.state, restored.state);
869
+ if (!decoded.ok) fail("saves", `a saved state fails schemas.state: ${formatIssues(decoded.issues)}`, k);
870
+ else if (!deepEqual(decoded.value, at.state)) fail("saves", `schemas.state changed a valid saved state: ${firstDifference(at.state, decoded.value)}`, k);
871
+ else try {
872
+ const rng = restoreRng(restored.rng);
873
+ let current = decoded.value;
874
+ for (const s of steps.slice(k)) {
875
+ const result = engine.apply(current, s.input, rng);
876
+ if (!result.ok) throw new Error(`input rejected after restoring: ${show(result.error)}`);
877
+ current = result.value.state;
878
+ }
879
+ const diff = firstDifference(final.state, current);
880
+ if (diff !== null) fail("saves", `a game restored from a save at input ${k} ended differently: ${diff}`, k);
881
+ } catch (error) {
882
+ fail("saves", `continuing a restored save failed: ${errorMessage(error)}`, k);
883
+ }
884
+ }
885
+ }
886
+ for (const seat of seatList.map((s) => s.id)) {
887
+ let runStart = 0;
888
+ for (let i = 1; i <= positions.length; i += 1) {
889
+ if (i < positions.length && deepEqual(positions[i]?.keys[seat], positions[i - 1]?.keys[seat])) continue;
890
+ const [base, ...later] = positions.slice(runStart, i).map((p, offset) => ({
891
+ p,
892
+ index: runStart + offset
893
+ })).filter(({ p }) => p.pending.includes(seat) && engine.outcome(p.state) === null);
894
+ if (base !== void 0) for (const { p, index } of later.slice(0, 4)) {
895
+ rec.ran("stale");
896
+ const rejectedAt = (cmds, pos, where) => cmds.filter((command) => attempt(pos.state, {
897
+ kind: "command",
898
+ seat,
899
+ command
900
+ }, pos.rng, where).kind !== "accepted");
901
+ const lost = rejectedAt(base.p.accepted.get(seat) ?? [], p, index);
902
+ const gained = rejectedAt(p.accepted.get(seat) ?? [], base.p, base.index);
903
+ if (lost.length > 0 || gained.length > 0) {
904
+ const command = lost[0] ?? gained[0];
905
+ fail("stale", `decisionKey for seat ${seat} is the same after input ${base.index} and after input ${index}, but command ${show(command)} is ${lost.length > 0 ? "accepted at the first and rejected at the second" : "rejected at the first and accepted at the second"}; the room would let a stale command through`, index, {
906
+ kind: "command",
907
+ seat,
908
+ command
909
+ });
910
+ }
911
+ }
912
+ runStart = i;
913
+ }
914
+ }
915
+ {
916
+ const humanAt = [];
917
+ let seatsNow = seatList;
918
+ for (const s of steps) {
919
+ const input = s.input;
920
+ humanAt.push(input.kind === "command" && seatsNow.find((x) => x.id === input.seat)?.controller === "human");
921
+ if (input.kind === "seat" && input.change.type === "controller") {
922
+ const { change } = input;
923
+ seatsNow = seatsNow.map((x) => x.id === input.seat ? {
924
+ id: x.id,
925
+ name: change.name,
926
+ controller: change.controller
927
+ } : x);
928
+ }
929
+ }
930
+ const barrierAt = steps.flatMap((s, i) => s.transition.undoBarrier === true ? [i] : []);
931
+ const assistAt = steps.flatMap((s, i) => s.transition.assist === void 0 ? [] : [i]);
932
+ const others = sample(steps.map((_, i) => i).filter((i) => !barrierAt.includes(i)), 4, driver);
933
+ for (const k of /* @__PURE__ */ new Set([
934
+ ...barrierAt,
935
+ ...assistAt,
936
+ ...others
937
+ ])) {
938
+ rec.ran("undo");
939
+ const cut = humanAt.slice(0, k + 1).lastIndexOf(true);
940
+ const blocked = cut >= 0 && steps.slice(cut, k + 1).some((s) => s.transition.undoBarrier === true);
941
+ try {
942
+ const result = undoByReplay(engine, replayUpTo(k + 1));
943
+ if (cut < 0) {
944
+ if (result.ok || result.error.code !== "NothingToUndo") fail("undo", `after input ${k} no human command is left to undo, but undo answered ${show(result.ok ? "ok" : result.error.code)}`, k + 1);
945
+ } else if (blocked) {
946
+ if (result.ok) fail("undo", `undo after input ${k} would step back past an undo barrier (replay doesn't reproduce the barrier)`, k + 1);
947
+ } else if (!result.ok) fail("undo", `undo after input ${k} was refused with "${result.error.code}" although no barrier lies in the way`, k + 1);
948
+ else {
949
+ const diff = firstDifference(positions[cut]?.state, result.value.state);
950
+ if (diff !== null) fail("undo", `undo after input ${k} didn't restore the state before input ${cut}: ${diff}`, k + 1);
951
+ rec.ran("assist");
952
+ const missing = steps.slice(0, k + 1).flatMap((s) => s.transition.assist === void 0 ? [] : [s.transition.assist]).filter((kind) => !result.value.assists.includes(kind));
953
+ if (missing.length > 0) fail("assist", `after an undo, replay no longer reports the assistance ${show(missing)} flagged during play`, k + 1);
954
+ }
955
+ } catch (error) {
956
+ fail("undo", `undo by replay threw: ${errorMessage(error)}`, k + 1);
957
+ }
958
+ }
959
+ }
960
+ }
961
+ }
962
+ if (options.saves !== void 0) {
963
+ const { version, migrations, fixtures } = options.saves;
964
+ const first = options.saves.firstVersion ?? 1;
965
+ for (const m of migrations) {
966
+ rec.ran("saves");
967
+ if (m.to !== m.from + 1) rec.fail("saves", { message: `migration ${m.from} → ${m.to} must step exactly one version` });
968
+ }
969
+ for (let v = first; v <= version; v += 1) {
970
+ rec.ran("saves");
971
+ if (!fixtures.some((f) => f.saveVersion === v)) rec.fail("saves", { message: `no save fixture for save version ${v}; keep one for every version ever shipped` });
972
+ }
973
+ for (const fixture of fixtures) {
974
+ rec.ran("saves");
975
+ try {
976
+ const migrated = runMigrations(migrations, fixture.state, fixture.saveVersion, version);
977
+ if (!migrated.ok) {
978
+ rec.fail("saves", { message: `save fixture "${fixture.name}" (v${fixture.saveVersion}) doesn't migrate: ${show(migrated.error)}` });
979
+ continue;
980
+ }
981
+ const decoded = await validate(engine.schemas.state, migrated.value);
982
+ if (!decoded.ok) rec.fail("saves", { message: `save fixture "${fixture.name}" (v${fixture.saveVersion}) migrates to a state that fails schemas.state: ${formatIssues(decoded.issues)}` });
983
+ } catch (error) {
984
+ rec.fail("saves", { message: `migrating save fixture "${fixture.name}" threw: ${errorMessage(error)}` });
985
+ }
986
+ }
987
+ } else rec.note("saves", "round trip only; give saves.fixtures to check the migration chain");
988
+ await Promise.all(validations);
989
+ if (options.perturbHidden !== void 0 && perturbedTotal > 0 && perturbedChanged === 0) rec.fail("no-leak", { message: `perturbHidden never changed a state in ${perturbedTotal} tries, so the no-leak check proved nothing; it must reshuffle what the viewer can't see` });
990
+ return rec.report(options.name ?? "engine", totalGames, totalSteps);
991
+ };
992
+ //#endregion
993
+ export { ENGINE_CHECKS, checkEngine };
994
+
995
+ //# sourceMappingURL=engine-conformance.js.map