Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion design/30-slices.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions design/90-decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion docs/docs/engine/TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
95 changes: 93 additions & 2 deletions src/engine/src/core/determinism/harness.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<TestKindState> = {
...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/);
});
});
75 changes: 59 additions & 16 deletions src/engine/src/core/determinism/harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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()`
Expand All @@ -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;
}
Loading
Loading