diff --git a/design/03-story-graph-kind.md b/design/03-story-graph-kind.md index 5e2b809..e314d08 100644 --- a/design/03-story-graph-kind.md +++ b/design/03-story-graph-kind.md @@ -6,6 +6,8 @@ ## 1. The Campaign +### 1.1 Modules, Includes and Attachments + ## 2. Variable Schema — Fully Typed (N6) ## 3. Nodes — The Single Content Type (N7) diff --git a/design/04-core.md b/design/04-core.md index 1984ee6..5bd9431 100644 --- a/design/04-core.md +++ b/design/04-core.md @@ -72,6 +72,8 @@ ### 10.3 Why Not Event Sourcing +### 10.4 Campaign Composition + ## 11. Tiered Validation #### Which string table validation checks against diff --git a/design/10-design.md b/design/10-design.md index 6082673..d9536f1 100644 --- a/design/10-design.md +++ b/design/10-design.md @@ -3643,6 +3643,7 @@ interface ContentPack { readonly experimentGate?: ExperimentGate; // §5a — absent means always included readonly campaigns: readonly BuiltCampaign[]; // 04 §10.1 — already the built form readonly strings: ReadonlyMap; + readonly attachments?: readonly CampaignAttachment[]; // §3a — absent means none } interface PackRef { readonly id: string; readonly version: string; } @@ -3692,6 +3693,34 @@ it came from, which is the point. --- +## 3a. Attachments — Reaching Into a Campaign a Pack Does Not Own + +Whole-campaign replacement (§3) is the right rule for a campaign a pack *owns*. It cannot +express the third-party case: a pack that adds a side quest to someone else's campaign without +restating it. That is an **attachment** — a `CampaignAttachment` (04 §10.4) naming a host +campaign by id, an include of the module to compose in, and a kind-owned payload saying where +the host reaches it. For `story-graph` the payload appends one choice to one host choice node +(03 §1.1), and that is all an attachment can do: add a path, never edit, remove or reorder one. + +The fold does not apply attachments; it cannot, because it holds no kinds. It collects them, in +pack order and then declaration order, and hands them on with the campaign map: + +```typescript +interface ResolvedRegistry extends ContentRegistry { + /** Each folded campaign's version *before* §6 stamped it — what an include pins (04 §10.4). */ + readonly authoredVersions: ReadonlyMap; + readonly attachments: readonly CampaignAttachment[]; +} +``` + +`buildValidatedPackRegistry` (04 §11) composes from these, validates, and freezes a plain +`ContentRegistry`; neither field survives into it. An attachment applies to whichever campaign +holds its `hostCampaignId` **after** the fold — a later pack that replaces the host wholesale +still receives the earlier pack's attachment, and the composed result is validated as a unit, +so an attachment that no longer fits the replacement fails loudly rather than vanishing. + +--- + ## 4. The One Change to `ContentRegistry` ```typescript @@ -3830,7 +3859,10 @@ hex characters of its SHA-256) over the pack's `campaigns` (each `BuiltCampaign` `kindId`, `version`, `titleKey` and `content` — never `migrateState`, which is a function and `canonicalStringify` rejects those outright) and its `strings`, sorted by key rather than left in the map's insertion order, since the digest names what the pack ships and reordering an -authoring file must not change it. `1.0.0` stays as a literal prefix — semver build metadata +authoring file must not change it. A campaign's `includes` and the pack's `attachments` (§3a) +are digest input too, each **only when present and non-empty** — they change what plays, so a +pack that changes one must move its version, and omitting them when empty leaves every +existing pack's version exactly where it was. `1.0.0` stays as a literal prefix — semver build metadata for a human skimming a version string, never compared as a range (`PackRef` compares exactly). There is no engine-supplied function that computes this; each shipped pack (`stable-life- @@ -3844,7 +3876,8 @@ function. ## 7. Validation -Pack resolution adds four checks to the tiered validator (04 §11): +Pack resolution adds four checks to the tiered validator (04 §11), and attachments (§3a) add a +fifth that composition runs: | Tier | Check | |---|---| @@ -3852,6 +3885,7 @@ Pack resolution adds four checks to the tiered validator (04 §11): | 1 | No campaign id collides *within* one pack — across packs is an override, within one is an authoring error | | 1 | No pack writes a `core.reason.*` string key | | 2 | A pack overrides a campaign or string that no earlier pack supplied — legal, and almost always a typo | +| 1 | An attachment names a host campaign present after the fold (`attachment_host_missing`), and passes every include check 04 §10.4 lists — run at composition, after the fold, not inside it | The third row is the protected-namespace rule 04 §12 already applies at registry assembly, restated because a pack is a second way into the same string table. Without it, packs would be diff --git a/design/11-content-packs.md b/design/11-content-packs.md index d059d75..3e9b7ff 100644 --- a/design/11-content-packs.md +++ b/design/11-content-packs.md @@ -10,6 +10,8 @@ ## 3. Resolution +## 3a. Attachments — Reaching Into a Campaign a Pack Does Not Own + ## 4. The One Change to `ContentRegistry` ## 5. Dependencies diff --git a/design/20-contract.md b/design/20-contract.md index 582c306..6082d3c 100644 --- a/design/20-contract.md +++ b/design/20-contract.md @@ -107,8 +107,10 @@ defeat it (§9.1). **Identifiers.** Maintained by Core Specification §17 and the two id ports (06 §5.1, §5.7). - **C17.** Every published id is ASCII `[a-z0-9_-]` in the shape §17's table fixes for its - category, and is unique within its scope. *Enforced by code — Tier 1 checks both the character - set and uniqueness.* + category, and is unique within its scope. The one exception is a **composed** id (§17, + *Composed ids*): `::` joins an include alias to a module's own id, and only composition ever + writes it — an authored id or alias containing `:` is rejected. *Enforced by code — Tier 1 + checks both the character set and uniqueness, and runs on the composed campaign.* - **C18.** An id is stable once published; a rename is a migration, never an edit. *Enforced by instruction, with C12 as the backstop that makes a violation loud rather than silent.* - **C19.** `gameId` and `seed` are opaque to the core: never parsed, compared, ordered, or @@ -383,6 +385,20 @@ interface Kind { * declare a version without a fold, or a fold without a version. */ readonly profileData?: KindProfileData; + + /** + * Merges included modules and pack attachments into a host campaign's content (§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; } /** @@ -1817,6 +1833,10 @@ interface Campaign { titleKey: LocKey; content: unknown; // kind-specific — e.g. StoryGraphCampaign (03 §1) + /** Modules this campaign composes in at registry build (§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 `version` (§10.2) — a renamed node or achievement id. @@ -1991,6 +2011,90 @@ the log: you get event sourcing's benefits where they pay off — the determinis its cost, which is loads that break the moment the rules move. This hybrid is a choice, not a gap. +### 10.4 Campaign Composition + +Content crosses a campaign boundary **by composition at registry build, never by traversal** +(`90-decisions.md`, 2026-10-07, #293/#292). A host campaign *includes* another campaign as a +**module**; registry assembly merges the module's content into the host's before validation +runs, so at runtime there is one campaign, one session and one `GameState`. Nothing in the +envelope, the save format or any host changes. + +```typescript +/** A pin on a campaign's *authored* version — never a resolution id (11 §6). */ +interface CampaignRef { id: string; version: string; } + +interface CampaignInclude { + /** The prefix the module's ids take in the host (§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 (e.g. `StoryGraphIncludeBinding`, + * 03 §1.1). Opaque to the core, like `Campaign.content`. */ + binding: unknown; +} + +/** An include a content pack adds to a campaign it does not own (11 §3a). */ +interface CampaignAttachment { + hostCampaignId: string; + include: CampaignInclude; + /** Kind-specific — where in the host the module is reached from (e.g. + * `StoryGraphAttachment`, 03 §1.1). */ + payload: unknown; +} + +/** What `Kind.composeContent` (§3) receives per module: the include as declared, and the + * module campaign already composed — its own includes and attachments merged in. */ +interface ComposedModule { + include: CampaignInclude; + campaign: Campaign; +} + +interface ComposedAttachment { + payload: unknown; + module: ComposedModule; +} +``` + +**The split.** The core owns the envelope; the kind owns the merge. + +| Step | Owner | Fails with | +|---|---|---| +| The include's `ref.id` names a campaign in the same registry input | core | `include_missing` | +| `ref.version` equals that campaign's **authored** version | core | `include_version_mismatch` | +| The module's `kindId` equals the host's | core | `include_kind_mismatch` | +| No campaign includes itself, directly or transitively | core | `include_cycle` | +| `alias` is well-formed and unique among the host's includes and attachments | core | `invalid_identifier`, `include_alias_collision` | +| An attachment's `hostCampaignId` names a campaign in the registry input | core | `attachment_host_missing` | +| The host's kind implements `composeContent` | core | `compose_unsupported` | +| Bindings, entries, exits and attachment points are meaningful for this kind | kind | the kind's own codes (03 §8.3) | + +**The order is fixed, and determinism depends on it.** Campaigns compose in registry-input +order; a host's includes in declared order, then its attachments in pack order and, within one +pack, declaration order. Composition is **depth-first**: a module is composed — its own +includes and its own attachments — before any host that includes it, and each campaign is +composed once and reused. A module is therefore always included in its composed form, so a +pack attachment on a module reaches every host that includes it. There is no depth limit; the +cycle check is the only bound. + +**The pin is on the authored version.** `resolvePacks` stamps every folded campaign's `version` +with the resolution id (11 §4), so a pin checked against the stamped value could never match. +The fold keeps each campaign's authored version beside it (11 §3a), and the pin is checked +against that. In the unpacked path (`buildValidatedContentRegistry`) the two are the same value. + +**Composition precedes validation.** Every Tier 1 and Tier 2 check (§11) runs on the composed +campaign — the content play will actually see. A module stays an ordinary campaign: it is also +validated, and playable, standalone. The frozen registry holds the composed `content`; the host's +`includes` stay on it as provenance, and nothing reads them again. + +**Composed ids are published ids.** A module node `x` included under alias `a` is `a::x` in +`kindState`, in saves and in replay fixtures. Renaming an alias is therefore a rename under C18 — +a `Campaign.migrateState`, never an edit — and so is re-pinning a module whose own ids moved. + +> **Why not compose at runtime.** A target chosen at runtime needs call/return frames in +> `kindState`, which is a state-shape change and a save migration. The module interface fixed +> here — entry, exits, inputs, outputs — is exactly what a frame would call, so that remains the +> extension if it is ever needed (`90-decisions.md`, 2026-10-07). + --- ## 11. Tiered Validation @@ -2076,6 +2180,16 @@ and reattaches the fold's `resolution` id — neither stage alone can produce a `resolution`-stamped registry. It is exported from the package root alongside `buildValidatedContentRegistry`. +**Composition runs first, in both entry points** (§10.4). Each entry point composes every +campaign that declares an include or receives an attachment, and only then validates — so the +ordering above becomes *compose, validate, assemble*. A composition failure is a Tier 1 error +like any other: it is reported in the same `errors` list, and the registry is never built. A +composed host's own string table, for the per-campaign check above, is its built strings +**united with those of every module it composed**, transitively. A module's `LocKey`s stay +unprefixed (03 §1.1), so the host's content references them by their own keys. This is not the +silent cross-campaign dependency the note below rules out: the include declares it and pins its +version. + > **This is a clarification of scope, not a weakening.** Nothing here permits an unresolved > key into a frozen registry. The merged table is a superset of every per-campaign table plus > the core's and the kinds' own, so a key that resolves in the narrower table resolves in the @@ -2120,6 +2234,9 @@ const BASE_REASON_CODES = [ // content-pack resolution (11 §7) — `resolvePacks`, `registry/packs.ts` "pack_kind_mismatch", "duplicate_campaign_id_in_pack", "pack_dependency_missing", "pack_dependency_version_conflict", "pack_dependency_cycle", "pack_override_unexpected", + // campaign composition (§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; ``` @@ -2128,8 +2245,10 @@ const BASE_REASON_CODES = [ > cross-kind failure mode with no code that fitted — the kernel's three rejections, registry > assembly's three, the core's own Tier-1 four, the profile store's five, the save > boundary's two, host persistence's four, session lifecycle's one, the audit vocabulary's -> one, and content-pack resolution's six. That is the intended shape: a code is registered when a real caller -> produces it, not pre-declared from this list. Because `ReasonCode` is *additive, never +> 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 > 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 @@ -2301,6 +2420,7 @@ Concrete mapping — and the reconciliation this document forces on | `Kind.outcome` | 03 §8.5 — `terminalId` is the `endingId`; `terminal` is "settled onto an `EndingNode`" (§3.2) | | `Kind.terminalCount` | 03 §8.5 — distinct `endingId`s across the campaign's `EndingNode`s; the denominator in `CampaignProgress` (§7.3) | | `RngHandle.weightedPick` | random-transition node resolution (03 §3) | +| `Kind.composeContent` | 03 §1.1 — prefixed module content, a synthesized entry and exits, appended attachment choices. The only kind that implements it | > **Reconciliation (done in 03).** Writing this seam exposed that `03`'s state > duplicated envelope-owned fields — `version`, `campaignId`, `campaignVersion`, `seed`, @@ -2363,6 +2483,15 @@ Rules: ids are stable once published (a rename is a migration, §10.2); ids are `[a-z0-9_-]` only; `LocKey`s namespace by content type so string tables stay navigable. Tier-1 validation (§11) enforces the character set and uniqueness. +**Composed ids** (§10.4) are the one exception to the character set. When a host includes a +module under an alias, composition rewrites the module's kind-scoped ids — for story-graph its +node, variable and achievement ids (03 §1.1) — to `::`. Nested includes chain: +a module that itself includes `b` contributes `a::b::x` to its host. `:` appears in no other +id, and no authored id may contain it: an authored id, alias or `LocKey` with a `:` fails +`invalid_identifier` (Tier 1), so a composed id can never collide with an authored one. +Each segment of a composed id still has its category's shape. Campaign ids, `LocKey`s and +reason codes are never composed. + ## 18. Frozen Primitives Two shared primitives are held **deliberately small**, because these are the surfaces @@ -2611,6 +2740,8 @@ interface StoryGraphCampaign { startNodeId: string; achievements: AchievementDefinition[]; // §7 + + module?: ModuleInterface; // §1.1 — present iff this campaign may be included } ``` @@ -2623,6 +2754,75 @@ runtime. Load-time validation (§11) checks that `startNodeId` exists, every `goto` resolves, every variable referenced is declared, and every `LocKey` is present. +### 1.1 Modules, Includes and Attachments + +This kind implements `Kind.composeContent` (04 §3, §10.4). A story-graph campaign can be +included in another — a side quest, a shared errand, a third-party pack's detour — and stays +an ordinary, playable campaign while it is. + +```typescript +/** Opt-in: a campaign without it fails `include_not_module` when included. */ +interface ModuleInterface { + entryNodeId?: string; // where an include enters; default `startNodeId` + inputs: string[]; // declared variables a host may write on entry + outputs: string[]; // declared variables a host may read on exit +} + +/** `CampaignInclude.binding` (04 §10.4) for a story-graph module. */ +interface StoryGraphIncludeBinding { + exits: Record; // module endingId → host node id; every one mapped + inputs?: Record; // module input → host variable, copied in on entry + outputs?: Record; // module output → host variable, copied out on exit +} + +/** `CampaignAttachment.payload` (04 §10.4): a pack reaches the module through one new + * choice on a host choice node. Its `goto` is implied — the include's entry. */ +interface StoryGraphAttachment { + hostNodeId: string; + choice: Omit; +} +``` + +**A module's exits are its endings.** Each distinct `endingId` among the module's ending nodes +is a named exit, and the binding must map every one (`exit_unmapped`). There is no separate +exit list to drift from the endings that exist. + +**What composition writes into the host**, for an include under alias `a`: + +- **Ids.** Every module node, variable and achievement id `x` becomes `a::x` (04 §17, + *Composed ids*), and every reference inside the module is rewritten to match — `goto`s, + `Condition` fields (`var.`, `visited.`, `achieved.`), consequence targets. Module variables + join the host's `variables` with their declared types and initial values. +- **The entry.** One `auto` node, id `a` — the alias itself — whose effects are one `copy` + (§5) per bound input, host → module, and whose `goto` is `a::`. A host reaches + the module by `goto: "a"`; no authored reference ever contains `:`. The node's `textKey` is + the entry node's, and is never rendered, because an `auto` node is a pass-through. +- **The exits.** Each module ending node `a::e` becomes an `auto` node whose effects are one + `copy` per bound output, module → host, and whose `goto` is the host node its `endingId` + maps to. A host ending is a valid target. **A module never ends the host run on its own.** +- **Attachments.** Each attachment appends its choice, with `goto` set to the include's entry + node, to the end of the named host choice node's `choices`, in the order §10.4 fixes. The + node must be a `ChoiceNode` (`attachment_node_not_choice`), and the choice id must be new on + that node (`attachment_choice_collision`). + +Because the entry and every exit are pass-through nodes, entering and leaving a module each +cost one `turn` and count one visit (§8.2) — `visited.a` is the number of times the host +entered the module. The entry's `copy` runs on every entry, so re-entering a module re-reads +its inputs and leaves every unbound module variable as the last visit left it. + +**What composition does not touch.** `LocKey`s stay unprefixed and shared with the module's +standalone form; the host validates against the module's strings (04 §11). Interpolation in +module text resolves `{name}` within the node's own alias scope — `{x}` in node `a::n` reads +`a::x` — so module text needs no rewrite and cannot reach a host variable. The module's +`descriptionKey` and `startNodeId` are unused. `turn` is the host's: a module that reads it +reads the host's transition count. + +**Achievements are the host's.** A module achievement `a::x` unlocks into the host's +`unlockedAchievements` and is recorded against the host campaign (§7), not the module's +standalone campaign, because the session is in the host. A module whose content reads the +`ending` field cannot be included (`include_not_module`): a module's endings never become the +host's, so the read could never be what its author meant. + --- ## 2. Variable Schema — Fully Typed (N6) @@ -2765,7 +2965,8 @@ simulation kind's §10.4, carried over. 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 ``` Validation checks: `var` is declared; the op suits its type (`increment`/`decrement` @@ -2774,6 +2975,15 @@ to the variable's `min`/`max` after applying. Clamping happens once, after all o transition's consequences apply — the same rule as the simulation kind's needs (§3.3 there), so a `+5` then `-5` nets to zero rather than clipping. +**`copy`** writes one declared variable from another. Consequences apply in order, so `from` +is read as earlier consequences in the same list left it, before the clamp. `from` must be +declared (`undeclared_variable`) and assignable to `var` (`invalid_consequence_value`): the +same `VarType`, and for an `enum`, every one of `from`'s values among `var`'s. An `int` copy +clamps to `var`'s range like any other `int` write. It audits like any write — one coalesced +`consequence_applied` for `var` (§8.3); `from` is read, not touched. It exists for module +bindings (§1.1), which compose into exactly these, and is open to authors for the same job +inside one campaign. + > **Turn advance is automatic, not a consequence.** The **kind** increments the built-in > `turn` by 1 on every transition, including settle pass-throughs (§8.2). It cannot be > the core's job: `turn` lives inside `kindState`, which the core treats as opaque @@ -2973,11 +3183,24 @@ rule is the same one — every registered code owes a localized message (04 §12 | `non_visible_variable_in_text` | 1 | text interpolates a hidden or undeclared variable | | `invalid_transition_weight` | 1 | a `RandomTransition.weight` is not a positive integer, or a `random` node has no transitions | | `unknown_condition_field` | 1 | a `Condition` reads a field this kind does not define (04 §18) | +| `include_not_module` | 1 | an included campaign declares no `module` (§1.1), or its content reads the `ending` field | +| `exit_unmapped` | 1 | an include's `exits` misses one of the module's `endingId`s, or names one it does not have | +| `binding_undeclared` | 1 | a binding names a module variable not in `inputs`/`outputs`, or a host variable that is not declared | +| `binding_type_mismatch` | 1 | a bound pair is not assignable in the binding's direction (§5, `copy`) | +| `attachment_node_not_choice` | 1 | an attachment's `hostNodeId` names a node that is not a `ChoiceNode` | +| `attachment_choice_collision` | 1 | an attachment's choice id already exists on its host node | | `unreachable_node` | 2 | no path from `startNodeId` reaches it | | `unreachable_cycle` | 2 | a `choice`/`auto`/`random` cycle with no exit to a choice or ending | | `no_reachable_choice` | 2 | no `ChoiceNode` is reachable from the start — valid but non-interactive (04 §11) | | `no_reachable_ending` | 2 | no reachable ending | +The six composition codes, `include_not_module` through `attachment_choice_collision`, are +raised by `composeContent` (§1.1), not `validateCampaign` — composition runs first, and +fails the same Tier-1 way (04 §11). Everything else a composed campaign can get wrong is +found by the ordinary checks on the composed result, without a code of its own: an exit target +or attachment `hostNodeId` that names no host node is `dangling_reference`, an alias that +collides with a host node id is `duplicate_id`, and a composed id is checked like any other. + **An audit code — carried on a `StateChange`, reported to nobody in particular.** It is neither a rejection nor a validation finding, and it is registered here for the same single reason both halves above are: `StateChange.reason` is typed `ReasonCode` and `visible` gates @@ -3230,6 +3453,10 @@ Tiered as in the architecture §9. - Every `RandomTransition.weight` is a **positive integer**, and every `random` node has at least one transition — `weightedPick` throws otherwise (04 §8), so this is a load-time rule, not a runtime crash. +- Every `copy` reads a declared variable assignable to its target (§5). +- No authored id, alias or `LocKey` contains `:` (04 §17, *Composed ids*). +- A composed campaign passes every check above **as composed** (§1.1): composition runs first + and fails with its own six codes (§8.3) before any of these runs. **Tier 2 — load-time, warning:** @@ -3368,6 +3595,10 @@ What this exercises, one-to-one against the MVP Definition of Done: | §8.2 | `SETTLE_STEPS` guard default 64 | Profiling or a legitimately deep auto-chain | | §3 | Four node kinds (choice/random/auto/ending); `auto` is arguably a one-transition `random` | Simplification pass finds `auto` redundant | | §6/§8.2 | `visited` counts *every* entry (settle pass-throughs + start node), so it works on auto/random nodes | Authors want "times rested here" only | +| §1.1 | A module's exits are its distinct `endingId`s, not a declared list | A module needs two exits from one ending, or an exit that is not an ending | +| §1.1 | Module entry and exits are synthesized `auto` nodes, so each costs a `turn` and a visit | A campaign needs a module crossing to be invisible to `turn` | +| §1.1 | A module reading `ending` is not includable, rather than rewritten | A module needs its own "how did I leave" signal — an output variable already gives it | +| §1.1 | Attachments only append a choice; they cannot edit, remove or reorder host content | A pack needs to replace a host path, not add one | diff --git a/design/30-slices.md b/design/30-slices.md index fbb9582..e71fff9 100644 --- a/design/30-slices.md +++ b/design/30-slices.md @@ -5282,6 +5282,96 @@ progress. Every action I leave alone costs exactly what it costs today. - Content-owned costs. - Any change to a default number, and any balancing. +### Depth: Campaign Composition + +One unit. The 2026-10-07 entry for #293 and #292 decided that content crosses a campaign +boundary by composition at registry build. Its follow-up, #561, contracted the result. That +contract covers the core envelope ([04 §10.4](04-core.md#104-campaign-composition)), the +story-graph merge ([03 §1.1](03-story-graph-kind.md#11-modules-includes-and-attachments)) and +pack attachments ([11 §3a](11-content-packs.md#3a-attachments--reaching-into-a-campaign-a-pack-does-not-own)). +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} + +**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 +choice node in a campaign my pack does not own. Either way the player plays one campaign, in one +session, and the save format does not change. + +- **Spec:** [04 §3](04-core.md#3-the-kind-interface--the-seam), + [§10.4](04-core.md#104-campaign-composition), [§11](04-core.md#11-tiered-validation), + [§12](04-core.md#12-reason-codes-state-changes-messages), + [§17](04-core.md#17-identifier-conventions) and C17; + [03 §1.1](03-story-graph-kind.md#11-modules-includes-and-attachments), + [§5](03-story-graph-kind.md#5-consequences--typed-effects), + [§8.3](03-story-graph-kind.md#83-reason-codes) and + [§11](03-story-graph-kind.md#11-validation-story-graph-specific); + [11 §3a](11-content-packs.md#3a-attachments--reaching-into-a-campaign-a-pack-does-not-own), + [§6](11-content-packs.md#6-identity-and-why-determinism-needs-it) and + [§7](11-content-packs.md#7-validation); `90-decisions.md`, 2026-10-07 (both entries); + [#561](https://github.com/The-Running-Dev/SubZeroDev.GameEngine/issues/561). +- **Touches:** the `Kind` interface and the core reason-code registry; the campaign and pack + types, `resolvePacks` and the pack digest; both validated registry entry points; the + 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. +- **Done when:** + - W120.1 These types are declared as 04 §10.4, 11 §3a and 03 §1.1 state: + - `CampaignRef`, `CampaignInclude`, `CampaignAttachment`, `ComposedModule` and + `ComposedAttachment`; + - `Campaign.includes`, `ContentPack.attachments` and `ResolvedRegistry`; + - `Kind.composeContent`; + - `ModuleInterface`, `StoryGraphIncludeBinding` and `StoryGraphAttachment`. + + `resolvePacks` returns the authored version of each campaign beside the stamped one. + - W120.2 Both `buildValidatedContentRegistry` and `buildValidatedPackRegistry` compose before + they validate, in the order §10.4 fixes. Composition is depth-first, and each campaign is + composed once. A three-level chain composes to ids of the form `a::b::x`. Two hosts that + include the same module get the same composed module. + - W120.3 Each of the seven core codes is rejected by at least one fixture, with a path: + `include_missing`, `include_version_mismatch`, `include_kind_mismatch`, `include_cycle` + (one direct cycle and one transitive cycle), `include_alias_collision`, + `attachment_host_missing` and `compose_unsupported`. In the packed path, a pin that names + the authored version passes. A pin that names the stamped resolution id fails. + - W120.4 Each of the six story-graph codes is rejected by at least one fixture, with a path: + `include_not_module` (once with no `module` block, once for a module that reads + `ending`), `exit_unmapped`, `binding_undeclared`, `binding_type_mismatch`, + `attachment_node_not_choice` and `attachment_choice_collision`. + - W120.5 An authored id that contains `:` is still rejected with `invalid_identifier`. This + holds for a node, a variable, an achievement and an alias. A composed id is accepted. + - W120.6 The `copy` consequence writes one declared variable from another, reading `from` as + earlier consequences in the same list left it, as 03 §5 states. Tier 1 rejects an + undeclared `from` with `undeclared_variable`. It rejects a `from` that cannot be assigned + to `var` with `invalid_consequence_value`, and an `enum` whose values are not all among + the target's is one such case. + - W120.7 Play a host that includes a module through its entry, through the module, out of + one exit and on to a host ending. The run is deterministic on a repeat, and also across a + save/load cut taken inside the module: `serialize()` output matches the uncut run. Each + of the following is shown: + - the entry and the exit each cost one `turn`; + - `visited.` counts entries; + - a bound input is copied in on every entry; + - a bound output is copied out on exit; + - interpolation in module text reads the module's own variable; + - a module achievement unlocks into the host's `unlockedAchievements`. + - W120.8 A pack attachment appends one choice to a host choice node, after every authored + choice and in the order §10.4 fixes. The attached choice reaches the module. An + attachment on a module reaches every host that includes it. + - W120.9 A pack with no includes and no attachments keeps its committed `campaignVersion` + digest. Every committed campaign, fixture, replay oracle and golden file is unchanged. + A pack that adds an attachment gets a different digest. + - W120.10 Every new code is registered with a message, in `reasons.ts` and in the + story-graph kind's reason table. The pull request states how many rejecting cases and + how many passing cases the new tests contain. `npm run typecheck`, `npm run lint` and + `npm test` pass from `src/engine/`. +- **Out of scope:** + - Carrying `Campaign.includes` in the portable format, which is its own decision. + - Runtime call/return frames, and any change to `kindState`, the envelope or the save format. + - An attachment that does anything other than append a choice. + - `composeContent` for the simulation and world-graph kinds. + ## Landed A slice's full body lives under `## Outstanding` only until its issue closes; once closed, the diff --git a/design/90-decisions.md b/design/90-decisions.md index 7b1a599..9305278 100644 --- a/design/90-decisions.md +++ b/design/90-decisions.md @@ -2242,3 +2242,44 @@ Chosen: **Composition at registry build.** A host campaign *includes* another ca Contract work, in `design/` before any code: the `Kind` composition operation, the story-graph include and module declarations, and the reason codes composition raises. Rejected: **Not supported** (author side quests as nodes inside one campaign) — overruled by the owner; it leaves #292's third-party case without an answer. **Jump** — leaves the first campaign's variables and `visitedCounts` in a session now claiming to be the second. **Call/return frames** — a `StoryGraphKindState` shape change and a save migration; kept as the extension if a target must ever be chosen at runtime, and a module's entry/exit/inputs/outputs are exactly the interface a frame would call. **Linked sessions** — every progress, standings and achievement surface would have to learn session parents. **Profile carry-over** — fits sequels, not a side quest inside a run; it needs no decision to use. Reversibility: cheap now — no code or contract exists yet. Expensive once composed campaigns are published, because prefixed ids become published ids (C18). + +### 2026-10-07 — Campaign composition contract: includes, attachments and `copy` (#561) +Context: #561 asked for the contract the previous entry called for, before any code. Nine questions were open, and each was put to the owner one at a time. +Chosen: Contracted in `20-contract.md` (04 §3, §10.4, §11, §12, §17, C17; 03 §1, §1.1, §5, §8.3, §11, §13) and `10-design.md` (11 §2, §3a, §6, §7). Built as W120. +- **Attachment shape.** A pack attachment appends one choice to a host `ChoiceNode`. The choice's `goto` is implied, and is the include's entry. A choice id that already exists on the node is rejected (`attachment_choice_collision`). +- **Module surface.** A module opts in with an explicit `module` block (`entryNodeId?`, `inputs`, `outputs`). Its exits are its distinct `endingId`s, and an include must map every one. +- **Nesting.** A module may itself include modules. Prefixes chain (`a::b::x`), and composition is depth-first, with each campaign composed once. +- **Strings.** `LocKey`s stay unprefixed and shared with the module's standalone form. +- **Ownership.** The core owns the envelope (`includes`, refs, pins, cycles, aliases, attachment hosts) and seven codes. The kind owns the merge, through an optional `Kind.composeContent`, and six codes. Only story-graph implements it; any other kind fails `compose_unsupported`. +- **Ids.** C17 and 04 §17 are amended so that `:` may appear in composed ids only. An authored id, alias or `LocKey` containing it is still `invalid_identifier`. +- **Depth.** There is no depth limit. The cycle check (`include_cycle`) is the only bound. +- **Codes.** The thirteen named in 04 §12 and 03 §8.3, as listed. +- **Bindings.** A new `copy` consequence (`var ← from`) carries inputs in on entry and outputs out on exit. This replaces the previous entry's "as ordinary consequences", which `set`/`increment`/`decrement` cannot express, because none of them reads another variable. + +Drafting calls, made while writing the contract and not put to the owner: +- **The pin checks the authored version.** `resolvePacks` stamps every folded campaign's version with the resolution id, so the fold now returns `ResolvedRegistry.authoredVersions`, and pins check against it. +- **The entry is a synthesized `auto` node whose id is the alias.** No authored `goto` ever contains `:`. +- **Entry and exit each cost a `turn` and a visit**, as any `auto` node does, so `visited.` counts entries. +- **A module that reads `ending` cannot be included.** A module's endings never become the host's. +- **Interpolation is alias-scoped.** `{x}` in `a::n` reads `a::x`. +- **The pack digest gains `includes` and `attachments` only when they are non-empty**, so every existing pack keeps its `campaignVersion`. +- **Attachments apply to the host after the fold.** +- **Composition failures that already had codes reuse them** (`dangling_reference`, `duplicate_id`, `invalid_identifier`). + +Rejected, as offered. Four of the owner's answers overrode the recommended option, which is named in each case. +- **Attachments.** *Append or redirect*: the host author loses control of their own flow. *No pack attachments*: it leaves #292's third-party case unanswered. *General content patches*: validation cannot judge their intent. +- **Module surface.** *Any story-graph campaign*: every internal detail becomes accidental public interface. *Module block plus `standalone: false`*: an include-only flag can be added later if it is needed. +- **Nesting.** *Forbid in v1* (recommended, overridden): the owner chose to allow it now, and accepted that nested ids are permanent once they are published. +- **Strings.** *A prefixed copy per include*: locale fixes multiply by the number of includes. *Shared strings with per-include overrides*: it needs a scoped-strings lookup that the registry lacks. +- **Ownership.** *The kind owns everything*: every future kind would reimplement the version, cycle and depth checks. +- **Ids.** *Double underscore* (recommended, overridden): it stays inside C17, but `alias::name` was what the previous entry wrote down, and it is unambiguous. The owner accepted the cost to downstream hosts. *Structured scope, no prefix*: it changes `StoryGraphKindState` and needs a save migration. +- **Depth.** *A limit of 4* (recommended, overridden) and *a limit of 8*: any cap is arbitrary. The owner accepted unbounded id length and build cost for pathological pack sets. +- **Codes.** *Merge the binding codes*: the errors would be less precise. +- **Bindings.** *Rename at compose* (recommended, overridden): it collapses inputs and outputs into one list of parameters, each live in both directions. `copy` keeps the two directions exact, at the cost of a new consequence op that is open to all authored content. + +Open, not decided here: +- The portable format (v2) does not carry `Campaign.includes`. Carrying it is its own decision. +- `digestPortableCampaign` is unchanged. +- SubZeroDev.Adventures may validate id shapes that the `:` amendment now widens. + +Reversibility: cheap until W120 merges and a composed campaign is published. After that, composed ids are published ids (C18). diff --git a/design/TODO.md b/design/TODO.md index dd5b929..d17ac7e 100644 --- a/design/TODO.md +++ b/design/TODO.md @@ -312,4 +312,8 @@ ### [x] W119 — Every Fixed Action Price Is the Campaign's {#w119} +### Depth: Campaign Composition + +### [ ] W120 — One Campaign Can Include Another {#w120} + ## Landed diff --git a/docs/docs/engine/03-story-graph-kind.md b/docs/docs/engine/03-story-graph-kind.md index 70649fb..179ca9c 100644 --- a/docs/docs/engine/03-story-graph-kind.md +++ b/docs/docs/engine/03-story-graph-kind.md @@ -54,6 +54,8 @@ interface StoryGraphCampaign { startNodeId: string; achievements: AchievementDefinition[]; // §7 + + module?: ModuleInterface; // §1.1 — present iff this campaign may be included } ``` @@ -66,6 +68,75 @@ runtime. Load-time validation (§11) checks that `startNodeId` exists, every `goto` resolves, every variable referenced is declared, and every `LocKey` is present. +### 1.1 Modules, Includes and Attachments + +This kind implements `Kind.composeContent` (04 §3, §10.4). A story-graph campaign can be +included in another — a side quest, a shared errand, a third-party pack's detour — and stays +an ordinary, playable campaign while it is. + +```typescript +/** Opt-in: a campaign without it fails `include_not_module` when included. */ +interface ModuleInterface { + entryNodeId?: string; // where an include enters; default `startNodeId` + inputs: string[]; // declared variables a host may write on entry + outputs: string[]; // declared variables a host may read on exit +} + +/** `CampaignInclude.binding` (04 §10.4) for a story-graph module. */ +interface StoryGraphIncludeBinding { + exits: Record; // module endingId → host node id; every one mapped + inputs?: Record; // module input → host variable, copied in on entry + outputs?: Record; // module output → host variable, copied out on exit +} + +/** `CampaignAttachment.payload` (04 §10.4): a pack reaches the module through one new + * choice on a host choice node. Its `goto` is implied — the include's entry. */ +interface StoryGraphAttachment { + hostNodeId: string; + choice: Omit; +} +``` + +**A module's exits are its endings.** Each distinct `endingId` among the module's ending nodes +is a named exit, and the binding must map every one (`exit_unmapped`). There is no separate +exit list to drift from the endings that exist. + +**What composition writes into the host**, for an include under alias `a`: + +- **Ids.** Every module node, variable and achievement id `x` becomes `a::x` (04 §17, + *Composed ids*), and every reference inside the module is rewritten to match — `goto`s, + `Condition` fields (`var.`, `visited.`, `achieved.`), consequence targets. Module variables + join the host's `variables` with their declared types and initial values. +- **The entry.** One `auto` node, id `a` — the alias itself — whose effects are one `copy` + (§5) per bound input, host → module, and whose `goto` is `a::`. A host reaches + the module by `goto: "a"`; no authored reference ever contains `:`. The node's `textKey` is + the entry node's, and is never rendered, because an `auto` node is a pass-through. +- **The exits.** Each module ending node `a::e` becomes an `auto` node whose effects are one + `copy` per bound output, module → host, and whose `goto` is the host node its `endingId` + maps to. A host ending is a valid target. **A module never ends the host run on its own.** +- **Attachments.** Each attachment appends its choice, with `goto` set to the include's entry + node, to the end of the named host choice node's `choices`, in the order §10.4 fixes. The + node must be a `ChoiceNode` (`attachment_node_not_choice`), and the choice id must be new on + that node (`attachment_choice_collision`). + +Because the entry and every exit are pass-through nodes, entering and leaving a module each +cost one `turn` and count one visit (§8.2) — `visited.a` is the number of times the host +entered the module. The entry's `copy` runs on every entry, so re-entering a module re-reads +its inputs and leaves every unbound module variable as the last visit left it. + +**What composition does not touch.** `LocKey`s stay unprefixed and shared with the module's +standalone form; the host validates against the module's strings (04 §11). Interpolation in +module text resolves `{name}` within the node's own alias scope — `{x}` in node `a::n` reads +`a::x` — so module text needs no rewrite and cannot reach a host variable. The module's +`descriptionKey` and `startNodeId` are unused. `turn` is the host's: a module that reads it +reads the host's transition count. + +**Achievements are the host's.** A module achievement `a::x` unlocks into the host's +`unlockedAchievements` and is recorded against the host campaign (§7), not the module's +standalone campaign, because the session is in the host. A module whose content reads the +`ending` field cannot be included (`include_not_module`): a module's endings never become the +host's, so the read could never be what its author meant. + --- ## 2. Variable Schema — Fully Typed (N6) @@ -208,7 +279,8 @@ simulation kind's §10.4, carried over. 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 ``` Validation checks: `var` is declared; the op suits its type (`increment`/`decrement` @@ -217,6 +289,15 @@ to the variable's `min`/`max` after applying. Clamping happens once, after all o transition's consequences apply — the same rule as the simulation kind's needs (§3.3 there), so a `+5` then `-5` nets to zero rather than clipping. +**`copy`** writes one declared variable from another. Consequences apply in order, so `from` +is read as earlier consequences in the same list left it, before the clamp. `from` must be +declared (`undeclared_variable`) and assignable to `var` (`invalid_consequence_value`): the +same `VarType`, and for an `enum`, every one of `from`'s values among `var`'s. An `int` copy +clamps to `var`'s range like any other `int` write. It audits like any write — one coalesced +`consequence_applied` for `var` (§8.3); `from` is read, not touched. It exists for module +bindings (§1.1), which compose into exactly these, and is open to authors for the same job +inside one campaign. + > **Turn advance is automatic, not a consequence.** The **kind** increments the built-in > `turn` by 1 on every transition, including settle pass-throughs (§8.2). It cannot be > the core's job: `turn` lives inside `kindState`, which the core treats as opaque @@ -416,11 +497,24 @@ rule is the same one — every registered code owes a localized message (04 §12 | `non_visible_variable_in_text` | 1 | text interpolates a hidden or undeclared variable | | `invalid_transition_weight` | 1 | a `RandomTransition.weight` is not a positive integer, or a `random` node has no transitions | | `unknown_condition_field` | 1 | a `Condition` reads a field this kind does not define (04 §18) | +| `include_not_module` | 1 | an included campaign declares no `module` (§1.1), or its content reads the `ending` field | +| `exit_unmapped` | 1 | an include's `exits` misses one of the module's `endingId`s, or names one it does not have | +| `binding_undeclared` | 1 | a binding names a module variable not in `inputs`/`outputs`, or a host variable that is not declared | +| `binding_type_mismatch` | 1 | a bound pair is not assignable in the binding's direction (§5, `copy`) | +| `attachment_node_not_choice` | 1 | an attachment's `hostNodeId` names a node that is not a `ChoiceNode` | +| `attachment_choice_collision` | 1 | an attachment's choice id already exists on its host node | | `unreachable_node` | 2 | no path from `startNodeId` reaches it | | `unreachable_cycle` | 2 | a `choice`/`auto`/`random` cycle with no exit to a choice or ending | | `no_reachable_choice` | 2 | no `ChoiceNode` is reachable from the start — valid but non-interactive (04 §11) | | `no_reachable_ending` | 2 | no reachable ending | +The six composition codes, `include_not_module` through `attachment_choice_collision`, are +raised by `composeContent` (§1.1), not `validateCampaign` — composition runs first, and +fails the same Tier-1 way (04 §11). Everything else a composed campaign can get wrong is +found by the ordinary checks on the composed result, without a code of its own: an exit target +or attachment `hostNodeId` that names no host node is `dangling_reference`, an alias that +collides with a host node id is `duplicate_id`, and a composed id is checked like any other. + **An audit code — carried on a `StateChange`, reported to nobody in particular.** It is neither a rejection nor a validation finding, and it is registered here for the same single reason both halves above are: `StateChange.reason` is typed `ReasonCode` and `visible` gates @@ -673,6 +767,10 @@ Tiered as in the architecture §9. - Every `RandomTransition.weight` is a **positive integer**, and every `random` node has at least one transition — `weightedPick` throws otherwise (04 §8), so this is a load-time rule, not a runtime crash. +- Every `copy` reads a declared variable assignable to its target (§5). +- No authored id, alias or `LocKey` contains `:` (04 §17, *Composed ids*). +- A composed campaign passes every check above **as composed** (§1.1): composition runs first + and fails with its own six codes (§8.3) before any of these runs. **Tier 2 — load-time, warning:** @@ -811,3 +909,7 @@ What this exercises, one-to-one against the MVP Definition of Done: | §8.2 | `SETTLE_STEPS` guard default 64 | Profiling or a legitimately deep auto-chain | | §3 | Four node kinds (choice/random/auto/ending); `auto` is arguably a one-transition `random` | Simplification pass finds `auto` redundant | | §6/§8.2 | `visited` counts *every* entry (settle pass-throughs + start node), so it works on auto/random nodes | Authors want "times rested here" only | +| §1.1 | A module's exits are its distinct `endingId`s, not a declared list | A module needs two exits from one ending, or an exit that is not an ending | +| §1.1 | Module entry and exits are synthesized `auto` nodes, so each costs a `turn` and a visit | A campaign needs a module crossing to be invisible to `turn` | +| §1.1 | A module reading `ending` is not includable, rather than rewritten | A module needs its own "how did I leave" signal — an output variable already gives it | +| §1.1 | Attachments only append a choice; they cannot edit, remove or reorder host content | A pack needs to replace a host path, not add one | diff --git a/docs/docs/engine/04-core.md b/docs/docs/engine/04-core.md index 7d5ed35..b09413d 100644 --- a/docs/docs/engine/04-core.md +++ b/docs/docs/engine/04-core.md @@ -247,6 +247,20 @@ interface Kind { * declare a version without a fold, or a fold without a version. */ readonly profileData?: KindProfileData; + + /** + * Merges included modules and pack attachments into a host campaign's content (§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; } /** @@ -1681,6 +1695,10 @@ interface Campaign { titleKey: LocKey; content: unknown; // kind-specific — e.g. StoryGraphCampaign (03 §1) + /** Modules this campaign composes in at registry build (§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 `version` (§10.2) — a renamed node or achievement id. @@ -1855,6 +1873,90 @@ the log: you get event sourcing's benefits where they pay off — the determinis its cost, which is loads that break the moment the rules move. This hybrid is a choice, not a gap. +### 10.4 Campaign Composition + +Content crosses a campaign boundary **by composition at registry build, never by traversal** +(`90-decisions.md`, 2026-10-07, #293/#292). A host campaign *includes* another campaign as a +**module**; registry assembly merges the module's content into the host's before validation +runs, so at runtime there is one campaign, one session and one `GameState`. Nothing in the +envelope, the save format or any host changes. + +```typescript +/** A pin on a campaign's *authored* version — never a resolution id (11 §6). */ +interface CampaignRef { id: string; version: string; } + +interface CampaignInclude { + /** The prefix the module's ids take in the host (§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 (e.g. `StoryGraphIncludeBinding`, + * 03 §1.1). Opaque to the core, like `Campaign.content`. */ + binding: unknown; +} + +/** An include a content pack adds to a campaign it does not own (11 §3a). */ +interface CampaignAttachment { + hostCampaignId: string; + include: CampaignInclude; + /** Kind-specific — where in the host the module is reached from (e.g. + * `StoryGraphAttachment`, 03 §1.1). */ + payload: unknown; +} + +/** What `Kind.composeContent` (§3) receives per module: the include as declared, and the + * module campaign already composed — its own includes and attachments merged in. */ +interface ComposedModule { + include: CampaignInclude; + campaign: Campaign; +} + +interface ComposedAttachment { + payload: unknown; + module: ComposedModule; +} +``` + +**The split.** The core owns the envelope; the kind owns the merge. + +| Step | Owner | Fails with | +|---|---|---| +| The include's `ref.id` names a campaign in the same registry input | core | `include_missing` | +| `ref.version` equals that campaign's **authored** version | core | `include_version_mismatch` | +| The module's `kindId` equals the host's | core | `include_kind_mismatch` | +| No campaign includes itself, directly or transitively | core | `include_cycle` | +| `alias` is well-formed and unique among the host's includes and attachments | core | `invalid_identifier`, `include_alias_collision` | +| An attachment's `hostCampaignId` names a campaign in the registry input | core | `attachment_host_missing` | +| The host's kind implements `composeContent` | core | `compose_unsupported` | +| Bindings, entries, exits and attachment points are meaningful for this kind | kind | the kind's own codes (03 §8.3) | + +**The order is fixed, and determinism depends on it.** Campaigns compose in registry-input +order; a host's includes in declared order, then its attachments in pack order and, within one +pack, declaration order. Composition is **depth-first**: a module is composed — its own +includes and its own attachments — before any host that includes it, and each campaign is +composed once and reused. A module is therefore always included in its composed form, so a +pack attachment on a module reaches every host that includes it. There is no depth limit; the +cycle check is the only bound. + +**The pin is on the authored version.** `resolvePacks` stamps every folded campaign's `version` +with the resolution id (11 §4), so a pin checked against the stamped value could never match. +The fold keeps each campaign's authored version beside it (11 §3a), and the pin is checked +against that. In the unpacked path (`buildValidatedContentRegistry`) the two are the same value. + +**Composition precedes validation.** Every Tier 1 and Tier 2 check (§11) runs on the composed +campaign — the content play will actually see. A module stays an ordinary campaign: it is also +validated, and playable, standalone. The frozen registry holds the composed `content`; the host's +`includes` stay on it as provenance, and nothing reads them again. + +**Composed ids are published ids.** A module node `x` included under alias `a` is `a::x` in +`kindState`, in saves and in replay fixtures. Renaming an alias is therefore a rename under C18 — +a `Campaign.migrateState`, never an edit — and so is re-pinning a module whose own ids moved. + +> **Why not compose at runtime.** A target chosen at runtime needs call/return frames in +> `kindState`, which is a state-shape change and a save migration. The module interface fixed +> here — entry, exits, inputs, outputs — is exactly what a frame would call, so that remains the +> extension if it is ever needed (`90-decisions.md`, 2026-10-07). + --- ## 11. Tiered Validation @@ -1940,6 +2042,16 @@ and reattaches the fold's `resolution` id — neither stage alone can produce a `resolution`-stamped registry. It is exported from the package root alongside `buildValidatedContentRegistry`. +**Composition runs first, in both entry points** (§10.4). Each entry point composes every +campaign that declares an include or receives an attachment, and only then validates — so the +ordering above becomes *compose, validate, assemble*. A composition failure is a Tier 1 error +like any other: it is reported in the same `errors` list, and the registry is never built. A +composed host's own string table, for the per-campaign check above, is its built strings +**united with those of every module it composed**, transitively. A module's `LocKey`s stay +unprefixed (03 §1.1), so the host's content references them by their own keys. This is not the +silent cross-campaign dependency the note below rules out: the include declares it and pins its +version. + > **This is a clarification of scope, not a weakening.** Nothing here permits an unresolved > key into a frozen registry. The merged table is a superset of every per-campaign table plus > the core's and the kinds' own, so a key that resolves in the narrower table resolves in the @@ -1984,6 +2096,9 @@ const BASE_REASON_CODES = [ // content-pack resolution (11 §7) — `resolvePacks`, `registry/packs.ts` "pack_kind_mismatch", "duplicate_campaign_id_in_pack", "pack_dependency_missing", "pack_dependency_version_conflict", "pack_dependency_cycle", "pack_override_unexpected", + // campaign composition (§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; ``` @@ -1992,8 +2107,10 @@ const BASE_REASON_CODES = [ > cross-kind failure mode with no code that fitted — the kernel's three rejections, registry > assembly's three, the core's own Tier-1 four, the profile store's five, the save > boundary's two, host persistence's four, session lifecycle's one, the audit vocabulary's -> one, and content-pack resolution's six. That is the intended shape: a code is registered when a real caller -> produces it, not pre-declared from this list. Because `ReasonCode` is *additive, never +> 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 > 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 @@ -2165,6 +2282,7 @@ Concrete mapping — and the reconciliation this document forces on | `Kind.outcome` | 03 §8.5 — `terminalId` is the `endingId`; `terminal` is "settled onto an `EndingNode`" (§3.2) | | `Kind.terminalCount` | 03 §8.5 — distinct `endingId`s across the campaign's `EndingNode`s; the denominator in `CampaignProgress` (§7.3) | | `RngHandle.weightedPick` | random-transition node resolution (03 §3) | +| `Kind.composeContent` | 03 §1.1 — prefixed module content, a synthesized entry and exits, appended attachment choices. The only kind that implements it | > **Reconciliation (done in 03).** Writing this seam exposed that `03`'s state > duplicated envelope-owned fields — `version`, `campaignId`, `campaignVersion`, `seed`, @@ -2227,6 +2345,15 @@ Rules: ids are stable once published (a rename is a migration, §10.2); ids are `[a-z0-9_-]` only; `LocKey`s namespace by content type so string tables stay navigable. Tier-1 validation (§11) enforces the character set and uniqueness. +**Composed ids** (§10.4) are the one exception to the character set. When a host includes a +module under an alias, composition rewrites the module's kind-scoped ids — for story-graph its +node, variable and achievement ids (03 §1.1) — to `::`. Nested includes chain: +a module that itself includes `b` contributes `a::b::x` to its host. `:` appears in no other +id, and no authored id may contain it: an authored id, alias or `LocKey` with a `:` fails +`invalid_identifier` (Tier 1), so a composed id can never collide with an authored one. +Each segment of a composed id still has its category's shape. Campaign ids, `LocKey`s and +reason codes are never composed. + ## 18. Frozen Primitives Two shared primitives are held **deliberately small**, because these are the surfaces diff --git a/docs/docs/engine/11-content-packs.md b/docs/docs/engine/11-content-packs.md index c0d84bf..bc583d7 100644 --- a/docs/docs/engine/11-content-packs.md +++ b/docs/docs/engine/11-content-packs.md @@ -51,6 +51,7 @@ interface ContentPack { readonly experimentGate?: ExperimentGate; // §5a — absent means always included readonly campaigns: readonly BuiltCampaign[]; // 04 §10.1 — already the built form readonly strings: ReadonlyMap; + readonly attachments?: readonly CampaignAttachment[]; // §3a — absent means none } interface PackRef { readonly id: string; readonly version: string; } @@ -100,6 +101,34 @@ it came from, which is the point. --- +## 3a. Attachments — Reaching Into a Campaign a Pack Does Not Own + +Whole-campaign replacement (§3) is the right rule for a campaign a pack *owns*. It cannot +express the third-party case: a pack that adds a side quest to someone else's campaign without +restating it. That is an **attachment** — a `CampaignAttachment` (04 §10.4) naming a host +campaign by id, an include of the module to compose in, and a kind-owned payload saying where +the host reaches it. For `story-graph` the payload appends one choice to one host choice node +(03 §1.1), and that is all an attachment can do: add a path, never edit, remove or reorder one. + +The fold does not apply attachments; it cannot, because it holds no kinds. It collects them, in +pack order and then declaration order, and hands them on with the campaign map: + +```typescript +interface ResolvedRegistry extends ContentRegistry { + /** Each folded campaign's version *before* §6 stamped it — what an include pins (04 §10.4). */ + readonly authoredVersions: ReadonlyMap; + readonly attachments: readonly CampaignAttachment[]; +} +``` + +`buildValidatedPackRegistry` (04 §11) composes from these, validates, and freezes a plain +`ContentRegistry`; neither field survives into it. An attachment applies to whichever campaign +holds its `hostCampaignId` **after** the fold — a later pack that replaces the host wholesale +still receives the earlier pack's attachment, and the composed result is validated as a unit, +so an attachment that no longer fits the replacement fails loudly rather than vanishing. + +--- + ## 4. The One Change to `ContentRegistry` ```typescript @@ -238,7 +267,10 @@ hex characters of its SHA-256) over the pack's `campaigns` (each `BuiltCampaign` `kindId`, `version`, `titleKey` and `content` — never `migrateState`, which is a function and `canonicalStringify` rejects those outright) and its `strings`, sorted by key rather than left in the map's insertion order, since the digest names what the pack ships and reordering an -authoring file must not change it. `1.0.0` stays as a literal prefix — semver build metadata +authoring file must not change it. A campaign's `includes` and the pack's `attachments` (§3a) +are digest input too, each **only when present and non-empty** — they change what plays, so a +pack that changes one must move its version, and omitting them when empty leaves every +existing pack's version exactly where it was. `1.0.0` stays as a literal prefix — semver build metadata for a human skimming a version string, never compared as a range (`PackRef` compares exactly). There is no engine-supplied function that computes this; each shipped pack (`stable-life- @@ -252,7 +284,8 @@ function. ## 7. Validation -Pack resolution adds four checks to the tiered validator (04 §11): +Pack resolution adds four checks to the tiered validator (04 §11), and attachments (§3a) add a +fifth that composition runs: | Tier | Check | |---|---| @@ -260,6 +293,7 @@ Pack resolution adds four checks to the tiered validator (04 §11): | 1 | No campaign id collides *within* one pack — across packs is an override, within one is an authoring error | | 1 | No pack writes a `core.reason.*` string key | | 2 | A pack overrides a campaign or string that no earlier pack supplied — legal, and almost always a typo | +| 1 | An attachment names a host campaign present after the fold (`attachment_host_missing`), and passes every include check 04 §10.4 lists — run at composition, after the fold, not inside it | The third row is the protected-namespace rule 04 §12 already applies at registry assembly, restated because a pack is a second way into the same string table. Without it, packs would be diff --git a/docs/docs/engine/TODO.md b/docs/docs/engine/TODO.md index cfb9029..e621495 100644 --- a/docs/docs/engine/TODO.md +++ b/docs/docs/engine/TODO.md @@ -5277,6 +5277,96 @@ progress. Every action I leave alone costs exactly what it costs today. - Content-owned costs. - Any change to a default number, and any balancing. +### Depth: Campaign Composition + +One unit. The 2026-10-07 entry for #293 and #292 decided that content crosses a campaign +boundary by composition at registry build. Its follow-up, #561, contracted the result. That +contract covers the core envelope ([04 §10.4](04-core.md#104-campaign-composition)), the +story-graph merge ([03 §1.1](03-story-graph-kind.md#11-modules-includes-and-attachments)) and +pack attachments ([11 §3a](11-content-packs.md#3a-attachments--reaching-into-a-campaign-a-pack-does-not-own)). +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} + +**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 +choice node in a campaign my pack does not own. Either way the player plays one campaign, in one +session, and the save format does not change. + +- **Spec:** [04 §3](04-core.md#3-the-kind-interface--the-seam), + [§10.4](04-core.md#104-campaign-composition), [§11](04-core.md#11-tiered-validation), + [§12](04-core.md#12-reason-codes-state-changes-messages), + [§17](04-core.md#17-identifier-conventions) and C17; + [03 §1.1](03-story-graph-kind.md#11-modules-includes-and-attachments), + [§5](03-story-graph-kind.md#5-consequences--typed-effects), + [§8.3](03-story-graph-kind.md#83-reason-codes) and + [§11](03-story-graph-kind.md#11-validation-story-graph-specific); + [11 §3a](11-content-packs.md#3a-attachments--reaching-into-a-campaign-a-pack-does-not-own), + [§6](11-content-packs.md#6-identity-and-why-determinism-needs-it) and + [§7](11-content-packs.md#7-validation); `90-decisions.md`, 2026-10-07 (both entries); + [#561](https://github.com/The-Running-Dev/SubZeroDev.GameEngine/issues/561). +- **Touches:** the `Kind` interface and the core reason-code registry; the campaign and pack + types, `resolvePacks` and the pack digest; both validated registry entry points; the + 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. +- **Done when:** + - W120.1 These types are declared as 04 §10.4, 11 §3a and 03 §1.1 state: + - `CampaignRef`, `CampaignInclude`, `CampaignAttachment`, `ComposedModule` and + `ComposedAttachment`; + - `Campaign.includes`, `ContentPack.attachments` and `ResolvedRegistry`; + - `Kind.composeContent`; + - `ModuleInterface`, `StoryGraphIncludeBinding` and `StoryGraphAttachment`. + + `resolvePacks` returns the authored version of each campaign beside the stamped one. + - W120.2 Both `buildValidatedContentRegistry` and `buildValidatedPackRegistry` compose before + they validate, in the order §10.4 fixes. Composition is depth-first, and each campaign is + composed once. A three-level chain composes to ids of the form `a::b::x`. Two hosts that + include the same module get the same composed module. + - W120.3 Each of the seven core codes is rejected by at least one fixture, with a path: + `include_missing`, `include_version_mismatch`, `include_kind_mismatch`, `include_cycle` + (one direct cycle and one transitive cycle), `include_alias_collision`, + `attachment_host_missing` and `compose_unsupported`. In the packed path, a pin that names + the authored version passes. A pin that names the stamped resolution id fails. + - W120.4 Each of the six story-graph codes is rejected by at least one fixture, with a path: + `include_not_module` (once with no `module` block, once for a module that reads + `ending`), `exit_unmapped`, `binding_undeclared`, `binding_type_mismatch`, + `attachment_node_not_choice` and `attachment_choice_collision`. + - W120.5 An authored id that contains `:` is still rejected with `invalid_identifier`. This + holds for a node, a variable, an achievement and an alias. A composed id is accepted. + - W120.6 The `copy` consequence writes one declared variable from another, reading `from` as + earlier consequences in the same list left it, as 03 §5 states. Tier 1 rejects an + undeclared `from` with `undeclared_variable`. It rejects a `from` that cannot be assigned + to `var` with `invalid_consequence_value`, and an `enum` whose values are not all among + the target's is one such case. + - W120.7 Play a host that includes a module through its entry, through the module, out of + one exit and on to a host ending. The run is deterministic on a repeat, and also across a + save/load cut taken inside the module: `serialize()` output matches the uncut run. Each + of the following is shown: + - the entry and the exit each cost one `turn`; + - `visited.` counts entries; + - a bound input is copied in on every entry; + - a bound output is copied out on exit; + - interpolation in module text reads the module's own variable; + - a module achievement unlocks into the host's `unlockedAchievements`. + - W120.8 A pack attachment appends one choice to a host choice node, after every authored + choice and in the order §10.4 fixes. The attached choice reaches the module. An + attachment on a module reaches every host that includes it. + - W120.9 A pack with no includes and no attachments keeps its committed `campaignVersion` + digest. Every committed campaign, fixture, replay oracle and golden file is unchanged. + A pack that adds an attachment gets a different digest. + - W120.10 Every new code is registered with a message, in `reasons.ts` and in the + story-graph kind's reason table. The pull request states how many rejecting cases and + how many passing cases the new tests contain. `npm run typecheck`, `npm run lint` and + `npm test` pass from `src/engine/`. +- **Out of scope:** + - Carrying `Campaign.includes` in the portable format, which is its own decision. + - Runtime call/return frames, and any change to `kindState`, the envelope or the save format. + - An attachment that does anything other than append a choice. + - `composeContent` for the simulation and world-graph kinds. + ## Landed A slice's full body lives under `## Outstanding` only until its issue closes; once closed, the diff --git a/docs/docs/guide.md b/docs/docs/guide.md index 9af3828..e312981 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. @@ -676,6 +676,24 @@ 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 +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 +runs, so play still sees one campaign in one session. + +- **Opting in.** A module opts in with a `module` block: an optional entry node, plus the + variables a host may write (`inputs`) and read (`outputs`). +- **Ids.** Module ids are prefixed `alias::id` in state and saves, and prefixes chain through + nested includes. They are published ids, so renaming an alias is a migration. +- **Wiring.** Every module ending is an exit that the include maps to a host node. Bound + variables cross on the entry and exit edges through the `copy` consequence. +- **Strings.** Localization keys stay shared and unprefixed. + +A content pack can also attach a module to a campaign it does not own, by appending one choice to +a host choice node. See [Core](/docs/engine/core) §10.4 and +[Story-graph kind](/docs/engine/story-graph-kind) §1.1. + The MVP's worked example — the Bulgaria Bureaucracy arc — exercises every part of this at once: typed variables and clamping, a requirement-gated retry with a real reason, a self-referential loop gated on a visit count, a seeded random transition, and a single achievement that fires