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 @@ -374,7 +374,7 @@ Out of scope: A durable archive and eviction, and listing a campaign that first

## S132 — A Story Moves Onto Additive Content

Status: todo
Status: done
Delivers: A player partway through a story can be moved onto a newer version of it that only
adds to what they are playing. They keep their place, their progress and their
achievements. A version that would strand them where they stand, or drop something
Expand Down
15 changes: 15 additions & 0 deletions design/90-decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -2631,6 +2631,21 @@ Context: a publication stores one frozen registry. A later publication can carry
Chosen: the first. A held key is never rewritten, so what `resolve` returns for a key never changes. That key's campaign and the whole string table are identical by digest in every later copy anyway, so only the *other* campaigns in the returned registry can differ, and the engine reads only the state's own campaign from it.
Rejected: **Repointing the key to the newest copy.** The registry an epoch resolves to would then change under a running session for no content reason, which is the drift C23 exists to rule out.

### 2026-10-09 — S132: story-graph adoption checks a carried variable's `VarType`, not its value against the target's declaration
Context: 03 §8.1 and 16 §5.5 require every variable the state carries that the target declares to have the target's `VarType`. A target can keep the type and still narrow the declaration: an `enum` that drops a member the state holds, or an `int` whose new `min`/`max` excludes the held value.
Chosen: the rule as written. Adoption compares the type only and carries the value through unchanged. An out-of-range `int` clamps on its next write. An `enum` holding a dropped member reads normally in conditions, but a later `copy` from it into another `enum` throws at that transition.
Rejected: **Also refusing a value the target's declaration excludes.** That is a stricter rule than the contract states, and it belongs in a contract amendment rather than an implementation unit. The gap is real enough to revisit: a narrowed `enum` is the one additive-looking change that can still fail a later move.

### 2026-10-09 — S132: a variable neither campaign declares passes through adoption
Context: the last condition of 16 §5.5 refuses a variable "the source declared and the state carries" that the target lacks. A state can also carry a variable that neither the source nor the target declares, which `validateState` tolerates at load as unread.
Chosen: such a variable passes through unchanged, like everything else adoption does not insert. Adoption is not the place to clean up what load already accepted.
Rejected: **Refusing it.** The session would be pinned over a variable nothing reads, and the rule names only source-declared variables.

### 2026-10-09 — S132: the real Bureaucracy campaign's `migrateState` pins every adoption onto an extension that keeps it
Context: S132.4 adopts the Bureaucracy campaign onto a copy of itself extended by one node and one variable. The engine runs the target's `migrateState` before the kind judges (16 §5.4). Bureaucracy's `migrateState` (`migrateV1AdventureState`) migrates from `1.0.0` only and fails for any other `fromVersion`.
Chosen: the S132.4 extension carries no `migrateState`, and a further test pins the real behaviour: the same extension, keeping the real `migrateState`, pins with `migration_failed`. Nothing in the campaign changed. Whether a content `migrateState` should pass through a version it does not migrate is authoring guidance, which this slice leaves out of scope.
Rejected: **Changing `migrateV1AdventureState` to pass other versions through.** That is a campaign change outside this slice. It would also change load-time behaviour, where a save from an unknown version is refused today.

### 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 @@ -368,7 +368,7 @@ Out of scope: A durable archive and eviction, and listing a campaign that first

## S132 — A Story Moves Onto Additive Content

Status: todo
Status: done
Delivers: A player partway through a story can be moved onto a newer version of it that only
adds to what they are playing. They keep their place, their progress and their
achievements. A version that would strand them where they stand, or drop something
Expand Down
141 changes: 141 additions & 0 deletions src/engine/src/campaigns/bulgaria-bureaucracy.adopt.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { describe, it, expect } from "vitest";
import { runFixture, traceFixture, type PlaythroughFixture } from "../core/determinism/harness.js";
import { createEngine } from "../core/kernel/engine.js";
import { createContentArchive } from "../core/registry/archive.js";
import { buildValidatedContentRegistry } from "../core/validation/tiered.js";
import type { IdSource } from "../core/composition/types.js";
import type { Engine, KindRegistry } from "../core/kernel/types.js";
import type { BuiltCampaign, ContentRegistry } from "../core/registry/types.js";
import type { StoryGraphCampaign } from "../kinds/story-graph/campaign.js";
import { storyGraphKind } from "../kinds/story-graph/kind.js";
import type { ChoiceNode } from "../kinds/story-graph/nodes.js";
import type { StoryGraphKindState } from "../kinds/story-graph/state.js";
import { validateStoryGraphState } from "../kinds/story-graph/validateState.js";
import { buildBulgariaBureaucracyCampaign, BULGARIA_BUREAUCRACY_CAMPAIGN_ID } from "./bulgaria-bureaucracy.js";

// S132.4 against real content: the Bureaucracy campaign adopts a copy of itself extended by
// one node and one variable, and a log crossing that epoch replays byte-identically (C21).

const SEED = "bureaucracy-seed-1";
const IDS: IdSource = { newGameId: () => "fixed-game-id", newSeed: () => "fixed-seed" };
const kinds = { "story-graph": storyGraphKind } as unknown as KindRegistry;
const PLAYED_BEFORE = ["wait", "registry_route_listen"];

function built(): BuiltCampaign {
const result = buildBulgariaBureaucracyCampaign();
if (!result.ok || !result.value) throw new Error("expected the real campaign to build");
return result.value;
}

