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 @@ -258,7 +258,7 @@ Out of scope: Generating the docs' other volatile facts, one of the review's non

## S129 — Every Game Plays the Content It Names

Status: todo
Status: done
Delivers: A host can keep more than one published version of a campaign available at once. A
new game can start on any of them, and every game plays the version it names rather
than whichever version the host registered under the campaign's name. A stored game
Expand Down
10 changes: 10 additions & 0 deletions design/90-decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -2581,6 +2581,16 @@ Chosen: the first predicate now also requires the target node at `currentNodeId`
Rejected: **Settling at adoption.** Settle walks nodes and may draw randomness, which is play, and C24 forbids adoption to invent play. **Requiring only that the node kind is unchanged.** That misses a choice node whose every choice the target gated away, which strands the player the same way through a different edit.
Reversibility: tightening a predicate is cheap before build. After build, a relaxed rule would change which recorded adoptions replay, so a change is a 07 divergence and needs a decision.

### 2026-10-09 — S129: the engine reads its registry's campaign map once, at construction
Context: `20-contract.md` §4 makes the construction-time `registry` the default epoch, and C23 says a key names one content for the archive's lifetime. Neither says what happens when a host mutates the registry's campaign map after `createEngine`. One existing store test does exactly that, re-registering a campaign at a new version under a live engine, and expects `branchSession` to refuse with `unknown_campaign`.
Chosen: `createEngine` copies the campaign map once. The default archive and the default version `createGame` starts on both come from that copy, so a later mutation changes neither which content a key names nor where a new game starts.
Rejected: **Reading the live registry.** The default archive would then stop resolving an epoch it had resolved before, which breaks C23, and a stored state would fail `deserialize` inside the store, which would report a defensive throw where it reported `unknown_campaign` (S129.5 requires every existing test to pass unchanged).

### 2026-10-09 — S129: an epoch resolves only when the archive's registry holds the campaign at that version
Context: `ResolutionArchive.resolve(id, version)` returns a registry. The contract does not say what the engine does when that registry holds the campaign at some other version. `createGame` stamps `campaign.version`, so such a registry would start a game on a version other than the one asked for.
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-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 @@ -252,7 +252,7 @@ Out of scope: Generating the docs' other volatile facts, one of the review's non

## S129 — Every Game Plays the Content It Names

Status: todo
Status: done
Delivers: A host can keep more than one published version of a campaign available at once. A
new game can start on any of them, and every game plays the version it names rather
than whichever version the host registered under the campaign's name. A stored game
Expand Down
5 changes: 4 additions & 1 deletion src/engine/src/core/composition/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
*/

import type { ContentRegistry } from "../registry/types.js";
import type { Engine, KindRegistry } from "../kernel/types.js";
import type { Engine, KindRegistry, ResolutionArchive } from "../kernel/types.js";
import type { Emitter } from "../observability/types.js";
import type { ProfileStore, SessionPersistence } from "../session/types.js";
import type { EmittedRecordSink } from "../observability/types.js";
Expand Down Expand Up @@ -76,6 +76,9 @@ export interface EngineHost {
readonly registry: ContentRegistry;
readonly ids?: IdSource;
readonly emitter?: Emitter;
/** Every epoch this engine can play (20-contract.md §4). Omitted → an archive holding only
* `registry`. Must resolve every campaign of `registry` at its registered version. */
readonly archive?: ResolutionArchive;
}

/** Composition root for the session layer — the only place a clock appears. */
Expand Down
180 changes: 180 additions & 0 deletions src/engine/src/core/kernel/engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import type { ValidationResult } from "../validation/types.js";
import type { EngineHost } from "../composition/types.js";
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";

interface TestKindState {
counter: number;
Expand Down Expand Up @@ -809,3 +811,181 @@ describe("withEmitter", () => {
expect(engine.serialize(viaRebound.value as GameState)).toBe(engine.serialize(viaOriginal.value as GameState));
});
});

