Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions design/20-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions design/30-slices.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 — [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
Expand Down
16 changes: 16 additions & 0 deletions design/90-decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 1 addition & 1 deletion design/TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,6 @@

### Depth: Campaign Composition

### [ ] W120 — One Campaign Can Include Another {#w120}
### [x] W120 — One Campaign Can Include Another {#w120}

## Landed
4 changes: 2 additions & 2 deletions docs/docs/engine/04-core.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions docs/docs/engine/TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 — [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
Expand Down
4 changes: 2 additions & 2 deletions docs/docs/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ sidebar_position: 1
sidebar_label: Developer Guide
---

<!-- design-digest: 2d57fa0f064f1dc036bff5fa5f994d51ba65e473d57a4f5e3f73fb9fdd9a76f3 -->
<!-- design-digest: 8824e3253442ab556eabc039f74225a7ecc342397c7781157575885660bc312a -->

> 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.
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions src/engine/src/authoring.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ const AUTHORING_TYPE_EXPORTS = [
"ItemDefinitionSource",
"JobDefinitionSource",
"LocationDefinitionSource",
"ModuleInterface",
"NPCDefinitionSource",
"NodeSource",
"OpportunityDefinitionSource",
Expand All @@ -91,8 +92,10 @@ const AUTHORING_TYPE_EXPORTS = [
"SimulationActionLabelKeysSource",
"SimulationCampaignSource",
"SkillDefinitionSource",
"StoryGraphAttachment",
"StoryGraphCampaign",
"StoryGraphCampaignSource",
"StoryGraphIncludeBinding",
"StoryGraphKindState",
"Submission",
"TraitDefinitionSource",
Expand Down
7 changes: 6 additions & 1 deletion src/engine/src/authoring.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
20 changes: 19 additions & 1 deletion src/engine/src/campaigns/stable-life-packs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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}$/);
Expand Down
14 changes: 12 additions & 2 deletions src/engine/src/campaigns/stable-life-packs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<LocKey, string>): string {
export function packVersion(
campaigns: readonly BuiltCampaign[],
strings: ReadonlyMap<LocKey, string>,
attachments: readonly CampaignAttachment[] = [],
): string {
const digest = sha256Hex(
canonicalStringify({
campaigns: campaigns.map(({ campaign }) => ({
Expand All @@ -60,10 +66,14 @@ function packVersion(campaigns: readonly BuiltCampaign[], strings: ReadonlyMap<L
version: campaign.version,
titleKey: campaign.titleKey,
content: campaign.content,
...(campaign.includes && campaign.includes.length > 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)}`;
Expand Down
21 changes: 21 additions & 0 deletions src/engine/src/core/kernel/reasons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -180,6 +194,13 @@ const CORE_REASON_TEXT: Readonly<Record<BaseReasonCode, string>> = {
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.<code>` → its shipped default-English message, for every base code. */
Expand Down
16 changes: 15 additions & 1 deletion src/engine/src/core/kernel/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -180,6 +180,20 @@ export interface Kind<KState> {
* 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<unknown>;
}

/**
Expand Down
Loading
Loading