diff --git a/design/30-slices.md b/design/30-slices.md index ad8babf6..df796441 100644 --- a/design/30-slices.md +++ b/design/30-slices.md @@ -292,7 +292,7 @@ Out of scope: Adoption and epoch log entries (S130); the reference archive (S131 ## S130 — A Game Can Move Onto New Content, and Replays Exactly Across It -Status: todo +Status: done Delivers: A running game can move onto another published version of its campaign as a recorded step in its own history, or stay where it is with a stated reason. Replaying that history reproduces the game byte for byte. A game that never moves is stored diff --git a/design/90-decisions.md b/design/90-decisions.md index b91fe4a9..814d4769 100644 --- a/design/90-decisions.md +++ b/design/90-decisions.md @@ -2591,6 +2591,26 @@ Context: `ResolutionArchive.resolve(id, version)` returns a registry. The contra Chosen: The engine treats that answer as unresolved, so `createGame`, `deserialize` and `migrate` refuse it with `unknown_campaign`, and `createEngine` refuses an archive that answers the registry's own epoch that way. Rejected: **Trusting the archive's answer.** A faulty host archive would then play a state against content it does not name, which is the C22 failure this slice exists to close. +### 2026-10-09 — S130: adopting the state's own version returns the same state and emits nothing +Context: 16 §5.4 step 0 makes `to` equal to `campaignVersion` a no-op adoption, and S130.3 says it returns the state unchanged. Neither says whether it emits `core.content.adopted`. +Chosen: `adoptContent` returns `{ adopted: true, state }` with the same object, appends no entry and emits no event. Nothing moved, so there is nothing for an operator to read. +Rejected: **Emitting `core.content.adopted` with equal versions.** A channel that offers the current version on every command would flood the event stream with adoptions that did not happen. + +### 2026-10-09 — S130: a `formatVersion: 2` state with no epoch entry is refused +Context: C26 says `formatVersion` is 2 *exactly when* the log carries an epoch entry. S130.4 lists the three rejections in the other direction and a version above 2, but not a version-2 state whose log carries no epoch entry. +Chosen: `deserialize` refuses it with `invalid_state`, so the stamp and the log agree both ways and a state that never adopted always serializes as version 1. +Rejected: **Accepting it.** It would be a second encoding of an unepoched game, and C26's byte-identical promise rests on there being one. + +### 2026-10-09 — S130: `branchSession` refuses a log that crossed an epoch until S134 +Context: once `actionLog` holds epoch entries, `branchSession`'s replay (create on the default epoch, submit each action) would replay a crossed log onto the wrong content. S134 makes branching start on the derived starting version and replay content entries; S130 adds the entries. +Chosen: an interim guard. `branchSession` refuses a source whose log carries any epoch entry with `invalid_state`, writing nothing, the refusal it already gives a `replayCompatible: false` session. S134 replaces the guard. +Rejected: **Replaying a crossed log through `branchSession` now.** That is S134's scope, and doing it here would build one slice inside another. **Leaving it unguarded.** A branch would silently play different content from its source. + +### 2026-10-09 — S130: the §14 harness checks a content entry's `from` before adopting it +Context: S130.5 says the harness replays each content entry through `adoptContent`, which must adopt. `adoptContent` takes only the target version, so a fixture whose content entry names a `from` other than the version the replay is on would still adopt, and the log the replay produces would differ from the fixture's. +Chosen: the harness fails a fixture whose content entry's `from` is not the replay's `campaignVersion`, naming both versions, before it calls `adoptContent`. The internal harness also gains `traceFixture`, which returns `serialize()` after creation and after every entry; `runFixture` serializes once, so long-horizon fixtures do not pay for a trace they do not compare. +Rejected: **Trusting `from`.** The fixture would pass while the replayed log disagreed with it, and C21's byte-identical comparison would fail with no message saying why. + ### 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 ce5c5723..88561526 100644 --- a/docs/docs/engine/TODO.md +++ b/docs/docs/engine/TODO.md @@ -286,7 +286,7 @@ Out of scope: Adoption and epoch log entries (S130); the reference archive (S131 ## S130 — A Game Can Move Onto New Content, and Replays Exactly Across It -Status: todo +Status: done Delivers: A running game can move onto another published version of its campaign as a recorded step in its own history, or stay where it is with a stated reason. Replaying that history reproduces the game byte for byte. A game that never moves is stored diff --git a/src/engine/src/core/determinism/harness.test.ts b/src/engine/src/core/determinism/harness.test.ts index 9110983f..773c3484 100644 --- a/src/engine/src/core/determinism/harness.test.ts +++ b/src/engine/src/core/determinism/harness.test.ts @@ -1,7 +1,15 @@ import { describe, it, expect } from "vitest"; -import { runFixture, type PlaythroughFixture } from "./harness.js"; +import { runFixture, traceFixture, type PlaythroughFixture } from "./harness.js"; import { createEngine } from "../kernel/engine.js"; -import type { AdvanceResult, AvailableAction, InitialStateResult, Kind, KindRegistry, SceneBody } from "../kernel/types.js"; +import type { + AdvanceResult, + AvailableAction, + InitialStateResult, + Kind, + KindRegistry, + ResolutionArchive, + SceneBody, +} from "../kernel/types.js"; import type { Campaign, ContentRegistry } from "../registry/types.js"; import type { ValidationResult } from "../validation/types.js"; import type { EngineHost, IdSource } from "../composition/types.js"; @@ -181,3 +189,86 @@ describe("deserialize(serialize(state)) round-trips", () => { expect(engine.serialize(result.value!)).toBe(serialized); }); }); + +describe("S130.5 — the harness replays across an epoch", () => { + // Two epochs of the campaign, and a kind that adopts by adding 1000 — except onto "3", + // which it refuses, so a fixture recorded before the kind changed its mind now fails. + function epoch(version: string): 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() }; + } + const epochs = new Map([["1", epoch("1")], ["2", epoch("2")], ["3", epoch("3")]]); + const archive: ResolutionArchive = { + resolve: (id, version) => (id === "test-campaign" ? epochs.get(version) : undefined), + }; + const adoptingKind: Kind = { + ...makeTestKind(), + adoptContent: (state, _from, to) => + to.version === "3" ? { adopt: false, reason: "content_incompatible" } : { adopt: true, state: { counter: state.counter + 1000 } }, + }; + function epochEngine() { + const kinds = { "story-graph": adoptingKind } as unknown as KindRegistry; + // The default epoch is "2": the fixture's own log, not the host's default, decides where it starts. + return createEngine({ kinds, registry: epochs.get("2")!, archive, ids: FIXED_IDS }); + } + const crossing: PlaythroughFixture = { + name: "crosses from 1 to 2", + config: { campaignId: "test-campaign", seed: "fixed-seed" }, + actionLog: [ + { seq: 0, actionId: "increment" }, + { seq: 1, system: "content", from: "1", to: "2" }, + { seq: 2, actionId: "increment" }, + ], + }; + + it("starts on the first epoch entry's from and serializes byte-identically after every entry across two runs", () => { + const first = traceFixture(epochEngine(), crossing); + const second = traceFixture(epochEngine(), crossing); + + expect(first).toHaveLength(4); + expect(second).toEqual(first); + const states = first.map((blob) => JSON.parse(blob) as { campaignVersion: string; formatVersion: number; kindState: TestKindState }); + expect(states.map((s) => s.campaignVersion)).toEqual(["1", "1", "2", "2"]); + expect(states.map((s) => s.formatVersion)).toEqual([1, 1, 2, 2]); + expect(states.map((s) => s.kindState.counter)).toEqual([0, 1, 1001, 1002]); + expect(runFixture(epochEngine(), crossing)).toBe(first[3]); + }); + + it("the replayed log is the fixture's log", () => { + const final = JSON.parse(runFixture(epochEngine(), crossing)) as { actionLog: unknown[] }; + expect(final.actionLog).toEqual(crossing.actionLog); + }); + + it("fails the fixture when a content entry now refuses", () => { + const refused: PlaythroughFixture = { + ...crossing, + name: "crosses onto 3", + actionLog: [{ seq: 0, system: "content", from: "1", to: "3" }], + }; + expect(() => runFixture(epochEngine(), refused)).toThrow(/adoptContent\("3"\) refused — content_incompatible/); + }); + + it("fails the fixture when a content entry moves from an epoch the replay is not on", () => { + const mismatched: PlaythroughFixture = { + ...crossing, + name: "skips an epoch", + actionLog: [ + { seq: 0, system: "content", from: "1", to: "2" }, + { seq: 1, system: "content", from: "1", to: "3" }, + ], + }; + expect(() => runFixture(epochEngine(), mismatched)).toThrow(/moves from "1", but the replay is on "2"/); + }); + + it("fails the fixture on any migration entry", () => { + const migrated: PlaythroughFixture = { + ...crossing, + name: "carries a migration", + actionLog: [ + { seq: 0, system: "content", from: "1", to: "2" }, + { seq: 1, system: "migration", from: "2", to: "3" }, + ], + }; + expect(() => runFixture(epochEngine(), migrated)).toThrow(/a migration entry at seq 1 cannot be replayed/); + }); +}); diff --git a/src/engine/src/core/determinism/harness.ts b/src/engine/src/core/determinism/harness.ts index e6bdeb76..0143e74f 100644 --- a/src/engine/src/core/determinism/harness.ts +++ b/src/engine/src/core/determinism/harness.ts @@ -10,7 +10,8 @@ * there — see `campaigns/bulgaria-bureaucracy.determinism.test.ts`. */ -import type { Engine, LoggedAction, NewGameConfig } from "../kernel/types.js"; +import type { Engine, GameState, LoggedEntry, NewGameConfig } from "../kernel/types.js"; +import { isLoggedAction, startingCampaignVersion } from "../kernel/log.js"; export interface PlaythroughFixture { name: string; @@ -21,22 +22,41 @@ export interface PlaythroughFixture { * one that way rather than leaving it to a doc comment nobody enforces. */ config: NewGameConfig & { seed: string }; - actionLog: LoggedAction[]; + /** Actions and content entries both replay (C21). A migration entry fails the fixture. */ + actionLog: LoggedEntry[]; } /** - * `createGame(config) → for each logged action, submitAction → serialize final state` - * (04 §14's own pseudocode, verbatim). `seq` on each `LoggedAction` is not consulted — - * `submitAction` assigns it itself, sequentially, from the state it's handed — so a - * fixture's own log only needs `actionId`/`params` to be meaningful; carrying `seq` too - * is what lets a fixture double as a literal `GameState.actionLog` slice. + * `createGame(config, starting version) → for each entry: an action → submitAction, a content + * entry → adoptContent(state, entry.to), which must adopt → serialize` (04 §14's pseudocode). + * `seq` on each entry is not consulted — the engine assigns it itself, sequentially, from the + * state it's handed — so carrying it is what lets a fixture double as a literal + * `GameState.actionLog` slice. The starting version is derived from the log (04 §2); a log + * with no epoch entry starts on the engine's default epoch. * - * Throws on the first rejection (`createGame` or any `submitAction`), naming the fixture - * and the failing step — a fixture is authored to succeed end to end; a rejection means + * Returns `serialize()` after `createGame` and after every entry, so a caller can compare two + * runs entry by entry (C21), not only at the end. + * + * Throws on the first failure, naming the fixture and the failing step — a fixture is + * authored to succeed end to end; a rejection, a refused adoption or a migration entry means * the fixture or the engine drifted, and a thrown error is more informative during * `vitest run` than a silently wrong final `serialize()`. */ +export function traceFixture(engine: Engine, fixture: PlaythroughFixture): string[] { + const trace: string[] = []; + replayFixture(engine, fixture, (state) => trace.push(engine.serialize(state))); + return trace; +} + +/** + * `traceFixture`'s last `serialize()` — the byte-identical comparison golden files make. + * Serializes once, not per entry: a long-horizon fixture replays thousands of entries. + */ export function runFixture(engine: Engine, fixture: PlaythroughFixture): string { + return engine.serialize(replayFixture(engine, fixture)); +} + +function replayFixture(engine: Engine, fixture: PlaythroughFixture, visit?: (state: GameState) => void): GameState { // Runtime backstop, not just the type: a fixture built from untyped data (JSON, an // `as` cast) could still smuggle a missing seed past the compiler. `typeof !== "string"` // rather than an `undefined` check alone — `createGame` falls back to `IdSource.newSeed()` @@ -48,24 +68,47 @@ export function runFixture(engine: Engine, fixture: PlaythroughFixture): string throw new Error(`runFixture "${fixture.name}": config.seed is required for a reproducible fixture`); } - const created = engine.createGame(fixture.config); + const created = engine.createGame(fixture.config, startingCampaignVersion(fixture.actionLog)); if (!created.ok || !created.value) { throw new Error( `runFixture "${fixture.name}": createGame rejected — ${created.errors[0]?.code ?? "unknown"}`, ); } - let state = created.value; + let state: GameState = created.value; + visit?.(state); for (const logged of fixture.actionLog) { + state = replayEntry(engine, fixture.name, state, logged); + visit?.(state); + } + return state; +} + +function replayEntry(engine: Engine, name: string, state: GameState, logged: LoggedEntry): GameState { + if (isLoggedAction(logged)) { const result = engine.submitAction(state, logged.actionId, logged.params); if (!result.ok || !result.value) { throw new Error( - `runFixture "${fixture.name}": submitAction("${logged.actionId}") rejected — ` + - `${result.errors[0]?.code ?? "unknown"}`, + `runFixture "${name}": submitAction("${logged.actionId}") rejected — ${result.errors[0]?.code ?? "unknown"}`, ); } - state = result.value; + return result.value; } - - return engine.serialize(state); + if (logged.system === "migration") { + // Not a step replay can perform: the epoch it left is one the host could not run (04 §10.2). + throw new Error(`runFixture "${name}": a migration entry at seq ${logged.seq} cannot be replayed`); + } + // A content entry records a move from the epoch the state is on; any other `from` means the + // log and the replay disagree before the adoption even runs. + if (logged.from !== state.campaignVersion) { + throw new Error( + `runFixture "${name}": content entry at seq ${logged.seq} moves from "${logged.from}", ` + + `but the replay is on "${state.campaignVersion}"`, + ); + } + const adoption = engine.adoptContent(state, logged.to); + if (!adoption.adopted) { + throw new Error(`runFixture "${name}": adoptContent("${logged.to}") refused — ${adoption.reason}`); + } + return adoption.state; } diff --git a/src/engine/src/core/kernel/engine.test.ts b/src/engine/src/core/kernel/engine.test.ts index 43a7a7d5..5082c50d 100644 --- a/src/engine/src/core/kernel/engine.test.ts +++ b/src/engine/src/core/kernel/engine.test.ts @@ -7,6 +7,7 @@ import type { InitialStateResult, Kind, KindRegistry, + LoggedAction, SceneBody, } from "./types.js"; import type { Campaign, ContentRegistry } from "../registry/types.js"; @@ -16,6 +17,16 @@ import { createRecordingEmitter, nullEmitter } from "../observability/emitter.js import type { GameEvent } from "../observability/types.js"; // S129.6: the archive type is reachable from the package root, not only from the kernel. import type { ResolutionArchive as RootResolutionArchive } from "../../index.js"; +// S130.7: the epoch log and adoption types are reachable from the package root too. +import type { + AdoptDecision as RootAdoptDecision, + AdoptionResult as RootAdoptionResult, + LoggedContent as RootLoggedContent, + LoggedEntry as RootLoggedEntry, + LoggedMigration as RootLoggedMigration, +} from "../../index.js"; +import { BASE_REASON_CODES, CORE_REASON_MESSAGES } from "./reasons.js"; +import { startingCampaignVersion } from "./log.js"; interface TestKindState { counter: number; @@ -301,7 +312,7 @@ describe("S124 — the engine owns what it logs", () => { const next = engine.submitAction(state, "increment", params).value as GameState; expect(seen).not.toBe(params); - expect(seen).toBe(next.actionLog[0]?.params); + expect(seen).toBe((next.actionLog[0] as LoggedAction | undefined)?.params); }); }); @@ -989,3 +1000,347 @@ describe("S129 — every state plays the epoch it names", () => { } }); }); + +describe("S130 — a game can move onto new content, and replays exactly across it", () => { + // One campaign at several epochs. The version names what the test kind's adoptContent does + // when asked to move onto it, so each pinned outcome has its own target. + function epoch(version: string, overrides?: Partial): ContentRegistry { + const campaign = makeCampaign({ version, ...overrides }); + return { campaigns: new Map([[campaign.id, campaign]]), strings: new Map() }; + } + const epochs = new Map([ + ["1", epoch("1")], + ["2", epoch("2")], + ["3", epoch("3")], + ["refuse", epoch("refuse")], + ["throw", epoch("throw")], + ["invalid", epoch("invalid")], + ["other-kind", epoch("other-kind", { kindId: "simulation" })], + [ + "migrate-fails", + epoch("migrate-fails", { + migrateState: () => ({ + ok: false, + errors: [{ code: "migration_failed", messageKey: "core.reason.migration_failed" }], + warnings: [], + }), + }), + ], + [ + "migrate-throws", + epoch("migrate-throws", { + migrateState: () => { + throw new Error("content-owned migration bug"); + }, + }), + ], + [ + "migrate-adds", + epoch("migrate-adds", { + migrateState: (kindState) => ({ + ok: true, + value: { counter: (kindState as TestKindState).counter + 10 }, + errors: [], + warnings: [], + }), + }), + ], + ]); + const archive: RootResolutionArchive = { + resolve: (campaignId, campaignVersion) => (campaignId === "test-campaign" ? epochs.get(campaignVersion) : undefined), + }; + + const judged: Array<{ from: string; to: string; counter: number }> = []; + const adoptContent = (state: TestKindState, from: Campaign, to: Campaign): RootAdoptDecision => { + judged.push({ from: from.version, to: to.version, counter: state.counter }); + if (to.version === "refuse") return { adopt: false, reason: "content_incompatible" }; + if (to.version === "throw") throw new Error("kind-owned adoption bug"); + if (to.version === "invalid") return { adopt: true, state: { counter: -1 } }; + return { adopt: true, state: { counter: state.counter + 1000 } }; + }; + const adoptingKind = makeTestKind({ + adoptContent, + validateState: (kindState) => (kindState as TestKindState).counter >= 0, + }); + const fixedIds = { newGameId: () => "fixed-game-id", newSeed: () => "fixed-seed" }; + + function adoptHost(overrides?: Partial): EngineHost { + return { kinds: makeKinds(adoptingKind), registry: epochs.get("1")!, archive, ids: fixedIds, ...overrides }; + } + function started(engine: ReturnType): GameState { + const created = engine.createGame({ campaignId: "test-campaign", seed: "fixed-seed" }); + return engine.submitAction(created.value as GameState, "increment").value as GameState; + } + function adopted(engine: ReturnType, state: GameState, to: string): GameState { + const result = engine.adoptContent(state, to); + if (!result.adopted) throw new Error(`expected adoption onto ${to}, pinned with ${result.reason}`); + return result.state; + } + const streamEvents = (events: readonly { name: string }[]) => events.filter((e) => e.name === "core.rng.stream.derived"); + + describe("S130.1 — the log holds epoch entries, and each one consumes a seq", () => { + it("an action after an adoption takes the next seq and derives its stream from the full log length", () => { + const recorder = createRecordingEmitter(); + const engine = createEngine(adoptHost({ emitter: recorder })); + const moved = adopted(engine, started(engine), "2"); + const before = recorder.events.length; + + const next = engine.submitAction(moved, "increment").value as GameState; + + expect(next.actionLog.map((entry) => entry.seq)).toEqual([0, 1, 2]); + expect(next.actionLog[2]).toEqual({ seq: 2, actionId: "increment" }); + const derived = streamEvents(recorder.events.slice(before)); + expect(derived.length).toBeGreaterThan(0); + for (const event of derived) expect(event).toMatchObject({ seq: 2 }); + }); + + it("that stream is the one an uncrossed log of the same length derives", () => { + const uncrossedRecorder = createRecordingEmitter(); + const uncrossedEngine = createEngine(adoptHost({ emitter: uncrossedRecorder })); + const twice = uncrossedEngine.submitAction(started(uncrossedEngine), "increment").value as GameState; + const uncrossedBefore = uncrossedRecorder.events.length; + uncrossedEngine.submitAction(twice, "increment"); + + const crossedRecorder = createRecordingEmitter(); + const crossedEngine = createEngine(adoptHost({ emitter: crossedRecorder })); + const moved = adopted(crossedEngine, started(crossedEngine), "2"); + const crossedBefore = crossedRecorder.events.length; + crossedEngine.submitAction(moved, "increment"); + + const uncrossed = streamEvents(uncrossedRecorder.events.slice(uncrossedBefore)); + const crossed = streamEvents(crossedRecorder.events.slice(crossedBefore)); + expect(crossed.length).toBeGreaterThan(0); + expect(crossed.map((e) => (e as { data?: unknown }).data)).toEqual(uncrossed.map((e) => (e as { data?: unknown }).data)); + }); + + it("the starting version is the first epoch entry's from, else campaignVersion", () => { + const engine = createEngine(adoptHost()); + const uncrossed = started(engine); + const twice = adopted(engine, adopted(engine, uncrossed, "2"), "3"); + expect(startingCampaignVersion(uncrossed.actionLog, uncrossed.campaignVersion)).toBe("1"); + expect(startingCampaignVersion(twice.actionLog, twice.campaignVersion)).toBe("1"); + expect(startingCampaignVersion([], undefined)).toBeUndefined(); + }); + }); + + describe("S130.2 — every refusal pins with its reason and leaves the input as it was", () => { + function expectPinned(engine: ReturnType, state: GameState, to: string, reason: string): void { + const snapshot = structuredClone(state); + const result = engine.adoptContent(state, to); + expect(result).toEqual({ adopted: false, reason }); + expect("state" in result).toBe(false); + expect(state).toEqual(snapshot); + } + + it("session_ended: the session is not active", () => { + const engine = createEngine(adoptHost()); + const ended = engine.submitAction(started(engine), "end").value as GameState; + judged.length = 0; + expectPinned(engine, ended, "2", "session_ended"); + expect(judged).toEqual([]); + }); + + it("unknown_campaign: the target epoch does not resolve", () => { + const engine = createEngine(adoptHost()); + expectPinned(engine, started(engine), "9", "unknown_campaign"); + }); + + it("unknown_campaign: the state's own epoch does not resolve", () => { + const engine = createEngine(adoptHost()); + expectPinned(engine, { ...started(engine), campaignVersion: "0" }, "2", "unknown_campaign"); + }); + + it("content_kind_changed: the target epoch is another kind's campaign", () => { + const engine = createEngine(adoptHost()); + expectPinned(engine, started(engine), "other-kind", "content_kind_changed"); + }); + + it("content_not_adoptable: the kind declares no adoptContent", () => { + const engine = createEngine(adoptHost({ kinds: makeKinds(makeTestKind()) })); + expectPinned(engine, started(engine), "2", "content_not_adoptable"); + }); + + it("migration_failed: the target campaign's migrateState fails, and the kind is never asked", () => { + const engine = createEngine(adoptHost()); + const state = started(engine); + judged.length = 0; + expectPinned(engine, state, "migrate-fails", "migration_failed"); + expect(judged).toEqual([]); + }); + + it("migration_failed: the target campaign's migrateState throws", () => { + const engine = createEngine(adoptHost()); + expectPinned(engine, started(engine), "migrate-throws", "migration_failed"); + }); + + it("content_incompatible: the kind refuses", () => { + const engine = createEngine(adoptHost()); + expectPinned(engine, started(engine), "refuse", "content_incompatible"); + }); + + it("content_incompatible: the kind throws", () => { + const engine = createEngine(adoptHost()); + expectPinned(engine, started(engine), "throw", "content_incompatible"); + }); + + it("invalid_state: the kind adopts a state it cannot run", () => { + const engine = createEngine(adoptHost()); + expectPinned(engine, started(engine), "invalid", "invalid_state"); + }); + + it("the kind judges the target campaign's migrated state, against both campaigns", () => { + const engine = createEngine(adoptHost()); + const state = started(engine); + judged.length = 0; + const moved = adopted(engine, state, "migrate-adds"); + expect(judged).toEqual([{ from: "1", to: "migrate-adds", counter: 11 }]); + expect((moved.kindState as TestKindState).counter).toBe(1011); + }); + }); + + describe("S130.3 — an adoption is a logged entry, an event and nothing else", () => { + it("appends the content entry and restamps the envelope, and returns nothing but the state", () => { + const engine = createEngine(adoptHost()); + const state = started(engine); + const result: RootAdoptionResult = engine.adoptContent(state, "2"); + + expect(result.adopted).toBe(true); + const moved = (result as { adopted: true; state: GameState }).state; + const entry: RootLoggedContent = { seq: 1, system: "content", from: "1", to: "2" }; + expect(moved.actionLog).toEqual([...state.actionLog, entry]); + expect(moved.campaignVersion).toBe("2"); + expect(moved.formatVersion).toBe(2); + expect({ + ...moved, + actionLog: state.actionLog, + campaignVersion: state.campaignVersion, + formatVersion: state.formatVersion, + kindState: state.kindState, + }).toEqual(state); + expect(Object.keys(result).sort()).toEqual(["adopted", "state"]); + }); + + it("emits one core.content.adopted with both versions, and derives no stream", () => { + const recorder = createRecordingEmitter(); + const engine = createEngine(adoptHost({ emitter: recorder })); + const state = started(engine); + const before = recorder.events.length; + + adopted(engine, state, "2"); + + const emitted = recorder.events.slice(before); + expect(emitted).toHaveLength(1); + expect(emitted[0]).toMatchObject({ + scope: "game", + name: "core.content.adopted", + severity: "info", + seq: 1, + data: { fromVersion: "1", toVersion: "2" }, + }); + }); + + it("emits one core.content.pinned with the reason and both versions on a refusal", () => { + const recorder = createRecordingEmitter(); + const engine = createEngine(adoptHost({ emitter: recorder })); + const state = started(engine); + const before = recorder.events.length; + + engine.adoptContent(state, "refuse"); + + const emitted = recorder.events.slice(before); + expect(emitted).toHaveLength(1); + expect(emitted[0]).toMatchObject({ + name: "core.content.pinned", + severity: "info", + seq: 1, + reason: "content_incompatible", + data: { fromVersion: "1", toVersion: "refuse" }, + }); + }); + + it("the state's own version returns the same state, logs nothing and emits nothing", () => { + const recorder = createRecordingEmitter(); + const engine = createEngine(adoptHost({ emitter: recorder })); + const state = started(engine); + const before = recorder.events.length; + judged.length = 0; + + const result = engine.adoptContent(state, "1"); + + expect(result).toEqual({ adopted: true, state }); + expect((result as { state: GameState }).state).toBe(state); + expect(recorder.events.length).toBe(before); + expect(judged).toEqual([]); + }); + + it("a second adoption logs a second entry from where the first left off", () => { + const engine = createEngine(adoptHost()); + const twice = adopted(engine, adopted(engine, started(engine), "2"), "3"); + expect(twice.actionLog.slice(1)).toEqual([ + { seq: 1, system: "content", from: "1", to: "2" }, + { seq: 2, system: "content", from: "2", to: "3" }, + ]); + expect(twice.campaignVersion).toBe("3"); + }); + }); + + describe("S130.4 — deserialize reads both envelope shapes and refuses a stamp the log contradicts", () => { + const engine = createEngine(adoptHost()); + const crossed = adopted(engine, started(engine), "2"); + const blob = engine.serialize(crossed); + function edited(edit: (raw: Record) => void): string { + const raw = JSON.parse(blob) as Record; + edit(raw); + return JSON.stringify(raw); + } + const log = (raw: Record) => raw["actionLog"] as Array>; + + const negatives: Array<[string, string]> = [ + ["formatVersion 1 over a log carrying an epoch entry", edited((raw) => (raw["formatVersion"] = 1))], + ["the last epoch entry's to is not campaignVersion", edited((raw) => (log(raw)[1]!["to"] = "3"))], + ["formatVersion 3", edited((raw) => (raw["formatVersion"] = 3))], + ["formatVersion 2 over a log with no epoch entry", edited((raw) => (raw["actionLog"] = log(raw).slice(0, 1)))], + ["an epoch entry carrying an actionId", edited((raw) => (log(raw)[1]!["actionId"] = "increment"))], + ["an epoch entry naming an unknown system", edited((raw) => (log(raw)[1]!["system"] = "patch"))], + ["an epoch entry whose to is not a string", edited((raw) => (log(raw)[1]!["to"] = 2))], + ["an epoch entry whose seq is out of place", edited((raw) => (log(raw)[1]!["seq"] = 5))], + ]; + for (const [name, data] of negatives) { + it(`rejects with invalid_state: ${name}`, () => { + const result = engine.deserialize(data); + expect(result.ok).toBe(false); + expect(result.errors.map((e) => e.code)).toEqual(["invalid_state"]); + }); + } + + it("accepts a version-2 state that satisfies C26, byte-identically", () => { + const loaded = engine.deserialize(blob); + expect(loaded.ok).toBe(true); + expect(engine.serialize(loaded.value as GameState)).toBe(blob); + }); + + it("accepts a migration entry whose from no archive holds, without resolving it", () => { + const migration: RootLoggedMigration = { seq: 2, system: "migration", from: "1.5-retired", to: "2" }; + const loaded = engine.deserialize(edited((raw) => log(raw).push({ ...migration }))); + expect(loaded.ok).toBe(true); + const entries: RootLoggedEntry[] = (loaded.value as GameState).actionLog; + expect(entries[2]).toEqual(migration); + }); + + it("S130.6 — a state with no epoch entry keeps formatVersion 1", () => { + const state = started(engine); + expect(state.formatVersion).toBe(1); + expect((JSON.parse(engine.serialize(state)) as { formatVersion: number }).formatVersion).toBe(1); + expect(engine.deserialize(engine.serialize(state)).ok).toBe(true); + }); + }); + + describe("S130.7 — the adoption reasons are base codes", () => { + it("each has a shipped message under its key", () => { + for (const code of ["content_kind_changed", "content_not_adoptable", "content_incompatible"] as const) { + expect(BASE_REASON_CODES).toContain(code); + expect(CORE_REASON_MESSAGES.get(`core.reason.${code}`)).toEqual(expect.any(String)); + } + }); + }); +}); diff --git a/src/engine/src/core/kernel/engine.ts b/src/engine/src/core/kernel/engine.ts index b73cb0d7..06de1cd7 100644 --- a/src/engine/src/core/kernel/engine.ts +++ b/src/engine/src/core/kernel/engine.ts @@ -13,17 +13,23 @@ import type { ActionParams, ActionResult, + AdoptDecision, + AdoptionResult, AvailableAction, Engine, GameState, Kind, KindContext, LoggedAction, + LoggedContent, + LoggedEntry, + LoggedMigration, NewGameConfig, ResolutionArchive, Scene, } from "./types.js"; -import type { CommandResult, OutcomeMessage } from "./reasons.js"; +import type { CommandResult, OutcomeMessage, ReasonCode } from "./reasons.js"; +import { isEpochEntry } from "./log.js"; import type { RngHandle, StreamId } from "../determinism/types.js"; import { encodeStreamId, rngHandleFor } from "../determinism/rng.js"; import type { Campaign, ContentRegistry } from "../registry/types.js"; @@ -177,7 +183,8 @@ function createGame(host: ResolvedHost, config: NewGameConfig, campaignVersion?: // plan 09's inline note under createGame. A kind that settles at start and wants its // opening messages seen is covered by the client calling scene() immediately after. const state: GameState = { - formatVersion: CURRENT_FORMAT_VERSION, + // A new game's log carries no epoch entry, so it is the original shape (C26). + formatVersion: UNEPOCHED_FORMAT_VERSION, gameId, kindId: campaign.kindId, campaignId: campaign.id, @@ -373,18 +380,18 @@ function isPlainObject(v: unknown): v is Record { return typeof v === "object" && v !== null && !Array.isArray(v); } -/** The only envelope shape that has ever existed (04 §2, §10.2). */ -const CURRENT_FORMAT_VERSION = 1; +/** + * The two envelope shapes (04 §2, §10.2; 16 §3.3). `2` exactly when the log carries an epoch + * entry and `1` otherwise, so a game that never adopts serializes byte-identically to one + * written before content epochs existed (C26). + */ +const UNEPOCHED_FORMAT_VERSION = 1; +const EPOCHED_FORMAT_VERSION = 2; const VALID_KIND_IDS: readonly string[] = ["story-graph", "simulation", "world-graph"]; const VALID_STATUSES: readonly string[] = ["active", "ended", "abandoned"]; -function isValidLoggedAction(v: unknown): v is LoggedAction { - if (!isPlainObject(v)) return false; - // "0-based, monotonic" (kernel/types.ts) rules out negatives and fractions here; the - // 0-based-monotonic-across-the-whole-log check lives in isValidActionLog below, since - // it needs the array, not just one entry. - if (typeof v["seq"] !== "number" || !Number.isInteger(v["seq"]) || v["seq"] < 0) return false; +function isValidLoggedAction(v: Record): v is Record & LoggedAction { if (typeof v["actionId"] !== "string") return false; if ("params" in v) { const params = v["params"]; @@ -396,14 +403,45 @@ function isValidLoggedAction(v: unknown): v is LoggedAction { return true; } +/** A `LoggedContent` or `LoggedMigration` (16 §3.1, §3.5): an epoch transition, no action. */ +function isValidEpochEntry(v: Record): v is Record & (LoggedContent | LoggedMigration) { + if ("actionId" in v || "params" in v) return false; + if (v["system"] !== "content" && v["system"] !== "migration") return false; + return typeof v["from"] === "string" && typeof v["to"] === "string"; +} + +function isValidLoggedEntry(v: unknown): v is LoggedEntry { + if (!isPlainObject(v)) return false; + // "0-based, monotonic" (kernel/types.ts) rules out negatives and fractions here; the + // 0-based-monotonic-across-the-whole-log check lives in isValidActionLog below, since + // it needs the array, not just one entry. + if (typeof v["seq"] !== "number" || !Number.isInteger(v["seq"]) || v["seq"] < 0) return false; + return "system" in v ? isValidEpochEntry(v) : isValidLoggedAction(v); +} + /** - * `submitAction` always appends at `state.actionLog.length` (04 §4), so a log it produced - * is always exactly `[0, 1, 2, ..., length-1]`. Enforcing that shape on the way in is what - * keeps a deserialized log from handing a later `submitAction` a duplicate or gapped `seq`. + * `submitAction` and `adoptContent` always append at `state.actionLog.length` (04 §4), so a + * log they produced is always exactly `[0, 1, 2, ..., length-1]` across every entry type. + * Enforcing that shape on the way in is what keeps a deserialized log from handing a later + * `submitAction` a duplicate or gapped `seq`. */ -function isValidActionLog(v: unknown): v is LoggedAction[] { +function isValidActionLog(v: unknown): v is LoggedEntry[] { if (!Array.isArray(v)) return false; - return v.every((entry, index) => isValidLoggedAction(entry) && entry.seq === index); + return v.every((entry, index) => isValidLoggedEntry(entry) && entry.seq === index); +} + +/** + * C26: `formatVersion` is `2` exactly when the log carries an epoch entry, and the last epoch + * entry's `to` is `campaignVersion`. An older engine reads a `2` as an unknown version, which is + * the right answer for it; this one reads both and refuses a stamp the log contradicts. A + * migration entry's `from` is not resolved — it names an epoch this host could not run, which + * is why the migration happened (04 §4). + */ +function isConsistentEpochStamp(formatVersion: unknown, campaignVersion: string, actionLog: readonly LoggedEntry[]): boolean { + const epochs = actionLog.filter(isEpochEntry); + const last = epochs[epochs.length - 1]; + if (last === undefined) return formatVersion === UNEPOCHED_FORMAT_VERSION; + return formatVersion === EPOCHED_FORMAT_VERSION && last.to === campaignVersion; } /** @@ -416,7 +454,7 @@ function isValidActionLog(v: unknown): v is LoggedAction[] { * same deep shape check `deserialize` runs, not a second, drifting reimplementation. */ export function isValidGameStateShape(v: unknown): v is GameState { if (!isPlainObject(v)) return false; - if (v["formatVersion"] !== CURRENT_FORMAT_VERSION) return false; + if (v["formatVersion"] !== UNEPOCHED_FORMAT_VERSION && v["formatVersion"] !== EPOCHED_FORMAT_VERSION) return false; if (typeof v["gameId"] !== "string") return false; if (typeof v["kindId"] !== "string" || !VALID_KIND_IDS.includes(v["kindId"])) return false; if (typeof v["campaignId"] !== "string") return false; @@ -425,6 +463,7 @@ export function isValidGameStateShape(v: unknown): v is GameState { if (typeof v["status"] !== "string" || !VALID_STATUSES.includes(v["status"])) return false; if (!("kindState" in v)) return false; if (!isValidActionLog(v["actionLog"])) return false; + if (!isConsistentEpochStamp(v["formatVersion"], v["campaignVersion"], v["actionLog"])) return false; return true; } @@ -529,6 +568,81 @@ function deserializeState(host: ResolvedHost, data: string): CommandResult, kindState: unknown, from: Campaign, to: Campaign): AdoptDecision { + try { + return kind.adoptContent!(kindState, from, to); + } catch { + return { adopt: false, reason: "content_incompatible" }; + } +} + +/** + * 16 §5.4, steps 0–8. Adoption is not play (C24): it derives no stream, so the kind's context + * is never built, and it returns no `StateChange` or `OutcomeMessage`. Every refusal leaves the + * input untouched and emits `core.content.pinned`; an adoption emits `core.content.adopted`. + */ +function adoptContent(host: ResolvedHost, state: GameState, to: string): AdoptionResult { + // Step 0: idempotent. Nothing moved, so nothing is logged and nothing is emitted. + if (to === state.campaignVersion) return { adopted: true, state }; + + const seq = state.actionLog.length; + const emitters = makeResolutionEmitters(host.emitter ?? nullEmitter, state.gameId, seq); + const data = { fromVersion: state.campaignVersion, toVersion: to }; + function pinned(reason: ReasonCode): AdoptionResult { + emitters.core.emit(CORE_EVENTS.contentPinned.name, CORE_EVENTS.contentPinned.severity, { reason, data }); + return { adopted: false, reason }; + } + + if (state.status !== "active") return pinned("session_ended"); + // The source resolves for every state this engine produced or accepted (deserialize checked + // it); a hand-built state that names an unheld epoch pins rather than throwing. + const from = resolveEpoch(host.archive, state.campaignId, state.campaignVersion); + const target = resolveEpoch(host.archive, state.campaignId, to); + if (!from || !target) return pinned("unknown_campaign"); + if (target.campaign.kindId !== state.kindId) return pinned("content_kind_changed"); + const kind = host.kinds[state.kindId]; + if (!kind?.adoptContent) return pinned("content_not_adoptable"); + + const migrated = migrateForAdoption(target.campaign, state.kindState, from.campaign.version); + if (!migrated.ok) return pinned("migration_failed"); + + const decision = judgeAdoption(kind, migrated.kindState, from.campaign, target.campaign); + if (!decision.adopt) return pinned(decision.reason); + if (!isAcceptedKindState(kind, decision.state, target.campaign)) return pinned("invalid_state"); + + const entry: LoggedContent = { seq, system: "content", from: state.campaignVersion, to }; + const adopted: GameState = { + ...state, + formatVersion: EPOCHED_FORMAT_VERSION, + campaignVersion: to, + kindState: decision.state, + actionLog: [...state.actionLog, entry], + }; + emitters.core.emit(CORE_EVENTS.contentAdopted.name, CORE_EVENTS.contentAdopted.severity, { data }); + return { adopted: true, state: adopted }; +} + function migrateState(host: ResolvedHost, data: string): CommandResult { // Migration mechanism is specified (04-core.md §10.2) but unexercised by the MVP // (MVP.md §4): exactly one formatVersion exists, so there is nothing to migrate from @@ -586,6 +700,7 @@ export function createEngine(host: EngineHost): Engine { serialize: (state) => serializeState(state), deserialize: (data) => deserializeState(resolved, data), migrate: (data) => migrateState(resolved, data), + adoptContent: (state, campaignVersion) => adoptContent(resolved, state, campaignVersion), // 05-observability.md §6.1: reconstructing over the new emitter is cheap and pure — // createEngine does no I/O, only the construction checks above, which are idempotent // over the same host. The resolved host carries the archive, so `content` survives. diff --git a/src/engine/src/core/kernel/log.ts b/src/engine/src/core/kernel/log.ts new file mode 100644 index 00000000..b5538772 --- /dev/null +++ b/src/engine/src/core/kernel/log.ts @@ -0,0 +1,28 @@ +/** + * Reading the replay spine — `GameState.actionLog` (04 §2; 16 §3.1). + * + * The log holds player actions and, once a game has adopted content, epoch entries: a + * `LoggedContent` per adoption and a `LoggedMigration` per migrated load over such a log. + * These are the two questions every reader of it asks — which entries are actions, and + * which epoch the log started on — answered once rather than re-derived per caller. + */ + +import type { LoggedAction, LoggedContent, LoggedEntry, LoggedMigration } from "./types.js"; + +export function isLoggedAction(entry: LoggedEntry): entry is LoggedAction { + return "actionId" in entry; +} + +export function isEpochEntry(entry: LoggedEntry): entry is LoggedContent | LoggedMigration { + return "system" in entry; +} + +/** + * The epoch a log started on: the first epoch entry's `from`, else `campaignVersion` (04 §2). + * Derived, never stored — a stored starting version would duplicate what the log says. + * `campaignVersion` is optional for a caller that holds a log but no state, such as a + * determinism fixture, which then starts on the engine's default epoch. + */ +export function startingCampaignVersion(actionLog: readonly LoggedEntry[], campaignVersion?: string): string | undefined { + return actionLog.find(isEpochEntry)?.from ?? campaignVersion; +} diff --git a/src/engine/src/core/kernel/reasons.ts b/src/engine/src/core/kernel/reasons.ts index 44dc5800..6db747b5 100644 --- a/src/engine/src/core/kernel/reasons.ts +++ b/src/engine/src/core/kernel/reasons.ts @@ -101,6 +101,12 @@ export type ReasonCode = string; * during W120 — campaign composition (04 §10.4, `registry/compose.ts`) checks an include's * envelope before any kind merges content, at registry build. Base rather than kind-owned * because the include, its pin and the cycle are core-owned; only the merge is the kind's. + * + * `content_kind_changed`, `content_not_adoptable` and `content_incompatible` were added with + * content epochs (16 §5.4) — `Engine.adoptContent` pins a session with one of them when the + * target epoch is another kind's, when the kind declares no `adoptContent`, and when the kind + * refuses or throws. Base rather than kind-owned because the session store reads a refusal + * without knowing which kind produced it, and the first two are decided before any kind runs. */ export const BASE_REASON_CODES = [ "action_not_available", @@ -147,6 +153,10 @@ export const BASE_REASON_CODES = [ "include_alias_collision", "attachment_host_missing", "compose_unsupported", + // content epochs (16 §5.4) — adoption + "content_kind_changed", + "content_not_adoptable", + "content_incompatible", ] as const; export type BaseReasonCode = (typeof BASE_REASON_CODES)[number]; @@ -201,6 +211,9 @@ const CORE_REASON_TEXT: Readonly> = { include_alias_collision: "This campaign uses the same include name twice.", attachment_host_missing: "A pack attaches content to a campaign that isn't available.", compose_unsupported: "This kind of campaign can't include other campaigns.", + content_kind_changed: "The new version of this campaign is a different kind of game, so this session stays on its version.", + content_not_adoptable: "This kind of game can't move a session onto new content, so it stays on its version.", + content_incompatible: "This session can't move onto the new version of its campaign, so it stays on the version it started.", }; /** `core.reason.` → its shipped default-English message, for every base code. */ diff --git a/src/engine/src/core/kernel/types.ts b/src/engine/src/core/kernel/types.ts index e79e35f8..cadaff0b 100644 --- a/src/engine/src/core/kernel/types.ts +++ b/src/engine/src/core/kernel/types.ts @@ -54,17 +54,43 @@ export interface GameState { * dependency arrow keeps pointing from kinds to the core and never back. */ kindState: unknown; - /** Ordered player actions — the replay spine. */ - actionLog: LoggedAction[]; + /** Ordered entries — the replay spine. Player actions, plus an epoch entry wherever + * `campaignVersion` changed once the log carried one (04 §2; 16 §3.1). */ + actionLog: LoggedEntry[]; } +export type LoggedEntry = LoggedAction | LoggedContent | LoggedMigration; + export interface LoggedAction { - /** 0-based, monotonic. A rejected action does not advance it. */ + /** 0-based, monotonic across every entry type. A rejected action does not advance it. */ seq: number; actionId: string; params?: Readonly>; } +/** An adoption (16 §3.1): the state moved from epoch `from` to epoch `to`. */ +export interface LoggedContent { + /** Consumes a seq, exactly like an action. */ + seq: number; + system: "content"; + /** The `campaignVersion` before adoption. */ + from: string; + /** The `campaignVersion` after — the last epoch entry's `to` is `campaignVersion` (C26). */ + to: string; +} + +/** A migrated load that changed `campaignVersion` on a log already carrying an epoch entry + * (04 §10.2; 16 §3.5). Never replayed. */ +export interface LoggedMigration { + /** Consumes a seq, exactly like an action. */ + seq: number; + system: "migration"; + /** The `campaignVersion` the save was made under. */ + from: string; + /** The `campaignVersion` the migration restamped. */ + to: string; +} + export type ActionParams = Readonly>; // --------------------------------------------------------------------------- @@ -96,6 +122,12 @@ export interface AdvanceResult { error?: ValidationError; } +/** A kind's judgement on moving a running state between two epochs of one campaign + * (16 §5.4). No changes and no messages: adoption is not play (C24). */ +export type AdoptDecision = + | { adopt: true; state: KState } + | { adopt: false; reason: ReasonCode }; + /** `AdvanceResult` minus `error`: a pre-validated campaign cannot fail to start. */ export interface InitialStateResult { state: KState; @@ -184,6 +216,16 @@ export interface Kind { */ migrateState?(oldState: unknown, fromVersion: string): CommandResult; + /** + * Whether a running `state` can move from campaign `from` to campaign `to`, two published + * epochs of the same campaign id, and what it becomes (16 §5.4). Called by + * `Engine.adoptContent` after the target campaign's `migrateState` ran. Optional: **absent + * means every session of this kind stays pinned** to the epoch it started on. Pure; reads + * neither the archive, the channel nor the log; draws no randomness. A throw is read as a + * refusal with `content_incompatible`. + */ + adoptContent?(state: KState, from: Campaign, to: Campaign): AdoptDecision; + /** * This kind's cross-game profile slice (04 §7.1). **Absent means the kind owns no * profile data at all** — no `KindProfileRecord` is ever written for it, `initialState` @@ -316,6 +358,10 @@ export interface NewGameConfig { * Content by epoch (16-content-epochs.md §4; 20-contract.md §4). One key names one content * forever (C23), so the engine treats `resolve` as pure. */ +export type AdoptionResult = + | { adopted: true; state: GameState } + | { adopted: false; reason: ReasonCode }; + export interface ResolutionArchive { /** The registry the epoch `(campaignId, campaignVersion)` was published in, or undefined. */ resolve(campaignId: string, campaignVersion: string): ContentRegistry | undefined; @@ -347,6 +393,10 @@ export interface Engine { serialize(state: GameState): string; deserialize(data: string): CommandResult; migrate(data: string): CommandResult; + /** Move a running state onto another published epoch of its campaign, or say why not + * (16 §5.4). Never a rejection: a refusal is a value, so the session store can complete + * the command it rides on. */ + adoptContent(state: GameState, campaignVersion: string): AdoptionResult; /** The same engine, with every event stamped for one command (05-observability.md * §6.1). The session store builds a short-lived decorator per command and swaps it in * here rather than the pure engine ever holding a clock or per-command context itself. */ diff --git a/src/engine/src/core/observability/events.ts b/src/engine/src/core/observability/events.ts index 7543c9bc..0866f548 100644 --- a/src/engine/src/core/observability/events.ts +++ b/src/engine/src/core/observability/events.ts @@ -38,4 +38,6 @@ export const CORE_EVENTS = { deserializeRejected: { name: "core.deserialize.rejected", severity: "error" }, validationCompleted: { name: "core.validation.completed", severity: "info" }, migrationApplied: { name: "core.migration.applied", severity: "warn" }, + contentAdopted: { name: "core.content.adopted", severity: "info" }, + contentPinned: { name: "core.content.pinned", severity: "info" }, } as const satisfies Record; diff --git a/src/engine/src/core/session/store.test.ts b/src/engine/src/core/session/store.test.ts index a64f0c03..7918c2cc 100644 --- a/src/engine/src/core/session/store.test.ts +++ b/src/engine/src/core/session/store.test.ts @@ -1668,6 +1668,42 @@ describe("session lifecycle — listSaves / deleteSave / branchSession (04 §7.4 expect(putCalls).toBe(0); }); + it("refuses a log that crossed an epoch with invalid_state, writing nothing, until it can replay one (S134)", async () => { + const second = makeRegistry([makeCampaign({ version: "2" })]); + const first = makeRegistry(); + const kinds = { + "story-graph": { ...makeTestKind(), adoptContent: (state: TestKindState) => ({ adopt: true as const, state }) }, + } as unknown as KindRegistry; + const engine = makeEngine({ + kinds, + registry: first, + archive: { resolve: (id, version) => (id !== "test-campaign" ? undefined : version === "1" ? first : version === "2" ? second : undefined) }, + }); + const created = engine.createGame({ campaignId: "test-campaign", seed: "fixed-seed" }); + const adoption = engine.adoptContent(created.value!, "2"); + if (!adoption.adopted) throw new Error("expected the test kind to adopt"); + const record: StoredSessionRecord = { + sessionId: "crossed", + blob: engine.serialize(adoption.state), + audience: "player", + attemptCounter: 1, + revision: 1, + replayCompatible: true, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }; + let putCalls = 0; + const store = makeStore({ + engine, + persistence: persistenceWith({ + sessions: { get: async (id) => (id === "crossed" ? record : undefined), put: async () => { putCalls += 1; } }, + }), + }); + + await expect(store.branchSession("crossed", 0)).rejects.toMatchObject({ code: "invalid_state" }); + expect(putCalls).toBe(0); + }); + it("B3 — a branch's sessionId is distinct from its source, and its gameId equals the source's", async () => { const store = makeStore(); const { sessionId } = await store.createSession({ campaignId: "test-campaign" }); diff --git a/src/engine/src/core/session/store.ts b/src/engine/src/core/session/store.ts index 2b51bea7..7e7d5728 100644 --- a/src/engine/src/core/session/store.ts +++ b/src/engine/src/core/session/store.ts @@ -20,6 +20,7 @@ import type { } from "../kernel/types.js"; import type { OutcomeMessage, StateChange } from "../kernel/reasons.js"; import type { Campaign, ContentRegistry } from "../registry/types.js"; +import { isLoggedAction } from "../kernel/log.js"; import { buildSaveEnvelope, resolveSaveEnvelope, serializeSaveEnvelope } from "../persistence/envelope.js"; import { canonicalize as canonicalStringify } from "subzerodev-data-json"; import type { PlayerView, ProjectionAudience } from "../projection/types.js"; @@ -994,6 +995,13 @@ function createStore(options: InMemorySessionStoreOptions): SessionStore { throw new SessionStoreErrorValue("branchSession", "invalid_state"); } const sourceState = mustDeserialize(engine, source.blob); + // A log that crossed an epoch starts on another version and replays its content entries + // through `adoptContent`; until branching does that (S134), it refuses with nothing + // written rather than replaying a crossed log onto the default epoch. + const actions = sourceState.actionLog.filter(isLoggedAction); + if (actions.length !== sourceState.actionLog.length) { + throw new SessionStoreErrorValue("branchSession", "invalid_state"); + } if (!Number.isInteger(atActionCount) || atActionCount < 0 || atActionCount > sourceState.actionLog.length) { throw new SessionStoreErrorValue("branchSession", "invalid_fork_point"); } @@ -1003,7 +1011,7 @@ function createStore(options: InMemorySessionStoreOptions): SessionStore { } const branchSessionId = newSessionId(recordIds); - const retained = sourceState.actionLog.slice(0, atActionCount); + const retained = actions.slice(0, atActionCount); return withCommand(branchSessionId, 0, async (decoratedEngine) => { const created = decoratedEngine.createGame({ campaignId: sourceState.campaignId, seed: sourceState.seed }); diff --git a/src/engine/src/index.ts b/src/engine/src/index.ts index 6d11fe85..60b72a0f 100644 --- a/src/engine/src/index.ts +++ b/src/engine/src/index.ts @@ -57,10 +57,10 @@ export type { PortableMigration, } from "./portable/format.js"; -export type { Engine, ResolutionArchive } from "./core/kernel/types.js"; +export type { Engine, ResolutionArchive, AdoptionResult, AdoptDecision } from "./core/kernel/types.js"; export type { EngineHost, SessionHost } from "./core/composition/types.js"; export type { IdSource, RecordIdSource, Clock, ExperimentSource } from "./core/composition/types.js"; -export type { GameState, GameStatus } from "./core/kernel/types.js"; +export type { GameState, GameStatus, LoggedEntry, LoggedAction, LoggedContent, LoggedMigration } from "./core/kernel/types.js"; export type { Kind, KindContext, KindOutcome, KindRegistry, KindProfileData } from "./core/kernel/types.js"; export type { ActionParams, ActionResult, AvailableAction, Scene, SceneBody } from "./core/kernel/types.js"; export type { PlayerView } from "./core/projection/types.js";