describe("S129 — every state plays the epoch it names", () => {
// Two epochs of one campaign that differ only in one node's text — once in the campaign's
// own content and once in the registry's strings, so both halves of the KindContext show.
function epochRegistry(version: string, text: string): ContentRegistry {
const campaign = makeCampaign({ version, content: { text } });
return { campaigns: new Map([[campaign.id, campaign]]), strings: new Map([["node.start", text]]) };
}
const first = epochRegistry("1", "The old clerk sighs.");
const second = epochRegistry("2", "The new clerk yawns.");

const archive: RootResolutionArchive = {
resolve: (campaignId, campaignVersion) =>
campaignId !== "test-campaign" ? undefined : campaignVersion === "1" ? first : campaignVersion === "2" ? second : undefined,
};

const validated: string[] = [];
const epochKind = makeTestKind({
scene: (_state, ctx): SceneBody => ({
textKey: "node.start",
text: `${(ctx.campaign.content as { text: string }).text} / ${ctx.registry.strings.get("node.start") ?? ""}`,
}),
project: (state, _audience, ctx) => ({ counter: state.counter, text: ctx.registry.strings.get("node.start") }),
availableActions: (_state, ctx): AvailableAction[] => [
{ id: `increment-v${ctx.campaign.version}`, labelKey: "node.start", available: true },
],
advance: (state, _actionId, _params, ctx): AdvanceResult<TestKindState> => ({
state: { counter: state.counter + (ctx.campaign.version === "2" ? 100 : 1) },
status: "active",
changes: [],
messages: [],
}),
validateState: (_kindState, campaign) => {
validated.push(campaign.version);
return true;
},
});

function epochHost(overrides?: Partial<EngineHost>): EngineHost {
return { kinds: makeKinds(epochKind), registry: first, archive, ...overrides };
}

describe("S129.1 — the archive in use", () => {
it("without an archive, Engine.content holds only the registry, at its registered version", () => {
const engine = createEngine(makeHost());
expect(engine.content.resolve("test-campaign", "1")?.campaigns.get("test-campaign")?.version).toBe("1");
expect(engine.content.resolve("test-campaign", "2")).toBeUndefined();
expect(engine.content.resolve("some-other-campaign", "1")).toBeUndefined();
});

it("with an archive, Engine.content is that archive, and withEmitter keeps it", () => {
const engine = createEngine(epochHost());
expect(engine.content).toBe(archive);
expect(engine.withEmitter(nullEmitter).content).toBe(archive);
});

it("createEngine throws when the archive does not resolve a registry campaign at its registered version", () => {
const onlySecond: RootResolutionArchive = {
resolve: (id, version) => (id === "test-campaign" && version === "2" ? second : undefined),
};
expect(() => createEngine(epochHost({ archive: onlySecond }))).toThrow(
/does not resolve campaign "test-campaign" at its registered version "1"/,
);
});

it("createEngine throws when the archive answers the key with a registry holding another version", () => {
const wrongContent: RootResolutionArchive = { resolve: () => second };
expect(() => createEngine(epochHost({ archive: wrongContent }))).toThrow(/does not resolve campaign/);
});

it("the default epoch is read at construction: mutating the registry afterwards changes nothing", () => {
const campaigns = new Map([["test-campaign", makeCampaign({ version: "1" })]]);
const engine = createEngine(makeHost({ registry: { campaigns, strings: new Map() } }));
campaigns.set("test-campaign", makeCampaign({ version: "2" }));
expect(engine.createGame({ campaignId: "test-campaign" }).value?.campaignVersion).toBe("1");
expect(engine.content.resolve("test-campaign", "2")).toBeUndefined();
});
});

describe("S129.2 — every state-taking operation reads its own epoch", () => {
const engine = createEngine(epochHost());
const onFirst = engine.createGame({ campaignId: "test-campaign", seed: "fixed-seed" }).value as GameState;
const onSecond = engine.createGame({ campaignId: "test-campaign", seed: "fixed-seed" }, "2").value as GameState;

it("scene renders each epoch's own text", () => {
expect(engine.scene(onFirst).body.text).toBe("The old clerk sighs. / The old clerk sighs.");
expect(engine.scene(onSecond).body.text).toBe("The new clerk yawns. / The new clerk yawns.");
expect(engine.scene(onSecond).view.kindView).toEqual({ counter: 0, text: "The new clerk yawns." });
});

it("view and availableActions read the state's epoch", () => {
expect(engine.view(onFirst, "player").kindView).toEqual({ counter: 0, text: "The old clerk sighs." });
expect(engine.view(onSecond, "player").kindView).toEqual({ counter: 0, text: "The new clerk yawns." });
expect(engine.availableActions(onFirst).map((a) => a.id)).toEqual(["increment-v1"]);
expect(engine.availableActions(onSecond).map((a) => a.id)).toEqual(["increment-v2"]);
});

it("submitAction and previewAction advance against the state's epoch", () => {
expect((engine.submitAction(onFirst, "increment").value?.kindState as TestKindState).counter).toBe(1);
expect((engine.submitAction(onSecond, "increment").value?.kindState as TestKindState).counter).toBe(100);
expect((engine.previewAction(onSecond, "increment").value?.kindState as TestKindState).counter).toBe(100);
});

it("deserialize and migrate judge the kind state against the state's epoch and keep it", () => {
const blob = engine.serialize(onSecond);
validated.length = 0;
const loaded = engine.deserialize(blob);
const migrated = engine.migrate(blob);
expect(validated).toEqual(["2", "2"]);
expect(loaded.ok).toBe(true);
expect(migrated.ok).toBe(true);
expect(engine.scene(loaded.value as GameState).body.text).toBe("The new clerk yawns. / The new clerk yawns.");
expect(engine.serialize(migrated.value as GameState)).toBe(blob);
});
});

describe("S129.3 — createGame starts on the default or a named epoch", () => {
const engine = createEngine(epochHost());

it("starts on the registry's version when no version is given", () => {
expect(engine.createGame({ campaignId: "test-campaign" }).value?.campaignVersion).toBe("1");
});

it("starts on the named epoch and stamps it", () => {
const created = engine.createGame({ campaignId: "test-campaign" }, "2");
expect(created.ok).toBe(true);
expect(created.value?.campaignVersion).toBe("2");
});

it("fails with unknown_campaign for a version that does not resolve", () => {
const created = engine.createGame({ campaignId: "test-campaign" }, "3");
expect(created.ok).toBe(false);
expect(created.errors).toEqual([
{ code: "unknown_campaign", messageKey: "core.reason.unknown_campaign", path: "test-campaign" },
]);
});

it("fails with unknown_campaign for a campaign id with no default epoch, version or not", () => {
expect(engine.createGame({ campaignId: "some-other-campaign" }).errors[0]?.code).toBe("unknown_campaign");
expect(engine.createGame({ campaignId: "some-other-campaign" }, "1").errors[0]?.code).toBe("unknown_campaign");
});
});

describe("S129.4 — deserialize and migrate refuse an epoch that does not resolve", () => {
function atVersion(engine: ReturnType<typeof createEngine>, campaignVersion: string): string {
const created = engine.createGame({ campaignId: "test-campaign" });
const raw = JSON.parse(engine.serialize(created.value as GameState)) as Record<string, unknown>;
raw["campaignVersion"] = campaignVersion;
return JSON.stringify(raw);
}

for (const operation of ["deserialize", "migrate"] as const) {
it(`${operation} refuses a campaign id the registry holds at another version (closes S125.5)`, () => {
const recorder = createRecordingEmitter();
const engine = createEngine(makeHost({ emitter: recorder }));
const blob = atVersion(engine, "2");
const before = recorder.events.length;

const result = engine[operation](blob);

expect(result.ok).toBe(false);
expect(result.value).toBeUndefined();
expect(result.errors).toEqual([
{ code: "unknown_campaign", messageKey: "core.reason.unknown_campaign", path: "test-campaign" },
]);
const emitted = recorder.events.slice(before);
expect(emitted).toHaveLength(1);
expect(emitted[0]).toMatchObject({ scope: "system", name: "core.deserialize.rejected", reason: "unknown_campaign" });
});

it(`${operation} refuses a version the archive does not hold`, () => {
const engine = createEngine(epochHost());
expect(engine[operation](atVersion(engine, "3")).errors[0]?.code).toBe("unknown_campaign");
expect(engine[operation](atVersion(engine, "2")).ok).toBe(true);
});
}
});
});
Loading
Loading