From a9597ee96857214f79d665840606ab2f47022c95 Mon Sep 17 00:00:00 2001 From: Ben Richards Date: Fri, 9 Oct 2026 13:38:57 +0300 Subject: [PATCH] =?UTF-8?q?S132=20=E2=80=94=20A=20story=20moves=20onto=20a?= =?UTF-8?q?dditive=20content?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- design/30-slices.md | 2 +- design/90-decisions.md | 15 ++ docs/docs/engine/TODO.md | 2 +- .../bulgaria-bureaucracy.adopt.test.ts | 141 +++++++++++ .../src/kinds/story-graph/adopt.test.ts | 230 ++++++++++++++++++ src/engine/src/kinds/story-graph/adopt.ts | 67 +++++ src/engine/src/kinds/story-graph/kind.ts | 2 + src/engine/src/kinds/story-graph/scene.ts | 10 +- 8 files changed, 466 insertions(+), 3 deletions(-) create mode 100644 src/engine/src/campaigns/bulgaria-bureaucracy.adopt.test.ts create mode 100644 src/engine/src/kinds/story-graph/adopt.test.ts create mode 100644 src/engine/src/kinds/story-graph/adopt.ts diff --git a/design/30-slices.md b/design/30-slices.md index 081592e2..2b11910c 100644 --- a/design/30-slices.md +++ b/design/30-slices.md @@ -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 diff --git a/design/90-decisions.md b/design/90-decisions.md index 165df6df..99b7564c 100644 --- a/design/90-decisions.md +++ b/design/90-decisions.md @@ -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. diff --git a/docs/docs/engine/TODO.md b/docs/docs/engine/TODO.md index a70ac530..36f95a2e 100644 --- a/docs/docs/engine/TODO.md +++ b/docs/docs/engine/TODO.md @@ -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 diff --git a/src/engine/src/campaigns/bulgaria-bureaucracy.adopt.test.ts b/src/engine/src/campaigns/bulgaria-bureaucracy.adopt.test.ts new file mode 100644 index 00000000..e1f7b32b --- /dev/null +++ b/src/engine/src/campaigns/bulgaria-bureaucracy.adopt.test.ts @@ -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" }); + }); +}); diff --git a/src/engine/src/kinds/story-graph/adopt.test.ts b/src/engine/src/kinds/story-graph/adopt.test.ts new file mode 100644 index 00000000..b4dda54b --- /dev/null +++ b/src/engine/src/kinds/story-graph/adopt.test.ts @@ -0,0 +1,230 @@ +import { describe, it, expect } from "vitest"; +import type { Campaign } from "../../core/registry/types.js"; +import type { KindContext } from "../../core/kernel/types.js"; +import { adoptContent } from "./adopt.js"; +import { storyGraphKind } from "./kind.js"; +import { availableActions } from "./scene.js"; +import type { StoryGraphCampaign } from "./campaign.js"; +import type { ChoiceNode } from "./nodes.js"; +import type { StoryGraphKindState } from "./state.js"; +import { validateStoryGraphState } from "./validateState.js"; + +// v1: one choice node with a way on, a second node already visited, an ending, an achievement. +const SOURCE: StoryGraphCampaign = { + descriptionKey: "d", + variables: { + money: { type: "int", initial: 2, min: 0, max: 10 }, + stamped: { type: "bool", initial: false }, + }, + startNodeId: "hall", + achievements: [{ id: "first_queue", nameKey: "a.n", descriptionKey: "a.d", condition: { field: "turn", operator: "greater_or_equal", value: 1 }, hidden: false }], + nodes: { + hall: { + id: "hall", + kind: "choice", + textKey: "node.hall", + choices: [{ id: "queue", labelKey: "choice.queue", goto: "window" }], + }, + window: { + id: "window", + kind: "choice", + textKey: "node.window", + choices: [{ id: "leave", labelKey: "choice.leave", goto: "done" }], + }, + done: { id: "done", kind: "ending", textKey: "node.done", endingId: "done" }, + }, +}; + +function campaign(content: StoryGraphCampaign, version: string): Campaign { + return { id: "queue", kindId: "story-graph", version, titleKey: "t", content }; +} + +/** v1 with `edit` applied to a deep copy. */ +function target(edit: (c: StoryGraphCampaign) => void): Campaign { + const content = structuredClone(SOURCE); + edit(content); + return campaign(content, "2"); +} + +// Partway through: standing in the hall again after visiting the window, one achievement. +function midGame(overrides?: Partial): StoryGraphKindState { + return { + currentNodeId: "hall", + variables: { money: 4, stamped: true }, + turn: 3, + visitedCounts: { hall: 2, window: 1 }, + unlockedAchievements: ["first_queue"], + ...overrides, + }; +} + +const FROM = campaign(SOURCE, "1"); + +const hall = (c: StoryGraphCampaign): ChoiceNode => c.nodes.hall as ChoiceNode; + +/** v1 plus a node, a choice into it, and a variable — purely additive. */ +const ADDITIVE = target((c) => { + c.variables.patience = { type: "int", initial: 5 }; + c.variables.mood = { type: "enum", initial: "calm", values: ["calm", "cross"] }; + c.nodes.annex = { + id: "annex", + kind: "choice", + textKey: "node.annex", + choices: [{ id: "return", labelKey: "choice.return", goto: "hall" }], + }; + hall(c).choices.push({ id: "annex", labelKey: "choice.annex", goto: "annex" }); +}); + +describe("S132.1 — story-graph adopts additive content", () => { + it("declares adoptContent", () => { + expect(typeof storyGraphKind.adoptContent).toBe("function"); + }); + + it("inserts each variable the target adds at its initial value and changes nothing else", () => { + const state = midGame(); + const before = structuredClone(state); + const decision = storyGraphKind.adoptContent!(state, FROM, ADDITIVE); + + expect(decision.adopt).toBe(true); + if (!decision.adopt) return; + expect({ ...decision.state.variables }).toEqual({ money: 4, stamped: true, patience: 5, mood: "calm" }); + expect({ ...decision.state, variables: null }).toEqual({ ...before, variables: null }); + expect(state).toEqual(before); + }); + + it("adopts a target identical to the source unchanged", () => { + const decision = adoptContent(midGame(), FROM, campaign(structuredClone(SOURCE), "2")); + expect(decision).toEqual({ adopt: true, state: midGame() }); + }); + + it("keeps a carried variable's value even where the target changes its initial", () => { + const to = target((c) => { + c.variables.money!.initial = 9; + }); + const decision = adoptContent(midGame(), FROM, to); + expect(decision.adopt && decision.state.variables.money).toBe(4); + }); +}); + +describe("S132.2 — story-graph refuses content that would strand the player", () => { + const refused = { adopt: false, reason: "content_incompatible" }; + + it("refuses a currentNodeId the target lacks", () => { + const to = target((c) => { + delete c.nodes.hall; + c.startNodeId = "window"; + }); + expect(adoptContent(midGame(), FROM, to)).toEqual(refused); + // A played state always counts the node it stands on, so the visitedCounts rule refuses + // this too; without that count, the current-node rule still does. + expect(adoptContent(midGame({ visitedCounts: { window: 1 } }), FROM, to)).toEqual(refused); + }); + + it("refuses a current node that is an ending in the target", () => { + const to = target((c) => { + c.nodes.hall = { id: "hall", kind: "ending", textKey: "node.hall", endingId: "gone" }; + }); + expect(adoptContent(midGame(), FROM, to)).toEqual(refused); + }); + + it("refuses a current node that is an auto node in the target", () => { + const to = target((c) => { + c.nodes.hall = { id: "hall", kind: "auto", textKey: "node.hall", goto: "window" }; + }); + expect(adoptContent(midGame(), FROM, to)).toEqual(refused); + }); + + it("refuses a current node that is a random node in the target", () => { + const to = target((c) => { + c.nodes.hall = { id: "hall", kind: "random", textKey: "node.hall", transitions: [{ weight: 1, goto: "window" }] }; + }); + expect(adoptContent(midGame(), FROM, to)).toEqual(refused); + }); + + it("refuses a current choice node with no available choice once inserted variables apply", () => { + // The only choice is now gated on a variable the target adds, whose initial value fails it. + const to = target((c) => { + c.variables.ticket = { type: "bool", initial: false }; + hall(c).choices[0]!.requirements = { + field: "var.ticket", + operator: "equals", + value: true, + }; + }); + expect(adoptContent(midGame(), FROM, to)).toEqual(refused); + }); + + it("refuses a visitedCounts key that names no target node", () => { + const to = target((c) => { + delete c.nodes.window; + hall(c).choices[0]!.goto = "done"; + }); + expect(adoptContent(midGame(), FROM, to)).toEqual(refused); + }); + + it("refuses an unlocked achievement the target lacks", () => { + const to = target((c) => { + c.achievements = []; + }); + expect(adoptContent(midGame(), FROM, to)).toEqual(refused); + }); + + it("refuses a carried variable whose target VarType differs", () => { + const to = target((c) => { + c.variables.stamped = { type: "int", initial: 0 }; + }); + expect(adoptContent(midGame(), FROM, to)).toEqual(refused); + }); + + it("refuses a variable the source declared and the state carries that the target drops", () => { + const to = target((c) => { + delete c.variables.stamped; + }); + expect(adoptContent(midGame(), FROM, to)).toEqual(refused); + }); + + it("tolerates a carried variable that neither campaign declares, as validateState does", () => { + const state = midGame({ variables: { money: 4, stamped: true, legacy: 1 } }); + const decision = adoptContent(state, FROM, ADDITIVE); + expect(decision.adopt && decision.state.variables.legacy).toBe(1); + }); +}); + +describe("S132.3 — availability is the scene's own", () => { + // The hall's only choice shows only when an inserted variable's initial value allows it. + function gatedBy(kind: "showWhen" | "requirements", initial: boolean): Campaign { + return target((c) => { + c.variables.ticket = { type: "bool", initial }; + hall(c).choices[0]![kind] = { + field: "var.ticket", + operator: "equals", + value: true, + }; + }); + } + + for (const kind of ["showWhen", "requirements"] as const) { + for (const initial of [true, false]) { + it(`adopts exactly when the scene offers an available choice (${kind}, initial ${initial})`, () => { + const to = gatedBy(kind, initial); + const decision = adoptContent(midGame(), FROM, to); + // What the scene would show on the target, for the state the adoption would produce. + const inserted = { ...midGame(), variables: { ...midGame().variables, ticket: initial } }; + const offered = availableActions(inserted, { campaign: to } as KindContext); + expect(decision.adopt).toBe(offered.some((a) => a.available)); + expect(decision.adopt).toBe(initial); + }); + } + } +}); + +describe("S132.4 — every adopted state is valid against the target", () => { + it("passes validateState on the target, and fails it on a target it was refused for", () => { + const decision = adoptContent(midGame(), FROM, ADDITIVE); + expect(decision.adopt).toBe(true); + if (!decision.adopt) return; + expect(validateStoryGraphState(decision.state, ADDITIVE)).toBe(true); + // The unadopted state lacks the inserted variables, so it is not valid there. + expect(validateStoryGraphState(midGame(), ADDITIVE)).toBe(false); + }); +}); diff --git a/src/engine/src/kinds/story-graph/adopt.ts b/src/engine/src/kinds/story-graph/adopt.ts new file mode 100644 index 00000000..e30b09c6 --- /dev/null +++ b/src/engine/src/kinds/story-graph/adopt.ts @@ -0,0 +1,67 @@ +/** + * Story-graph kind — `Kind.adoptContent` (16-content-epochs.md §5.5). + * + * Contract: `20-contract.md` 03 §8.1; `10-design.md` Content Epochs §5.5. + * + * Adopts additive change and refuses anything that could strand the player. Runs no settle + * and draws no randomness (C24): the only change an adoption makes is inserting each variable + * the target declares and the state lacks, at its declared initial value. Every refusal + * reasons `content_incompatible`; the engine then leaves the session pinned on the epoch it is + * on, where it can still play. + */ + +import type { AdoptDecision } from "../../core/kernel/types.js"; +import type { Campaign } from "../../core/registry/types.js"; +import type { StoryGraphCampaign } from "./campaign.js"; +import { offeredChoices } from "./scene.js"; +import type { StoryGraphKindState } from "./state.js"; +import type { VarValue } from "./variables.js"; + +const INCOMPATIBLE: AdoptDecision = { adopt: false, reason: "content_incompatible" }; + +function varType(value: VarValue): "bool" | "int" | "enum" | undefined { + if (typeof value === "boolean") return "bool"; + if (typeof value === "number") return Number.isInteger(value) ? "int" : undefined; + if (typeof value === "string") return "enum"; + return undefined; +} + +export function adoptContent( + state: StoryGraphKindState, + from: Campaign, + to: Campaign, +): AdoptDecision { + const source = from.content as StoryGraphCampaign; + const target = to.content as StoryGraphCampaign; + + // Nothing the state carries may be dropped: its history, its achievements, and any + // variable the source declared. A variable the source never declared is tolerated here as + // `validateState` tolerates it — unread, and not this adoption's to drop. + if (!Object.keys(state.visitedCounts).every((nodeId) => Object.hasOwn(target.nodes, nodeId))) return INCOMPATIBLE; + const achievementIds = new Set(target.achievements.map((a) => a.id)); + if (!state.unlockedAchievements.every((id) => achievementIds.has(id))) return INCOMPATIBLE; + + for (const name of Object.keys(state.variables)) { + if (Object.hasOwn(target.variables, name)) { + if (varType(state.variables[name]!) !== target.variables[name]!.type) return INCOMPATIBLE; + } else if (Object.hasOwn(source.variables, name)) { + return INCOMPATIBLE; + } + } + + // Insert what the target adds, sorted, as `buildInitialVariables` seeds a new game. + const variables: Record = Object.create(null) as Record; + for (const name of Object.keys(state.variables)) variables[name] = state.variables[name]!; + for (const name of Object.keys(target.variables).sort()) { + if (!Object.hasOwn(variables, name)) variables[name] = target.variables[name]!.initial; + } + const adopted: StoryGraphKindState = { ...state, variables }; + + // The player must still have somewhere to act, judged against the adopted state. + if (!Object.hasOwn(target.nodes, state.currentNodeId)) return INCOMPATIBLE; + const node = target.nodes[state.currentNodeId]!; + if (node.kind !== "choice") return INCOMPATIBLE; + if (!offeredChoices(node, adopted).some((choice) => choice.available)) return INCOMPATIBLE; + + return { adopt: true, state: adopted }; +} diff --git a/src/engine/src/kinds/story-graph/kind.ts b/src/engine/src/kinds/story-graph/kind.ts index e80ab819..b87c6fe5 100644 --- a/src/engine/src/kinds/story-graph/kind.ts +++ b/src/engine/src/kinds/story-graph/kind.ts @@ -19,6 +19,7 @@ import type { SceneBody, } from "../../core/kernel/types.js"; import type { Campaign } from "../../core/registry/types.js"; +import { adoptContent } from "./adopt.js"; import { advance } from "./advance.js"; import type { StoryGraphCampaign } from "./campaign.js"; import { composeContent } from "./compose.js"; @@ -58,6 +59,7 @@ export const storyGraphKind: Kind = { validateCampaign: (campaign, strings) => validateCampaign(campaign, strings), validateState: (kindState, campaign) => validateStoryGraphState(kindState, campaign), composeContent: (host, modules, attachments) => composeContent(host, modules, attachments), + adoptContent: (state, from, to) => adoptContent(state, from, to), outcome: (state) => { const endingId = state.endingId ?? null; return { terminal: endingId !== null, terminalId: endingId, endingId }; diff --git a/src/engine/src/kinds/story-graph/scene.ts b/src/engine/src/kinds/story-graph/scene.ts index 3d5d6b5a..a8c24c44 100644 --- a/src/engine/src/kinds/story-graph/scene.ts +++ b/src/engine/src/kinds/story-graph/scene.ts @@ -9,7 +9,7 @@ import type { AvailableAction, KindContext, SceneBody } from "../../core/kernel/types.js"; import { resolveLocKey } from "../../core/localization/resolve.js"; -import { requireNode } from "./nodes.js"; +import { requireNode, type ChoiceNode } from "./nodes.js"; import { visibleVariables } from "./variables.js"; import { interpolateText, interpolationScope } from "./text.js"; import { evaluateStoryGraphCondition, toConditionContext } from "./conditions.js"; @@ -27,7 +27,15 @@ export function availableActions(state: StoryGraphKindState, ctx: KindContext): const content = ctx.campaign.content as StoryGraphCampaign; const node = requireNode(content.nodes, state.currentNodeId); if (node.kind !== "choice") return []; + return offeredChoices(node, state); +} +/** + * The choices `node` offers `state`: `showWhen`-hidden ones omitted, gated ones marked + * unavailable. Shared with adoption (`adopt.ts`, 16 §5.5), so "at least one choice is + * available" is judged by exactly the evaluation the scene shows the player. + */ +export function offeredChoices(node: ChoiceNode, state: StoryGraphKindState): AvailableAction[] { const context = toConditionContext(state); const actions: AvailableAction[] = [];