From a1560d4d75ffae0e948c0341be3d19ae4c924a90 Mon Sep 17 00:00:00 2001 From: Sorra the Orc <250240+SorraTheOrc@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:38:25 +0100 Subject: [PATCH 01/39] MS-0MV8BFOVB000HM0W: sync reference pack fixture to 54-column piggybackable schema Main Street's card-data.csv gained a trailing piggybackable column (piggyback-eligibility flag, epic MS-0MUYC47EL005Z69H). The card-pack merge requires pack fragments to reuse the base header exactly, so the reference main-street-foundations pack fixture (and its README column count) is synced to the new 54-column schema. All rows stay unflagged (not organiser events). --- tests/fixtures/reference-packs/main-street/README.md | 2 +- .../main-street/main-street-foundations/cards.csv | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/fixtures/reference-packs/main-street/README.md b/tests/fixtures/reference-packs/main-street/README.md index db77fa29b..3510d7e36 100644 --- a/tests/fixtures/reference-packs/main-street/README.md +++ b/tests/fixtures/reference-packs/main-street/README.md @@ -14,7 +14,7 @@ card-art assets: | File | Purpose | |------|---------| | `manifest.json` | The pack-channel manifest (`{ version, packs[] }`) declaring the pack and its relative files. | -| `main-street/main-street-foundations/cards.csv` | The pack's CSV fragment in Main Street's `card-data.csv` schema (53 columns). | +| `main-street/main-street-foundations/cards.csv` | The pack's CSV fragment in Main Street's `card-data.csv` schema (54 columns). | | `main-street/main-street-foundations/assets/*.png` | Real (non-symlink) card art copied into the built pack. | The directory is laid out like the on-disk target diff --git a/tests/fixtures/reference-packs/main-street/main-street/main-street-foundations/cards.csv b/tests/fixtures/reference-packs/main-street/main-street/main-street-foundations/cards.csv index 70731526e..5eff42e8c 100644 --- a/tests/fixtures/reference-packs/main-street/main-street/main-street-foundations/cards.csv +++ b/tests/fixtures/reference-packs/main-street/main-street/main-street-foundations/cards.csv @@ -1,4 +1,4 @@ -family,id,name,cost,baseIncome,synergyTypes,upgradePath,maxLevel,reputationPerTurn,synergyCoinBonus,synergyRepBonus,description,tier,trigger,effect,target,targetSynergy,coinDelta,reputationDelta,duration,effectType,multiplier,targetBusiness,incomeBonus,synergyRangeBonus,requiredLevel,reputationBonus,newDisplayName,ongoingCost,handSlotsAdded,refreshCostDiscount,actionsPerTurn,peekOncePerTurn,upgradeCostDiscount,purchaseCostDiscount,art_notes,hasChoices,acceptNextCardId,rejectNextCardId,availableWeekStart,availableWeekEnd,allowedBusinessTypes,coinPercentDelta,taxAuditRate,storylineId,storylineTitle,unitTestStatus,unitTestFailReason,browserTestStatus,browserTestFailReason,marketRelevanceBias,freeMarketRerollPerTurn,drawWeight -business,biz-ms-foundations-teahouse,Foundations Tea House,300,161,Food,Foundations Tea House,2,20,,,A welcoming place to plan the next block. Gains {SYNERGY_RATE} of base income per adjacent Food business.,1,,,,,,,,,,,,,,,,75,,,,,,,"A porcelain teapot pouring amber tea into a small cup, steam curling upward.",,,,,,,,,,,pass,,pass,,,, -event,evt-ms-foundations-fair,Founders' Day Fair,300,,,,,,,,A street fair celebrates the neighbourhood. Grants +200 coins to all Culture businesses and +100 reputation.,1,Investment,+200 coins to all Culture businesses and +100 reputation.,SpecificSynergy,Culture,200,100,,,,,,,,,,,,,,,,,A wide banner reading Founders' Day strung between two lampposts.,,,,18,35,,,,,,pass,,pass,,,, -upgrade,upg-ms-foundations-teahouse,Upgrade to Tea House,300,,,,,,,,"Turns a Food business into a Tea House, increasing income and synergy range.",1,,,,,,,,,,Food,100,1,0,,Tea House,,,,,,,,A porcelain teapot with a painted blue willow pattern.,,,,,,,,,,,pass,,pass,,,, +family,id,name,cost,baseIncome,synergyTypes,upgradePath,maxLevel,reputationPerTurn,synergyCoinBonus,synergyRepBonus,description,tier,trigger,effect,target,targetSynergy,coinDelta,reputationDelta,duration,effectType,multiplier,targetBusiness,incomeBonus,synergyRangeBonus,requiredLevel,reputationBonus,newDisplayName,ongoingCost,handSlotsAdded,refreshCostDiscount,actionsPerTurn,peekOncePerTurn,upgradeCostDiscount,purchaseCostDiscount,art_notes,hasChoices,acceptNextCardId,rejectNextCardId,availableWeekStart,availableWeekEnd,allowedBusinessTypes,coinPercentDelta,taxAuditRate,storylineId,storylineTitle,unitTestStatus,unitTestFailReason,browserTestStatus,browserTestFailReason,marketRelevanceBias,freeMarketRerollPerTurn,drawWeight,piggybackable +business,biz-ms-foundations-teahouse,Foundations Tea House,300,161,Food,Foundations Tea House,2,20,,,A welcoming place to plan the next block. Gains {SYNERGY_RATE} of base income per adjacent Food business.,1,,,,,,,,,,,,,,,,75,,,,,,,"A porcelain teapot pouring amber tea into a small cup, steam curling upward.",,,,,,,,,,,pass,,pass,,,,, +event,evt-ms-foundations-fair,Founders' Day Fair,300,,,,,,,,A street fair celebrates the neighbourhood. Grants +200 coins to all Culture businesses and +100 reputation.,1,Investment,+200 coins to all Culture businesses and +100 reputation.,SpecificSynergy,Culture,200,100,,,,,,,,,,,,,,,,,A wide banner reading Founders' Day strung between two lampposts.,,,,18,35,,,,,,pass,,pass,,,,, +upgrade,upg-ms-foundations-teahouse,Upgrade to Tea House,300,,,,,,,,"Turns a Food business into a Tea House, increasing income and synergy range.",1,,,,,,,,,,Food,100,1,0,,Tea House,,,,,,,,A porcelain teapot with a painted blue willow pattern.,,,,,,,,,,,pass,,pass,,,,, From dc4ec0ced6a4389c38477531b3940ce914c8fd84 Mon Sep 17 00:00:00 2001 From: Sorra the Orc <250240+SorraTheOrc@users.noreply.github.com> Date: Thu, 15 Oct 2026 11:59:32 +0100 Subject: [PATCH 02/39] MS-0MV8KQQQ90044CM5: sync reference pack fixture to 55-column options schema --- .../main-street/main-street-foundations/cards.csv | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/fixtures/reference-packs/main-street/main-street/main-street-foundations/cards.csv b/tests/fixtures/reference-packs/main-street/main-street/main-street-foundations/cards.csv index 5eff42e8c..52e6adb3d 100644 --- a/tests/fixtures/reference-packs/main-street/main-street/main-street-foundations/cards.csv +++ b/tests/fixtures/reference-packs/main-street/main-street/main-street-foundations/cards.csv @@ -1,4 +1,4 @@ -family,id,name,cost,baseIncome,synergyTypes,upgradePath,maxLevel,reputationPerTurn,synergyCoinBonus,synergyRepBonus,description,tier,trigger,effect,target,targetSynergy,coinDelta,reputationDelta,duration,effectType,multiplier,targetBusiness,incomeBonus,synergyRangeBonus,requiredLevel,reputationBonus,newDisplayName,ongoingCost,handSlotsAdded,refreshCostDiscount,actionsPerTurn,peekOncePerTurn,upgradeCostDiscount,purchaseCostDiscount,art_notes,hasChoices,acceptNextCardId,rejectNextCardId,availableWeekStart,availableWeekEnd,allowedBusinessTypes,coinPercentDelta,taxAuditRate,storylineId,storylineTitle,unitTestStatus,unitTestFailReason,browserTestStatus,browserTestFailReason,marketRelevanceBias,freeMarketRerollPerTurn,drawWeight,piggybackable -business,biz-ms-foundations-teahouse,Foundations Tea House,300,161,Food,Foundations Tea House,2,20,,,A welcoming place to plan the next block. Gains {SYNERGY_RATE} of base income per adjacent Food business.,1,,,,,,,,,,,,,,,,75,,,,,,,"A porcelain teapot pouring amber tea into a small cup, steam curling upward.",,,,,,,,,,,pass,,pass,,,,, -event,evt-ms-foundations-fair,Founders' Day Fair,300,,,,,,,,A street fair celebrates the neighbourhood. Grants +200 coins to all Culture businesses and +100 reputation.,1,Investment,+200 coins to all Culture businesses and +100 reputation.,SpecificSynergy,Culture,200,100,,,,,,,,,,,,,,,,,A wide banner reading Founders' Day strung between two lampposts.,,,,18,35,,,,,,pass,,pass,,,,, -upgrade,upg-ms-foundations-teahouse,Upgrade to Tea House,300,,,,,,,,"Turns a Food business into a Tea House, increasing income and synergy range.",1,,,,,,,,,,Food,100,1,0,,Tea House,,,,,,,,A porcelain teapot with a painted blue willow pattern.,,,,,,,,,,,pass,,pass,,,,, +family,id,name,cost,baseIncome,synergyTypes,upgradePath,maxLevel,reputationPerTurn,synergyCoinBonus,synergyRepBonus,description,tier,trigger,effect,target,targetSynergy,coinDelta,reputationDelta,duration,effectType,multiplier,targetBusiness,incomeBonus,synergyRangeBonus,requiredLevel,reputationBonus,newDisplayName,ongoingCost,handSlotsAdded,refreshCostDiscount,actionsPerTurn,peekOncePerTurn,upgradeCostDiscount,purchaseCostDiscount,art_notes,hasChoices,acceptNextCardId,rejectNextCardId,options,availableWeekStart,availableWeekEnd,allowedBusinessTypes,coinPercentDelta,taxAuditRate,storylineId,storylineTitle,unitTestStatus,unitTestFailReason,browserTestStatus,browserTestFailReason,marketRelevanceBias,freeMarketRerollPerTurn,drawWeight,piggybackable +business,biz-ms-foundations-teahouse,Foundations Tea House,300,161,Food,Foundations Tea House,2,20,,,A welcoming place to plan the next block. Gains {SYNERGY_RATE} of base income per adjacent Food business.,1,,,,,,,,,,,,,,,,75,,,,,,,"A porcelain teapot pouring amber tea into a small cup, steam curling upward.",,,,,,,,,,,,pass,,pass,,,,, +event,evt-ms-foundations-fair,Founders' Day Fair,300,,,,,,,,A street fair celebrates the neighbourhood. Grants +200 coins to all Culture businesses and +100 reputation.,1,Investment,+200 coins to all Culture businesses and +100 reputation.,SpecificSynergy,Culture,200,100,,,,,,,,,,,,,,,,,A wide banner reading Founders' Day strung between two lampposts.,,,,,18,35,,,,,,pass,,pass,,,,, +upgrade,upg-ms-foundations-teahouse,Upgrade to Tea House,300,,,,,,,,"Turns a Food business into a Tea House, increasing income and synergy range.",1,,,,,,,,,,Food,100,1,0,,Tea House,,,,,,,,A porcelain teapot with a painted blue willow pattern.,,,,,,,,,,,,pass,,pass,,,,, From a1de87170efc07ed02fd35427c74973b0516f79b Mon Sep 17 00:00:00 2001 From: Sorra the Orc Date: Sun, 18 Oct 2026 00:32:10 +0100 Subject: [PATCH 03/39] CG-0MVD0IZDR008921Q: engine-sync script + scaffold engine:sync/verify wiring (MS-0MVCRGKZY007DIED) Adds core/scripts/engine-sync.mjs (sync/verify subcommands with dirty-state guard and pin-mismatch failure paths) + .d.mts types, and wires the game-repo scaffold template (renderPackageJson) with engine:sync / engine:verify / build:release npm scripts so re-scaffolded game repos carry the dev-sync / release-pin build split. Lists the new scripts modules in the repo partition layout so extracted core repos typecheck. Engine tests: new tests/scripts/engine-sync.test.ts (17 tests) + scaffold test coverage for the generated scripts. --- scripts/configs/repo-layout.json | 2 + scripts/engine-sync.d.mts | 95 +++++++ scripts/engine-sync.mjs | 332 +++++++++++++++++++++++ scripts/game-repo-scaffold.ts | 13 +- tests/scripts/engine-sync.test.ts | 286 +++++++++++++++++++ tests/scripts/game-repo-scaffold.test.ts | 20 ++ 6 files changed, 746 insertions(+), 2 deletions(-) create mode 100644 scripts/engine-sync.d.mts create mode 100644 scripts/engine-sync.mjs create mode 100644 tests/scripts/engine-sync.test.ts diff --git a/scripts/configs/repo-layout.json b/scripts/configs/repo-layout.json index d2cf09e30..473ccc44c 100644 --- a/scripts/configs/repo-layout.json +++ b/scripts/configs/repo-layout.json @@ -28,6 +28,8 @@ "scripts/build-game-artifact.d.mts", "scripts/build-card-pack.mjs", "scripts/build-card-pack.d.mts", + "scripts/engine-sync.d.mts", + "scripts/engine-sync.mjs", "scripts/check-browser-test-env.ts", "scripts/codemod-src-imports.ts", "scripts/game-repo-scaffold.ts", diff --git a/scripts/engine-sync.d.mts b/scripts/engine-sync.d.mts new file mode 100644 index 000000000..45aed77a3 --- /dev/null +++ b/scripts/engine-sync.d.mts @@ -0,0 +1,95 @@ +/** + * Type declarations for the engine submodule sync/verify script + * (`scripts/engine-sync.mjs`, MS-0MVCRGKZY007DIED). + * + * The script is plain ESM so it runs with a stock `node` (no build step); + * these declarations give its programmatic API types for the TypeScript + * tests (game-repo `tests/…/engine-sync.test.ts` and engine + * `tests/scripts/engine-sync.test.ts`). + */ + +/** The engine pin file contract: a single `sha` field. */ +export interface EnginePin { + sha: string; +} + +/** Standard git command result. */ +export interface GitResult { + ok: boolean; + code: number; + stdout: string; + stderr: string; +} + +/** Result of `syncEngine`. */ +export interface SyncResult { + updated: boolean; + from: string; + to: string; + warning?: string; +} + +/** Result of `verifyEngine`. */ +export interface VerifyResult { + sha: string; +} + +export declare class EngineSyncError extends Error {} + +/** Submodule path relative to the game repo root (from .gitmodules). */ +export declare const DEFAULT_CORE_REL: string; +/** Engine pin file relative to the game repo root. */ +export declare const DEFAULT_PIN_REL: string; + +/** Run a git command from a given cwd. */ +export declare function runGit( + cwd: string, + args: string[], + env?: Record, +): GitResult; + +/** Absolute submodule directory for a game repo root. */ +export declare function corePath(repoRoot: string, coreRel?: string): string; + +/** True when the submodule working tree has uncommitted changes. */ +export declare function submoduleIsDirty(repoRoot: string, coreRel?: string): boolean; + +/** Ensure the `core` submodule is initialised (throws on failure). */ +export declare function ensureSubmoduleInit(repoRoot: string, coreRel?: string): void; + +/** The branch the `core` submodule tracks ('' = remote HEAD default). */ +export declare function resolveTrackedBranch(repoRoot: string, coreRel?: string): string; + +/** The full SHA of the remote-resolved target of the tracked branch. */ +export declare function resolveRemoteTarget(repoRoot: string, coreRel?: string): string; + +/** Read and validate the engine pin file; returns its `sha`. */ +export declare function readPin(repoRoot: string, pinRel?: string): string; + +/** Expand a (possibly short) SHA to a full 40-hex SHA (or null). */ +export declare function resolveShaFull( + repoRoot: string, + coreRel: string, + sha: string, +): string | null; + +/** Dev sync: guard, then follow the latest engine remote head. */ +export declare function syncEngine(opts: { + repoRoot: string; + coreRel?: string; + log?: (line: string) => void; +}): SyncResult; + +/** Release verify: guard, pin check, exact checkout. */ +export declare function verifyEngine(opts: { + repoRoot: string; + coreRel?: string; + pinRel?: string; + log?: (line: string) => void; +}): VerifyResult; + +/** CLI entry. Returns the process exit code. */ +export declare function main( + argv: string[], + io?: { stdout: { write(s: string): void }; stderr: { write(s: string): void } }, +): number; diff --git a/scripts/engine-sync.mjs b/scripts/engine-sync.mjs new file mode 100644 index 000000000..f8907d687 --- /dev/null +++ b/scripts/engine-sync.mjs @@ -0,0 +1,332 @@ +#!/usr/bin/env node +/** + * Engine submodule sync/verify for a single-game TCE launcher + * (MS-0MVCRGKZY007DIED). + * + * Game repos compose the Tableau-Card-Engine checkout as a git submodule at + * `./core`. Two build-time policies keep that checkout coherent: + * + * - **sync** (dev builds, `npm run engine:sync`): always compose the LATEST + * engine — refuse loudly when `./core` has uncommitted changes (never + * clobber), then run `git submodule update --remote core` so the tracked + * branch's remote tip is fetched and checked out. When the remote is + * unreachable (network failure) the build falls back to the current + * checkout with a warning instead of failing (risk note: offline builds + * keep working). + * - **verify** (release builds, `npm run engine:verify`): pin the engine to a + * committed SHA — refuse loudly when `./core` has local modifications, read + * `configs/engine-pin.json` (a single `sha` field), confirm the pinned SHA + * equals the remote-resolved target (the tip of the branch `core` tracks), + * then check out exactly that SHA. Release builds require the remote to be + * reachable so the pin can be compared; a mismatch fails the build. + * + * The npm scripts wrap this script: + * + * "engine:sync": "node scripts/engine-sync.mjs sync" + * "engine:verify": "node scripts/engine-sync.mjs verify" + * + * This file is the canonical implementation in the engine repo; the game-repo + * scaffold (`scripts/game-repo-scaffold.ts`) copies it into each game repo as + * `scripts/engine-sync.mjs`, so edit it here and re-scaffold — never edit the + * generated copy by hand. + * + * Subcommands: + * node engine-sync.mjs sync|verify [--core-rel ] [--pin ] + */ + +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; + +/** Submodule path relative to the game repo root (from .gitmodules). */ +export const DEFAULT_CORE_REL = 'core'; + +/** Engine pin file relative to the game repo root. */ +export const DEFAULT_PIN_REL = path.join('configs', 'engine-pin.json'); + +/** Error type for deterministic failure messages. */ +export class EngineSyncError extends Error {} + +/** + * Run a git command from a given cwd. + * @returns {{ ok: boolean, code: number, stdout: string, stderr: string }} + */ +export function runGit(cwd, args, env = {}) { + const res = spawnSync('git', args, { + cwd, + encoding: 'utf8', + env: { ...process.env, ...env }, + windowsHide: true, + }); + const code = res.status ?? 1; + return { + ok: code === 0, + code, + stdout: (res.stdout ?? '').trim(), + stderr: (res.stderr ?? '').trim(), + }; +} + +/** Absolute submodule directory for a game repo root. */ +export function corePath(repoRoot, coreRel = DEFAULT_CORE_REL) { + return path.join(repoRoot, coreRel); +} + +/** + * True when the submodule working tree has uncommitted changes (tracked + * modifications, staged files, or untracked files that would block a + * checkout). `git -C status --porcelain` is the guard: any output means + * dirty. A submodule that is not (yet) a git repo returns true so sync/verify + * never attempt to clobber it. + */ +export function submoduleIsDirty(repoRoot, coreRel = DEFAULT_CORE_REL) { + const sub = corePath(repoRoot, coreRel); + // Not a git repo at all → cannot prove it is safe to update (sync/verify + // will fail loudly via ensureSubmoduleInit when it is uninitialised). + if (!fs.existsSync(path.join(sub, '.git'))) return true; + const res = runGit(repoRoot, ['-C', coreRel, 'status', '--porcelain', '--untracked-files=all']); + return !res.ok || res.stdout.length > 0; +} + +/** + * Ensure the submodule is present and registered. Returns an array of extra + * git args to pass (none here), or throws a loud failure when the submodule + * cannot be initialised. + */ +export function ensureSubmoduleInit(repoRoot, coreRel = DEFAULT_CORE_REL) { + const sub = corePath(repoRoot, coreRel); + if (!fs.existsSync(path.join(sub, '.git'))) { + // Initialise from .gitmodules (never depends on the remote yet). + const res = runGit(repoRoot, ['submodule', 'update', '--init', '--recursive', coreRel]); + if (!res.ok) { + throw new EngineSyncError( + `${coreRel} submodule is not initialised and could not be initialised:\n` + + `${res.stderr}\n` + + `Run 'git submodule update --init --recursive' and retry.`, + ); + } + } +} + +/** + * The branch the `core` submodule tracks (for `--remote` resolution): + * `submodule.core.branch` from `.gitmodules` / local config, falling back to + * the remote's default branch (`HEAD`). + */ +export function resolveTrackedBranch(repoRoot, coreRel = DEFAULT_CORE_REL) { + for (const args of [ + ['config', '-f', path.join(repoRoot, '.gitmodules'), '--get', 'submodule.core.branch'], + ['config', '--get', 'submodule.core.branch'], + ]) { + const res = runGit(repoRoot, args); + if (res.ok && res.stdout) return res.stdout.trim(); + } + // Default: remote HEAD (the default branch of the submodule's remote). + return ''; +} + +/** + * Resolve the remote target: the full SHA that `git submodule update + * --remote core` would check out — the tip of the branch the submodule + * tracks (or the remote's default branch when no branch is configured). + */ +export function resolveRemoteTarget(repoRoot, coreRel = DEFAULT_CORE_REL) { + const gitmodules = path.join(repoRoot, '.gitmodules'); + const branch = resolveTrackedBranch(repoRoot, coreRel); + for (const args of [ + ['config', '-f', gitmodules, '--get', 'submodule.core.url'], + ['config', '--get', 'submodule.core.url'], + ]) { + const res = runGit(repoRoot, args); + if (res.ok && res.stdout) { + const url = res.stdout.trim(); + const ref = branch ? `refs/heads/${branch}` : 'HEAD'; + const ls = runGit(repoRoot, ['ls-remote', url, ref]); + if (!ls.ok) { + throw new EngineSyncError( + `engine:verify: cannot resolve remote target for ${coreRel} (${url} ${ref}):\n${ls.stderr}\n` + + `Release builds need network access so the pin can be checked.`, + ); + } + const sha = ls.stdout.split(/\s+/)[0]; + if (!/^[0-9a-f]{40}$/i.test(sha)) { + throw new EngineSyncError( + `engine:verify: unexpected ls-remote output for ${url} ${ref}:\n${ls.stdout}`, + ); + } + return sha; + } + } + throw new EngineSyncError( + `engine:verify: no remote URL configured for the ${coreRel} submodule. ` + + `Check .gitmodules.`, + ); +} + +/** + * Read the engine pin file (default `configs/engine-pin.json`) and return its + * `sha` field. Throws EngineSyncError with a loud, actionable message when the + * file is missing, malformed, or has no sha. + */ +export function readPin(repoRoot, pinRel = DEFAULT_PIN_REL) { + const pinPath = path.join(repoRoot, pinRel); + let raw; + try { + raw = fs.readFileSync(pinPath, 'utf-8'); + } catch { + throw new EngineSyncError( + `engine:verify: engine pin file not found: ${pinRel}\n` + + `Release builds compile against a committed engine SHA. Create ` + + `${pinRel} with a single "sha" field (e.g. {"sha": "${'0'.repeat(40)}"}); ` + + `the release process writes it alongside the version bump.`, + ); + } + let data; + try { + data = JSON.parse(raw); + } catch (err) { + throw new EngineSyncError( + `engine:verify: engine pin file ${pinRel} is not valid JSON: ${String(err)}`, + ); + } + const sha = data && typeof data === 'object' && 'sha' in data ? String(data.sha) : ''; + if (!/^[0-9a-f]{7,40}$/i.test(sha)) { + throw new EngineSyncError( + `engine:verify: engine pin file ${pinRel} must contain a single "sha" ` + + `field with a hex commit SHA (7–40 chars), got ${JSON.stringify(data)}.`, + ); + } + return sha; +} + +/** Expand a (possibly short) SHA to a full 40-hex SHA via rev-parse. */ +export function resolveShaFull(repoRoot, coreRel, sha) { + const res = runGit(corePath(repoRoot, coreRel), ['rev-parse', `${sha}^{commit}`]); + if (!res.ok) return null; + return res.stdout.split(/\s+/)[0] ?? null; +} + +/** + * sync: refuse loudly on a dirty submodule, then `git submodule update + * --remote core` so dev builds follow the latest engine of the tracked + * branch. On network failure, warn and fall back to the current checkout + * (exit 0) per the documented offline-build assumption. + * + * @returns {{ updated: boolean, from: string, to: string, warning?: string }} + */ +export function syncEngine({ repoRoot, coreRel = DEFAULT_CORE_REL, log = console.log }) { + ensureSubmoduleInit(repoRoot, coreRel); + if (submoduleIsDirty(repoRoot, coreRel)) { + throw new EngineSyncError( + `engine:sync: ${coreRel} has uncommitted changes — refusing to sync ` + + `(never clobber).\nCommit, stash or discard the changes in ${coreRel} ` + + `and rerun the build, or use engine:verify for a pinned release build.`, + ); + } + const before = resolveShaFull(repoRoot, coreRel, 'HEAD'); + const res = runGit(repoRoot, ['submodule', 'update', '--init', '--recursive', '--remote', coreRel]); + if (!res.ok) { + const msg = `${res.stderr}\n${res.stdout}`; + if (/unable to access|could not resolve|connection timed out|operation timed out|failed to connect|fatal: unable to access/i.test(msg)) { + log(`engine:sync: WARNING remote unreachable — using existing ${coreRel} checkout (no change).`); + log(msg.trim()); + return { updated: false, from: before ?? '', to: before ?? '', warning: 'remote unreachable' }; + } + throw new EngineSyncError( + `engine:sync: failed to update ${coreRel}:\n${msg}\n` + + `If the engine repo is unreachable, your build toolchain is offline ` + + `and the existing checkout was kept.`, + ); + } + const after = resolveShaFull(repoRoot, coreRel, 'HEAD'); + log(`engine:sync: ${coreRel} updated ${(before ?? '').slice(0, 10)} → ${(after ?? '').slice(0, 10)}`); + return { updated: before !== after, from: before ?? '', to: after ?? '' }; +} + +/** + * verify: refuse loudly on a dirty submodule, read the pin, compare the + * pinned SHA against the remote-resolved target of the tracked branch, then + * check out exactly the pinned SHA. Release builds require a reachable + * remote so the pin can be verified. + * + * @returns {{ sha: string }} + */ +export function verifyEngine({ repoRoot, coreRel = DEFAULT_CORE_REL, pinRel = DEFAULT_PIN_REL, log = console.log }) { + ensureSubmoduleInit(repoRoot, coreRel); + if (submoduleIsDirty(repoRoot, coreRel)) { + throw new EngineSyncError( + `engine:verify: ${coreRel} has local modifications — refusing to verify ` + + `(release builds must be reproducible).\nClean ${coreRel} and rerun.`, + ); + } + const pinned = readPin(repoRoot, pinRel); + const target = resolveRemoteTarget(repoRoot, coreRel); + // Fetch so the pinned commit's objects exist locally before resolving it to + // a full SHA (release builds possess network by construction). + runGit(repoRoot, ['submodule', 'update', '--init', '--recursive', coreRel]); + runGit(corePath(repoRoot, coreRel), ['fetch', 'origin']); + const pinnedFull = resolveShaFull(repoRoot, coreRel, pinned); + if (!pinnedFull) { + throw new EngineSyncError( + `engine:verify: pinned engine SHA ${pinned} (${pinRel}) is not reachable ` + + `from ${coreRel}'s remotes — check the pin.`, + ); + } + if (pinnedFull !== target) { + throw new EngineSyncError( + `engine:verify: PIN MISMATCH — pinned engine ${pinnedFull} (${pinRel}) ` + + `differs from the remote-resolved target ${target} of the branch ${coreRel} tracks.\n` + + `Update ${pinRel} to {"sha": "${target}"} alongside the release version bump.`, + ); + } + // Check out exactly the pinned SHA (detached HEAD — this is a build, not a + // checkout-to-work login). + const checkout = runGit(corePath(repoRoot, coreRel), ['checkout', '--detach', pinnedFull]); + if (!checkout.ok) { + throw new EngineSyncError( + `engine:verify: failed to check out pinned engine ${pinnedFull}:\n${checkout.stderr}`, + ); + } + log(`engine:verify: engine pinned and checked out at ${pinnedFull}`); + return { sha: pinnedFull }; +} + +/** CLI entry: `node engine-sync.mjs sync|verify [--core-rel path] [--pin path]`. */ +export function main(argv, io = { stdout: process.stdout, stderr: process.stderr }) { + const [mode, ...rest] = argv; + const flagValue = (name) => { + const i = rest.indexOf(`--${name}`); + return i >= 0 ? rest[i + 1] : undefined; + }; + const coreRel = flagValue('core-rel') ?? DEFAULT_CORE_REL; + const pinRel = flagValue('pin') ?? DEFAULT_PIN_REL; + let result; + try { + if (mode === 'sync') result = syncEngine({ repoRoot: process.cwd(), coreRel }); + else if (mode === 'verify') result = verifyEngine({ repoRoot: process.cwd(), coreRel, pinRel }); + else { + io.stderr.write( + `usage: node engine-sync.mjs [--core-rel ] [--pin ]\n` + + ` sync dev build — follow latest engine remote head (offline: keep current)\n` + + ` verify release — check out exactly configs/engine-pin.json's sha\n`, + ); + return 2; + } + io.stdout.write(JSON.stringify({ ok: true, ...result }) + '\n'); + return 0; + } catch (err) { + io.stderr.write(`${err instanceof EngineSyncError ? err.message : String(err)}\n`); + io.stdout.write(JSON.stringify({ ok: false, error: err instanceof EngineSyncError ? err.message : String(err) }) + '\n'); + return 1; + } +} + +// Run only when invoked directly (not when imported by tests). +const invokedDirectly = + process.argv[1] !== undefined && + path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url)); +if (invokedDirectly) { + process.exitCode = main(process.argv.slice(2)); +} diff --git a/scripts/game-repo-scaffold.ts b/scripts/game-repo-scaffold.ts index 1bc5ea5f9..8619d9878 100644 --- a/scripts/game-repo-scaffold.ts +++ b/scripts/game-repo-scaffold.ts @@ -220,8 +220,17 @@ export function renderPackageJson( ]), ), dev: 'vite --host', - build: 'tsc --noEmit && vite build', - 'build:electron': 'tsc --noEmit && vite build --mode electron', + build: `npm run engine:sync && tsc --noEmit && vite build`, + 'build:electron': `npm run engine:sync && tsc --noEmit && vite build --mode electron`, + 'build:release': `npm run engine:verify && tsc --noEmit && vite build`, + // Engine submodule sync/verify (MS-0MVCRGKZY007DIED): dev builds always + // compose the latest engine remote head; release builds compile exactly + // the committed `configs/engine-pin.json` SHA. The script is canonical + // in the engine repo (`/scripts/engine-sync.mjs`); game repos + // invoke it through the submodule — there is no game-repo `scripts/` + // tree (Option C). + 'engine:sync': `node ${coreRel}/scripts/engine-sync.mjs sync`, + 'engine:verify': `node ${coreRel}/scripts/engine-sync.mjs verify`, preview: 'vite preview --host', test: 'vitest run --project unit', 'test:unit': 'vitest run --project unit', diff --git a/tests/scripts/engine-sync.test.ts b/tests/scripts/engine-sync.test.ts new file mode 100644 index 000000000..eef5b152e --- /dev/null +++ b/tests/scripts/engine-sync.test.ts @@ -0,0 +1,286 @@ +/** + * Unit tests for the engine submodule sync/verify script + * (`scripts/engine-sync.mjs`, MS-0MVCRGKZY007DIED). + * + * The script implements the dev/release engine-composition policies for game + * repos: + * + * - **sync** (dev build): refuse loudly when `./core` has uncommitted changes + * (never clobber), then `git submodule update --remote core` so the tracked + * branch's remote tip is checked out. Unreachable remote → fall back to the + * current checkout with a warning (offline build). + * - **verify** (release build): refuse loudly on local modifications, read + * `configs/engine-pin.json`, confirm the pinned SHA equals the + * remote-resolved target, then check out exactly the pinned SHA. + * + * These tests drive real git fixtures (temp repos + local-path remotes, no + * network) and assert observable behaviour through the exported API. + */ +import { describe, expect, it, afterEach } from 'vitest'; +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { + DEFAULT_CORE_REL, + DEFAULT_PIN_REL, + EngineSyncError, + readPin, + resolveRemoteTarget, + resolveTrackedBranch, + submoduleIsDirty, + syncEngine, + verifyEngine, +} from '../../scripts/engine-sync.mjs'; + +const tempRoots: string[] = []; + +/** Create a temp fixture root that is removed after the file runs. */ +function tempRoot(prefix: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tempRoots.push(dir); + return dir; +} + +afterEach(() => { + for (const dir of tempRoots.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +/** Run git quietly in a fixture (tests only — no network). */ +function git(cwd: string, args: string[]): { ok: boolean; stdout: string; stderr: string } { + const res = spawnSync('git', args, { + cwd, + encoding: 'utf8', + env: { ...process.env, GIT_ALLOW_PROTOCOL: 'file', GIT_TERMINAL_PROMPT: '0' }, + windowsHide: true, + }); + return { + ok: res.status === 0, + stdout: (res.stdout ?? '').trim(), + stderr: (res.stderr ?? '').trim(), + }; +} + +function commit(cwd: string, message: string): void { + const res = git(cwd, ['commit', '--allow-empty', '-m', message]); + if (!res.ok) throw new Error(`fixture commit failed: ${res.stderr}`); +} + +/** + * Build a fixture engine repo + game repo: + * + * root/engine/ — the "engine repo" (branch `dev`) + * root/game/ — the game repo: configs/engine-pin.json, core submodule + * registered against a local-path remote named `origin` + * (no network; GIT_ALLOW_PROTOCOL=file enables the + * local transport). + * + * @returns fixture paths plus helpers. + */ +function makeFixture(): { + root: string; + engine: string; + game: string; + pinPath: string; + devTip: () => string; + advanceEngine: () => string; + pinTo: (sha: string) => void; +} { + const root = tempRoot('engine-sync-'); + const engine = path.join(root, 'engine'); + const game = path.join(root, 'game'); + + for (const dir of [engine, game]) fs.mkdirSync(dir, { recursive: true }); + + // Engine repo with a `dev` branch. + git(engine, ['init', '-q', '-b', 'dev']); + git(engine, ['config', 'user.email', 'fixture@test']); + git(engine, ['config', 'user.name', 'Fixture']); + fs.writeFileSync(path.join(engine, 'version.txt'), 'v1\n'); + git(engine, ['add', 'version.txt']); + commit(engine, 'v1'); + + // Game repo consuming the engine as a `core` submodule (local-path remote). + git(game, ['init', '-q', '-b', 'main']); + git(game, ['config', 'user.email', 'fixture@test']); + git(game, ['config', 'user.name', 'Fixture']); + const subAdd = git(game, ['-c', 'protocol.file.allow=always', 'submodule', 'add', engine, 'core']); + if (!subAdd.ok) throw new Error(`fixture submodule add failed: ${subAdd.stderr}`); + git(game, ['config', '-f', '.gitmodules', 'submodule.core.branch', 'dev']); + git(game, ['add', '.gitmodules', 'core']); + commit(game, 'init with core @ dev'); + + const pinPath = path.join(game, DEFAULT_PIN_REL); + fs.mkdirSync(path.dirname(pinPath), { recursive: true }); + + const devTip = (): string => + git(engine, ['rev-parse', 'HEAD']).stdout.split(/\s+/)[0] ?? ''; + + const advanceEngine = (): string => { + fs.appendFileSync(path.join(engine, 'version.txt'), `v${Date.now()}\n`); + git(engine, ['add', 'version.txt']); + commit(engine, 'advance'); + return devTip(); + }; + + const pinTo = (sha: string): void => { + fs.writeFileSync(pinPath, JSON.stringify({ sha })); + }; + + return { root, engine, game, pinPath, devTip, advanceEngine, pinTo }; +} + +describe('submoduleIsDirty', () => { + it('is false on a clean submodule checkout and true after a change', () => { + const f = makeFixture(); + expect(submoduleIsDirty(f.game)).toBe(false); + fs.appendFileSync(path.join(f.game, 'core', 'version.txt'), 'dirty\n'); + expect(submoduleIsDirty(f.game)).toBe(true); + }); + + it('is true when the submodule is not (yet) a git repo', () => { + const f = makeFixture(); + const nonGit = tempRoot('engine-sync-ng-'); + fs.mkdirSync(path.join(nonGit, 'core'), { recursive: true }); + fs.writeFileSync(path.join(nonGit, 'core', 'x.txt'), 'x'); + expect(submoduleIsDirty(nonGit)).toBe(true); + }); +}); + +describe('syncEngine — dirty-state guard (AC1)', () => { + it('fails loudly on an uncommitted submodule change and does not clobber it', () => { + const f = makeFixture(); + const before = git(path.join(f.game, 'core'), ['rev-parse', 'HEAD']).stdout; + fs.appendFileSync(path.join(f.game, 'core', 'version.txt'), 'wip\n'); + + expect(() => syncEngine({ repoRoot: f.game })).toThrow(EngineSyncError); + expect(() => syncEngine({ repoRoot: f.game })).toThrow(/uncommitted changes/); + + // Never clobbered: the submodule still points at the same commit with the + // WIP file intact. + const after = git(path.join(f.game, 'core'), ['rev-parse', 'HEAD']).stdout; + expect(after).toBe(before); + expect(fs.readFileSync(path.join(f.game, 'core', 'version.txt'), 'utf-8')).toContain('wip'); + }); + + it('advances a clean submodule to the tracked branch remote tip', () => { + const f = makeFixture(); + const tip = f.advanceEngine(); + const res = syncEngine({ repoRoot: f.game }); + expect(res.updated).toBe(true); + expect(res.to).toBe(tip); + expect(git(path.join(f.game, 'core'), ['rev-parse', 'HEAD']).stdout).toBe(tip); + }); + + it('is a no-op when the submodule is already at the remote tip', () => { + const f = makeFixture(); + const tip = f.devTip(); + const res = syncEngine({ repoRoot: f.game }); + expect(res.updated).toBe(false); + expect(res.to).toBe(tip); + }); + + it('falls back to the current checkout with a warning when the remote is unreachable', () => { + const f = makeFixture(); + // The submodule must be behind the remote tip so `submodule update + // --remote` actually attempts a fetch — then break its origin URL so the + // fetch fails like an offline build would. + const tip = f.advanceEngine(); + git(path.join(f.game, 'core'), ['remote', 'set-url', 'origin', 'http://127.0.0.1:9/nowhere.git']); + const before = git(path.join(f.game, 'core'), ['rev-parse', 'HEAD']).stdout; + expect(before).not.toBe(tip); + const res = syncEngine({ repoRoot: f.game }); + expect(res.warning).toMatch(/remote unreachable|WARNING/); + expect(res.updated).toBe(false); + // Offline fallback never clobbers: the checkout is untouched. + expect(git(path.join(f.game, 'core'), ['rev-parse', 'HEAD']).stdout).toBe(before); + }); +}); + +describe('readPin / pin file (AC3)', () => { + it('reads a single `sha` field', () => { + const f = makeFixture(); + f.pinTo('0123456789abcdef0123456789abcdef01234567'); + expect(readPin(f.game)).toBe('0123456789abcdef0123456789abcdef01234567'); + }); + + it('fails loudly when the pin file is missing', () => { + const f = makeFixture(); + expect(() => readPin(f.game)).toThrow(/pin file not found/); + }); + + it('fails loudly on malformed or shapeless pins', () => { + const f = makeFixture(); + for (const raw of ['not json', '{"foo": 1}', '{"sha": "xyz"}', '{"sha": 42}']) { + fs.writeFileSync(path.join(f.game, DEFAULT_PIN_REL), raw); + expect(() => readPin(f.game), raw).toThrow(EngineSyncError); + } + }); +}); + +describe('verifyEngine — pin-mismatch failure path (AC2)', () => { + it('fails loudly on local modifications before touching anything', () => { + const f = makeFixture(); + f.pinTo(f.devTip()); + fs.appendFileSync(path.join(f.game, 'core', 'version.txt'), 'wip\n'); + expect(() => verifyEngine({ repoRoot: f.game })).toThrow(/local modifications/); + }); + + it('fails loudly when the pinned SHA differs from the remote-resolved target', () => { + const f = makeFixture(); + const oldTip = f.devTip(); + f.advanceEngine(); + f.pinTo(oldTip); // stale pin + expect(() => verifyEngine({ repoRoot: f.game })).toThrow(/PIN MISMATCH/); + // The submodule was not moved by the failed verify. + expect(git(path.join(f.game, 'core'), ['rev-parse', 'HEAD']).stdout).toBe(oldTip); + }); + + it('fails loudly when the pin is missing (release needs a committed pin)', () => { + const f = makeFixture(); + expect(() => verifyEngine({ repoRoot: f.game })).toThrow(/pin file not found/); + }); + + it('checks out exactly the pinned SHA when it matches the remote target', () => { + const f = makeFixture(); + const tip = f.advanceEngine(); + f.pinTo(tip); + const res = verifyEngine({ repoRoot: f.game }); + expect(res.sha).toBe(tip); + expect(git(path.join(f.game, 'core'), ['rev-parse', 'HEAD']).stdout).toBe(tip); + }); + + it('re-pins an older checkout to the exact pinned SHA', () => { + const f = makeFixture(); + const tip = f.advanceEngine(); + f.pinTo(tip); + // Drift the checkout back to the original commit, then verify re-pins it. + const original = git(path.join(f.game, 'core'), ['rev-parse', 'HEAD']).stdout; + git(path.join(f.game, 'core'), ['checkout', '-q', original]); + expect(git(path.join(f.game, 'core'), ['rev-parse', 'HEAD']).stdout).toBe(original); + verifyEngine({ repoRoot: f.game }); + expect(git(path.join(f.game, 'core'), ['rev-parse', 'HEAD']).stdout).toBe(tip); + }); +}); + +describe('branch + remote target resolution', () => { + it('resolves the tracked branch from .gitmodules (dev by default)', () => { + const f = makeFixture(); + expect(resolveTrackedBranch(f.game)).toBe('dev'); + }); + + it('resolves the remote target to the dev tip', () => { + const f = makeFixture(); + const tip = f.advanceEngine(); + expect(resolveRemoteTarget(f.game)).toBe(tip); + }); + + it('derives the submodule path from the fixture layout', () => { + const f = makeFixture(); + expect(fs.existsSync(path.join(f.game, DEFAULT_CORE_REL, '.git'))).toBe(true); + }); +}); diff --git a/tests/scripts/game-repo-scaffold.test.ts b/tests/scripts/game-repo-scaffold.test.ts index 419b25ff5..d6f1b79e7 100644 --- a/tests/scripts/game-repo-scaffold.test.ts +++ b/tests/scripts/game-repo-scaffold.test.ts @@ -305,6 +305,26 @@ describe('renderPackageJson', () => { expect(pkg.dependencies).toEqual(MANIFEST.dependencies); expect(pkg.devDependencies).toEqual(MANIFEST.devDependencies); }); + + it('wires engine:sync into dev builds and engine:verify into release builds', () => { + const pkg = JSON.parse(renderPackageJson('golf', MANIFEST, DEFAULT_CORE_REL)); + // Dev builds compose the latest engine remote head before compiling + // (MS-0MVCRGKZY007DIED AC1). + expect(pkg.scripts.build).toBe('npm run engine:sync && tsc --noEmit && vite build'); + expect(pkg.scripts['build:electron']).toBe( + 'npm run engine:sync && tsc --noEmit && vite build --mode electron', + ); + // Release builds verify the committed engine pin instead (AC2/AC3). + expect(pkg.scripts['build:release']).toBe('npm run engine:verify && tsc --noEmit && vite build'); + // The engine-sync script lives in the engine repo — game repos have no + // root `scripts/` tree (Option C) and invoke it through the submodule. + expect(pkg.scripts['engine:sync']).toBe( + `node ${DEFAULT_CORE_REL}/scripts/engine-sync.mjs sync`, + ); + expect(pkg.scripts['engine:verify']).toBe( + `node ${DEFAULT_CORE_REL}/scripts/engine-sync.mjs verify`, + ); + }); }); describe('renderViteConfig', () => { From ed801cd170334f85ef6ac4aa07d9f9c5f907c77c Mon Sep 17 00:00:00 2001 From: Sorra the Orc Date: Sun, 18 Oct 2026 00:35:42 +0100 Subject: [PATCH 04/39] CG-0MVD0IZDR008921Q: document engine-sync/verify in generated game repos (DEVELOPER.md) --- docs/DEVELOPER.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docs/DEVELOPER.md b/docs/DEVELOPER.md index 95c123c52..d4da77662 100644 --- a/docs/DEVELOPER.md +++ b/docs/DEVELOPER.md @@ -1893,6 +1893,26 @@ symlinks. Game tests that referenced `example-games//…` are repointed to [Multi-Repo Architecture](dev/multi-repo-architecture.md#5-per-game-repo-scaffold-f4) and the [layout decision record](dev/per-game-src-layout-decision.md). +### Engine sync/verify scripts in generated game repos + +`renderPackageJson` wires every generated game repo with two engine submodule +scripts (MS-0MVCRGKZY007DIED): + +- `engine:sync` — dev builds compose the **latest** engine: refuse loudly on + uncommitted `./core` changes, then `git submodule update --remote core` + (remote unreachable → warn and keep the current checkout). +- `engine:verify` — release builds compile **exactly** the SHA pinned in the + game repo's `configs/engine-pin.json`: same dirty guard, then compare the + pin against the tracked branch's remote tip and check that SHA out exactly. +- `build` / `build:electron` run `engine:sync` first; `build:release` runs + `engine:verify` instead. + +The script is the engine-owned `scripts/engine-sync.mjs` (+ `.d.mts` types), +invoked through the submodule — game repos have no root `scripts/` tree +(Option C). Game repos must declare the tracked branch in `.gitmodules` +(e.g. `branch = dev`) so `--remote` follows development rather than the +remote's default branch (`main`). + ### App icon linkage (`sharedPublicRoot`) Every `tce-` repo is its own Vite app with its own `index.html` and From e4d0e10baa62a1d459b07ad4926e1968cf344b00 Mon Sep 17 00:00:00 2001 From: Sorra the Orc Date: Sun, 18 Oct 2026 00:41:07 +0100 Subject: [PATCH 05/39] CG-0MVD0IZDR008921Q: correct engine-sync.mjs header (submodule-invoked, not copied) --- scripts/engine-sync.mjs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/scripts/engine-sync.mjs b/scripts/engine-sync.mjs index f8907d687..f2774dd27 100644 --- a/scripts/engine-sync.mjs +++ b/scripts/engine-sync.mjs @@ -20,15 +20,16 @@ * then check out exactly that SHA. Release builds require the remote to be * reachable so the pin can be compared; a mismatch fails the build. * - * The npm scripts wrap this script: + * The npm scripts wrap this script (game repos have no root `scripts/` + * tree — Option C — so the canonical file lives in the engine repo and is + * invoked through the `./core` submodule): * - * "engine:sync": "node scripts/engine-sync.mjs sync" - * "engine:verify": "node scripts/engine-sync.mjs verify" + * "engine:sync": "node ./core/scripts/engine-sync.mjs sync" + * "engine:verify": "node ./core/scripts/engine-sync.mjs verify" * * This file is the canonical implementation in the engine repo; the game-repo - * scaffold (`scripts/game-repo-scaffold.ts`) copies it into each game repo as - * `scripts/engine-sync.mjs`, so edit it here and re-scaffold — never edit the - * generated copy by hand. + * scaffold (`scripts/game-repo-scaffold.ts`) wires the npm scripts to it, so + * re-scaffolding a game repo points the scripts here — never edit a copy. * * Subcommands: * node engine-sync.mjs sync|verify [--core-rel ] [--pin ] From 49705fc162242a19a9a9677351d20d3fd3d237e9 Mon Sep 17 00:00:00 2001 From: Sorra the Orc <250240+SorraTheOrc@users.noreply.github.com> Date: Tue, 20 Oct 2026 03:11:47 +0100 Subject: [PATCH 06/39] CG-0MVFU3JTN00841OR: restore engine dev build/test green - tests/scripts/engine-sync.test.ts: drop unused fileURLToPath import and unused fixture variable (TS6133; origin/dev build was red since a1de8717). - tests/scripts/build-card-pack.test.ts: compare the copied reference-pack header against the committed reference fixture's own 55-column header instead of the generic 53-column CARD_PACK_CSV_HEADER. The reference fixture was re-synced to the production schema (options + piggybackable) in a1560d4d/dc4ec0ce; the shared constant describes the separate 53-column merge/UI fixture schema and must not be bumped. Verified: tsc --noEmit clean; npm run build clean; the four affected unit files pass (73 tests). Full-suite run deferred: host was saturated by concurrent CI suites for several hours (unit stage exceeded its 300s bound). --- tests/scripts/build-card-pack.test.ts | 12 ++++++++++-- tests/scripts/engine-sync.test.ts | 2 -- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/tests/scripts/build-card-pack.test.ts b/tests/scripts/build-card-pack.test.ts index ffa517193..3c4f51231 100644 --- a/tests/scripts/build-card-pack.test.ts +++ b/tests/scripts/build-card-pack.test.ts @@ -204,10 +204,18 @@ describe('buildCardPack — reference Main Street pack', () => { const written = JSON.parse(fs.readFileSync(result.manifestPath, 'utf-8')); expect(written.packs[0].title).toBe('Main Street Foundations'); - // CSV fragment copied, retaining the Main Street schema header. + // CSV fragment copied, retaining the committed reference schema header + // (the reference pack uses the 55-column schema: base 53 + options + + // piggybackable — see MS-0MV8BFOVB000HM0W / MS-0MV8KQQQ90044CM5). + const referencePackCsvPath = path.join( + REFERENCE_PACK_INPUT, + REFERENCE_GAME_ID, + REFERENCE_PACK_ID, + 'cards.csv', + ); expect(fs.existsSync(pack.csvPath)).toBe(true); expect(fs.readFileSync(pack.csvPath, 'utf-8').split('\n')[0]).toBe( - CARD_PACK_CSV_HEADER, + fs.readFileSync(referencePackCsvPath, 'utf-8').split('\n')[0], ); expect(pack.files).toContain('cards.csv'); diff --git a/tests/scripts/engine-sync.test.ts b/tests/scripts/engine-sync.test.ts index eef5b152e..0c622f811 100644 --- a/tests/scripts/engine-sync.test.ts +++ b/tests/scripts/engine-sync.test.ts @@ -21,7 +21,6 @@ import { spawnSync } from 'node:child_process'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { fileURLToPath } from 'node:url'; import { DEFAULT_CORE_REL, @@ -143,7 +142,6 @@ describe('submoduleIsDirty', () => { }); it('is true when the submodule is not (yet) a git repo', () => { - const f = makeFixture(); const nonGit = tempRoot('engine-sync-ng-'); fs.mkdirSync(path.join(nonGit, 'core'), { recursive: true }); fs.writeFileSync(path.join(nonGit, 'core', 'x.txt'), 'x'); From 41f86950ca2fcb9909a4753318700e62a039f23b Mon Sep 17 00:00:00 2001 From: Sorra the Orc <250240+SorraTheOrc@users.noreply.github.com> Date: Tue, 20 Oct 2026 03:14:17 +0100 Subject: [PATCH 07/39] CG-0MVFQ6QOZ003FVO4: document The Rising ToneForge SFX in SFX_CONVENTION.md Adds a '## The Rising ToneForge SFX' profile: the three face-off keys and asset filenames, the exact deterministic tf generate commands (recipes card-treasure-reveal/whoosh-air-swish/card-timer-tick, seeds 42/43/44), runtime wiring (THERISING_SFX_KEYS/THERISING_AUDIO_FILES, preload + audioPathWithFallback, SoundManager/safePlaySound), reduced-motion behaviour (all three beats still play), fallback behaviour (face-off WAVs game-specific, nine baseline SFX fall back to default/, no synth mapping), and a link to the game README regeneration section. Adds the-rising/ to the audio-tree with a note that the assets live in the game repo. Docs-only: no engine code or constants changed. --- docs/SFX_CONVENTION.md | 85 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 82 insertions(+), 3 deletions(-) diff --git a/docs/SFX_CONVENTION.md b/docs/SFX_CONVENTION.md index d90e942d4..844dd09ca 100644 --- a/docs/SFX_CONVENTION.md +++ b/docs/SFX_CONVENTION.md @@ -1,7 +1,7 @@ # SFX Key Naming Convention -> **Last updated:** 2026-07-11 -> **Related work-items:** CG-0MM1OQN4E153GJY3 — SFX key naming inconsistency and potential collision; CG-0MQK1GC79000CQIJ — Illegal Move sound +> **Last updated:** 2026-10-19 +> **Related work-items:** CG-0MM1OQN4E153GJY3 — SFX key naming inconsistency and potential collision; CG-0MQK1GC79000CQIJ — Illegal Move sound; CG-0MVFQ6QOZ003FVO4 — The Rising ToneForge SFX profile ## Overview @@ -61,7 +61,8 @@ public/assets/audio/ ├── feudalism/ ├── beleaguered-castle/ ├── lost-cities/ -└── main-street/ # (Main Street uses assets/games/main-street/audio/) +├── main-street/ # (Main Street uses assets/games/main-street/audio/) +└── the-rising/ # (The Rising keeps these in its game repo: tce-the-rising/public/assets/audio/the-rising/) ``` When loading audio, use the `audioPathWithFallback()` helper from `src/ui/CardGameScene.ts`: @@ -147,6 +148,84 @@ Main Street uses ToneForge-generated synth audio in addition to WAV fallbacks. T Main Street also uses the two shared **game-over** convention keys (`sfx-game-win` / `sfx-game-lost`) directly from the default audio dir (`public/assets/audio/default/game-win.wav` / `game-lost.wav` — see the [Audio Asset Organization](#audio-asset-organization) tree above). They are loaded in `MainStreetLifecycleManager.preload()` and played by `MainStreetAnimator.animateGameOver()`; they are **not** synth-mapped (no ToneForge factory), so playback falls through to the Phaser WAV assets. +## The Rising ToneForge SFX + +The Rising (`tce-the-rising`) generates its three face-off resolution sounds +deterministically with ToneForge and commits the resulting WAVs to its own +repo. This section is maintained in the engine because sibling game repos read +this document; the assets themselves stay in the game repo and are **not** +vendored here. + +### Face-off SFX keys and recipes + +| Key | Asset (in `tce-the-rising/public/assets/audio/the-rising/`) | ToneForge recipe | Seed | +|-----|-----------------------------------------------------------|------------------|------| +| `sfx-face-off-reveal` | `face-off-reveal.wav` | `card-treasure-reveal` | 42 | +| `sfx-float-in-whoosh` | `float-in-whoosh.wav` | `whoosh-air-swish` | 43 | +| `sfx-land-tick` | `land-tick.wav` | `card-timer-tick` | 44 | + +Regenerate each sound from the game repo root with the pinned recipe and seed +(identical inputs produce byte-identical output): + +```bash +# Face-off reveal/impact — played at the start of board resolution +tf generate --recipe card-treasure-reveal --seed 42 \ + --output public/assets/audio/the-rising/face-off-reveal.wav + +# Fly-in whoosh — played as the winning card travels to the VP counter +tf generate --recipe whoosh-air-swish --seed 43 \ + --output public/assets/audio/the-rising/float-in-whoosh.wav + +# Landing tick — played as the winning card lands on the VP counter +tf generate --recipe card-timer-tick --seed 44 \ + --output public/assets/audio/the-rising/land-tick.wav +``` + +The game README's [*Regenerating the face-off SFX*](../tce-the-rising/README.md#regenerating-the-face-off-sfx) +section is the authority for these recipes and seeds; keep this section in sync +with it. + +### Runtime wiring + +- **Keys** are declared in `THERISING_SFX_KEYS` (`src/scenes/TheRisingConstants.ts`), + using the shared `sfx-` prefix with no game identifier. Nine baseline keys + are plain strings or come from `COMMON_SFX_KEYS`; the three face-off keys + above are game-specific. +- **File mappings** are declared in `THERISING_AUDIO_FILES` (same module), + mapping every key to its filename relative to the game's audio directory. +- **Loading** happens in `TheRisingScene.preload()`: each key is registered + with `audioPathWithFallback('the-rising', file)`, which yields + `[assets/audio/the-rising/, assets/audio/default/]` so Phaser + tries the game file first and the shared default second. +- **Playback** goes only through `SoundManager` (via the animator's + `playSfx()`); overlay helpers use the exported `safePlaySound()`. Direct + `scene.sound.play()` calls are blocked by the `no-direct-sound-play` ESLint + rule. + +The three beats are sequenced by `TheRisingAnimator.playFaceOffSfxSequence()`: +the reveal sting fires immediately, the whoosh after the reveal→flight gap, and +the landing tick once the flight would land. Deterministic fixed offsets keep +the sequence testable. + +### Reduced motion + +With reduced motion enabled the face-off travel is skipped and the outcome is +presented instantly, but **all three SFX beats still play** in order — the +result is always audible; only the animation is shortened. The animator reads +the shared reduced-motion setting once in `TheRisingScene.create()` and applies +it to the card tweens and sweeps. + +### Fallback behaviour + +- The three face-off WAVs are **game-specific** and have no `default/` + equivalent, so they play only while the game audio directory is present. +- The nine baseline SFX (`sfx-card-draw`, `sfx-card-swap`, `sfx-card-flip`, + `sfx-illegal-move`, `sfx-score-reveal`, `sfx-ui-click`, `sfx-turn-change`, + `sfx-game-win`, `sfx-game-lost`) fall back to `public/assets/audio/default/`. +- The Rising has **no** ToneForge synth-mapping module (unlike Main Street's + `example-games/main-street/sfx-tf-mapping.ts`), so playback always resolves + to the committed WAV assets — there is no synthesised fallback. + ## Testing - Run `npm test` to verify all SFX-related tests pass. From 5f419f7a2770daadfafb8bc0cd3abd94f7f5355b Mon Sep 17 00:00:00 2001 From: Sorra the Orc <250240+SorraTheOrc@users.noreply.github.com> Date: Tue, 20 Oct 2026 15:53:32 +0100 Subject: [PATCH 08/39] MS-0MVFM9YNN008WYZO: generic story-line module in core Adds a game-agnostic story-line system under core/src/story-line: types + engine/UI adapter seams, legacy hasChoices and CSV options compilation, a per-system registered-option registry, resolution semantics (apply/skip, successor precedence, one seeded rng draw per candidate selection, deterministic serial chain naming), journal helpers and a pluggable default UI adapter. Wires the @story-line alias through resolveCoreAliases, core tsconfig and the scaffold template. Includes the parity unit tests from MS-0MVFM9HGI004XADO under core/tests/story-line (compile/resolve/journal/ui-adapter). --- scripts/game-repo-scaffold.ts | 2 + scripts/vite-game-discovery-plugin.ts | 2 + src/story-line/compile.ts | 216 +++++++++++++++ src/story-line/index.ts | 190 +++++++++++++ src/story-line/journal.ts | 75 ++++++ src/story-line/resolve.ts | 119 ++++++++ src/story-line/types.ts | 289 ++++++++++++++++++++ src/story-line/ui-adapter.ts | 159 +++++++++++ tests/scripts/game-repo-scaffold.test.ts | 2 + tests/story-line/compile.test.ts | 330 +++++++++++++++++++++++ tests/story-line/helpers.ts | 115 ++++++++ tests/story-line/journal.test.ts | 140 ++++++++++ tests/story-line/resolve.test.ts | 275 +++++++++++++++++++ tests/story-line/ui-adapter.test.ts | 217 +++++++++++++++ tests/vite-config.test.ts | 2 +- tsconfig.json | 2 + 16 files changed, 2134 insertions(+), 1 deletion(-) create mode 100644 src/story-line/compile.ts create mode 100644 src/story-line/index.ts create mode 100644 src/story-line/journal.ts create mode 100644 src/story-line/resolve.ts create mode 100644 src/story-line/types.ts create mode 100644 src/story-line/ui-adapter.ts create mode 100644 tests/story-line/compile.test.ts create mode 100644 tests/story-line/helpers.ts create mode 100644 tests/story-line/journal.test.ts create mode 100644 tests/story-line/resolve.test.ts create mode 100644 tests/story-line/ui-adapter.test.ts diff --git a/scripts/game-repo-scaffold.ts b/scripts/game-repo-scaffold.ts index 8619d9878..934091e07 100644 --- a/scripts/game-repo-scaffold.ts +++ b/scripts/game-repo-scaffold.ts @@ -388,6 +388,8 @@ export function renderTsconfig(_game: string, coreRel: string): string { '@ai/*': [`${coreRel}/src/ai/*`], '@balance-cards': [`${coreRel}/src/balance-cards`], '@balance-cards/*': [`${coreRel}/src/balance-cards/*`], + '@story-line': [`${coreRel}/src/story-line`], + '@story-line/*': [`${coreRel}/src/story-line/*`], // Core-owned framework trees consumed by the game's own tests and // replay adapter (F9 / C1). '@core-scripts/*': [`${coreRel}/scripts/*`], diff --git a/scripts/vite-game-discovery-plugin.ts b/scripts/vite-game-discovery-plugin.ts index 990fcac17..4a71a5579 100644 --- a/scripts/vite-game-discovery-plugin.ts +++ b/scripts/vite-game-discovery-plugin.ts @@ -94,6 +94,8 @@ export function resolveCoreAliases(coreRoot: string): Record { '@ai': path.resolve(coreRoot, 'src/ai'), // Core-owned `src/` module without a top-level alias (balance-card tooling). '@balance-cards': path.resolve(coreRoot, 'src/balance-cards'), + // Generic game-agnostic story-line system (branching card narratives). + '@story-line': path.resolve(coreRoot, 'src/story-line'), // Core-owned framework trees consumed by per-game repos (F9 / C2). '@core-scripts': path.resolve(coreRoot, 'scripts'), '@core-tests': path.resolve(coreRoot, 'tests'), diff --git a/src/story-line/compile.ts b/src/story-line/compile.ts new file mode 100644 index 000000000..0fb774d87 --- /dev/null +++ b/src/story-line/compile.ts @@ -0,0 +1,216 @@ +/** + * Story-line option compilation and the registered-option registry. + * + * Compilation turns a card's legacy `hasChoices` fields, its CSV `options` + * encoding, or a programmatically registered option list into the + * generalised {@link StorylineOption} model used by resolution. + * + * Precedence: registered options > CSV `options` encoding > legacy two-way + * choice fields. + * + * @module + */ + +import type { + CompiledStoryline, + StorylineCard, + StorylineOption, +} from './types'; + +/** + * Registry of explicitly registered option lists, keyed by base event id + * (without the instance serial). Module-scoped per system instance so a game + * or test never sees another system's registrations; games are responsible + * for resetting it in test teardown. + */ +export interface StorylineOptionRegistry { + register(eventId: string, options: readonly StorylineOption[]): void; + reset(): void; + has(eventId: string): boolean; + get(eventId: string): StorylineOption[] | undefined; +} + +/** Creates an empty option registry keyed by base type id. */ +export function createStorylineOptionRegistry( + baseTypeId: (id: string) => string, +): StorylineOptionRegistry { + const map = new Map[]>(); + return { + register(eventId: string, options: readonly StorylineOption[]): void { + map.set( + baseTypeId(eventId), + options.map((o) => ({ ...o })), + ); + }, + reset(): void { + map.clear(); + }, + has(eventId: string): boolean { + return map.has(baseTypeId(eventId)); + }, + get(eventId: string): StorylineOption[] | undefined { + return map.get(baseTypeId(eventId)); + }, + }; +} + +/** Alias for the registry type, re-exported for callers. */ +export type { StorylineOptionRegistry as StorylineOptionRegistryType }; + +/** + * Parses a CSV `options` encoding into the generalised option model. + * + * Encoding: `label|cardA,cardB;label2|cardC` + * - `;` separates options, `|` separates the label from its candidate card + * ids, and `,` separates candidate ids. + * - A blank / whitespace-only encoding decodes to no options (non-choice). + * - An empty candidate list (`label|`) is a terminal option — the chain ends. + * - Every CSV-authored option carries `effectPolicy: 'apply'`; `skip` remains + * available only via the programmatic registry. + * + * Parsing is deterministic: labels and candidate ids are whitespace-trimmed + * and empty segments/candidates are dropped. A non-empty encoding that does + * not decode to at least one valid option (or contains a malformed segment) + * is rejected with an error rather than silently ignored. + * + * @param encoding Raw `options` cell. + * @returns Ordered option list (empty for a blank encoding). + * @throws Error when the encoding is non-empty but malformed / yields no option. + */ +export function parseStorylineOptionsEncoding( + encoding: string, +): StorylineOption[] { + const trimmed = encoding.trim(); + if (trimmed === '') return []; + + const options: StorylineOption[] = []; + for (const rawSegment of trimmed.split(';')) { + const segment = rawSegment.trim(); + if (segment === '') continue; + + const separator = segment.indexOf('|'); + if (separator < 0) { + throw new Error( + `Invalid storyline options encoding "${encoding}": option "${segment}" is missing the "|" separator.`, + ); + } + + const label = segment.slice(0, separator).trim(); + if (label === '') { + throw new Error( + `Invalid storyline options encoding "${encoding}": option "${segment}" has an empty label.`, + ); + } + + const successorIds = segment + .slice(separator + 1) + .split(',') + .map((id) => id.trim()) + .filter((id) => id !== ''); + + options.push({ label, successorIds, effectPolicy: 'apply' }); + } + + if (options.length === 0) { + throw new Error( + `Invalid storyline options encoding "${encoding}": no valid option decoded.`, + ); + } + return options; +} + +/** + * Compiles a card's story-line into the generalised option model. + * + * Precedence: explicit registry options > CSV `options` encoding > legacy + * two-way choice fields. + * + * - A card with a non-empty `options` encoding compiles to the ordered option + * list it declares (candidate successor sets, always `effectPolicy: 'apply'`). + * - Registered options take precedence over both CSV and legacy compilation. + * - Non-choice cards (no `options`, no registered list, no `hasChoices`) → + * empty option list. + * - Legacy choice cards → two options [Accept(apply), Reject(skip)]. + * + * When `state` is provided, registry options whose `condition` callback + * returns `false` are omitted from the result (draw-time filtering). + * + * @param event The story-line card. + * @param registry The option registry to consult (base-type-id keyed). + * @param state Optional game state for draw-time condition filtering. + */ +export function compileStorylineFromEvent( + event: TCard, + registry: StorylineOptionRegistry, + state?: TState, +): CompiledStoryline { + const storylineId = event.storylineId ?? null; + const registered = registry.get(event.id); + if (registered) { + const options = registered.map((o) => ({ ...o })); + if (state) { + // Filter by condition at draw time: omit options whose condition + // callback returns false. Options without a condition (or whose + // condition returns true) are included unchanged. + return { + storylineId, + compiledOptions: options.filter( + (o) => o.condition == null || o.condition(state), + ), + }; + } + return { storylineId, compiledOptions: options }; + } + + if (event.options) { + // CSV-authored arbitrary option list. A non-empty encoding is always a + // choice node; malformed encodings throw here so a bad CSV value is + // rejected rather than silently ignored. + return { + storylineId, + compiledOptions: parseStorylineOptionsEncoding(event.options), + }; + } + + if (!event.hasChoices) { + return { storylineId, compiledOptions: [] }; + } + + return { + storylineId, + compiledOptions: [ + { + label: 'Accept', + successorId: event.acceptNextCardId ?? undefined, + effectPolicy: 'apply', + }, + { + label: 'Reject', + successorId: event.rejectNextCardId ?? undefined, + effectPolicy: 'skip', + }, + ] as StorylineOption[], + }; +} + +/** + * Returns true if the event presents a story-driven choice at resolution + * time — i.e. it has legacy `hasChoices`, or a non-empty `options` encoding, + * or an explicit registered option list. + * + * NOTE: a bare `storylineId` is descriptive metadata (grouping cards into a + * named arc) and does NOT by itself make a card a choice. Terminal escalation + * cards carry a `storylineId` for grouping/journal purposes but must still + * resolve as plain incidents. Only `hasChoices` / `options` / registered + * options intercept resolution. + */ +export function eventHasStoryline( + event: TCard, + registry: StorylineOptionRegistry, +): boolean { + return ( + Boolean(event.hasChoices) || + Boolean(event.options && event.options.trim() !== '') || + registry.has(event.id) + ); +} diff --git a/src/story-line/index.ts b/src/story-line/index.ts new file mode 100644 index 000000000..5b2ed90a0 --- /dev/null +++ b/src/story-line/index.ts @@ -0,0 +1,190 @@ +/** + * Generic, game-agnostic story-line system. + * + * A drawn card presents an ordered set of options; the player's choice + * selects the next card until the chain ends. This module owns the generic + * compilation, resolution, pending-choice lifecycle and journal helpers. + * Games supply state and presentation through the {@link StorylineEngineAdapter} + * and {@link StorylineUiAdapter} seams — core never imports game types and + * has no Phaser dependency. + * + * ```ts + * import { createStorylineSystem } from '@story-line'; + * + * const storyline = createStorylineSystem({ + * engine: myEngineAdapter, // rng / effect / log / deck sink + * ui: myUiAdapter, // optional presentation overrides + * }); + * const compiled = storyline.compileStorylineFromEvent(card, state); + * ``` + * + * @module + */ + +import { + compileStorylineFromEvent, + createStorylineOptionRegistry, + eventHasStoryline, + parseStorylineOptionsEncoding, +} from './compile'; +import { + buildJournal, + journalIsEmpty, + journalTitle, + parseStoryUpdate, +} from './journal'; +import { pushChainCard, resolveStorylineOption } from './resolve'; +import { createDefaultStorylineUiAdapter } from './ui-adapter'; +import type { + CompiledStoryline, + PendingStorylineChoice, + StorylineCard, + StorylineEngineAdapter, + StorylineJournalEntry, + StorylineOption, + StorylineResolution, + StorylineSystem, + StorylineUiAdapter, +} from './types'; + +// ── Public types ──────────────────────────────────────────── +export type { + ChoiceClarityLabels, + CompiledStoryline, + PendingStorylineChoice, + StorylineCard, + StorylineEngineAdapter, + StorylineJournalEntry, + StorylineLogEntry, + StorylineOption, + StorylineResolution, + StorylineSystem, + StorylineUiAdapter, +} from './types'; + +// ── Public runtime helpers ─────────────────────────────────── +export { STORY_UPDATE_PREFIX, parseStoryUpdate } from './journal'; +export { parseStorylineOptionsEncoding } from './compile'; +export { + createDefaultStorylineUiAdapter, + defaultStorylineUiAdapter, + defaultStorylineName, + defaultChoiceDialogTitle, + defaultChoiceDialogSubtitle, + defaultStoryUpdateLine, + defaultChoiceClarityLabels, + defaultJournalTitle, + defaultStorylineSummary, +} from './ui-adapter'; +export { pushChainCard, resolveStorylineOption } from './resolve'; +export { + buildJournal, + journalIsEmpty, + journalTitle, +} from './journal'; +export { + compileStorylineFromEvent, + createStorylineOptionRegistry, + eventHasStoryline, +} from './compile'; +export type { StorylineOptionRegistry } from './compile'; + +/** Configuration for {@link createStorylineSystem}. */ +export interface StorylineSystemConfig< + TState, + TCard extends StorylineCard = StorylineCard, +> { + /** Required state-access adapter for the target game. */ + engine: StorylineEngineAdapter; + /** + * Optional presentation adapter. Defaults to core's default UI adapter + * (dialog titles, choice-clarity labels, story-update lines, journal). + */ + ui?: StorylineUiAdapter; +} + +/** + * Creates a story-line system bound to a game's engine/UI adapters. + * + * The registered-option registry is scoped to the returned system instance, + * so games and tests never share registry state. Call + * {@link StorylineSystem.resetStorylineRegistry} in test teardown. + */ +export function createStorylineSystem< + TState, + TCard extends StorylineCard = StorylineCard, +>( + config: StorylineSystemConfig, +): StorylineSystem { + const engine = config.engine; + const ui = + config.ui ?? createDefaultStorylineUiAdapter(); + const registry = createStorylineOptionRegistry((id) => + engine.getBaseTypeId(id), + ); + + const compile = ( + card: TCard, + state?: TState, + ): CompiledStoryline => + compileStorylineFromEvent(card, registry, state); + + const getStorylineOptions = ( + card: TCard, + state?: TState, + ): StorylineOption[] => compile(card, state).compiledOptions; + + return { + engine, + ui, + + // ── Compilation ─────────────────────────────────────────── + compileStorylineFromEvent: compile, + eventHasStoryline: (card) => eventHasStoryline(card, registry), + getStorylineOptions, + storylineOptionCount: (card, state) => + getStorylineOptions(card, state).length, + parseStorylineOptionsEncoding: (encoding) => + parseStorylineOptionsEncoding(encoding), + registerStorylineOptions: (eventId, options) => + registry.register(eventId, options), + resetStorylineRegistry: () => registry.reset(), + hasRegisteredStorylineOptions: (eventId) => registry.has(eventId), + + // ── Resolution ──────────────────────────────────────────── + resolveStorylineOption: (state, card, option): StorylineResolution => + resolveStorylineOption(state, engine, card, option), + pushChainCard: (state, templateId) => + pushChainCard(state, engine, templateId), + + // ── Pending-choice lifecycle ────────────────────────────── + createPendingStorylineChoice: (card, state) => { + if (!eventHasStoryline(card, registry)) return null; + const pending: PendingStorylineChoice = { + event: card, + chosenOption: null, + resolved: false, + }; + if (state) { + // Freeze the presented option list at draw time: condition callbacks + // are evaluated here and never again during resolution. + pending.options = getStorylineOptions(card, state); + } + return pending; + }, + getPendingStorylineOptions: (pending, state) => { + if (pending.options) return [...pending.options]; + return getStorylineOptions(pending.event, state); + }, + markChoiceResolved: (pending, option) => { + pending.chosenOption = option; + pending.resolved = true; + }, + + // ── Journal ─────────────────────────────────────────────── + parseStoryUpdate, + buildJournal: (state): StorylineJournalEntry[] => buildJournal(state, engine), + journalIsEmpty: (state) => journalIsEmpty(state, engine), + journalTitle: (state) => journalTitle(state, engine, ui), + }; +} diff --git a/src/story-line/journal.ts b/src/story-line/journal.ts new file mode 100644 index 000000000..7603b1b73 --- /dev/null +++ b/src/story-line/journal.ts @@ -0,0 +1,75 @@ +/** + * Story-line journal helpers. + * + * Pure, Phaser-free helpers backing the story-line journal overlay. They + * derive journal rows from the activity log's "story update" lines + * (written when a choice resolves), most recent first. + * + * @module + */ + +import type { + StorylineEngineAdapter, + StorylineJournalEntry, + StorylineUiAdapter, +} from './types'; + +/** Prefix of the narrative lines written when a story-line choice resolves. */ +export const STORY_UPDATE_PREFIX = 'Story update — '; + +/** + * Parses a story-update log line into `{ storyline, outcome }`, or null when + * the text is not a story update. + */ +export function parseStoryUpdate( + text: string, +): { storyline: string; outcome: string } | null { + if (!text.startsWith(STORY_UPDATE_PREFIX)) return null; + const rest = text.slice(STORY_UPDATE_PREFIX.length); + const sep = rest.indexOf(': '); + if (sep < 0) return null; + return { + storyline: rest.slice(0, sep), + outcome: rest.slice(sep + 2), + }; +} + +/** + * Builds the journal from the activity log's story-update lines, most recent + * first. Only entries produced by story-line choices are included. + */ +export function buildJournal( + state: TState, + adapter: StorylineEngineAdapter, +): StorylineJournalEntry[] { + const entries: StorylineJournalEntry[] = []; + for (const log of adapter.getActivityLog(state)) { + const parsed = parseStoryUpdate(log.text); + if (!parsed) continue; + entries.push({ + turn: log.turn, + storyline: parsed.storyline, + outcome: parsed.outcome, + text: log.text, + }); + } + // Most recent first (a stable reverse — the log is append-only). + return entries.reverse(); +} + +/** True when the journal has no entries yet (empty state). */ +export function journalIsEmpty( + state: TState, + adapter: StorylineEngineAdapter, +): boolean { + return buildJournal(state, adapter).length === 0; +} + +/** Journal title: names the active story-line(s) when available. */ +export function journalTitle( + state: TState, + adapter: StorylineEngineAdapter, + ui: StorylineUiAdapter, +): string { + return ui.journalTitle(buildJournal(state, adapter)); +} diff --git a/src/story-line/resolve.ts b/src/story-line/resolve.ts new file mode 100644 index 000000000..ff2c90987 --- /dev/null +++ b/src/story-line/resolve.ts @@ -0,0 +1,119 @@ +/** + * Story-line resolution semantics: effect policy, successor precedence and + * deterministic chain-card naming. + * + * Successor precedence at resolution time: + * + * 1. `successorResolver` callback (when present) — invoked for the successor + * id; a `null`/`undefined` return ends the chain. Consumes no RNG. + * 2. Candidate set (`successorIds`) — exactly **one** seeded `engine.rng()` + * draw selects the successor uniformly at random (one draw per selection, + * at resolution time; replay with the same seed yields the same + * successor). An **empty** candidate list is a terminal option — the + * chain ends without consuming a draw. + * 3. Legacy single `successorId` — pushed directly, no draw. + * + * The `effectPolicy` is honoured regardless of how the successor is resolved: + * `'apply'` runs the event's effect, `'skip'` ignores it. + * + * @module + */ + +import type { + StorylineCard, + StorylineEngineAdapter, + StorylineOption, + StorylineResolution, +} from './types'; + +/** + * Pushes a chain successor card onto the active deck with deterministic + * serial naming. + * + * Deterministic serial: highest existing suffix for the base template across + * every chain-scan card location, +1. Replay-safe (no RNG / wall clock). + * + * @param state Current game state (mutated — the deck may grow). + * @param adapter The engine adapter (template lookup, base-type id, scan, + * push sink, log). + * @param templateId The card template ID to add (successorId), or + * null/undefined to end the chain. + * @returns The pushed card instance, or null when no chain card was requested + * or the template does not exist. + */ +export function pushChainCard( + state: TState, + adapter: StorylineEngineAdapter, + templateId: string | null | undefined, +): TCard | null { + if (!templateId) return null; // chain ends — nothing added. + const template = adapter.getTemplate(templateId); + if (!template) { + adapter.appendLog(state, `Chain card ${templateId} not found in card data.`, 'neutral'); + return null; + } + const base = adapter.getBaseTypeId(template.id); + let maxSerial = -1; + for (const card of adapter.getChainScanCards(state)) { + if (adapter.getBaseTypeId(card.id) !== base) continue; + const m = card.id.match(/-(\d+)$/); + const n = m ? Number(m[1]) : -1; + if (n > maxSerial) maxSerial = n; + } + const card: TCard = { ...template, id: `${base}-${maxSerial + 1}` }; + adapter.pushChainCard(state, card); + return card; +} + +/** + * Resolves a story-line option by applying its effect policy and pushing the + * successor card. + * + * See the module doc comment for the successor-precedence contract. The + * uniform candidate draw consumes exactly one `engine.rng()` call per + * selection (replay-safe with a seeded rng); terminal options (empty + * candidate list) consume no draw. + * + * @param state Current game state (mutated by effect application/push). + * @param adapter The engine adapter (rng draw, effect application, resources, + * chain-card sink). + * @param card The story-line card. + * @param option The chosen story-line option. + * @returns The resolution result (pushed card, deltas). + */ +export function resolveStorylineOption( + state: TState, + adapter: StorylineEngineAdapter, + card: TCard, + option: StorylineOption, +): StorylineResolution { + const before = adapter.getResources(state); + + // Apply effect if the option's policy says so. + if (option.effectPolicy === 'apply') { + adapter.applyEffect(state, card); + } + + const after = adapter.getResources(state); + const coinChange = after.coins - before.coins; + const repChange = after.reputation - before.reputation; + + // Determine the successor id (precedence per the module doc): the uniform + // draw consumes exactly one rng() call per selection. + let resolvedSuccessor: string | null | undefined; + if (option.successorResolver != null) { + resolvedSuccessor = option.successorResolver(state); + } else if (option.successorIds != null) { + resolvedSuccessor = + option.successorIds.length > 0 + ? option.successorIds[Math.floor(adapter.rng(state) * option.successorIds.length)] + : null; // terminal option — the chain ends, no draw. + } else { + resolvedSuccessor = option.successorId; + } + + // Push successor card if one exists. + const pushedCard = pushChainCard(state, adapter, resolvedSuccessor); + + return { pushedCard, coinChange, repChange }; +} diff --git a/src/story-line/types.ts b/src/story-line/types.ts new file mode 100644 index 000000000..f4809cba5 --- /dev/null +++ b/src/story-line/types.ts @@ -0,0 +1,289 @@ +/** + * Generic story-line types and adapter interfaces. + * + * This module is the game-agnostic core of the branching story-line mechanic: + * a drawn card presents an ordered set of options, and the player's choice + * determines the next card until the chain ends. + * + * Coupling to a concrete game is inverted through two interfaces: + * + * - {@link StorylineEngineAdapter} — state access (seeded `rng()` draw, effect + * application, activity-log append, chain-card sink). + * - {@link StorylineUiAdapter} — presentation (names, dialog labels, + * story-update lines, journal formatting). + * + * Core owns the compilation, resolution and pure-helper semantics; games plug + * their own state and presentation in without core importing any game types. + * Nothing here depends on Phaser. + * + * @module + */ + +/** + * The minimal card shape the story-line system operates on. A game's own card + * type must be structurally assignable to this interface. + * + * All fields other than `id` / `name` are optional so a game card can carry + * extra data without core caring about it. + */ +export interface StorylineCard { + /** Stable card template id (e.g. `evt-tax-error`). */ + readonly id: string; + /** Human-readable card name. */ + readonly name: string; + /** Human-readable effect description used by the default UI labels. */ + readonly effect?: string; + /** Coin delta applied when the card's effect resolves. */ + readonly coinDelta?: number; + /** Reputation delta applied when the card's effect resolves. */ + readonly reputationDelta?: number; + /** + * Legacy two-way choice flag. When true the card compiles to + * Accept (apply) / Reject (skip) options. + */ + readonly hasChoices?: boolean; + /** Legacy accept successor card id (null / absent ends the chain). */ + readonly acceptNextCardId?: string | null; + /** Legacy reject successor card id (null / absent ends the chain). */ + readonly rejectNextCardId?: string | null; + /** + * Raw CSV options encoding for an arbitrary multi-outcome choice node. + * Format: `label|cardA,cardB;label2|cardC`. + */ + readonly options?: string; + /** Optional story-arc identifier; `null`/absent means "no storyline". */ + readonly storylineId?: string | null; + /** Optional human-readable story-arc title (defaults to the id). */ + readonly storylineTitle?: string | null; +} + +/** A single activity-log entry the journal reads. */ +export interface StorylineLogEntry { + /** Turn the entry was written on. */ + readonly turn: number; + /** The log line text. */ + readonly text: string; +} + +/** A single journal row: a past story-line choice outcome. */ +export interface StorylineJournalEntry { + /** Turn the choice resolved on. */ + readonly turn: number; + /** Story-line display name. */ + readonly storyline: string; + /** The outcome summary (text after the story-line name). */ + readonly outcome: string; + /** The original log line. */ + readonly text: string; +} + +/** The explanatory labels for the choice buttons. */ +export interface ChoiceClarityLabels { + readonly accept: string; + readonly reject: string; +} + +/** + * A single option within a story-line's ordered option list. + * + * The engine resolves options in declaration order; the player selects one. + * Generic over the game state type passed to the runtime callbacks. + */ +export interface StorylineOption { + /** Human-readable label (e.g. "Accept", "Reject", "Investigate"). */ + readonly label: string; + /** + * The single card id to push when this option is chosen. Null / absent + * ends the chain. Retained for the programmatic registry seam and + * transitional legacy compilation; CSV-authored options use + * {@link successorIds}. + */ + readonly successorId?: string | null | undefined; + /** + * Ordered candidate successor card ids. At resolution time exactly one + * candidate is selected with a single seeded `rng()` draw. An empty list + * is a terminal option — the chain ends and no draw is consumed. + */ + readonly successorIds?: readonly string[]; + /** Whether the event's intrinsic effect applies when this option is chosen. */ + readonly effectPolicy: 'apply' | 'skip'; + /** + * Optional runtime condition evaluated at draw time. When it returns + * `false` the option is omitted from the presented list. Evaluated once, + * when the option list is compiled at draw time. + * + * Contract: must be a pure function — no observable side effects. + */ + readonly condition?: (state: TState) => boolean; + /** + * Optional runtime successor resolver evaluated at resolution time. When + * present, its return value is used as the pushed successor card id + * instead of {@link successorId} / {@link successorIds}. Returns + * `null`/`undefined` to end the chain. Consumes no RNG. + * + * Contract: must be a pure function — no observable side effects. + */ + readonly successorResolver?: (state: TState) => string | null | undefined; +} + +/** A card's compiled story-line option list. */ +export interface CompiledStoryline { + /** The story-line identifier this definition belongs to, or null. */ + readonly storylineId: string | null; + /** Ordered options; empty for non-choice cards. */ + readonly compiledOptions: StorylineOption[]; +} + +/** Pending-choice state, created when a choice card is drawn. */ +export interface PendingStorylineChoice< + TState = unknown, + TCard extends StorylineCard = StorylineCard, +> { + /** The choice event drawn from the deck (effect deferred). */ + readonly event: TCard; + /** The chosen option's label token once made; null while pending. */ + chosenOption: string | null; + /** False while the dialog is pending; true after the choice is applied. */ + resolved: boolean; + /** + * Draw-time snapshot of the compiled option list. When present, resolution + * reads this frozen list (condition callbacks are never re-evaluated). + */ + options?: readonly StorylineOption[]; +} + +/** Result of resolving a story-line option. */ +export interface StorylineResolution { + /** The card pushed as a consequence, or null if the chain ends. */ + readonly pushedCard: TCard | null; + /** The coin change from applying the effect (0 for skip). */ + readonly coinChange: number; + /** The reputation change from applying the effect (0 for skip). */ + readonly repChange: number; +} + +/** + * State-access adapter a game supplies so the generic system can operate on + * its state without importing game types. + */ +export interface StorylineEngineAdapter { + /** Return the seeded random number in `[0, 1)` for this draw. */ + rng(state: TState): number; + /** Apply the card's intrinsic effect to the game state. */ + applyEffect(state: TState, card: TCard): void; + /** Current spendable resources, for delta calculation. */ + getResources(state: TState): { coins: number; reputation: number }; + /** Append a line to the game's activity log. */ + appendLog(state: TState, text: string, kind?: string): void; + /** Read the game's activity log (append-only, oldest first). */ + getActivityLog(state: TState): readonly StorylineLogEntry[]; + /** Look up a card template id, or null when it does not exist. */ + getTemplate(id: string): TCard | null; + /** Strip an instance serial suffix from a card id (`evt-x-3` → `evt-x`). */ + getBaseTypeId(id: string): string; + /** + * Every card currently in a location that chain serial naming scans + * (e.g. incident deck, event deck, event discard) — the sink for + * deterministic `${base}-${maxSerial+1}` naming. + */ + getChainScanCards(state: TState): readonly TCard[]; + /** Push a newly created chain card onto the game's active deck. */ + pushChainCard(state: TState, card: TCard): void; +} + +/** + * Presentation adapter. Core ships a default implementation + * ({@link createDefaultStorylineUiAdapter}); a game overrides any subset to + * change wording without touching core logic. + */ +export interface StorylineUiAdapter< + TState = unknown, + TCard extends StorylineCard = StorylineCard, +> { + /** Human-readable story-line name: explicit title, else the id, else null. */ + storylineName(card: TCard): string | null; + /** Dialog title for a pending choice. */ + choiceDialogTitle(card: TCard): string; + /** Dialog subtitle (the specific incident within the story-line). */ + choiceDialogSubtitle(card: TCard): string; + /** The narrative "story update" line emitted when a choice resolves. */ + storyUpdateLine( + card: TCard, + option: string, + coinChange: number, + repChange: number, + ): string | null; + /** + * Explanatory labels for the choice buttons. Must not reveal the + * escalation card id. + */ + choiceClarityLabels( + card: TCard, + options: readonly StorylineOption[], + ): ChoiceClarityLabels; + /** Journal title for a list of journal entries. */ + journalTitle(entries: readonly StorylineJournalEntry[]): string; + /** A one-line summary of a story-line card for a tooltip/intro. */ + storylineSummary(card: TCard): string; +} + +/** + * The composed story-line system: generic compilation, resolution, pending + * choice lifecycle and journal helpers, bound to a game's adapters. + */ +export interface StorylineSystem { + readonly engine: StorylineEngineAdapter; + readonly ui: StorylineUiAdapter; + + // ── Compilation ───────────────────────────────────────────── + /** Compile a card's story-line into the generalised option model. */ + compileStorylineFromEvent(card: TCard, state?: TState): CompiledStoryline; + /** True when the card presents a story-driven choice. */ + eventHasStoryline(card: TCard): boolean; + /** Ordered options for a card (empty when non-choice). */ + getStorylineOptions(card: TCard, state?: TState): StorylineOption[]; + /** Number of options (0 for non-choice). */ + storylineOptionCount(card: TCard, state?: TState): number; + /** Parse a CSV `options` encoding into the generalised model. */ + parseStorylineOptionsEncoding(encoding: string): StorylineOption[]; + /** Register an explicit ordered option list for an event id. */ + registerStorylineOptions(eventId: string, options: readonly StorylineOption[]): void; + /** Clear all registered options (test isolation). */ + resetStorylineRegistry(): void; + /** True when an explicit option list is registered for the event id. */ + hasRegisteredStorylineOptions(eventId: string): boolean; + + // ── Resolution ────────────────────────────────────────────── + /** Apply the chosen option and push its successor (if any). */ + resolveStorylineOption( + state: TState, + card: TCard, + option: StorylineOption, + ): StorylineResolution; + /** Push a chain successor card with deterministic serial naming. */ + pushChainCard(state: TState, templateId: string | null | undefined): TCard | null; + + // ── Pending-choice lifecycle ──────────────────────────────── + /** Create a pending choice from a drawn card (null when non-choice). */ + createPendingStorylineChoice( + card: TCard, + state?: TState, + ): PendingStorylineChoice | null; + /** Options presented for a pending choice (snapshot preferred). */ + getPendingStorylineOptions( + pending: PendingStorylineChoice, + state?: TState, + ): StorylineOption[]; + /** Mark a pending choice resolved with the chosen option label. */ + markChoiceResolved(pending: PendingStorylineChoice, option: string): void; + + // ── Journal ───────────────────────────────────────────────── + /** Parse a "story update" log line, or null when it is not one. */ + parseStoryUpdate(text: string): { storyline: string; outcome: string } | null; + /** Build the journal (most recent first) from the activity log. */ + buildJournal(state: TState): StorylineJournalEntry[]; + /** True when the journal has no entries yet. */ + journalIsEmpty(state: TState): boolean; + /** Journal title, naming the story-lines seen. */ + journalTitle(state: TState): string; +} diff --git a/src/story-line/ui-adapter.ts b/src/story-line/ui-adapter.ts new file mode 100644 index 000000000..c23fb78d1 --- /dev/null +++ b/src/story-line/ui-adapter.ts @@ -0,0 +1,159 @@ +/** + * Pluggable story-line UI adapter. + * + * Core ships a default implementation covering dialog titles, choice-clarity + * labels, story-update lines and journal formatting. Games override any + * subset to change wording without touching core logic. No Phaser dependency. + * + * The default strings are the ones Main Street has shipped since the + * mechanic was introduced; keeping them here means a game that supplies no + * override keeps the same player-visible text. + * + * @module + */ + +import type { + ChoiceClarityLabels, + StorylineCard, + StorylineJournalEntry, + StorylineOption, + StorylineUiAdapter, +} from './types'; + +/** Human-readable story-line name: explicit title, else the id, else null. */ +export function defaultStorylineName(card: StorylineCard): string | null { + if (!card.storylineId) return null; + return card.storylineTitle ?? card.storylineId; +} + +/** + * Dialog title for a pending choice. Prefers the story-line name so the + * player sees which arc they are in; falls back to the card name. + */ +export function defaultChoiceDialogTitle(card: StorylineCard): string { + return defaultStorylineName(card) ?? card.name; +} + +/** + * Dialog subtitle: the specific incident within the story-line (empty when + * the card has no storyline, so the dialog stays unchanged for legacy choice + * cards). + */ +export function defaultChoiceDialogSubtitle(card: StorylineCard): string { + return card.storylineId ? card.name : ''; +} + +/** Formats a delta summary for the story-update line. */ +function describeDelta(coinChange: number, repChange: number): string { + const parts: string[] = []; + if (coinChange !== 0) { + parts.push(`${coinChange > 0 ? '+' : ''}${Math.round(coinChange)} coins`); + } + if (repChange !== 0) { + parts.push(`${repChange > 0 ? '+' : ''}${Math.round(repChange)} rep`); + } + return parts.length > 0 ? ` (${parts.join(', ')})` : ''; +} + +/** + * The concise narrative "story update" line emitted when a choice resolves. + * Returns null for cards without a `storylineId` (no storyline, no update). + * + * @param card The resolved story-line card. + * @param option The chosen option label ('accept' | 'reject' | custom). + * @param coinChange Applied coin delta (0 for skip options). + * @param repChange Applied reputation delta (0 for skip options). + */ +export function defaultStoryUpdateLine( + card: StorylineCard, + option: string, + coinChange: number, + repChange: number, +): string | null { + const name = defaultStorylineName(card); + if (!name) return null; + const accepted = option.toLowerCase() === 'accept'; + if (accepted) { + return `Story update — ${name}: you accepted the consequence${describeDelta(coinChange, repChange)}.`; + } + if (option.toLowerCase() === 'reject') { + return `Story update — ${name}: you refused the consequence; the thread continues.`; + } + // Generalised multi-way option: name the option. + return `Story update — ${name}: you chose "${option}"${describeDelta(coinChange, repChange)}.`; +} + +/** + * Builds the choice-clarity labels for a pending choice card. + * + * The labels explain what each option *does* (apply vs skip the incident's + * effect) without revealing which card the chain escalates to. For the legacy + * two-option model the labels are Accept/Reject; the text is derived from the + * compiled options' effect policies. + */ +export function defaultChoiceClarityLabels( + card: StorylineCard, + options: readonly StorylineOption[], +): ChoiceClarityLabels { + const acceptOption = options[0]; + const rejectOption = options[1]; + + const accept = + acceptOption?.effectPolicy === 'apply' + ? `Accept: apply “${card.effect ?? ''}”.` + : 'Accept: continue without applying an effect.'; + + // Never name the escalation: only the semantic (skip + a different + // incident). CSV-authored options always apply the event effect, so an + // 'apply' Reject still leads to a different incident — say so honestly + // instead of implying the effect is waived. + const reject = + rejectOption?.effectPolicy === 'skip' + ? 'Reject: refuse this consequence; a different incident will follow.' + : 'Reject: this consequence is applied; a different incident will follow.'; + + return { accept, reject }; +} + +/** Journal title: names the active story-line(s) when available. */ +export function defaultJournalTitle( + entries: readonly StorylineJournalEntry[], +): string { + const names = [...new Set(entries.map((e) => e.storyline))]; + if (names.length === 0) return 'Storyline Journal'; + return `Storyline Journal — ${names.join(', ')}`; +} + +/** A one-line summary of a story-line card for the journal tooltip/intro. */ +export function defaultStorylineSummary(card: StorylineCard): string { + const name = defaultStorylineName(card) ?? card.name; + return `Storyline: ${name}`; +} + +/** + * Builds the default UI adapter for a given state/card pair. + * + * The default adapter is stateless, so a single shared instance would work; + * this factory exists so games can pass a concrete `TState`/`TCard` without + * a cast and override individual methods by spreading the result. + */ +export function createDefaultStorylineUiAdapter< + TState = unknown, + TCard extends StorylineCard = StorylineCard, +>(): StorylineUiAdapter { + return { + storylineName: (card) => defaultStorylineName(card), + choiceDialogTitle: (card) => defaultChoiceDialogTitle(card), + choiceDialogSubtitle: (card) => defaultChoiceDialogSubtitle(card), + storyUpdateLine: (card, option, coinChange, repChange) => + defaultStoryUpdateLine(card, option, coinChange, repChange), + choiceClarityLabels: (card, options) => + defaultChoiceClarityLabels(card, options), + journalTitle: (entries) => defaultJournalTitle(entries), + storylineSummary: (card) => defaultStorylineSummary(card), + }; +} + +/** The default UI adapter, typed for the generic (unparameterised) case. */ +export const defaultStorylineUiAdapter: StorylineUiAdapter = + createDefaultStorylineUiAdapter(); diff --git a/tests/scripts/game-repo-scaffold.test.ts b/tests/scripts/game-repo-scaffold.test.ts index d6f1b79e7..b080a36aa 100644 --- a/tests/scripts/game-repo-scaffold.test.ts +++ b/tests/scripts/game-repo-scaffold.test.ts @@ -395,6 +395,8 @@ describe('renderTsconfig', () => { ['@ai/*', 'src/ai/*'], ['@balance-cards', 'src/balance-cards'], ['@balance-cards/*', 'src/balance-cards/*'], + ['@story-line', 'src/story-line'], + ['@story-line/*', 'src/story-line/*'], ['@core-scripts/*', 'scripts/*'], ['@core-tests/*', 'tests/*'], ['@core-gym', 'example-games/gym'], diff --git a/tests/story-line/compile.test.ts b/tests/story-line/compile.test.ts new file mode 100644 index 000000000..3f08f6f9a --- /dev/null +++ b/tests/story-line/compile.test.ts @@ -0,0 +1,330 @@ +/** + * Story-line compilation parity tests (MS-0MVFM9HGI004XADO AC1/AC2). + * + * Ports Main Street's storyline-model / storyline-csv-options compilation + * cases onto the generic story-line API: + * + * - legacy `hasChoices` → Accept(apply)/Reject(skip) compilation; + * - CSV `options` encoding parsing (whitespace trimming, terminal options, + * malformed/rejected encodings, `apply` policy); + * - registered-option precedence over legacy/CSV compilation; + * - draw-time condition filtering (evaluated once, at compile time); + * - `eventHasStoryline` semantics (a bare `storylineId` is NOT a choice). + * + * @module + */ +import { describe, expect, it } from 'vitest'; + +import { createStorylineSystem } from '../../src/story-line'; +import { + makeAdapter, + makeCard, + makeState, + type TemplateMap, +} from './helpers'; + +/** A system instance bound to an empty template catalogue + adapter. */ +function makeSystem() { + const templates: TemplateMap = new Map(); + const engine = makeAdapter(templates); + const system = createStorylineSystem({ engine }); + return { system, templates, engine, state: makeState() }; +} + +// ── AC1: legacy hasChoices compilation ────────────────────── + +describe('legacy `hasChoices` compilation', () => { + it('compiles a two-way choice to Accept(apply) / Reject(skip)', () => { + const { system } = makeSystem(); + const card = makeCard({ + id: 'evt-legacy', + hasChoices: true, + acceptNextCardId: 'evt-next-a', + rejectNextCardId: 'evt-next-b', + }); + + const compiled = system.compileStorylineFromEvent(card); + expect(compiled.storylineId).toBeNull(); + expect(compiled.compiledOptions).toEqual([ + { label: 'Accept', successorId: 'evt-next-a', effectPolicy: 'apply' }, + { label: 'Reject', successorId: 'evt-next-b', effectPolicy: 'skip' }, + ]); + }); + + it('null successor ids compile to terminal options', () => { + const { system } = makeSystem(); + const card = makeCard({ id: 'evt-legacy', hasChoices: true }); + const compiled = system.compileStorylineFromEvent(card); + expect(compiled.compiledOptions[0]?.successorId).toBeUndefined(); + expect(compiled.compiledOptions[1]?.successorId).toBeUndefined(); + }); + + it('a non-choice card compiles to an empty option list', () => { + const { system } = makeSystem(); + const card = makeCard({ id: 'evt-plain' }); + expect(system.compileStorylineFromEvent(card).compiledOptions).toEqual([]); + }); + + it('a card with only a storylineId is NOT a choice', () => { + const { system } = makeSystem(); + const card = makeCard({ id: 'evt-terminal', storylineId: 'arc', storylineTitle: 'Arc' }); + expect(system.eventHasStoryline(card)).toBe(false); + expect(system.compileStorylineFromEvent(card).compiledOptions).toEqual([]); + expect(system.storylineOptionCount(card)).toBe(0); + }); + + it('carries storyline metadata through compilation', () => { + const { system } = makeSystem(); + const card = makeCard({ + id: 'evt-story', + storylineId: 'storyline-alpha', + storylineTitle: 'Alpha Arc', + hasChoices: true, + }); + const compiled = system.compileStorylineFromEvent(card); + expect(compiled.storylineId).toBe('storyline-alpha'); + expect(compiled.compiledOptions).toHaveLength(2); + }); +}); + +// ── AC1: CSV `options` encoding ───────────────────────────── + +describe('CSV `options` encoding compilation', () => { + it('compiles an arbitrary ordered option list with candidate sets', () => { + const { system } = makeSystem(); + const card = makeCard({ + id: 'evt-multi', + storylineId: 'arc-multi', + options: 'Negotiate|evt-multi-a,evt-multi-b;Walk away|;Escalate|evt-multi-c', + }); + + const compiled = system.compileStorylineFromEvent(card); + expect(compiled.compiledOptions.map((o) => o.label)).toEqual([ + 'Negotiate', + 'Walk away', + 'Escalate', + ]); + expect(compiled.compiledOptions[0]?.successorIds).toEqual([ + 'evt-multi-a', + 'evt-multi-b', + ]); + expect(compiled.compiledOptions[1]?.successorIds).toEqual([]); + expect(compiled.compiledOptions[2]?.successorIds).toEqual(['evt-multi-c']); + }); + + it('compilation is deterministic and returns fresh option objects', () => { + const { system } = makeSystem(); + const card = makeCard({ id: 'evt-multi', options: 'One|a,b;Two|' }); + + const first = system.compileStorylineFromEvent(card).compiledOptions; + const second = system.compileStorylineFromEvent(card).compiledOptions; + expect(first).toEqual(second); + expect(first).not.toBe(second); + expect(first[0]).not.toBe(second[0]); + }); + + it('whitespace around labels, separators and candidate ids is trimmed', () => { + const { system } = makeSystem(); + const card = makeCard({ + id: 'evt-padded', + options: ' Negotiate | evt-a , evt-b ; Walk away | ', + }); + + const options = system.compileStorylineFromEvent(card).compiledOptions; + expect(options.map((o) => o.label)).toEqual(['Negotiate', 'Walk away']); + expect(options[0]?.successorIds).toEqual(['evt-a', 'evt-b']); + expect(options[1]?.successorIds).toEqual([]); + expect(options.every((o) => o.effectPolicy === 'apply')).toBe(true); + }); + + it('a blank / whitespace-only options cell is a non-choice node', () => { + const { system } = makeSystem(); + expect(system.eventHasStoryline(makeCard({ id: 'evt-plain' }))).toBe(false); + expect( + system.eventHasStoryline(makeCard({ id: 'evt-spaces', options: ' ' })), + ).toBe(false); + expect(system.compileStorylineFromEvent(makeCard({ id: 'evt-spaces', options: ' ' })).compiledOptions).toEqual([]); + }); + + it('hasChoices is derived from a non-empty options encoding', () => { + const { system } = makeSystem(); + const card = makeCard({ id: 'evt-derived', options: 'Go|' }); + expect(system.eventHasStoryline(card)).toBe(true); + expect(system.storylineOptionCount(card)).toBe(1); + }); + + it('every CSV-authored option carries effectPolicy "apply"', () => { + const { system } = makeSystem(); + const card = makeCard({ id: 'evt-apply', options: 'One|a;Two|;Three|b' }); + const policies = system.getStorylineOptions(card).map((o) => o.effectPolicy); + expect(policies).toEqual(['apply', 'apply', 'apply']); + }); +}); + +describe('malformed `options` encodings are rejected', () => { + const { system } = makeSystem(); + + it('a non-empty encoding that decodes to no valid option throws', () => { + expect(() => system.parseStorylineOptionsEncoding(';;')).toThrow(/no valid option/); + expect(() => + system.compileStorylineFromEvent(makeCard({ id: 'evt-bad', options: ' ; ; ' })), + ).toThrow(/no valid option/); + }); + + it('an option segment missing the label/successor separator is rejected', () => { + expect(() => + system.parseStorylineOptionsEncoding('Negotiate;Go|evt-a'), + ).toThrow(/missing the "\|" separator/); + }); + + it('an empty label is rejected', () => { + expect(() => system.parseStorylineOptionsEncoding('|evt-a')).toThrow(/empty label/); + expect(() => system.parseStorylineOptionsEncoding('Good|evt-a;|evt-b')).toThrow( + /empty label/, + ); + }); + + it('blank encodings decode to an empty option list (non-choice)', () => { + expect(system.parseStorylineOptionsEncoding('')).toEqual([]); + expect(system.parseStorylineOptionsEncoding(' \n ')).toEqual([]); + }); +}); + +// ── AC2: registered-option precedence ─────────────────────── + +describe('registered-option precedence', () => { + it('registered options take precedence over legacy compilation', () => { + const { system } = makeSystem(); + const card = makeCard({ + id: 'evt-reg', + hasChoices: true, + acceptNextCardId: 'evt-a', + rejectNextCardId: 'evt-b', + }); + + system.registerStorylineOptions('evt-reg', [ + { label: 'Investigate', successorIds: ['evt-c'], effectPolicy: 'apply' }, + ]); + expect(system.getStorylineOptions(card).map((o) => o.label)).toEqual([ + 'Investigate', + ]); + }); + + it('registered options take precedence over CSV options', () => { + const { system } = makeSystem(); + const card = makeCard({ id: 'evt-reg-csv', options: 'CSV|evt-a' }); + + system.registerStorylineOptions('evt-reg-csv', [ + { label: 'Programmatic', successorId: 'evt-b', effectPolicy: 'skip' }, + ]); + expect(system.getStorylineOptions(card).map((o) => o.label)).toEqual([ + 'Programmatic', + ]); + }); + + it('registry lookups are keyed by base type id (instance-serial tolerant)', () => { + const { system } = makeSystem(); + system.registerStorylineOptions('evt-serial', [ + { label: 'Go', successorId: 'evt-x', effectPolicy: 'apply' }, + ]); + const card = makeCard({ id: 'evt-serial-3' }); + expect(system.hasRegisteredStorylineOptions('evt-serial-3')).toBe(true); + expect(system.eventHasStoryline(card)).toBe(true); + }); + + it('resetStorylineRegistry clears all registrations (test isolation)', () => { + const { system } = makeSystem(); + system.registerStorylineOptions('evt-r1', [ + { label: 'Go', successorId: 'evt-x', effectPolicy: 'apply' }, + ]); + expect(system.hasRegisteredStorylineOptions('evt-r1')).toBe(true); + system.resetStorylineRegistry(); + expect(system.hasRegisteredStorylineOptions('evt-r1')).toBe(false); + expect( + system.eventHasStoryline(makeCard({ id: 'evt-r1' })), + ).toBe(false); + }); + + it('registry state is scoped per system instance', () => { + const { system: a } = makeSystem(); + const { system: b } = makeSystem(); + a.registerStorylineOptions('evt-shared', [ + { label: 'A', successorId: 'evt-x', effectPolicy: 'apply' }, + ]); + expect(a.hasRegisteredStorylineOptions('evt-shared')).toBe(true); + expect(b.hasRegisteredStorylineOptions('evt-shared')).toBe(false); + }); +}); + +// ── AC2: draw-time condition filtering ────────────────────── + +describe('draw-time condition filtering', () => { + it('omits options whose condition returns false when state is provided', () => { + const { system, state } = makeSystem(); + system.registerStorylineOptions('evt-cond', [ + { label: 'Always', effectPolicy: 'apply', condition: () => true }, + { label: 'Rich only', effectPolicy: 'apply', condition: (s) => s.coins > 500 }, + { label: 'Never', effectPolicy: 'apply', condition: () => false }, + ]); + + const card = makeCard({ id: 'evt-cond' }); + const options = system.getStorylineOptions(card, state); + expect(options.map((o) => o.label)).toEqual(['Always', 'Rich only']); + }); + + it('includes all options when condition is absent or returns true', () => { + const { system, state } = makeSystem(); + system.registerStorylineOptions('evt-cond2', [ + { label: 'No cond', effectPolicy: 'apply' }, + { label: 'True cond', effectPolicy: 'apply', condition: () => true }, + ]); + expect( + system.getStorylineOptions(makeCard({ id: 'evt-cond2' }), state).map((o) => o.label), + ).toEqual(['No cond', 'True cond']); + }); + + it('includes every option when no state is provided (legacy behaviour)', () => { + const { system } = makeSystem(); + system.registerStorylineOptions('evt-cond3', [ + { label: 'Yes', effectPolicy: 'apply', condition: () => false }, + ]); + expect( + system.getStorylineOptions(makeCard({ id: 'evt-cond3' })).map((o) => o.label), + ).toEqual(['Yes']); + }); + + it('condition filtering does not mutate the registered list', () => { + const { system, state } = makeSystem(); + const card = makeCard({ id: 'evt-cond4' }); + system.registerStorylineOptions('evt-cond4', [ + { label: 'Drop', effectPolicy: 'apply', condition: () => false }, + { label: 'Keep', effectPolicy: 'apply' }, + ]); + expect(system.getStorylineOptions(card).map((o) => o.label)).toEqual(['Drop', 'Keep']); + expect(system.getStorylineOptions(card, state).map((o) => o.label)).toEqual(['Keep']); + }); +}); + +// ── eventHasStoryline semantics ───────────────────────────── + +describe('eventHasStoryline', () => { + const { system } = makeSystem(); + + it('is true for legacy hasChoices, CSV options and registered options', () => { + expect(system.eventHasStoryline(makeCard({ id: 'evt-h', hasChoices: true }))).toBe(true); + expect(system.eventHasStoryline(makeCard({ id: 'evt-o', options: 'Go|a' }))).toBe(true); + }); + + it('is false for plain cards and bare-storylineId cards', () => { + expect(system.eventHasStoryline(makeCard({ id: 'evt-plain' }))).toBe(false); + expect(system.eventHasStoryline(makeCard({ id: 'evt-story', storylineId: 'arc' }))).toBe(false); + }); + + it('is true for a registered choice even without card fields', () => { + const { system: sys } = makeSystem(); + sys.registerStorylineOptions('evt-reg', [ + { label: 'X', effectPolicy: 'apply' }, + ]); + expect(sys.eventHasStoryline(makeCard({ id: 'evt-reg' }))).toBe(true); + }); +}); diff --git a/tests/story-line/helpers.ts b/tests/story-line/helpers.ts new file mode 100644 index 000000000..6cb93be07 --- /dev/null +++ b/tests/story-line/helpers.ts @@ -0,0 +1,115 @@ +/** + * Shared test fixtures for the story-line parity tests. + * + * A minimal in-memory state + engine adapter double exercising the generic + * story-line API without any game dependency. Mirrors the shape of Main + * Street's engine adapter (resolved later by the adapters work item). + * + * @module + */ + +import type { + StorylineCard, + StorylineEngineAdapter, + StorylineLogEntry, +} from '../../src/story-line'; + +/** Minimal card shape used by the tests (structurally assignable). */ +export interface TestCard extends StorylineCard { + readonly id: string; + readonly name: string; +} + +/** Minimal in-memory game state the double mutates. */ +export interface TestState { + coins: number; + reputation: number; + turn: number; + activityLog: StorylineLogEntry[]; + deck: TestCard[]; + discards: TestCard[]; + /** Deterministic RNG queue consumed by `engine.rng` (default 0.5). */ + rngQueue: number[]; + /** Total `engine.rng` calls (asserting the one-draw-per-selection rule). */ + rngCalls: number; +} + +/** Builds a well-formed test card with the given overrides. */ +export function makeCard(overrides: Partial & { id: string }): TestCard { + return { + name: overrides.id.replace(/^evt-/, '').replace(/-/g, ' '), + ...overrides, + } as TestCard; +} + +/** Builds a fresh test state. */ +export function makeState(overrides: Partial = {}): TestState { + return { + coins: 1000, + reputation: 50, + turn: 1, + activityLog: [], + deck: [], + discards: [], + rngQueue: [], + rngCalls: 0, + ...overrides, + }; +} + +/** A template catalogue: card template id → card. */ +export type TemplateMap = Map; + +/** Builds a template from a card spec and adds it to the map. */ +export function addTemplate( + templates: TemplateMap, + card: TestCard, +): TestCard { + templates.set(card.id, card); + return card; +} + +/** + * Builds the engine adapter double for the test state. + * + * Semantics mirror Main Street's adapter contract: + * - `rng` pops the next queued value (fallback 0.5) and counts the call. + * - `applyEffect` applies `coinDelta` / `reputationDelta`. + * - `pushChainCard` pushes onto `state.deck`; the serial-naming scan covers + * `deck` + `discards`. + */ +export function makeAdapter( + templates: TemplateMap, +): StorylineEngineAdapter { + return { + rng(state) { + state.rngCalls += 1; + return state.rngQueue.length > 0 ? state.rngQueue.shift()! : 0.5; + }, + applyEffect(state, card) { + state.coins += card.coinDelta ?? 0; + state.reputation += card.reputationDelta ?? 0; + }, + getResources(state) { + return { coins: state.coins, reputation: state.reputation }; + }, + appendLog(state, text) { + state.activityLog.push({ turn: state.turn, text }); + }, + getActivityLog(state) { + return state.activityLog; + }, + getTemplate(id) { + return templates.get(id) ?? null; + }, + getBaseTypeId(id) { + return id.replace(/-\d+$/, ''); + }, + getChainScanCards(state) { + return [...state.deck, ...state.discards]; + }, + pushChainCard(state, card) { + state.deck.push(card); + }, + }; +} diff --git a/tests/story-line/journal.test.ts b/tests/story-line/journal.test.ts new file mode 100644 index 000000000..062ac12a7 --- /dev/null +++ b/tests/story-line/journal.test.ts @@ -0,0 +1,140 @@ +/** + * Story-line journal parity tests (MS-0MVFM9HGI004XADO AC4). + * + * Ports Main Street's storyline-journal cases onto the generic API: + * + * - `parseStoryUpdate` well-formed / non-story / malformed lines; + * - `buildJournal` collects story updates most recent first, recording the + * turn and outcome text, and ignoring non-story lines; + * - `journalIsEmpty` empty/non-empty state; + * - `journalTitle` distinct story-line names. + * + * @module + */ +import { describe, expect, it } from 'vitest'; + +import { + STORY_UPDATE_PREFIX, + createStorylineSystem, +} from '../../src/story-line'; +import { makeAdapter, makeState, type TestCard } from './helpers'; + +function makeSystem() { + const map = new Map(); + const engine = makeAdapter(map); + return { system: createStorylineSystem({ engine }), state: makeState() }; +} + +// ── parseStoryUpdate ──────────────────────────────────────── + +describe('parseStoryUpdate', () => { + const { system } = makeSystem(); + + it('parses a well-formed story update line', () => { + expect( + system.parseStoryUpdate( + 'Story update — Tax Troubles: you accepted the consequence (-200 coins).', + ), + ).toEqual({ + storyline: 'Tax Troubles', + outcome: 'you accepted the consequence (-200 coins).', + }); + }); + + it('returns null for non-story lines', () => { + expect(system.parseStoryUpdate('Incident: Rainy Day (-100 coins)')).toBeNull(); + expect(system.parseStoryUpdate('')).toBeNull(); + }); + + it('returns null for a malformed update (missing separator)', () => { + expect( + system.parseStoryUpdate(`${STORY_UPDATE_PREFIX}Tax Troubles without separator`), + ).toBeNull(); + }); + + it('exposes the canonical prefix', () => { + expect(STORY_UPDATE_PREFIX).toBe('Story update — '); + }); +}); + +// ── buildJournal ──────────────────────────────────────────── + +describe('buildJournal', () => { + it('collects story updates from the activity log in reverse order', () => { + const { system, state } = makeSystem(); + state.activityLog = [ + { turn: 1, text: 'Story update — Public Health Crisis: you refused the consequence; the thread continues.' }, + { turn: 2, text: 'Incident: Rainy Day (-100 coins)' }, + { turn: 3, text: 'Story update — Tax Troubles: you accepted the consequence (-200 coins).' }, + ]; + + const journal = system.buildJournal(state); + expect(journal.map((e) => e.storyline)).toEqual(['Tax Troubles', 'Public Health Crisis']); + }); + + it('records the turn and outcome text', () => { + const { system, state } = makeSystem(); + state.activityLog = [ + { turn: 7, text: 'Story update — Tax Troubles: you accepted the consequence (-200 coins).' }, + ]; + const entry = system.buildJournal(state)[0]!; + expect(entry.turn).toBe(7); + expect(entry.storyline).toBe('Tax Troubles'); + expect(entry.outcome).toContain('accepted'); + expect(entry.text).toBe(state.activityLog[0]!.text); + }); + + it('ignores non-story log lines', () => { + const { system, state } = makeSystem(); + state.activityLog = [ + { turn: 1, text: 'Incident: Rainy Day (-100 coins)' }, + { turn: 2, text: 'Tax collected (+50 coins)' }, + ]; + expect(system.buildJournal(state)).toEqual([]); + }); +}); + +// ── journalIsEmpty ────────────────────────────────────────── + +describe('journalIsEmpty', () => { + it('is true before any choice is made', () => { + const { system, state } = makeSystem(); + expect(system.journalIsEmpty(state)).toBe(true); + expect(system.buildJournal(state)).toEqual([]); + expect(system.journalTitle(state)).toBe('Storyline Journal'); + }); + + it('is false after a story-line choice resolves', () => { + const { system, state } = makeSystem(); + state.activityLog = [ + { turn: 1, text: 'Story update — Tax Troubles: you accepted the consequence.' }, + ]; + expect(system.journalIsEmpty(state)).toBe(false); + }); +}); + +// ── journalTitle ──────────────────────────────────────────── + +describe('journalTitle', () => { + it('lists distinct story-line names only', () => { + const { system, state } = makeSystem(); + state.activityLog = [ + { turn: 1, text: 'Story update — Public Health Crisis: you refused the consequence.' }, + { turn: 2, text: 'Story update — Public Health Crisis: you accepted the consequence.' }, + ]; + const title = system.journalTitle(state); + expect(title).toContain('Storyline Journal'); + expect(title).toContain('Public Health Crisis'); + // Distinct names only (no duplicate). + expect(title.split('Public Health Crisis').length - 1).toBe(1); + }); + + it('names multiple distinct story-lines', () => { + const { system, state } = makeSystem(); + state.activityLog = [ + { turn: 1, text: 'Story update — Tax Troubles: you accepted the consequence.' }, + { turn: 2, text: 'Story update — Public Health Crisis: you refused the consequence.' }, + ]; + expect(system.journalTitle(state)).toContain('Public Health Crisis, Tax Troubles'); + }); +}); diff --git a/tests/story-line/resolve.test.ts b/tests/story-line/resolve.test.ts new file mode 100644 index 000000000..2b41d09db --- /dev/null +++ b/tests/story-line/resolve.test.ts @@ -0,0 +1,275 @@ +/** + * Story-line resolution parity tests (MS-0MVFM9HGI004XADO AC3). + * + * Ports Main Street's resolution cases onto the generic API: + * + * - `apply` / `skip` effect policy; + * - successor precedence (resolver → seeded candidate draw → legacy id); + * - exactly one seeded `rng()` draw per candidate selection at resolution + * time, terminal options consuming no draw; + * - deterministic chain-card serial naming across deck locations; + * - pending-choice lifecycle (draw-time snapshot, resolve marking). + * + * @module + */ +import { describe, expect, it } from 'vitest'; + +import { createStorylineSystem } from '../../src/story-line'; +import { + addTemplate, + makeAdapter, + makeCard, + makeState, + type TemplateMap, + type TestCard, +} from './helpers'; + +/** Builds a system plus its template catalogue. */ +function setup(templates: TestCard[] = []) { + const map: TemplateMap = new Map(); + for (const t of templates) addTemplate(map, t); + const engine = makeAdapter(map); + const system = createStorylineSystem({ engine }); + return { system, map, engine, state: makeState() }; +} + +// ── AC3: effect policy ────────────────────────────────────── + +describe('effect policy', () => { + it('apply runs the card effect and reports the deltas', () => { + const { system, state } = setup(); + const card = makeCard({ id: 'evt-eff', coinDelta: -200, reputationDelta: 5 }); + const res = system.resolveStorylineOption(state, card, { + label: 'Accept', + effectPolicy: 'apply', + }); + + expect(res.coinChange).toBe(-200); + expect(res.repChange).toBe(5); + expect(state.coins).toBe(800); + expect(state.reputation).toBe(55); + }); + + it('skip leaves the effect unapplied and reports zero deltas', () => { + const { system, state } = setup(); + const card = makeCard({ id: 'evt-eff', coinDelta: -200, reputationDelta: 5 }); + const res = system.resolveStorylineOption(state, card, { + label: 'Reject', + effectPolicy: 'skip', + }); + + expect(res.coinChange).toBe(0); + expect(res.repChange).toBe(0); + expect(state.coins).toBe(1000); + expect(state.reputation).toBe(50); + }); +}); + +// ── AC3: successor precedence ─────────────────────────────── + +describe('successor precedence', () => { + it('resolver wins over candidates and legacy id, consuming no rng', () => { + const { system, state } = setup([makeCard({ id: 'evt-resolved' })]); + const res = system.resolveStorylineOption(state, makeCard({ id: 'evt-card' }), { + label: 'Go', + effectPolicy: 'apply', + successorResolver: () => 'evt-resolved', + successorIds: ['evt-cand'], + successorId: 'evt-legacy', + }); + + expect(res.pushedCard?.id).toBe('evt-resolved-0'); + expect(state.rngCalls).toBe(0); + }); + + it('a resolver returning null ends the chain without a draw', () => { + const { system, state } = setup(); + const res = system.resolveStorylineOption(state, makeCard({ id: 'evt-card' }), { + label: 'Stop', + effectPolicy: 'apply', + successorResolver: () => null, + }); + expect(res.pushedCard).toBeNull(); + expect(state.rngCalls).toBe(0); + }); + + it('candidate set consumes exactly one seeded draw per selection', () => { + const { system, state } = setup([ + makeCard({ id: 'evt-a' }), + makeCard({ id: 'evt-b' }), + ]); + state.rngQueue = [0]; + system.resolveStorylineOption(state, makeCard({ id: 'evt-card' }), { + label: 'Pick', + effectPolicy: 'apply', + successorIds: ['evt-a', 'evt-b'], + }); + expect(state.rngCalls).toBe(1); + }); + + it('the single draw selects uniformly across the candidate list', () => { + const { system } = setup([ + makeCard({ id: 'evt-a' }), + makeCard({ id: 'evt-b' }), + makeCard({ id: 'evt-c' }), + ]); + + const low = makeState({ rngQueue: [0] }); + const lowRes = system.resolveStorylineOption(low, makeCard({ id: 'evt-card' }), { + label: 'Pick', + effectPolicy: 'apply', + successorIds: ['evt-a', 'evt-b', 'evt-c'], + }); + expect(lowRes.pushedCard?.id).toBe('evt-a-0'); + + const high = makeState({ rngQueue: [0.99] }); + const highRes = system.resolveStorylineOption(high, makeCard({ id: 'evt-card' }), { + label: 'Pick', + effectPolicy: 'apply', + successorIds: ['evt-a', 'evt-b', 'evt-c'], + }); + expect(highRes.pushedCard?.id).toBe('evt-c-0'); + }); + + it('a terminal (empty candidate) option consumes no draw', () => { + const { system, state } = setup(); + const res = system.resolveStorylineOption(state, makeCard({ id: 'evt-card' }), { + label: 'Walk away', + effectPolicy: 'apply', + successorIds: [], + }); + expect(res.pushedCard).toBeNull(); + expect(state.rngCalls).toBe(0); + }); + + it('a legacy single successorId is pushed directly, consuming no draw', () => { + const { system, state } = setup([makeCard({ id: 'evt-legacy' })]); + const res = system.resolveStorylineOption(state, makeCard({ id: 'evt-card' }), { + label: 'Accept', + successorId: 'evt-legacy', + effectPolicy: 'apply', + }); + expect(res.pushedCard?.id).toBe('evt-legacy-0'); + expect(state.rngCalls).toBe(0); + }); +}); + +// ── AC3: deterministic chain-card naming ──────────────────── + +describe('deterministic chain-card naming', () => { + it('sums the highest serial across deck and discard locations, +1', () => { + const { system, state } = setup([makeCard({ id: 'evt-chain', name: 'Chain' })]); + state.deck = [makeCard({ id: 'evt-chain-0' }), makeCard({ id: 'evt-chain-2' })]; + state.discards = [makeCard({ id: 'evt-chain-5' })]; + + const card = system.pushChainCard(state, 'evt-chain'); + expect(card?.id).toBe('evt-chain-6'); + expect(state.deck[state.deck.length - 1]?.id).toBe('evt-chain-6'); + }); + + it('starts at serial 0 when no instance exists', () => { + const { system, state } = setup([makeCard({ id: 'evt-chain' })]); + expect(system.pushChainCard(state, 'evt-chain')?.id).toBe('evt-chain-0'); + }); + + it('ignores serials from unrelated base ids', () => { + const { system, state } = setup([makeCard({ id: 'evt-chain' })]); + state.deck = [makeCard({ id: 'evt-other-9' })]; + expect(system.pushChainCard(state, 'evt-chain')?.id).toBe('evt-chain-0'); + }); + + it('is replay-safe: repeated pushes increment monotonically', () => { + const { system, state } = setup([makeCard({ id: 'evt-chain' })]); + expect(system.pushChainCard(state, 'evt-chain')?.id).toBe('evt-chain-0'); + expect(system.pushChainCard(state, 'evt-chain')?.id).toBe('evt-chain-1'); + expect(system.pushChainCard(state, 'evt-chain')?.id).toBe('evt-chain-2'); + }); + + it('copies the template and overrides only the id', () => { + const { system, state } = setup([ + makeCard({ id: 'evt-chain', name: 'Chain', coinDelta: -50, storylineId: 'arc' }), + ]); + const card = system.pushChainCard(state, 'evt-chain'); + expect(card).toMatchObject({ id: 'evt-chain-0', name: 'Chain', coinDelta: -50, storylineId: 'arc' }); + }); + + it('null / undefined ends the chain with no push', () => { + const { system, state } = setup(); + expect(system.pushChainCard(state, null)).toBeNull(); + expect(system.pushChainCard(state, undefined)).toBeNull(); + expect(state.deck).toHaveLength(0); + }); + + it('a missing template logs a neutral line and pushes nothing', () => { + const { system, state } = setup(); + expect(system.pushChainCard(state, 'evt-missing')).toBeNull(); + expect(state.deck).toHaveLength(0); + expect(state.activityLog[state.activityLog.length - 1]?.text).toContain( + 'Chain card evt-missing not found in card data.', + ); + }); +}); + +// ── AC3: pending-choice lifecycle ─────────────────────────── + +describe('pending-choice lifecycle', () => { + it('returns null for a non-choice card', () => { + const { system } = setup(); + expect(system.createPendingStorylineChoice(makeCard({ id: 'evt-plain' }))).toBeNull(); + }); + + it('creates a pending choice for a choice card', () => { + const { system } = setup(); + const pending = system.createPendingStorylineChoice( + makeCard({ id: 'evt-choice', hasChoices: true }), + ); + expect(pending).not.toBeNull(); + expect(pending?.chosenOption).toBeNull(); + expect(pending?.resolved).toBe(false); + }); + + it('freezes the condition-filtered option list at draw time', () => { + const { system, state } = setup(); + let evaluations = 0; + system.registerStorylineOptions('evt-cond', [ + { + label: 'Rich only', + effectPolicy: 'apply', + condition: (s) => { + evaluations += 1; + return s.coins > 500; + }, + }, + ]); + + const pending = system.createPendingStorylineChoice(makeCard({ id: 'evt-cond' }), state); + expect(pending?.options?.map((o) => o.label)).toEqual(['Rich only']); + expect(evaluations).toBe(1); + + // Mutating state after the draw cannot change the frozen snapshot. + state.coins = 0; + expect(system.getPendingStorylineOptions(pending!, state).map((o) => o.label)).toEqual([ + 'Rich only', + ]); + expect(evaluations).toBe(1); + }); + + it('without state the pending choice has no snapshot and recompiles on demand', () => { + const { system } = setup(); + const pending = system.createPendingStorylineChoice( + makeCard({ id: 'evt-choice', options: 'Go|evt-a' }), + ); + expect(pending?.options).toBeUndefined(); + expect(system.getPendingStorylineOptions(pending!).map((o) => o.label)).toEqual(['Go']); + }); + + it('markChoiceResolved records the chosen label and resolution flag', () => { + const { system } = setup(); + const pending = system.createPendingStorylineChoice( + makeCard({ id: 'evt-choice', hasChoices: true }), + )!; + system.markChoiceResolved(pending, 'Accept'); + expect(pending.chosenOption).toBe('Accept'); + expect(pending.resolved).toBe(true); + }); +}); diff --git a/tests/story-line/ui-adapter.test.ts b/tests/story-line/ui-adapter.test.ts new file mode 100644 index 000000000..41db56816 --- /dev/null +++ b/tests/story-line/ui-adapter.test.ts @@ -0,0 +1,217 @@ +/** + * Story-line UI adapter parity tests (MS-0MVFM9HGI004XADO AC4). + * + * Ports Main Street's storyline-player-agency / journal choice-clarity cases + * onto the generic API and pins the pluggable seam: + * + * - the default UI adapter's dialog titles, subtitles, story-update lines, + * choice-clarity labels, journal title and summary match today's wording; + * - choice-clarity labels never reveal the escalation card id; + * - a game-supplied adapter overrides presentation without touching core. + * + * @module + */ +import { describe, expect, it } from 'vitest'; + +import { + createDefaultStorylineUiAdapter, + createStorylineSystem, + defaultChoiceClarityLabels, + defaultStorylineName, +} from '../../src/story-line'; +import { makeAdapter, makeCard, type TestCard } from './helpers'; + +/** Non-storyline card factory (mirrors the agency test's `makeEvent`). */ +function makeEvent(overrides: Omit[0], 'id'> & { id?: string }) { + return makeCard({ id: 'evt-x', name: 'X', ...overrides }); +} + +/** A system with an optional UI override. */ +function makeSystem(ui?: Parameters[0]['ui']) { + const engine = makeAdapter(new Map()); + return createStorylineSystem({ engine, ui }); +} + +// ── storylineName ─────────────────────────────────────────── + +describe('storylineName', () => { + it('prefers the title, falls back to the id, then null', () => { + expect(defaultStorylineName(makeEvent({ id: 's1', storylineId: 's1', storylineTitle: 'Arc One' }))).toBe('Arc One'); + expect(defaultStorylineName(makeEvent({ id: 's1', storylineId: 's1' }))).toBe('s1'); + expect(defaultStorylineName(makeEvent({ id: 's1' }))).toBeNull(); + }); + + it('is reachable through the system UI adapter', () => { + const system = makeSystem(); + expect( + system.ui.storylineName(makeEvent({ id: 's1', storylineId: 's1', storylineTitle: 'Arc One' })), + ).toBe('Arc One'); + }); +}); + +// ── choiceDialogTitle / Subtitle ──────────────────────────── + +describe('choice dialog titles', () => { + const system = makeSystem(); + + it('shows the story-line name when available', () => { + const card = makeEvent({ + id: 'evt-flu-outbreak', + name: 'Flu Outbreak', + storylineId: 'storyline-health', + storylineTitle: 'Public Health Crisis', + }); + expect(system.ui.choiceDialogTitle(card)).toBe('Public Health Crisis'); + expect(system.ui.choiceDialogSubtitle(card)).toBe('Flu Outbreak'); + }); + + it('falls back to the card name for non-storyline choices', () => { + const plain = makeCard({ id: 'evt-plain', name: 'Plain Choice', hasChoices: true }); + expect(system.ui.choiceDialogTitle(plain)).toBe('Plain Choice'); + expect(system.ui.choiceDialogSubtitle(plain)).toBe(''); + }); +}); + +// ── storyUpdateLine ───────────────────────────────────────── + +describe('storyUpdateLine', () => { + const system = makeSystem(); + const tax = makeCard({ + id: 'evt-tax-error', + name: 'Tax Error', + storylineId: 'storyline-tax', + storylineTitle: 'Tax Troubles', + effect: 'Lose 200 coins', + coinDelta: -200, + }); + + it('emits an accept line tied to the story-line title', () => { + expect(system.ui.storyUpdateLine(tax, 'accept', -200, 0)).toBe( + 'Story update — Tax Troubles: you accepted the consequence (-200 coins).', + ); + }); + + it('emits a reject line that keeps the thread open', () => { + const line = system.ui.storyUpdateLine(tax, 'reject', 0, 0); + expect(line).toContain('Tax Troubles'); + expect(line).toContain('refused'); + }); + + it('returns null for cards without a storyline', () => { + expect(system.ui.storyUpdateLine(makeEvent({ id: 'evt-plain', hasChoices: true }), 'accept', -10, 0)).toBeNull(); + }); + + it('names a generalised multi-way option label and formats deltas', () => { + const line = system.ui.storyUpdateLine( + makeCard({ id: 'evt-multi', storylineId: 's1', storylineTitle: 'Arc' }), + 'Investigate', + 50, + 0, + ); + expect(line).toContain('Investigate'); + expect(line).toContain('+50 coins'); + }); + + it('formats reputation deltas too', () => { + const line = system.ui.storyUpdateLine(tax, 'accept', 0, 3); + expect(line).toContain('+3 rep'); + }); +}); + +// ── choiceClarityLabels ───────────────────────────────────── + +describe('choiceClarityLabels', () => { + const system = makeSystem(); + + it('explains apply semantics for Accept', () => { + const card = makeCard({ id: 'evt-tax-error', effect: 'Lose 200 coins', hasChoices: true }); + const labels = system.ui.choiceClarityLabels(card, system.getStorylineOptions(card)); + expect(labels.accept).toContain('apply'); + expect(labels.accept).toContain('Lose 200 coins'); + }); + + it('explains applied-but-branching semantics for CSV multi-way cards', () => { + const card = makeCard({ id: 'evt-multi', options: 'One|a;Two|b' }); + const labels = system.ui.choiceClarityLabels(card, system.getStorylineOptions(card)); + expect(labels.reject.toLowerCase()).toContain('different incident'); + expect(labels.reject.toLowerCase()).toContain('applied'); + }); + + it('explains skip semantics for a legacy Reject option', () => { + const card = makeCard({ id: 'evt-legacy', effect: 'Lose 100 coins', hasChoices: true }); + const labels = system.ui.choiceClarityLabels(card, system.getStorylineOptions(card)); + expect(labels.reject.toLowerCase()).toContain('refuse this consequence'); + }); + + it('does NOT reveal the escalation card id', () => { + const card = makeCard({ + id: 'evt-leak-check', + effect: 'Lose 100 coins', + options: 'Grind through|evt-secret-escalation;Walk away|', + }); + const labels = system.ui.choiceClarityLabels(card, system.getStorylineOptions(card)); + const revealed = `${labels.accept} ${labels.reject}`; + expect(revealed).not.toContain('evt-secret-escalation'); + }); + + it('falls back gracefully for a generalised multi-way card', () => { + const card = makeCard({ id: 'evt-multi', hasChoices: true, effect: 'x' }); + const labels = system.ui.choiceClarityLabels(card, system.getStorylineOptions(card)); + expect(labels.accept.length).toBeGreaterThan(0); + expect(labels.reject.length).toBeGreaterThan(0); + }); + + it('is exposed as a pure standalone helper', () => { + const card = makeCard({ id: 'evt-help', effect: 'Lose 5 coins', hasChoices: true }); + const options = system.getStorylineOptions(card); + expect(defaultChoiceClarityLabels(card, options)).toEqual( + system.ui.choiceClarityLabels(card, options), + ); + }); +}); + +// ── journalTitle / storylineSummary ───────────────────────── + +describe('journal formatting', () => { + const system = makeSystem(); + + it('names distinct story-line names in the journal title', () => { + const entries = [ + { turn: 1, storyline: 'Tax Troubles', outcome: 'x', text: 'a' }, + { turn: 2, storyline: 'Tax Troubles', outcome: 'y', text: 'b' }, + { turn: 3, storyline: 'Public Health Crisis', outcome: 'z', text: 'c' }, + ]; + expect(system.ui.journalTitle(entries)).toBe( + 'Storyline Journal — Tax Troubles, Public Health Crisis', + ); + }); + + it('falls back to the plain journal title when empty', () => { + expect(system.ui.journalTitle([])).toBe('Storyline Journal'); + }); + + it('summarises a story-line card', () => { + expect( + system.ui.storylineSummary(makeCard({ id: 'evt-s', name: 'Card', storylineId: 'arc', storylineTitle: 'Arc One' })), + ).toBe('Storyline: Arc One'); + expect(system.ui.storylineSummary(makeCard({ id: 'evt-s', name: 'Card' }))).toBe('Storyline: Card'); + }); +}); + +// ── Pluggable seam ────────────────────────────────────────── + +describe('pluggable UI adapter', () => { + it('a game-supplied adapter overrides presentation without touching core', () => { + const system = makeSystem({ + ...createDefaultStorylineUiAdapter(), + storyUpdateLine: () => 'CUSTOM UPDATE', + choiceDialogTitle: () => 'CUSTOM TITLE', + }); + + const card = makeCard({ id: 'evt-x', hasChoices: true, storylineId: 's1' }); + expect(system.ui.storyUpdateLine(card, 'accept', 0, 0)).toBe('CUSTOM UPDATE'); + expect(system.ui.choiceDialogTitle(card)).toBe('CUSTOM TITLE'); + // Unoverridden methods still use the default implementation. + expect(system.ui.choiceDialogSubtitle(makeCard({ id: 'evt-y', name: 'Y', storylineId: 's1' }))).toBe('Y'); + }); +}); diff --git a/tests/vite-config.test.ts b/tests/vite-config.test.ts index 2faf837ad..b28e034ad 100644 --- a/tests/vite-config.test.ts +++ b/tests/vite-config.test.ts @@ -62,7 +62,7 @@ describe('vite config transcript plugin registration', () => { }); describe('vite config core path aliases', () => { - const ALIAS_KEYS = ['@core-engine', '@card-system', '@rule-engine', '@ui', '@ai']; + const ALIAS_KEYS = ['@core-engine', '@card-system', '@rule-engine', '@ui', '@ai', '@story-line']; it('points core aliases at this checkout by default', () => { const original = process.env.CORE_ROOT; diff --git a/tsconfig.json b/tsconfig.json index 48429fc44..0832a8e2e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -30,6 +30,8 @@ "@ai/*": ["src/ai/*"], "@balance-cards": ["src/balance-cards"], "@balance-cards/*": ["src/balance-cards/*"], + "@story-line": ["src/story-line"], + "@story-line/*": ["src/story-line/*"], "@core-scripts/*": ["scripts/*"], "@core-tests/*": ["tests/*"], "@core-gym": ["example-games/gym"], From c6b1bc82c22cd760da1f829df67558ea60413617 Mon Sep 17 00:00:00 2001 From: Sorra the Orc <250240+SorraTheOrc@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:28:11 +0100 Subject: [PATCH 09/39] MS-0MVFMA2XD002Z0A5: Gym storyline demo scene Adds GymStorylineScene, a proof-of-concept demo of the generic @story-line module: a small branching fixture (two-way and terminal options) where the player draws a card, picks an option, and the choice deals the next card until the chain ends. Uses the default core UI adapter to demonstrate the pluggable seam. Registers the scene in the Gym registry/catalogue, the barrel, the auto-generated scene list, the individual-scene smoke test and the Gym index. --- docs/gym/GYM_INDEX.md | 1 + example-games/gym/GymRegistry.ts | 9 + example-games/gym/index.ts | 2 + example-games/gym/scenes/GymStorylineScene.ts | 315 ++++++++++++++++++ scripts/vite-game-discovery-plugin.ts | 1 + tests/gym/GymSceneSmoke.browser.test.ts | 3 + 6 files changed, 331 insertions(+) create mode 100644 example-games/gym/scenes/GymStorylineScene.ts diff --git a/docs/gym/GYM_INDEX.md b/docs/gym/GYM_INDEX.md index d55967780..db0c562db 100644 --- a/docs/gym/GYM_INDEX.md +++ b/docs/gym/GYM_INDEX.md @@ -42,6 +42,7 @@ npx vitest run --project browser tests/gym/*.browser.test.ts | DPR Resolution Comparison | `GymDprResolutionScene` | `rasteriseSvgToTexture`, `makeTextureKey`, `MIN_QUALITY_SCALE`, `markSceneValid`, `markSceneInvalid`, SLL `anchorPoint` | [`scenes/GymDprResolutionScene.ts`](../../example-games/gym/scenes/GymDprResolutionScene.ts) | [`GymDprResolutionScene.browser.test.ts`](../../tests/gym/GymDprResolutionScene.browser.test.ts) | | Card Type Index & Synergy Groups | `GymCardIndexScene` | Main Street template getters (`getBusinessTemplates`, `getCommunitySpaceTemplates`, `getEventTemplates`, `getUpgradeTemplates`, `getStaffCardTemplates`), `buildCardTooltipInfo`, `TooltipManager`, `createOverlayDialog`, SLL `anchorPoint` | [`scenes/GymCardIndexScene.ts`](../../example-games/gym/scenes/GymCardIndexScene.ts), [`GymCardIndex.ts`](../../example-games/gym/GymCardIndex.ts) | [`GymCardIndex.test.ts`](../../tests/gym/GymCardIndex.test.ts), [`GymCardIndexScene.browser.test.ts`](../../tests/gym/GymCardIndexScene.browser.test.ts), [`GymRegistry.test.ts`](../../tests/gym/GymRegistry.test.ts), [`GymSceneSmoke.browser.test.ts`](../../tests/gym/GymSceneSmoke.browser.test.ts) | | In-game DLC Gate | `GymDlcUnlockScene` | `createDlcGate` (`DlcGate`, `DlcUnlockReader`), `contentUnlockClientFromWindow` (F5 total read client), SLL `anchorPoint` | [`scenes/GymDlcUnlockScene.ts`](../../example-games/gym/scenes/GymDlcUnlockScene.ts) | [`DlcGate.test.ts`](../../tests/core-engine/DlcGate.test.ts), [`GymDlcUnlock.browser.test.ts`](../../tests/gym/GymDlcUnlock.browser.test.ts), [`GymSceneSmoke.browser.test.ts`](../../tests/gym/GymSceneSmoke.browser.test.ts) | +| Storyline | `GymStorylineScene` | `createStorylineSystem`, `compileStorylineFromEvent`, `resolveStorylineOption`, `createPendingStorylineChoice`, `defaultStorylineUiAdapter` | [`scenes/GymStorylineScene.ts`](../../example-games/gym/scenes/GymStorylineScene.ts) | [`GymSceneSmoke.browser.test.ts`](../../tests/gym/GymSceneSmoke.browser.test.ts) | ## Scene Navigation diff --git a/example-games/gym/GymRegistry.ts b/example-games/gym/GymRegistry.ts index 3aa037e92..1e392e58e 100644 --- a/example-games/gym/GymRegistry.ts +++ b/example-games/gym/GymRegistry.ts @@ -91,6 +91,9 @@ export const GYM_RULE_ENGINE_KEY = 'GymRuleEngineScene'; /** In-game DLC gate proof scene key. */ export const GYM_DLC_UNLOCK_KEY = 'GymDlcUnlockScene'; +/** Generic story-line (branching narrative) demo scene key. */ +export const GYM_STORYLINE_KEY = 'GymStorylineScene'; + // ── Registry ────────────────────────────────────────────── /** @@ -118,6 +121,12 @@ export const GYM_SCENE_CATALOGUE: GymSceneEntry[] = [ description: 'Open and close help/settings overlays. Toggle feedback intensity and observe live UI changes.', }, + { + sceneKey: GYM_STORYLINE_KEY, + title: 'Storyline', + description: + 'Deal a branching narrative from the generic @story-line module: draw a card, pick an option, and watch the choice determine the next card until the chain ends.', + }, { sceneKey: GYM_UNDO_REDO_KEY, title: 'Undo / Redo', diff --git a/example-games/gym/index.ts b/example-games/gym/index.ts index 0c70b429d..34d3e9679 100644 --- a/example-games/gym/index.ts +++ b/example-games/gym/index.ts @@ -34,6 +34,7 @@ export { GymSpatialRulesScene } from './scenes/GymSpatialRulesScene'; export { GymTokenPileViewScene } from './scenes/GymTokenPileViewScene'; export { GymRuleEngineScene } from './scenes/GymRuleEngineScene'; export { GymDlcUnlockScene } from './scenes/GymDlcUnlockScene'; +export { GymStorylineScene } from './scenes/GymStorylineScene'; export { GYM_ROUTER_KEY, @@ -60,6 +61,7 @@ export { GYM_TOKEN_PILE_VIEW_KEY, GYM_RULE_ENGINE_KEY, GYM_DLC_UNLOCK_KEY, + GYM_STORYLINE_KEY, GYM_SCENE_CATALOGUE, } from './GymRegistry'; export type { GymSceneEntry } from './GymRegistry'; \ No newline at end of file diff --git a/example-games/gym/scenes/GymStorylineScene.ts b/example-games/gym/scenes/GymStorylineScene.ts new file mode 100644 index 000000000..0bafd8d75 --- /dev/null +++ b/example-games/gym/scenes/GymStorylineScene.ts @@ -0,0 +1,315 @@ +/** + * GymStorylineScene -- Demonstrates the generic, game-agnostic story-line + * system (`@story-line`) end to end. + * + * A small hand of story cards deals a branching narrative: the player draws a + * card, sees the compiled options, picks one, and the choice determines the + * next card until the chain ends. The scene deliberately uses only the core + * story-line module and its **default UI adapter** (no Main Street code), so + * it doubles as a worked example of the pluggable seams: + * + * - `createStorylineSystem({ engine })` binds a tiny in-scene engine adapter + * (seeded rng, effect application, activity log, chain-card sink) to the + * default presentation adapter. + * - `compileStorylineFromEvent` / `createPendingStorylineChoice` drive the + * choice dialog; `resolveStorylineOption` applies the effect and pushes the + * successor card. + * + * @module example-games/gym/scenes/GymStorylineScene + */ + +import Phaser from 'phaser'; +import { GymSceneBase } from './GymSceneBase'; +import { GYM_STORYLINE_KEY } from '../GymRegistry'; +import { createHudText } from '../../../src/ui/Renderer'; +import { GAME_W } from '../../../src/ui/constants'; +import { + createStorylineSystem, + type PendingStorylineChoice, + type StorylineCard, + type StorylineEngineAdapter, + type StorylineLogEntry, + type StorylineOption, + type StorylineSystem, +} from '@story-line'; +import { SCENE_HEADER_Y } from './GymConstants'; + +/** The demo's minimal game state. */ +interface DemoState { + coins: number; + reputation: number; + turn: number; + rngSeed: number; + activityLog: StorylineLogEntry[]; + deck: StorylineCard[]; +} + +/** The demo's card catalogue (templates, no instance serials). */ +const DEMO_CARDS: readonly StorylineCard[] = [ + { + id: 'gym-story-start', + name: 'Town Meeting', + storylineId: 'gym-story', + storylineTitle: 'Gym Storyline', + effect: 'Lose 50 coins', + coinDelta: -50, + options: 'Invest|gym-story-invest;Ignore|gym-story-ignore', + }, + { + id: 'gym-story-invest', + name: 'Public Investment', + storylineId: 'gym-story', + storylineTitle: 'Gym Storyline', + effect: 'Gain 120 coins', + coinDelta: 120, + options: 'Celebrate|gym-story-end;Double down|', + }, + { + id: 'gym-story-ignore', + name: 'Neglected Roads', + storylineId: 'gym-story', + storylineTitle: 'Gym Storyline', + effect: 'Lose 80 coins', + coinDelta: -80, + options: 'Repair|gym-story-end;Let it be|', + }, + { + id: 'gym-story-end', + name: 'New Beginning', + storylineId: 'gym-story', + storylineTitle: 'Gym Storyline', + effect: 'Gain 60 reputation', + reputationDelta: 60, + }, +]; + +const TEMPLATES = new Map( + DEMO_CARDS.map((card) => [card.id, card]), +); + +/** Deterministic LCG so the demo is replay-safe (no candidate draws here). */ +function nextRandom(state: DemoState): number { + state.rngSeed = (state.rngSeed * 1664525 + 1013904223) >>> 0; + return state.rngSeed / 0x100000000; +} + +/** Engine adapter mapping the generic seams onto the demo state. */ +function createDemoEngine(): StorylineEngineAdapter { + return { + rng: (state) => nextRandom(state), + applyEffect: (state, card) => { + state.coins += card.coinDelta ?? 0; + state.reputation += card.reputationDelta ?? 0; + }, + getResources: (state) => ({ coins: state.coins, reputation: state.reputation }), + appendLog: (state, text) => { + state.activityLog.push({ turn: state.turn, text }); + }, + getActivityLog: (state) => state.activityLog, + getTemplate: (id) => TEMPLATES.get(id) ?? null, + getBaseTypeId: (id) => id.replace(/-\d+$/, ''), + getChainScanCards: (state) => state.deck, + pushChainCard: (state, card) => { + state.deck.push(card); + }, + }; +} + +const LOG_MAX_LINES = 6; + +export class GymStorylineScene extends GymSceneBase { + constructor() { + super({ key: GYM_STORYLINE_KEY }); + } + + private system!: StorylineSystem; + private state!: DemoState; + private currentCard: StorylineCard | null = null; + private pending: PendingStorylineChoice | null = null; + private finished = false; + + private titleText!: Phaser.GameObjects.Text; + private subtitleText!: Phaser.GameObjects.Text; + private cardText!: Phaser.GameObjects.Text; + private statusText!: Phaser.GameObjects.Text; + private logText!: Phaser.GameObjects.Text; + private optionButtons: Phaser.GameObjects.Text[] = []; + + private readonly optionStartY = SCENE_HEADER_Y + 210; + + create(): void { + this.cameras.main.setBackgroundColor('#1a2a1a'); + this.initHeader('Storyline'); + this.addDivider(); + this.initReducedMotion(); + + this.initHelp([ + { + heading: 'What this demonstrates', + body: 'The generic story-line system from `@story-line`: a drawn card presents an ordered set of compiled options, the player picks one, and the choice applies the card effect and pushes the next card onto the chain. It is game-agnostic — a thin engine adapter supplies state access and the default UI adapter supplies presentation.', + }, + { + heading: 'Controls', + body: 'Each option button is generated from the current card\'s compiled options. The choice-clarity labels explain what each option does without revealing the next card. [ Reset ] restarts the demo from the first card.', + }, + { + heading: 'Test plan', + body: '1. The Town Meeting card offers Invest / Ignore.\n2. Picking Invest gains 120 coins and deals Public Investment, which offers Celebrate / Double down.\n3. Celebrate deals the terminal New Beginning card, applies its reputation effect, and ends the chain.\n4. Double down (a terminal option) ends the chain immediately with no successor.\n5. Reset returns to the first card.', + }, + ]); + + this.system = createStorylineSystem({ engine: createDemoEngine() }); + + const cx = GAME_W / 2; + this.titleText = createHudText(this, cx, SCENE_HEADER_Y + 30, '', '#ffffff', { + fontSize: '28px', + }).setOrigin(0.5); + this.subtitleText = createHudText(this, cx, SCENE_HEADER_Y + 65, '', '#99cc99', { + fontSize: '18px', + }).setOrigin(0.5); + this.cardText = createHudText(this, cx, SCENE_HEADER_Y + 105, '', '#ffdd99', { + fontSize: '20px', + }).setOrigin(0.5); + this.statusText = createHudText(this, cx, SCENE_HEADER_Y + 145, '', '#ffffff', { + fontSize: '18px', + }).setOrigin(0.5); + this.logText = createHudText(this, 60, SCENE_HEADER_Y + 360, '', '#88bb88', { + fontSize: '15px', + align: 'left', + }); + + this.renderResetButton(); + this.resetDemo(); + } + + private renderResetButton(): void { + // A single persistent reset control, right-aligned. + const reset = createHudText(this, GAME_W - 40, SCENE_HEADER_Y + 30, '[ Reset ]', '#ffffff', { + fontSize: '18px', + }).setOrigin(1, 0.5); + reset.setInteractive({ useHandCursor: true }); + reset.on('pointerdown', () => this.resetDemo()); + } + + private resetDemo(): void { + this.state = { + coins: 1000, + reputation: 50, + turn: 1, + rngSeed: 0x5eed, + activityLog: [], + deck: [], + }; + this.finished = false; + this.pending = null; + this.currentCard = null; + this.clearOptionButtons(); + this.showCard(TEMPLATES.get('gym-story-start')!); + } + + private clearOptionButtons(): void { + for (const button of this.optionButtons) button.destroy(); + this.optionButtons = []; + } + + /** Draws a card: renders a choice, or applies a terminal card and ends. */ + private showCard(card: StorylineCard): void { + this.currentCard = card; + this.pending = null; + this.clearOptionButtons(); + + this.titleText.setText(this.system.ui.choiceDialogTitle(card)); + this.subtitleText.setText(this.system.ui.choiceDialogSubtitle(card)); + this.cardText.setText( + `${card.name} — ${card.effect ?? 'no effect'}`, + ); + + if (this.system.eventHasStoryline(card)) { + this.pending = this.system.createPendingStorylineChoice(card, this.state); + const options = this.system.getPendingStorylineOptions(this.pending!, this.state); + this.renderOptions(card, options); + } else { + // Terminal (non-choice) card: apply its effect and end the chain. + const before = this.system.engine.getResources(this.state); + this.system.engine.applyEffect(this.state, card); + const after = this.system.engine.getResources(this.state); + const line = this.system.ui.storyUpdateLine( + card, + 'accept', + after.coins - before.coins, + after.reputation - before.reputation, + ); + if (line) this.system.engine.appendLog(this.state, line); + this.finished = true; + this.statusText.setText('Chain complete — press Reset to play again.'); + } + + this.renderStatus(); + } + + /** Renders one button per compiled option. */ + private renderOptions(card: StorylineCard, options: StorylineOption[]): void { + const labels = this.system.ui.choiceClarityLabels(card, options); + const cx = GAME_W / 2; + options.forEach((option, index) => { + const y = this.optionStartY + index * 70; + const hint = index === 0 ? labels.accept : index === 1 ? labels.reject : ''; + const text = createHudText( + this, + cx, + y, + `[ ${option.label} ]${hint ? ` ${hint}` : ''}`, + '#aaddff', + { fontSize: '17px' }, + ).setOrigin(0.5); + text.setInteractive({ useHandCursor: true }); + text.on('pointerdown', () => this.choose(option)); + this.optionButtons.push(text); + }); + } + + /** Resolves the chosen option, logs the story update and draws the successor. */ + private choose(option: StorylineOption): void { + if (!this.pending || !this.currentCard || this.finished) return; + const card = this.currentCard; + const result = this.system.resolveStorylineOption(this.state, card, option); + this.system.markChoiceResolved(this.pending, option.label); + + const line = this.system.ui.storyUpdateLine( + card, + option.label, + result.coinChange, + result.repChange, + ); + if (line) this.system.engine.appendLog(this.state, line); + + // Remove the spent option buttons before rendering the next card. + this.clearOptionButtons(); + this.pending = null; + + if (result.pushedCard) { + this.state.turn += 1; + this.showCard(result.pushedCard); + } else { + this.finished = true; + this.renderStatus(); + } + } + + /** Refreshes resources, chain state and the journal-style activity log. */ + private renderStatus(): void { + const chainState = this.finished + ? 'finished' + : `step ${this.state.turn}`; + this.statusText.setText( + `Coins: ${this.state.coins} Reputation: ${this.state.reputation} (${chainState})`, + ); + + const lines = this.state.activityLog + .slice(-LOG_MAX_LINES) + .map((entry) => `• ${entry.text}`); + this.logText.setText( + ['──── Story Journal ────', ...(lines.length ? lines : ['(no choices yet)'])].join('\n'), + ); + } +} diff --git a/scripts/vite-game-discovery-plugin.ts b/scripts/vite-game-discovery-plugin.ts index 4a71a5579..faaab363d 100644 --- a/scripts/vite-game-discovery-plugin.ts +++ b/scripts/vite-game-discovery-plugin.ts @@ -573,6 +573,7 @@ const GYM_SCENE_NAMES = [ 'GymTokenPileViewScene', 'GymRuleEngineScene', 'GymDlcUnlockScene', + 'GymStorylineScene', ] as const; // ── The plugin ──────────────────────────────────────────────────────────── diff --git a/tests/gym/GymSceneSmoke.browser.test.ts b/tests/gym/GymSceneSmoke.browser.test.ts index 3a804db2d..04de60e7e 100644 --- a/tests/gym/GymSceneSmoke.browser.test.ts +++ b/tests/gym/GymSceneSmoke.browser.test.ts @@ -19,6 +19,7 @@ import { GymSpatialRulesScene } from '../../example-games/gym/scenes/GymSpatialR import { GymTokenPileViewScene } from '../../example-games/gym/scenes/GymTokenPileViewScene'; import { GymRuleEngineScene } from '../../example-games/gym/scenes/GymRuleEngineScene'; import { GymDlcUnlockScene } from '../../example-games/gym/scenes/GymDlcUnlockScene'; +import { GymStorylineScene } from '../../example-games/gym/scenes/GymStorylineScene'; import { GymAiStrategyScene } from '../../example-games/gym/scenes/GymAiStrategyScene'; import { GymAudioFeedbackScene } from '../../example-games/gym/scenes/GymAudioFeedbackScene'; import { GymI18nScene } from '../../example-games/gym/scenes/GymI18nScene'; @@ -50,6 +51,7 @@ import { GYM_TOKEN_PILE_VIEW_KEY, GYM_RULE_ENGINE_KEY, GYM_DLC_UNLOCK_KEY, + GYM_STORYLINE_KEY, } from '../../example-games/gym/GymRegistry'; const SCENES = [ @@ -74,6 +76,7 @@ const SCENES = [ { key: GYM_TOKEN_PILE_VIEW_KEY, Scene: GymTokenPileViewScene }, { key: GYM_RULE_ENGINE_KEY, Scene: GymRuleEngineScene }, { key: GYM_DLC_UNLOCK_KEY, Scene: GymDlcUnlockScene }, + { key: GYM_STORYLINE_KEY, Scene: GymStorylineScene }, ]; describe('Gym individual scene smoke', () => { From fab1ce2878587ac386b397558279c817b4957d4a Mon Sep 17 00:00:00 2001 From: Sorra the Orc <250240+SorraTheOrc@users.noreply.github.com> Date: Tue, 20 Oct 2026 21:25:57 +0100 Subject: [PATCH 10/39] MS-0MVFMA9EW001MIQR: document the generic story-line system Adds core/docs/core-engine/story-line.md (module API, engine/UI adapter seams, Gym demo, testing), and links it from DEVELOPER.md and the Gym index. Documents the completed extraction of the branching story-line mechanic into @story-line. --- docs/DEVELOPER.md | 4 + docs/core-engine/story-line.md | 132 +++++++++++++++++++++++++++++++++ docs/gym/GYM_INDEX.md | 5 ++ 3 files changed, 141 insertions(+) create mode 100644 docs/core-engine/story-line.md diff --git a/docs/DEVELOPER.md b/docs/DEVELOPER.md index d4da77662..137d6ffa7 100644 --- a/docs/DEVELOPER.md +++ b/docs/DEVELOPER.md @@ -1605,6 +1605,7 @@ The project defines path aliases in both `tsconfig.json` and `vite.config.ts`: | `@rule-engine/*` | `src/rule-engine/*` | | `@ai/*` | `src/ai/*` | | `@ui/*` | `src/ui/*` | +| `@story-line` | `src/story-line` (generic branching story-line system) | Usage in code: @@ -1626,6 +1627,9 @@ CORE_ROOT=./core npm run build # game-repo context This keeps `@core-engine/*`, `@card-system/*`, `@rule-engine/*`, `@ui/*` and `@ai/*` import specifiers identical across the core repo and every game repo. +The generic branching story-line system (`@story-line`) is documented in +[`docs/core-engine/story-line.md`](core-engine/story-line.md). + ## Build-Time Version Injection The app version (from `package.json`'s `version` field) is injected at build time diff --git a/docs/core-engine/story-line.md b/docs/core-engine/story-line.md new file mode 100644 index 000000000..a08f62c73 --- /dev/null +++ b/docs/core-engine/story-line.md @@ -0,0 +1,132 @@ +# Core: Generic Story-Line System + +A game-agnostic branching narrative engine for tableau card games. A drawn +card presents an ordered set of options; the player's choice selects the next +card until the chain ends. The mechanic is used by Main Street and demonstrated +by the Gym `GymStorylineScene`. + +Module root: [`src/story-line/`](../../src/story-line/) — import via the +`@story-line` alias. The module has **no game imports and no Phaser +dependency**; a game plugs its state and presentation in through two adapters. + +## API + +```ts +import { createStorylineSystem } from '@story-line'; + +const storyline = createStorylineSystem({ engine, ui }); +``` + +`createStorylineSystem` composes the generic mechanics with a game's adapters +and returns a {@link StorylineSystem} with: + +| Concern | Members | +|---|---| +| Compilation | `compileStorylineFromEvent`, `eventHasStoryline`, `getStorylineOptions`, `storylineOptionCount`, `parseStorylineOptionsEncoding` | +| Registry | `registerStorylineOptions`, `resetStorylineRegistry`, `hasRegisteredStorylineOptions` | +| Resolution | `resolveStorylineOption`, `pushChainCard` | +| Pending choice | `createPendingStorylineChoice`, `getPendingStorylineOptions`, `markChoiceResolved` | +| Journal | `parseStoryUpdate`, `buildJournal`, `journalIsEmpty`, `journalTitle` | + +### Compilation + +Precedence: **registered options > CSV `options` encoding > legacy +`hasChoices` fields**. + +- Legacy `hasChoices` cards compile to two options: `Accept` (`apply`) and + `Reject` (`skip`). +- CSV `options` encoding: `label|cardA,cardB;label2|cardC` (`;` separates + options, `|` separates the label from candidate ids, `,` separates + candidates). An empty candidate list is a **terminal** option. Every + CSV-authored option carries `effectPolicy: 'apply'`. +- Registry/CSV options can carry a `condition(state)` callback, evaluated once + at draw time; options whose condition returns `false` are omitted from the + presented list. + +### Resolution + +`resolveStorylineOption(state, card, option)` applies the option's +`effectPolicy` (`apply` runs the card effect, `skip` does not), then resolves +the successor with this precedence: + +1. `option.successorResolver(state)` — a callback; `null`/`undefined` ends the + chain. Consumes no RNG. +2. `option.successorIds` — exactly **one** seeded `rng()` draw selects a + candidate. An empty list is terminal and consumes no draw. +3. `option.successorId` — a legacy single id, pushed directly (no draw). + +The pushed card uses deterministic serial naming: `${base}-${maxSerial+1}`, +scanning every card the engine adapter returns from `getChainScanCards`. + +## Engine adapter + +`StorylineEngineAdapter` inverts all state coupling: + +| Method | Contract | +|---|---| +| `rng(state)` | Returns the next seeded value in `[0, 1)`. | +| `applyEffect(state, card)` | Applies the card's intrinsic effect. | +| `getResources(state)` | Current `{ coins, reputation }` for delta reporting. | +| `appendLog(state, text, kind?)` | Appends to the game activity log. | +| `getActivityLog(state)` | The game's append-only log (`{ turn, text }`). | +| `getTemplate(id)` | Template lookup for chain-card creation. | +| `getBaseTypeId(id)` | Strips an instance serial (`evt-x-3` → `evt-x`). | +| `getChainScanCards(state)` | Cards scanned for serial naming (deck/discard locations). | +| `pushChainCard(state, card)` | Pushes a created chain card onto the active deck. | + +The adapter is the **only** path from core logic into game state, which keeps +the module deterministic and replay-safe (one seeded draw per candidate +selection). + +## UI adapter + +`StorylineUiAdapter` is the pluggable presentation seam: + +- `storylineName` / `choiceDialogTitle` / `choiceDialogSubtitle` +- `storyUpdateLine` (the narrative line emitted when a choice resolves) +- `choiceClarityLabels` (must explain apply/skip semantics without revealing + the escalation card id) +- `journalTitle` / `storylineSummary` + +Core ships `createDefaultStorylineUiAdapter()` (and a ready-made +`defaultStorylineUiAdapter`). Its wording is the shipped Main Street wording, so +a game that supplies no override is visually identical to Main Street. A game +overrides any subset: + +```ts +const ui = { + ...createDefaultStorylineUiAdapter(), + storyUpdateLine: (card, option, coins, rep) => + `${card.name}: ${option} (${coins >= 0 ? '+' : ''}${coins} coins)`, +}; +``` + +The Gym demo uses the default adapter, demonstrating that no game code is +required to present the mechanic. + +## Gym demo + +[`GymStorylineScene`](../../example-games/gym/scenes/GymStorylineScene.ts) +(`GymStorylineScene` in the Gym catalogue) deals a small branching fixture +(3–4 cards) with two-way and terminal options. It draws a card, renders the +compiled options with the default UI adapter, resolves the choice, and deals +the successor until the chain ends. It is covered by the Gym individual-scene +smoke test (`tests/gym/GymSceneSmoke.browser.test.ts`) and listed in the +[Gym index](../gym/GYM_INDEX.md). + +## Testing + +- `tests/story-line/compile.test.ts` — legacy and CSV compilation, registry + precedence, draw-time condition filtering. +- `tests/story-line/resolve.test.ts` — apply/skip, successor precedence, the + one-draw rule, deterministic serial naming, pending-choice lifecycle. +- `tests/story-line/journal.test.ts` — journal parsing, ordering, empty state. +- `tests/story-line/ui-adapter.test.ts` — default adapter wording and the + pluggable override seam. + +Run the unit project with `npx vitest run --project unit` (or +`/skill:test --type unit`). The Gym scene runs in the `smoke` project. + +Cross-repo behaviour preservation is guarded in the Main Street repo by a +parity gate that compares this module against the shipped Main Street +implementation and a set of golden fixtures. diff --git a/docs/gym/GYM_INDEX.md b/docs/gym/GYM_INDEX.md index db0c562db..1838d1dd9 100644 --- a/docs/gym/GYM_INDEX.md +++ b/docs/gym/GYM_INDEX.md @@ -44,6 +44,11 @@ npx vitest run --project browser tests/gym/*.browser.test.ts | In-game DLC Gate | `GymDlcUnlockScene` | `createDlcGate` (`DlcGate`, `DlcUnlockReader`), `contentUnlockClientFromWindow` (F5 total read client), SLL `anchorPoint` | [`scenes/GymDlcUnlockScene.ts`](../../example-games/gym/scenes/GymDlcUnlockScene.ts) | [`DlcGate.test.ts`](../../tests/core-engine/DlcGate.test.ts), [`GymDlcUnlock.browser.test.ts`](../../tests/gym/GymDlcUnlock.browser.test.ts), [`GymSceneSmoke.browser.test.ts`](../../tests/gym/GymSceneSmoke.browser.test.ts) | | Storyline | `GymStorylineScene` | `createStorylineSystem`, `compileStorylineFromEvent`, `resolveStorylineOption`, `createPendingStorylineChoice`, `defaultStorylineUiAdapter` | [`scenes/GymStorylineScene.ts`](../../example-games/gym/scenes/GymStorylineScene.ts) | [`GymSceneSmoke.browser.test.ts`](../../tests/gym/GymSceneSmoke.browser.test.ts) | + +The Storyline demo's generic engine is documented in +[`docs/core-engine/story-line.md`](../core-engine/story-line.md) (module API, +engine/UI adapters and this demo). + ## Scene Navigation All Gym demo scenes that extend `GymSceneBase` include `[ < Prev ]` and `[ Next > ]` buttons in the header bar, positioned to the right of the `[ Menu ]` button. These cycle through the `GYM_SCENE_CATALOGUE` with wrap-around navigation. From 2709804284c40a06058a5aa47f12fff09c77a6c1 Mon Sep 17 00:00:00 2001 From: Sorra the Orc <250240+SorraTheOrc@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:58:51 +0100 Subject: [PATCH 11/39] MS-0MV8PDNDT0033TH8: add ToneForge factories for the four income/fanfare SFX Adds runtime Tone.js factories (+ descriptors, generator script kept in lockstep) for income-positive-chime, income-negative-chime, income-neutral-chime and success-fanfare, so Main Street's 16 mapped SFX keys all play through the synth instead of the WAV fallback. Test key lists and docs updated. --- docs/the-build/audio.md | 8 +-- scripts/tf-generate-synths.sh | 68 +++++++++++++++++++ .../tf-runtime/main-street-runtime-synth.mjs | 68 +++++++++++++++++++ .../tf-runtime-integration.browser.test.ts | 4 +- tests/core-engine/tf-runtime-module.test.ts | 4 ++ tests/core-engine/tf-runtime-repro.test.ts | 6 +- 6 files changed, 151 insertions(+), 7 deletions(-) diff --git a/docs/the-build/audio.md b/docs/the-build/audio.md index 482a7d242..000176c08 100644 --- a/docs/the-build/audio.md +++ b/docs/the-build/audio.md @@ -25,12 +25,12 @@ A load or normalisation failure is **loud**: `loadMainStreetTfModule()` emits a which the scene forwards to `SoundManager.setSynthDiagnostics()`. **Missing-factory fallback.** A logical key may map to a factory the runtime -module does not ship (for example the `sfx-income-*` and -`sfx-challenge-complete` entries currently map to factories absent from the -committed module). `tfAdapter`'s player reports whether it handled a key and +module does not ship. `tfAdapter`'s player reports whether it handled a key and `SoundManager.play()` falls through to the WAV/Phaser path when it did not — so a synth-mapped key with no matching factory is **never silently dropped** -(CG-0MUU9PSWC009CW76). +(CG-0MUU9PSWC009CW76). Main Street's committed module ships a factory for all +16 mapped keys, so its mapped keys all play through the synth +(MS-0MV8PDNDT0033TH8). > **Environment note.** Synthesised voices can only be constructed in a real > browser, where a Web Audio context exists. Under Node (no Web Audio context) diff --git a/scripts/tf-generate-synths.sh b/scripts/tf-generate-synths.sh index 9b05c5281..5501a866d 100755 --- a/scripts/tf-generate-synths.sh +++ b/scripts/tf-generate-synths.sh @@ -260,6 +260,66 @@ function crowdCheerVoice() { ); } +function incomePositiveVoice() { + return oneShotVoice( + () => new Tone.PolySynth(Tone.Synth, { + oscillator: { type: 'triangle' }, + envelope: { attack: 0.004, decay: 0.18, sustain: 0, release: 0.16 }, + volume: -10, + }), + (synth) => { + const now = Tone.now(); + ['C5', 'E5', 'G5'].forEach((note, index) => { + synth.triggerAttackRelease(note, '32n', now + index * 0.09); + }); + }, + ); +} + +function incomeNegativeVoice() { + return oneShotVoice( + () => new Tone.PolySynth(Tone.Synth, { + oscillator: { type: 'triangle' }, + envelope: { attack: 0.006, decay: 0.22, sustain: 0, release: 0.18 }, + volume: -12, + }), + (synth) => { + const now = Tone.now(); + ['G4', 'E4', 'C4'].forEach((note, index) => { + synth.triggerAttackRelease(note, '32n', now + index * 0.1); + }); + }, + ); +} + +function incomeNeutralVoice() { + return oneShotVoice( + () => new Tone.Synth({ + oscillator: { type: 'sine' }, + envelope: { attack: 0.01, decay: 0.25, sustain: 0, release: 0.2 }, + volume: -12, + }), + (synth) => synth.triggerAttackRelease('A4', '8n'), + ); +} + +function successFanfareVoice() { + return oneShotVoice( + () => new Tone.PolySynth(Tone.Synth, { + oscillator: { type: 'triangle' }, + envelope: { attack: 0.005, decay: 0.2, sustain: 0.04, release: 0.22 }, + volume: -10, + }), + (synth) => { + const now = Tone.now(); + ['C5', 'E5', 'G5'].forEach((note, index) => { + synth.triggerAttackRelease(note, '32n', now + index * 0.11); + }); + synth.triggerAttackRelease(['C5', 'E5', 'G5', 'C6'], '4n', now + 0.33); + }, + ); +} + export const descriptors = { 'card-draw': { runtime: 'Tone.FMSynth' }, 'card-slide': { runtime: 'Tone.NoiseSynth' }, @@ -273,6 +333,10 @@ export const descriptors = { 'construction-lite-hammer': { runtime: 'Tone.MetalSynth' }, 'construction-lite-saw': { runtime: 'Tone.NoiseSynth' }, 'crowd-cheer': { runtime: 'Tone.PolySynth' }, + 'income-positive-chime': { runtime: 'Tone.PolySynth' }, + 'income-negative-chime': { runtime: 'Tone.PolySynth' }, + 'income-neutral-chime': { runtime: 'Tone.Synth' }, + 'success-fanfare': { runtime: 'Tone.PolySynth' }, }; export const factories = { @@ -303,6 +367,10 @@ export const factories = { 'construction-lite-hammer': () => constructionHammerVoice({ lite: true }), 'construction-lite-saw': () => constructionSawVoice({ lite: true }), 'crowd-cheer': () => crowdCheerVoice(), + 'income-positive-chime': () => incomePositiveVoice(), + 'income-negative-chime': () => incomeNegativeVoice(), + 'income-neutral-chime': () => incomeNeutralVoice(), + 'success-fanfare': () => successFanfareVoice(), }; export function getFactory(name) { diff --git a/src/core-engine/tf-runtime/main-street-runtime-synth.mjs b/src/core-engine/tf-runtime/main-street-runtime-synth.mjs index 677f248b9..0bf6d3e96 100644 --- a/src/core-engine/tf-runtime/main-street-runtime-synth.mjs +++ b/src/core-engine/tf-runtime/main-street-runtime-synth.mjs @@ -177,6 +177,66 @@ function crowdCheerVoice() { ); } +function incomePositiveVoice() { + return oneShotVoice( + () => new Tone.PolySynth(Tone.Synth, { + oscillator: { type: 'triangle' }, + envelope: { attack: 0.004, decay: 0.18, sustain: 0, release: 0.16 }, + volume: -10, + }), + (synth) => { + const now = Tone.now(); + ['C5', 'E5', 'G5'].forEach((note, index) => { + synth.triggerAttackRelease(note, '32n', now + index * 0.09); + }); + }, + ); +} + +function incomeNegativeVoice() { + return oneShotVoice( + () => new Tone.PolySynth(Tone.Synth, { + oscillator: { type: 'triangle' }, + envelope: { attack: 0.006, decay: 0.22, sustain: 0, release: 0.18 }, + volume: -12, + }), + (synth) => { + const now = Tone.now(); + ['G4', 'E4', 'C4'].forEach((note, index) => { + synth.triggerAttackRelease(note, '32n', now + index * 0.1); + }); + }, + ); +} + +function incomeNeutralVoice() { + return oneShotVoice( + () => new Tone.Synth({ + oscillator: { type: 'sine' }, + envelope: { attack: 0.01, decay: 0.25, sustain: 0, release: 0.2 }, + volume: -12, + }), + (synth) => synth.triggerAttackRelease('A4', '8n'), + ); +} + +function successFanfareVoice() { + return oneShotVoice( + () => new Tone.PolySynth(Tone.Synth, { + oscillator: { type: 'triangle' }, + envelope: { attack: 0.005, decay: 0.2, sustain: 0.04, release: 0.22 }, + volume: -10, + }), + (synth) => { + const now = Tone.now(); + ['C5', 'E5', 'G5'].forEach((note, index) => { + synth.triggerAttackRelease(note, '32n', now + index * 0.11); + }); + synth.triggerAttackRelease(['C5', 'E5', 'G5', 'C6'], '4n', now + 0.33); + }, + ); +} + export const descriptors = { 'card-draw': { runtime: 'Tone.FMSynth' }, 'card-slide': { runtime: 'Tone.NoiseSynth' }, @@ -190,6 +250,10 @@ export const descriptors = { 'construction-lite-hammer': { runtime: 'Tone.MetalSynth' }, 'construction-lite-saw': { runtime: 'Tone.NoiseSynth' }, 'crowd-cheer': { runtime: 'Tone.PolySynth' }, + 'income-positive-chime': { runtime: 'Tone.PolySynth' }, + 'income-negative-chime': { runtime: 'Tone.PolySynth' }, + 'income-neutral-chime': { runtime: 'Tone.Synth' }, + 'success-fanfare': { runtime: 'Tone.PolySynth' }, }; export const factories = { @@ -220,6 +284,10 @@ export const factories = { 'construction-lite-hammer': () => constructionHammerVoice({ lite: true }), 'construction-lite-saw': () => constructionSawVoice({ lite: true }), 'crowd-cheer': () => crowdCheerVoice(), + 'income-positive-chime': () => incomePositiveVoice(), + 'income-negative-chime': () => incomeNegativeVoice(), + 'income-neutral-chime': () => incomeNeutralVoice(), + 'success-fanfare': () => successFanfareVoice(), }; export function getFactory(name) { diff --git a/tests/core-engine/tf-runtime-integration.browser.test.ts b/tests/core-engine/tf-runtime-integration.browser.test.ts index 533fc5887..a4b9ac85e 100644 --- a/tests/core-engine/tf-runtime-integration.browser.test.ts +++ b/tests/core-engine/tf-runtime-integration.browser.test.ts @@ -63,7 +63,7 @@ describe('ToneForge runtime activation (real browser)', () => { } } - expect(Object.keys(runtimeSynth.factories ?? {})).toHaveLength(12); + expect(Object.keys(runtimeSynth.factories ?? {})).toHaveLength(16); expect(failures).toEqual([]); }); @@ -101,6 +101,6 @@ describe('ToneForge runtime activation (real browser)', () => { { factoryCount: Object.keys(runtimeSynth.factories ?? {}).length }, ); - expect(manager.getSynthStatus().factoryCount).toBe(12); + expect(manager.getSynthStatus().factoryCount).toBe(16); }); }); diff --git a/tests/core-engine/tf-runtime-module.test.ts b/tests/core-engine/tf-runtime-module.test.ts index 8dc071e29..746ef6c0e 100644 --- a/tests/core-engine/tf-runtime-module.test.ts +++ b/tests/core-engine/tf-runtime-module.test.ts @@ -46,6 +46,10 @@ const EXPECTED_KEYS = [ 'construction-lite-hammer', 'construction-lite-saw', 'crowd-cheer', + 'income-positive-chime', + 'income-negative-chime', + 'income-neutral-chime', + 'success-fanfare', ]; describe('ToneForge runtime synth module', () => { diff --git a/tests/core-engine/tf-runtime-repro.test.ts b/tests/core-engine/tf-runtime-repro.test.ts index e0471d6cf..664516198 100644 --- a/tests/core-engine/tf-runtime-repro.test.ts +++ b/tests/core-engine/tf-runtime-repro.test.ts @@ -17,7 +17,7 @@ * successfully. The real-browser proof lives in * `tests/core-engine/tf-runtime-integration.browser.test.ts`, and the real * scene attaches an active synth player (`isSynthActive() === true`, - * 12 factories, no load error). + * 16 factories, no load error). * * This Node-scoped test therefore asserts only the **environment-independent * structural contract** — the module ships the expected factory keys and the @@ -44,6 +44,10 @@ const RUNTIME_FACTORY_KEYS = [ 'construction-lite-hammer', 'construction-lite-saw', 'crowd-cheer', + 'income-positive-chime', + 'income-negative-chime', + 'income-neutral-chime', + 'success-fanfare', ] as const; describe('ToneForge runtime synth module structure', () => { From ac502d12ed3822dae7d0516c9cf406b8542c6354 Mon Sep 17 00:00:00 2001 From: Sorra the Orc <250240+SorraTheOrc@users.noreply.github.com> Date: Thu, 22 Oct 2026 22:10:21 +0100 Subject: [PATCH 12/39] CG-0MVGF2QE9008NA5R: Implementation complete --- docs/DEVELOPER.md | 9 ++ docs/dev/creating-a-new-game.md | 21 ++++ docs/dev/multi-repo-architecture.md | 6 +- scripts/game-repo-scaffold.ts | 89 ++++++++++++++++- tests/scripts/game-repo-scaffold.test.ts | 120 ++++++++++++++++++++++- 5 files changed, 241 insertions(+), 4 deletions(-) diff --git a/docs/DEVELOPER.md b/docs/DEVELOPER.md index 137d6ffa7..11276098d 100644 --- a/docs/DEVELOPER.md +++ b/docs/DEVELOPER.md @@ -1637,6 +1637,15 @@ via Vite's `define` in `vite.config.ts`. The global constant `__APP_VERSION__` i replaced with the version string during the Vite transform phase (both dev server and production builds). +In the multi-repo distribution the label therefore shows the **composing repo's** +version: a game repo's `vite.config.ts` reads that game's own `package.json`, so +the label is the **game** version; the core repo shows the **engine** version. +The two are independent — a game repo owns its version (the scaffold seeds +`0.1.0`, preserves it on re-scaffold, and a game release bumps only that game's +`package.json`), so engine and game releases proceed at different cadences. See +[Creating a New Game §3 — per-game version policy](dev/creating-a-new-game.md#per-game-version-policy) +and [Per-game repo scaffold](dev/multi-repo-architecture.md#5-per-game-repo-scaffold-f4). + The version is displayed as `v` (e.g. `v0.1.7`) in two locations: - The **GameSelectorScene** menu screen (top-right corner, below the GitHub icon) - The **SettingsPanel** overlay (shown on the game canvas when the panel opens) diff --git a/docs/dev/creating-a-new-game.md b/docs/dev/creating-a-new-game.md index 05098f22b..f07bb98b2 100644 --- a/docs/dev/creating-a-new-game.md +++ b/docs/dev/creating-a-new-game.md @@ -129,6 +129,27 @@ root app-icon/public assets. Engine imports resolve through the path aliases (`@core-engine/*`, `@card-system/*`, `@rule-engine/*`, `@ui/*`, `@ai/*`, …) against `./core`. +### Per-game version policy + +**A game repo owns its own version — it is not the engine version.** The +scaffold seeds a brand-new game repo at `0.1.0`, **preserves** the `version` +already in the repo's `package.json` when re-scaffolding (so an existing game is +never renumbered), and accepts an explicit `--version ` override for +cases where a specific version must be stamped. It never copies the core's +`version`. + +Game releases are therefore independent of engine releases: a release cut +inside `tce-` bumps only that game's `package.json` (the ship +`bump-version.js` resolves the invoking repo's own root via +`git rev-parse --show-toplevel`), and the first release of a newly scaffolded +game tags `v0.1.1`. The version label shown on game screens comes from the +game's own `package.json` (`__APP_VERSION__` in its `vite.config.ts`), so it +follows the game build, not the engine build — see +[Build-Time Version Injection](../DEVELOPER.md#build-time-version-injection). + +Existing sibling game repos keep their current numbers; they diverge from the +engine at their next release rather than being renumbered. + --- ## 4. Architecture and implementation diff --git a/docs/dev/multi-repo-architecture.md b/docs/dev/multi-repo-architecture.md index 392cea70a..20a046b5e 100644 --- a/docs/dev/multi-repo-architecture.md +++ b/docs/dev/multi-repo-architecture.md @@ -276,7 +276,11 @@ What it writes into the game repo: - **`package.json`** — name `tce-`, the core's dependencies and devDependencies, and the core toolchain scripts (`save-load-smoke`, `monte-carlo`, `replay`, …) with the repo entry points (`dev`, `build`, -`build:electron`, `test`) overridden. +`build:electron`, `test`) overridden. Its **`version` belongs to the game, +not the engine** (CG-0MVGF2QE9008NA5R): a new repo is seeded at `0.1.0`, a +re-scaffold preserves the version already in the repo (`--version` overrides), +and the core manifest's version is never copied. Each game's release bump is +repo-local, so engine and game releases can proceed at different cadences. - **`vite.config.ts`** — imports `gameDiscoveryPlugin` and `resolveCoreAliases` from the sibling core and reads `configs/game.json` (Gym + this one game) by default; `GAMES_CONFIG` can still override for an ad-hoc build. diff --git a/scripts/game-repo-scaffold.ts b/scripts/game-repo-scaffold.ts index 934091e07..8c2983f95 100644 --- a/scripts/game-repo-scaffold.ts +++ b/scripts/game-repo-scaffold.ts @@ -53,6 +53,15 @@ * ``` * * Run with `--help` for the full option list. + * + * ## Per-game version policy + * + * A scaffolded game repo owns its own `version`, decoupled from the engine's. + * The scaffold seeds a **new** game at {@link GAME_INITIAL_VERSION} (`0.1.0`), + * **preserves** the version already in a game repo's `package.json` on a + * re-scaffold, and accepts an explicit `--version` override. It never copies + * `CoreManifest.version` (CG-0MVGF2QE9008NA5R). A game release bumps only that + * game's version; the ship `bump-version.js` resolves the invoking repo root. */ import fs from 'node:fs'; @@ -77,10 +86,27 @@ export const GAME_REPO_CONFIGS_DIR = 'configs'; /** The preset filename a game repo builds with by default. */ export const DEFAULT_GAME_PRESET = 'game'; +/** + * Version a freshly scaffolded game repo is seeded with. + * + * Per-game version policy (CG-0MVGF2QE9008NA5R): **each game repo owns its + * own version**, decoupled from the engine's release cadence. + * `renderPackageJson` never derives a game version from the core manifest; the + * scaffold seeds a new game at `GAME_INITIAL_VERSION`, preserves an existing + * game's version on re-scaffold, and a game release bumps only that game's + * `package.json` (the ship `bump-version.js` resolves the invoking repo root). + */ +export const GAME_INITIAL_VERSION = '0.1.0'; + // ── Types ───────────────────────────────────────────────────────────────── /** The subset of the core `package.json` the scaffold reuses. */ export interface CoreManifest { + /** + * The **core's** version. Kept typed because a manifest is a `package.json` + * subset, but deliberately **unused** by the scaffold: a game repo owns its + * own version (CG-0MVGF2QE9008NA5R), see {@link GAME_INITIAL_VERSION}. + */ version?: string; scripts?: Record; dependencies?: Record; @@ -119,8 +145,17 @@ export interface ScaffoldOptions { adapterPath?: string; /** The core manifest; read from `coreRoot/package.json` when omitted. */ coreManifest?: CoreManifest; + /** + * Explicit version for the generated `package.json`. Wins over any version + * already present in the game repo (preserve-on-re-scaffold); when omitted, + * the existing version is preserved, else {@link GAME_INITIAL_VERSION}. + */ + version?: string; } +/** Why the scaffold picked the game version it wrote. */ +export type ScaffoldVersionSource = 'explicit' | 'preserved' | 'baseline'; + /** Result of {@link scaffoldGameRepo}. */ export interface ScaffoldResult { gameRepoRoot: string; @@ -135,6 +170,10 @@ export interface ScaffoldResult { assetLinks: string[]; /** Core root app-icon files linked into `public/` (CG-0MUUFZSE90061KKL). */ publicLinks: string[]; + /** The version written to the game's `package.json`. */ + version: string; + /** Why {@link version} was chosen. */ + versionSource: ScaffoldVersionSource; } // ── Small helpers ───────────────────────────────────────────────────────── @@ -170,6 +209,28 @@ export function readCoreManifest(coreRoot: string): CoreManifest { return JSON.parse(fs.readFileSync(p, 'utf-8')) as CoreManifest; } +/** + * Read the `version` field of an existing game repo `package.json`. + * + * Total by design: returns the string version when the file exists and parses + * with a string `version`; `undefined` when the file is absent, unreadable, + * malformed, or declares no string version — callers then fall back to + * {@link GAME_INITIAL_VERSION}. This is the preserve-on-re-scaffold seam: a + * re-scaffold must not clobber a game's own release version. + */ +export function readExistingPackageVersion( + gameRepoRoot: string, +): string | undefined { + try { + const parsed = JSON.parse( + fs.readFileSync(path.join(gameRepoRoot, 'package.json'), 'utf-8'), + ) as { version?: unknown }; + return typeof parsed.version === 'string' ? parsed.version : undefined; + } catch { + return undefined; + } +} + /** * Resolve the scene/adapter paths for a game from the core's `configs/full.json`. * @@ -198,10 +259,13 @@ export function renderPackageJson( game: string, manifest: CoreManifest, coreRel: string, + version: string = GAME_INITIAL_VERSION, ): string { const pkg = { name: `tce-${game}`, - version: manifest.version ?? '0.0.0', + // The game owns its version (CG-0MVGF2QE9008NA5R): the caller decides it + // (baseline / preserved / explicit), never the core manifest. + version, private: true, description: `Single-game Tableau Card Engine launcher for "${game}". ` + @@ -733,6 +797,16 @@ export function scaffoldGameRepo(options: ScaffoldOptions): ScaffoldResult { } const manifest = options.coreManifest ?? readCoreManifest(coreRoot); + // Per-game version policy: an explicit override wins, else preserve an + // existing released version, else seed the independent baseline. + const existingVersion = readExistingPackageVersion(gameRepoRoot); + const version = options.version ?? existingVersion ?? GAME_INITIAL_VERSION; + const versionSource: ScaffoldVersionSource = + options.version !== undefined + ? 'explicit' + : existingVersion !== undefined + ? 'preserved' + : 'baseline'; const preset = options.scenePath ? undefined : findGameInPreset(coreRoot, options.game); @@ -754,7 +828,7 @@ export function scaffoldGameRepo(options: ScaffoldOptions): ScaffoldResult { writeFile( path.join(gameRepoRoot, 'package.json'), - renderPackageJson(options.game, manifest, coreRel), + renderPackageJson(options.game, manifest, coreRel, version), written, ); writeFile( @@ -824,6 +898,8 @@ export function scaffoldGameRepo(options: ScaffoldOptions): ScaffoldResult { rewritten, assetLinks, publicLinks, + version, + versionSource, }; } @@ -837,6 +913,7 @@ interface CliArgs { reposDir?: string; all?: boolean; layout?: string; + version?: string; } function parseArgs(argv: string[]): CliArgs { @@ -861,6 +938,10 @@ function parseArgs(argv: string[]): CliArgs { args.coreRel = next; i += 1; break; + case '--version': + args.version = next; + i += 1; + break; case '--repos-dir': args.reposDir = next; i += 1; @@ -896,6 +977,8 @@ function usage(): string { ' /Tableau-Card-Engine.', ' --core-rel Core path as used inside the game repo', ` (default: ${DEFAULT_CORE_REL}).`, + ' --version Explicit game package version (default:', + ' preserve the repo version, else seed 0.1.0).', ' --layout Layout file for --all', ' (default: /scripts/configs/repo-layout.json).', ' -h, --help Show this help.', @@ -950,11 +1033,13 @@ function main(argv: string[]): number { coreRoot, coreRel, coreManifest: manifest, + version: args.version, }); console.log(`Scaffolded tce-${game}`); console.log(` game repo: ${result.gameRepoRoot}`); console.log(` core: ${result.coreRoot} (${result.coreRel})`); console.log(` preset: ${result.presetPath}`); + console.log(` version: ${result.version} (${result.versionSource})`); for (const f of result.written) console.log(` wrote ${f}`); for (const l of result.symlinks) console.log(` linked ${l}`); if (result.rewritten.length) { diff --git a/tests/scripts/game-repo-scaffold.test.ts b/tests/scripts/game-repo-scaffold.test.ts index b080a36aa..e0c69e567 100644 --- a/tests/scripts/game-repo-scaffold.test.ts +++ b/tests/scripts/game-repo-scaffold.test.ts @@ -19,11 +19,13 @@ import { CORE_REMOTE, DEFAULT_CORE_REL, DEFAULT_GAME_PRESET, + GAME_INITIAL_VERSION, defaultScenePath, findGameInPreset, linkSharedAssets, linkSharedPublicAssets, pascalCase, + readExistingPackageVersion, readLayoutGames, renderPackageJson, renderPreset, @@ -292,7 +294,6 @@ describe('renderPackageJson', () => { it('names the repo for the game and reuses the core toolchain deps', () => { const pkg = JSON.parse(renderPackageJson('golf', MANIFEST, DEFAULT_CORE_REL)); expect(pkg.name).toBe('tce-golf'); - expect(pkg.version).toBe('9.9.9'); expect(pkg.type).toBe('module'); // Game-specific toolchain scripts are inherited from the core manifest… expect(pkg.scripts['save-load-smoke']).toContain('src/scripts/save-load-smoke'); @@ -306,6 +307,27 @@ describe('renderPackageJson', () => { expect(pkg.devDependencies).toEqual(MANIFEST.devDependencies); }); + it('seeds a new game repo with an engine-independent baseline version', () => { + // The core manifest deliberately declares a different version: a game repo + // owns its own version and must never inherit the engine's + // (CG-0MVGF2QE9008NA5R). + expect(MANIFEST.version).toBe('9.9.9'); + const pkg = JSON.parse(renderPackageJson('golf', MANIFEST, DEFAULT_CORE_REL)); + expect(pkg.version).toBe(GAME_INITIAL_VERSION); + expect(pkg.version).toBe('0.1.0'); + expect(pkg.version).not.toBe(MANIFEST.version); + }); + + it('writes an explicitly supplied version verbatim', () => { + const pkg = JSON.parse( + renderPackageJson('golf', MANIFEST, DEFAULT_CORE_REL, '1.4.2'), + ); + expect(pkg.version).toBe('1.4.2'); + // An explicit version is the only thing that can diverge from the baseline; + // the core manifest version still never leaks through. + expect(pkg.version).not.toBe(MANIFEST.version); + }); + it('wires engine:sync into dev builds and engine:verify into release builds', () => { const pkg = JSON.parse(renderPackageJson('golf', MANIFEST, DEFAULT_CORE_REL)); // Dev builds compose the latest engine remote head before compiling @@ -498,6 +520,102 @@ describe('scaffoldGameRepo', () => { expect(testSrc).toContain("'../../src/scripts/game-script'"); }); + it('seeds a new game repo package.json with the 0.1.0 baseline', () => { + const { core, gameRepo } = makeLayout('golf'); + const result = scaffoldGameRepo({ + game: 'golf', + gameRepoRoot: gameRepo, + coreRoot: core, + }); + + // Assert the real on-disk write, not just the rendered template. + const pkg = JSON.parse( + fs.readFileSync(path.join(gameRepo, 'package.json'), 'utf-8'), + ); + expect(pkg.version).toBe('0.1.0'); + expect(result.version).toBe('0.1.0'); + expect(result.versionSource).toBe('baseline'); + }); + + it('preserves an existing repo version on re-scaffold', () => { + const { core, gameRepo } = makeLayout('golf'); + scaffoldGameRepo({ game: 'golf', gameRepoRoot: gameRepo, coreRoot: core }); + + // Simulate a released game: its own release history owns its version. + const pkgPath = path.join(gameRepo, 'package.json'); + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')); + pkg.version = '0.1.17'; + fs.writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`, 'utf-8'); + + const result = scaffoldGameRepo({ + game: 'golf', + gameRepoRoot: gameRepo, + coreRoot: core, + }); + + expect(result.version).toBe('0.1.17'); + expect(result.versionSource).toBe('preserved'); + expect(readExistingPackageVersion(gameRepo)).toBe('0.1.17'); + expect(JSON.parse(fs.readFileSync(pkgPath, 'utf-8')).version).toBe('0.1.17'); + + // A further re-scaffold is version-neutral (no drift toward the core's). + const third = scaffoldGameRepo({ + game: 'golf', + gameRepoRoot: gameRepo, + coreRoot: core, + }); + expect(third.version).toBe('0.1.17'); + expect(third.versionSource).toBe('preserved'); + }); + + it('falls back to the baseline when the existing package.json is unusable', () => { + const { core, gameRepo } = makeLayout('golf'); + const pkgPath = path.join(gameRepo, 'package.json'); + + // Malformed JSON is treated as no existing version. + fs.writeFileSync(pkgPath, '{ not json', 'utf-8'); + expect(readExistingPackageVersion(gameRepo)).toBeUndefined(); + const malformed = scaffoldGameRepo({ + game: 'golf', + gameRepoRoot: gameRepo, + coreRoot: core, + }); + expect(malformed.version).toBe('0.1.0'); + expect(malformed.versionSource).toBe('baseline'); + + // A package.json with no string version is treated the same way. + fs.writeFileSync(pkgPath, JSON.stringify({ name: 'tce-golf' }), 'utf-8'); + expect(readExistingPackageVersion(gameRepo)).toBeUndefined(); + const missingField = scaffoldGameRepo({ + game: 'golf', + gameRepoRoot: gameRepo, + coreRoot: core, + }); + expect(missingField.version).toBe('0.1.0'); + expect(missingField.versionSource).toBe('baseline'); + }); + + it('honours an explicit version override over the preserved one', () => { + const { core, gameRepo } = makeLayout('golf'); + scaffoldGameRepo({ game: 'golf', gameRepoRoot: gameRepo, coreRoot: core }); + + const pkgPath = path.join(gameRepo, 'package.json'); + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')); + pkg.version = '0.1.17'; + fs.writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`, 'utf-8'); + + const result = scaffoldGameRepo({ + game: 'golf', + gameRepoRoot: gameRepo, + coreRoot: core, + version: '2.0.0', + }); + + expect(result.version).toBe('2.0.0'); + expect(result.versionSource).toBe('explicit'); + expect(JSON.parse(fs.readFileSync(pkgPath, 'utf-8')).version).toBe('2.0.0'); + }); + it('composes the core root app-icon files into public/ and reports them', () => { const { core, gameRepo } = makeLayout('golf'); writeCoreIconLayout(core); From 64063eac8a16db187cc0b5fea54ebbb6502a498d Mon Sep 17 00:00:00 2001 From: Sorra the Orc <250240+SorraTheOrc@users.noreply.github.com> Date: Fri, 23 Oct 2026 00:20:27 +0100 Subject: [PATCH 13/39] CG-0MVI048QU000UF0F: Implementation complete --- src/story-line/index.ts | 4 + src/story-line/types.ts | 98 +++++++++++++++++++++- tests/story-line/option-model.test.ts | 116 ++++++++++++++++++++++++++ 3 files changed, 216 insertions(+), 2 deletions(-) create mode 100644 tests/story-line/option-model.test.ts diff --git a/src/story-line/index.ts b/src/story-line/index.ts index 5b2ed90a0..de2ab9bd1 100644 --- a/src/story-line/index.ts +++ b/src/story-line/index.ts @@ -51,6 +51,10 @@ import type { export type { ChoiceClarityLabels, CompiledStoryline, + Condition, + ConditionalSuccessorRule, + OptionCost, + OptionExtraEffect, PendingStorylineChoice, StorylineCard, StorylineEngineAdapter, diff --git a/src/story-line/types.ts b/src/story-line/types.ts index f4809cba5..36212fbb7 100644 --- a/src/story-line/types.ts +++ b/src/story-line/types.ts @@ -83,6 +83,62 @@ export interface ChoiceClarityLabels { readonly reject: string; } +/** + * Declarative affordability cost for a story-line option. + * + * Every specified component is a required payment: an option is only + * affordable when the player has at least `coins` coins **and** at least + * `reputation` reputation. An unspecified component is unconstrained (it is + * never defaulted to zero). + */ +export interface OptionCost { + /** Coins the player must pay to select the option. */ + readonly coins?: number; + /** Reputation the player must pay to select the option. */ + readonly reputation?: number; +} + +/** + * Declarative resource delta applied when a story-line option resolves. + * + * Each specified component is a signed delta (`-5` deducts, `5` grants). An + * unspecified component leaves that resource unchanged. + */ +export interface OptionExtraEffect { + /** Coin delta applied on resolution. */ + readonly coins?: number; + /** Reputation delta applied on resolution. */ + readonly reputation?: number; +} + +/** + * A single declarative threshold condition on one resource. + * + * Conditions are pure data (field / operator / numeric value) so they can be + * authored, serialised and validated without executable callbacks. + */ +export interface Condition { + /** The resource the condition tests. */ + readonly field: 'coins' | 'reputation'; + /** Comparison applied between the current resource and {@link value}. */ + readonly op: '>=' | '<=' | '>' | '<' | '=='; + /** Numeric threshold the current resource is compared against. */ + readonly value: number; +} + +/** + * One ordered declarative successor rule. + * + * When {@link when} matches the current resources, {@link successorId} is the + * pushed successor card id; `null` ends the chain. + */ +export interface ConditionalSuccessorRule { + /** Condition evaluated against the current resources at resolution time. */ + readonly when: Condition; + /** Successor card id to push when the rule matches; `null` ends the chain. */ + readonly successorId: string | null; +} + /** * A single option within a story-line's ordered option list. * @@ -103,6 +159,10 @@ export interface StorylineOption { * Ordered candidate successor card ids. At resolution time exactly one * candidate is selected with a single seeded `rng()` draw. An empty list * is a terminal option — the chain ends and no draw is consumed. + * + * Only consulted when neither {@link successorResolver} nor a matching + * {@link conditionalSuccessors} rule determines the successor (see + * {@link conditionalSuccessors} for the full precedence contract). */ readonly successorIds?: readonly string[]; /** Whether the event's intrinsic effect applies when this option is chosen. */ @@ -118,12 +178,46 @@ export interface StorylineOption { /** * Optional runtime successor resolver evaluated at resolution time. When * present, its return value is used as the pushed successor card id - * instead of {@link successorId} / {@link successorIds}. Returns - * `null`/`undefined` to end the chain. Consumes no RNG. + * instead of {@link conditionalSuccessors} / {@link successorIds} / + * {@link successorId}. Returns `null`/`undefined` to end the chain. + * Consumes no RNG. * * Contract: must be a pure function — no observable side effects. */ readonly successorResolver?: (state: TState) => string | null | undefined; + /** + * Optional declarative affordability cost. + * + * Declared contract (applied by the draw-time filtering and resolution + * helpers): an option is presented only when every specified cost component + * is affordable, and selecting it deducts the cost from the game state via + * the engine adapter's resource-mutation seam. Cost is **independent of + * {@link effectPolicy}** — it is a real payment made in addition to the + * card's intrinsic effect (which {@link effectPolicy} governs separately). + */ + readonly cost?: OptionCost; + /** + * Optional declarative resource delta applied when the option resolves. + * + * Declared contract: applied after any {@link cost} deduction and + * **independently of {@link effectPolicy}** (it applies for both `'apply'` + * and `'skip'`). Its deltas are reported in the resolution's `coinChange` / + * `repChange`. + */ + readonly extraEffect?: OptionExtraEffect; + /** + * Ordered declarative successor rules evaluated at resolution time against + * the current resources. The first rule whose {@link Condition} matches + * determines the pushed successor card id (`null` ends the chain). A + * matching rule consumes no RNG draw. + * + * Successor precedence (documented contract): + * 1. {@link successorResolver} callback, when present; + * 2. the first matching {@link conditionalSuccessors} rule; + * 3. {@link successorIds} — a single seeded random draw; + * 4. legacy {@link successorId}. + */ + readonly conditionalSuccessors?: readonly ConditionalSuccessorRule[]; } /** A card's compiled story-line option list. */ diff --git a/tests/story-line/option-model.test.ts b/tests/story-line/option-model.test.ts new file mode 100644 index 000000000..a68f9b017 --- /dev/null +++ b/tests/story-line/option-model.test.ts @@ -0,0 +1,116 @@ +/** + * Story-line option model extension tests (CG-0MVI048QU000UF0F). + * + * The declarative `cost`, `extraEffect` and `conditionalSuccessors` fields are + * authored programmatically through `registerStorylineOptions` and must be + * carried verbatim by the compilation seam, which future draw-time filtering + * and resolution work builds on. CSV-authored and legacy options keep their + * current shape (no shipped content uses the new fields). + * + * @module + */ +import { describe, expect, it } from 'vitest'; + +import { + createStorylineSystem, + type ConditionalSuccessorRule, + type Condition, + type OptionCost, + type OptionExtraEffect, + type StorylineOption, +} from '../../src/story-line'; +import { + makeAdapter, + makeCard, + makeState, + type TemplateMap, + type TestState, +} from './helpers'; + +/** A system instance bound to an empty template catalogue + adapter. */ +function makeSystem() { + const templates: TemplateMap = new Map(); + const engine = makeAdapter(templates); + const system = createStorylineSystem({ engine }); + return { system, templates, engine, state: makeState() }; +} + +describe('declarative story-line option fields', () => { + it('carries cost, extraEffect and conditionalSuccessors through compilation verbatim', () => { + const { system } = makeSystem(); + + const cost: OptionCost = { coins: 250 }; + const extraEffect: OptionExtraEffect = { coins: -50, reputation: 3 }; + const reputationCondition: Condition = { + field: 'reputation', + op: '>=', + value: 40, + }; + const conditionalSuccessors: readonly ConditionalSuccessorRule[] = [ + { when: reputationCondition, successorId: 'evt-flourish' }, + // A terminal rule (`successorId: null`) must be representable. + { when: { field: 'coins', op: '<', value: 100 }, successorId: null }, + ]; + const options: StorylineOption[] = [ + { + label: 'Pay the levy', + effectPolicy: 'apply', + successorId: 'evt-after', + cost, + extraEffect, + conditionalSuccessors, + }, + ]; + + system.registerStorylineOptions('evt-levy', options); + const compiled = system.compileStorylineFromEvent(makeCard({ id: 'evt-levy' })); + + expect(compiled.compiledOptions).toEqual(options); + // An unspecified cost component is unconstrained — not defaulted to 0. + expect(compiled.compiledOptions[0]?.cost).toEqual({ coins: 250 }); + expect(compiled.compiledOptions[0]?.cost?.reputation).toBeUndefined(); + }); + + it('keeps the declarative fields on options that survive draw-time condition filtering', () => { + const { system } = makeSystem(); + const state = makeState({ coins: 250 }); + + const options: StorylineOption[] = [ + { + label: 'Affordable', + effectPolicy: 'apply', + cost: { coins: 100, reputation: 5 }, + extraEffect: { reputation: 1 }, + condition: (s) => s.coins >= 100, + }, + { + label: 'Unaffordable', + effectPolicy: 'apply', + cost: { coins: 10_000 }, + condition: (s) => s.coins >= 10_000, + }, + ]; + system.registerStorylineOptions('evt-filtered', options); + + const presented = system.getStorylineOptions(makeCard({ id: 'evt-filtered' }), state); + + expect(presented).toHaveLength(1); + expect(presented[0]).toEqual(options[0]); + }); + + it('authors no declarative fields on CSV-encoded options', () => { + const { system } = makeSystem(); + + const parsed = system.parseStorylineOptionsEncoding('Pay|evt-a;Flee|evt-b'); + + expect(parsed).toEqual([ + { label: 'Pay', successorIds: ['evt-a'], effectPolicy: 'apply' }, + { label: 'Flee', successorIds: ['evt-b'], effectPolicy: 'apply' }, + ]); + for (const option of parsed) { + expect(option.cost).toBeUndefined(); + expect(option.extraEffect).toBeUndefined(); + expect(option.conditionalSuccessors).toBeUndefined(); + } + }); +}); From b422437a9efdc71705f767ddd5ddffb57bb7c95a Mon Sep 17 00:00:00 2001 From: Sorra the Orc <250240+SorraTheOrc@users.noreply.github.com> Date: Fri, 23 Oct 2026 01:11:42 +0100 Subject: [PATCH 14/39] CG-0MVI04A5Z008HVJA: Implementation complete --- src/story-line/compile.ts | 54 ++++++++++-- src/story-line/index.ts | 5 +- src/story-line/types.ts | 5 ++ tests/story-line/compile.test.ts | 144 +++++++++++++++++++++++++++++++ 4 files changed, 198 insertions(+), 10 deletions(-) diff --git a/src/story-line/compile.ts b/src/story-line/compile.ts index 0fb774d87..ee4f1297a 100644 --- a/src/story-line/compile.ts +++ b/src/story-line/compile.ts @@ -13,6 +13,7 @@ import type { CompiledStoryline, + OptionCost, StorylineCard, StorylineOption, } from './types'; @@ -119,6 +120,29 @@ export function parseStorylineOptionsEncoding( return options; } +/** + * True when the player's current resources cover the option's declarative + * `cost`. + * + * An option with no `cost` is always affordable. Every specified cost + * component is a required gate: the option is affordable only when the + * player has at least `cost.coins` coins **and** at least `cost.reputation` + * reputation. An unspecified component is unconstrained (never defaulted to + * zero). A cost exactly equal to the current balance is affordable. + * + * @param option The option to check (its `cost` may be absent). + * @param resources Current spendable resources. + */ +export function isOptionAffordable( + option: { cost?: OptionCost }, + resources: { coins: number; reputation: number }, +): boolean { + if (!option.cost) return true; + const coins = option.cost.coins ?? 0; + const reputation = option.cost.reputation ?? 0; + return resources.coins >= coins && resources.reputation >= reputation; +} + /** * Compiles a card's story-line into the generalised option model. * @@ -132,30 +156,42 @@ export function parseStorylineOptionsEncoding( * empty option list. * - Legacy choice cards → two options [Accept(apply), Reject(skip)]. * - * When `state` is provided, registry options whose `condition` callback - * returns `false` are omitted from the result (draw-time filtering). + * When `state` **and** a resources oracle are provided, registry options are + * filtered at draw time: an option is omitted when its `condition` callback + * returns `false` **or** its declarative `cost` exceeds the current + * resources ({@link isOptionAffordable}). Without a resources oracle (or + * without state) no cost filtering happens — legacy behaviour is preserved. * - * @param event The story-line card. - * @param registry The option registry to consult (base-type-id keyed). - * @param state Optional game state for draw-time condition filtering. + * @param event The story-line card. + * @param registry The option registry to consult (base-type-id keyed). + * @param state Optional game state for draw-time condition filtering. + * @param getResources Optional resources oracle for draw-time cost + * affordability filtering. Reads the current spendable + * resources from `state`; when absent, cost-gated options + * are never filtered (legacy behaviour). */ export function compileStorylineFromEvent( event: TCard, registry: StorylineOptionRegistry, state?: TState, + getResources?: (state: TState) => { coins: number; reputation: number }, ): CompiledStoryline { const storylineId = event.storylineId ?? null; const registered = registry.get(event.id); if (registered) { const options = registered.map((o) => ({ ...o })); if (state) { - // Filter by condition at draw time: omit options whose condition - // callback returns false. Options without a condition (or whose - // condition returns true) are included unchanged. + // Filter at draw time: omit options whose condition callback returns + // false or whose declarative cost exceeds the current resources. + // Options without a condition (or whose condition returns true) and + // without an unaffordable cost are included unchanged. + const resources = getResources ? getResources(state) : null; return { storylineId, compiledOptions: options.filter( - (o) => o.condition == null || o.condition(state), + (o) => + (o.condition == null || o.condition(state)) && + (resources == null || isOptionAffordable(o, resources)), ), }; } diff --git a/src/story-line/index.ts b/src/story-line/index.ts index de2ab9bd1..8ab67c48b 100644 --- a/src/story-line/index.ts +++ b/src/story-line/index.ts @@ -90,6 +90,7 @@ export { compileStorylineFromEvent, createStorylineOptionRegistry, eventHasStoryline, + isOptionAffordable, } from './compile'; export type { StorylineOptionRegistry } from './compile'; @@ -131,7 +132,9 @@ export function createStorylineSystem< card: TCard, state?: TState, ): CompiledStoryline => - compileStorylineFromEvent(card, registry, state); + compileStorylineFromEvent(card, registry, state, (s) => + engine.getResources(s), + ); const getStorylineOptions = ( card: TCard, diff --git a/src/story-line/types.ts b/src/story-line/types.ts index 36212fbb7..0d9c410c2 100644 --- a/src/story-line/types.ts +++ b/src/story-line/types.ts @@ -242,6 +242,11 @@ export interface PendingStorylineChoice< /** * Draw-time snapshot of the compiled option list. When present, resolution * reads this frozen list (condition callbacks are never re-evaluated). + * + * The snapshot is **cost-filtered** as well: an option whose declarative + * `cost` exceeded the player's coins/reputation at draw time is absent from + * this list. Consumers (e.g. AI scoring) must read this snapshot — it is the + * presented list, computed once at draw time and stable thereafter. */ options?: readonly StorylineOption[]; } diff --git a/tests/story-line/compile.test.ts b/tests/story-line/compile.test.ts index 3f08f6f9a..9a1be88ed 100644 --- a/tests/story-line/compile.test.ts +++ b/tests/story-line/compile.test.ts @@ -305,6 +305,150 @@ describe('draw-time condition filtering', () => { }); }); +// ── AC3: draw-time cost filtering ─────────────────────────── + +describe('draw-time cost filtering', () => { + it('omits an option whose coin cost exceeds the balance', () => { + const { system, state } = makeSystem(); + state.coins = 100; + system.registerStorylineOptions('evt-cost', [ + { label: 'Cheap', effectPolicy: 'apply', cost: { coins: 50 } }, + { label: 'Too dear', effectPolicy: 'apply', cost: { coins: 101 } }, + ]); + + expect( + system.getStorylineOptions(makeCard({ id: 'evt-cost' }), state).map((o) => o.label), + ).toEqual(['Cheap']); + }); + + it('omits an option whose reputation cost exceeds the balance', () => { + const { system, state } = makeSystem(); + state.reputation = 10; + system.registerStorylineOptions('evt-cost-rep', [ + { label: 'Affordable', effectPolicy: 'apply', cost: { reputation: 10 } }, + { label: 'Unaffordable', effectPolicy: 'apply', cost: { reputation: 11 } }, + ]); + + expect( + system.getStorylineOptions(makeCard({ id: 'evt-cost-rep' }), state).map((o) => o.label), + ).toEqual(['Affordable']); + }); + + it('treats a cost equal to the current balance as affordable (boundary)', () => { + const { system, state } = makeSystem(); + state.coins = 75; + state.reputation = 3; + system.registerStorylineOptions('evt-cost-boundary', [ + { + label: 'Exact', + effectPolicy: 'apply', + cost: { coins: 75, reputation: 3 }, + }, + ]); + + expect( + system.getStorylineOptions(makeCard({ id: 'evt-cost-boundary' }), state).map( + (o) => o.label, + ), + ).toEqual(['Exact']); + }); + + it('enforces both the coin and reputation gates when both are specified', () => { + const { system, state } = makeSystem(); + state.coins = 100; + state.reputation = 5; + system.registerStorylineOptions('evt-cost-both', [ + { label: 'Both ok', effectPolicy: 'apply', cost: { coins: 100, reputation: 5 } }, + { label: 'Coins ok, rep short', effectPolicy: 'apply', cost: { coins: 100, reputation: 6 } }, + { label: 'Rep ok, coins short', effectPolicy: 'apply', cost: { coins: 101, reputation: 5 } }, + ]); + + expect( + system.getStorylineOptions(makeCard({ id: 'evt-cost-both' }), state).map((o) => o.label), + ).toEqual(['Both ok']); + }); + + it('always includes options without a cost', () => { + const { system, state } = makeSystem(); + state.coins = 0; + state.reputation = 0; + system.registerStorylineOptions('evt-cost-free', [ + { label: 'Free', effectPolicy: 'apply' }, + { label: 'Free skip', effectPolicy: 'skip' }, + ]); + + expect( + system.getStorylineOptions(makeCard({ id: 'evt-cost-free' }), state).map((o) => o.label), + ).toEqual(['Free', 'Free skip']); + }); + + it('applies the condition callback and the cost gate together', () => { + const { system, state } = makeSystem(); + state.coins = 50; + system.registerStorylineOptions('evt-cost-and-cond', [ + { label: 'Cond fails', effectPolicy: 'apply', condition: () => false, cost: { coins: 10 } }, + { label: 'Cost fails', effectPolicy: 'apply', condition: () => true, cost: { coins: 51 } }, + { label: 'Both pass', effectPolicy: 'apply', condition: () => true, cost: { coins: 50 } }, + ]); + + expect( + system.getStorylineOptions(makeCard({ id: 'evt-cost-and-cond' }), state).map( + (o) => o.label, + ), + ).toEqual(['Both pass']); + }); + + it('keeps a stable draw-time snapshot of the cost-filtered list', () => { + const { system, state } = makeSystem(); + state.coins = 100; + system.registerStorylineOptions('evt-cost-snapshot', [ + { label: 'Pay', effectPolicy: 'apply', cost: { coins: 100 } }, + { label: 'Free', effectPolicy: 'apply' }, + ]); + const card = makeCard({ id: 'evt-cost-snapshot' }); + + const pending = system.createPendingStorylineChoice(card, state); + expect(pending).not.toBeNull(); + // Resources change after the draw: the frozen snapshot must not change. + state.coins = 0; + expect(system.getPendingStorylineOptions(pending!, state).map((o) => o.label)).toEqual([ + 'Pay', + 'Free', + ]); + }); + + it('re-applies the cost filter when a legacy pending choice has no snapshot', () => { + const { system, state } = makeSystem(); + state.coins = 100; + system.registerStorylineOptions('evt-cost-legacy', [ + { label: 'Pay', effectPolicy: 'apply', cost: { coins: 100 } }, + ]); + const card = makeCard({ id: 'evt-cost-legacy' }); + const pending = system.createPendingStorylineChoice(card); + expect(pending).not.toBeNull(); + expect(pending!.options).toBeUndefined(); + + expect(system.getPendingStorylineOptions(pending!, state).map((o) => o.label)).toEqual([ + 'Pay', + ]); + state.coins = 99; + expect(system.getPendingStorylineOptions(pending!, state)).toEqual([]); + }); + + it('inherits cost-filtered options into the pending snapshot at draw time', () => { + const { system, state } = makeSystem(); + state.coins = 10; + system.registerStorylineOptions('evt-cost-draw', [ + { label: 'Affordable', effectPolicy: 'apply', cost: { coins: 10 } }, + { label: 'Unaffordable', effectPolicy: 'apply', cost: { coins: 11 } }, + { label: 'Free', effectPolicy: 'apply' }, + ]); + + const pending = system.createPendingStorylineChoice(makeCard({ id: 'evt-cost-draw' }), state); + expect(pending?.options?.map((o) => o.label)).toEqual(['Affordable', 'Free']); + }); +}); + // ── eventHasStoryline semantics ───────────────────────────── describe('eventHasStoryline', () => { From f8f2610b4e890155b83819e5a23fc93b93a1805e Mon Sep 17 00:00:00 2001 From: Sorra the Orc <250240+SorraTheOrc@users.noreply.github.com> Date: Fri, 23 Oct 2026 01:32:22 +0100 Subject: [PATCH 15/39] CG-0MVI04BLE0099ZP9: Implementation complete --- example-games/gym/scenes/GymStorylineScene.ts | 4 + src/story-line/resolve.ts | 107 ++++++++++-- src/story-line/types.ts | 18 ++ tests/story-line/helpers.ts | 4 + tests/story-line/resolve.test.ts | 158 ++++++++++++++++++ 5 files changed, 275 insertions(+), 16 deletions(-) diff --git a/example-games/gym/scenes/GymStorylineScene.ts b/example-games/gym/scenes/GymStorylineScene.ts index 0bafd8d75..d401f67f1 100644 --- a/example-games/gym/scenes/GymStorylineScene.ts +++ b/example-games/gym/scenes/GymStorylineScene.ts @@ -101,6 +101,10 @@ function createDemoEngine(): StorylineEngineAdapter { state.coins += card.coinDelta ?? 0; state.reputation += card.reputationDelta ?? 0; }, + applyResourceDelta: (state, delta) => { + state.coins += delta.coins ?? 0; + state.reputation += delta.reputation ?? 0; + }, getResources: (state) => ({ coins: state.coins, reputation: state.reputation }), appendLog: (state, text) => { state.activityLog.push({ turn: state.turn, text }); diff --git a/src/story-line/resolve.ts b/src/story-line/resolve.ts index ff2c90987..c33626a82 100644 --- a/src/story-line/resolve.ts +++ b/src/story-line/resolve.ts @@ -1,6 +1,20 @@ /** - * Story-line resolution semantics: effect policy, successor precedence and - * deterministic chain-card naming. + * Story-line resolution semantics: effect policy, cost payment, extra + * effects, successor precedence and deterministic chain-card naming. + * + * Resolution order for a selected option: + * + * 1. `effectPolicy` — `'apply'` runs the event's intrinsic effect, `'skip'` + * ignores it. Unchanged from before cost/extra-effect support. + * 2. Declarative `cost` — a real payment: the specified coins/reputation are + * deducted through the adapter's `applyResourceDelta` seam. Cost is + * **independent of `effectPolicy`** (it is paid for a `'skip'` option too). + * Draw-time filtering guarantees the player can afford it; resolution + * trusts the presented list. + * 3. Declarative `extraEffect` — a signed resource delta applied through the + * same seam, also **independent of `effectPolicy`**. + * + * `coinChange` / `repChange` in the result account for all three steps. * * Successor precedence at resolution time: * @@ -13,9 +27,6 @@ * chain ends without consuming a draw. * 3. Legacy single `successorId` — pushed directly, no draw. * - * The `effectPolicy` is honoured regardless of how the successor is resolved: - * `'apply'` runs the event's effect, `'skip'` ignores it. - * * @module */ @@ -26,6 +37,40 @@ import type { StorylineResolution, } from './types'; +/** + * Formats a signed resource delta for the activity log, omitting zero + * components (`-5 coins`, `+1 reputation`, both joined by `, `). + */ +function formatDelta(coins: number, reputation: number): string { + const parts: string[] = []; + if (coins !== 0) parts.push(`${coins > 0 ? '+' : ''}${coins} coins`); + if (reputation !== 0) parts.push(`${reputation > 0 ? '+' : ''}${reputation} reputation`); + return parts.join(', '); +} + +/** + * Applies a signed resource delta through the adapter seam. + * + * A legacy adapter without `applyResourceDelta` is left untouched and a + * single warning is appended to the activity log, so the missing capability + * is visible rather than silent. + */ +function applyDelta( + state: TState, + adapter: StorylineEngineAdapter, + delta: { coins?: number; reputation?: number }, +): void { + if (adapter.applyResourceDelta) { + adapter.applyResourceDelta(state, delta); + return; + } + adapter.appendLog( + state, + 'Story-line cost/extra-effect not applied: the engine adapter does not implement applyResourceDelta.', + 'neutral', + ); +} + /** * Pushes a chain successor card onto the active deck with deterministic * serial naming. @@ -66,20 +111,21 @@ export function pushChainCard( } /** - * Resolves a story-line option by applying its effect policy and pushing the - * successor card. + * Resolves a story-line option by applying its effect policy, paying its + * cost, applying its extra effect and pushing the successor card. * - * See the module doc comment for the successor-precedence contract. The - * uniform candidate draw consumes exactly one `engine.rng()` call per - * selection (replay-safe with a seeded rng); terminal options (empty - * candidate list) consume no draw. + * See the module doc comment for the resolution-order and successor- + * precedence contracts. The uniform candidate draw consumes exactly one + * `engine.rng()` call per selection (replay-safe with a seeded rng); a + * callback resolver and terminal options consume no draw. * - * @param state Current game state (mutated by effect application/push). - * @param adapter The engine adapter (rng draw, effect application, resources, - * chain-card sink). + * @param state Current game state (mutated by effect/cost/extra-effect + * application and the chain push). + * @param adapter The engine adapter (rng draw, effect application, optional + * resource-delta seam, resources, chain-card sink). * @param card The story-line card. * @param option The chosen story-line option. - * @returns The resolution result (pushed card, deltas). + * @returns The resolution result (pushed card, deltas across all three steps). */ export function resolveStorylineOption( state: TState, @@ -89,11 +135,40 @@ export function resolveStorylineOption( ): StorylineResolution { const before = adapter.getResources(state); - // Apply effect if the option's policy says so. + // 1. Apply effect if the option's policy says so (unchanged legacy step). if (option.effectPolicy === 'apply') { adapter.applyEffect(state, card); } + // 2. Pay the declarative cost — a real payment, independent of policy. + if (option.cost) { + const coins = option.cost.coins ?? 0; + const reputation = option.cost.reputation ?? 0; + if (coins !== 0 || reputation !== 0) { + applyDelta(state, adapter, { coins: -coins, reputation: -reputation }); + adapter.appendLog( + state, + `Cost paid: ${formatDelta(-coins, -reputation)}.`, + 'neutral', + ); + } + } + + // 3. Apply the declarative extra effect — independent of policy and of the + // cost payment. + if (option.extraEffect) { + const coins = option.extraEffect.coins ?? 0; + const reputation = option.extraEffect.reputation ?? 0; + if (coins !== 0 || reputation !== 0) { + applyDelta(state, adapter, { coins, reputation }); + adapter.appendLog( + state, + `Extra effect: ${formatDelta(coins, reputation)}.`, + 'neutral', + ); + } + } + const after = adapter.getResources(state); const coinChange = after.coins - before.coins; const repChange = after.reputation - before.reputation; diff --git a/src/story-line/types.ts b/src/story-line/types.ts index 0d9c410c2..6b7daf00e 100644 --- a/src/story-line/types.ts +++ b/src/story-line/types.ts @@ -270,6 +270,24 @@ export interface StorylineEngineAdapter { rng(state: TState): number; /** Apply the card's intrinsic effect to the game state. */ applyEffect(state: TState, card: TCard): void; + /** + * Optional resource-mutation seam: apply a signed resource delta to the + * game state. + * + * Core uses this to deduct an option's declarative `cost` and to apply its + * `extraEffect` without importing game types. Each specified component is a + * signed delta (`-5` deducts, `5` grants); an unspecified component leaves + * that resource unchanged. + * + * Optional for backwards compatibility: an adapter that predates the seam + * (or deliberately does not expose resources for mutation) omits it. In + * that case core leaves the state untouched and appends a warning line to + * the activity log whenever a cost or extra effect needs applying. + */ + applyResourceDelta?( + state: TState, + delta: { coins?: number; reputation?: number }, + ): void; /** Current spendable resources, for delta calculation. */ getResources(state: TState): { coins: number; reputation: number }; /** Append a line to the game's activity log. */ diff --git a/tests/story-line/helpers.ts b/tests/story-line/helpers.ts index 6cb93be07..afd46a3d4 100644 --- a/tests/story-line/helpers.ts +++ b/tests/story-line/helpers.ts @@ -90,6 +90,10 @@ export function makeAdapter( state.coins += card.coinDelta ?? 0; state.reputation += card.reputationDelta ?? 0; }, + applyResourceDelta(state, delta) { + state.coins += delta.coins ?? 0; + state.reputation += delta.reputation ?? 0; + }, getResources(state) { return { coins: state.coins, reputation: state.reputation }; }, diff --git a/tests/story-line/resolve.test.ts b/tests/story-line/resolve.test.ts index 2b41d09db..72a2cf727 100644 --- a/tests/story-line/resolve.test.ts +++ b/tests/story-line/resolve.test.ts @@ -273,3 +273,161 @@ describe('pending-choice lifecycle', () => { expect(pending.resolved).toBe(true); }); }); + +// ── C3: cost payment and extra effects ────────────────────── + +describe('cost payment and extra effects', () => { + it('deducts the option cost and reports it in the deltas', () => { + const { system, state } = setup(); + const card = makeCard({ id: 'evt-cost', name: 'Cost' }); + const res = system.resolveStorylineOption(state, card, { + label: 'Pay', + effectPolicy: 'skip', + cost: { coins: 100, reputation: 5 }, + }); + + expect(state.coins).toBe(900); + expect(state.reputation).toBe(45); + expect(res.coinChange).toBe(-100); + expect(res.repChange).toBe(-5); + }); + + it('applies extraEffect independently of the effect policy', () => { + const { system, state } = setup(); + const card = makeCard({ id: 'evt-extra', coinDelta: -200, reputationDelta: 5 }); + + const applied = system.resolveStorylineOption(state, card, { + label: 'Accept', + effectPolicy: 'apply', + extraEffect: { coins: 10, reputation: 2 }, + }); + // -200 (policy) + 10 (extra effect) + expect(applied.coinChange).toBe(-190); + expect(applied.repChange).toBe(7); + expect(state.coins).toBe(810); + expect(state.reputation).toBe(57); + + const skipped = system.resolveStorylineOption(state, card, { + label: 'Reject', + effectPolicy: 'skip', + extraEffect: { coins: 10, reputation: 2 }, + }); + // The card effect is skipped; the extra effect still applies. + expect(skipped.coinChange).toBe(10); + expect(skipped.repChange).toBe(2); + expect(state.coins).toBe(820); + expect(state.reputation).toBe(59); + }); + + it('combines cost and extraEffect in one resolution', () => { + const { system, state } = setup(); + const res = system.resolveStorylineOption(state, makeCard({ id: 'evt-both' }), { + label: 'Pay and gain', + effectPolicy: 'skip', + cost: { coins: 100 }, + extraEffect: { coins: 30, reputation: -2 }, + }); + + expect(res.coinChange).toBe(-70); + expect(res.repChange).toBe(-2); + expect(state.coins).toBe(930); + expect(state.reputation).toBe(48); + }); + + it('applies the policy effect before the cost and the extra effect', () => { + const { map } = setup(); + const calls: string[] = []; + const engine = makeAdapter(map); + const applyEffect = engine.applyEffect.bind(engine); + const applyResourceDelta = engine.applyResourceDelta!.bind(engine); + engine.applyEffect = (state, card) => { + calls.push(`effect:${card.id}`); + applyEffect(state, card); + }; + engine.applyResourceDelta = (state, delta) => { + calls.push(`delta:${delta.coins ?? 0}`); + applyResourceDelta(state, delta); + }; + const ordered = createStorylineSystem({ engine }); + const state = makeState(); + + ordered.resolveStorylineOption(state, makeCard({ id: 'evt-order' }), { + label: 'Ordered', + effectPolicy: 'apply', + cost: { coins: 100 }, + extraEffect: { coins: 25 }, + }); + + expect(calls).toEqual(['effect:evt-order', 'delta:-100', 'delta:25']); + expect(state.coins).toBe(925); + }); + + it('logs the cost payment before the extra effect', () => { + const { system, state } = setup(); + system.resolveStorylineOption(state, makeCard({ id: 'evt-log' }), { + label: 'Logged', + effectPolicy: 'skip', + cost: { coins: 5 }, + extraEffect: { reputation: 1 }, + }); + + const texts = state.activityLog.map((e) => e.text); + expect(texts).toEqual([ + 'Cost paid: -5 coins.', + 'Extra effect: +1 reputation.', + ]); + }); + + it('can drive resources to zero (loss-check precondition)', () => { + const { system, state } = setup(); + state.coins = 25; + state.reputation = 2; + + system.resolveStorylineOption(state, makeCard({ id: 'evt-zero' }), { + label: 'All in', + effectPolicy: 'skip', + cost: { coins: 25, reputation: 2 }, + }); + + expect(state.coins).toBe(0); + expect(state.reputation).toBe(0); + }); + + it('behaves identically when cost and extraEffect are absent', () => { + const { system, state } = setup(); + const card = makeCard({ id: 'evt-plain', coinDelta: -40, reputationDelta: 3 }); + const res = system.resolveStorylineOption(state, card, { + label: 'Accept', + effectPolicy: 'apply', + }); + + expect(res.coinChange).toBe(-40); + expect(res.repChange).toBe(3); + expect(state.coins).toBe(960); + expect(state.reputation).toBe(53); + expect(state.activityLog).toHaveLength(0); + }); + + it('leaves resources untouched and warns when the adapter has no mutation seam', () => { + const { map, state } = setup(); + const engine = makeAdapter(map); + // Simulate a legacy adapter implementor predating `applyResourceDelta`. + delete (engine as { applyResourceDelta?: unknown }).applyResourceDelta; + const legacy = createStorylineSystem({ engine }); + + const res = legacy.resolveStorylineOption(state, makeCard({ id: 'evt-legacy-seam' }), { + label: 'Pay', + effectPolicy: 'skip', + cost: { coins: 100 }, + extraEffect: { coins: 10 }, + }); + + expect(state.coins).toBe(1000); + expect(res.coinChange).toBe(0); + expect( + state.activityLog.some((e) => + e.text.includes('applyResourceDelta'), + ), + ).toBe(true); + }); +}); From 8fa2c1923c5afb9310aed939961a18e0d6bda65f Mon Sep 17 00:00:00 2001 From: Sorra the Orc <250240+SorraTheOrc@users.noreply.github.com> Date: Fri, 23 Oct 2026 02:06:04 +0100 Subject: [PATCH 16/39] CG-0MVI04D640023PDI: Implementation complete --- src/story-line/index.ts | 6 +- src/story-line/resolve.ts | 81 +++++++++-- tests/story-line/resolve.test.ts | 241 ++++++++++++++++++++++++++++++- 3 files changed, 316 insertions(+), 12 deletions(-) diff --git a/src/story-line/index.ts b/src/story-line/index.ts index 8ab67c48b..74127a06f 100644 --- a/src/story-line/index.ts +++ b/src/story-line/index.ts @@ -80,7 +80,11 @@ export { defaultJournalTitle, defaultStorylineSummary, } from './ui-adapter'; -export { pushChainCard, resolveStorylineOption } from './resolve'; +export { + evaluateCondition, + pushChainCard, + resolveStorylineOption, +} from './resolve'; export { buildJournal, journalIsEmpty, diff --git a/src/story-line/resolve.ts b/src/story-line/resolve.ts index c33626a82..3c5544de4 100644 --- a/src/story-line/resolve.ts +++ b/src/story-line/resolve.ts @@ -20,23 +20,62 @@ * * 1. `successorResolver` callback (when present) — invoked for the successor * id; a `null`/`undefined` return ends the chain. Consumes no RNG. - * 2. Candidate set (`successorIds`) — exactly **one** seeded `engine.rng()` + * 2. Declarative `conditionalSuccessors` — evaluated in declaration order + * against the post-resolution resources; the first matching rule's + * `successorId` is pushed (`null` ends the chain). A matching rule + * consumes no RNG draw; when no rule matches, resolution falls through + * to the random/legacy steps below. + * 3. Candidate set (`successorIds`) — exactly **one** seeded `engine.rng()` * draw selects the successor uniformly at random (one draw per selection, * at resolution time; replay with the same seed yields the same * successor). An **empty** candidate list is a terminal option — the * chain ends without consuming a draw. - * 3. Legacy single `successorId` — pushed directly, no draw. + * 4. Legacy single `successorId` — pushed directly, no draw. * * @module */ import type { + Condition, StorylineCard, StorylineEngineAdapter, StorylineOption, StorylineResolution, } from './types'; +/** + * True when a declarative condition matches the current resources. + * + * Pure: no RNG, no state mutation, no logging. The `==` operator compares + * numerically (`5 == 5.0`); equality boundaries follow the usual numeric + * semantics for the ordered operators. + * + * @param condition The field/op/value rule to evaluate. + * @param resources Current spendable resources. + */ +export function evaluateCondition( + condition: Condition, + resources: { coins: number; reputation: number }, +): boolean { + const current = resources[condition.field]; + switch (condition.op) { + case '>=': + return current >= condition.value; + case '<=': + return current <= condition.value; + case '>': + return current > condition.value; + case '<': + return current < condition.value; + case '==': + return current === condition.value; + default: { + const exhaustive: never = condition.op; + return exhaustive; + } + } +} + /** * Formats a signed resource delta for the activity log, omitting zero * components (`-5 coins`, `+1 reputation`, both joined by `, `). @@ -173,18 +212,23 @@ export function resolveStorylineOption( const coinChange = after.coins - before.coins; const repChange = after.reputation - before.reputation; - // Determine the successor id (precedence per the module doc): the uniform - // draw consumes exactly one rng() call per selection. + // Determine the successor id (precedence per the module doc): a matching + // declarative rule consumes no rng(); the uniform candidate draw consumes + // exactly one rng() call per selection. let resolvedSuccessor: string | null | undefined; if (option.successorResolver != null) { resolvedSuccessor = option.successorResolver(state); - } else if (option.successorIds != null) { - resolvedSuccessor = - option.successorIds.length > 0 - ? option.successorIds[Math.floor(adapter.rng(state) * option.successorIds.length)] - : null; // terminal option — the chain ends, no draw. + } else if (option.conditionalSuccessors != null && option.conditionalSuccessors.length > 0) { + // First matching rule wins (declaration order, post-resolution resources); + // when no rule matches, fall through to the random/legacy resolution. + const match = option.conditionalSuccessors.find((rule) => + evaluateCondition(rule.when, after), + ); + resolvedSuccessor = match + ? match.successorId + : resolveRawSuccessor(state, adapter, option); } else { - resolvedSuccessor = option.successorId; + resolvedSuccessor = resolveRawSuccessor(state, adapter, option); } // Push successor card if one exists. @@ -192,3 +236,20 @@ export function resolveStorylineOption( return { pushedCard, coinChange, repChange }; } + +/** + * Legacy successor resolution: the candidate-set random draw when + * `successorIds` is declared, otherwise the single `successorId`. + */ +function resolveRawSuccessor( + state: TState, + adapter: StorylineEngineAdapter, + option: StorylineOption, +): string | null | undefined { + if (option.successorIds != null) { + return option.successorIds.length > 0 + ? option.successorIds[Math.floor(adapter.rng(state) * option.successorIds.length)] + : null; // terminal option — the chain ends, no draw. + } + return option.successorId; +} diff --git a/tests/story-line/resolve.test.ts b/tests/story-line/resolve.test.ts index 72a2cf727..93479916b 100644 --- a/tests/story-line/resolve.test.ts +++ b/tests/story-line/resolve.test.ts @@ -14,7 +14,7 @@ */ import { describe, expect, it } from 'vitest'; -import { createStorylineSystem } from '../../src/story-line'; +import { createStorylineSystem, evaluateCondition } from '../../src/story-line'; import { addTemplate, makeAdapter, @@ -431,3 +431,242 @@ describe('cost payment and extra effects', () => { ).toBe(true); }); }); + +// ── C4: declarative conditional successors ────────────────── + +describe('declarative conditional successors', () => { + it('picks the first matching rule (greater-than threshold)', () => { + const { system, state, map } = setup([makeCard({ id: 'evt-high' })]); + expect(map.size).toBe(1); + state.coins = 600; + + const res = system.resolveStorylineOption(state, makeCard({ id: 'evt-card' }), { + label: 'Branch', + effectPolicy: 'skip', + conditionalSuccessors: [ + { when: { field: 'coins', op: '>', value: 500 }, successorId: 'evt-high' }, + ], + successorId: 'evt-fallback', + }); + + expect(res.pushedCard?.id).toBe('evt-high-0'); + expect(state.rngCalls).toBe(0); + }); + + it('does not match below the threshold', () => { + const { system, state, map } = setup([ + makeCard({ id: 'evt-low' }), + makeCard({ id: 'evt-fallback' }), + ]); + state.coins = 400; + expect(map.size).toBe(2); + + const res = system.resolveStorylineOption(state, makeCard({ id: 'evt-card' }), { + label: 'Branch', + effectPolicy: 'skip', + conditionalSuccessors: [ + { when: { field: 'coins', op: '>', value: 500 }, successorId: 'evt-low' }, + ], + successorId: 'evt-fallback', + }); + + expect(res.pushedCard?.id).toBe('evt-fallback-0'); + }); + + it('treats equality as a match for >= (boundary)', () => { + const { system, state } = setup([makeCard({ id: 'evt-eq' })]); + state.reputation = 42; + + const res = system.resolveStorylineOption(state, makeCard({ id: 'evt-card' }), { + label: 'Branch', + effectPolicy: 'skip', + conditionalSuccessors: [ + { when: { field: 'reputation', op: '>=', value: 42 }, successorId: 'evt-eq' }, + ], + successorId: 'evt-fallback', + }); + + expect(res.pushedCard?.id).toBe('evt-eq-0'); + }); + + it('treats equality as a non-match for > (boundary)', () => { + const { system, state } = setup([ + makeCard({ id: 'evt-eq' }), + makeCard({ id: 'evt-fallback' }), + ]); + state.reputation = 42; + + const res = system.resolveStorylineOption(state, makeCard({ id: 'evt-card' }), { + label: 'Branch', + effectPolicy: 'skip', + conditionalSuccessors: [ + { when: { field: 'reputation', op: '>', value: 42 }, successorId: 'evt-eq' }, + ], + successorId: 'evt-fallback', + }); + + expect(res.pushedCard?.id).toBe('evt-fallback-0'); + }); + + it('evaluates rules sequentially and takes the first match', () => { + const { system, state } = setup([ + makeCard({ id: 'evt-first' }), + makeCard({ id: 'evt-second' }), + ]); + state.coins = 100; + + const res = system.resolveStorylineOption(state, makeCard({ id: 'evt-card' }), { + label: 'Branch', + effectPolicy: 'skip', + conditionalSuccessors: [ + { when: { field: 'coins', op: '>=', value: 50 }, successorId: 'evt-first' }, + { when: { field: 'coins', op: '>=', value: 0 }, successorId: 'evt-second' }, + ], + }); + + expect(res.pushedCard?.id).toBe('evt-first-0'); + }); + + it('a matching rule consumes no RNG draw', () => { + const { system, state } = setup([makeCard({ id: 'evt-branch' })]); + state.coins = 10; + state.rngQueue = [0]; // would select evt-a if the draw ran + + const res = system.resolveStorylineOption(state, makeCard({ id: 'evt-card' }), { + label: 'Branch', + effectPolicy: 'skip', + conditionalSuccessors: [ + { when: { field: 'coins', op: '<=', value: 10 }, successorId: 'evt-branch' }, + ], + successorIds: ['evt-cand'], + }); + + expect(res.pushedCard?.id).toBe('evt-branch-0'); + expect(state.rngCalls).toBe(0); + }); + + it('runs ahead of the random candidate draw', () => { + const { system, state } = setup([makeCard({ id: 'evt-rule' })]); + state.coins = 1; + + const res = system.resolveStorylineOption(state, makeCard({ id: 'evt-card' }), { + label: 'Branch', + effectPolicy: 'skip', + conditionalSuccessors: [ + { when: { field: 'coins', op: '>=', value: 0 }, successorId: 'evt-rule' }, + ], + successorIds: ['evt-cand-a', 'evt-cand-b'], + }); + + expect(res.pushedCard?.id).toBe('evt-rule-0'); + expect(state.rngCalls).toBe(0); + }); + + it('keeps the callback resolver at the highest precedence', () => { + const { system, state } = setup([makeCard({ id: 'evt-resolved' })]); + state.coins = 1; + + const res = system.resolveStorylineOption(state, makeCard({ id: 'evt-card' }), { + label: 'Branch', + effectPolicy: 'skip', + successorResolver: () => 'evt-resolved', + conditionalSuccessors: [ + { when: { field: 'coins', op: '>=', value: 0 }, successorId: 'evt-rule' }, + ], + }); + + expect(res.pushedCard?.id).toBe('evt-resolved-0'); + expect(state.rngCalls).toBe(0); + }); + + it('a matching null target ends the chain without pushing a card', () => { + const { system, state } = setup(); + state.coins = 999; + + const res = system.resolveStorylineOption(state, makeCard({ id: 'evt-card' }), { + label: 'Branch', + effectPolicy: 'skip', + conditionalSuccessors: [ + { when: { field: 'coins', op: '>=', value: 500 }, successorId: null }, + ], + successorId: 'evt-fallback', + }); + + expect(res.pushedCard).toBeNull(); + expect(state.deck).toHaveLength(0); + }); + + it('falls back to the legacy single successorId when no rule matches', () => { + const { system, state } = setup([makeCard({ id: 'evt-legacy' })]); + state.coins = 10; + + const res = system.resolveStorylineOption(state, makeCard({ id: 'evt-card' }), { + label: 'Branch', + effectPolicy: 'skip', + conditionalSuccessors: [ + { when: { field: 'coins', op: '>', value: 100 }, successorId: 'unused' }, + ], + successorId: 'evt-legacy', + }); + expect(res.pushedCard?.id).toBe('evt-legacy-0'); + }); + + it('evaluates rules against the post-effect resources', () => { + const { system, state } = setup([ + makeCard({ id: 'evt-with-effect', coinDelta: -50 }), + ]); + state.coins = 100; + + const res = system.resolveStorylineOption(state, makeCard({ id: 'evt-card', coinDelta: -50 }), { + label: 'Branch', + effectPolicy: 'apply', + conditionalSuccessors: [ + { when: { field: 'coins', op: '<=', value: 50 }, successorId: 'evt-with-effect' }, + ], + successorId: 'evt-fallback', + }); + + // The card effect drops coins 100 → 50, so the <= 50 rule matches and + // the pushed successor must be the rule's target (not the fallback). + expect(res.pushedCard?.id).toBe('evt-with-effect-0'); + }); + + it('behaves unchanged when conditionalSuccessors is absent', () => { + const { system, state } = setup([makeCard({ id: 'evt-cand' })]); + + const res = system.resolveStorylineOption(state, makeCard({ id: 'evt-card' }), { + label: 'Pick', + effectPolicy: 'skip', + successorIds: ['evt-cand'], + }); + + expect(state.rngCalls).toBe(1); + expect(res.pushedCard).not.toBeNull(); + }); +}); + +// ── C4: pure condition helper ─────────────────────────────── + +describe('evaluateCondition', () => { + const resources = { coins: 100, reputation: 5 }; + + it('implements every declarative operator at and around the boundary', () => { + expect(evaluateCondition({ field: 'coins', op: '>=', value: 100 }, resources)).toBe(true); + expect(evaluateCondition({ field: 'coins', op: '>=', value: 101 }, resources)).toBe(false); + expect(evaluateCondition({ field: 'coins', op: '<=', value: 100 }, resources)).toBe(true); + expect(evaluateCondition({ field: 'coins', op: '<=', value: 99 }, resources)).toBe(false); + expect(evaluateCondition({ field: 'coins', op: '>', value: 99 }, resources)).toBe(true); + expect(evaluateCondition({ field: 'coins', op: '>', value: 100 }, resources)).toBe(false); + expect(evaluateCondition({ field: 'coins', op: '<', value: 101 }, resources)).toBe(true); + expect(evaluateCondition({ field: 'coins', op: '<', value: 100 }, resources)).toBe(false); + expect(evaluateCondition({ field: 'coins', op: '==', value: 100 }, resources)).toBe(true); + expect(evaluateCondition({ field: 'coins', op: '==', value: 99 }, resources)).toBe(false); + }); + + it('reads the field named by the condition', () => { + expect(evaluateCondition({ field: 'reputation', op: '>=', value: 5 }, resources)).toBe(true); + expect(evaluateCondition({ field: 'reputation', op: '>=', value: 6 }, resources)).toBe(false); + expect(evaluateCondition({ field: 'reputation', op: '<=', value: 5 }, resources)).toBe(true); + expect(evaluateCondition({ field: 'reputation', op: '>', value: 5 }, resources)).toBe(false); + }); +}); From 58d4e12ee6df52aa04e67342ba323839ca6c85bd Mon Sep 17 00:00:00 2001 From: Sorra the Orc <250240+SorraTheOrc@users.noreply.github.com> Date: Fri, 23 Oct 2026 02:21:48 +0100 Subject: [PATCH 17/39] CG-0MVI04VKX009VPVD: Implementation complete --- docs/core-engine/story-line.md | 42 +++++ src/story-line/index.ts | 7 + src/story-line/types.ts | 4 + src/story-line/validate.ts | 249 +++++++++++++++++++++++++ tests/story-line/resolve.test.ts | 6 +- tests/story-line/validate.test.ts | 292 ++++++++++++++++++++++++++++++ 6 files changed, 597 insertions(+), 3 deletions(-) create mode 100644 src/story-line/validate.ts create mode 100644 tests/story-line/validate.test.ts diff --git a/docs/core-engine/story-line.md b/docs/core-engine/story-line.md index a08f62c73..cd587fa0a 100644 --- a/docs/core-engine/story-line.md +++ b/docs/core-engine/story-line.md @@ -58,6 +58,48 @@ the successor with this precedence: The pushed card uses deterministic serial naming: `${base}-${maxSerial+1}`, scanning every card the engine adapter returns from `getChainScanCards`. +## Validation + +`validateStorylineOptions(options, { knownTemplateIds? })` statically validates +the **declarative** parts of an option list — `cost`, `extraEffect` and +`conditionalSuccessors`. It never throws and never mutates the list, and it +works on any source: options registered with `registerStorylineOptions`, +options compiled from a CSV `options` encoding, or an option list assembled by +a game. + +```ts +import { validateStorylineOptions } from '@story-line'; + +const { valid, issues } = validateStorylineOptions( + storyline.getStorylineOptions(card, state), + { knownTemplateIds: allCardTemplateIds }, +); +if (!valid) { + for (const issue of issues) { + console.warn(`${issue.path}: ${issue.message}`); + } +} +``` + +Rules: + +| Code | Rejects | +|---|---| +| `non-positive-cost` | `cost.coins` / `cost.reputation` equal to zero or negative. | +| `negative-extra-effect` | `extraEffect.coins` / `extraEffect.reputation` below zero (extra effects are gains). | +| `unknown-condition-field` | A condition field other than `coins` / `reputation`. | +| `unknown-condition-op` | An operator other than `>=`, `<=`, `>`, `<`, `==`. | +| `non-numeric-value` | A non-finite (or non-numeric) cost, extra-effect or condition value. | +| `dangling-conditional-successor` | A rule targeting an id outside `knownTemplateIds` (checked only when the catalogue is supplied). | +| `invalid-successor-id` | A rule `successorId` that is neither a string nor `null`. | +| `invalid-conditional-successors` | A `conditionalSuccessors` value that is not an array of rule objects. | +| `invalid-condition` | A rule without a `when` condition object. | + +Validation is authoring-time only: runtime resolution remains permissive, so a +list that fails validation still resolves exactly as declared. No shipped CSV +content uses `cost`, `extraEffect` or `conditionalSuccessors`, so all shipped +content validates cleanly. + ## Engine adapter `StorylineEngineAdapter` inverts all state coupling: diff --git a/src/story-line/index.ts b/src/story-line/index.ts index 74127a06f..69eb887ea 100644 --- a/src/story-line/index.ts +++ b/src/story-line/index.ts @@ -85,6 +85,13 @@ export { pushChainCard, resolveStorylineOption, } from './resolve'; +export { validateStorylineOptions } from './validate'; +export type { + StorylineValidationCode, + StorylineValidationIssue, + StorylineValidationResult, + ValidateStorylineOptionsOptions, +} from './validate'; export { buildJournal, journalIsEmpty, diff --git a/src/story-line/types.ts b/src/story-line/types.ts index 6b7daf00e..3bfa28cba 100644 --- a/src/story-line/types.ts +++ b/src/story-line/types.ts @@ -103,6 +103,10 @@ export interface OptionCost { * * Each specified component is a signed delta (`-5` deducts, `5` grants). An * unspecified component leaves that resource unchanged. + * + * Authoring rule: extra effects are **gains**, so a negative component is + * rejected by {@link validateStorylineOptions} (`negative-extra-effect`). + * Resolution itself stays permissive and applies whatever is declared. */ export interface OptionExtraEffect { /** Coin delta applied on resolution. */ diff --git a/src/story-line/validate.ts b/src/story-line/validate.ts new file mode 100644 index 000000000..b742a724e --- /dev/null +++ b/src/story-line/validate.ts @@ -0,0 +1,249 @@ +/** + * Static validation for declarative story-line option metadata. + * + * Core has no CSV pipeline, JSON schema or graph exporter (those are game + * concerns). This helper validates the **declarative** parts of an option + * list — `cost`, `extraEffect`, `conditionalSuccessors` — whether the list + * was registered programmatically via `registerStorylineOptions`, compiled + * from a CSV `options` encoding, or assembled by a game. It never throws and + * never mutates the options it inspects. + * + * Validation is intentionally authoring-oriented: runtime resolution stays + * permissive, so an option list that fails validation still resolves exactly + * as declared. Use the result to reject bad content at load/build time. + * + * @module + */ + +import type { StorylineOption } from './types'; + +/** Machine-readable category for a single validation issue. */ +export type StorylineValidationCode = + | 'non-positive-cost' + | 'negative-extra-effect' + | 'unknown-condition-field' + | 'unknown-condition-op' + | 'non-numeric-value' + | 'dangling-conditional-successor' + | 'invalid-successor-id' + | 'invalid-conditional-successors' + | 'invalid-condition'; + +/** A single validation issue: where, what, and why. */ +export interface StorylineValidationIssue { + /** Dotted path to the offending field (e.g. `options[1].cost.coins`). */ + readonly path: string; + /** Stable machine-readable category. */ + readonly code: StorylineValidationCode; + /** Human-readable explanation for content authors. */ + readonly message: string; +} + +/** Result of {@link validateStorylineOptions}. */ +export interface StorylineValidationResult { + /** True when no issues were found. */ + readonly valid: boolean; + /** Every issue found, in declaration order (empty when valid). */ + readonly issues: readonly StorylineValidationIssue[]; +} + +/** Options controlling {@link validateStorylineOptions}. */ +export interface ValidateStorylineOptionsOptions { + /** + * Ids of every card template that can be a chain successor. When provided, + * a conditional rule targeting an id outside this set is reported as a + * dangling successor. When omitted, only the successor id's *shape* + * (`string` or `null`) is checked. + */ + readonly knownTemplateIds?: Iterable; +} + +const CONDITION_FIELDS: ReadonlySet = new Set(['coins', 'reputation']); +const CONDITION_OPS: ReadonlySet = new Set(['>=', '<=', '>', '<', '==']); +const COST_KEYS = ['coins', 'reputation'] as const; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isFiniteNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value); +} + +/** + * Validates declarative option metadata (costs, extra effects and + * conditional successors). + * + * Rules: + * + * - `cost.coins` / `cost.reputation`, when specified, must be a finite + * number **greater than zero** (a zero or negative cost is rejected). + * - `extraEffect.coins` / `extraEffect.reputation`, when specified, must be a + * finite number and **must not be negative** (extra effects are gains). + * - A condition must use field `coins`/`reputation`, one of the five + * supported operators, and a finite numeric value. + * - A conditional rule's `successorId` must be a string or `null`; when + * `knownTemplateIds` is supplied, a string successor id outside that set is + * reported as dangling. + * + * Every issue is reported (the result is not short-circuited), so content + * authors see the full problem list at once. + * + * @param options Ordered option list to validate. + * @param config Optional known-template catalogue for dangling checks. + * @returns `{ valid, issues }` — never throws. + */ +export function validateStorylineOptions( + options: readonly StorylineOption[], + config: ValidateStorylineOptionsOptions = {}, +): StorylineValidationResult { + const issues: StorylineValidationIssue[] = []; + const knownTemplateIds = + config.knownTemplateIds === undefined + ? undefined + : new Set(config.knownTemplateIds); + + const push = ( + path: string, + code: StorylineValidationCode, + message: string, + ): void => { + issues.push({ path, code, message }); + }; + + options.forEach((option, index) => { + const base = `options[${index}]`; + + // ── cost ──────────────────────────────────────────────── + const cost: unknown = option.cost; + if (cost !== undefined) { + if (!isRecord(cost)) { + push(`${base}.cost`, 'non-numeric-value', 'cost must be an object with numeric coins/reputation.'); + } else { + for (const key of COST_KEYS) { + const value = cost[key]; + if (value === undefined) continue; + if (!isFiniteNumber(value)) { + push( + `${base}.cost.${key}`, + 'non-numeric-value', + `cost.${key} must be a finite number.`, + ); + } else if (value <= 0) { + push( + `${base}.cost.${key}`, + 'non-positive-cost', + `cost.${key} must be greater than zero (got ${value}).`, + ); + } + } + } + } + + // ── extraEffect ───────────────────────────────────────── + const extraEffect: unknown = option.extraEffect; + if (extraEffect !== undefined) { + if (!isRecord(extraEffect)) { + push( + `${base}.extraEffect`, + 'non-numeric-value', + 'extraEffect must be an object with numeric coins/reputation.', + ); + } else { + for (const key of COST_KEYS) { + const value = extraEffect[key]; + if (value === undefined) continue; + if (!isFiniteNumber(value)) { + push( + `${base}.extraEffect.${key}`, + 'non-numeric-value', + `extraEffect.${key} must be a finite number.`, + ); + } else if (value < 0) { + push( + `${base}.extraEffect.${key}`, + 'negative-extra-effect', + `extraEffect.${key} must not be negative (got ${value}).`, + ); + } + } + } + } + + // ── conditionalSuccessors ─────────────────────────────── + const rules: unknown = option.conditionalSuccessors; + if (rules !== undefined) { + if (!Array.isArray(rules)) { + push( + `${base}.conditionalSuccessors`, + 'invalid-conditional-successors', + 'conditionalSuccessors must be an ordered array of rules.', + ); + } else { + rules.forEach((rule: unknown, ruleIndex: number) => { + const rulePath = `${base}.conditionalSuccessors[${ruleIndex}]`; + if (!isRecord(rule)) { + push( + rulePath, + 'invalid-conditional-successors', + 'a conditional successor rule must be an object.', + ); + return; + } + + const when: unknown = rule.when; + if (!isRecord(when)) { + push( + `${rulePath}.when`, + 'invalid-condition', + 'a conditional rule requires a when condition object.', + ); + } else { + if (!CONDITION_FIELDS.has(String(when.field))) { + push( + `${rulePath}.when.field`, + 'unknown-condition-field', + `unknown condition field ${JSON.stringify(when.field)} (expected "coins" or "reputation").`, + ); + } + if (!CONDITION_OPS.has(String(when.op))) { + push( + `${rulePath}.when.op`, + 'unknown-condition-op', + `unknown condition op ${JSON.stringify(when.op)} (expected one of >=, <=, >, <, ==).`, + ); + } + if (!isFiniteNumber(when.value)) { + push( + `${rulePath}.when.value`, + 'non-numeric-value', + 'a condition value must be a finite number.', + ); + } + } + + const successorId: unknown = rule.successorId; + if (successorId !== null && typeof successorId !== 'string') { + push( + `${rulePath}.successorId`, + 'invalid-successor-id', + 'a rule successorId must be a card id string or null.', + ); + } else if ( + typeof successorId === 'string' && + knownTemplateIds !== undefined && + !knownTemplateIds.has(successorId) + ) { + push( + `${rulePath}.successorId`, + 'dangling-conditional-successor', + `conditional successor "${successorId}" is not a known card template.`, + ); + } + }); + } + } + }); + + return { valid: issues.length === 0, issues }; +} diff --git a/tests/story-line/resolve.test.ts b/tests/story-line/resolve.test.ts index 93479916b..8e225c389 100644 --- a/tests/story-line/resolve.test.ts +++ b/tests/story-line/resolve.test.ts @@ -325,13 +325,13 @@ describe('cost payment and extra effects', () => { label: 'Pay and gain', effectPolicy: 'skip', cost: { coins: 100 }, - extraEffect: { coins: 30, reputation: -2 }, + extraEffect: { coins: 30, reputation: 2 }, }); expect(res.coinChange).toBe(-70); - expect(res.repChange).toBe(-2); + expect(res.repChange).toBe(2); expect(state.coins).toBe(930); - expect(state.reputation).toBe(48); + expect(state.reputation).toBe(52); }); it('applies the policy effect before the cost and the extra effect', () => { diff --git a/tests/story-line/validate.test.ts b/tests/story-line/validate.test.ts new file mode 100644 index 000000000..1f4855b6d --- /dev/null +++ b/tests/story-line/validate.test.ts @@ -0,0 +1,292 @@ +/** + * Static validation tests for declarative story-line option metadata. + * + * Each case is a broken fixture targeting one validation rule, plus positive + * cases (clean declarative options, CSV-compiled options, null rule targets) + * and the registered-option path. + * + * @module + */ +import { describe, expect, it } from 'vitest'; + +import { + createStorylineSystem, + parseStorylineOptionsEncoding, + validateStorylineOptions, + type StorylineOption, +} from '../../src/story-line'; +import { makeAdapter, makeCard, makeState } from './helpers'; + +/** A complete, valid declarative option. */ +const VALID_OPTION: StorylineOption = { + label: 'Investigate', + effectPolicy: 'apply', + cost: { coins: 50, reputation: 2 }, + extraEffect: { coins: 10, reputation: 1 }, + conditionalSuccessors: [ + { when: { field: 'coins', op: '>=', value: 100 }, successorId: 'evt-rich' }, + { when: { field: 'coins', op: '<', value: 5 }, successorId: null }, + ], + successorId: 'evt-fallback', +}; + +/** Collects the issue codes for a list of options. */ +function codes(options: readonly StorylineOption[]): string[] { + return validateStorylineOptions(options).issues.map((i) => i.code); +} + +describe('validateStorylineOptions — valid data', () => { + it('accepts options without declarative metadata (legacy/CSV shape)', () => { + const { system } = makeSystem(); + const options = system.parseStorylineOptionsEncoding('Accept|evt-a;Reject|'); + expect(validateStorylineOptions(options)).toEqual({ valid: true, issues: [] }); + }); + + it('accepts a fully declarative option', () => { + const result = validateStorylineOptions([VALID_OPTION]); + expect(result.valid).toBe(true); + expect(result.issues).toEqual([]); + }); + + it('accepts a null conditional target (ends the chain) and structural ids', () => { + const result = validateStorylineOptions([ + { + label: 'Stop', + effectPolicy: 'skip', + conditionalSuccessors: [ + { when: { field: 'reputation', op: '==', value: 0 }, successorId: null }, + ], + }, + ]); + expect(result.valid).toBe(true); + }); + + it('only treats conditional ids as dangling when a template catalogue is supplied', () => { + expect(validateStorylineOptions([VALID_OPTION]).valid).toBe(true); + + const known = validateStorylineOptions([VALID_OPTION], { + knownTemplateIds: ['evt-rich', 'evt-fallback'], + }); + expect(known.valid).toBe(true); + + const dangling = validateStorylineOptions([VALID_OPTION], { + knownTemplateIds: ['evt-fallback'], + }); + expect(dangling.issues.map((i) => i.code)).toEqual(['dangling-conditional-successor']); + expect(dangling.issues[0]?.path).toContain('conditionalSuccessors[0].successorId'); + }); +}); + +describe('validateStorylineOptions — cost rules', () => { + it('rejects a zero cost', () => { + expect( + validateStorylineOptions([ + { label: 'Free?', effectPolicy: 'skip', cost: { coins: 0 } }, + ]).issues.map((i) => i.code), + ).toEqual(['non-positive-cost']); + }); + + it('rejects a negative cost (coins and reputation)', () => { + const result = validateStorylineOptions([ + { label: 'Bad', effectPolicy: 'skip', cost: { coins: -1, reputation: -5 } }, + ]); + expect(result.valid).toBe(false); + expect(result.issues.map((i) => i.code)).toEqual([ + 'non-positive-cost', + 'non-positive-cost', + ]); + expect(result.issues.map((i) => i.path)).toEqual([ + 'options[0].cost.coins', + 'options[0].cost.reputation', + ]); + }); + + it('rejects a non-numeric cost value', () => { + const result = validateStorylineOptions([ + { + label: 'Bad', + effectPolicy: 'skip', + cost: { coins: Number.NaN }, + }, + ]); + expect(result.issues.map((i) => i.code)).toEqual(['non-numeric-value']); + }); + + it('rejects a non-object cost at runtime', () => { + const result = validateStorylineOptions([ + { + label: 'Bad', + effectPolicy: 'skip', + cost: 'free' as unknown as StorylineOption['cost'], + }, + ]); + expect(result.issues.map((i) => i.code)).toEqual(['non-numeric-value']); + }); +}); + +describe('validateStorylineOptions — extra effect rules', () => { + it('rejects a negative extra effect on either resource', () => { + const result = validateStorylineOptions([ + { + label: 'Bad', + effectPolicy: 'apply', + extraEffect: { coins: -10, reputation: -1 }, + }, + ]); + expect(result.issues.map((i) => i.code)).toEqual([ + 'negative-extra-effect', + 'negative-extra-effect', + ]); + }); + + it('accepts a zero extra effect and a positive gain', () => { + expect( + validateStorylineOptions([ + { label: 'Ok', effectPolicy: 'apply', extraEffect: { coins: 0, reputation: 3 } }, + ]).valid, + ).toBe(true); + }); + + it('rejects a non-numeric extra effect value', () => { + expect( + codes([ + { + label: 'Bad', + effectPolicy: 'apply', + extraEffect: { coins: Number.POSITIVE_INFINITY }, + }, + ]), + ).toEqual(['non-numeric-value']); + }); +}); + +describe('validateStorylineOptions — condition rules', () => { + it('rejects an unknown condition field', () => { + const result = validateStorylineOptions([ + { + label: 'Bad', + effectPolicy: 'skip', + conditionalSuccessors: [ + { + when: { field: 'gold' as unknown as 'coins', op: '>=', value: 1 }, + successorId: 'evt-a', + }, + ], + }, + ]); + expect(result.issues.map((i) => i.code)).toEqual(['unknown-condition-field']); + }); + + it('rejects an unknown condition operator', () => { + const result = validateStorylineOptions([ + { + label: 'Bad', + effectPolicy: 'skip', + conditionalSuccessors: [ + { + when: { field: 'coins', op: '!=' as unknown as '>=', value: 1 }, + successorId: 'evt-a', + }, + ], + }, + ]); + expect(result.issues.map((i) => i.code)).toEqual(['unknown-condition-op']); + }); + + it('rejects a non-numeric condition value', () => { + const result = validateStorylineOptions([ + { + label: 'Bad', + effectPolicy: 'skip', + conditionalSuccessors: [ + { + when: { + field: 'coins', + op: '>=', + value: '100' as unknown as number, + }, + successorId: 'evt-a', + }, + ], + }, + ]); + expect(result.issues.map((i) => i.code)).toEqual(['non-numeric-value']); + }); + + it('rejects a structurally invalid successor id', () => { + const result = validateStorylineOptions([ + { + label: 'Bad', + effectPolicy: 'skip', + conditionalSuccessors: [ + { + when: { field: 'coins', op: '>=', value: 1 }, + successorId: 42 as unknown as string, + }, + ], + }, + ]); + expect(result.issues.map((i) => i.code)).toEqual(['invalid-successor-id']); + }); + + it('rejects a missing when condition', () => { + const rule = { successorId: 'evt-a' } as unknown as NonNullable< + StorylineOption['conditionalSuccessors'] + >[number]; + const result = validateStorylineOptions([ + { label: 'Bad', effectPolicy: 'skip', conditionalSuccessors: [rule] }, + ]); + expect(result.issues.map((i) => i.code)).toEqual(['invalid-condition']); + }); + + it('rejects a non-array conditionalSuccessors value', () => { + const result = validateStorylineOptions([ + { + label: 'Bad', + effectPolicy: 'skip', + conditionalSuccessors: {} as unknown as StorylineOption['conditionalSuccessors'], + }, + ]); + expect(result.issues.map((i) => i.code)).toEqual(['invalid-conditional-successors']); + }); +}); + +describe('validateStorylineOptions — reported scope', () => { + it('reports every issue with its option-relative path', () => { + const result = validateStorylineOptions([ + { label: 'Ok', effectPolicy: 'apply', cost: { coins: 5 } }, + { label: 'Bad', effectPolicy: 'apply', cost: { coins: -5 } }, + { label: 'Also bad', effectPolicy: 'apply', extraEffect: { reputation: -1 } }, + ]); + expect(result.valid).toBe(false); + expect(result.issues.map((i) => i.path)).toEqual([ + 'options[1].cost.coins', + 'options[2].extraEffect.reputation', + ]); + }); + + it('validates options registered programmatically, not only compiled CSV rows', () => { + const { system } = makeSystem(); + system.registerStorylineOptions('evt-registered', [ + { label: 'Pay', effectPolicy: 'apply', cost: { coins: 0 } }, + ]); + const card = makeCard({ id: 'evt-registered' }); + + const compiled = system.getStorylineOptions(card, makeState()); + expect(compiled).toHaveLength(1); + expect(validateStorylineOptions(compiled).issues.map((i) => i.code)).toEqual([ + 'non-positive-cost', + ]); + }); + + it('validates parsed CSV encodings (shipped content carries no cost metadata)', () => { + const options = parseStorylineOptionsEncoding('Investigate|evt-a,evt-b;Ignore|'); + expect(validateStorylineOptions(options).valid).toBe(true); + }); +}); + +/** A system instance bound to an empty template catalogue + adapter. */ +function makeSystem() { + const system = createStorylineSystem({ engine: makeAdapter(new Map()) }); + return { system }; +} From fc9fa5f65a050debc91e811366132e9e101efa6a Mon Sep 17 00:00:00 2001 From: Sorra the Orc <250240+SorraTheOrc@users.noreply.github.com> Date: Fri, 23 Oct 2026 02:32:24 +0100 Subject: [PATCH 18/39] CG-0MVI04WZ8005SD28: Implementation complete --- docs/DEVELOPER.md | 11 +++ docs/core-engine/story-line.md | 121 ++++++++++++++++++++++++++++----- 2 files changed, 114 insertions(+), 18 deletions(-) diff --git a/docs/DEVELOPER.md b/docs/DEVELOPER.md index 11276098d..050a29576 100644 --- a/docs/DEVELOPER.md +++ b/docs/DEVELOPER.md @@ -1630,6 +1630,17 @@ This keeps `@core-engine/*`, `@card-system/*`, `@rule-engine/*`, `@ui/*` and The generic branching story-line system (`@story-line`) is documented in [`docs/core-engine/story-line.md`](core-engine/story-line.md). +**Authoring seam.** The extended option model — declarative `cost`, +`extraEffect` and `conditionalSuccessors` — is **programmatic** only: register +it with `registerStorylineOptions(eventId, options)` and validate the list with +`validateStorylineOptions(...)`. The CSV `options` column +(`label|cardA,cardB;label2|cardC`) stays a label/outcome shorthand and cannot +author the extended fields, so existing CSV content is unaffected. The engine +adapter's optional `applyResourceDelta(state, { coins?, reputation? })` method +is the seam core uses to deduct costs and apply extra effects; an adapter that +omits it keeps compiling and running unchanged (core warns in the activity log +instead of mutating resources). + ## Build-Time Version Injection The app version (from `package.json`'s `version` field) is injected at build time diff --git a/docs/core-engine/story-line.md b/docs/core-engine/story-line.md index cd587fa0a..e76ff0687 100644 --- a/docs/core-engine/story-line.md +++ b/docs/core-engine/story-line.md @@ -27,6 +27,66 @@ and returns a {@link StorylineSystem} with: | Resolution | `resolveStorylineOption`, `pushChainCard` | | Pending choice | `createPendingStorylineChoice`, `getPendingStorylineOptions`, `markChoiceResolved` | | Journal | `parseStoryUpdate`, `buildJournal`, `journalIsEmpty`, `journalTitle` | +| Validation | `validateStorylineOptions` (declarative metadata guard) | + +### Story-line options + +A `StorylineOption` is the unit a player chooses from. Core describes options +**programmatically** via `registerStorylineOptions` — the CSV `options` column +is a *label/outcome* shorthand and cannot author the extended fields below +(producer decision: programmatic only). + +| Field | Type | When it applies | +|---|---|---| +| `label` | `string` | always | +| `effectPolicy` | `'apply' \| 'skip'` | always — controls the card's intrinsic effect | +| `condition?` | `(state) => boolean` | draw time — omit the option when `false` | +| `successorId?` | `string \| null` | resolution — legacy single successor | +| `successorIds?` | `string[]` | resolution — one seeded random draw picks a candidate | +| `successorResolver?` | `(state) => string \| null` | resolution — callback, highest precedence | +| `conditionalSuccessors?` | `ConditionalSuccessorRule[]` | resolution — first matching rule wins | +| `cost?` | `{ coins?; reputation? }` | draw time **and** resolution — affordability gate + real payment | +| `extraEffect?` | `{ coins?; reputation? }` | resolution — additional resource gain | + +```ts +import { createStorylineSystem, validateStorylineOptions } from '@story-line'; + +storyline.registerStorylineOptions('evt-mystery', [ + { + label: 'Hire a detective', + effectPolicy: 'apply', + cost: { coins: 100, reputation: 5 }, + extraEffect: { coins: 10 }, + conditionalSuccessors: [ + { when: { field: 'coins', op: '>=', value: 500 }, successorId: 'evt-rich-outcome' }, + { when: { field: 'reputation', op: '<', value: 10 }, successorId: null }, + ], + successorId: 'evt-default-outcome', + }, + { + label: 'Walk away', + effectPolicy: 'skip', + }, +]); + +const { valid, issues } = validateStorylineOptions( + storyline.getStorylineOptions(eventCard, state), +); +``` + +Semantics: + +- **`cost`** — an affordability *gate* at draw time (an option the player + cannot afford is **omitted**, never disabled) and a *real payment* at + resolution. An unspecified component is unconstrained. Costs are paid in + addition to whatever `effectPolicy` does — `skip` still pays. +- **`extraEffect`** — a resource *gain* applied after the cost payment, + independently of `effectPolicy`. Negative extras are rejected by + `validateStorylineOptions`. +- **`conditionalSuccessors`** — ordered declarative rules. The first rule + whose `when` condition matches the **post-resolution** resources determines + the pushed card; `null` ends the chain. Rules are evaluated before any + random draw and consume no RNG. ### Compilation @@ -39,21 +99,38 @@ Precedence: **registered options > CSV `options` encoding > legacy options, `|` separates the label from candidate ids, `,` separates candidates). An empty candidate list is a **terminal** option. Every CSV-authored option carries `effectPolicy: 'apply'`. -- Registry/CSV options can carry a `condition(state)` callback, evaluated once - at draw time; options whose condition returns `false` are omitted from the - presented list. +- Draw-time filtering is **combined**: a registered option is omitted when its + `condition(state)` callback returns `false` **or** its declarative `cost` + exceeds the player's current coins/reputation. Options without a cost are + always included; a cost exactly equal to the balance is affordable. + Filtering happens once, when the option list is compiled at draw time, and + the result is frozen into `PendingStorylineChoice.options`, where consumers + (including AI scoring) read the stable presented list. ### Resolution -`resolveStorylineOption(state, card, option)` applies the option's -`effectPolicy` (`apply` runs the card effect, `skip` does not), then resolves -the successor with this precedence: - -1. `option.successorResolver(state)` — a callback; `null`/`undefined` ends the - chain. Consumes no RNG. -2. `option.successorIds` — exactly **one** seeded `rng()` draw selects a - candidate. An empty list is terminal and consumes no draw. -3. `option.successorId` — a legacy single id, pushed directly (no draw). +`resolveStorylineOption(state, card, option)` runs the full lifecycle for a +selected option: + +1. **`effectPolicy`** — `apply` runs the card effect, `skip` does not + (unchanged legacy step). +2. **Cost payment** — the option's `cost` is deducted through the engine + adapter's resource-mutation seam, in addition to the effect. +3. **Extra effect** — the option's `extraEffect` gains are applied, also + through the resource-mutation seam. +4. **Deltas** — `coinChange` / `repChange` account for all three steps; each + applied cost/extra effect appends an activity-log line (`Cost paid: -100 + coins.`, `Extra effect: +10 coins.`). +5. **Successor** — resolved with this precedence: + + 1. `option.successorResolver(state)` — a callback; `null`/`undefined` ends + the chain. Consumes no RNG. + 2. `option.conditionalSuccessors` — evaluated in declaration order against + the post-resolution resources; the first matching rule's `successorId` + is pushed (`null` ends the chain). A matching rule consumes no RNG. + 3. `option.successorIds` — exactly **one** seeded `rng()` draw selects a + candidate. An empty list is terminal and consumes no draw. + 4. `option.successorId` — a legacy single id, pushed directly (no draw). The pushed card uses deterministic serial naming: `${base}-${maxSerial+1}`, scanning every card the engine adapter returns from `getChainScanCards`. @@ -108,7 +185,8 @@ content validates cleanly. |---|---| | `rng(state)` | Returns the next seeded value in `[0, 1)`. | | `applyEffect(state, card)` | Applies the card's intrinsic effect. | -| `getResources(state)` | Current `{ coins, reputation }` for delta reporting. | +| `applyResourceDelta?(state, { coins?, reputation? })` | **Optional** resource-mutation seam core uses to deduct costs and apply extra effects. A legacy adapter that omits it compiles and runs unchanged; core then leaves resources untouched and appends a warning line instead. | +| `getResources(state)` | Current `{ coins, reputation }` for delta reporting and condition evaluation. | | `appendLog(state, text, kind?)` | Appends to the game activity log. | | `getActivityLog(state)` | The game's append-only log (`{ turn, text }`). | | `getTemplate(id)` | Template lookup for chain-card creation. | @@ -118,7 +196,9 @@ content validates cleanly. The adapter is the **only** path from core logic into game state, which keeps the module deterministic and replay-safe (one seeded draw per candidate -selection). +selection). All new declarative semantics — cost payments, extra effects and +condition evaluation — go through `getResources` + `applyResourceDelta`, so no +new coupling is introduced. ## UI adapter @@ -144,7 +224,9 @@ const ui = { ``` The Gym demo uses the default adapter, demonstrating that no game code is -required to present the mechanic. +required to present the mechanic. The demo's fixtures are unchanged by the +cost/extra-effect/conditional-successor extension: it exercises the legacy +experience, and no Gym fixture uses the new fields. ## Gym demo @@ -159,9 +241,12 @@ smoke test (`tests/gym/GymSceneSmoke.browser.test.ts`) and listed in the ## Testing - `tests/story-line/compile.test.ts` — legacy and CSV compilation, registry - precedence, draw-time condition filtering. -- `tests/story-line/resolve.test.ts` — apply/skip, successor precedence, the - one-draw rule, deterministic serial naming, pending-choice lifecycle. + precedence, draw-time condition **and cost** filtering, snapshot stability. +- `tests/story-line/resolve.test.ts` — apply/skip, cost payment, extra + effects, conditional successors, successor precedence, the one-draw rule, + deterministic serial naming, pending-choice lifecycle. +- `tests/story-line/validate.test.ts` — declarative metadata validation + (costs, extra effects, conditions, dangling successors). - `tests/story-line/journal.test.ts` — journal parsing, ordering, empty state. - `tests/story-line/ui-adapter.test.ts` — default adapter wording and the pluggable override seam. From 50b545e1c985a433ced75ffc80255966ce721e18 Mon Sep 17 00:00:00 2001 From: Sorra the Orc <250240+SorraTheOrc@users.noreply.github.com> Date: Fri, 23 Oct 2026 08:37:09 +0100 Subject: [PATCH 19/39] CG-0MVKNC9MY006ULES: extract Gym storyline demo pure logic and unit-test it --- docs/gym/GYM_INDEX.md | 9 +- example-games/gym/GymStorylineDemo.ts | 286 ++++++++++++++++++ example-games/gym/scenes/GymStorylineScene.ts | 191 ++++-------- tests/gym/GymStorylineDemo.test.ts | 228 ++++++++++++++ 4 files changed, 576 insertions(+), 138 deletions(-) create mode 100644 example-games/gym/GymStorylineDemo.ts create mode 100644 tests/gym/GymStorylineDemo.test.ts diff --git a/docs/gym/GYM_INDEX.md b/docs/gym/GYM_INDEX.md index 1838d1dd9..92dc2cde5 100644 --- a/docs/gym/GYM_INDEX.md +++ b/docs/gym/GYM_INDEX.md @@ -42,12 +42,17 @@ npx vitest run --project browser tests/gym/*.browser.test.ts | DPR Resolution Comparison | `GymDprResolutionScene` | `rasteriseSvgToTexture`, `makeTextureKey`, `MIN_QUALITY_SCALE`, `markSceneValid`, `markSceneInvalid`, SLL `anchorPoint` | [`scenes/GymDprResolutionScene.ts`](../../example-games/gym/scenes/GymDprResolutionScene.ts) | [`GymDprResolutionScene.browser.test.ts`](../../tests/gym/GymDprResolutionScene.browser.test.ts) | | Card Type Index & Synergy Groups | `GymCardIndexScene` | Main Street template getters (`getBusinessTemplates`, `getCommunitySpaceTemplates`, `getEventTemplates`, `getUpgradeTemplates`, `getStaffCardTemplates`), `buildCardTooltipInfo`, `TooltipManager`, `createOverlayDialog`, SLL `anchorPoint` | [`scenes/GymCardIndexScene.ts`](../../example-games/gym/scenes/GymCardIndexScene.ts), [`GymCardIndex.ts`](../../example-games/gym/GymCardIndex.ts) | [`GymCardIndex.test.ts`](../../tests/gym/GymCardIndex.test.ts), [`GymCardIndexScene.browser.test.ts`](../../tests/gym/GymCardIndexScene.browser.test.ts), [`GymRegistry.test.ts`](../../tests/gym/GymRegistry.test.ts), [`GymSceneSmoke.browser.test.ts`](../../tests/gym/GymSceneSmoke.browser.test.ts) | | In-game DLC Gate | `GymDlcUnlockScene` | `createDlcGate` (`DlcGate`, `DlcUnlockReader`), `contentUnlockClientFromWindow` (F5 total read client), SLL `anchorPoint` | [`scenes/GymDlcUnlockScene.ts`](../../example-games/gym/scenes/GymDlcUnlockScene.ts) | [`DlcGate.test.ts`](../../tests/core-engine/DlcGate.test.ts), [`GymDlcUnlock.browser.test.ts`](../../tests/gym/GymDlcUnlock.browser.test.ts), [`GymSceneSmoke.browser.test.ts`](../../tests/gym/GymSceneSmoke.browser.test.ts) | -| Storyline | `GymStorylineScene` | `createStorylineSystem`, `compileStorylineFromEvent`, `resolveStorylineOption`, `createPendingStorylineChoice`, `defaultStorylineUiAdapter` | [`scenes/GymStorylineScene.ts`](../../example-games/gym/scenes/GymStorylineScene.ts) | [`GymSceneSmoke.browser.test.ts`](../../tests/gym/GymSceneSmoke.browser.test.ts) | +| Storyline | `GymStorylineScene` | `createStorylineSystem`, `compileStorylineFromEvent`, `resolveStorylineOption`, `createPendingStorylineChoice`, `defaultStorylineUiAdapter`; demo fixture + transitions (`DEMO_CARDS`, `createDemoState`, `createDemoEngine`, `createDemoRuntime`, `startDemo`, `drawDemoCard`, `applyDemoChoice`) in [`GymStorylineDemo.ts`](../../example-games/gym/GymStorylineDemo.ts) | [`scenes/GymStorylineScene.ts`](../../example-games/gym/scenes/GymStorylineScene.ts), [`GymStorylineDemo.ts`](../../example-games/gym/GymStorylineDemo.ts) | [`GymStorylineDemo.test.ts`](../../tests/gym/GymStorylineDemo.test.ts), [`GymSceneSmoke.browser.test.ts`](../../tests/gym/GymSceneSmoke.browser.test.ts) | The Storyline demo's generic engine is documented in [`docs/core-engine/story-line.md`](../core-engine/story-line.md) (module API, -engine/UI adapters and this demo). +engine/UI adapters and this demo). The demo's own branching fixture and its +pure draw/choice transitions live in the Phaser-free +[`example-games/gym/GymStorylineDemo.ts`](../../example-games/gym/GymStorylineDemo.ts) +and are unit-tested by `tests/gym/GymStorylineDemo.test.ts` — the scene only +renders what those transitions return, so the fixture can be verified without +a browser. ## Scene Navigation diff --git a/example-games/gym/GymStorylineDemo.ts b/example-games/gym/GymStorylineDemo.ts new file mode 100644 index 000000000..5d58411b8 --- /dev/null +++ b/example-games/gym/GymStorylineDemo.ts @@ -0,0 +1,286 @@ +/** + * GymStorylineDemo — the Phaser-free pure logic behind `GymStorylineScene`. + * + * The scene is deliberately thin: everything that decides *what happens* when + * a card is drawn or an option is chosen lives here, so the demo's branching + * fixture can be unit-tested without booting Phaser or a browser: + * + * - {@link DEMO_CARDS} — the four-card branching fixture (two-way choice → + * successor choice → terminal, plus a terminal option that ends the chain + * immediately). + * - {@link createDemoState} — the initial demo state (coins, reputation, + * turn, seeded rng, activity log, chain deck). + * - {@link createDemoEngine} — the {@link StorylineEngineAdapter} mapping the + * generic story-line seams (rng draw, effect application, resource reads, + * activity log, template lookup, chain-card sink) onto that state. + * - {@link startDemo} / {@link applyDemoChoice} / {@link applyTerminalCard} — + * the three pure transitions the scene renders. Each returns plain data + * (compiled/available options, resource deltas, the pushed successor) and + * leaves all presentation to the caller. + * + * Nothing in this module imports `phaser` or any game beyond the generic + * `@story-line` contract, which is what makes it directly testable — see + * `core/tests/gym/GymStorylineDemo.test.ts`. + * + * @module example-games/gym/GymStorylineDemo + */ + +import { + createStorylineSystem, + type PendingStorylineChoice, + type StorylineCard, + type StorylineEngineAdapter, + type StorylineLogEntry, + type StorylineOption, + type StorylineSystem, +} from '@story-line'; + +/** The demo's minimal game state. */ +export interface DemoState { + coins: number; + reputation: number; + turn: number; + rngSeed: number; + activityLog: StorylineLogEntry[]; + deck: StorylineCard[]; +} + +/** Identifier of the demo's first (drawn) card. */ +export const DEMO_START_CARD_ID = 'gym-story-start'; + +/** Deterministic LCG seed used by {@link createDemoState}. */ +export const DEMO_RNG_SEED = 0x5eed; + +/** + * The demo's card catalogue (templates, no instance serials). + * + * Shape: `Town Meeting` (Invest / Ignore) → `Public Investment` (Celebrate / + * Double down) or `Neglected Roads` (Repair / Let it be) → terminal + * `New Beginning`. `Double down` and `Let it be` carry an empty successor, so + * they end the chain immediately. + */ +export const DEMO_CARDS: readonly StorylineCard[] = [ + { + id: 'gym-story-start', + name: 'Town Meeting', + storylineId: 'gym-story', + storylineTitle: 'Gym Storyline', + effect: 'Lose 50 coins', + coinDelta: -50, + options: 'Invest|gym-story-invest;Ignore|gym-story-ignore', + }, + { + id: 'gym-story-invest', + name: 'Public Investment', + storylineId: 'gym-story', + storylineTitle: 'Gym Storyline', + effect: 'Gain 120 coins', + coinDelta: 120, + options: 'Celebrate|gym-story-end;Double down|', + }, + { + id: 'gym-story-ignore', + name: 'Neglected Roads', + storylineId: 'gym-story', + storylineTitle: 'Gym Storyline', + effect: 'Lose 80 coins', + coinDelta: -80, + options: 'Repair|gym-story-end;Let it be|', + }, + { + id: 'gym-story-end', + name: 'New Beginning', + storylineId: 'gym-story', + storylineTitle: 'Gym Storyline', + effect: 'Gain 60 reputation', + reputationDelta: 60, + }, +]; + +const TEMPLATES = new Map( + DEMO_CARDS.map((card) => [card.id, card]), +); + +/** Looks up a demo card template by id (undefined when unknown). */ +export function getDemoTemplate(id: string): StorylineCard | undefined { + return TEMPLATES.get(id); +} + +/** + * Strips a chain instance serial (`gym-story-invest-3` → `gym-story-invest`), + * matching the adapter's own base-type rule. + */ +export function getBaseTypeId(id: string): string { + return id.replace(/-\d+$/, ''); +} + +/** Deterministic LCG so the demo is replay-safe (no candidate draws here). */ +function nextRandom(state: DemoState): number { + state.rngSeed = (state.rngSeed * 1664525 + 1013904223) >>> 0; + return state.rngSeed / 0x100000000; +} + +/** Fresh demo state, identical to what `[ Reset ]` installs. */ +export function createDemoState(): DemoState { + return { + coins: 1000, + reputation: 50, + turn: 1, + rngSeed: DEMO_RNG_SEED, + activityLog: [], + deck: [], + }; +} + +/** Engine adapter mapping the generic seams onto the demo state. */ +export function createDemoEngine(): StorylineEngineAdapter { + return { + rng: (state) => nextRandom(state), + applyEffect: (state, card) => { + state.coins += card.coinDelta ?? 0; + state.reputation += card.reputationDelta ?? 0; + }, + applyResourceDelta: (state, delta) => { + state.coins += delta.coins ?? 0; + state.reputation += delta.reputation ?? 0; + }, + getResources: (state) => ({ coins: state.coins, reputation: state.reputation }), + appendLog: (state, text) => { + state.activityLog.push({ turn: state.turn, text }); + }, + getActivityLog: (state) => state.activityLog, + getTemplate: (id) => TEMPLATES.get(id) ?? null, + getBaseTypeId: (id) => getBaseTypeId(id), + getChainScanCards: (state) => state.deck, + pushChainCard: (state, card) => { + state.deck.push(card); + }, + }; +} + +/** A wired demo system plus the state it drives. */ +export interface DemoRuntime { + system: StorylineSystem; + state: DemoState; +} + +/** Builds a fresh demo runtime (default UI adapter, deterministic seed). */ +export function createDemoRuntime(): DemoRuntime { + return { + system: createStorylineSystem({ engine: createDemoEngine() }), + state: createDemoState(), + }; +} + +/** The result of drawing a card. */ +export interface DemoDraw { + /** The drawn card. */ + card: StorylineCard; + /** Pending choice for a branching card; `null` for a terminal card. */ + pending: PendingStorylineChoice | null; + /** Options the player may pick (empty for a terminal card). */ + options: StorylineOption[]; + /** Resource deltas applied by a terminal card (zero for a choice card). */ + coinChange: number; + reputationChange: number; + /** True when the drawn card ended the chain instead of offering options. */ + finished: boolean; + /** Story-update line to journal for a terminal card (null when none). */ + storyUpdate: string | null; +} + +/** + * Draws a card: returns the compiled options for a branching card, or applies + * a terminal card's effect, journals it and reports the chain finished. + */ +export function drawDemoCard( + system: StorylineSystem, + state: DemoState, + card: StorylineCard, +): DemoDraw { + if (system.eventHasStoryline(card)) { + const pending = system.createPendingStorylineChoice(card, state); + if (pending) { + return { + card, + pending, + options: system.getPendingStorylineOptions(pending, state), + coinChange: 0, + reputationChange: 0, + finished: false, + storyUpdate: null, + }; + } + } + + const before = system.engine.getResources(state); + system.engine.applyEffect(state, card); + const after = system.engine.getResources(state); + const coinChange = after.coins - before.coins; + const reputationChange = after.reputation - before.reputation; + const storyUpdate = system.ui.storyUpdateLine( + card, + 'accept', + coinChange, + reputationChange, + ); + if (storyUpdate) system.engine.appendLog(state, storyUpdate); + + return { + card, + pending: null, + options: [], + coinChange, + reputationChange, + finished: true, + storyUpdate, + }; +} + +/** The result of resolving a chosen option. */ +export interface DemoChoice { + /** Resource deltas applied by the chosen option. */ + coinChange: number; + reputationChange: number; + /** Successor card pushed onto the chain, or `null` when the chain ended. */ + pushedCard: StorylineCard | null; + /** Story-update line to journal (null when the adapter produced none). */ + storyUpdate: string | null; +} + +/** + * Resolves the chosen option, journals the story update and returns the + * successor card (if any). The caller decides whether to draw it. + */ +export function applyDemoChoice( + system: StorylineSystem, + state: DemoState, + card: StorylineCard, + option: StorylineOption, + pending: PendingStorylineChoice | null = null, +): DemoChoice { + const result = system.resolveStorylineOption(state, card, option); + if (pending) system.markChoiceResolved(pending, option.label); + + const storyUpdate = system.ui.storyUpdateLine( + card, + option.label, + result.coinChange, + result.repChange, + ); + if (storyUpdate) system.engine.appendLog(state, storyUpdate); + + return { + coinChange: result.coinChange, + reputationChange: result.repChange, + pushedCard: result.pushedCard ?? null, + storyUpdate, + }; +} + +/** Draws the demo's first card onto a fresh runtime. */ +export function startDemo(runtime: DemoRuntime): DemoDraw & { card: StorylineCard } { + const card = TEMPLATES.get(DEMO_START_CARD_ID); + if (!card) throw new Error(`Demo start card ${DEMO_START_CARD_ID} is missing`); + return { ...drawDemoCard(runtime.system, runtime.state, card), card }; +} diff --git a/example-games/gym/scenes/GymStorylineScene.ts b/example-games/gym/scenes/GymStorylineScene.ts index d401f67f1..f98f68712 100644 --- a/example-games/gym/scenes/GymStorylineScene.ts +++ b/example-games/gym/scenes/GymStorylineScene.ts @@ -8,13 +8,17 @@ * story-line module and its **default UI adapter** (no Main Street code), so * it doubles as a worked example of the pluggable seams: * - * - `createStorylineSystem({ engine })` binds a tiny in-scene engine adapter - * (seeded rng, effect application, activity log, chain-card sink) to the - * default presentation adapter. + * - `createStorylineSystem({ engine })` binds a tiny engine adapter (seeded + * rng, effect application, activity log, chain-card sink) to the default + * presentation adapter. * - `compileStorylineFromEvent` / `createPendingStorylineChoice` drive the * choice dialog; `resolveStorylineOption` applies the effect and pushes the * successor card. * + * The demo's fixture and every decision it makes live in the Phaser-free + * `../GymStorylineDemo` module, which is unit-tested directly; this scene only + * renders what those pure transitions return. + * * @module example-games/gym/scenes/GymStorylineScene */ @@ -24,101 +28,22 @@ import { GYM_STORYLINE_KEY } from '../GymRegistry'; import { createHudText } from '../../../src/ui/Renderer'; import { GAME_W } from '../../../src/ui/constants'; import { - createStorylineSystem, - type PendingStorylineChoice, - type StorylineCard, - type StorylineEngineAdapter, - type StorylineLogEntry, - type StorylineOption, - type StorylineSystem, + applyDemoChoice, + createDemoRuntime, + drawDemoCard, + startDemo, + type DemoDraw, + type DemoRuntime, + type DemoState, +} from '../GymStorylineDemo'; +import type { + PendingStorylineChoice, + StorylineCard, + StorylineOption, + StorylineSystem, } from '@story-line'; import { SCENE_HEADER_Y } from './GymConstants'; -/** The demo's minimal game state. */ -interface DemoState { - coins: number; - reputation: number; - turn: number; - rngSeed: number; - activityLog: StorylineLogEntry[]; - deck: StorylineCard[]; -} - -/** The demo's card catalogue (templates, no instance serials). */ -const DEMO_CARDS: readonly StorylineCard[] = [ - { - id: 'gym-story-start', - name: 'Town Meeting', - storylineId: 'gym-story', - storylineTitle: 'Gym Storyline', - effect: 'Lose 50 coins', - coinDelta: -50, - options: 'Invest|gym-story-invest;Ignore|gym-story-ignore', - }, - { - id: 'gym-story-invest', - name: 'Public Investment', - storylineId: 'gym-story', - storylineTitle: 'Gym Storyline', - effect: 'Gain 120 coins', - coinDelta: 120, - options: 'Celebrate|gym-story-end;Double down|', - }, - { - id: 'gym-story-ignore', - name: 'Neglected Roads', - storylineId: 'gym-story', - storylineTitle: 'Gym Storyline', - effect: 'Lose 80 coins', - coinDelta: -80, - options: 'Repair|gym-story-end;Let it be|', - }, - { - id: 'gym-story-end', - name: 'New Beginning', - storylineId: 'gym-story', - storylineTitle: 'Gym Storyline', - effect: 'Gain 60 reputation', - reputationDelta: 60, - }, -]; - -const TEMPLATES = new Map( - DEMO_CARDS.map((card) => [card.id, card]), -); - -/** Deterministic LCG so the demo is replay-safe (no candidate draws here). */ -function nextRandom(state: DemoState): number { - state.rngSeed = (state.rngSeed * 1664525 + 1013904223) >>> 0; - return state.rngSeed / 0x100000000; -} - -/** Engine adapter mapping the generic seams onto the demo state. */ -function createDemoEngine(): StorylineEngineAdapter { - return { - rng: (state) => nextRandom(state), - applyEffect: (state, card) => { - state.coins += card.coinDelta ?? 0; - state.reputation += card.reputationDelta ?? 0; - }, - applyResourceDelta: (state, delta) => { - state.coins += delta.coins ?? 0; - state.reputation += delta.reputation ?? 0; - }, - getResources: (state) => ({ coins: state.coins, reputation: state.reputation }), - appendLog: (state, text) => { - state.activityLog.push({ turn: state.turn, text }); - }, - getActivityLog: (state) => state.activityLog, - getTemplate: (id) => TEMPLATES.get(id) ?? null, - getBaseTypeId: (id) => id.replace(/-\d+$/, ''), - getChainScanCards: (state) => state.deck, - pushChainCard: (state, card) => { - state.deck.push(card); - }, - }; -} - const LOG_MAX_LINES = 6; export class GymStorylineScene extends GymSceneBase { @@ -126,12 +51,21 @@ export class GymStorylineScene extends GymSceneBase { super({ key: GYM_STORYLINE_KEY }); } - private system!: StorylineSystem; - private state!: DemoState; + private demo!: DemoRuntime; private currentCard: StorylineCard | null = null; private pending: PendingStorylineChoice | null = null; private finished = false; + /** The demo's mutable state (owned by the pure demo runtime). */ + private get state(): DemoState { + return this.demo.state; + } + + /** The wired story-line system (generic seams + default UI adapter). */ + private get system(): StorylineSystem { + return this.demo.system; + } + private titleText!: Phaser.GameObjects.Text; private subtitleText!: Phaser.GameObjects.Text; private cardText!: Phaser.GameObjects.Text; @@ -158,11 +92,11 @@ export class GymStorylineScene extends GymSceneBase { }, { heading: 'Test plan', - body: '1. The Town Meeting card offers Invest / Ignore.\n2. Picking Invest gains 120 coins and deals Public Investment, which offers Celebrate / Double down.\n3. Celebrate deals the terminal New Beginning card, applies its reputation effect, and ends the chain.\n4. Double down (a terminal option) ends the chain immediately with no successor.\n5. Reset returns to the first card.', + body: '1. The Town Meeting card offers Invest / Ignore; either choice applies Town Meeting\'s effect (-50 coins) and deals the matching successor.\n2. Invest deals Public Investment, which offers Celebrate / Double down; Celebrate applies +120 coins and deals the terminal New Beginning card.\n3. New Beginning has no options: drawing it applies its +60 reputation effect and ends the chain.\n4. Double down (a terminal option) ends the chain immediately with no successor.\n5. Reset returns to the first card.', }, ]); - this.system = createStorylineSystem({ engine: createDemoEngine() }); + this.demo = createDemoRuntime(); const cx = GAME_W / 2; this.titleText = createHudText(this, cx, SCENE_HEADER_Y + 30, '', '#ffffff', { @@ -196,19 +130,12 @@ export class GymStorylineScene extends GymSceneBase { } private resetDemo(): void { - this.state = { - coins: 1000, - reputation: 50, - turn: 1, - rngSeed: 0x5eed, - activityLog: [], - deck: [], - }; + this.demo = createDemoRuntime(); this.finished = false; this.pending = null; this.currentCard = null; this.clearOptionButtons(); - this.showCard(TEMPLATES.get('gym-story-start')!); + this.renderDraw(startDemo(this.demo)); } private clearOptionButtons(): void { @@ -216,34 +143,29 @@ export class GymStorylineScene extends GymSceneBase { this.optionButtons = []; } - /** Draws a card: renders a choice, or applies a terminal card and ends. */ + /** + * Draws a card and renders the pure decision's outcome: options for a + * branching card, or the applied effect and a finished chain for a terminal + * card. + */ private showCard(card: StorylineCard): void { + this.renderDraw(drawDemoCard(this.system, this.state, card)); + } + + /** Renders the outcome of a pure draw (options, or a finished chain). */ + private renderDraw(draw: DemoDraw): void { + const card = draw.card; this.currentCard = card; - this.pending = null; + this.pending = draw.pending; this.clearOptionButtons(); this.titleText.setText(this.system.ui.choiceDialogTitle(card)); this.subtitleText.setText(this.system.ui.choiceDialogSubtitle(card)); - this.cardText.setText( - `${card.name} — ${card.effect ?? 'no effect'}`, - ); + this.cardText.setText(`${card.name} — ${card.effect ?? 'no effect'}`); - if (this.system.eventHasStoryline(card)) { - this.pending = this.system.createPendingStorylineChoice(card, this.state); - const options = this.system.getPendingStorylineOptions(this.pending!, this.state); - this.renderOptions(card, options); + if (!draw.finished) { + this.renderOptions(card, draw.options); } else { - // Terminal (non-choice) card: apply its effect and end the chain. - const before = this.system.engine.getResources(this.state); - this.system.engine.applyEffect(this.state, card); - const after = this.system.engine.getResources(this.state); - const line = this.system.ui.storyUpdateLine( - card, - 'accept', - after.coins - before.coins, - after.reputation - before.reputation, - ); - if (line) this.system.engine.appendLog(this.state, line); this.finished = true; this.statusText.setText('Chain complete — press Reset to play again.'); } @@ -276,16 +198,13 @@ export class GymStorylineScene extends GymSceneBase { private choose(option: StorylineOption): void { if (!this.pending || !this.currentCard || this.finished) return; const card = this.currentCard; - const result = this.system.resolveStorylineOption(this.state, card, option); - this.system.markChoiceResolved(this.pending, option.label); - - const line = this.system.ui.storyUpdateLine( + const result = applyDemoChoice( + this.system, + this.state, card, - option.label, - result.coinChange, - result.repChange, + option, + this.pending, ); - if (line) this.system.engine.appendLog(this.state, line); // Remove the spent option buttons before rendering the next card. this.clearOptionButtons(); diff --git a/tests/gym/GymStorylineDemo.test.ts b/tests/gym/GymStorylineDemo.test.ts new file mode 100644 index 000000000..a684f1985 --- /dev/null +++ b/tests/gym/GymStorylineDemo.test.ts @@ -0,0 +1,228 @@ +/** + * GymStorylineDemo — unit tests for the demo's own branching wiring. + * + * `GymStorylineScene` renders a fixture defined in the Phaser-free + * `GymStorylineDemo` module. The generic algorithms are covered by the core + * story-line parity suite (`core/tests/story-line/*`); what is NOT covered + * anywhere else — and what these tests pin — is the demo's *fixture and + * transitions*: which options the Town Meeting card offers, where each choice + * leads, which branch ends the chain immediately, and that the seeded replay is + * deterministic. + * + * Everything here goes through the public `@story-line` API via the exported + * runtime, so a change to the fixture (a renamed option, a re-pointed + * successor, a dropped effect) fails a test instead of silently changing what + * the demo teaches. + * + * No Phaser, no browser: the module under test has no rendering dependency. + * + * @module tests/gym/GymStorylineDemo + */ +import { describe, expect, it } from 'vitest'; +import { + DEMO_CARDS, + getBaseTypeId, + DEMO_RNG_SEED, + DEMO_START_CARD_ID, + applyDemoChoice, + createDemoRuntime, + createDemoState, + drawDemoCard, + getDemoTemplate, + startDemo, + type DemoState, + type DemoRuntime, +} from '../../example-games/gym/GymStorylineDemo'; +import type { StorylineCard, StorylineOption } from '@story-line'; + +/** Draws `card`, asserts it is a choice, and returns its option labels. */ +function optionLabels( + runtime: DemoRuntime, + card: StorylineCard, +): string[] { + const draw = drawDemoCard(runtime.system, runtime.state, card); + expect(draw.finished).toBe(false); + expect(draw.pending).not.toBeNull(); + return draw.options.map((o) => o.label); +} + +/** Picks the option with `label` from the card's compiled options. */ +function pick( + runtime: DemoRuntime, + card: StorylineCard, + label: string, +): { option: StorylineOption; result: ReturnType } { + const draw = drawDemoCard(runtime.system, runtime.state, card); + const option = draw.options.find((o) => o.label === label); + if (!option) { + throw new Error( + `Option "${label}" not offered by ${card.id}; got ${draw.options + .map((o) => o.label) + .join(', ')}`, + ); + } + return { + option, + result: applyDemoChoice(runtime.system, runtime.state, card, option, draw.pending), + }; +} + +describe('GymStorylineDemo — fixture', () => { + it('defines four cards forming a two-way branch into a terminal card', () => { + expect(DEMO_CARDS.map((c) => c.id)).toEqual([ + 'gym-story-start', + 'gym-story-invest', + 'gym-story-ignore', + 'gym-story-end', + ]); + expect(DEMO_START_CARD_ID).toBe('gym-story-start'); + // Every non-terminal successor named by the fixture actually exists. + for (const card of DEMO_CARDS) { + if (!card.options) continue; + for (const segment of card.options.split(';')) { + const successor = segment.split('|')[1]; + if (!successor) continue; + expect(getDemoTemplate(successor), `${card.id} → ${successor}`).toBeDefined(); + } + } + }); + + it('starts with a deterministic seed and a clean journal', () => { + const a = createDemoState(); + const b = createDemoState(); + expect(a).toEqual(b); + expect(a.rngSeed).toBe(DEMO_RNG_SEED); + expect(a.activityLog).toEqual([]); + expect(a.deck).toEqual([]); + expect(a.turn).toBe(1); + }); +}); + +describe('GymStorylineDemo — draw', () => { + it('the first card compiles to the ordered Invest / Ignore options', () => { + const runtime = createDemoRuntime(); + const draw = startDemo(runtime); + + expect(draw.card.id).toBe('gym-story-start'); + expect(draw.options.map((o) => o.label)).toEqual(['Invest', 'Ignore']); + expect(draw.finished).toBe(false); + // Drawing a branching card applies no effect. + expect(runtime.state.coins).toBe(1000); + }); + + it('a terminal card applies its effect, journals it and finishes the chain', () => { + const runtime = createDemoRuntime(); + const terminal = getDemoTemplate('gym-story-end')!; + + const draw = drawDemoCard(runtime.system, runtime.state, terminal); + + expect(draw.finished).toBe(true); + expect(draw.options).toEqual([]); + expect(draw.pending).toBeNull(); + expect(draw.reputationChange).toBe(60); + expect(runtime.state.reputation).toBe(110); + expect(runtime.state.activityLog).toHaveLength(1); + expect(draw.storyUpdate).toContain('Gym Storyline'); + }); +}); + +describe('GymStorylineDemo — choices determine the next card', () => { + it('Invest applies the choosing card\'s effect and deals Public Investment', () => { + const runtime = createDemoRuntime(); + const start = getDemoTemplate('gym-story-start')!; + + const { result } = pick(runtime, start, 'Invest'); + + // Resolution applies the *drawn* card's effect (Town Meeting: -50), not the + // successor's; the successor's own effect applies when it is resolved. + expect(result.coinChange).toBe(-50); + expect(runtime.state.coins).toBe(950); + expect(result.pushedCard?.name).toBe('Public Investment'); + expect(getBaseTypeId(result.pushedCard!.id)).toBe('gym-story-invest'); + // The successor lands on the chain deck with a deterministic instance id. + expect(runtime.state.deck).toHaveLength(1); + expect(runtime.state.deck[0].id).toMatch(/^gym-story-invest-\d+$/); + }); + + it('Ignore deals Neglected Roads on the other branch', () => { + const runtime = createDemoRuntime(); + const start = getDemoTemplate('gym-story-start')!; + + const { result } = pick(runtime, start, 'Ignore'); + + expect(result.coinChange).toBe(-50); + expect(runtime.state.coins).toBe(950); + expect(getBaseTypeId(result.pushedCard!.id)).toBe('gym-story-ignore'); + }); + + it('the successor\'s own effect applies when that card is resolved', () => { + const runtime = createDemoRuntime(); + const start = getDemoTemplate('gym-story-start')!; + + const invest = pick(runtime, start, 'Invest').result.pushedCard!; + const { result } = pick(runtime, invest, 'Celebrate'); + + // Public Investment: +120 coins, then the terminal card is pushed. + expect(result.coinChange).toBe(120); + expect(runtime.state.coins).toBe(1070); + expect(getBaseTypeId(result.pushedCard!.id)).toBe('gym-story-end'); + }); + + it('a terminal option (Double down) ends the chain with no successor', () => { + const runtime = createDemoRuntime(); + const invest = getDemoTemplate('gym-story-invest')!; + + const { result } = pick(runtime, invest, 'Double down'); + + expect(result.pushedCard).toBeNull(); + expect(runtime.state.deck).toHaveLength(0); + // Resolution still applies Public Investment's own effect (+120). + expect(result.coinChange).toBe(120); + expect(runtime.state.coins).toBe(1120); + }); + + it('the full happy path ends on the terminal New Beginning card', () => { + const runtime = createDemoRuntime(); + + const start = startDemo(runtime).card; + const invest = pick(runtime, start, 'Invest').result.pushedCard!; + const end = pick(runtime, invest, 'Celebrate').result.pushedCard!; + expect(getBaseTypeId(end.id)).toBe('gym-story-end'); + + const finalDraw = drawDemoCard(runtime.system, runtime.state, end); + expect(finalDraw.finished).toBe(true); + // 1000 - 50 (Town Meeting) + 120 (Public Investment), then +60 reputation. + expect(runtime.state.coins).toBe(1070); + expect(runtime.state.reputation).toBe(110); + + // The journal records the choice lines the scene renders. + expect(runtime.state.activityLog.length).toBeGreaterThanOrEqual(3); + }); +}); + +describe('GymStorylineDemo — determinism', () => { + it('replays identically from the same seed', () => { + const play = () => { + const runtime = createDemoRuntime(); + const start = startDemo(runtime).card; + const invest = pick(runtime, start, 'Invest').result.pushedCard!; + pick(runtime, invest, 'Celebrate'); + return { + coins: runtime.state.coins, + reputation: runtime.state.reputation, + deck: runtime.state.deck.map((c) => c.id), + rngSeed: runtime.state.rngSeed, + log: runtime.state.activityLog.map((e) => e.text), + }; + }; + + expect(play()).toEqual(play()); + }); + + it('offers the same option order on every draw of the same card', () => { + const first = optionLabels(createDemoRuntime(), getDemoTemplate('gym-story-start')!); + const second = optionLabels(createDemoRuntime(), getDemoTemplate('gym-story-start')!); + expect(first).toEqual(second); + expect(first).toEqual(['Invest', 'Ignore']); + }); +}); From c3315267b909bd365b3db5bfc48bc1d33a8e1829 Mon Sep 17 00:00:00 2001 From: Sorra the Orc <250240+SorraTheOrc@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:24:50 +0100 Subject: [PATCH 20/39] MS-0MVLINW3J009RWTO: add shared load-tolerant waitForCondition helper --- tests/helpers/waitForCondition.test.ts | 233 +++++++++++++++++++++++++ tests/helpers/waitForCondition.ts | 168 ++++++++++++++++++ 2 files changed, 401 insertions(+) create mode 100644 tests/helpers/waitForCondition.test.ts create mode 100644 tests/helpers/waitForCondition.ts diff --git a/tests/helpers/waitForCondition.test.ts b/tests/helpers/waitForCondition.test.ts new file mode 100644 index 000000000..c0b93a0ef --- /dev/null +++ b/tests/helpers/waitForCondition.test.ts @@ -0,0 +1,233 @@ +/** + * Unit tests for the shared load-tolerant `waitForCondition` helper. + * + * The helper must derive its timeout budget from the production cap it races + * (`budget = max(round(capMs * starvationFactor), capMs + floorMs)`) so a + * CPU-starved host cannot make a finished scene look like a hung test. These + * tests pin the derivation, the `budget > capMs` invariant, the polling + * behaviour and the timeout message shape. + * + * Fake timers keep every assertion deterministic (no wall-clock sleeps). + * + * @group unit + */ + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { resolveWaitBudget, waitForCondition } from './waitForCondition'; + +/** + * Await a promise expected to reject and return its error. + * + * Resolves to an explicit `Error` (never `void`), so callers can read + * `error.message` without a union narrowing, and fails the test loudly when + * the promise unexpectedly resolves. + */ +async function captureRejection(settled: Promise): Promise { + try { + await settled; + } catch (error) { + return error as Error; + } + throw new Error('Expected the wait to reject, but it resolved'); +} + +describe('resolveWaitBudget', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('derives the proportional budget for the 16 000 ms income-collection cap', () => { + expect(resolveWaitBudget({ capMs: 16_000 })).toBe(24_000); + }); + + it('applies the default floor margin to a small cap (1 500 ms → 6 500 ms)', () => { + // max(round(1500 * 1.5) = 2250, 1500 + 5000 = 6500) — the floor dominates. + expect(resolveWaitBudget({ capMs: 1_500 })).toBe(6_500); + }); + + it('honours a custom starvationFactor', () => { + expect(resolveWaitBudget({ capMs: 10_000, starvationFactor: 2 })).toBe( + 20_000, + ); + }); + + it('honours a custom floorMs', () => { + expect( + resolveWaitBudget({ capMs: 1_000, starvationFactor: 1.2, floorMs: 4_000 }), + ).toBe(5_000); + }); + + it('rounds the proportional term rather than truncating it', () => { + // round(333 * 1.5) = round(499.5) = 500; the floor is disabled so the + // proportional term is the deciding one. + expect( + resolveWaitBudget({ capMs: 333, starvationFactor: 1.5, floorMs: 0 }), + ).toBe(500); + }); + + it('always returns a budget strictly greater than the cap it races', () => { + for (const capMs of [1, 250, 1_500, 16_000]) { + expect(resolveWaitBudget({ capMs })).toBeGreaterThan(capMs); + } + }); + + it('throws when the derived budget is not strictly greater than capMs', () => { + expect(() => + resolveWaitBudget({ capMs: 1_000, starvationFactor: 1, floorMs: 0 }), + ).toThrow(/strictly greater/); + }); + + it('names both the derived budget and the cap in the invariant error', () => { + expect(() => + resolveWaitBudget({ capMs: 1_000, starvationFactor: 1, floorMs: 0 }), + ).toThrow(/1000ms/); + }); +}); + +describe('waitForCondition', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('resolves immediately when the predicate is already true', async () => { + vi.useFakeTimers(); + + await expect( + waitForCondition(() => true, { capMs: 1_000 }), + ).resolves.toBeUndefined(); + }); + + it('polls at intervalMs and resolves on the first truthy predicate', async () => { + vi.useFakeTimers(); + const predicate = vi + .fn<() => boolean>() + .mockReturnValueOnce(false) + .mockReturnValueOnce(false) + .mockReturnValue(true); + + const settled = waitForCondition(predicate, { + capMs: 1_000, + intervalMs: 20, + label: 'flip', + }); + + // The first poll is immediate; the next ones happen one interval apart. + expect(predicate).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(19); + expect(predicate).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(1); + expect(predicate).toHaveBeenCalledTimes(2); + await vi.advanceTimersByTimeAsync(20); + + await expect(settled).resolves.toBeUndefined(); + expect(predicate).toHaveBeenCalledTimes(3); + }); + + it('defaults to a 25 ms poll interval', async () => { + vi.useFakeTimers(); + let polls = 0; + + const settled = waitForCondition( + () => { + polls += 1; + return polls > 2; + }, + { capMs: 1_000 }, + ); + + expect(polls).toBe(1); + await vi.advanceTimersByTimeAsync(49); + expect(polls).toBe(2); + await vi.advanceTimersByTimeAsync(1); + + await expect(settled).resolves.toBeUndefined(); + expect(polls).toBe(3); + }); + + it('rejects with the label, the derived budget and the raced cap on timeout', async () => { + vi.useFakeTimers(); + const settled = captureRejection( + waitForCondition(() => false, { + capMs: 1_000, + starvationFactor: 2, + floorMs: 500, + intervalMs: 50, + label: 'income collection deferral', + }), + ); + + await vi.advanceTimersByTimeAsync(2_100); + const error = await settled; + + expect(error).toBeInstanceOf(Error); + expect(error.message).toBe( + 'Timed out waiting for income collection deferral after 2000ms (cap 1000ms)', + ); + }); + + it('uses the default "condition" label in the timeout message', async () => { + vi.useFakeTimers(); + const settled = captureRejection( + waitForCondition(() => false, { capMs: 1_000, intervalMs: 1_000 }), + ); + + await vi.advanceTimersByTimeAsync(6_100); + const error = await settled; + + expect(error.message).toBe( + 'Timed out waiting for condition after 6000ms (cap 1000ms)', + ); + }); + + it('resolves when the predicate turns true exactly at the budget boundary', async () => { + vi.useFakeTimers(); + const start = Date.now(); + const settled = waitForCondition(() => Date.now() - start >= 6_000, { + capMs: 1_000, + intervalMs: 1_000, + }); + + await vi.advanceTimersByTimeAsync(6_000); + + await expect(settled).resolves.toBeUndefined(); + }); + + it('accepts a raw timeoutMs for callers that race no production cap', async () => { + vi.useFakeTimers(); + const settled = captureRejection( + waitForCondition(() => false, { + timeoutMs: 300, + intervalMs: 100, + label: 'legacy condition', + }), + ); + + await vi.advanceTimersByTimeAsync(400); + const error = await settled; + + expect(error).toBeInstanceOf(Error); + // Legacy message shape (no cap suffix) so existing triage greps keep matching. + expect(error.message).toBe( + 'Timed out waiting for legacy condition after 300ms', + ); + }); + + it('resolves within a raw timeoutMs budget when the predicate becomes true', async () => { + vi.useFakeTimers(); + let polls = 0; + + const settled = waitForCondition( + () => { + polls += 1; + return polls === 2; + }, + { timeoutMs: 300, intervalMs: 100 }, + ); + + await vi.advanceTimersByTimeAsync(100); + + await expect(settled).resolves.toBeUndefined(); + expect(polls).toBe(2); + }); +}); diff --git a/tests/helpers/waitForCondition.ts b/tests/helpers/waitForCondition.ts new file mode 100644 index 000000000..39da9c752 --- /dev/null +++ b/tests/helpers/waitForCondition.ts @@ -0,0 +1,168 @@ +/** + * Shared, load-tolerant poll helper for browser tests (and node unit tests). + * + * ## Budget model: nominal production cap + starvation margin + * + * A test that waits on work paced by a *production* timing cap (the 16 000 ms + * income-collection deferral, `DRAG_TRANSFER_DURATION_MAX_MS`, …) must not use + * a hand-guessed poll timeout. On a CPU-starved host the production work + * legitimately finishes later than its nominal cap, and a hand-picked budget + * equal to (or barely above) that cap turns a green scene into a red test. + * + * The budget is therefore *derived from the cap it races*: + * + * ``` + * budget = max(round(capMs * starvationFactor), capMs + floorMs) + * ``` + * + * - `capMs` — the **nominal production cap** the wait races. Pass the real + * production constant, not a guess: the budget then tracks the production + * pacing change automatically. + * - `starvationFactor` (default `1.5`) — widens the cap proportionally, so the + * wait survives rAF starvation when the host is loaded. + * - `floorMs` (default `5_000`) — guarantees a fixed scheduling margin even + * for very small caps (where the proportional widening alone is negligible). + * + * The invariant **`budget > capMs`** is enforced in code (`resolveWaitBudget` + * throws otherwise), so a caller can never configure a poll shorter than the + * cap it documents as racing. With the defaults a 16 000 ms cap derives a + * 24 000 ms budget and a 1 500 ms cap derives 6 500 ms — both comfortably + * inside the browser project's 60 s `testTimeout`. + * + * Raw `timeoutMs` remains accepted for callers that wait on something that is + * **not** paced by a production cap; passing `capMs` takes precedence. + * + * The helper is pure `Date.now()` + `setTimeout` polling (no Phaser import), + * so it runs unchanged in both the browser and the node `unit` projects. + * + * The module is a sibling of `waitForScene.ts`; existing per-file + * `waitForCondition` copies can migrate onto it without changing their + * timeout-message shape (`Timed out waiting for