From be1379e0451bf6e262467cf26d223edf26df6851 Mon Sep 17 00:00:00 2001 From: Ben Richards Date: Wed, 7 Oct 2026 22:57:47 +0300 Subject: [PATCH 1/2] =?UTF-8?q?W120=20=E2=80=94=20One=20Campaign=20Can=20I?= =?UTF-8?q?nclude=20Another?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds the campaign composition contracted in #561: the core checks includes, pins, cycles, aliases and attachment hosts (seven codes) and composes depth-first, each campaign once; story-graph merges modules under alias:: ids through Kind.composeContent (six codes), with entry and exit as synthesized auto nodes and bindings carried by the new copy consequence. Pack attachments append one choice to a host node after the fold, and a pack's digest moves only when it carries includes or attachments, so every committed campaignVersion is unchanged. --- design/20-contract.md | 4 +- design/30-slices.md | 4 +- design/90-decisions.md | 16 + design/TODO.md | 2 +- docs/docs/engine/04-core.md | 4 +- docs/docs/engine/TODO.md | 4 +- docs/docs/guide.md | 4 +- src/engine/src/authoring.test.ts | 3 + src/engine/src/authoring.ts | 7 +- .../src/campaigns/stable-life-packs.test.ts | 20 +- src/engine/src/campaigns/stable-life-packs.ts | 14 +- src/engine/src/core/kernel/reasons.ts | 21 + src/engine/src/core/kernel/types.ts | 16 +- src/engine/src/core/registry/compose.test.ts | 227 ++++++++ src/engine/src/core/registry/compose.ts | 231 ++++++++ src/engine/src/core/registry/packs.ts | 24 +- src/engine/src/core/registry/types.ts | 58 ++ src/engine/src/core/validation/tiered.ts | 49 +- src/engine/src/index.ts | 9 + src/engine/src/kinds/story-graph/campaign.ts | 34 +- .../src/kinds/story-graph/compose.test.ts | 542 ++++++++++++++++++ src/engine/src/kinds/story-graph/compose.ts | 277 +++++++++ src/engine/src/kinds/story-graph/kind.ts | 2 + .../src/kinds/story-graph/reasons.test.ts | 8 +- src/engine/src/kinds/story-graph/reasons.ts | 13 + src/engine/src/kinds/story-graph/scene.ts | 4 +- src/engine/src/kinds/story-graph/source.ts | 4 +- src/engine/src/kinds/story-graph/text.ts | 26 +- .../src/kinds/story-graph/validate.test.ts | 44 ++ src/engine/src/kinds/story-graph/validate.ts | 65 ++- .../src/kinds/story-graph/variables.test.ts | 55 ++ src/engine/src/kinds/story-graph/variables.ts | 25 +- 32 files changed, 1776 insertions(+), 40 deletions(-) create mode 100644 src/engine/src/core/registry/compose.test.ts create mode 100644 src/engine/src/core/registry/compose.ts create mode 100644 src/engine/src/kinds/story-graph/compose.test.ts create mode 100644 src/engine/src/kinds/story-graph/compose.ts diff --git a/design/20-contract.md b/design/20-contract.md index ffe716a7..f14208d6 100644 --- a/design/20-contract.md +++ b/design/20-contract.md @@ -2247,8 +2247,8 @@ const BASE_REASON_CODES = [ > boundary's two, host persistence's four, session lifecycle's one, the audit vocabulary's > one, content-pack resolution's six, and campaign composition's seven. That is the intended > shape: a code is registered when a real caller produces it, not pre-declared from this list. -> Composition's seven are the one batch this contract names before their caller exists; they -> were specified with that caller (§10.4) and are not in `reasons.ts` until W120 builds it. Because `ReasonCode` is *additive, never +> Composition's seven were the one batch this contract named before their caller existed; they +> were specified with that caller (§10.4), and W120 shipped them in `reasons.ts`. Because `ReasonCode` is *additive, never > renamed* (above), growth costs nothing — a client switching on a code it has never seen > falls through to the localized message, which the core ships for every base code. Expect > this list to keep growing, and keep it in step with diff --git a/design/30-slices.md b/design/30-slices.md index e71fff98..2a653916 100644 --- a/design/30-slices.md +++ b/design/30-slices.md @@ -5292,7 +5292,7 @@ pack attachments ([11 §3a](11-content-packs.md#3a-attachments--reaching-into-a- It is one unit because no part of it can be played without the others. An include with no merge composes nothing, and a merge with no core envelope has nothing to call it. -### [ ] W120 — One Campaign Can Include Another {#w120} +### [x] W120 — One Campaign Can Include Another {#w120} **Delivers:** As a campaign author, I can include another story-graph campaign as a module. I name its exits and bind its inputs and outputs. As a pack author, I can attach a module to a @@ -5316,7 +5316,7 @@ session, and the save format does not change. story-graph kind's campaign and source types, source builder, consequences, interpolation, validator and reason codes; their tests and synthetic fixtures; the regenerated human docs. - **Depends on:** none. -- **Status:** Not started. +- **Status:** Done. - **Done when:** - W120.1 These types are declared as 04 §10.4, 11 §3a and 03 §1.1 state: - `CampaignRef`, `CampaignInclude`, `CampaignAttachment`, `ComposedModule` and diff --git a/design/90-decisions.md b/design/90-decisions.md index 0d624e28..bbb81f9d 100644 --- a/design/90-decisions.md +++ b/design/90-decisions.md @@ -2289,3 +2289,19 @@ Context: 12 §4.2 names the only canonical orders: runtime entity ids by prefix Chosen: Runtime-entity projections sort with `compareRuntimeEntityId`; every definition-id sort uses `compareDefinitionId`. Path and reason compare by ordinal code unit, now stated in 12 §12. The determinism lint block bans `localeCompare` with fewer than two arguments; a call naming its locale (the simulation kind's contracted `"en-US-POSIX"`, 10 §2.2) stays allowed. Rejected: **Numeric-aware path order** (`buildings.building:10` after `building:2`) — paths are not ids, and a path comparator that parses ids out of paths is a second canonical order to keep in step with §4.2. **Fix the call sites without a lint rule** — the same defect would return with the next sort. Reversibility: cheap. No fixture or golden changed order, because no shipped campaign has ten instances under one prefix. + +### 2026-10-07 — W120: composition is built as contracted; seven implementation calls recorded +Context: W120 built campaign composition against the contract from #561 (04 §10.4, 11 §3a, 03 §1.1, §5, §8.3). Building it forced calls that the contract leaves open. +Chosen: +- **Validation stops at composition.** When `composeCampaigns` rejects, no Tier 1 or Tier 2 check runs, because they would run against content that was never assembled. +- **Binding host variables check against the host's own `variables`**, not against anything merged in from other modules. A binding names the host's state, and no other module's. +- **A composed-shaped authored id (`a::x`) is rejected twice.** `composeContent` rejects any `:` in a host's own ids, and `validateCampaign` accepts a `:` only under a synthesized entry node's alias. +- **The entry node interpolates in the host's scope.** It is the host's node, and only nodes inside the module read `{x}` as `alias::x`. +- **Module strings come first in the validation union**, so a host's own key wins. +- **`duplicate_id` and `dangling_reference` from a merge are raised by `composeContent`**, with the composed path. They are not raised later by `validateCampaign` against the merged content. +- **`ComposedModule`, `ComposedAttachment` and `CampaignInclude` are exported from `/authoring`**, beside the campaign types an author already imports. +Rejected: +- **Run Tier 1 over a failed composition.** It would report the same defect again as a dangling reference. +- **Check bindings against the content merged so far.** That makes a binding's validity depend on the order of includes. +- **Raise merge collisions in Tier 1.** The path would name the merged node, not the include that caused it. +Reversibility: cheap. Each call is internal to the build, and no published campaign composes yet. diff --git a/design/TODO.md b/design/TODO.md index d17ac7ef..ceee6a5c 100644 --- a/design/TODO.md +++ b/design/TODO.md @@ -314,6 +314,6 @@ ### Depth: Campaign Composition -### [ ] W120 — One Campaign Can Include Another {#w120} +### [x] W120 — One Campaign Can Include Another {#w120} ## Landed diff --git a/docs/docs/engine/04-core.md b/docs/docs/engine/04-core.md index b09413dd..20d99c5d 100644 --- a/docs/docs/engine/04-core.md +++ b/docs/docs/engine/04-core.md @@ -2109,8 +2109,8 @@ const BASE_REASON_CODES = [ > boundary's two, host persistence's four, session lifecycle's one, the audit vocabulary's > one, content-pack resolution's six, and campaign composition's seven. That is the intended > shape: a code is registered when a real caller produces it, not pre-declared from this list. -> Composition's seven are the one batch this contract names before their caller exists; they -> were specified with that caller (§10.4) and are not in `reasons.ts` until W120 builds it. Because `ReasonCode` is *additive, never +> Composition's seven were the one batch this contract named before their caller existed; they +> were specified with that caller (§10.4), and W120 shipped them in `reasons.ts`. Because `ReasonCode` is *additive, never > renamed* (above), growth costs nothing — a client switching on a code it has never seen > falls through to the localized message, which the core ships for every base code. Expect > this list to keep growing, and keep it in step with diff --git a/docs/docs/engine/TODO.md b/docs/docs/engine/TODO.md index e6214956..22efc61f 100644 --- a/docs/docs/engine/TODO.md +++ b/docs/docs/engine/TODO.md @@ -5287,7 +5287,7 @@ pack attachments ([11 §3a](11-content-packs.md#3a-attachments--reaching-into-a- It is one unit because no part of it can be played without the others. An include with no merge composes nothing, and a merge with no core envelope has nothing to call it. -### [ ] W120 — One Campaign Can Include Another {#w120} +### [x] W120 — One Campaign Can Include Another {#w120} **Delivers:** As a campaign author, I can include another story-graph campaign as a module. I name its exits and bind its inputs and outputs. As a pack author, I can attach a module to a @@ -5311,7 +5311,7 @@ session, and the save format does not change. story-graph kind's campaign and source types, source builder, consequences, interpolation, validator and reason codes; their tests and synthetic fixtures; the regenerated human docs. - **Depends on:** none. -- **Status:** Not started. +- **Status:** Done. - **Done when:** - W120.1 These types are declared as 04 §10.4, 11 §3a and 03 §1.1 state: - `CampaignRef`, `CampaignInclude`, `CampaignAttachment`, `ComposedModule` and diff --git a/docs/docs/guide.md b/docs/docs/guide.md index d110c078..1c3b14c1 100644 --- a/docs/docs/guide.md +++ b/docs/docs/guide.md @@ -3,7 +3,7 @@ sidebar_position: 1 sidebar_label: Developer Guide --- - + > Generated from `design/` by `/make-human-docs`. Do not edit by hand — edit the > design docs and regenerate. `/reconcile` reports when this has gone stale. @@ -678,7 +678,7 @@ imposes no relationship or currency model of its own; a campaign that wants a me declares an int and advances it itself, since the built-in turn counter is deliberately just a transition count. -**Campaign composition is contracted, not yet built (W120).** Content never crosses a campaign +**Campaign composition is built (W120).** Content never crosses a campaign boundary at runtime: the session's `campaignId` is singular and the save format has no frames. Instead, a host campaign *includes* another story-graph campaign as a module, pinned by its authored `{id, version}`. Registry build then merges the module into the host before validation diff --git a/src/engine/src/authoring.test.ts b/src/engine/src/authoring.test.ts index 90159b3d..89ac0588 100644 --- a/src/engine/src/authoring.test.ts +++ b/src/engine/src/authoring.test.ts @@ -70,6 +70,7 @@ const AUTHORING_TYPE_EXPORTS = [ "ItemDefinitionSource", "JobDefinitionSource", "LocationDefinitionSource", + "ModuleInterface", "NPCDefinitionSource", "NodeSource", "OpportunityDefinitionSource", @@ -91,8 +92,10 @@ const AUTHORING_TYPE_EXPORTS = [ "SimulationActionLabelKeysSource", "SimulationCampaignSource", "SkillDefinitionSource", + "StoryGraphAttachment", "StoryGraphCampaign", "StoryGraphCampaignSource", + "StoryGraphIncludeBinding", "StoryGraphKindState", "Submission", "TraitDefinitionSource", diff --git a/src/engine/src/authoring.ts b/src/engine/src/authoring.ts index fe5cf3f9..59db3142 100644 --- a/src/engine/src/authoring.ts +++ b/src/engine/src/authoring.ts @@ -35,7 +35,12 @@ export type { VariableDeclSource, VariableSchemaSource, } from "./kinds/story-graph/source.js"; -export type { StoryGraphCampaign } from "./kinds/story-graph/campaign.js"; +export type { + ModuleInterface, + StoryGraphAttachment, + StoryGraphCampaign, + StoryGraphIncludeBinding, +} from "./kinds/story-graph/campaign.js"; export type { StoryGraphKindState } from "./kinds/story-graph/state.js"; export type { AchievementDefinitionSource as SimulationAchievementDefinitionSource, diff --git a/src/engine/src/campaigns/stable-life-packs.test.ts b/src/engine/src/campaigns/stable-life-packs.test.ts index e9528ebe..852fba48 100644 --- a/src/engine/src/campaigns/stable-life-packs.test.ts +++ b/src/engine/src/campaigns/stable-life-packs.test.ts @@ -18,7 +18,7 @@ import { simulationKind } from "../kinds/simulation/kind.js"; import type { LocKey } from "../core/localization/types.js"; import { resolvePacks, type ContentPack } from "../core/registry/packs.js"; import type { ContentRegistry } from "../core/registry/types.js"; -import { bulgariaCulturePack, resolveStableLifeRegistry, stableLifeBasePack } from "./stable-life-packs.js"; +import { bulgariaCulturePack, packVersion, resolveStableLifeRegistry, stableLifeBasePack } from "./stable-life-packs.js"; import { STABLE_LIFE_CAMPAIGN_ID } from "./stable-life.js"; const kinds = { simulation: simulationKind } as unknown as KindRegistry; @@ -185,6 +185,24 @@ describe("W71 — Stable Life Bulgaria culture pack", () => { * from `stable-life.ts` — so both versions are derived from a digest of what they ship. * Two packs shipping different strings must therefore carry different versions. */ + // W120.9 — 11 §6: includes and attachments change what plays, so they move the digest, but + // only when present; a pack carrying neither keeps the version it already had. + it("moves the digest for an attachment or an include, and only then", () => { + const { campaigns, strings } = stableLifeBasePack; + expect(packVersion(campaigns, strings)).toBe(stableLifeBasePack.version); + expect(packVersion(campaigns, strings, [])).toBe(stableLifeBasePack.version); + + const include = { alias: "m", ref: { id: "module", version: "1.0.0" }, binding: {} }; + const attached = packVersion(campaigns, strings, [{ hostCampaignId: STABLE_LIFE_CAMPAIGN_ID, include, payload: {} }]); + expect(attached).toMatch(/^1\.0\.0\+[0-9a-f]{12}$/); + expect(attached).not.toBe(stableLifeBasePack.version); + + const including = campaigns.map((c) => ({ ...c, campaign: { ...c.campaign, includes: [include] } })); + expect(packVersion(including, strings)).not.toBe(stableLifeBasePack.version); + const emptyIncludes = campaigns.map((c) => ({ ...c, campaign: { ...c.campaign, includes: [] } })); + expect(packVersion(emptyIncludes, strings)).toBe(stableLifeBasePack.version); + }); + it("derives each pack's version from the content it ships", () => { expect(stableLifeBasePack.version).toMatch(/^1\.0\.0\+[0-9a-f]{12}$/); expect(bulgariaCulturePack.version).toMatch(/^1\.0\.0\+[0-9a-f]{12}$/); diff --git a/src/engine/src/campaigns/stable-life-packs.ts b/src/engine/src/campaigns/stable-life-packs.ts index 71885bab..ab69f23f 100644 --- a/src/engine/src/campaigns/stable-life-packs.ts +++ b/src/engine/src/campaigns/stable-life-packs.ts @@ -12,7 +12,7 @@ */ import type { ContentPack } from "../core/registry/packs.js"; -import type { BuiltCampaign, ContentRegistry } from "../core/registry/types.js"; +import type { BuiltCampaign, CampaignAttachment, ContentRegistry } from "../core/registry/types.js"; import type { LocKey } from "../core/localization/types.js"; import type { KindRegistry } from "../core/kernel/types.js"; import type { CommandResult } from "../core/kernel/reasons.js"; @@ -50,8 +50,14 @@ const bulgarianCampaign = built(buildBulgariaStableLifeCampaign); * The campaign fields are listed rather than digested whole: `Campaign.migrateState` (04 * §10.1) is an optional *function*, and `canonicalStringify` rejects one outright, so a * campaign that ever gains a migration must not take this module down at import time. + * + * Exported for the test that holds 11 §6's composition clause (W120.9), not as engine API. */ -function packVersion(campaigns: readonly BuiltCampaign[], strings: ReadonlyMap): string { +export function packVersion( + campaigns: readonly BuiltCampaign[], + strings: ReadonlyMap, + attachments: readonly CampaignAttachment[] = [], +): string { const digest = sha256Hex( canonicalStringify({ campaigns: campaigns.map(({ campaign }) => ({ @@ -60,10 +66,14 @@ function packVersion(campaigns: readonly BuiltCampaign[], strings: ReadonlyMap 0 ? { includes: campaign.includes } : {}), })), // Sorted by key, not left in insertion order: the digest names what a pack ships, and // reordering an authoring file ships the same content. strings: [...strings].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)), + // Includes and attachments change what plays, so they move the version — but only when + // present, which leaves every pack that has none exactly where it was (11 §6). + ...(attachments.length > 0 ? { attachments } : {}), }), ); return `1.0.0+${digest.slice(0, 12)}`; diff --git a/src/engine/src/core/kernel/reasons.ts b/src/engine/src/core/kernel/reasons.ts index b3a92203..44dc5800 100644 --- a/src/engine/src/core/kernel/reasons.ts +++ b/src/engine/src/core/kernel/reasons.ts @@ -95,6 +95,12 @@ export type ReasonCode = string; * (`Kind.profileData`, 04 §7.1): a version mismatch with no migration (or one that fails) * drops a kind's profile slice for the session, and a throwing/oversized `fold` result is * refused, in both cases only ever surfacing as a warning. + * + * `include_missing`, `include_version_mismatch`, `include_kind_mismatch`, `include_cycle`, + * `include_alias_collision`, `attachment_host_missing` and `compose_unsupported` were added + * 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. */ export const BASE_REASON_CODES = [ "action_not_available", @@ -133,6 +139,14 @@ export const BASE_REASON_CODES = [ "pack_override_unexpected", "profile_kind_data_unreadable", "profile_kind_data_rejected", + // campaign composition (04 §10.4) — registry build, before validation + "include_missing", + "include_version_mismatch", + "include_kind_mismatch", + "include_cycle", + "include_alias_collision", + "attachment_host_missing", + "compose_unsupported", ] as const; export type BaseReasonCode = (typeof BASE_REASON_CODES)[number]; @@ -180,6 +194,13 @@ const CORE_REASON_TEXT: Readonly> = { pack_override_unexpected: "This pack overrides content no earlier pack supplied.", profile_kind_data_unreadable: "This kind's saved cross-game data couldn't be read for this session; it starts with none.", profile_kind_data_rejected: "This kind's cross-game data couldn't be updated. Your game progress was not affected.", + include_missing: "This campaign includes a campaign that isn't available.", + include_version_mismatch: "This campaign includes a different version of a campaign than the one available.", + include_kind_mismatch: "A campaign can only include a campaign of its own kind.", + include_cycle: "Campaigns can't include each other in a cycle.", + 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.", }; /** `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 3ff12c5e..a17709d7 100644 --- a/src/engine/src/core/kernel/types.ts +++ b/src/engine/src/core/kernel/types.ts @@ -9,7 +9,7 @@ import type { LocKey } from "../localization/types.js"; import type { RngHandle, StreamId } from "../determinism/types.js"; import type { ProjectionAudience, PlayerView } from "../projection/types.js"; import type { ValidationError, ValidationResult } from "../validation/types.js"; -import type { Campaign, ContentRegistry } from "../registry/types.js"; +import type { Campaign, ComposedAttachment, ComposedModule, ContentRegistry } from "../registry/types.js"; import type { Emitter, EventName, ResolutionEmitter } from "../observability/types.js"; import type { CommandResult, @@ -180,6 +180,20 @@ export interface Kind { * is the state every kind is in until it declares this member. */ readonly profileData?: KindProfileData; + + /** + * Merges included modules and pack attachments into a host campaign's content (04 §10.4). + * Optional: a kind without it cannot host an include or receive an attachment, and a + * campaign of that kind that declares either fails with `compose_unsupported`. Called by + * registry assembly **before** `validateCampaign`, once per host, with every module + * already composed depth-first — so it only ever merges one level. Pure over content: the + * result replaces `host.content` in the registry and is what validation and play both see. + */ + composeContent?( + host: Campaign, + modules: readonly ComposedModule[], + attachments: readonly ComposedAttachment[], + ): CommandResult; } /** diff --git a/src/engine/src/core/registry/compose.test.ts b/src/engine/src/core/registry/compose.test.ts new file mode 100644 index 00000000..a1a85d0d --- /dev/null +++ b/src/engine/src/core/registry/compose.test.ts @@ -0,0 +1,227 @@ +/** + * W120 — the core's half of campaign composition (04 §10.4): every envelope check rejects + * with a path, and a composition that passes walks depth-first, composing each campaign once. + * The kind here is a stub, so these hold for any kind; the story-graph merge is + * `kinds/story-graph/compose.test.ts`. + */ + +import { describe, expect, it } from "vitest"; +import type { KindId, KindRegistry } from "../kernel/types.js"; +import type { CommandResult } from "../kernel/reasons.js"; +import { composeCampaigns } from "./compose.js"; +import type { BuiltCampaign, Campaign, CampaignAttachment, CampaignInclude, ComposedAttachment, ComposedModule } from "./types.js"; + +interface Call { + host: string; + modules: ComposedModule[]; + attachments: ComposedAttachment[]; +} + +/** A kind that composes by recording the call and tagging the host's content. */ +function stubKinds(): { kinds: KindRegistry; calls: Call[] } { + const calls: Call[] = []; + const composeContent = (host: Campaign, modules: readonly ComposedModule[], attachments: readonly ComposedAttachment[]): CommandResult => { + calls.push({ host: host.id, modules: [...modules], attachments: [...attachments] }); + return { ok: true, value: { composed: host.id }, errors: [], warnings: [] }; + }; + const kinds = { + "story-graph": { id: "story-graph", composeContent }, + // Real kind ids, stubbed: one that composes, and one without `composeContent`. + simulation: { id: "simulation", composeContent }, + "world-graph": { id: "world-graph" }, + } as unknown as KindRegistry; + return { kinds, calls }; +} + +function include(alias: string, id: string, version = "1.0.0"): CampaignInclude { + return { alias, ref: { id, version }, binding: {} }; +} + +function campaign(id: string, includes: CampaignInclude[] = [], kindId: KindId = "story-graph", version = "1.0.0"): BuiltCampaign { + return { + campaign: { id, kindId, version, titleKey: `${id}.title`, content: { own: id }, ...(includes.length > 0 ? { includes } : {}) }, + strings: new Map([[`${id}.title`, id]]), + }; +} + +function compose(builtCampaigns: BuiltCampaign[], attachments: CampaignAttachment[] = [], authored?: Map) { + const { kinds, calls } = stubKinds(); + const authoredVersions = authored ?? new Map(builtCampaigns.map(({ campaign: c }) => [c.id, c.version] as const)); + return { result: composeCampaigns({ builtCampaigns, authoredVersions, attachments }, kinds), calls }; +} + +function codes(result: { errors: readonly { code: string; path?: string }[] }): [string, string | undefined][] { + return result.errors.map((e) => [e.code, e.path]); +} + +describe("composeCampaigns — rejecting (04 §10.4)", () => { + it("include_missing: the target is not in the registry input", () => { + const { result, calls } = compose([campaign("host", [include("m", "ghost")])]); + expect(result.ok).toBe(false); + expect(codes(result)).toEqual([["include_missing", "host.m"]]); + expect(calls).toEqual([]); + }); + + it("include_version_mismatch: the pin names a version other than the authored one", () => { + const { result } = compose([campaign("host", [include("m", "mod", "0.9.0")]), campaign("mod")]); + expect(codes(result)).toEqual([["include_version_mismatch", "host.m"]]); + expect(result.errors[0]!.details).toEqual({ pinned: "0.9.0", authored: "1.0.0" }); + }); + + it("include_version_mismatch: checked against the authored version, not the campaign's own", () => { + const stamped = campaign("mod", [], "story-graph", "stamped-resolution"); + const { result } = compose( + [campaign("host", [include("m", "mod", "stamped-resolution")]), stamped], + [], + new Map([["host", "1.0.0"], ["mod", "1.0.0"]]), + ); + expect(codes(result)).toEqual([["include_version_mismatch", "host.m"]]); + }); + + it("include_kind_mismatch: host and module are different kinds", () => { + const { result } = compose([campaign("host", [include("m", "mod")]), campaign("mod", [], "simulation")]); + expect(codes(result)).toEqual([["include_kind_mismatch", "host.m"]]); + }); + + it("include_cycle: a campaign that includes itself", () => { + const { result } = compose([campaign("a", [include("self", "a")])]); + expect(codes(result)).toEqual([["include_cycle", "a -> a"]]); + }); + + it("include_cycle: direct, a includes b includes a", () => { + const { result } = compose([campaign("a", [include("b", "b")]), campaign("b", [include("a", "a")])]); + expect(codes(result)).toEqual([["include_cycle", "a -> b -> a"]]); + }); + + it("include_cycle: transitive, a -> b -> c -> a", () => { + const { result } = compose([ + campaign("a", [include("b", "b")]), + campaign("b", [include("c", "c")]), + campaign("c", [include("a", "a")]), + ]); + expect(codes(result)).toEqual([["include_cycle", "a -> b -> c -> a"]]); + }); + + it("include_cycle: closed by an attachment", () => { + const { result } = compose( + [campaign("a", [include("b", "b")]), campaign("b")], + [{ hostCampaignId: "b", include: include("back", "a"), payload: {} }], + ); + expect(codes(result)).toEqual([["include_cycle", "a -> b -> a"]]); + }); + + it("include_alias_collision: two includes share an alias", () => { + const { result } = compose([campaign("host", [include("m", "mod"), include("m", "mod")]), campaign("mod")]); + expect(codes(result)).toEqual([["include_alias_collision", "host.m"]]); + }); + + it("include_alias_collision: an attachment reuses an include's alias", () => { + const { result } = compose( + [campaign("host", [include("m", "mod")]), campaign("mod")], + [{ hostCampaignId: "host", include: include("m", "mod"), payload: {} }], + ); + expect(codes(result)).toEqual([["include_alias_collision", "host.m"]]); + }); + + it("attachment_host_missing: the host campaign is absent", () => { + const { result } = compose([campaign("mod")], [{ hostCampaignId: "ghost", include: include("m", "mod"), payload: {} }]); + expect(codes(result)).toEqual([["attachment_host_missing", "ghost"]]); + }); + + it("compose_unsupported: the host's kind has no composeContent", () => { + const { result } = compose([campaign("host", [include("m", "mod", "1.0.0")], "world-graph"), campaign("mod", [], "world-graph")]); + expect(codes(result)).toEqual([["compose_unsupported", "host"]]); + }); + + it("invalid_identifier: an alias carrying ':'", () => { + const { result } = compose([campaign("host", [include("a:b", "mod")]), campaign("mod")]); + expect(codes(result)).toEqual([["invalid_identifier", "host.a:b"]]); + }); + + it("reports every envelope error at once and composes nothing", () => { + const { result, calls } = compose([campaign("host", [include("m", "ghost"), include("n", "mod", "2.0.0")]), campaign("mod")]); + expect(codes(result)).toEqual([ + ["include_missing", "host.m"], + ["include_version_mismatch", "host.n"], + ]); + expect(calls).toEqual([]); + }); + + it("every error carries a core.reason messageKey", () => { + const { result } = compose([campaign("host", [include("m", "ghost")])]); + expect(result.errors[0]!.messageKey).toBe("core.reason.include_missing"); + }); +}); + +describe("composeCampaigns — passing (04 §10.4)", () => { + it("leaves a campaign that composes nothing as the same object", () => { + const plain = campaign("plain"); + const { result, calls } = compose([plain]); + expect(result.ok).toBe(true); + expect(result.value!.builtCampaigns[0]).toBe(plain); + expect(calls).toEqual([]); + }); + + it("composes depth-first: a module is composed before its host", () => { + const { result, calls } = compose([campaign("a", [include("b", "b")]), campaign("b", [include("c", "c")]), campaign("c")]); + expect(result.ok).toBe(true); + expect(calls.map((c) => c.host)).toEqual(["b", "a"]); + // `a` receives `b` already composed. + expect(calls[1]!.modules[0]!.campaign.content).toEqual({ composed: "b" }); + }); + + it("composes a shared module once, and both hosts receive the same composed object", () => { + const { result, calls } = compose([ + campaign("h1", [include("m", "mod")]), + campaign("h2", [include("m", "mod")]), + campaign("mod", [include("leaf", "leaf")]), + campaign("leaf"), + ]); + expect(result.ok).toBe(true); + expect(calls.map((c) => c.host)).toEqual(["mod", "h1", "h2"]); + expect(calls[1]!.modules[0]!.campaign).toBe(calls[2]!.modules[0]!.campaign); + }); + + it("hands attachments to the host after its includes, in the order given", () => { + const { calls } = compose( + [campaign("host", [include("m", "mod")]), campaign("mod"), campaign("x"), campaign("y")], + [ + { hostCampaignId: "host", include: include("y", "y"), payload: { n: 1 } }, + { hostCampaignId: "host", include: include("x", "x"), payload: { n: 2 } }, + ], + ); + expect(calls).toHaveLength(1); + expect(calls[0]!.modules.map((m) => m.include.alias)).toEqual(["m"]); + expect(calls[0]!.attachments.map((a) => [a.module.include.alias, a.payload])).toEqual([ + ["y", { n: 1 }], + ["x", { n: 2 }], + ]); + }); + + it("unites module strings into the host's validation table, the host's own winning", () => { + const host = campaign("host", [include("m", "mod")]); + const mod: BuiltCampaign = { ...campaign("mod"), strings: new Map([["mod.title", "mod"], ["shared", "module"]]) }; + const hostWithShared: BuiltCampaign = { ...host, strings: new Map([["host.title", "host"], ["shared", "host"]]) }; + const { result } = compose([hostWithShared, mod]); + const table = result.value!.validationStrings[0]!; + expect(table.get("mod.title")).toBe("mod"); + expect(table.get("shared")).toBe("host"); + // The composed campaign keeps its own strings; the union is for validation only. + expect(result.value!.builtCampaigns[0]!.strings).toBe(hostWithShared.strings); + }); + + it("surfaces composeContent's own rejection", () => { + const kinds = { + "story-graph": { + id: "story-graph", + composeContent: () => ({ ok: false, errors: [{ code: "exit_unmapped", messageKey: "k", path: "host.m" }], warnings: [] }), + }, + } as unknown as KindRegistry; + const builtCampaigns = [campaign("host", [include("m", "mod")]), campaign("mod")]; + const result = composeCampaigns( + { builtCampaigns, authoredVersions: new Map([["host", "1.0.0"], ["mod", "1.0.0"]]), attachments: [] }, + kinds, + ); + expect(codes(result)).toEqual([["exit_unmapped", "host.m"]]); + }); +}); diff --git a/src/engine/src/core/registry/compose.ts b/src/engine/src/core/registry/compose.ts new file mode 100644 index 00000000..3b573c31 --- /dev/null +++ b/src/engine/src/core/registry/compose.ts @@ -0,0 +1,231 @@ +/** + * Campaign composition — content crossing a campaign boundary at registry build. + * + * Contract: `04-core.md` §10.4, §11; `11-content-packs.md` §3a, §7. + * + * The core owns the envelope and the kind owns the merge. This module checks every include + * and attachment against the registry input — the target exists, the pin names its + * *authored* version, the kinds agree, nothing includes itself, the alias is well-formed + * and unique, the host's kind can compose — and only when all of that holds does it call + * `Kind.composeContent`, once per host, depth-first, each campaign composed once and + * reused. Both validated entry points (`validation/tiered.ts`) run it before any Tier 1 or + * Tier 2 check, so validation sees the content play will see. + */ + +import type { KindRegistry } from "../kernel/types.js"; +import type { CommandResult } from "../kernel/reasons.js"; +import type { LocKey } from "../localization/types.js"; +import type { ValidationError } from "../validation/types.js"; +import type { + BuiltCampaign, + Campaign, + CampaignAttachment, + CampaignInclude, + ComposedAttachment, + ComposedModule, +} from "./types.js"; + +/** 04 §17: an alias is an authored id — ASCII `[a-z0-9_-]`, so never a `:`. */ +const ALIAS_SHAPE = /^[a-z0-9_-]+$/; + +export interface CompositionInput { + /** The registry input, in the order composition walks it (04 §10.4). */ + readonly builtCampaigns: readonly BuiltCampaign[]; + /** What an include's `ref.version` is checked against — never a stamped resolution id. */ + readonly authoredVersions: ReadonlyMap; + /** In pack order, then declaration order (11 §3a). */ + readonly attachments: readonly CampaignAttachment[]; +} + +export interface CompositionOutput { + /** The input, position for position, with each host's `content` composed. A campaign + * that composes nothing is the same object it was. Strings are the campaign's own. */ + readonly builtCampaigns: readonly BuiltCampaign[]; + /** Per position: the campaign's own strings united with every module it composed, + * transitively — the table its own validation runs against (04 §11). */ + readonly validationStrings: readonly ReadonlyMap[]; +} + +function compositionError(code: string, path: string, details?: Readonly>): ValidationError { + return { code, messageKey: `core.reason.${code}`, path, ...(details ? { details } : {}) }; +} + +/** + * Composes every campaign that declares an include or receives an attachment. Fails with + * every envelope error at once and composes nothing if any exists; otherwise fails with + * whatever the kinds' `composeContent` calls rejected. + */ +export function composeCampaigns(input: CompositionInput, kinds: KindRegistry): CommandResult { + const { builtCampaigns, authoredVersions, attachments } = input; + + // Last wins, as the pack fold's own whole-campaign replace does; a duplicate id fails + // registry assembly regardless (`build.ts`). + const byId = new Map(); + for (const built of builtCampaigns) byId.set(built.campaign.id, built); + + const attachmentsByHost = new Map(); + const errors: ValidationError[] = []; + + const checkInclude = (host: Campaign, include: CampaignInclude, aliases: Set): void => { + const path = `${host.id}.${include.alias}`; + if (!ALIAS_SHAPE.test(include.alias)) { + errors.push(compositionError("invalid_identifier", path)); + } else if (aliases.has(include.alias)) { + errors.push(compositionError("include_alias_collision", path)); + } + aliases.add(include.alias); + + const target = byId.get(include.ref.id); + if (!target) { + errors.push(compositionError("include_missing", path, { campaignId: include.ref.id })); + return; + } + const authored = authoredVersions.get(include.ref.id) ?? target.campaign.version; + if (include.ref.version !== authored) { + errors.push( + compositionError("include_version_mismatch", path, { pinned: include.ref.version, authored }), + ); + } + if (target.campaign.kindId !== host.kindId) { + errors.push( + compositionError("include_kind_mismatch", path, { hostKindId: host.kindId, moduleKindId: target.campaign.kindId }), + ); + } + }; + + for (const attachment of attachments) { + if (!byId.has(attachment.hostCampaignId)) { + errors.push(compositionError("attachment_host_missing", attachment.hostCampaignId)); + continue; + } + const list = attachmentsByHost.get(attachment.hostCampaignId); + if (list) list.push(attachment); + else attachmentsByHost.set(attachment.hostCampaignId, [attachment]); + } + + /** A host's modules in composition order: its includes as declared, then its attachments. */ + const edges = (campaign: Campaign): CampaignInclude[] => [ + ...(campaign.includes ?? []), + ...(attachmentsByHost.get(campaign.id) ?? []).map((attachment) => attachment.include), + ]; + + for (const { campaign } of builtCampaigns) { + const aliases = new Set(); + for (const include of edges(campaign)) checkInclude(campaign, include, aliases); + } + + errors.push(...cycleErrors(builtCampaigns, byId, edges)); + + for (const { campaign } of builtCampaigns) { + if (edges(campaign).length === 0) continue; + const kind = kinds[campaign.kindId]; + // An unknown kind is `unknown_kind` at validation, not this check's to name. + if (kind && !kind.composeContent) errors.push(compositionError("compose_unsupported", campaign.id)); + } + + if (errors.length > 0) return { ok: false, errors, warnings: [] }; + + // Depth-first, each campaign composed once: a module is always included in its composed + // form, so an attachment on a module reaches every host that includes it. + const composed = new Map(); + const unions = new Map>(); + const composeErrors: ValidationError[] = []; + + const compose = (built: BuiltCampaign): BuiltCampaign => { + const id = built.campaign.id; + const done = composed.get(id); + if (done) return done; + + const includes = built.campaign.includes ?? []; + const hostAttachments = attachmentsByHost.get(id) ?? []; + if (includes.length === 0 && hostAttachments.length === 0) { + composed.set(id, built); + unions.set(id, built.strings); + return built; + } + + const moduleOf = (include: CampaignInclude): ComposedModule => ({ + include, + campaign: compose(byId.get(include.ref.id)!).campaign, + }); + const modules = includes.map(moduleOf); + const attached: ComposedAttachment[] = hostAttachments.map((attachment) => ({ + payload: attachment.payload, + module: moduleOf(attachment.include), + })); + + // Module strings first, so a host's own text wins inside its own validation table; two + // campaigns disagreeing on one key still fail registry assembly as `string_conflict`. + const union = new Map(); + for (const module of [...modules, ...attached.map((a) => a.module)]) { + for (const [key, text] of unions.get(module.campaign.id)!) union.set(key, text); + } + for (const [key, text] of built.strings) union.set(key, text); + unions.set(id, union); + + const result = kinds[built.campaign.kindId]!.composeContent!(built.campaign, modules, attached); + if (!result.ok) { + composeErrors.push(...result.errors); + composed.set(id, built); + return built; + } + const next: BuiltCampaign = { campaign: { ...built.campaign, content: result.value }, strings: built.strings }; + composed.set(id, next); + return next; + }; + + const out: BuiltCampaign[] = []; + const validationStrings: ReadonlyMap[] = []; + for (const built of builtCampaigns) { + if (!kinds[built.campaign.kindId]) { + out.push(built); + validationStrings.push(built.strings); + continue; + } + // A duplicate id composes as the campaign `byId` holds; assembly rejects it either way. + const held = byId.get(built.campaign.id)!; + const result = compose(held); + out.push(result === held ? built : { campaign: result.campaign, strings: built.strings }); + validationStrings.push(unions.get(built.campaign.id)!); + } + + if (composeErrors.length > 0) return { ok: false, errors: composeErrors, warnings: [] }; + return { ok: true, value: { builtCampaigns: out, validationStrings }, errors: [], warnings: [] }; +} + +/** + * Every include cycle, direct or transitive, each reported once with its path written + * `a -> b -> a`. Walked in registry-input order, edges in composition order, so the report + * is deterministic. + */ +function cycleErrors( + builtCampaigns: readonly BuiltCampaign[], + byId: ReadonlyMap, + edges: (campaign: Campaign) => CampaignInclude[], +): ValidationError[] { + const errors: ValidationError[] = []; + const state = new Map(); + + const visit = (id: string, stack: string[]): void => { + state.set(id, "active"); + stack.push(id); + for (const include of edges(byId.get(id)!.campaign)) { + const next = include.ref.id; + if (!byId.has(next)) continue; // `include_missing`, already reported + const seen = state.get(next); + if (seen === "active") { + const cycle = [...stack.slice(stack.indexOf(next)), next]; + errors.push(compositionError("include_cycle", cycle.join(" -> "))); + } else if (seen === undefined) { + visit(next, stack); + } + } + stack.pop(); + state.set(id, "done"); + }; + + for (const { campaign } of builtCampaigns) { + if (!state.has(campaign.id)) visit(campaign.id, []); + } + return errors; +} diff --git a/src/engine/src/core/registry/packs.ts b/src/engine/src/core/registry/packs.ts index bbe92882..20e9ebdd 100644 --- a/src/engine/src/core/registry/packs.ts +++ b/src/engine/src/core/registry/packs.ts @@ -18,7 +18,7 @@ import type { KindId } from "../kernel/types.js"; import type { CommandResult } from "../kernel/reasons.js"; import type { ExperimentSource } from "../composition/types.js"; import type { ValidationError, ValidationWarning } from "../validation/types.js"; -import type { BuiltCampaign, Campaign, ContentRegistry, ResolutionId } from "./types.js"; +import type { BuiltCampaign, Campaign, CampaignAttachment, ResolutionId, ResolvedRegistry } from "./types.js"; export interface PackRef { readonly id: string; @@ -40,6 +40,9 @@ export interface ContentPack { readonly experimentGate?: ExperimentGate; readonly campaigns: readonly BuiltCampaign[]; readonly strings: ReadonlyMap; + /** §3a — includes into campaigns this pack does not own. Absent means none. The fold only + * collects them; composition applies them (04 §10.4). */ + readonly attachments?: readonly CampaignAttachment[]; } /** @@ -269,11 +272,15 @@ function validateDependencies(packs: readonly ContentPack[]): ValidationError[] } /** - * Folds an ordered pack set into a `ContentRegistry` (§3), pure and total: either every + * Folds an ordered pack set into a `ResolvedRegistry` (§3, §3a), pure and total: either every * structural check in §7 passes and a complete registry comes back, or none of them do * and every conflict is reported together — never a partial registry. + * + * Beside the stamped campaigns it keeps each one's authored version, which is what an include + * pins (04 §10.4), and every pack's attachments in pack order then declaration order. It holds + * no kinds, so it applies neither; `buildValidatedPackRegistry` composes from both. */ -export function resolvePacks(packs: readonly ContentPack[]): CommandResult { +export function resolvePacks(packs: readonly ContentPack[]): CommandResult { const errors: ValidationError[] = [ ...validatePackKinds(packs), ...validateNoDuplicateCampaignIdsWithinPack(packs), @@ -289,6 +296,7 @@ export function resolvePacks(packs: readonly ContentPack[]): CommandResult(); const seenCampaignIds = new Set(); const seenStringKeys = new Set(); + const attachments: CampaignAttachment[] = []; packs.forEach((pack, index) => { // §7 Tier 2: a pack "overriding" a campaign or string no earlier pack supplied is @@ -318,14 +326,22 @@ export function resolvePacks(packs: readonly ContentPack[]): CommandResult([...campaigns].map(([id, campaign]) => [id, campaign.version])); const stampedCampaigns = new Map( [...campaigns].map(([id, campaign]) => [id, { ...campaign, version: resolution }]), ); - return { ok: true, value: { campaigns: stampedCampaigns, strings, resolution }, errors: [], warnings }; + return { + ok: true, + value: { campaigns: stampedCampaigns, strings, resolution, authoredVersions, attachments }, + errors: [], + warnings, + }; } diff --git a/src/engine/src/core/registry/types.ts b/src/engine/src/core/registry/types.ts index 08c899d6..7fc74bb2 100644 --- a/src/engine/src/core/registry/types.ts +++ b/src/engine/src/core/registry/types.ts @@ -41,6 +41,12 @@ export interface Campaign { /** Kind-specific; opaque to the core. */ content: unknown; + /** + * Modules this campaign composes in at registry build (04 §10.4). Absent means none. In + * the frozen registry the list stays as provenance only; `content` is already composed. + */ + includes?: readonly CampaignInclude[]; + /** * Migrates a `kindState` forward when this campaign's own content ids or shape changed * between `fromVersion` and this `Campaign.version` (04 §10.2) — e.g. a node or @@ -52,6 +58,58 @@ export interface Campaign { migrateState?(kindState: unknown, fromVersion: string): CommandResult; } +/** A pin on a campaign's *authored* version — never a resolution id (11 §6). */ +export interface CampaignRef { + id: string; + version: string; +} + +/** One module a host composes in (04 §10.4). */ +export interface CampaignInclude { + /** + * The prefix the module's ids take in the host (04 §17, *Composed ids*). An authored id: + * ASCII `[a-z0-9_-]`, no `:`, unique among the host's includes and attachments. + */ + alias: string; + ref: CampaignRef; + /** Kind-specific — entry, exit and variable mappings. Opaque to the core, like `content`. */ + binding: unknown; +} + +/** An include a content pack adds to a campaign it does not own (11 §3a). */ +export interface CampaignAttachment { + hostCampaignId: string; + include: CampaignInclude; + /** Kind-specific — where in the host the module is reached from. */ + payload: unknown; +} + +/** + * What `Kind.composeContent` receives per module: the include as declared, and the module + * campaign already composed — its own includes and attachments merged in. + */ +export interface ComposedModule { + include: CampaignInclude; + campaign: Campaign; +} + +export interface ComposedAttachment { + payload: unknown; + module: ComposedModule; +} + +/** + * What `resolvePacks` (`packs.ts`) hands registry assembly (11 §3a). Neither extra field + * survives into the frozen registry: `buildValidatedPackRegistry` composes from them and + * returns a plain `ContentRegistry`. + */ +export interface ResolvedRegistry extends ContentRegistry { + /** Each folded campaign's version *before* 11 §6 stamped it — what an include pins. */ + readonly authoredVersions: ReadonlyMap; + /** Every pack's attachments, in pack order and then declaration order. */ + readonly attachments: readonly CampaignAttachment[]; +} + /** One authored string, before it is lifted into the string table. */ export interface AuthoredText { key: LocKey; diff --git a/src/engine/src/core/validation/tiered.ts b/src/engine/src/core/validation/tiered.ts index f887f323..9d4b3323 100644 --- a/src/engine/src/core/validation/tiered.ts +++ b/src/engine/src/core/validation/tiered.ts @@ -15,8 +15,9 @@ * its own; a kind's warnings are collected and passed through unchanged. */ -import type { BuiltCampaign, Campaign, ContentRegistry } from "../registry/types.js"; +import type { BuiltCampaign, Campaign, CampaignAttachment, ContentRegistry } from "../registry/types.js"; import { buildContentRegistry } from "../registry/build.js"; +import { composeCampaigns } from "../registry/compose.js"; import { resolvePacks, type ContentPack } from "../registry/packs.js"; import type { Kind, KindId, KindRegistry } from "../kernel/types.js"; import type { CommandResult } from "../kernel/reasons.js"; @@ -88,23 +89,52 @@ function missingReasonCodeMessages(kind: Kind): ValidationError[] { * §12). Only kinds actually referenced by `builtCampaigns` are checked and threaded; a * kind never used in this batch (including a `KindRegistry` test double missing some of * its entries) is never touched. + * + * Composes first (04 §10.4, W120): a campaign that declares `includes` is validated and + * frozen with its modules merged into its `content`, by `composeValidateAndAssemble` below. */ export function buildValidatedContentRegistry( builtCampaigns: readonly BuiltCampaign[], kinds: KindRegistry, ): CommandResult { + // Unpacked, a campaign's authored version and its version are the same value (04 §10.4). + const authoredVersions = new Map(builtCampaigns.map(({ campaign }) => [campaign.id, campaign.version] as const)); + return composeValidateAndAssemble(builtCampaigns, kinds, authoredVersions, []); +} + +/** + * *Compose, validate, assemble* (04 §11) — both entry points' shared body. Composition runs + * first (04 §10.4): its failures are Tier 1 errors in the same list, and nothing is validated + * against content play would never see. Each campaign then validates **as composed**, against + * its own strings united with every module's it composed, and the registry is assembled from + * the composed campaigns with each one's own strings. + */ +function composeValidateAndAssemble( + builtCampaigns: readonly BuiltCampaign[], + kinds: KindRegistry, + authoredVersions: ReadonlyMap, + attachments: readonly CampaignAttachment[], +): CommandResult { + const composition = composeCampaigns({ builtCampaigns, authoredVersions, attachments }, kinds); + if (!composition.ok || !composition.value) { + return { ok: false, errors: composition.errors, warnings: composition.warnings }; + } + const composed = composition.value.builtCampaigns; + const validationStrings = composition.value.validationStrings; + const errors: ValidationError[] = []; const warnings: ValidationWarning[] = []; const checkedKindIds = new Set(); const kindMessages: ReadonlyMap[] = []; - for (const { campaign, strings } of builtCampaigns) { + composed.forEach(({ campaign }, index) => { + const strings = validationStrings[index]!; errors.push(...validateCoreOwnedFields(campaign, strings)); const kind = kinds[campaign.kindId]; if (!kind) { errors.push({ code: "unknown_kind", messageKey: "core.reason.unknown_kind", path: campaign.kindId }); - continue; + return; } if (!checkedKindIds.has(kind.id)) { @@ -116,13 +146,13 @@ export function buildValidatedContentRegistry( const kindResult = kind.validateCampaign(campaign, strings); errors.push(...kindResult.errors); warnings.push(...kindResult.warnings); - } + }); if (errors.length > 0) { return { ok: false, errors, warnings }; } - const built = buildContentRegistry(builtCampaigns, kindMessages); + const built = buildContentRegistry(composed, kindMessages); if (!built.ok) { return { ok: false, errors: built.errors, warnings }; } @@ -158,14 +188,19 @@ export function buildValidatedPackRegistry( if (!folded.ok || !folded.value) { return { ok: false, errors: folded.errors, warnings: folded.warnings }; } - const { campaigns, strings, resolution } = folded.value; + const { campaigns, strings, resolution, authoredVersions, attachments } = folded.value; // Optional on the type, but never absent on a folded registry (04 §10.1) — asserted // rather than spread away, so the reattachment below cannot silently become a no-op. if (resolution === undefined) throw new Error("buildValidatedPackRegistry: expected the fold to name its resolution"); - const validated = buildValidatedContentRegistry( + // The pin is checked against each campaign's authored version, never the stamped + // resolution id (04 §10.4); attachments apply to whichever campaign holds the host id + // after the fold (11 §3a). + const validated = composeValidateAndAssemble( Array.from(campaigns.values(), (campaign) => ({ campaign, strings })), kinds, + authoredVersions, + attachments, ); if (!validated.ok || !validated.value) { return { ok: false, errors: validated.errors, warnings: [...folded.warnings, ...validated.warnings] }; diff --git a/src/engine/src/index.ts b/src/engine/src/index.ts index 4499e4d5..6c010419 100644 --- a/src/engine/src/index.ts +++ b/src/engine/src/index.ts @@ -66,6 +66,15 @@ export type { ActionParams, ActionResult, AvailableAction, Scene, SceneBody } fr export type { PlayerView } from "./core/projection/types.js"; export type { Campaign, BuiltCampaign, ContentRegistry, AuthoredText } from "./core/registry/types.js"; export type { ResolutionId } from "./core/registry/types.js"; +// Campaign composition (04 §10.4, 11 §3a — W120). +export type { + CampaignRef, + CampaignInclude, + CampaignAttachment, + ComposedModule, + ComposedAttachment, + ResolvedRegistry, +} from "./core/registry/types.js"; export type { ContentPack, ExperimentGate, PackRef } from "./core/registry/packs.js"; export { SESSION_PERSISTENCE_CONFLICT, SessionStoreError } from "./core/session/types.js"; export type { AchievementRecord, TerminalRecord, KindProfileRecord, PlayerProfile, ProfileLoadResult, ProfileSaveResult, ProfileWarning, ProfileWarningCode, CampaignSummary, CampaignCatalog, CampaignProgress, SessionStore, ProfileStore, SaveHandle, SaveSummary, SessionHandle, SessionActionResult, CreateSessionConfig, SessionPersistence, SessionPersistenceConflict, SessionRecordStore, SaveRecordStore, StoredSessionRecord, StoredSaveRecord, SessionStoreErrorCode } from "./core/session/types.js"; diff --git a/src/engine/src/kinds/story-graph/campaign.ts b/src/engine/src/kinds/story-graph/campaign.ts index cd1605d2..b1d3472b 100644 --- a/src/engine/src/kinds/story-graph/campaign.ts +++ b/src/engine/src/kinds/story-graph/campaign.ts @@ -1,7 +1,7 @@ /** * Story-graph kind — the campaign content envelope. * - * Contract: `03-story-graph-kind.md` §1. + * Contract: `03-story-graph-kind.md` §1, §1.1. * * The runtime `content` inside the core's `Campaign` envelope (`registry/types.ts`) — * `id`/`version`/`kind`/`titleKey` live on `Campaign` itself, not here (the @@ -10,7 +10,7 @@ import type { LocKey } from "../../core/localization/types.js"; import type { VariableSchema } from "./variables.js"; -import type { Node } from "./nodes.js"; +import type { Choice, Node } from "./nodes.js"; import type { AchievementDefinition } from "./achievements.js"; export interface StoryGraphCampaign { @@ -19,4 +19,34 @@ export interface StoryGraphCampaign { nodes: Record; startNodeId: string; achievements: AchievementDefinition[]; + + /** Present iff this campaign may be included (03 §1.1). */ + module?: ModuleInterface; +} + +/** Opt-in: a campaign without it fails `include_not_module` when included (03 §1.1). */ +export interface ModuleInterface { + /** Where an include enters; default `startNodeId`. */ + entryNodeId?: string; + /** Declared variables a host may write on entry. */ + inputs: string[]; + /** Declared variables a host may read on exit. */ + outputs: string[]; +} + +/** `CampaignInclude.binding` (04 §10.4) for a story-graph module. */ +export interface StoryGraphIncludeBinding { + /** Module `endingId` → host node id; every one mapped. */ + exits: Record; + /** Module input → host variable, copied in on entry. */ + inputs?: Record; + /** Module output → host variable, copied out on exit. */ + outputs?: Record; +} + +/** `CampaignAttachment.payload` (04 §10.4): one new choice on a host choice node, whose + * `goto` is implied — the include's entry. */ +export interface StoryGraphAttachment { + hostNodeId: string; + choice: Omit; } diff --git a/src/engine/src/kinds/story-graph/compose.test.ts b/src/engine/src/kinds/story-graph/compose.test.ts new file mode 100644 index 00000000..e216dcb9 --- /dev/null +++ b/src/engine/src/kinds/story-graph/compose.test.ts @@ -0,0 +1,542 @@ +/** + * W120 — the story-graph half of campaign composition (03 §1.1, 04 §10.4): the six kind codes + * reject with a path, composed ids are accepted and authored `:` ids are not, a pack + * attachment appends a choice, and a host plays through a module and back out in one session + * with the save format unchanged. + */ + +import { describe, expect, it } from "vitest"; +import { createCountingIds } from "../../core/determinism/counting-ids.js"; +import { createEngine } from "../../core/kernel/engine.js"; +import type { Engine, GameState, KindRegistry } from "../../core/kernel/types.js"; +import type { LocKey } from "../../core/localization/types.js"; +import type { ContentPack } from "../../core/registry/packs.js"; +import { resolvePacks } from "../../core/registry/packs.js"; +import type { BuiltCampaign, CampaignAttachment, CampaignInclude, ContentRegistry } from "../../core/registry/types.js"; +import { buildValidatedContentRegistry, buildValidatedPackRegistry } from "../../core/validation/tiered.js"; +import type { StoryGraphAttachment, StoryGraphCampaign, StoryGraphIncludeBinding } from "./campaign.js"; +import { storyGraphKind } from "./kind.js"; +import type { StoryGraphKindState } from "./state.js"; + +const kinds = { "story-graph": storyGraphKind } as unknown as KindRegistry; + +// --------------------------------------------------------------------------- +// Fixtures — a market host that includes a haggling module. +// --------------------------------------------------------------------------- + +/** The module: two exits, one input, two outputs, a visible stat and an achievement. */ +function haggleContent(): StoryGraphCampaign { + return { + descriptionKey: "haggle.desc", + variables: { + price: { type: "int", initial: 10, min: 0, max: 20, visible: true, labelKey: "haggle.stat.price" }, + deal: { type: "bool", initial: false }, + }, + startNodeId: "start", + achievements: [ + { + id: "bargainer", + nameKey: "haggle.ach.name", + descriptionKey: "haggle.ach.desc", + condition: { field: "var.deal", operator: "equals", value: true }, + hidden: false, + }, + ], + nodes: { + start: { + id: "start", + kind: "choice", + textKey: "haggle.start", + choices: [ + { id: "accept", labelKey: "haggle.accept", effects: [{ op: "set", var: "deal", value: true }], goto: "accepted" }, + { id: "walk", labelKey: "haggle.walk", effects: [{ op: "decrement", var: "price", by: 1 }], goto: "walked" }, + ], + }, + accepted: { id: "accepted", kind: "ending", textKey: "haggle.accepted", endingId: "deal_done" }, + walked: { id: "walked", kind: "ending", textKey: "haggle.walked", endingId: "walked_away" }, + }, + module: { inputs: ["price"], outputs: ["deal", "price"] }, + }; +} + +const haggleStrings: [string, string][] = [ + ["haggle.title", "Haggle"], + ["haggle.desc", "A haggle."], + ["haggle.stat.price", "Price"], + ["haggle.ach.name", "Bargainer"], + ["haggle.ach.desc", "Struck a deal."], + ["haggle.start", "The vendor asks {price}."], + ["haggle.accept", "Accept"], + ["haggle.walk", "Walk away"], + ["haggle.accepted", "Done."], + ["haggle.walked", "You walk."], +]; + +function marketBinding(): StoryGraphIncludeBinding { + return { + exits: { deal_done: "home", walked_away: "stall" }, + inputs: { price: "cash" }, + outputs: { deal: "bought", price: "cash" }, + }; +} + +function marketContent(): StoryGraphCampaign { + return { + descriptionKey: "market.desc", + variables: { + cash: { type: "int", initial: 5, min: 0, max: 20, visible: true, labelKey: "market.stat.cash" }, + bought: { type: "bool", initial: false }, + }, + startNodeId: "stall", + achievements: [], + nodes: { + stall: { + id: "stall", + kind: "choice", + textKey: "market.stall", + choices: [ + { id: "haggle", labelKey: "market.choice.haggle", goto: "haggle" }, + { id: "leave", labelKey: "market.choice.leave", goto: "home" }, + ], + }, + home: { id: "home", kind: "ending", textKey: "market.home", endingId: "home" }, + }, + }; +} + +const marketStrings: [string, string][] = [ + ["market.title", "Market"], + ["market.desc", "A market."], + ["market.stat.cash", "Cash"], + ["market.stall", "You have {cash}."], + ["market.choice.haggle", "Haggle"], + ["market.choice.leave", "Leave"], + ["market.home", "Home."], +]; + +function include(alias: string, id: string, binding: unknown, version = "1.0.0"): CampaignInclude { + return { alias, ref: { id, version }, binding }; +} + +function built( + id: string, + content: StoryGraphCampaign, + strings: [string, string][], + includes: CampaignInclude[] = [], + version = "1.0.0", +): BuiltCampaign { + return { + campaign: { id, kindId: "story-graph", version, titleKey: `${id}.title`, content, ...(includes.length > 0 ? { includes } : {}) }, + strings: new Map(strings), + }; +} + +function haggle(content = haggleContent()): BuiltCampaign { + return built("haggle", content, haggleStrings); +} + +function market(binding: unknown = marketBinding(), content = marketContent()): BuiltCampaign { + return built("market", content, marketStrings, [include("haggle", "haggle", binding)]); +} + +function registry(campaigns: BuiltCampaign[]): ContentRegistry { + const result = buildValidatedContentRegistry(campaigns, kinds); + if (!result.ok || !result.value) throw new Error(`expected a registry — ${JSON.stringify(result.errors)}`); + return result.value; +} + +function rejected(campaigns: BuiltCampaign[]): [string, string | undefined][] { + const result = buildValidatedContentRegistry(campaigns, kinds); + expect(result.ok).toBe(false); + return result.errors.map((e) => [e.code, e.path]); +} + +function composedOf(reg: ContentRegistry, id: string): StoryGraphCampaign { + return reg.campaigns.get(id)!.content as StoryGraphCampaign; +} + +function kindState(state: GameState): StoryGraphKindState { + return state.kindState as StoryGraphKindState; +} + +function play(engine: Engine, state: GameState, actionId: string): GameState { + const result = engine.submitAction(state, actionId); + if (!result.ok || !result.value) throw new Error(`expected ${actionId} to apply — ${JSON.stringify(result.errors)}`); + return result.value; +} + +// --------------------------------------------------------------------------- +// W120.4 — the six story-graph codes +// --------------------------------------------------------------------------- + +describe("composeContent — rejecting (03 §8.3)", () => { + it("include_not_module: the included campaign has no module block", () => { + const plain = haggleContent(); + delete plain.module; + expect(rejected([market(), haggle(plain)])).toEqual([["include_not_module", "market.haggle"]]); + }); + + it("include_not_module: the module reads `ending`", () => { + const content = haggleContent(); + content.achievements[0]!.condition = { field: "ending", operator: "equals", value: "deal_done" }; + expect(rejected([market(), haggle(content)])).toEqual([["include_not_module", "market.haggle"]]); + }); + + it("exit_unmapped: a module ending the binding does not map", () => { + const binding = { ...marketBinding(), exits: { deal_done: "home" } }; + expect(rejected([market(binding), haggle()])).toEqual([["exit_unmapped", "market.haggle.exits.walked_away"]]); + }); + + it("exit_unmapped: an exit mapped that the module does not have", () => { + const binding = { ...marketBinding(), exits: { ...marketBinding().exits, vanished: "home" } }; + expect(rejected([market(binding), haggle()])).toEqual([["exit_unmapped", "market.haggle.exits.vanished"]]); + }); + + it("binding_undeclared: a module variable the module does not declare as an input", () => { + const binding = { ...marketBinding(), inputs: { deal: "bought" } }; + expect(rejected([market(binding), haggle()])).toEqual([["binding_undeclared", "market.haggle.inputs.deal"]]); + }); + + it("binding_undeclared: a host variable the host does not declare", () => { + const binding = { ...marketBinding(), outputs: { deal: "ghost" } }; + expect(rejected([market(binding), haggle()])).toEqual([["binding_undeclared", "market.haggle.outputs.deal"]]); + }); + + it("binding_type_mismatch: a bool output bound to an int host variable", () => { + const binding = { ...marketBinding(), outputs: { deal: "cash" } }; + expect(rejected([market(binding), haggle()])).toEqual([["binding_type_mismatch", "market.haggle.outputs.deal"]]); + }); + + it("attachment_node_not_choice: the host node is an ending", () => { + const result = buildValidatedPackRegistry([marketPack(), hagglePack(), attachmentPack("home", "extra")], kinds); + expect(result.errors.map((e) => [e.code, e.path])).toEqual([["attachment_node_not_choice", "home"]]); + }); + + it("attachment_choice_collision: the host node already has the choice id", () => { + const result = buildValidatedPackRegistry([marketPack(), hagglePack(), attachmentPack("stall", "leave")], kinds); + expect(result.errors.map((e) => [e.code, e.path])).toEqual([["attachment_choice_collision", "stall.leave"]]); + }); + + it("every kind code carries a story-graph.reason messageKey", () => { + const binding = { ...marketBinding(), exits: { deal_done: "home" } }; + const result = buildValidatedContentRegistry([market(binding), haggle()], kinds); + expect(result.errors[0]!.messageKey).toBe("story-graph.reason.exit_unmapped"); + }); +}); + +// --------------------------------------------------------------------------- +// W120.5 — identifiers +// --------------------------------------------------------------------------- + +describe("identifiers (04 §17)", () => { + it("rejects an authored node, variable and achievement id containing ':'", () => { + const content = haggleContent(); + content.nodes["a:b"] = { id: "a:b", kind: "ending", textKey: "haggle.walked", endingId: "walked_away" }; + content.variables["v:w"] = { type: "bool", initial: false }; + content.achievements[0]!.id = "x:y"; + const codes = rejected([built("solo", content, [...haggleStrings, ["solo.title", "Solo"]])]); + expect(codes).toEqual(expect.arrayContaining([["invalid_identifier", "a:b"], ["invalid_identifier", "v:w"], ["invalid_identifier", "x:y"]])); + expect(codes.every(([code]) => code === "invalid_identifier")).toBe(true); + }); + + it("rejects a composed-shaped id authored in a host", () => { + const content = marketContent(); + content.variables["haggle::price"] = { type: "int", initial: 0 }; + expect(rejected([market(marketBinding(), content), haggle()])).toEqual([["invalid_identifier", "haggle::price"]]); + }); + + it("rejects an alias containing ':'", () => { + const host = built("market", marketContent(), marketStrings, [include("hag:gle", "haggle", marketBinding())]); + expect(rejected([host, haggle()])).toEqual([["invalid_identifier", "market.hag:gle"]]); + }); + + it("accepts composed ids", () => { + const composed = composedOf(registry([market(), haggle()]), "market"); + expect(Object.keys(composed.nodes).sort()).toEqual(["haggle", "haggle::accepted", "haggle::start", "haggle::walked", "home", "stall"]); + expect(Object.keys(composed.variables).sort()).toEqual(["bought", "cash", "haggle::deal", "haggle::price"]); + expect(composed.achievements.map((a) => a.id)).toEqual(["haggle::bargainer"]); + }); +}); + +// --------------------------------------------------------------------------- +// W120.2 — composition shape +// --------------------------------------------------------------------------- + +describe("composition shape (04 §10.4)", () => { + /** inner: x → fin (exit `done`); outer: start → b (inner) → fin2 (exit `out`); host: s → a (outer) → end. */ + function chain(): BuiltCampaign[] { + const inner: StoryGraphCampaign = { + descriptionKey: "inner.desc", + variables: {}, + startNodeId: "x", + achievements: [], + nodes: { + x: { id: "x", kind: "choice", textKey: "inner.x", choices: [{ id: "go", labelKey: "inner.go", goto: "fin" }] }, + fin: { id: "fin", kind: "ending", textKey: "inner.fin", endingId: "done" }, + }, + module: { inputs: [], outputs: [] }, + }; + const outer: StoryGraphCampaign = { + descriptionKey: "outer.desc", + variables: {}, + startNodeId: "start", + achievements: [], + nodes: { + start: { id: "start", kind: "choice", textKey: "outer.start", choices: [{ id: "in", labelKey: "outer.in", goto: "b" }] }, + fin2: { id: "fin2", kind: "ending", textKey: "outer.fin2", endingId: "out" }, + }, + module: { inputs: [], outputs: [] }, + }; + const host: StoryGraphCampaign = { + descriptionKey: "host.desc", + variables: {}, + startNodeId: "s", + achievements: [], + nodes: { + s: { id: "s", kind: "choice", textKey: "host.s", choices: [{ id: "in", labelKey: "host.in", goto: "a" }] }, + end: { id: "end", kind: "ending", textKey: "host.end", endingId: "end" }, + }, + }; + const text = (id: string, keys: string[]): [string, string][] => [[`${id}.title`, id], ...keys.map((k): [string, string] => [k, k])]; + return [ + built("host", host, text("host", ["host.desc", "host.s", "host.in", "host.end"]), [include("a", "outer", { exits: { out: "end" } })]), + built("outer", outer, text("outer", ["outer.desc", "outer.start", "outer.in", "outer.fin2"]), [include("b", "inner", { exits: { done: "fin2" } })]), + built("inner", inner, text("inner", ["inner.desc", "inner.x", "inner.go", "inner.fin"])), + ]; + } + + it("a three-level chain composes to ids of the form a::b::x", () => { + const composed = composedOf(registry(chain()), "host"); + expect(Object.keys(composed.nodes).sort()).toEqual(["a", "a::b", "a::b::fin", "a::b::x", "a::fin2", "a::start", "end", "s"]); + expect(composed.nodes["a::b::fin"]).toMatchObject({ kind: "auto", goto: "a::fin2" }); + expect(composed.nodes["a::fin2"]).toMatchObject({ kind: "auto", goto: "end" }); + }); + + it("plays the chain through both modules and out", () => { + const engine = createEngine({ kinds, registry: registry(chain()), ids: createCountingIds() }); + let state = engine.createGame({ campaignId: "host", seed: "chain" }).value!; + state = play(engine, state, "in"); + expect(kindState(state).currentNodeId).toBe("a::start"); + state = play(engine, state, "in"); + expect(kindState(state).currentNodeId).toBe("a::b::x"); + state = play(engine, state, "go"); + expect(kindState(state).endingId).toBe("end"); + }); + + it("two hosts including the same module get the same composed module", () => { + const [host, outer, inner] = chain(); + const twin: BuiltCampaign = { + campaign: { ...host!.campaign, id: "twin", titleKey: "twin.title" }, + strings: new Map([...host!.strings, ["twin.title", "twin"]]), + }; + // Distinct host-owned keys would collide on `string_conflict`; the twin shares the host's text. + const reg = registry([host!, twin, outer!, inner!]); + const a = composedOf(reg, "host"); + const b = composedOf(reg, "twin"); + for (const id of Object.keys(a.nodes).filter((id) => id.includes("::"))) { + expect(b.nodes[id]).toEqual(a.nodes[id]); + } + }); + + it("validates the module standalone as well as composed", () => { + const reg = registry([market(), haggle()]); + expect(composedOf(reg, "haggle").nodes["start"]).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// W120.7 — play through a module +// --------------------------------------------------------------------------- + +describe("playing a host through a module (03 §1.1)", () => { + function engine(): Engine { + return createEngine({ kinds, registry: registry([market(), haggle()]), ids: createCountingIds() }); + } + + /** stall → haggle → walk (exit to stall) → haggle → accept (exit to home). */ + function run(e: Engine, cutAfter?: number): { state: GameState; trail: GameState[] } { + let state = e.createGame({ campaignId: "market", seed: "w120" }).value!; + const trail: GameState[] = [state]; + ["haggle", "walk", "haggle", "accept"].forEach((actionId, index) => { + state = play(e, state, actionId); + if (index === cutAfter) state = e.deserialize(e.serialize(state)).value!; + trail.push(state); + }); + return { state, trail }; + } + + it("is deterministic on a repeat", () => { + expect(engine().serialize(run(engine()).state)).toBe(engine().serialize(run(engine()).state)); + }); + + it("matches the uncut run across a save/load cut taken inside the module", () => { + const e = engine(); + const cut = run(e, 2); // after the second entry, at haggle::start + expect(kindState(cut.trail[3]!).currentNodeId).toBe("haggle::start"); + expect(e.serialize(cut.state)).toBe(e.serialize(run(engine()).state)); + }); + + it("entry and exit each cost one turn", () => { + const { trail } = run(engine()); + // A choice costs one; passing the entry costs one more. + expect(kindState(trail[1]!).turn - kindState(trail[0]!).turn).toBe(2); + // A choice costs one; passing the exit costs one more. + expect(kindState(trail[2]!).turn - kindState(trail[1]!).turn).toBe(2); + expect(kindState(trail[4]!).turn).toBe(8); + }); + + it("visited. counts entries", () => { + const { trail } = run(engine()); + expect(kindState(trail[1]!).visitedCounts["haggle"]).toBe(1); + expect(kindState(trail[3]!).visitedCounts["haggle"]).toBe(2); + }); + + it("copies a bound input in on every entry", () => { + const { trail } = run(engine()); + expect(kindState(trail[1]!).variables["haggle::price"]).toBe(5); + // Walking away decrements the module's price to 4 and copies it out to cash... + expect(kindState(trail[2]!).variables["cash"]).toBe(4); + // ...and re-entering copies cash back in. + expect(kindState(trail[3]!).variables["haggle::price"]).toBe(4); + }); + + it("copies a bound output out on exit", () => { + const { trail } = run(engine()); + expect(kindState(trail[3]!).variables["bought"]).toBe(false); + expect(kindState(trail[4]!).variables["bought"]).toBe(true); + expect(kindState(trail[4]!).endingId).toBe("home"); + }); + + it("interpolation in module text reads the module's own variable", () => { + const e = engine(); + const { trail } = run(e); + expect(e.scene(trail[1]!).body.text).toBe("The vendor asks 5."); + expect(e.scene(trail[3]!).body.text).toBe("The vendor asks 4."); + expect(e.scene(trail[2]!).body.text).toBe("You have 4."); + }); + + it("a module achievement unlocks into the host's unlockedAchievements", () => { + const { trail } = run(engine()); + expect(kindState(trail[3]!).unlockedAchievements).toEqual([]); + expect(kindState(trail[4]!).unlockedAchievements).toEqual(["haggle::bargainer"]); + }); +}); + +// --------------------------------------------------------------------------- +// W120.3 / W120.8 — packs: the authored pin and attachments +// --------------------------------------------------------------------------- + +function marketPack(includes: CampaignInclude[] = []): ContentPack { + const market = built("market", marketContent(), marketStrings, includes); + return { id: "market-pack", version: "1.0.0", kindId: "story-graph", dependsOn: [], campaigns: [market], strings: market.strings }; +} + +function hagglePack(): ContentPack { + const module = haggle(); + return { id: "haggle-pack", version: "1.0.0", kindId: "story-graph", dependsOn: [], campaigns: [module], strings: module.strings }; +} + +function attachment(hostNodeId: string, choiceId: string, alias = "haggle", hostCampaignId = "market"): CampaignAttachment { + const payload: StoryGraphAttachment = { hostNodeId, choice: { id: choiceId, labelKey: "attach.label" } }; + return { hostCampaignId, include: include(alias, "haggle", marketBinding()), payload }; +} + +function attachmentPack(hostNodeId: string, choiceId: string, ...more: CampaignAttachment[]): ContentPack { + return { + id: "attach-pack", + version: "1.0.0", + kindId: "story-graph", + dependsOn: [], + campaigns: [], + strings: new Map([["attach.label", "Haggle (attached)"]]), + attachments: [attachment(hostNodeId, choiceId), ...more], + }; +} + +describe("packs (04 §10.4, 11 §3a)", () => { + it("a pin naming the authored version passes the packed path", () => { + const result = buildValidatedPackRegistry([marketPack([include("haggle", "haggle", marketBinding())]), hagglePack()], kinds); + expect(result.errors).toEqual([]); + expect(result.ok).toBe(true); + }); + + it("a pin naming the stamped resolution id fails the packed path", () => { + // The resolution id is a digest of the ordered pack refs only, so it can be known before + // the include that names it is written. + const stamped = resolvePacks([marketPack(), hagglePack()]).value!.resolution; + const result = buildValidatedPackRegistry([marketPack([include("haggle", "haggle", marketBinding(), stamped)]), hagglePack()], kinds); + expect(result.errors.map((e) => [e.code, e.path])).toEqual([["include_version_mismatch", "market.haggle"]]); + }); + + it("an attachment appends one choice after the authored ones, and it reaches the module", () => { + const result = buildValidatedPackRegistry([marketPack(), hagglePack(), attachmentPack("stall", "bargain")], kinds); + expect(result.errors).toEqual([]); + const reg = result.value!; + const stall = composedOf(reg, "market").nodes["stall"]!; + expect(stall.kind === "choice" && stall.choices.map((c) => [c.id, c.goto])).toEqual([ + ["haggle", "haggle"], + ["leave", "home"], + ["bargain", "haggle"], + ]); + + const e = createEngine({ kinds, registry: reg, ids: createCountingIds() }); + let state = e.createGame({ campaignId: "market", seed: "attach" }).value!; + state = play(e, state, "bargain"); + expect(kindState(state).currentNodeId).toBe("haggle::start"); + }); + + it("several attachments append in the order composition is handed them", () => { + const second: CampaignAttachment = { ...attachment("stall", "second", "again"), include: include("again", "haggle", marketBinding()) }; + const result = buildValidatedPackRegistry([marketPack(), hagglePack(), attachmentPack("stall", "first", second)], kinds); + expect(result.errors).toEqual([]); + const stall = composedOf(result.value!, "market").nodes["stall"]!; + expect(stall.kind === "choice" && stall.choices.map((c) => [c.id, c.goto])).toEqual([ + ["haggle", "haggle"], + ["leave", "home"], + ["first", "haggle"], + ["second", "again"], + ]); + }); + + it("an attachment on a module reaches every host that includes it", () => { + // `tip` is attached to the haggle module's start node; both hosts include haggle. + const tipModule: StoryGraphCampaign = { + descriptionKey: "tip.desc", + variables: {}, + startNodeId: "tip", + achievements: [], + nodes: { tip: { id: "tip", kind: "ending", textKey: "tip.text", endingId: "tipped" } }, + module: { inputs: [], outputs: [] }, + }; + const tip = built("tip", tipModule, [["tip.title", "Tip"], ["tip.desc", "A tip."], ["tip.text", "You tip."]]); + const tipAttachment: CampaignAttachment = { + hostCampaignId: "haggle", + include: include("tip", "tip", { exits: { tipped: "accepted" } }), + payload: { hostNodeId: "start", choice: { id: "tip", labelKey: "attach.label" } } satisfies StoryGraphAttachment, + }; + const twinContent = marketContent(); + const twin = built("twin", twinContent, [...marketStrings.filter(([k]) => k !== "market.title"), ["twin.title", "Twin"]], [ + include("haggle", "haggle", marketBinding()), + ]); + const market = built("market", marketContent(), marketStrings, [include("haggle", "haggle", marketBinding())]); + const pack: ContentPack = { + id: "all", + version: "1.0.0", + kindId: "story-graph", + dependsOn: [], + campaigns: [market, twin, haggle(), tip], + strings: new Map([...market.strings, ...twin.strings, ...haggle().strings, ...tip.strings, ["attach.label", "Tip"]]), + attachments: [tipAttachment], + }; + const result = buildValidatedPackRegistry([pack], kinds); + expect(result.errors).toEqual([]); + for (const host of ["market", "twin"]) { + const start = composedOf(result.value!, host).nodes["haggle::start"]!; + expect(start.kind === "choice" && start.choices.map((c) => [c.id, c.goto])).toEqual([ + ["accept", "haggle::accepted"], + ["walk", "haggle::walked"], + ["tip", "haggle::tip"], + ]); + } + }); +}); diff --git a/src/engine/src/kinds/story-graph/compose.ts b/src/engine/src/kinds/story-graph/compose.ts new file mode 100644 index 00000000..5674a9e2 --- /dev/null +++ b/src/engine/src/kinds/story-graph/compose.ts @@ -0,0 +1,277 @@ +/** + * Story-graph kind — composing modules into a host (`Kind.composeContent`). + * + * Contract: `03-story-graph-kind.md` §1.1, §5 (`copy`); `04-core.md` §10.4, §17 + * (*Composed ids*). + * + * The core has already checked the envelope — every module exists, is pinned to its authored + * version, is this kind, and is already composed itself. What is left is this kind's half: + * the module opted in, its exits and bindings fit, and the attachments land on a choice + * node. Only when all of that holds is anything merged; the result is then validated as + * ordinary content (`validate.ts`), so a dangling exit target or an alias that collides with + * a host node is found there, not here. + */ + +import type { Condition } from "../../core/condition/types.js"; +import type { CommandResult } from "../../core/kernel/reasons.js"; +import type { Campaign, CampaignInclude, ComposedAttachment, ComposedModule } from "../../core/registry/types.js"; +import type { ValidationError } from "../../core/validation/types.js"; +import type { StoryGraphAttachment, StoryGraphCampaign, StoryGraphIncludeBinding } from "./campaign.js"; +import type { AutoNode, Choice, Node } from "./nodes.js"; +import { isAssignable, type Consequence, type VariableSchema } from "./variables.js"; +import type { AchievementDefinition } from "./achievements.js"; + +function error(code: string, path: string): ValidationError { + return { code, messageKey: `story-graph.reason.${code}`, path }; +} + +function invalidIdentifier(path: string): ValidationError { + return { code: "invalid_identifier", messageKey: "core.reason.invalid_identifier", path }; +} + +/** A null-prototype copy — every map here is keyed by content-controlled ids. */ +function record(entries: Iterable = []): Record { + const out = Object.create(null) as Record; + for (const [key, value] of entries) out[key] = value; + return out; +} + +function stringMap(value: unknown): Record { + if (typeof value !== "object" || value === null) return record(); + return record(Object.entries(value as Record).filter((e): e is [string, string] => typeof e[1] === "string")); +} + +/** A binding as authored, with every malformed part read as absent rather than trusted. */ +function readBinding(binding: unknown): Required { + const raw = (typeof binding === "object" && binding !== null ? binding : {}) as Partial; + return { exits: stringMap(raw.exits), inputs: stringMap(raw.inputs), outputs: stringMap(raw.outputs) }; +} + +/** Every condition a campaign carries — `showWhen`, `requirements`, achievement conditions. */ +function conditionsOf(content: StoryGraphCampaign): Condition[] { + const conditions: Condition[] = []; + for (const node of Object.values(content.nodes)) { + if (node.kind !== "choice") continue; + for (const choice of node.choices) { + if (choice.showWhen) conditions.push(choice.showWhen); + if (choice.requirements) conditions.push(choice.requirements); + } + } + for (const achievement of content.achievements) conditions.push(achievement.condition); + return conditions; +} + +function readsEnding(condition: Condition): boolean { + if ("all" in condition) return condition.all.some(readsEnding); + if ("any" in condition) return condition.any.some(readsEnding); + if ("not" in condition) return readsEnding(condition.not); + if ("exists" in condition || "count" in condition) return false; + return condition.field === "ending"; +} + +/** The host's own authored ids carry no `:` — so no composed id can collide with one (04 §17). */ +function hostIdentifierErrors(content: StoryGraphCampaign): ValidationError[] { + const errors: ValidationError[] = []; + for (const [id, node] of Object.entries(content.nodes)) { + if (id.includes(":")) errors.push(invalidIdentifier(id)); + if (node.kind === "choice") { + for (const choice of node.choices) if (choice.id.includes(":")) errors.push(invalidIdentifier(choice.id)); + } + } + for (const name of Object.keys(content.variables)) if (name.includes(":")) errors.push(invalidIdentifier(name)); + for (const achievement of content.achievements) { + if (achievement.id.includes(":")) errors.push(invalidIdentifier(achievement.id)); + } + return errors; +} + +/** This kind's checks on one include; nothing is merged unless every include passes. */ +function includeErrors(host: Campaign, hostContent: StoryGraphCampaign, include: CampaignInclude, module: StoryGraphCampaign): ValidationError[] { + const path = `${host.id}.${include.alias}`; + if (!module.module) return [error("include_not_module", path)]; + if (conditionsOf(module).some(readsEnding)) return [error("include_not_module", path)]; + + const errors: ValidationError[] = []; + const binding = readBinding(include.binding); + + const endingIds = new Set(); + for (const node of Object.values(module.nodes)) if (node.kind === "ending") endingIds.add(node.endingId); + for (const endingId of endingIds) { + if (!Object.hasOwn(binding.exits, endingId)) errors.push(error("exit_unmapped", `${path}.exits.${endingId}`)); + } + for (const endingId of Object.keys(binding.exits)) { + if (!endingIds.has(endingId)) errors.push(error("exit_unmapped", `${path}.exits.${endingId}`)); + } + + // Inputs flow host → module, outputs module → host; a pair is checked in its direction. + const pairs = [ + ["inputs", binding.inputs, module.module.inputs] as const, + ["outputs", binding.outputs, module.module.outputs] as const, + ]; + for (const [direction, bound, declared] of pairs) { + for (const [moduleVar, hostVar] of Object.entries(bound)) { + const at = `${path}.${direction}.${moduleVar}`; + const moduleDecl = declared.includes(moduleVar) && Object.hasOwn(module.variables, moduleVar) ? module.variables[moduleVar]! : undefined; + const hostDecl = Object.hasOwn(hostContent.variables, hostVar) ? hostContent.variables[hostVar]! : undefined; + if (!moduleDecl || !hostDecl) { + errors.push(error("binding_undeclared", at)); + continue; + } + const assignable = direction === "inputs" ? isAssignable(moduleDecl, hostDecl) : isAssignable(hostDecl, moduleDecl); + if (!assignable) errors.push(error("binding_type_mismatch", at)); + } + } + + return errors; +} + +/** Rewrites every module-scoped reference in a condition to its composed id. */ +function prefixCondition(condition: Condition, p: (id: string) => string): Condition { + if ("all" in condition) return { all: condition.all.map((c) => prefixCondition(c, p)) }; + if ("any" in condition) return { any: condition.any.map((c) => prefixCondition(c, p)) }; + if ("not" in condition) return { not: prefixCondition(condition.not, p) }; + if ("exists" in condition || "count" in condition) return condition; + for (const scope of ["var.", "visited.", "achieved."]) { + if (condition.field.startsWith(scope)) return { ...condition, field: scope + p(condition.field.slice(scope.length)) }; + } + return condition; // `turn` is the host's (03 §1.1) +} + +function prefixConsequence(consequence: Consequence, p: (id: string) => string): Consequence { + if (consequence.op === "copy") return { ...consequence, var: p(consequence.var), from: p(consequence.from) }; + return { ...consequence, var: p(consequence.var) }; +} + +function prefixEffects(effects: Consequence[] | undefined, p: (id: string) => string): { effects?: Consequence[] } { + return effects !== undefined ? { effects: effects.map((c) => prefixConsequence(c, p)) } : {}; +} + +function withEffects(effects: Consequence[]): { effects?: Consequence[] } { + return effects.length > 0 ? { effects } : {}; +} + +/** Merges one already-checked include into the host's nodes, variables and achievements. */ +function mergeInclude( + nodes: Record, + variables: VariableSchema, + achievements: AchievementDefinition[], + include: CampaignInclude, + module: StoryGraphCampaign, + errors: ValidationError[], +): void { + const alias = include.alias; + const p = (id: string): string => `${alias}::${id}`; + const binding = readBinding(include.binding); + + for (const [name, decl] of Object.entries(module.variables)) variables[p(name)] = decl; + + for (const [id, node] of Object.entries(module.nodes)) { + const composedId = p(id); + switch (node.kind) { + case "choice": + nodes[composedId] = { + ...node, + id: composedId, + choices: node.choices.map((choice) => ({ + ...choice, + ...(choice.showWhen ? { showWhen: prefixCondition(choice.showWhen, p) } : {}), + ...(choice.requirements ? { requirements: prefixCondition(choice.requirements, p) } : {}), + ...prefixEffects(choice.effects, p), + goto: p(choice.goto), + })), + }; + break; + case "random": + nodes[composedId] = { + ...node, + id: composedId, + transitions: node.transitions.map((t) => ({ ...t, ...prefixEffects(t.effects, p), goto: p(t.goto) })), + }; + break; + case "auto": + nodes[composedId] = { ...node, id: composedId, ...prefixEffects(node.effects, p), goto: p(node.goto) }; + break; + case "ending": { + // An exit: copy the bound outputs out, then continue in the host (03 §1.1). + const copies = Object.entries(binding.outputs).map( + ([moduleVar, hostVar]): Consequence => ({ op: "copy", var: hostVar, from: p(moduleVar) }), + ); + const exit: AutoNode = { id: composedId, kind: "auto", textKey: node.textKey, ...withEffects(copies), goto: binding.exits[node.endingId]! }; + nodes[composedId] = exit; + break; + } + } + } + + for (const achievement of module.achievements) { + achievements.push({ ...achievement, id: p(achievement.id), condition: prefixCondition(achievement.condition, p) }); + } + + // The entry: the alias itself, copying the bound inputs in on every entry. + if (Object.hasOwn(nodes, alias)) { + errors.push(error("duplicate_id", alias)); + return; + } + const entryId = module.module?.entryNodeId ?? module.startNodeId; + const entryNode = Object.hasOwn(module.nodes, entryId) ? module.nodes[entryId] : undefined; + const copies = Object.entries(binding.inputs).map( + ([moduleVar, hostVar]): Consequence => ({ op: "copy", var: p(moduleVar), from: hostVar }), + ); + nodes[alias] = { + id: alias, + kind: "auto", + // Never rendered — an `auto` node is a pass-through. A missing entry node falls back to + // the module's description; its dangling `goto` is `validate.ts`'s to report. + textKey: entryNode?.textKey ?? module.descriptionKey, + ...withEffects(copies), + goto: p(entryId), + }; +} + +/** `Kind.composeContent` (04 §3, §10.4). */ +export function composeContent( + host: Campaign, + modules: readonly ComposedModule[], + attachments: readonly ComposedAttachment[], +): CommandResult { + const hostContent = host.content as StoryGraphCampaign; + const included = [...modules, ...attachments.map((attachment) => attachment.module)]; + + const errors: ValidationError[] = [...hostIdentifierErrors(hostContent)]; + for (const { include, campaign } of included) { + errors.push(...includeErrors(host, hostContent, include, campaign.content as StoryGraphCampaign)); + } + if (errors.length > 0) return { ok: false, errors, warnings: [] }; + + const nodes = record(Object.entries(hostContent.nodes)); + const variables = record(Object.entries(hostContent.variables)); + const achievements = [...hostContent.achievements]; + + for (const { include, campaign } of included) { + mergeInclude(nodes, variables, achievements, include, campaign.content as StoryGraphCampaign, errors); + } + + // Each attachment appends one choice after the node's authored ones, in the order + // composition was handed them (11 §3a); its `goto` is the include's entry. + for (const { payload, module } of attachments) { + const { hostNodeId, choice } = payload as StoryGraphAttachment; + const node = Object.hasOwn(hostContent.nodes, hostNodeId) ? nodes[hostNodeId] : undefined; + if (!node) { + errors.push(error("dangling_reference", hostNodeId)); + continue; + } + if (node.kind !== "choice") { + errors.push(error("attachment_node_not_choice", hostNodeId)); + continue; + } + if (node.choices.some((existing) => existing.id === choice.id)) { + errors.push(error("attachment_choice_collision", `${hostNodeId}.${choice.id}`)); + continue; + } + const attached: Choice = { ...choice, goto: module.include.alias }; + nodes[hostNodeId] = { ...node, choices: [...node.choices, attached] }; + } + + if (errors.length > 0) return { ok: false, errors, warnings: [] }; + return { ok: true, value: { ...hostContent, nodes, variables, achievements }, errors: [], warnings: [] }; +} diff --git a/src/engine/src/kinds/story-graph/kind.ts b/src/engine/src/kinds/story-graph/kind.ts index 9dd7f468..67dd5a64 100644 --- a/src/engine/src/kinds/story-graph/kind.ts +++ b/src/engine/src/kinds/story-graph/kind.ts @@ -21,6 +21,7 @@ import type { import type { Campaign } from "../../core/registry/types.js"; import { advance } from "./advance.js"; import type { StoryGraphCampaign } from "./campaign.js"; +import { composeContent } from "./compose.js"; import { STORY_GRAPH_EVENT_NAMES } from "./events.js"; import { STORY_GRAPH_REASON_CODES, STORY_GRAPH_REASON_MESSAGES } from "./reasons.js"; import { availableActions, scene } from "./scene.js"; @@ -54,6 +55,7 @@ export const storyGraphKind: Kind = { advance: (state, actionId, params, ctx): AdvanceResult => advance(state, actionId, params, ctx), project: (state, audience, ctx) => project(state, audience, ctx), validateCampaign: (campaign, strings) => validateCampaign(campaign, strings), + composeContent: (host, modules, attachments) => composeContent(host, modules, attachments), outcome: (state) => { const endingId = state.endingId ?? null; return { terminal: endingId !== null, terminalId: endingId, endingId }; diff --git a/src/engine/src/kinds/story-graph/reasons.test.ts b/src/engine/src/kinds/story-graph/reasons.test.ts index dc7f7c00..063f2d1e 100644 --- a/src/engine/src/kinds/story-graph/reasons.test.ts +++ b/src/engine/src/kinds/story-graph/reasons.test.ts @@ -14,7 +14,7 @@ describe("STORY_GRAPH_REASON_MESSAGES", () => { expect(STORY_GRAPH_REASON_MESSAGES.size).toBe(STORY_GRAPH_REASON_CODES.length); }); - it("declares the runtime codes (03 §8.3, W10's unknown_condition_field), W14's Tier 1/2 validation codes, and the audit code", () => { + it("declares the runtime codes (03 §8.3, W10's unknown_condition_field), W14's Tier 1/2 validation codes, W120's composition codes, and the audit code", () => { expect([...STORY_GRAPH_REASON_CODES]).toEqual([ "not_a_choice_node", "unexpected_params", @@ -27,6 +27,12 @@ describe("STORY_GRAPH_REASON_MESSAGES", () => { "missing_label_key", "non_visible_variable_in_text", "invalid_transition_weight", + "include_not_module", + "exit_unmapped", + "binding_undeclared", + "binding_type_mismatch", + "attachment_node_not_choice", + "attachment_choice_collision", "unreachable_node", "unreachable_cycle", "no_reachable_choice", diff --git a/src/engine/src/kinds/story-graph/reasons.ts b/src/engine/src/kinds/story-graph/reasons.ts index 8c93713f..2bff2aa5 100644 --- a/src/engine/src/kinds/story-graph/reasons.ts +++ b/src/engine/src/kinds/story-graph/reasons.ts @@ -39,6 +39,13 @@ export const STORY_GRAPH_REASON_CODES = [ "missing_label_key", "non_visible_variable_in_text", "invalid_transition_weight", + // campaign composition (03 §1.1) — raised by `composeContent`, before validation + "include_not_module", + "exit_unmapped", + "binding_undeclared", + "binding_type_mismatch", + "attachment_node_not_choice", + "attachment_choice_collision", "unreachable_node", "unreachable_cycle", "no_reachable_choice", @@ -60,6 +67,12 @@ const STORY_GRAPH_REASON_TEXT: Readonly> = missing_label_key: "This campaign shows a stat with no label text.", non_visible_variable_in_text: "This campaign's text refers to a hidden or undeclared variable.", invalid_transition_weight: "This campaign's random event has an invalid or missing weight.", + include_not_module: "This campaign includes a campaign that can't be included.", + exit_unmapped: "This campaign includes a campaign without mapping each of its exits.", + binding_undeclared: "This campaign binds a variable that isn't declared for it.", + binding_type_mismatch: "This campaign binds two variables of different types.", + attachment_node_not_choice: "This campaign attaches a choice to a node that isn't a choice.", + attachment_choice_collision: "This campaign attaches a choice that already exists there.", unreachable_node: "This campaign has content the player can never reach.", unreachable_cycle: "This campaign has a loop with no way out.", no_reachable_choice: "This campaign never lets the player make a choice.", diff --git a/src/engine/src/kinds/story-graph/scene.ts b/src/engine/src/kinds/story-graph/scene.ts index cbea1e77..3d5d6b5a 100644 --- a/src/engine/src/kinds/story-graph/scene.ts +++ b/src/engine/src/kinds/story-graph/scene.ts @@ -11,7 +11,7 @@ import type { AvailableAction, KindContext, SceneBody } from "../../core/kernel/ import { resolveLocKey } from "../../core/localization/resolve.js"; import { requireNode } from "./nodes.js"; import { visibleVariables } from "./variables.js"; -import { interpolateText } from "./text.js"; +import { interpolateText, interpolationScope } from "./text.js"; import { evaluateStoryGraphCondition, toConditionContext } from "./conditions.js"; import type { StoryGraphCampaign } from "./campaign.js"; import type { StoryGraphKindState } from "./state.js"; @@ -61,5 +61,5 @@ export function scene(state: StoryGraphKindState, ctx: KindContext): SceneBody { } const visible = visibleVariables(content.variables, state.variables); - return { textKey: node.textKey, text: interpolateText(template, visible) }; + return { textKey: node.textKey, text: interpolateText(template, visible, interpolationScope(node)) }; } diff --git a/src/engine/src/kinds/story-graph/source.ts b/src/engine/src/kinds/story-graph/source.ts index fc2a61ef..df70a321 100644 --- a/src/engine/src/kinds/story-graph/source.ts +++ b/src/engine/src/kinds/story-graph/source.ts @@ -20,7 +20,7 @@ import type { AuthoredText } from "../../core/registry/types.js"; import type { Choice, Node, ChoiceNode, RandomNode, AutoNode, EndingNode } from "./nodes.js"; import type { VariableDecl, VariableSchema } from "./variables.js"; import type { AchievementDefinition } from "./achievements.js"; -import type { StoryGraphCampaign } from "./campaign.js"; +import type { ModuleInterface, StoryGraphCampaign } from "./campaign.js"; export type ChoiceSource = Omit & { label: AuthoredText; @@ -60,6 +60,7 @@ export interface StoryGraphCampaignSource { nodes: Record; startNodeId: string; achievements: AchievementDefinitionSource[]; + module?: ModuleInterface; } type Take = (text: AuthoredText) => LocKey; @@ -138,6 +139,7 @@ export function buildStoryGraphCampaign(source: StoryGraphCampaignSource): { nodes, startNodeId: source.startNodeId, achievements: source.achievements.map((a) => buildAchievement(a, take)), + ...(source.module !== undefined ? { module: source.module } : {}), }; return { content, authoredText }; diff --git a/src/engine/src/kinds/story-graph/text.ts b/src/engine/src/kinds/story-graph/text.ts index 460ce7c3..89d4b01d 100644 --- a/src/engine/src/kinds/story-graph/text.ts +++ b/src/engine/src/kinds/story-graph/text.ts @@ -7,8 +7,21 @@ * only visible variables ever reach here, so a template can never leak a hidden one. */ +import type { Node } from "./nodes.js"; import type { VarValue } from "./variables.js"; +/** + * The alias scope a node's text interpolates in (03 §1.1): `{x}` in node `a::n` reads + * `a::x`. A composed id's scope is everything through its last `::`; an include's entry + * node — an `auto` node whose `goto` enters `::` — renders the module's entry + * text, so its scope is its own id. An authored node's scope is empty. + */ +export function interpolationScope(node: Node): string { + if (node.kind === "auto" && node.goto.startsWith(`${node.id}::`)) return `${node.id}::`; + const at = node.id.lastIndexOf("::"); + return at === -1 ? "" : node.id.slice(0, at + 2); +} + /** * A fresh `RegExp` per call (never a shared module-level instance) — both * `interpolateText` and `placeholderNames` use the `g` flag, and a stateful shared @@ -21,19 +34,24 @@ function placeholderPattern(): RegExp { /** * Substitutes `{name}` in an already-resolved template string with the current value of - * a visible variable. A `{name}` for anything else — undeclared, or declared but not + * a visible variable, looked up within `scope` (`interpolationScope`). A `{name}` for anything else — undeclared, or declared but not * visible — throws: 03 §3.1 calls this a load-time error, which Tier 1 (W14, * `validate.ts`'s `nonVisibleVariableInText` check, built against `placeholderNames` * below) is meant to make unreachable in valid content; this is the runtime backstop * until then, the same `Object.hasOwn`-guarded pattern every other content-controlled * lookup in this kind uses. */ -export function interpolateText(template: string, visibleVariables: Readonly>): string { +export function interpolateText( + template: string, + visibleVariables: Readonly>, + scope = "", +): string { return template.replace(placeholderPattern(), (_match, name: string) => { - if (!Object.hasOwn(visibleVariables, name)) { + const scoped = scope + name; + if (!Object.hasOwn(visibleVariables, scoped)) { throw new Error(`story-graph text: "{${name}}" is not a visible declared variable`); } - return String(visibleVariables[name]); + return String(visibleVariables[scoped]); }); } diff --git a/src/engine/src/kinds/story-graph/validate.test.ts b/src/engine/src/kinds/story-graph/validate.test.ts index 82a531f2..16e7df4f 100644 --- a/src/engine/src/kinds/story-graph/validate.test.ts +++ b/src/engine/src/kinds/story-graph/validate.test.ts @@ -357,6 +357,50 @@ describe("validateCampaign — Tier 1: consequence type checking", () => { }); }); +describe("validateCampaign — Tier 1: copy (03 §5, W120.6)", () => { + const schema: VariableSchema = { + ...validSchema, + mood: { type: "enum", initial: "calm", values: ["calm", "angry", "tired"] }, + small_mood: { type: "enum", initial: "calm", values: ["calm", "angry"] }, + stamina: { type: "int", initial: 3 }, + }; + + function withCopy(effect: { op: "copy"; var: string; from: string }): StoryGraphCampaign { + const nodes: Record = { + ...validCampaign.nodes, + room_14: { id: "room_14", kind: "auto", textKey: "node.room_14.text", effects: [effect], goto: "reward" }, + }; + return { ...validCampaign, variables: schema, nodes }; + } + + function errorsOf(effect: { op: "copy"; var: string; from: string }): [string, string | undefined][] { + return validateCampaign(campaignEnvelope(withCopy(effect)), validStrings).errors.map((e) => [e.code, e.path]); + } + + it("accepts an assignable copy: same type, and an enum whose values are all the target's", () => { + expect(errorsOf({ op: "copy", var: "patience", from: "stamina" })).toEqual([]); + expect(errorsOf({ op: "copy", var: "mood", from: "small_mood" })).toEqual([]); + }); + + it("rejects an undeclared from with undeclared_variable", () => { + expect(errorsOf({ op: "copy", var: "patience", from: "ghost" })).toEqual([["undeclared_variable", "ghost"]]); + }); + + it("rejects a from of another type with invalid_consequence_value", () => { + expect(errorsOf({ op: "copy", var: "patience", from: "documents_collected" })).toEqual([ + ["invalid_consequence_value", "patience"], + ]); + }); + + it("rejects an enum from whose values are not all among the target's", () => { + expect(errorsOf({ op: "copy", var: "small_mood", from: "mood" })).toEqual([["invalid_consequence_value", "small_mood"]]); + }); + + it("rejects an undeclared var with undeclared_variable", () => { + expect(errorsOf({ op: "copy", var: "ghost", from: "stamina" })).toEqual([["undeclared_variable", "ghost"]]); + }); +}); + describe("validateCampaign — Tier 2: reachability", () => { it("warns on an unreachable node, without appearing in errors or blocking ok", () => { const nodes: Record = { diff --git a/src/engine/src/kinds/story-graph/validate.ts b/src/engine/src/kinds/story-graph/validate.ts index 8eb7c95a..b558c525 100644 --- a/src/engine/src/kinds/story-graph/validate.ts +++ b/src/engine/src/kinds/story-graph/validate.ts @@ -21,8 +21,9 @@ import type { ValidationError, ValidationResult, ValidationWarning } from "../.. import type { StoryGraphCampaign } from "./campaign.js"; import type { Node } from "./nodes.js"; import type { VariableSchema, Consequence } from "./variables.js"; +import { isAssignable } from "./variables.js"; import { validateConditionFields } from "./conditions.js"; -import { placeholderNames } from "./text.js"; +import { interpolationScope, placeholderNames } from "./text.js"; function error(code: string, path: string): ValidationError { return { code, messageKey: `story-graph.reason.${code}`, path }; @@ -32,6 +33,10 @@ function warning(code: string, path: string): ValidationWarning { return { code, messageKey: `story-graph.reason.${code}`, path }; } +function invalidIdentifier(path: string): ValidationError { + return { code: "invalid_identifier", messageKey: "core.reason.invalid_identifier", path }; +} + function requireLocKey(strings: ReadonlyMap, key: LocKey): ValidationError | undefined { if (strings.has(key)) return undefined; return { code: "missing_string_key", messageKey: "core.reason.missing_string_key", path: key }; @@ -95,6 +100,8 @@ function validateLocKeys(content: StoryGraphCampaign, strings: ReadonlyMap { if (key === undefined) return; + // LocKeys are never composed (04 §17): a module's keys stay its own, unprefixed. + if (key.includes(":")) errors.push(invalidIdentifier(key)); const e = requireLocKey(strings, key); if (e) errors.push(e); }; @@ -133,8 +140,10 @@ function validateTextInterpolation(content: StoryGraphCampaign, strings: Readonl for (const node of Object.values(content.nodes)) { const text = strings.get(node.textKey); if (text === undefined) continue; // already reported by validateLocKeys + // Module text reads its own alias scope (03 §1.1), exactly as `scene` renders it. + const scope = interpolationScope(node); for (const name of placeholderNames(text)) { - if (!visibleNames.has(name)) errors.push(error("non_visible_variable_in_text", name)); + if (!visibleNames.has(scope + name)) errors.push(error("non_visible_variable_in_text", scope + name)); } } @@ -182,6 +191,12 @@ function validateConsequenceValue(schema: VariableSchema, consequence: Consequen return Number.isInteger(consequence.by) ? undefined : error("invalid_consequence_value", consequence.var); } + if (consequence.op === "copy") { + // 03 §5: `from` is declared and assignable — the runtime check in `applyConsequences`. + if (!Object.hasOwn(schema, consequence.from)) return error("undeclared_variable", consequence.from); + return isAssignable(decl, schema[consequence.from]!) ? undefined : error("invalid_consequence_value", consequence.var); + } + switch (decl.type) { case "bool": return typeof consequence.value === "boolean" ? undefined : error("invalid_consequence_value", consequence.var); @@ -235,6 +250,50 @@ function validateAllConditions(content: StoryGraphCampaign, nodeIds: ReadonlySet return errors; } +/** + * A composed id (04 §17): `::`-joined, non-empty, colon-free segments, the first naming the + * include's entry — an `auto` node that enters `::`. Composition is the only thing + * that writes one; it rejects a `:` in the host's own ids before merging (`compose.ts`). + */ +function isComposedId(id: string, nodes: Record): boolean { + const segments = id.split("::"); + if (segments.length < 2 || segments.some((segment) => segment === "" || segment.includes(":"))) return false; + const first = segments[0]!; + const entry = Object.hasOwn(nodes, first) ? nodes[first]! : undefined; + return entry?.kind === "auto" && entry.goto.startsWith(`${first}::`); +} + +/** No authored id contains `:` (04 §17); node, variable and achievement ids may be composed. */ +function validateIdentifiers(content: StoryGraphCampaign): ValidationError[] { + const errors: ValidationError[] = []; + const check = (id: string): void => { + if (id.includes(":") && !isComposedId(id, content.nodes)) errors.push(invalidIdentifier(id)); + }; + for (const [id, node] of Object.entries(content.nodes)) { + check(id); + if (node.kind === "choice") { + for (const choice of node.choices) if (choice.id.includes(":")) errors.push(invalidIdentifier(choice.id)); + } + } + for (const name of Object.keys(content.variables)) check(name); + for (const achievement of content.achievements) check(achievement.id); + return errors; +} + +/** A `module` block names declared variables and a real entry node (03 §1.1). */ +function validateModuleInterface(content: StoryGraphCampaign, nodeIds: ReadonlySet): ValidationError[] { + const module = content.module; + if (!module) return []; + const errors: ValidationError[] = []; + for (const name of [...module.inputs, ...module.outputs]) { + if (!Object.hasOwn(content.variables, name)) errors.push(error("undeclared_variable", name)); + } + if (module.entryNodeId !== undefined && !nodeIds.has(module.entryNodeId)) { + errors.push(error("dangling_reference", module.entryNodeId)); + } + return errors; +} + // --------------------------------------------------------------------------- // Tier 2 // --------------------------------------------------------------------------- @@ -333,6 +392,8 @@ export function validateCampaign(campaign: Campaign, strings: ReadonlyMap { expect(() => visibleVariables(schema, {})).toThrow(); }); }); + +describe("applyConsequences — copy (03 §5, W120.6)", () => { + const copySchema: VariableSchema = { + money: { type: "int", initial: 2, min: 0, max: 3 }, + savings: { type: "int", initial: 9 }, + flag: { type: "bool", initial: true }, + mood: { type: "enum", initial: "neutral", values: ["neutral", "happy", "sad"] }, + small_mood: { type: "enum", initial: "happy", values: ["happy", "sad"] }, + }; + + it("writes var from another variable's current value", () => { + const { variables } = applyConsequences(copySchema, buildInitialVariables(copySchema), [ + { op: "copy", var: "mood", from: "small_mood" }, + ]); + expect(variables["mood"]).toBe("happy"); + }); + + it("reads from as earlier consequences in the same list left it", () => { + const { variables } = applyConsequences(copySchema, buildInitialVariables(copySchema), [ + { op: "increment", var: "savings", by: 1 }, + { op: "copy", var: "money", from: "savings" }, + ]); + // savings is 10 when copied; money clamps once, at the end, to its own max. + expect(variables["savings"]).toBe(10); + expect(variables["money"]).toBe(3); + }); + + it("reads from before the batch clamp, not after", () => { + const { variables } = applyConsequences(copySchema, buildInitialVariables(copySchema), [ + { op: "increment", var: "money", by: 5 }, + { op: "copy", var: "savings", from: "money" }, + ]); + expect(variables["money"]).toBe(3); + expect(variables["savings"]).toBe(7); + }); + + it("throws on an undeclared from", () => { + expect(() => + applyConsequences(copySchema, buildInitialVariables(copySchema), [{ op: "copy", var: "money", from: "nope" }]), + ).toThrow(); + }); + + it("throws on a from that is not assignable to var", () => { + const variables = buildInitialVariables(copySchema); + expect(() => applyConsequences(copySchema, variables, [{ op: "copy", var: "money", from: "flag" }])).toThrow(); + expect(() => applyConsequences(copySchema, variables, [{ op: "copy", var: "small_mood", from: "mood" }])).toThrow(); + }); + + it("records one StateChange for var, not for from", () => { + const { changes } = applyConsequences(copySchema, buildInitialVariables(copySchema), [ + { op: "copy", var: "mood", from: "small_mood" }, + ]); + expect(changes.map((c) => c.path)).toEqual(["var.mood"]); + }); +}); diff --git a/src/engine/src/kinds/story-graph/variables.ts b/src/engine/src/kinds/story-graph/variables.ts index 70383bfe..4da92893 100644 --- a/src/engine/src/kinds/story-graph/variables.ts +++ b/src/engine/src/kinds/story-graph/variables.ts @@ -52,7 +52,19 @@ export type VariableSchema = Record; export type Consequence = | { op: "set"; var: string; value: VarValue } | { op: "increment"; var: string; by: number } // int only - | { op: "decrement"; var: string; by: number }; // int only + | { op: "decrement"; var: string; by: number } // int only + | { op: "copy"; var: string; from: string }; // var ← from's current value (03 §5) + +/** + * Whether every value `from` can hold is one `to` accepts (03 §5, `copy`): the same + * `VarType`, and for an `enum`, every one of `from`'s values among `to`'s. An `int` is + * assignable regardless of range — the write clamps, it never rejects. + */ +export function isAssignable(to: VariableDecl, from: VariableDecl): boolean { + if (to.type !== from.type) return false; + if (to.type !== "enum") return true; + return (from.values ?? []).every((value) => to.values?.includes(value) ?? false); +} /** * Seeds the runtime `variables` map from a schema's declared `initial` values, walking @@ -156,6 +168,17 @@ export function applyConsequences( requireFiniteInt(`"${c.var}" decrement amount`, c.by); next[c.var] = (next[c.var] as number) - c.by; break; + case "copy": { + // Read as earlier consequences in this batch left it, before the clamp (03 §5). + const fromDecl = requireDecl(schema, c.from); + if (!isAssignable(decl, fromDecl)) { + throw new Error(`story-graph variables: "${c.from}" is not assignable to "${c.var}"`); + } + const value = next[c.from] as VarValue; + checkSetValue(c.var, decl, value); + next[c.var] = value; + break; + } } } From 095e1c763b2904953c9780c7b2923a59d80fc913 Mon Sep 17 00:00:00 2001 From: Ben Richards Date: Wed, 7 Oct 2026 22:59:16 +0300 Subject: [PATCH 2/2] =?UTF-8?q?W120=20=E2=80=94=20link=20the=20ledger=20st?= =?UTF-8?q?atus=20to=20PR=20#568?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- design/30-slices.md | 2 +- docs/docs/engine/TODO.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/design/30-slices.md b/design/30-slices.md index 2a653916..b8abe061 100644 --- a/design/30-slices.md +++ b/design/30-slices.md @@ -5316,7 +5316,7 @@ session, and the save format does not change. story-graph kind's campaign and source types, source builder, consequences, interpolation, validator and reason codes; their tests and synthetic fixtures; the regenerated human docs. - **Depends on:** none. -- **Status:** Done. +- **Status:** Done — [PR #568](https://github.com/The-Running-Dev/SubZeroDev.GameEngine/pull/568). - **Done when:** - W120.1 These types are declared as 04 §10.4, 11 §3a and 03 §1.1 state: - `CampaignRef`, `CampaignInclude`, `CampaignAttachment`, `ComposedModule` and diff --git a/docs/docs/engine/TODO.md b/docs/docs/engine/TODO.md index 22efc61f..1b7065cf 100644 --- a/docs/docs/engine/TODO.md +++ b/docs/docs/engine/TODO.md @@ -5311,7 +5311,7 @@ session, and the save format does not change. story-graph kind's campaign and source types, source builder, consequences, interpolation, validator and reason codes; their tests and synthetic fixtures; the regenerated human docs. - **Depends on:** none. -- **Status:** Done. +- **Status:** Done — [PR #568](https://github.com/The-Running-Dev/SubZeroDev.GameEngine/pull/568). - **Done when:** - W120.1 These types are declared as 04 §10.4, 11 §3a and 03 §1.1 state: - `CampaignRef`, `CampaignInclude`, `CampaignAttachment`, `ComposedModule` and