diff --git a/design/30-slices.md b/design/30-slices.md index 1fe043da..ff98672c 100644 --- a/design/30-slices.md +++ b/design/30-slices.md @@ -470,7 +470,7 @@ Out of scope: Capture refusing a `replayCompatible: false` session (capture is u ## S135 — The Replay Oracle Crosses Content Epochs -Status: todo +Status: done Delivers: A recorded play session that moved onto new content partway through is a regression fixture like any other. A later engine version replays its adoptions in place, and reports a divergence if the kind would now refuse one. diff --git a/design/90-decisions.md b/design/90-decisions.md index 32534cd2..39c036a0 100644 --- a/design/90-decisions.md +++ b/design/90-decisions.md @@ -2691,6 +2691,31 @@ Context: S130 added a test that `branchSession` refuses a crossed log with `inva Chosen: both are removed. The first asserted the interim refusal S134 retires. The second relied on the store's own registry deciding a branch, which no longer happens: the engine's archive decides, and S134.4's tests cover a starting version or adopted version that is no longer held. Rejected: **Rewriting the second against a mutated registry.** The engine snapshots what it holds, so mutating the store's registry no longer reaches a branch, and the test would assert nothing. +### 2026-10-09 — S135: withdrawal is read from the registry, every named version from the archive +Context: 07 §6 widens `campaign_version_missing` to any version a fixture names, the starting `campaignVersion` and each adoption's. `campaign_withdrawn` still means the id is gone, and a `ResolutionArchive` resolves `(id, version)` pairs but cannot enumerate ids. +Chosen: the runner's pre-check reports `campaign_withdrawn` when the runner context's registry lacks the id. Otherwise every named version must resolve through `engine.content.resolve`, to a campaign carrying that exact version, as the kernel's own epoch resolution requires. Any miss is `campaign_version_missing`, before `createGame` runs. +Rejected: **Deciding withdrawal from the archive.** It would need an enumeration the archive does not offer, or a probe of some version, and either would put a second definition of "the id is gone" beside the registry's. + +### 2026-10-09 — S135: an adoption naming the version the game is already on is a malformed fixture +Context: `adoptContent`'s step 0 returns the state unchanged, with no log entry, when the target is the version the game is on. An `AdoptionDecision` for it would have no `seq`, and would read as accepted although nothing happened. +Chosen: the runner throws on such a submission, as it already throws on a missing seed or a `createGame` rejection. Capture records only adoptions that happened (16 §5.6), so the shape cannot arise from a recorded session. +Rejected: **Recording it as accepted with `seq: null`.** `seq: null` means refused everywhere else in `Outcome`, and the fixture would stay silently wrong. + +### 2026-10-09 — S135: the replay types and helpers ship on `/authoring`, not `src/index.ts` +Context: the slice's *Touches* names `src/index.ts`. The replay types were already exported from the `/authoring` subpath (20-contract §19), and the new `ActionSubmission`, `AdoptionSubmission`, `ActionDecision` and `AdoptionDecision` belong beside them. +Chosen: `src/authoring.ts` exports the four new types beside `Submission`, `Decision` and `Outcome`. `src/index.ts` is unchanged. +Rejected: **Exporting them from the package root.** It would split one family across two entry points, against §19. + +### 2026-10-09 — S135: corpus suites that play a fixture directly read its actions through `actionSubmissions` +Context: several suites and `scripts/capture-save-fixtures.ts` play a fixture's submissions through `submitAction` or a client, without the runner. A `Submission` can now be an adoption, which those paths cannot replay. +Chosen: `replay-corpus.ts` exports `actionSubmissions(fixture)`, which returns the action submissions and throws on an adoption, and `isAdoption`. The direct-play suites use the first, so a crossing fixture in their prefix fails loudly. `reasonCompletenessGuard` skips adoptions, since an adoption is not play (C24). `statesThatFailToDeserialize` creates the game on the fixture's `campaignVersion` and adopts through `engine.adoptContent`. +Rejected: **Filtering adoptions out silently everywhere.** A suite would then replay a crossing fixture as if it never crossed, and compare against an outcome it cannot reach. + +### 2026-10-09 — S135: the corpus's crossing fixture adopts an epoch derived in the test, with its own `migrateState` +Context: S135.4 needs a corpus fixture that crosses an epoch, run against an archive holding both. Bureaucracy ships one epoch, 2.0.0. Adoption runs the *target* campaign's `migrateState` from the source version, and the shipped one migrates only from 1.0.0, so an epoch that inherited it would be refused with `migration_failed`. +Chosen: `bureaucracy-epoch-crossing` starts on 2.0.0, plays `wait`, adopts 2.1.0, then plays `registry_route_listen`. The bureaucracy replay suite derives 2.1.0 from the built campaign by adding one int variable, purely additive, with a `migrateState` that passes a 2.0.0 state through unchanged and delegates any other version to the shipped one. It publishes 2.1.0 into the engine's archive; the registry, and so every other fixture's start, stays 2.0.0. +Rejected: **Shipping a 2.1.0 bureaucracy.** It would change content to test the oracle. **Clearing `migrateState` on the derived epoch.** It would drop the 1.0.0 save path the shipped campaign carries. + ### 2026-10-08 — `getStrings` returns the union of a session's live epochs, newest winning per key (red-team F3) Context: red-team F3 (`design/redteam/2026-10-08-10-design.md`, STRUCTURAL). A client calls `getScene` on `e1`, and then another client's `resumeSession` adopts `e2`, which renamed a label key. The first client's `getStrings` then returned only the `e2` table, so its scene carried a key it could not resolve. Neither `Scene` nor `StringTable` carries an epoch, so the client could not detect this. The race is between completed calls, so the session lock cannot close it. Chosen: `getStrings(sessionId)` returns the union of the string tables of the session's live epochs. Those are the starting version, or the last migration entry's `to`, and every content entry's `to` after it. Each key resolves from the most recent of those epochs that defines it. New invariant C27: a session's table never loses a key, so any key in any result the store returned for a session resolves in every later `getStrings`. A client whose cached table lacks a key fetches the table again. The union also covers a `submitAction` that adopts after commit, whose messages come from the old epoch while its scene comes from the new one. Accepted residual: a key both epochs define resolves to the newer wording, even on an older scene. No type, signature or MCP schema changes, and the union spans only epochs the log already names, which retention already keeps. The design is `16-content-epochs.md` §5.7 and Clients §2; the contract is `20-contract.md` C27 and §7. diff --git a/docs/docs/engine/TODO.md b/docs/docs/engine/TODO.md index 545d5e89..1773b3dc 100644 --- a/docs/docs/engine/TODO.md +++ b/docs/docs/engine/TODO.md @@ -464,7 +464,7 @@ Out of scope: Capture refusing a `replayCompatible: false` session (capture is u ## S135 — The Replay Oracle Crosses Content Epochs -Status: todo +Status: done Delivers: A recorded play session that moved onto new content partway through is a regression fixture like any other. A later engine version replays its adoptions in place, and reports a divergence if the kind would now refuse one. diff --git a/src/engine/fixtures/replay/bureaucracy-epoch-crossing.fixture.json b/src/engine/fixtures/replay/bureaucracy-epoch-crossing.fixture.json new file mode 100644 index 00000000..26755622 --- /dev/null +++ b/src/engine/fixtures/replay/bureaucracy-epoch-crossing.fixture.json @@ -0,0 +1,20 @@ +{ + "name": "bureaucracy-epoch-crossing", + "config": { + "campaignId": "bulgaria-bureaucracy", + "seed": "bureaucracy-seed-1" + }, + "campaignVersion": "2.0.0", + "capturedUnder": "0.11.0", + "submissions": [ + { + "actionId": "wait" + }, + { + "adopt": "2.1.0" + }, + { + "actionId": "registry_route_listen" + } + ] +} diff --git a/src/engine/fixtures/replay/bureaucracy-epoch-crossing.outcome.json b/src/engine/fixtures/replay/bureaucracy-epoch-crossing.outcome.json new file mode 100644 index 00000000..370a7293 --- /dev/null +++ b/src/engine/fixtures/replay/bureaucracy-epoch-crossing.outcome.json @@ -0,0 +1,32 @@ +{ + "finalStatus": "active", + "acceptedActions": 2, + "decisions": [ + { + "index": 0, + "seq": 0, + "actionId": "wait", + "accepted": true + }, + { + "index": 1, + "seq": 1, + "adopt": "2.1.0", + "accepted": true + }, + { + "index": 2, + "seq": 2, + "actionId": "registry_route_listen", + "accepted": true + } + ], + "achievements": [ + "found_registry_route_event_1a" + ], + "terminal": { + "terminal": false, + "terminalId": null, + "endingId": null + } +} diff --git a/src/engine/scripts/capture-save-fixtures.test.ts b/src/engine/scripts/capture-save-fixtures.test.ts index cdd30bbd..1ad7cd5d 100644 --- a/src/engine/scripts/capture-save-fixtures.test.ts +++ b/src/engine/scripts/capture-save-fixtures.test.ts @@ -18,7 +18,7 @@ import { describe, it, expect } from "vitest"; import { createEngine } from "../src/core/kernel/engine.js"; import { createInMemorySessionStore } from "../src/core/session/store.js"; import { TextClient } from "../src/clients/text/client.js"; -import { loadFixture } from "../src/campaigns/replay-corpus.js"; +import { actionSubmissions, loadFixture } from "../src/campaigns/replay-corpus.js"; import { SessionStoreError } from "../src/core/session/types.js"; import type { SaveRecordStore, SessionRecordStore, StoredSaveRecord, StoredSessionRecord } from "../src/core/session/types.js"; import { SAVE_FIXTURES, buildRegistry } from "./capture-save-fixtures.js"; @@ -65,7 +65,7 @@ describe("W104.3 — representative saves load through a real SessionStore", () const independentClient = new TextClient(independentStore); const started = await independentClient.createSession(fixture.config); const sessionId = started.value.sessionId; - for (const submission of fixture.submissions) { + for (const submission of actionSubmissions(fixture)) { await independentClient.submitAction(sessionId, submission.actionId, submission.params); } const expectedScene = (await independentClient.getScene(sessionId)).value; diff --git a/src/engine/scripts/capture-save-fixtures.ts b/src/engine/scripts/capture-save-fixtures.ts index 9068e483..a674853a 100644 --- a/src/engine/scripts/capture-save-fixtures.ts +++ b/src/engine/scripts/capture-save-fixtures.ts @@ -13,7 +13,7 @@ import { mkdirSync, writeFileSync } from "node:fs"; import { createEngine } from "../src/core/kernel/engine.js"; import { buildValidatedContentRegistry } from "../src/core/validation/tiered.js"; import { buildSaveEnvelope, serializeSaveEnvelope } from "../src/core/persistence/envelope.js"; -import { loadFixture } from "../src/campaigns/replay-corpus.js"; +import { actionSubmissions, loadFixture } from "../src/campaigns/replay-corpus.js"; import { buildBulgariaBureaucracyCampaign } from "../src/campaigns/bulgaria-bureaucracy.js"; import { buildStableLifeCampaign } from "../src/campaigns/stable-life.js"; import { buildStableLifeEffectsCampaign } from "../src/campaigns/stable-life-effects.js"; @@ -69,7 +69,7 @@ function runFixtureToState(engine: Engine, fixtureName: string): GameState { const created = engine.createGame(fixture.config); if (!created.ok || !created.value) throw new Error(`"${fixtureName}": createGame rejected`); let state = created.value; - for (const submission of fixture.submissions) { + for (const submission of actionSubmissions(fixture)) { const result = engine.submitAction(state, submission.actionId, submission.params); if (result.ok && result.value) state = result.value; } diff --git a/src/engine/src/authoring.test.ts b/src/engine/src/authoring.test.ts index 89ac0588..0e9f9fd1 100644 --- a/src/engine/src/authoring.test.ts +++ b/src/engine/src/authoring.test.ts @@ -45,6 +45,10 @@ const AUTHORING_VALUE_EXPORTS = [ const AUTHORING_TYPE_EXPORTS = [ "AchievementDefinitionSource", + "ActionDecision", + "ActionSubmission", + "AdoptionDecision", + "AdoptionSubmission", "AdventureConfig", "AdventureEnding", "AdventureRoute", @@ -59,6 +63,7 @@ const AUTHORING_TYPE_EXPORTS = [ "Condition", "Consequence", "CourseDefinitionSource", + "Decision", "DifficultyDefinitionSource", "EmployerDefinitionSource", "EndingNodeSource", diff --git a/src/engine/src/authoring.ts b/src/engine/src/authoring.ts index 59db3142..85207aec 100644 --- a/src/engine/src/authoring.ts +++ b/src/engine/src/authoring.ts @@ -80,6 +80,11 @@ export type { PortableMigration, } from "./index.js"; export type { + ActionDecision, + ActionSubmission, + AdoptionDecision, + AdoptionSubmission, + Decision, Outcome, ReplayFixture, ReplayVerdict, diff --git a/src/engine/src/campaigns/bulgaria-bureaucracy.replay.test.ts b/src/engine/src/campaigns/bulgaria-bureaucracy.replay.test.ts index 8f386665..c50f2c62 100644 --- a/src/engine/src/campaigns/bulgaria-bureaucracy.replay.test.ts +++ b/src/engine/src/campaigns/bulgaria-bureaucracy.replay.test.ts @@ -36,6 +36,9 @@ import { buildValidatedContentRegistry } from "../core/validation/tiered.js"; import { createInMemoryProfileStore } from "../core/session/profile-store.js"; import { storyGraphKind } from "../kinds/story-graph/kind.js"; import { createCountingIds } from "../core/determinism/counting-ids.js"; +import { createContentArchive } from "../core/registry/archive.js"; +import type { BuiltCampaign } from "../core/registry/types.js"; +import type { StoryGraphCampaign } from "../kinds/story-graph/campaign.js"; import { buildReplayOutcome, findDivergence, runReplayFixture, type ReplayRunnerContext } from "../core/replay/runner.js"; import type { Outcome, ReplayFixture } from "../core/replay/types.js"; import type { KindRegistry } from "../core/kernel/types.js"; @@ -62,18 +65,51 @@ const FIXTURE_NAMES = fixtureNamesByPrefix("bureaucracy-", CORPUS_DIR); * `CURRENT_STABLE_LIFE_FIXTURE_NAMES` and `STABLE_LIFE_FIXTURE_NAMES`. */ const CURRENT_BUREAUCRACY_FIXTURE_NAMES = fixtureNamesByPrefix("bureaucracy-", FIXTURES_DIR); +/** The epoch `bureaucracy-epoch-crossing` adopts (S135.4): the shipped campaign plus one + * variable, purely additive, so story-graph's `adoptContent` accepts it (16 §6.1). Derived + * here rather than shipped: it exists to give the corpus a second held epoch, not as content. + * Adoption runs the *target's* `migrateState` from the source version, and the shipped one + * migrates only from 1.0.0 — so this epoch passes a shipped-epoch state through unchanged, + * as its author would, and leaves the new variable to `adoptContent`. */ +const BUREAUCRACY_NEXT_EPOCH = "2.1.0"; + +function nextEpoch(built: BuiltCampaign): BuiltCampaign { + const content = built.campaign.content as StoryGraphCampaign; + const shipped = built.campaign.version; + const migrateShipped = built.campaign.migrateState; + return { + ...built, + campaign: { + ...built.campaign, + version: BUREAUCRACY_NEXT_EPOCH, + migrateState: (state, fromVersion) => + fromVersion === shipped + ? { ok: true, value: state, errors: [], warnings: [] } + : migrateShipped + ? migrateShipped(state, fromVersion) + : { ok: false, errors: [{ code: "migration_failed", messageKey: "core.reason.migration_failed" }], warnings: [] }, + content: { ...content, variables: { ...content.variables, forms_refiled: { type: "int", initial: 0 } } }, + }, + }; +} + /** A fresh `ReplayRunnerContext` per call — each fixture gets its own counting `IdSource` * starting at 0 (07 §5) and its own in-memory `ProfileStore`, so achievements from one - * fixture never leak into another's. */ + * fixture never leak into another's. The engine's archive holds the shipped epoch and + * `BUREAUCRACY_NEXT_EPOCH`; the registry, and so every fixture's default, stays the shipped one. */ function makeContext(): ReplayRunnerContext { const built = buildBulgariaBureaucracyCampaign(); if (!built.ok || !built.value) throw new Error("expected the real campaign to build"); const kinds = { "story-graph": storyGraphKind } as unknown as KindRegistry; const registryResult = buildValidatedContentRegistry([built.value], kinds); if (!registryResult.ok || !registryResult.value) throw new Error("expected the real campaign to validate"); + const nextResult = buildValidatedContentRegistry([nextEpoch(built.value)], kinds); + if (!nextResult.ok || !nextResult.value) throw new Error("expected the next epoch to validate"); + const archive = createContentArchive({ kinds, initial: registryResult.value }); + if (!archive.publish(nextResult.value).ok) throw new Error("expected the next epoch to publish"); return { - engine: createEngine({ kinds, registry: registryResult.value, ids: createCountingIds() }), + engine: createEngine({ kinds, registry: registryResult.value, ids: createCountingIds(), archive }), kinds, registry: registryResult.value, profiles: createInMemoryProfileStore(), @@ -173,6 +209,16 @@ describe.skipIf(COMPARING_ACROSS_VERSIONS)("the replay corpus's mechanics (07-re expect(verdict).toEqual({ kind: "unrunnable", reason: "campaign_version_missing" }); }); + it("bureaucracy-epoch-crossing: the adoption is accepted at its own seq and counts in decisions, not acceptedActions (S135.4)", async () => { + const fixture = loadFixture("bureaucracy-epoch-crossing"); + const result = await buildReplayOutcome(makeContext(), fixture); + if (result.kind !== "outcome") throw new Error("expected an outcome"); + + expect(result.outcome.decisions[1]).toEqual({ index: 1, seq: 1, adopt: BUREAUCRACY_NEXT_EPOCH, accepted: true }); + expect(result.outcome.decisions[2]).toEqual({ index: 2, seq: 2, actionId: "registry_route_listen", accepted: true }); + expect(result.outcome.acceptedActions).toBe(2); + }); + it("findDivergence(expected, expected) is always undefined — a fixture never diverges from itself", async () => { for (const name of FIXTURE_NAMES) { const expected = loadExpectedOutcome(name); diff --git a/src/engine/src/campaigns/long-horizon.replay.test.ts b/src/engine/src/campaigns/long-horizon.replay.test.ts index e296bbc8..210e9ffb 100644 --- a/src/engine/src/campaigns/long-horizon.replay.test.ts +++ b/src/engine/src/campaigns/long-horizon.replay.test.ts @@ -45,7 +45,7 @@ import { buildLongHorizonWinCampaign, buildLongHorizonLossCampaign } from "./lon import { buildSimulationCampaign, type SimulationCampaignSource } from "../kinds/simulation/source.js"; import { buildCampaign } from "../core/registry/build.js"; import type { BuiltCampaign, Campaign } from "../core/registry/types.js"; -import { COMPARING_ACROSS_VERSIONS, CORPUS_DIR, FIXTURES_DIR, fixtureNamesByPrefix, hasFixture, loadExpectedOutcome, loadFixture, statesThatFailToDeserialize } from "./replay-corpus.js"; +import { COMPARING_ACROSS_VERSIONS, CORPUS_DIR, FIXTURES_DIR, fixtureNamesByPrefix, hasFixture, loadExpectedOutcome, loadFixture, statesThatFailToDeserialize, actionSubmissions } from "./replay-corpus.js"; const REPLAY_PROFILE_ID = "long-horizon-replay-profile"; const PREFIX = "long-horizon-"; @@ -157,7 +157,7 @@ async function playFixture(name: string) { let state = created.value; let weeksPlayed = 0; - for (const submission of fixture.submissions) { + for (const submission of actionSubmissions(fixture)) { const result = engine.submitAction(state, submission.actionId, submission.params); if (!result.ok || !result.value) throw new Error(`${name}: expected "${submission.actionId}" to be accepted`); state = result.value; @@ -254,7 +254,7 @@ async function observedSystems(name: string): Promise> { let lastCareer = JSON.stringify(state.kindState && (state.kindState as SimulationKindState).player.career); const seen = new Set(); - for (const submission of fixture.submissions) { + for (const submission of actionSubmissions(fixture)) { const result = engine.submitAction(state, submission.actionId, submission.params); if (!result.ok || !result.value) throw new Error(`${name}: expected "${submission.actionId}" to be accepted`); for (const change of result.changes) { @@ -303,7 +303,7 @@ describe.skipIf(!HAS_BOTH_RUNS)("W89.5 — byte-identical replay and a save/rest const playthrough: PlaythroughFixture = { name, config: fixture.config, - actionLog: toActionLog(fixture.submissions), + actionLog: toActionLog(actionSubmissions(fixture)), }; const engineA = createEngine({ kinds, registry: buildRegistry(), ids: FIXED_IDS }); const engineB = createEngine({ kinds, registry: buildRegistry(), ids: FIXED_IDS }); @@ -320,7 +320,7 @@ describe.skipIf(!HAS_BOTH_RUNS)("W89.5 — byte-identical replay and a save/rest const baselineCreated = baselineEngine.createGame(fixture.config); if (!baselineCreated.ok || !baselineCreated.value) throw new Error(`${name}: expected createGame to succeed`); let baselineState = baselineCreated.value; - for (const submission of fixture.submissions) { + for (const submission of actionSubmissions(fixture)) { const result = baselineEngine.submitAction(baselineState, submission.actionId, submission.params); if (!result.ok || !result.value) throw new Error(`${name}: expected "${submission.actionId}" to be accepted`); baselineState = result.value; @@ -329,12 +329,12 @@ describe.skipIf(!HAS_BOTH_RUNS)("W89.5 — byte-identical replay and a save/rest // Split: same submissions, same seed/ids, but serialize()/deserialize() partway // through — proving nothing accumulated outside the serialized state itself. - const splitAt = Math.floor(fixture.submissions.length / 2); + const splitAt = Math.floor(actionSubmissions(fixture).length / 2); const splitEngine = createEngine({ kinds, registry: buildRegistry(), ids: FIXED_IDS }); const splitCreated = splitEngine.createGame(fixture.config); if (!splitCreated.ok || !splitCreated.value) throw new Error(`${name}: expected createGame to succeed`); let splitState = splitCreated.value; - for (const submission of fixture.submissions.slice(0, splitAt)) { + for (const submission of actionSubmissions(fixture).slice(0, splitAt)) { const result = splitEngine.submitAction(splitState, submission.actionId, submission.params); if (!result.ok || !result.value) throw new Error(`${name}: expected "${submission.actionId}" to be accepted`); splitState = result.value; @@ -343,7 +343,7 @@ describe.skipIf(!HAS_BOTH_RUNS)("W89.5 — byte-identical replay and a save/rest const restored = splitEngine.deserialize(saved); expect(restored.ok).toBe(true); let rehydratedState = restored.value!; - for (const submission of fixture.submissions.slice(splitAt)) { + for (const submission of actionSubmissions(fixture).slice(splitAt)) { const result = splitEngine.submitAction(rehydratedState, submission.actionId, submission.params); if (!result.ok || !result.value) throw new Error(`${name}: expected "${submission.actionId}" to be accepted`); rehydratedState = result.value; @@ -355,7 +355,7 @@ describe.skipIf(!HAS_BOTH_RUNS)("W89.5 — byte-identical replay and a save/rest it.for(BOTH_RUNS)("%s: replays identically under nullEmitter and a recordingEmitter", (name) => { const fixture = loadFixture(name); - const playthrough: PlaythroughFixture = { name, config: fixture.config, actionLog: toActionLog(fixture.submissions) }; + const playthrough: PlaythroughFixture = { name, config: fixture.config, actionLog: toActionLog(actionSubmissions(fixture)) }; const withoutRecording = runFixture(createEngine({ kinds, registry: buildRegistry(), ids: FIXED_IDS }).withEmitter(nullEmitter), playthrough); const withRecording = runFixture(createEngine({ kinds, registry: buildRegistry(), ids: FIXED_IDS }).withEmitter(createRecordingEmitter()), playthrough); expect(withRecording).toBe(withoutRecording); @@ -441,7 +441,7 @@ describe.skipIf(!HAS_BOTH_RUNS)("W94.5 — no pending response is ever bypassed" let acceptedCount = 0; let endWeekCount = 0; - for (const [index, submission] of fixture.submissions.entries()) { + for (const [index, submission] of actionSubmissions(fixture).entries()) { if (submission.actionId === "end_week") { // The gate's own condition (`unaddressedPendingResponses`, `state.ts`) — not "no // pending at all": a response presented this week is legitimately still in @@ -463,7 +463,7 @@ describe.skipIf(!HAS_BOTH_RUNS)("W94.5 — no pending response is ever bypassed" acceptedCount += 1; } - expect(acceptedCount).toBe(fixture.submissions.length); + expect(acceptedCount).toBe(actionSubmissions(fixture).length); expect(endWeekCount).toBeGreaterThanOrEqual(150); expect((state.kindState as SimulationKindState).pendingEventResponses).toEqual([]); }); diff --git a/src/engine/src/campaigns/pipeline-equivalence.test.ts b/src/engine/src/campaigns/pipeline-equivalence.test.ts index ef2cae2f..def63f6e 100644 --- a/src/engine/src/campaigns/pipeline-equivalence.test.ts +++ b/src/engine/src/campaigns/pipeline-equivalence.test.ts @@ -44,7 +44,7 @@ import { buildStableLifePossessionsCampaign } from "./stable-life-possessions.js import { buildStableLifeEventsCampaign } from "./stable-life-events.js"; import { buildLongHorizonWinCampaign, buildLongHorizonLossCampaign } from "./long-horizon.js"; import { buildWorldGraphMvpCampaign } from "./world-graph-mvp.js"; -import { CORPUS_DIR, FIXTURES_DIR, fixtureNamesByPrefix, loadFixture } from "./replay-corpus.js"; +import { CORPUS_DIR, FIXTURES_DIR, fixtureNamesByPrefix, loadFixture, actionSubmissions } from "./replay-corpus.js"; function unwrap(built: CommandResult, what: string): BuiltCampaign { if (!built.ok || !built.value) throw new Error(`expected ${what} to build`); @@ -100,7 +100,7 @@ function runFixtureTrace(engine: Engine, name: string): RunTrace { if (!created.ok || !created.value) throw new Error(`${name}: createGame rejected — ${created.errors[0]?.code ?? "unknown"}`); let state = created.value; const changes: (StateChange | string)[] = []; - for (const submission of fixture.submissions) { + for (const submission of actionSubmissions(fixture)) { const result = engine.submitAction(state, submission.actionId, submission.params); if (result.ok && result.value) { state = result.value; diff --git a/src/engine/src/campaigns/reasonCompletenessGuard.test.ts b/src/engine/src/campaigns/reasonCompletenessGuard.test.ts index b9d079c1..4253eb03 100644 --- a/src/engine/src/campaigns/reasonCompletenessGuard.test.ts +++ b/src/engine/src/campaigns/reasonCompletenessGuard.test.ts @@ -30,7 +30,7 @@ import { buildValidatedContentRegistry } from "../core/validation/tiered.js"; import type { CommandResult } from "../core/kernel/reasons.js"; import type { BuiltCampaign } from "../core/registry/types.js"; import type { ReplayFixture } from "../core/replay/types.js"; -import { fixtureNamesByPrefix, loadFixture, FIXTURES_DIR } from "./replay-corpus.js"; +import { fixtureNamesByPrefix, isAdoption, loadFixture, FIXTURES_DIR } from "./replay-corpus.js"; import { storyGraphKind } from "../kinds/story-graph/kind.js"; import { buildBulgariaBureaucracyCampaign } from "./bulgaria-bureaucracy.js"; import { simulationKind } from "../kinds/simulation/kind.js"; @@ -89,6 +89,10 @@ function replayAndCollect(engine: Engine, fixture: ReplayFixture): { reasons: Se if (!created.ok || !created.value) throw new Error(`${fixture.name}: expected createGame to succeed`); let state = created.value; for (const submission of fixture.submissions) { + // An adoption is not play (C24): it reports no `StateChange`, and a refusal is a value + // rather than a message, so there is nothing here for it to contribute. This engine holds + // one epoch, so the actions after it play on the starting one. + if (isAdoption(submission)) continue; const result = engine.submitAction(state, submission.actionId, submission.params); for (const change of result.changes) if (change.visible) reasons.add(change.reason); for (const error of result.errors) codes.add(error.code); diff --git a/src/engine/src/campaigns/replay-corpus.ts b/src/engine/src/campaigns/replay-corpus.ts index ac99e9d7..574c2b9f 100644 --- a/src/engine/src/campaigns/replay-corpus.ts +++ b/src/engine/src/campaigns/replay-corpus.ts @@ -15,7 +15,7 @@ import { existsSync, readFileSync, readdirSync } from "node:fs"; import { fileURLToPath } from "node:url"; import type { Engine, GameState } from "../core/kernel/types.js"; -import type { Outcome, ReplayFixture } from "../core/replay/types.js"; +import type { ActionSubmission, Outcome, ReplayFixture, Submission } from "../core/replay/types.js"; export const FIXTURES_DIR = fileURLToPath(new URL("../../fixtures/replay/", import.meta.url)); @@ -83,7 +83,7 @@ export function outcomeNamesByPrefix(prefix: string, dir: string = CORPUS_DIR): * campaign version this build no longer has. */ export function statesThatFailToDeserialize(engine: Engine, fixture: ReplayFixture): number[] { - const created = engine.createGame(fixture.config); + const created = engine.createGame(fixture.config, fixture.campaignVersion); if (!created.ok || !created.value) throw new Error(`fixture "${fixture.name}": createGame rejected`); const failures: number[] = []; @@ -94,6 +94,14 @@ export function statesThatFailToDeserialize(engine: Engine, fixture: ReplayFixtu let state = created.value; check(state, -1); for (const [index, submission] of fixture.submissions.entries()) { + if (isAdoption(submission)) { + const adoption = engine.adoptContent(state, submission.adopt); + if (adoption.adopted) { + state = adoption.state; + check(state, index); + } + continue; + } const result = engine.submitAction(state, submission.actionId, submission.params); if (result.ok && result.value) { state = result.value; @@ -102,3 +110,18 @@ export function statesThatFailToDeserialize(engine: Engine, fixture: ReplayFixtu } return failures; } + +/** S135: a recorded content adoption rather than an action (07 §2). */ +export function isAdoption(submission: Submission): submission is Exclude { + return "adopt" in submission; +} + +/** A fixture's submissions, for a suite that plays them as actions directly against an engine. + * Throws on a recorded adoption: such a suite builds an engine holding one epoch, so it could + * not replay one, and must fail rather than silently skip it. */ +export function actionSubmissions(fixture: ReplayFixture): readonly ActionSubmission[] { + return fixture.submissions.map((submission, index) => { + if (isAdoption(submission)) throw new Error(`fixture "${fixture.name}": submission ${index} is an adoption, which this suite does not replay`); + return submission; + }); +} diff --git a/src/engine/src/campaigns/stable-life.replay.test.ts b/src/engine/src/campaigns/stable-life.replay.test.ts index 67c5298f..9aff4773 100644 --- a/src/engine/src/campaigns/stable-life.replay.test.ts +++ b/src/engine/src/campaigns/stable-life.replay.test.ts @@ -39,7 +39,7 @@ import { buildStableLifeEffectsCampaign } from "./stable-life-effects.js"; import { buildStableLifeHousingCampaign } from "./stable-life-housing.js"; import { buildStableLifePossessionsCampaign } from "./stable-life-possessions.js"; import { buildStableLifeEventsCampaign } from "./stable-life-events.js"; -import { COMPARING_ACROSS_VERSIONS, CORPUS_DIR, FIXTURES_DIR, fixtureNamesByPrefix, hasFixture, loadExpectedOutcome, loadFixture, statesThatFailToDeserialize } from "./replay-corpus.js"; +import { COMPARING_ACROSS_VERSIONS, CORPUS_DIR, FIXTURES_DIR, fixtureNamesByPrefix, hasFixture, loadExpectedOutcome, loadFixture, statesThatFailToDeserialize, actionSubmissions } from "./replay-corpus.js"; const REPLAY_PROFILE_ID = "replay-oracle-profile"; @@ -137,7 +137,7 @@ describe.skipIf(!hasFixtureInBaseline("stable-life-housing-eviction", "stable-li if (!created.ok || !created.value) throw new Error(`expected "${fixtureName}" to create a game`); let state = created.value; - for (const submission of fixture.submissions) { + for (const submission of actionSubmissions(fixture)) { const result = ctx.engine.submitAction(state, submission.actionId, submission.params); if (result.ok && result.value) state = result.value; } @@ -175,7 +175,7 @@ describe.skipIf(!hasFixtureInBaseline("stable-life-possessions"))( let state = created.value; const perWeek: { condition?: number; itemEffects: number; cashCents: number }[] = []; - for (const submission of fixture.submissions) { + for (const submission of actionSubmissions(fixture)) { const result = ctx.engine.submitAction(state, submission.actionId, submission.params); if (!result.ok || !result.value) throw new Error(`expected "${submission.actionId}" to be accepted`); state = result.value; @@ -210,7 +210,7 @@ describe.skipIf(!hasFixtureInBaseline("stable-life-possessions"))( if (!created.ok || !created.value) throw new Error("expected the possessions fixture to create a game"); let state = created.value; - for (const submission of fixture.submissions) { + for (const submission of actionSubmissions(fixture)) { const result = ctx.engine.submitAction(state, submission.actionId, submission.params); if (result.ok && result.value) state = result.value; } diff --git a/src/engine/src/core/replay/runner.test.ts b/src/engine/src/core/replay/runner.test.ts index 9600246b..87341dee 100644 --- a/src/engine/src/core/replay/runner.test.ts +++ b/src/engine/src/core/replay/runner.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from "vitest"; import { buildReplayOutcome, findDivergence, runReplayFixture, type ReplayRunnerContext } from "./runner.js"; -import type { ReplayFixture } from "./types.js"; +import type { Outcome, ReplayFixture, Submission } from "./types.js"; +import { createContentArchive } from "../registry/archive.js"; import { createEngine } from "../kernel/engine.js"; import { createInMemoryProfileStore } from "../session/profile-store.js"; import type { @@ -241,3 +242,152 @@ describe("runReplayFixture", () => { expect(verdict).toEqual({ kind: "unrunnable", reason: "campaign_withdrawn" }); }); }); + +// --------------------------------------------------------------------------- +// S135 — the oracle crosses content epochs (07 §2, §3, §6) +// --------------------------------------------------------------------------- + +/** The test kind, adopting any epoch whose content does not say `refuse`. */ +function makeAdoptingKind(): Kind { + return { + ...makeTestKind(), + adoptContent: (state, _from, to) => + (to.content as { refuse?: boolean }).refuse ? { adopt: false, reason: "content_incompatible" } : { adopt: true, state }, + }; +} + +function epochRegistry(version: string, content: Record = {}): ContentRegistry { + const campaign: Campaign = { id: "test-campaign", kindId: "story-graph", version, titleKey: "test.title", content }; + return { campaigns: new Map([["test-campaign", campaign]]), strings: new Map() }; +} + +/** An engine whose default epoch is `registry`'s, over an archive that also holds `others`. */ +function makeEpochContext(registry: ContentRegistry, others: readonly ContentRegistry[]): ReplayRunnerContext { + const kinds = { "story-graph": makeAdoptingKind() } as unknown as KindRegistry; + const archive = createContentArchive({ kinds, initial: registry }); + for (const other of others) { + if (!archive.publish(other).ok) throw new Error("expected the epoch to publish"); + } + const host: EngineHost = { kinds, registry, ids: FIXED_IDS, archive }; + return { engine: createEngine(host), kinds, registry, profiles: createInMemoryProfileStore(), profileId: PROFILE_ID }; +} + +const CROSSING: readonly Submission[] = [{ actionId: "increment" }, { adopt: "2" }, { actionId: "increment" }, { actionId: "end" }]; + +describe("S135.1 — Submission and Decision are unions; an action-only fixture reads unchanged", () => { + it("an action-only fixture's decisions are ActionDecisions, carrying no adopt field", async () => { + const result = await buildReplayOutcome(makeContext(), makeFixture()); + if (result.kind !== "outcome") throw new Error("expected an outcome"); + expect(result.outcome.decisions.every((d) => !("adopt" in d))).toBe(true); + }); + + it("an outcome recorded before content epochs still matches as plain JSON", async () => { + const recorded = JSON.parse( + JSON.stringify({ + finalStatus: "ended", + acceptedActions: 3, + decisions: [ + { index: 0, seq: 0, actionId: "increment", accepted: true }, + { index: 1, seq: 1, actionId: "increment", accepted: true }, + { index: 2, seq: 2, actionId: "end", accepted: true }, + ], + achievements: ["milestone"], + terminal: { terminal: true, terminalId: "the_end", endingId: "the_end" }, + }), + ) as Outcome; + const fixture = JSON.parse(JSON.stringify(makeFixture())) as ReplayFixture; + expect(await runReplayFixture(makeContext(), fixture, recorded)).toEqual({ kind: "match" }); + }); +}); + +describe("S135.2 — every version the fixture names resolves through the engine's content first", () => { + it("starts on the fixture's campaignVersion, not the registry's, when the archive holds it", async () => { + const context = makeEpochContext(epochRegistry("2"), [epochRegistry("1")]); + const result = await buildReplayOutcome(context, makeFixture({ campaignVersion: "1", submissions: [{ actionId: "increment" }, { adopt: "2" }] })); + if (result.kind !== "outcome") throw new Error("expected an outcome"); + // Only a game that started on "1" can adopt "2" — one created on the registry's "2" + // would be refused nothing and append nothing (adoptContent step 0). + expect(result.outcome.decisions[1]).toEqual({ index: 1, seq: 1, adopt: "2", accepted: true }); + }); + + it("an adopted version the archive does not hold is campaign_version_missing, before anything runs", async () => { + const context = makeEpochContext(epochRegistry("1"), [epochRegistry("2")]); + const result = await buildReplayOutcome(context, makeFixture({ submissions: [{ actionId: "increment" }, { adopt: "3" }] })); + expect(result).toEqual({ kind: "unrunnable", reason: "campaign_version_missing" }); + }); + + it("a starting version the archive does not hold is campaign_version_missing", async () => { + const context = makeEpochContext(epochRegistry("2"), [epochRegistry("3")]); + expect(await buildReplayOutcome(context, makeFixture({ campaignVersion: "1" }))).toEqual({ + kind: "unrunnable", + reason: "campaign_version_missing", + }); + }); + + it("a campaign id the registry no longer has is still campaign_withdrawn", async () => { + const context = makeEpochContext(epochRegistry("1"), [epochRegistry("2")]); + const fixture = makeFixture({ config: { campaignId: "does-not-exist", seed: "fixed-seed" }, submissions: CROSSING }); + expect(await buildReplayOutcome(context, fixture)).toEqual({ kind: "unrunnable", reason: "campaign_withdrawn" }); + }); +}); + +describe("S135.3 — an adoption runs through adoptContent, counts in decisions, never in acceptedActions", () => { + it("records an accepted AdoptionDecision at the content entry's seq, and acceptedActions counts actions only", async () => { + const context = makeEpochContext(epochRegistry("1"), [epochRegistry("2")]); + const result = await buildReplayOutcome(context, makeFixture({ submissions: CROSSING })); + if (result.kind !== "outcome") throw new Error("expected an outcome"); + + expect(result.outcome.decisions).toEqual([ + { index: 0, seq: 0, actionId: "increment", accepted: true }, + { index: 1, seq: 1, adopt: "2", accepted: true }, + { index: 2, seq: 2, actionId: "increment", accepted: true }, + { index: 3, seq: 3, actionId: "end", accepted: true }, + ]); + expect(result.outcome.acceptedActions).toBe(3); + expect(result.outcome.finalStatus).toBe("ended"); + expect(result.outcome.achievements).toEqual(["milestone"]); + }); + + it("a refusal records accepted: false with its reason and no seq, and the replay continues on the pinned epoch", async () => { + const context = makeEpochContext(epochRegistry("1"), [epochRegistry("2", { refuse: true })]); + const result = await buildReplayOutcome(context, makeFixture({ submissions: CROSSING })); + if (result.kind !== "outcome") throw new Error("expected an outcome"); + + expect(result.outcome.decisions[1]).toEqual({ index: 1, seq: null, adopt: "2", accepted: false, reason: "content_incompatible" }); + expect(result.outcome.decisions[2]).toEqual({ index: 2, seq: 1, actionId: "increment", accepted: true }); + expect(result.outcome.acceptedActions).toBe(3); + }); + + it("an adoption the kind now refuses is diverged at that index", async () => { + const recorded = await buildReplayOutcome(makeEpochContext(epochRegistry("1"), [epochRegistry("2")]), makeFixture({ submissions: CROSSING })); + if (recorded.kind !== "outcome") throw new Error("expected an outcome"); + + const refusing = makeEpochContext(epochRegistry("1"), [epochRegistry("2", { refuse: true })]); + const verdict = await runReplayFixture(refusing, makeFixture({ submissions: CROSSING }), recorded.outcome); + expect(verdict.kind).toBe("diverged"); + if (verdict.kind !== "diverged") throw new Error("expected diverged"); + expect(verdict.at).toBe(1); + }); + + it("matches a crossing fixture against its own recorded outcome", async () => { + const context = (): ReplayRunnerContext => makeEpochContext(epochRegistry("1"), [epochRegistry("2")]); + const recorded = await buildReplayOutcome(context(), makeFixture({ submissions: CROSSING })); + if (recorded.kind !== "outcome") throw new Error("expected an outcome"); + expect(await runReplayFixture(context(), makeFixture({ submissions: CROSSING }), recorded.outcome)).toEqual({ kind: "match" }); + }); + + it("findDivergence tells an adoption from an action at the same index", () => { + const base: Outcome = { finalStatus: "active", acceptedActions: 0, decisions: [{ index: 0, seq: 0, adopt: "2", accepted: true }], achievements: [] }; + const asAction: Outcome = { ...base, decisions: [{ index: 0, seq: 0, actionId: "2", accepted: true }] }; + const otherVersion: Outcome = { ...base, decisions: [{ index: 0, seq: 0, adopt: "3", accepted: true }] }; + expect(findDivergence(base, asAction)).toBe(0); + expect(findDivergence(asAction, base)).toBe(0); + expect(findDivergence(base, otherVersion)).toBe(0); + expect(findDivergence(base, base)).toBeUndefined(); + }); + + it("throws on an adoption naming the version the game is already on — a fixture records only adoptions that happened", async () => { + const context = makeEpochContext(epochRegistry("1"), [epochRegistry("2")]); + await expect(buildReplayOutcome(context, makeFixture({ submissions: [{ adopt: "1" }] }))).rejects.toThrow(/already on/); + }); +}); diff --git a/src/engine/src/core/replay/runner.ts b/src/engine/src/core/replay/runner.ts index 79474e6e..b4e1ba96 100644 --- a/src/engine/src/core/replay/runner.ts +++ b/src/engine/src/core/replay/runner.ts @@ -32,18 +32,26 @@ export type ReplayResult = | { readonly kind: "unrunnable"; readonly reason: "campaign_withdrawn" | "campaign_version_missing" }; /** - * The pre-check 07 §6 names first: resolve the fixture's `campaignVersion` in the registry - * before ever calling `createGame`, so the two `unrunnable` reasons stay distinct — a - * campaign that no longer exists at all versus one that exists at a version this fixture - * was not captured against. Both are legitimate content decisions, never a failure (07 §6). + * The pre-check 07 §6 names first, before ever calling `createGame`, so the two `unrunnable` + * reasons stay distinct — a campaign that no longer exists at all versus one that exists but + * not at every version this fixture was captured against. Both are legitimate content + * decisions, never a failure (07 §6). + * + * Withdrawal is read from the registry: a `ResolutionArchive` cannot enumerate, so it cannot + * say an id is gone. Every *named* version — the starting `campaignVersion` and each + * adoption's — resolves through `engine.content`, requiring the resolved campaign to carry + * that exact version, as the kernel's own epoch resolution does (16 §5.6). */ function resolveCampaign( - registry: ContentRegistry, + ctx: ReplayRunnerContext, fixture: ReplayFixture, ): { readonly reason: "campaign_withdrawn" | "campaign_version_missing" } | undefined { - const campaign = registry.campaigns.get(fixture.config.campaignId); - if (!campaign) return { reason: "campaign_withdrawn" }; - if (campaign.version !== fixture.campaignVersion) return { reason: "campaign_version_missing" }; + const campaignId = fixture.config.campaignId; + if (!ctx.registry.campaigns.has(campaignId)) return { reason: "campaign_withdrawn" }; + const named = [fixture.campaignVersion, ...fixture.submissions.flatMap((s) => ("adopt" in s ? [s.adopt] : []))]; + const held = (version: string): boolean => + ctx.engine.content.resolve(campaignId, version)?.campaigns.get(campaignId)?.version === version; + if (!named.every(held)) return { reason: "campaign_version_missing" }; return undefined; } @@ -65,10 +73,12 @@ export async function buildReplayOutcome(ctx: ReplayRunnerContext, fixture: Repl throw new Error(`buildReplayOutcome "${fixture.name}": config.seed is required for a reproducible replay`); } - const unrunnable = resolveCampaign(ctx.registry, fixture); + const unrunnable = resolveCampaign(ctx, fixture); if (unrunnable) return { kind: "unrunnable", reason: unrunnable.reason }; - const created = ctx.engine.createGame(fixture.config); + // The fixture's `campaignVersion` is the *starting* epoch (07 §2), not necessarily the + // registry's current one. + const created = ctx.engine.createGame(fixture.config, fixture.campaignVersion); if (!created.ok || !created.value) { // A fixture that passed the campaign/version check but still fails to start is a // broken fixture or a broken engine, not a divergence this oracle exists to report — @@ -80,6 +90,25 @@ export async function buildReplayOutcome(ctx: ReplayRunnerContext, fixture: Repl const decisions: Decision[] = []; for (const [index, submission] of fixture.submissions.entries()) { + if ("adopt" in submission) { + // Capture records only adoptions that happened (16 §5.6). One naming the version the + // game is already on would append no content entry — `adoptContent`'s step 0 — so it + // has no `seq` to record: a malformed fixture, like a missing seed, not a divergence. + if (submission.adopt === state.campaignVersion) { + throw new Error( + `buildReplayOutcome "${fixture.name}": submission ${index} adopts "${submission.adopt}", the version the game is already on`, + ); + } + const adoption = ctx.engine.adoptContent(state, submission.adopt); + if (adoption.adopted) { + decisions.push({ index, seq: adoption.state.actionLog.length - 1, adopt: submission.adopt, accepted: true }); + state = adoption.state; + } else { + decisions.push({ index, seq: null, adopt: submission.adopt, accepted: false, reason: adoption.reason }); + } + continue; + } + const result: ActionResult = ctx.engine.submitAction(state, submission.actionId, submission.params); if (result.ok && result.value) { @@ -110,7 +139,8 @@ export async function buildReplayOutcome(ctx: ReplayRunnerContext, fixture: Repl const outcome: Outcome = { finalStatus: state.status, - acceptedActions: decisions.filter((d) => d.accepted).length, + // Actions only: an adoption is a log entry but not an action (07 §3). + acceptedActions: decisions.filter((d) => "actionId" in d && d.accepted).length, decisions, achievements, terminal, @@ -130,9 +160,14 @@ export function findDivergence(expected: Outcome, actual: Outcome): number | und for (let i = 0; i < length; i++) { const e = expected.decisions[i]; const a = actual.decisions[i]; - if (!e || !a || e.index !== a.index || e.seq !== a.seq || e.actionId !== a.actionId || e.accepted !== a.accepted || e.reason !== a.reason) { + if (!e || !a || e.index !== a.index || e.seq !== a.seq || e.accepted !== a.accepted || e.reason !== a.reason) { return i; } + // An adoption where an action was recorded, or the other way round, diverges as surely + // as a different action id does. + const expectedId = "adopt" in e ? `adopt:${e.adopt}` : `action:${e.actionId}`; + const actualId = "adopt" in a ? `adopt:${a.adopt}` : `action:${a.actionId}`; + if (expectedId !== actualId) return i; } const tail = { diff --git a/src/engine/src/core/replay/types.ts b/src/engine/src/core/replay/types.ts index 78cf4dbe..7f15b138 100644 --- a/src/engine/src/core/replay/types.ts +++ b/src/engine/src/core/replay/types.ts @@ -16,13 +16,22 @@ import type { ReasonCode } from "../kernel/reasons.js"; * One attempted action, whether or not the engine accepted it. `04 §4`'s `actionLog` omits * rejections — `seq` is the log's length, so a rejected submission leaves no trace there — * which is exactly why the fixture carries its own list instead of reusing `actionLog` - * (07 §2.1). + * (07 §2.1). The `Submission` every fixture written before content epochs carries, unchanged. */ -export interface Submission { +export interface ActionSubmission { readonly actionId: string; readonly params?: ActionParams; } +/** A recorded content adoption (16 §5.6) — only adoptions that happened are recorded, so its + * position among the submissions is where the session moved onto `adopt` (07 §2). */ +export interface AdoptionSubmission { + /** The `campaignVersion` adopted. */ + readonly adopt: string; +} + +export type Submission = ActionSubmission | AdoptionSubmission; + export interface ReplayFixture { readonly name: string; /** @@ -31,7 +40,8 @@ export interface ReplayFixture { * no explicit seed is not reproducible. */ readonly config: NewGameConfig & { seed: string }; - /** Pinned here, not on `config` — a *runtime* input has no reason to carry it (07 §2). */ + /** Pinned here, not on `config` — a *runtime* input has no reason to carry it (07 §2). + * The *starting* epoch: an `AdoptionSubmission` moves the game off it. */ readonly campaignVersion: string; /** The `ENGINE_VERSION` (`../../version.js`) current when this fixture's `.outcome.json` * was captured or last regenerated. */ @@ -44,7 +54,7 @@ export interface ReplayFixture { * a row share one `seq` (rejection never advances it), so `index`, the 0-based position in * `submissions`, is what stays unique (07 §3.1). */ -export interface Decision { +export interface ActionDecision { readonly index: number; /** The accepted log position, or `null` if this submission was rejected — never a * repeated number left to be misread. */ @@ -55,6 +65,20 @@ export interface Decision { readonly reason?: ReasonCode; } +/** An `AdoptionSubmission`'s result. `accepted: false` means the kind now refuses an adoption + * that once happened — a divergence (16 §5.6). */ +export interface AdoptionDecision { + readonly index: number; + /** The content entry's log position, or `null` if the session stayed pinned. */ + readonly seq: number | null; + readonly adopt: string; + readonly accepted: boolean; + /** Set iff refused (04 §12). */ + readonly reason?: ReasonCode; +} + +export type Decision = ActionDecision | AdoptionDecision; + /** * The deliberately small, cross-version-stable projection the oracle actually compares. * Never variable values, never `serialize()` bytes (07 §3.4) — every field here is stable @@ -62,6 +86,8 @@ export interface Decision { */ export interface Outcome { readonly finalStatus: GameStatus; + /** Accepted *actions* only — an adoption is a log entry but not an action, and appears + * only in `decisions` (07 §3). */ readonly acceptedActions: number; readonly decisions: readonly Decision[]; /** Unlocked achievement ids, sorted (07 §3.2). */