function validated(campaign: BuiltCampaign): ContentRegistry {
const result = buildValidatedContentRegistry([campaign], kinds);
if (!result.ok || !result.value) throw new Error(`expected the campaign to validate: ${result.errors.map((e) => e.code).join(", ")}`);
return result.value;
}

const V1 = built();

/** Where the fixture stands when it adopts — read from v1, so the extension hangs off a node
* the player is actually on. */
function adoptionNode(): string {
const engine = createEngine({ kinds, registry: validated(V1), ids: IDS });
let state = engine.createGame({ campaignId: BULGARIA_BUREAUCRACY_CAMPAIGN_ID, seed: SEED }).value!;
for (const actionId of PLAYED_BEFORE) state = engine.submitAction(state, actionId).value!;
return (state.kindState as StoryGraphKindState).currentNodeId;
}

const HOST_NODE = adoptionNode();

/** v1 plus an `annex` choice node reached from the host node, and a `patience` variable.
* The real campaign's `migrateState` migrates from 1.0.0 only, so the copy carries none —
* see the last test. */
function extended(withMigrateState = false): BuiltCampaign {
const { migrateState, ...data } = V1.campaign;
const content = structuredClone(data.content) as StoryGraphCampaign;
content.variables.patience = { type: "int", initial: 3, min: 0, max: 5 };
content.nodes.annex = {
id: "annex",
kind: "choice",
textKey: "node.annex.text",
choices: [{ id: "annex_return", labelKey: "choice.annex_return", effects: [{ op: "decrement", var: "patience", by: 1 }], goto: HOST_NODE }],
};
(content.nodes[HOST_NODE] as ChoiceNode).choices.push({ id: "annex_visit", labelKey: "choice.annex_visit", goto: "annex" });

const strings = new Map(V1.strings);
strings.set("node.annex.text", "A smaller queue, for people who have given up on the larger one.");
strings.set("choice.annex_visit", "Try the annex");
strings.set("choice.annex_return", "Return to the main hall");
return {
campaign: { ...data, version: "2.1.0", content, ...(withMigrateState && migrateState ? { migrateState } : {}) },
strings,
};
}

function buildEngine(target: BuiltCampaign): Engine {
const initial = validated(V1);
const archive = createContentArchive({ kinds, initial });
const published = archive.publish(validated(target));
if (!published.ok) throw new Error(`expected the extension to publish: ${published.errors.map((e) => e.code).join(", ")}`);
return createEngine({ kinds, registry: initial, archive, ids: IDS });
}

const CROSSING: PlaythroughFixture = {
name: "Bureaucracy, adopting its own one-node extension partway through",
config: { campaignId: BULGARIA_BUREAUCRACY_CAMPAIGN_ID, seed: SEED },
actionLog: [
{ seq: 0, actionId: PLAYED_BEFORE[0]! },
{ seq: 1, actionId: PLAYED_BEFORE[1]! },
{ seq: 2, system: "content", from: V1.campaign.version, to: "2.1.0" },
{ seq: 3, actionId: "annex_visit" },
{ seq: 4, actionId: "annex_return" },
],
};

describe("S132.4 — the Bureaucracy campaign adopts its own additive extension", () => {
it("adopts mid-arc, and the adopted state passes validateState against the target", () => {
const engine = buildEngine(extended());
let state = engine.createGame(CROSSING.config).value!;
for (const actionId of PLAYED_BEFORE) state = engine.submitAction(state, actionId).value!;

const result = engine.adoptContent(state, "2.1.0");
expect(result.adopted).toBe(true);
if (!result.adopted) return;
const kindState = result.state.kindState as StoryGraphKindState;
expect(kindState.variables.patience).toBe(3);
expect(kindState.currentNodeId).toBe(HOST_NODE);
expect(validateStoryGraphState(kindState, extended().campaign)).toBe(true);
expect(engine.availableActions(result.state).map((a) => a.id)).toContain("annex_visit");
});

it("replays a log crossing the epoch byte-identically, entry by entry", () => {
const first = traceFixture(buildEngine(extended()), CROSSING);
const second = traceFixture(buildEngine(extended()), CROSSING);
expect(first).toHaveLength(CROSSING.actionLog.length + 1);
expect(second).toEqual(first);

const final = JSON.parse(runFixture(buildEngine(extended()), CROSSING)) as {
formatVersion: number;
campaignVersion: string;
kindState: StoryGraphKindState;
};
expect(final).toMatchObject({ formatVersion: 2, campaignVersion: "2.1.0" });
expect(final.kindState.currentNodeId).toBe(HOST_NODE);
expect(final.kindState.variables.patience).toBe(2);
expect(final.kindState.visitedCounts.annex).toBe(1);
});

it("round-trips the crossed state through deserialize", () => {
const engine = buildEngine(extended());
const serialized = runFixture(engine, CROSSING);
const loaded = engine.deserialize(serialized);
expect(loaded.ok).toBe(true);
expect(engine.serialize(loaded.value!)).toBe(serialized);
});

it("pins with migration_failed when the extension keeps the real migrateState, which migrates from 1.0.0 only", () => {
const engine = buildEngine(extended(true));
let state = engine.createGame(CROSSING.config).value!;
for (const actionId of PLAYED_BEFORE) state = engine.submitAction(state, actionId).value!;
expect(engine.adoptContent(state, "2.1.0")).toEqual({ adopted: false, reason: "migration_failed" });
});
});
Loading
Loading