diff --git a/.pi/skills_extensions/test/SKILL_PREFIX.md b/.pi/skills_extensions/test/SKILL_PREFIX.md index 79846f894..833c11045 100644 --- a/.pi/skills_extensions/test/SKILL_PREFIX.md +++ b/.pi/skills_extensions/test/SKILL_PREFIX.md @@ -57,9 +57,13 @@ Every typed Vitest command runs through `scripts/vitest-run-with-retry.ts`, which: - retries once on Vitest's transient contention-induced failures (worker RPC - timeout / browser WebSocket drop) when either every test file actually + timeout, browser WebSocket drop, or a Vite dev-server module fetch failure) + when either every test file actually passed **or** the reported failures are attributable solely to the transient - signature (the failed-file variant; CG-0MUIMM28K001W88F); + signature (the failed-file variant; CG-0MUIMM28K001W88F). Acceptance after + both attempts is restricted to the two original signatures: module fetch is + retry-only, so a deterministic module-resolution regression still surfaces + (CG-0MVNDK56G007C1U7); - bounds each attempt with a wall-clock timeout and exits `124` with a `[hang-timeout]` diagnostic on a true hang (hangs are never retried); and - emits a final `[vitest-runner] attempts=N status=S outcome=…` line as the diff --git a/AGENTS.md b/AGENTS.md index c6330e08c..b209abce3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -63,6 +63,10 @@ npm run preview # Serve production build locally (binds to all interfaces, npm run setup:distribution -- --dir .. # Clone core + sibling game repos (full distribution) GAMES_CONFIG=full npm run dev # Run all games + Gym (after setup:distribution) npm run monte-carlo # Run Main Street Monte Carlo harness via vite-node (JSON + CSV; needs the tce-main-street sibling) +npm run storylines:validate # Validate story-line content through the content adapter (default: Gym) +npm run storylines:graph # Regenerate the committed manifest/Mermaid artefacts (-- --check to guard drift) +npm run storylines:author # Transactional content edits (add-card / update-card; --dry-run supported) +npm run storylines:editor # Story-line editor: viewer + guided editing (local dev tool; -- --content/--adapter) npm run tf:generate # Generate ToneForge audio artifacts into build/tf-synths/ npm run build:electron # Electron-mode Vite build (relative base, file://-safe) for the desktop launcher npm run start:electron # Build + launch the Electron desktop app locally @@ -111,7 +115,7 @@ Rules of thumb: 2. **Non-tutorial browser tests** (`--project browser`, headless Chromium) — Phaser UI/rendering tests in `tests/**/*.browser.test.ts`. Phaser needs a real browser (WebGL/Canvas) and cannot run under Node/JSDOM. 3. **Tutorial E2E tests** (`scripts/run-tutorial-tests.sh`) — Main Street tutorial flows in `tests/e2e/main-street-tutorial-e2e-part{1-6}.browser.test.ts`, each part in its own browser instance to avoid Phaser 4 canvas/GPU context exhaustion. These are Main Street-owned: in a game-free core checkout (no `tce-main-street` sibling) the runner skips the absent parts and the projects set `passWithNoTests`, so the core suite stays green. -The unit and browser stages run through `scripts/vitest-run-with-retry.ts`, which retries once on Vitest's transient contention-induced failures (worker RPC timeout, browser WebSocket drop) when either every file actually passed **or** the reported failures are attributable solely to the transient signature (the failed-file variant; CG-0MUIMM28K001W88F). It accepts the run as green if the retry is also a pure transient failure (no genuine failure to mask; CG-0MUF0LU4X006IXXU), and bounds every attempt with a wall-clock timeout (exit 124 `[hang-timeout]` diagnostic on a genuine hang — never retried; see CG-0MT08R2QR0070F3N). A `replay-e2e` project (`tests/e2e/replay-*.test.ts`) is also defined for Playwright-driven replay checks. See `docs/DEVELOPER.md#testing` for the full project table, CPU-contention mitigations, and guidance on writing unit and browser tests. +The unit and browser stages run through `scripts/vitest-run-with-retry.ts`, which retries once on Vitest's transient contention-induced failures (worker RPC timeout, browser WebSocket drop, or a Vite dev-server module fetch failure — `Failed to fetch dynamically imported module`) when either every file actually passed **or** the reported failures are attributable solely to the transient signature (the failed-file variant; CG-0MUIMM28K001W88F). It accepts the run as green if the retry is also a pure transient failure of one of the two original signatures (no genuine failure to mask; CG-0MUF0LU4X006IXXU) — the module-fetch signature is deliberately **retry-only**, because a genuine module-resolution regression is deterministic and must surface (CG-0MVNDK56G007C1U7) — and bounds every attempt with a wall-clock timeout (exit 124 `[hang-timeout]` diagnostic on a genuine hang — never retried; see CG-0MT08R2QR0070F3N). Those two stages also opt into the runner's **project-coverage gate** (`--expect-project-coverage`, CG-0MVL21CVM003VK5H): before the run the runner derives the expected test-file set from a collection-only `vitest list --filesOnly --json` over the same project, captures the files each attempt actually executed, and fails the stage if the sets differ — so a run that silently dropped part of the suite can never be accepted as green, and a pre-flight collection failure fails closed (exit 2). `--expected-file ` (repeatable) pins an explicit expected set. Smoke/dev/tutorial stages and every `/skill:test --type` command keep the gate **off**. A `replay-e2e` project (`tests/e2e/replay-*.test.ts`) is also defined for Playwright-driven replay checks. See `docs/DEVELOPER.md#testing` for the full project table, CPU-contention mitigations, and guidance on writing unit and browser tests. - **During implementation** — prefer targeted runs for fast feedback (seconds, not minutes): - Unit: `npx vitest run --project unit tests//` or `npx vitest run --project unit tests//.test.ts` @@ -341,7 +345,7 @@ Use `SaveLoadStore`, `serializeWithVersion()`, and `deserializeWithVersion()` fr ### 8. Event-Driven Audio and Visual Feedback -**Requirement:** Every player **and** AI action that uses a core engine animation/feedback helper — `dealCard`, `discardCard`, `flipCard`, `placeCard`, `moveGameObject`, `shakeIllegalMove`, `popTextOrIcon`, and any future helpers — **must** be rendered with the corresponding animation and wired with a sound effect (SFX), so the action is both animated and audible. Satisfy both via each helper's `soundManager` + `sfx` (`start`/`move`/`end`) parameters, or an equivalent event-driven `GameEventEmitter`/`SoundManager` mapping. SFX keys must follow the shared convention — `COMMON_SFX_KEYS` from `src/core-engine/SoundManager.ts` with the `sfx-` prefix per `docs/SFX_CONVENTION.md`; no game-scoped string literals. (`shakeIllegalMove` plays `COMMON_SFX_KEYS.ILLEGAL_MOVE` automatically; `popTextOrIcon()` is the lightweight score/notification popup.) +**Requirement:** Every player **and** AI action that uses a core engine animation/feedback helper — `dealCard`, `discardCard`, `flipCard`, `placeCard`, `moveGameObject`, `shakeIllegalMove`, `popTextOrIcon`, and any future helpers — **must** be rendered with the corresponding animation and wired with a sound effect (SFX), so the action is both animated and audible. Satisfy both via each helper's `soundManager` + `sfx` (`start`/`move`/`end`) parameters, or an equivalent event-driven `GameEventEmitter`/`SoundManager` mapping. SFX keys must follow the shared convention — `COMMON_SFX_KEYS` from `src/core-engine/SoundManager.ts` with the `sfx-` prefix per `docs/SFX_CONVENTION.md`; no game-scoped string literals. (`shakeIllegalMove` plays `COMMON_SFX_KEYS.ILLEGAL_MOVE` automatically and, when passed a `reason` — normally the rules layer's `LegalityResult.reason` — also renders the shared transient reason toast, so a refused move is explained as well as animated (`docs/ui-animations.md` → shakeIllegalMove); `popTextOrIcon()` is the lightweight score/notification popup.) **AI actions:** AI turns must be animated with a brief delay so the player can see and hear what the AI did (e.g. card placement / row take). Coloretto is the in-repo precedent — `../tce-coloretto/src/scenes/ColorettoScene.ts` runs AI turns via `time.delayedCall` (750ms, 150ms under reduced motion) then executes the AI's action through the same animated/sounded path as a human turn. diff --git a/CHANGELOG.md b/CHANGELOG.md index 6163000f5..c888719b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,28 @@ # Changelog +## v0.1.22 (2026-10-29) +> **Release focus:** This release focuses on strengthening the shared engine and tooling behind the games, with more reliable CI testing, automatic engine syncing, consistent card actions, and custom event logging. It also expands branching story choices with coin and reputation requirements, adds on-screen reasons for rejected moves, and gives each game its own version number. +### Features +- CI test runs now fail loudly if any test files are silently skipped, so broken code can't slip through as a false pass. (CG-0MVL21CVM003VK5H) +- Branching story choices are now powered by a shared system, paving the way for richer narratives. (CG-0MVH2H67H002M27B) +- Engine updates now stay in sync automatically, so releases no longer fail or need manual fixes. (CG-0MVO268500071BPI) +- Card actions now behave more consistently across all games, reducing bugs and glitches. (CG-0MTXDWHDT009Q2IU) +- Games can now add their own custom events, and every event is automatically logged for debugging. (CG-0MVRAT58G002RYRL) +- Rejected moves now show a brief on-screen reason so you know why a move was refused. (CG-0MVPXFHBZ004LGEB) +- Game updates now stay in sync with engine improvements for a smoother experience. (CG-0MVD0IZDR008921Q) +- The Rising's face-off sounds are now documented so future games can reuse them consistently. (CG-0MVFQ6QOZ003FVO4) +- Browser tests now retry once when a loaded machine drops a module fetch, so test gates reflect your code, not CPU load. (CG-0MVNDK56G007C1U7) +- New game repos now keep a single copy of Phaser and Tone, preventing mysterious startup crashes. (CG-0MVPWPWLA008FTDJ) +- Story choices can now require coins or reputation, show extra effects, and branch based on your status. (CG-0MVI013DU000KEAB) +### Bug Fixes +- New game projects now save transcripts correctly, and dev consoles no longer spam warnings. (CG-0MVSIB5LH0069LB3) +- Illegal-move toasts no longer break game tests, so your suite runs clean again. (CG-0MVQQ2M8E003FQMZ) +- Fixed internal build and test issues so future game updates can ship smoothly. (CG-0MVFU3JTN00841OR) +- Each game now shows its own version number, separate from the engine's. (CG-0MVGF2QE9008NA5R) +### Other +- Storylines work exactly as before, with the Gym demo and all Main Street arcs verified. (CG-0MVHY82PQ004Q3B9) +> [Play the alpha release](https://thewizardscode.github.io/Tableau-Card-Engine/). Provide feedback in [Discord](https://discord.gg/gUKQTFkzQ4) + ## v0.1.21 (2026-10-15) > **Release focus:** This release introduces a new Irish history card game while overhauling how card packs work, letting third-party packs carry their own artwork and be added as unlockable DLC. It also improves audio defaults, publishing automation, and developer tooling, including a more reliable dev server and a single up-to-date guide for creating new games. ### Features diff --git a/README.md b/README.md index 80b42f936..63cdacf0b 100644 --- a/README.md +++ b/README.md @@ -218,6 +218,18 @@ into the game's `./core` submodule via `CORE_ROOT`. There is no `src` symlink: the aliases replace the F4 compatibility symlinks. See the [layout decision record](docs/dev/per-game-src-layout-decision.md). +#### Per-game version policy + +A game repo owns its own version — it is never the engine's version. The +scaffold seeds a brand-new game repo at `0.1.0`, **preserves** the `version` +already in an existing repo's `package.json` when re-scaffolding (so an existing +game is never renumbered), accepts an explicit `--version ` override, +and never copies the core's `version`. A release cut inside `tce-` bumps +only that game's `package.json`, so game and engine releases proceed at +independent cadences and the version label on game screens follows the game +build. See +[Per-game version policy](docs/dev/creating-a-new-game.md#per-game-version-policy). + ### Repository layout (this checkout) ``` diff --git a/docs/DEVELOPER.md b/docs/DEVELOPER.md index 95c123c52..05ff211ff 100644 --- a/docs/DEVELOPER.md +++ b/docs/DEVELOPER.md @@ -631,7 +631,7 @@ repositories by design: - **Core repo owns the mechanism** — the unified `UnlockRule`/`UnlockTarget` model, the `ContentUnlockStore`, the read API (`contentUnlockClientFromWindow()`), and the pure, framework-free - [`createDlcGate`](../../src/core-engine/DlcGate.ts). + [`createDlcGate`](../src/core-engine/DlcGate.ts). - **Each game repo owns its DLC content and the gating call site.** The DLC is identified by `{ kind: 'dlc', gameId, dlcId }`, where `dlcId` is owned by that game's content definition — there is no central DLC registry. The gate is a @@ -923,7 +923,13 @@ run. Browser-mode runs have a sibling failure mode (see CG-0MSCI73RH004VPCE): wh the browser RPC WebSocket is dropped under load, vitest browser mode closes the connection and exits non-zero with `[vitest] Browser connection was closed while running tests.` even though every -file completed. Because `scripts/run-ci-tests.sh` runs with `set -euo pipefail`, +file completed. A third sibling failure mode (see CG-0MVNDK56G007C1U7) is the +Vite dev server failing to serve a test module under load, so the browser +cannot dynamically import it — Vitest reports the file as a failed suite with +`Error: Failed to import test file ` and +`Caused by: TypeError: Failed to fetch dynamically imported module: …` (the +Chromium text; the browser project only runs headless Chromium). +Because `scripts/run-ci-tests.sh` runs with `set -euo pipefail`, those non-zero exits previously aborted the CI gate after the unit/browser step. Two mitigations are in place in this repository: @@ -933,12 +939,15 @@ Two mitigations are in place in this repository: 2. **Retry-once on the transient signatures (attribution-aware)** — the unit **and** browser steps in `scripts/run-ci-tests.sh` run through `scripts/vitest-run-with-retry.ts`, whose `shouldRetryOnce` guard recovers - two shapes of the same contention transient: + two shapes of the same contention transient (`TRANSIENT_SIGNATURES`: + `WORKER_TIMEOUT_SIGNATURE`, `BROWSER_DROP_SIGNATURE` and + `MODULE_FETCH_SIGNATURE`): - **All-passed transient** — the reporter summary shows every file passed and the sole error is a transient signature - (`[vitest-worker]: Timeout calling "onTaskUpdate"` or - `[vitest] Browser connection was closed while running tests`). + (`[vitest-worker]: Timeout calling "onTaskUpdate"`, + `[vitest] Browser connection was closed while running tests`, or + `Failed to fetch dynamically imported module`). - **Attributable failed-file transient** (CG-0MUIMM28K001W88F) — the summary reports failed files/tests, but every error block in the run output is the transient signature itself (`failuresAreTransientOnly`). @@ -948,14 +957,22 @@ Two mitigations are in place in this repository: a re-run at the same commit was green. The guard is unit-tested in `tests/scripts/vitest-run-with-retry.test.ts`, - including a **regression fixture** of the recorded - CG-0MUIMM28K001W88F failing run and its genuine-assertion counter-case. A - genuine assertion failure (a `Failed Tests` block carrying an - `AssertionError`, not the timeout) can never be retried or hidden. If the - retry is **also** a pure transient failure (both attempts poisoned by - sustained contention), the run is accepted as green (exit 0): the guard - attributed every reported failure to the transient signature, so there is - no genuine failure to mask (CG-0MUF0LU4X006IXXU). + including **regression fixtures** of the recorded + CG-0MUIMM28K001W88F failing run and the recorded + CG-0MVNDK56G007C1U7 module-fetch incident, plus their genuine-assertion + counter-cases. A genuine assertion failure (a `Failed Tests` block carrying + an `AssertionError`, not the timeout) can never be retried or hidden. If the + retry is **also** a pure transient failure of one of the two original + signatures (both attempts poisoned by sustained contention), the run is + accepted as green (exit 0): the guard attributed every reported failure to + the transient signature, so there is no genuine failure to mask + (CG-0MUF0LU4X006IXXU). That both-attempts acceptance is deliberately **not** + extended to `MODULE_FETCH_SIGNATURE` (`shouldAcceptBothAttempts`): a genuine + module-resolution regression (mistyped import path, deleted file, 404 on an + asset module) emits the identical text deterministically, so a both-attempts + module-fetch run keeps the retry's own failing status and reports + `outcome=retry-failed` — module fetch is retry-only recovery + (CG-0MVNDK56G007C1U7). The runner emits a final `[vitest-runner] attempts=N status=S outcome=... args="..."` line after every @@ -966,6 +983,55 @@ Two mitigations are in place in this repository: failed test/file and shows any distinct assertion error alongside the transient signature. + **Project-coverage gate (opt-in, CG-0MVL21CVM003VK5H).** The retry/acceptance + logic above reasons purely about the run's *output text*, so a run that + silently dropped part of the suite could still be accepted — Vitest's + `executeTests` group loop can abort after an earlier pool run rejects and + summarise only the executed files as green (`Test Files N passed (N)`). The + unit and browser stages in `scripts/run-ci-tests.sh` therefore pass + `--expect-project-coverage`, which makes a file-set mismatch a genuine + failure: + + - **Expected set** — derived before the run from a collection-only + `vitest list --filesOnly --json --staticParse` over the same forwarded + project/args (`--staticParse` avoids a second Chromium start for the + browser stage; a consumer whose Vitest version rejects the flag is + retried once without it). `--expected-file ` (repeatable) pins an + explicit expected set for tests/non-standard layouts instead. + - **Executed set** — each attempt gets an injected + `--reporter=default --reporter=json --outputFile.json=` so the set + is exact and independent of the caller's reporters; the artifact is + parsed and removed after the attempt, with a fallback to default-reporter + per-file lines if the artifact is missing. The JSON artifact is what keeps + the gate honest for a file that failed to dynamically import: Vitest + emits a `testResults` entry for every collected suite, including a failed + import (the `FAIL … [ file ]` header the fallback cannot match), so a + module-fetch retry is judged against the full expected set + (CG-0MVNDK56G007C1U7). + - **Mismatch is a genuine failure** — any missing OR extra file fails the + stage (exit 1, `outcome=coverage-failed`) with a stderr diagnostic naming + every missing/extra file and stating `expected=N ran=M`. It is evaluated + on the first attempt before the retry decision and again on the second + before any acceptance, so neither `accepted-transient` nor a clean retry + can mask a dropped group — regardless of the run's own exit code (a + dropped group can exit 0 with an all-passed summary). + - **Fail closed** — a pre-flight collection that fails, times out (exit + 124), or returns an empty set aborts with exit 2 + (`[preflight-failed]`); an expectation supplied but an executed set that + cannot be determined fails identically. Hang semantics are unchanged: a + wall-clock overrun still exits 124, is never retried, and is reported + before any coverage check. + - **Auditable summary** — the `[vitest-runner]` line appends + `expected=N ran=M` while the gate is active (`expected=unset` without + it), so a downstream consumer can tell a genuine full-suite green from + a recovered transient. Outcome strings for expectation-free runs are + unchanged. + - **Off by default** — smoke/dev/tutorial stages and every `/skill:test + --type` command do not pass the flag; a consumer repo syncing this runner + sees byte-identical decisions until it opts in. Tested in + `tests/scripts/vitest-run-with-retry.test.ts` (parity fixtures + regression + for the incident shape). + 3. **Test-side hardening (browser tests)** — beyond the runner-level mitigations above, browser tests that drive the real Phaser pointer pipeline (dispatched DOM events at layout-derived canvas coordinates, e.g. @@ -1347,6 +1413,160 @@ This lists the installed browsers and their expected locations (e.g. `chromium-1 **Fast-fail pre-check:** `npm test` (`scripts/run-ci-tests.sh`) and a direct `bash scripts/run-tutorial-tests.sh` run `scripts/check-browser-test-env.ts` first. The pre-check detects a missing Chromium binary launch-free (via `chromium.executablePath()` + `fs.existsSync()`, under 2 seconds) and aborts with the exact remediation command above — instead of failing minutes later with an opaque Vitest browser error. PR CI is build-only (CG-0MT022826006EM0D) and no longer runs browser tests; local devs run `npx playwright install chromium` once (see [Browser test setup](#browser-test-setup)). +## Story-line Authoring Tooling + +The core engine ships a game-agnostic story-line **content** toolkit +(`src/story-line/content/`): CSV parse/serialise + checksum, a deterministic +manifest/Mermaid generator, a static validator and one transactional author +API. A game plugs in only data — a content adapter mapping its CSV columns onto +the generic story-line fields (`id`, `name`, `effect`, choice flag, +accept/reject or `options`, storyline id/title); every other column is carried +as a game column. Core ships the Gym adapter as the default. + +Three `tsx` CLIs expose the seams (no Vite loader, no game import): + +```bash +npm run storylines:validate # validate the Gym content +npm run storylines:validate -- --csv path.csv --json --fail-on-cycle +npm run storylines:graph # rewrite the Gym artefacts +npm run storylines:graph -- --format json --check # drift guard +npm run storylines:author -- add-card --id evt-x --name "X" --dry-run +npm run storylines:author -- update-card --id evt-x --storyline-id storyline-x +npm run storylines:editor # open the local graph editor +npm run storylines:editor -- --help # editor flags +``` + +Every CLI supports `--help` and `--game ` (default `gym`), and exits +`0` on success/valid content and `1` on invalid content, unreadable input or +artefact drift. The author API validates before writing and writes atomically; +`--dry-run` prints the candidate and writes nothing. The committed Gym +artefacts (`example-games/gym/content/storyline-manifest.json` and +`storyline-graph.mmd`) are validated against +`schemas/story-line-manifest.schema.json` and kept in sync by `--check`. + +See [Core: Generic Story-Line System](core-engine/story-line.md#authoring-seams-content-manifest-validator-author) +for the adapter contract, validator codes, manifest shape and API examples. + +### Story-line Editor (viewer and guided editing) + +A fourth `tsx` entry point, `npm run storylines:editor`, opens a **local** graph +viewer and guided editing page for a game's story-lines (viewer: +CG-0MVHZ2N2X0082OW7). It is a development tool, not a shipped game. The +[Story-line editor walkthrough](dev/storyline-editor.md) is the +content-designer/developer guide — supplying a content adapter, running against +a game's content, the edit workflow, artefact regeneration and headless +`--apply`: + +```bash +npm run storylines:editor # Gym content, http://127.0.0.1:5174 +npm run storylines:editor -- --port 5180 +npm run storylines:editor -- --content path/to/storylines.csv +npm run storylines:editor -- --adapter ./my-game-adapter.ts +npm run storylines:editor -- --help +``` + +- `--content` is the game's content CSV (default: the Gym demo content). +- `--adapter` is a **shipped adapter id** (`gym`, the default) or a module + path/specifier whose default (or `adapter` / `contentAdapter`) export is a + `ContentAdapter` — the same declarative column mapping the CLIs use. +- `--port` / `--host` default to `5174` and `127.0.0.1`; the tool is loopback-only + by design. The command exits `0` on `--help` and `1` on a bad option, an + unknown adapter, unreadable content or a host failure. + +The host reads the CSV **through the content adapter**, builds the deterministic +manifest and serves it at `/manifest.json` from memory (`scripts/storyline-editor-server.ts` +composes the dedicated `vite.editor.config.ts` with a manifest middleware). The +page fetches that one manifest, so pan/zoom and node selection never round-trip +to the host. The page (`tools/storyline-editor/`) renders one column per +story-line group, choice cards as diamonds and plain incidents as rounded +rectangles, cycle edges/members in red, and an option label on every link. +Selecting a card shows its name, effect, adapter impact, routing summary, +successor and incoming links, story-line id/title and adapter-declared columns; +the toolbar provides zoom in/out and reset, and the graph pans by dragging and +zooms with the wheel. + +The mapping step is `src/story-line/editor/view-model.ts` — a pure manifest → +view model function that reuses the manifest's own label helpers +(`nodeLabel`, `briefEventDescription`, `describeEventImpact`, +`describeChoiceRouting`) and `filterManifestByStoryline` for grouping, so the +editor can never disagree with the generated artefacts. + +**Build exclusion.** The editor page has its own Vite root +(`tools/storyline-editor`) and output (`dist/editor`), and nothing in +`index.html`, `main.ts`, `src/` or `electron/` references it. That property is +asserted by `tests/story-line/editor/build-exclusion.test.ts` (entry documents, +production source imports, build/packaging scripts and the Electron +`files:` list), alongside `vite-editor-config.test.ts` for the config identity. + +**Editing (curated forms + story-line management).** The same toolbar also +mounts the guided editing workflow (CG-0MVHZ2PVT0072YYK, CG-0MVHZ2RBJ004O0VD): +**Edit card** opens the selected node's card form, **Add card** opens the guided +add-card form (core-editable fields from the adapter's field descriptors), and +**Manage story-lines** opens the story-line management panel. Every operation +previews a diff and validates **before** write, then applies through the F2 +bridge — the single transactional author API (`applyContentEditToFile` in +`src/story-line/content/author.ts`). The editor never writes the content CSV +itself; that property is asserted by the write-path guard below. + +The story-line panel lists every group the generated manifest reports (its +`storylineIds` plus each member's declared id/title) and any group created in +the session. A story-line has no declaration file of its own — it exists +through its member cards — so a newly created group is listed as *pending* +(red-dashed border) until member cards are moved into it. Creating a group, +renaming one (a batch over every member card) and reassigning member cards all +write **one** `set-storyline-batch` edit: the combined result is validated once +and written once, so renaming a four-member group is a single author-API call, +never one write per member. After an accepted edit the list matches the +regenerated manifest. + +Tests: `tests/story-line/editor/view-model.test.ts` diffs the view model against +the generated manifest (nodes, edges, story-line ids/titles, cycle flags), +`viewer.browser.test.ts` covers rendering, grouping, cycle marking, pan/zoom, +selection and the details sidebar, `edit-forms.test.ts` / `edit-roundtrip.browser.test.ts` +cover the curated card forms round-trip, `storyline-manager.test.ts` (Node: +group derivation, draft validation, one-batch writes against a temp fixture +copy) and `storyline-manager.browser.test.ts` (real DOM: create/rename/reassign +through an in-memory bridge) cover the story-line management panel, and +`cli.test.ts` covers launch/`--help`, adapter resolution, manifest loading and +the host middleware. + +### Story-line Editor Test Harness + +The editor host, read-only viewer and guided editing now ship (see +[Story-line Editor](#story-line-editor-viewer-and-guided-editing)); its shared +test foundation lives in `tests/story-line/editor/` so every editor slice can be +driven test-first (CG-0MVHZ2LS3008ATY9): + +- **Fixtures** (`fixtures.ts`) — copies the committed content CSV and + generated manifest/Mermaid artefacts into an isolated OS temp directory and + returns the paths, so edit tests never mutate committed files. All source + paths derive from the generator's exported defaults (`DEFAULT_CONTENT_PATH`, + `DEFAULT_GRAPH_OUTPUTS`, `DEFAULT_MANIFEST_SCHEMA_PATH`), so an artefact or + schema move fails the fixture existence assertions loudly instead of silently + copying a stale file. +- **Write-path guard** (`write-path-guard.ts`) — a reusable static scan over the + editor source roots (`src/story-line/editor/`, `tools/storyline-editor/`, + `scripts/storyline-editor-*`) asserting no editor module performs a file + write/delete; every edit must go through the transactional author API + (`applyContentEditToFile`). Roots that do not exist yet are reported as + `skipped`, so the guard is green before the feature code lands. +- **Manifest schema guard** (`manifest-schema.test.ts`) — validates the + committed manifest artefact and a manifest regenerated from the fixture copy + against `schemas/story-line-manifest.schema.json` with the existing `ajv` + devDependency. + +**Browser-test path (decided).** The editor is a non-Phaser, non-game dev page, +so its renderers are plain-DOM modules. The core `browser` project drives them +**directly** — `harness.browser.test.ts` imports and mounts the DOM module into +Vitest's real Chromium document and asserts rendered output and events. No +second dev server, page boot, game loop or new runtime dependency is involved. +The editor *page* is served by its own config, `vite.editor.config.ts` (root +`tools/storyline-editor`, port 5174, output `dist/editor/`), which keeps the +game/build config (`vite.config.ts`) untouched and keeps the editor out of the +production game bundle and packaged Electron app. The harness browser test is +part of the `dev` test profile, and `story-line` is a core test group so the +smoke/dev lists always keep it. + ## Startup Context Budget The pre-push hook enforces a committed byte budget for the pi **startup context surface** (`AGENTS.md`, the global ruleset, and skills prose) so it does not silently grow session startup cost. Any commit that changes `AGENTS.md` must refresh `docs/dev/context-budget.thresholds.json` in the same commit: @@ -1594,6 +1814,35 @@ tests/ Each `src/` module has a barrel file (`index.ts`) that serves as its public API. Import engine modules using path aliases (see below). +### Phaser-free `@ui` helper imports for Node tests + +The `@ui` **barrel** (`src/ui/index.ts`) is Phaser-bearing at evaluation +time (it exports `CardGameScene`/`Phaser.Scene` subclasses), so it must never +be imported by a Node unit test. Scene code may keep importing it — but a +shared UI helper whose graph is consumed by downstream game unit suites (in +Node, no `window`) must **not** pull Phaser (or a Phaser-bearing module such +as `@ui/Renderer`) at evaluation time; a reintroduced value import fails every +downstream game unit suite with `window is not defined` (CG-0MVQQ2M8E003FQMZ). + +Rules of thumb: + +- **Import deep, Phaser-free paths in Node tests:** `@ui/shakeIllegalMove`, + `@ui/dragDrop`, `@ui/illegalMoveToastView` and `@ui/illegalMoveToast` are + explicitly node-safe; other `@ui` modules (e.g. `@ui/screen-layout`, + `@ui/constants`) are also Phaser-free. When in doubt, probe with a readonly + `vite-node` import before relying on it. +- **Do not add a Phaser value import to a node-safe helper.** Type-only usage + (`import type Phaser from 'phaser'`) is fine and elided at compile time; a + value import (or an import of a module like `@ui/Renderer` that evaluates + Phaser at module scope) is not. +- **Guard:** `tests/ui/phaser-free-imports.test.ts` imports every + framework-free entry point under a **file-scoped** `vi.mock('phaser', …)` + whose factory throws, plus a negative control asserting `@ui/Renderer` fails + under the same mock — so a reintroduced Phaser import fails loudly. The mock + must stay file-scoped (never in a global setup file). +- **Documented surface:** the broader documented Phaser-free node surface and + a static import-graph check are tracked in `CG-0MVS9HBN3009HIQN`. + ## Path Aliases The project defines path aliases in both `tsconfig.json` and `vite.config.ts`: @@ -1605,6 +1854,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 +1876,20 @@ 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). + +**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 @@ -1633,6 +1897,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) @@ -1893,6 +2166,43 @@ 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), under one explicit policy +(CG-0MVO268500071BPI): + +> **The pin tracks the tracked branch tip.** A pin that lags the tip is a +> genuine failure — and **the pin and the `./core` gitlink always travel +> together** (one actor, one moment, one commit). + +- `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). When the + checkout moves and the repo has a `configs/engine-pin.json`, the pin is + rewritten to the new HEAD and **both the pin and the gitlink are staged + together**, so a commit can never record them divergent. A second no-op + sync leaves the pin byte-identical (nothing writes unless the SHA moved). + Repos **without** a pin file are never given one — a repo is pinned only + once the release process (or a maintainer) creates the pin, after which + sync maintains it. +- `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. A **lagging** pin (an ancestor of the tracked tip by N commits) + fails with a lag-specific message telling the operator to re-pin — the pin + and gitlink travel together; a **bad/foreign** pin (does not resolve on the + tracked branch) fails with a different message telling the operator to fix + the pin. Both are hard failures; only the diagnosis and next steps differ. +- `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 @@ -2464,7 +2774,7 @@ Reference implementations: `example-games/gym/scenes/GymHandPileScene.ts` (max h ## Animation & Sound Feedback for Player and AI Actions -**Requirement:** Every player **and** AI action that uses a core engine animation/feedback helper — `dealCard`, `discardCard`, `flipCard`, `placeCard`, `moveGameObject`, `shakeIllegalMove`, `popTextOrIcon`, `createDragDropManager`, and any future helpers — must be rendered with the corresponding animation and wired with a sound effect (SFX), so the action is both animated and audible. Each helper accepts a `soundManager` + `sfx` (`start`/`move`/`end`) options map (see [UI Animation Helpers](ui-animations.md)); pass both so the action is never silent or instant by default. SFX keys must follow the shared `sfx-` prefix convention — `COMMON_SFX_KEYS` from `src/core-engine/SoundManager.ts`, detailed in [docs/SFX_CONVENTION.md](SFX_CONVENTION.md); no game-scoped string literals. (`shakeIllegalMove` plays `COMMON_SFX_KEYS.ILLEGAL_MOVE` automatically; `popTextOrIcon()` is the lightweight score/notification popup; `createDragDropManager` — the reusable drag-and-drop lifecycle in `src/ui/dragDrop.ts`, see [drag-and-drop lifecycle](ui-animations.md#createdragdropmanager-drag-and-drop-lifecycle) — plays the illegal feedback sound on pickup veto and invalid drops.) +**Requirement:** Every player **and** AI action that uses a core engine animation/feedback helper — `dealCard`, `discardCard`, `flipCard`, `placeCard`, `moveGameObject`, `shakeIllegalMove`, `popTextOrIcon`, `createDragDropManager`, and any future helpers — must be rendered with the corresponding animation and wired with a sound effect (SFX), so the action is both animated and audible. Each helper accepts a `soundManager` + `sfx` (`start`/`move`/`end`) options map (see [UI Animation Helpers](ui-animations.md)); pass both so the action is never silent or instant by default. SFX keys must follow the shared `sfx-` prefix convention — `COMMON_SFX_KEYS` from `src/core-engine/SoundManager.ts`, detailed in [docs/SFX_CONVENTION.md](SFX_CONVENTION.md); no game-scoped string literals. `shakeIllegalMove` plays `COMMON_SFX_KEYS.ILLEGAL_MOVE` automatically; pass its `reason` option (the rules-layer `LegalityResult.reason`) and it also renders the transient reason toast, so a rejected move is explained as well as animated — see [shakeIllegalMove](ui-animations.md#shakeillegalmove-illegal-move-feedback--reason-toast). `popTextOrIcon()` is the lightweight score/notification popup; `createDragDropManager` — the reusable drag-and-drop lifecycle in `src/ui/dragDrop.ts`, see [drag-and-drop lifecycle](ui-animations.md#createdragdropmanager-drag-and-drop-lifecycle) — plays the illegal feedback sound on pickup veto and invalid drops and renders the reason toast when an `illegalReason` resolver supplies `payload.reason`.) **AI actions:** AI turns must be animated with a brief delay so the player can see and hear what the AI did (e.g. card placement / row take). Coloretto is the in-repo precedent — `../tce-coloretto/src/scenes/ColorettoAiScheduler.ts` schedules AI turns via `time.delayedCall` (750ms, 150ms under reduced motion) then dispatches the AI's action through the same animated/sounded path as a human turn (rendered by `ColorettoRenderer`, orchestrated by `ColorettoScene`). @@ -2521,7 +2831,30 @@ When running `npm run dev`, a Vite plugin intercepts `POST /api/transcripts` req data/transcripts//-.json ``` -This happens via a fire-and-forget POST from `TranscriptStore.save()`. If the POST fails (e.g. the production build is being served instead of the dev server), a `console.warn` is emitted but gameplay is not disrupted. +This happens via a fire-and-forget POST from `TranscriptStore.save()`. + +The disk mirror is deliberately quiet. The request is only issued where the +endpoint can exist (`import.meta.env.DEV`, i.e. a Vite dev server), and any +non-2xx or unreachable outcome is reported **at most once per store instance**: + +- an absent endpoint (HTTP 404/405) or an unreachable dev server logs one + `console.debug` note and then stays silent — that condition is expected in + some runtimes (a production/Electron bundle, a Vitest browser run, a game + repo whose generated `vite.config.ts` does not register the plugin); +- any other non-2xx status (5xx) logs one `console.warn`, because that is a + genuine server-side failure. + +Either way gameplay is never disrupted — the browser-storage save has already +succeeded by then. + +**Game-repo registration contract.** The middleware is a Vite plugin +(`transcriptPersistPlugin()` in `scripts/vite-transcript-plugin.ts`). This +repository's `vite.config.ts` registers it for `command === 'serve'` only and +never when `process.env.VITEST` is set, and the game-repo scaffold template +(`scripts/game-repo-scaffold.ts` → `renderViteConfig`) emits the identical +registration into every generated `vite.config.ts`. A game repo that 404s on +`POST /api/transcripts` therefore has an out-of-date generated config: run the +scaffold (or copy the `...(command === 'serve' && !process.env.VITEST ? [transcriptPersistPlugin()] : [])` entry into `plugins`). The `data/` directory is gitignored, so persisted transcripts remain local to your machine. @@ -2978,6 +3311,73 @@ this.events.on('state-settled', (payload) => { /* ... */ }); bridge.destroy(); ``` +### Game-specific events + +The engine ships a small, game-agnostic event vocabulary. A game declares its +own typed event names **without editing the engine** by augmenting the exported +`GameEventMap` interface (TypeScript module augmentation): + +```typescript +// In the game's own source, next to its event declarations. +import '@core-engine/GameEventEmitter'; + +declare module '@core-engine/GameEventEmitter' { + interface GameEventMap { + 'face-off-resolved': { + slot: number; + playerTotal: number; + adversaryTotal: number; + winner: 'player' | 'adversary'; + }; + } +} +``` + +No other engine change is needed: `on`, `once`, `off` and `emit` all work over +the merged map, with the same type safety as the engine's own events. + +```typescript +import { GameEventEmitter } from '@core-engine'; + +const gameEvents = new GameEventEmitter(); + +// Typed per-name subscription (the augmented name is fully typed). +gameEvents.on('face-off-resolved', ({ slot, winner }) => { + console.log(`Slot ${slot} won by ${winner}`); +}); + +// Catch-all subscription: every emission, engine or game-specific, with +// its name and payload. Returns an unsubscribe function. +const offAny = gameEvents.onAny((eventName, payload) => { + console.log(eventName, payload); +}); + +gameEvents.emit('face-off-resolved', { + slot: 2, + playerTotal: 11, + adversaryTotal: 8, + winner: 'player', +}); + +offAny(); +``` + +The catch-all is what makes game-specific events automatic everywhere else: + +- **`PhaserEventBridge`** subscribes with `onAny`, so a game-specific event + reaches `scene.events` under its own name with no per-game list. (The + Phaser → engine direction still bridges the engine's known names, because an + un-typed Phaser emitter cannot be enumerated.) +- **`GlobalEventBuffer`** (the dev "Game Events" overlay's source) subscribes + with `onAny`, so game-specific events are buffered from session start and + appear in the overlay automatically. + +**Engine-agnostic rule:** the engine provides the *mechanism* (augmentable map ++ catch-all), never the vocabulary. Game event names and payloads live in the +game repository; the `gym` example game and every other consumer keep working +unchanged. Emitting with no listeners attached is a no-op, so the mechanism is +free when unused. + ## Managing Assets - All assets go in `public/assets/` and are served by Vite at the `/assets/` URL path @@ -3187,12 +3587,22 @@ render consistently above gameplay content across all example games. that manages game-state overlay lifecycle. Supports types: `'game-over'`, `'win/loss'`, `'round-end'`, `'custom'`. +3. **`shakeIllegalMove` / `showIllegalMoveToast`** (`src/ui/shakeIllegalMove.ts`, + `src/ui/illegalMoveToastView.ts`, `src/ui/illegalMoveToast.ts`) — the + shared illegal-move feedback routine (tint + shake + `sfx-illegal-move`). + Pass `reason` (normally `LegalityResult.reason`) and the routine also + renders a transient, non-blocking reason toast — independently of the + shake branch, so it still appears for Containers, `null` targets and under + reduced motion. See + [UI Animation Helpers → shakeIllegalMove](ui-animations.md#shakeillegalmove-illegal-move-feedback--reason-toast). + ### Depth Convention -| Layer | Depth | Purpose | -|----------------------|--------|--------------------------------------------| -| HUD container | 1000 | Help/settings panels, buttons | -| Game-state overlays | 2000 | Win, loss, game-over, round-end overlays | +| Layer | Depth | Purpose | +|------------------------------|--------|--------------------------------------------| +| HUD container | 1000 | Help/settings panels, buttons | +| Illegal-move reason toast | 1900 | Transient rejection explanation | +| Game-state overlays | 2000 | Win, loss, game-over, round-end overlays | ### Full Component Reference @@ -4331,6 +4741,7 @@ The `GymButtonBar` is exported from the UI barrel (`src/ui/index.ts`) and can be | Panel background | 901 | | Panel content | 902 | | Panel close button | 903 | +| Illegal-move reason toast | 1900 | | Overlay background | 10–2000 (game-specific) | | Overlay buttons | overlay depth + 1 | 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. diff --git a/docs/core-engine/story-line.md b/docs/core-engine/story-line.md new file mode 100644 index 000000000..20cc6613f --- /dev/null +++ b/docs/core-engine/story-line.md @@ -0,0 +1,617 @@ +# 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` | +| 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 + +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'`. +- 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)` 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`. + +## 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. + +## Authoring seams (content, manifest, validator, author) + +The runtime system above resolves a story-line; the **content toolkit** in +[`src/story-line/content/`](../../src/story-line/content/) authors one. It is +game-agnostic: a game supplies a **content adapter** (plain data) that maps its +CSV columns onto the generic story-line fields, and core provides parsing, a +manifest/graph generator, a validator and a single transactional write path. + +The toolkit has **no game imports and no Phaser dependency**, and only +`author.ts` writes content. Import paths are relative (or `node:` builtins), so +the whole toolkit also loads under plain `tsx` with no bundler. + +| Module | Responsibility | +|---|---| +| `content/types.ts` | `ContentCard` (generic card record), `ContentAdapter` / `ContentFieldMapping`, `baseIdOf`, mapping helpers. | +| `content/csv.ts` | `parseCsv` / `serialiseCsv` (byte-identical round-trip), `computeContentChecksum`, `cardsFromCsv`, `compileContentOptions`. | +| `content/manifest.ts` | `buildStorylineManifest`, `serialiseManifest`, `renderMermaid`, `filterManifestByStoryline`, label helpers (`nodeLabel`, `briefEventDescription`, `describeChoiceRouting`). | +| `content/validate.ts` | `validateContentCards` / `validateContentCsv`, text + JSON reports, `findCycles`. | +| `content/author.ts` | `addContentCard`, `updateContentCard`, `applyContentEditToFile` — the only content writer. | +| `content/adapters.ts` | `GYM_CONTENT_ADAPTER` (the default), `getContentAdapter`, `DEFAULT_CONTENT_PATH`. | +| `content/index.ts` | Barrel (also reachable as `@story-line/content`). | + +### Content adapter + +An adapter declares the game's columns, the story-line field mapping and the +new-card defaults. Only `id` and `name` mappings are required: a game without a +legacy two-way choice model simply omits `hasChoices` / +`acceptNextCardId` / `rejectNextCardId`, and a game without an `options` column +omits `options`. + +```ts +import type { ContentAdapter } from '@story-line/content'; + +export const TAVERN_ADAPTER: ContentAdapter = { + gameId: 'tavern', + columns: ['card_key', 'card_name', 'flavour', 'arc', 'arc_name', 'choices', 'payoff'], + fields: { + id: 'card_key', + name: 'card_name', + effect: 'flavour', + options: 'choices', + storylineId: 'arc', + storylineTitle: 'arc_name', + }, + editableColumns: ['payoff'], + newCardDefaults: { flavour: '', choices: '', payoff: '0' }, + // Optional: a human-readable game impact for manifest labels/diagrams. + describeImpact: (card) => (card.fields.payoff ? `payoff ${card.fields.payoff}` : ''), +}; +``` + +Every column **not** in `fields` lands in `ContentCard.fields`, keyed by column +name, so game columns (coin/reputation deltas, renown, payoff, …) are editable +and inspectable without core knowing what they mean. Two optional hooks keep +game semantics out of core: `baseId` (chain-serial stripping; identity by +default) and `describeImpact` (manifest impact text). + +Core ships `GYM_CONTENT_ADAPTER` as the default, backed by the committed +[`example-games/gym/content/storylines.csv`](../../example-games/gym/content/storylines.csv) +of the Gym demo content. Tests additionally exercise a differently shaped +fixture adapter, proving the seam is not shaped around one game. + +### CSV and change detection + +`parseCsv` handles quoted fields, embedded commas/quotes/newlines and CRLF; +`serialiseCsv` writes LF with a trailing newline, so a canonically quoted file +round-trips **byte-identically** (column order preserved). +`computeContentChecksum(csvText)` is a deterministic DJB2 hex hash (8 chars) for +change detection — any byte change produces a different checksum. + +`cardsFromCsv(parsed, adapter)` projects rows onto `ContentCard`; +`compileContentOptions(card)` applies the runtime engine's precedence (CSV +`options` encoding → legacy `hasChoices`), so authored content compiles exactly +as the runtime reads it. + +### Manifest, Mermaid and the JSON schema + +`buildStorylineManifest(cards, adapter)` produces a **deterministic, +timestamp-free** manifest — sorted nodes (by id) and edges (by source, label, +candidate index, target), sorted story-line ids and sorted cycle paths. Two +runs over the same content are byte-identical, and the result is independent of +input card order, which is what makes `--check` a safe drift guard. + +Manifest shape: `{ version, source, gameId, storylineIds, nodes, edges, cycles }`, +where a node carries the derived story-line fields plus `impact` and `fields` +(every game column), and an edge carries `from`, `label`, `to`, `random`, +`candidateIndex`, `cycle` and `effectPolicy`. A terminal option becomes one +`to: null` edge; every candidate of a multi-candidate option becomes its own +`random` edge. Cycles are a first-class shape: they are reported and their +edges marked, never treated as an error by the builder. + +The generated manifest validates against +[`schemas/story-line-manifest.schema.json`](../../schemas/story-line-manifest.schema.json) +(devDependency `ajv`), which is enforced in the unit suite. The committed Gym +artefacts are `example-games/gym/content/storyline-manifest.json` and +`example-games/gym/content/storyline-graph.mmd`; `--check` fails on drift. + +### Validator + +`validateContentCards(cards, adapter, { failOnCycle? })` is pure (never mutates, +never throws) and returns `{ valid, issues, findings, cardCount, +choiceCardCount, storylineIds, gameId }`. + +| Hard error code | Meaning | +|---|---| +| `duplicate-id` | Two cards share the same (base) id. | +| `missing-required-field` | The id or name is empty. | +| `missing-storyline-id` | A choice card declares no storyline id. | +| `orphan-link` | A card declares accept/reject links but is not a choice card. | +| `invalid-option` | Malformed `options` encoding, or an empty/duplicate option label. | +| `unknown-successor` | An option (including a legacy `Accept`/`Reject` option) names an unknown card. | +| `dangling-link` | An inert accept/reject link (on a non-choice card) names an unknown card. | +| `unreachable-card` | A story-line member is unreachable from its story-line's entry card. | +| `cycle-error` | A cycle treated as invalid — **opt-in** via `failOnCycle`. | + +Informational findings are `cycle` and `self-link`; they never fail validation +unless `failOnCycle` is set. A storyline's entry card is its first member (in +file order) with no incoming edge from another member (a closed cycle uses its +first member), so a disconnected fragment is reported as `unreachable-card`. + +`formatContentValidationReport(result)` and +`contentValidationToJson(result)` render the same issue/finding data, so the +text and JSON reports cannot disagree. + +### Transactional author API + +Every edit is: parse → mutate in memory → validate → write atomically (temp file ++ rename). On any validation failure the original CSV is returned unchanged and +the file is not touched; the writer re-validates the candidate itself, so no +caller can bypass the check. + +```ts +import { + GYM_CONTENT_ADAPTER, + addContentCard, + applyContentEditToFile, + updateContentCard, +} from '@story-line/content'; + +// Pure, in-memory (returns the candidate or the unchanged input + errors). +const edited = addContentCard(csvText, GYM_CONTENT_ADAPTER, { + id: 'evt-new', + name: 'A New Event', + effect: 'Something happens.', +}); + +// Transactional file edit (`dryRun` returns the candidate without writing). +const result = applyContentEditToFile({ + contentPath: 'src/card-data.csv', + adapter: GYM_CONTENT_ADAPTER, + dryRun: true, + mutate: (csv) => + updateContentCard(csv, GYM_CONTENT_ADAPTER, { + id: 'evt-tax', + storylineId: 'storyline-tax', + storylineTitle: 'Tax Arc', + }), +}); +``` + +`addContentCard` refuses a duplicate id, a missing id/name, an unknown or mapped +game column, and any edit whose result fails validation (for example a new card +in a story-line that nothing links to — it would be `unreachable-card`). +`updateContentCard` changes only the supplied fields; a nullable field set to +`null` is cleared, and setting a legacy link also marks the card as a choice. + +### CLIs + +Thin `tsx` entry points over the seams (no Vite loader, no game import): + +| npm script | Script | Purpose | +|---|---|---| +| `npm run storylines:validate` | `scripts/validate-storylines.ts` | Validate a content CSV (`--csv`, `--game`, `--json`, `--fail-on-cycle`). | +| `npm run storylines:graph` | `scripts/storyline-graph.ts` | Write or `--check` the JSON manifest / Mermaid graph (`--format`, `--out`). | +| `npm run storylines:author` | `scripts/storyline-author.ts` | `add-card` / `update-card` (`--dry-run`, `--field column=value`). | +| `npm run storylines:editor` | `scripts/storyline-editor-cli.ts` | Launch the local editor page or apply one edit headlessly (`--content`, `--adapter`, `--port`, `--host`, `--apply`, `--dry-run`). | + +Every CLI supports `--help`, resolves `--game` (the author/validator/graph +CLIs) or `--adapter` (the editor) through the shipped adapter registry +(default `gym`), and exits `0` on success and `1` on invalid/unreadable content +or drift. `npm run storylines:author -- add-card --id … --dry-run` prints the +candidate content without writing it. + +## Editor (visual authoring tool) + +`npm run storylines:editor` launches a local, plain-DOM authoring page over the +same seams. It is a **development tool**, not a shipped game: it has its own +Vite root ([`tools/storyline-editor/`](../../tools/storyline-editor/)) and config +([`vite.editor.config.ts`](../../vite.editor.config.ts), port 5174, output +`dist/editor/`), nothing in `index.html`, `main.ts`, `src/` or `electron/` +references it, and it adds no runtime dependency. Every mutation goes through +the single transactional author API — the page never writes the content CSV +itself. + +### Launch + +```bash +npm run storylines:editor # Gym content on http://127.0.0.1:5174 +npm run storylines:editor -- --port 5180 +npm run storylines:editor -- --host 127.0.0.1 # loopback is the default +npm run storylines:editor -- --content path/to/storylines.csv +npm run storylines:editor -- --adapter ./my-game-adapter.ts +npm run storylines:editor -- --apply '{"edit":{"kind":"update-card","spec":{...}}}' +npm run storylines:editor -- --help +``` + +| Option | Meaning | +|---|---| +| `--content ` | The game's content CSV (default: the Gym demo content). | +| `--adapter ` | A shipped adapter id (`gym`) or a module path/specifier exporting a `ContentAdapter` as its default, `adapter` or `contentAdapter` export. | +| `--port` / `--host` | Dev-server port (default `5174`) and bind host (default `127.0.0.1`). The tool is loopback-only by design. | +| `--apply ` | Apply one edit through the write bridge, print the JSON result and exit (no dev server). `--dry-run` previews/validates while writing nothing. | +| `--help` | Print usage and exit `0`. | + +The CLI resolves the adapter, loads the content into an **editor session** (the +CSV snapshot plus its concurrency checksum) and either boots the host or — with +`--apply` — runs the same write bridge headlessly. Exit codes: `0` when serving, +when help was shown or when a headless apply succeeded; `1` on a bad option, an +unknown adapter, unreadable content, a host failure or a refused edit (invalid +or stale content). + +### Viewer + +The host serves `GET /manifest.json` from memory (built at launch from the +content CSV + adapter) and the page fetches it **once**, so pan/zoom and node +selection never round-trip to the host. The rendered graph is manifest-driven: + +- one column per story-line group; +- choice cards drawn as diamonds, plain incidents as rounded rectangles; +- cycle edges and cycle members highlighted; +- an option label on every link (a terminal option is a `to: null` link); +- drag to pan, wheel or toolbar to zoom, fit and reset; +- selecting a node shows its name, effect, adapter `impact`, routing summary, +successor and incoming links, story-line id/title and every adapter-declared +game column. + +The mapping step is [`src/story-line/editor/view-model.ts`](../../src/story-line/editor/view-model.ts) +— a pure manifest → view-model function that reuses the manifest's own label +helpers (`nodeLabel`, `briefEventDescription`, `describeEventImpact`, +`describeChoiceRouting`) and `filterManifestByStoryline` for grouping, so the +editor can never disagree with the generated artefacts. + +### Edit workflow + +Three surfaces share one workflow — draft, preview, apply, discard: + +- **Edit card** — opens the selected node's card form. Core edits the generic +story-line fields (id read-only, name, effect, choice flag, `options` encoding, +legacy accept/reject links, story-line id/title); every adapter-declared column +renders as an additional field without core knowing its semantics. The field +list comes from the adapter's descriptors +([`field-descriptors.ts`](../../src/story-line/editor/field-descriptors.ts)), +not hard-coded column names. +- **Add card** — a guided form that picks the story-line group (existing or a +new one) and optionally the card that leads to the new card. With an incoming +link the form emits a single transactional `add-linked-card` edit (append the +card **and** add the predecessor's option), validated once and written once. +- **Manage story-lines** — create, rename and reassign groups (below). + +A change stays a **pending draft** until it is applied; **Discard** throws it +away and leaves the content file untouched. **Apply** previews again first, so a +write can never happen without a fresh, valid preview. Inline validation errors +are attributed to the offending field and block the write, while the full +validator report stays visible. + +### Preview and validation + +Every edit is previewed before it is written: the preview panel renders the +pending **diff** (only the affected rows, column by column, before → after) +plus the validator's own report — the same text and JSON the `storylines:validate` +CLI renders — so the author sees exactly what would be written and why it is +(or is not) allowed. A refused edit renders its validation errors instead of a +diff, writes nothing, and keeps the draft pending. + +The host exposes the bridge as `POST /api/preview`, `POST /api/validate` and +`POST /api/apply`; the page is a client of those endpoints, so tooling can +drive the same workflow. + +### Artefact regeneration and hand-written documentation + +After an **accepted** edit the committed Mermaid graph and JSON manifest are +regenerated and then re-read to verify them (`writeArtefacts` → `checkArtefacts` +in [`content/artefacts.ts`](../../src/story-line/content/artefacts.ts)). A +"regenerated" claim is therefore verified rather than assumed, and the result +reports any remaining drift. A dry run reports whether the artefacts **would** +stay in sync. + +Hand-written documentation is never rewritten. An edit that touches a row +documented by hand surfaces an explicit **"manual documentation update +required"** result naming the check that now fails. The registry lives in +[`src/story-line/editor/manual-docs.ts`](../../src/story-line/editor/manual-docs.ts); +the shipped entry is the Gym demo's hand-maintained card catalogue. + +Artefact paths default to the committed Gym artefacts (`DEFAULT_ARTEFACT_PATHS`). +A game supplies its own as a session/bridge option (`loadEditorSession({ +artefactPaths })`, `applyEdit({ artefactPaths })`) or in the `--apply` JSON +payload; the host has no CLI flag for them yet, so editing non-Gym content from +the served page targets the default paths — use `--apply`, or that game's own +`storylines:graph` invocation, when its artefacts live elsewhere. + +### Story-line management + +A story-line has no declaration file of its own: it exists through the cards +that declare its id and title. Managing one is therefore always a **batch edit +of its member cards' story-line fields**, and the panel drives create, rename +and reassign as one `set-storyline-batch` edit — the combined member cards are +validated once and written once through `applyContentEditToFile`, so renaming a +four-member group is a single author-API call, never one write per card. The +panel lists every group the generated manifest reports plus any group created in +the session; a newly created group with no members stays in-session, listed as +pending, until cards are moved into it (there is nothing to write yet). + +### Concurrency guard + +The session captures the content checksum at load and hands it to the write +bridge, which compares it with its own read of the file. If the CSV changed on +disk since the page loaded (an external edit, a branch switch), the write is +refused with `stale-content` (HTTP 409), nothing is written, and the page +instructs the author to reload before retrying — a stale snapshot can never +clobber a concurrent edit. + +## 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. | +| `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. | +| `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). All new declarative semantics — cost payments, extra effects and +condition evaluation — go through `getResources` + `applyResourceDelta`, so no +new coupling is introduced. + +## 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. 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 + +[`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 **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. +- `tests/story-line/content/csv.test.ts` — byte-identical round-trip (two + adapter shapes), checksum determinism, projection and option compilation. +- `tests/story-line/content/manifest.test.ts` — manifest shape, determinism and + input-order independence, cycle marking, filtering, Mermaid rendering, AJV + schema validation and `--check` drift behaviour. +- `tests/story-line/content/validate.test.ts` — every hard-error class, cycle + policy, report parity and non-mutation. +- `tests/story-line/content/author.test.ts` — transactional add/update, refusal + on invalid content, dry-run and atomic file writes. +- `tests/story-line/content/adapters.test.ts` — the default adapter/registry and + the seam proven with a second, differently shaped adapter. +- `tests/story-line/content/import-boundary.test.ts` — the module graph imports + only relative modules/`node:` builtins, and only `author.ts` writes files. +- `tests/story-line/content/cli.test.ts` — validator/builder exit codes, report + output and a `tsx` subprocess smoke run of the entry points. +- `tests/story-line/editor/view-model.test.ts` — the view model diffs against the + generated manifest (nodes, edges, story-line ids/titles, cycle flags). +- `tests/story-line/editor/bridge.test.ts` / `host-api.test.ts` / `cli.test.ts` + — the write bridge (preview/validate/apply), the host middleware and the CLI + (launch, `--help`, adapter resolution, headless `--apply`). +- `tests/story-line/editor/concurrency.test.ts` — the stale-content guard refuses + a write and returns a reload instruction. +- `tests/story-line/editor/preview-validate.test.ts` — broken edits are refused, + write nothing and surface the offending card/link. +- `tests/story-line/editor/edit-forms.test.ts` / `add-card-form` coverage — + adapter-driven field descriptors, drafts, diff preview and inline error + attribution. +- `tests/story-line/editor/storyline-manager.test.ts` — group derivation, draft + validation and one-batch writes against a temp fixture copy. +- `tests/story-line/editor/harness.browser.test.ts` / `viewer.browser.test.ts` / + `edit-roundtrip.browser.test.ts` / `storyline-manager.browser.test.ts` — the + editor page's plain-DOM renderers run in Vitest's browser project: viewer + rendering, grouping, cycle marking, pan/zoom/select, and a representative + edit round-trip. +- `tests/story-line/editor/build-exclusion.test.ts` / `vite-editor-config.test.ts` + — the editor entry is not referenced from the game/Electron build, and its + config is distinct from the game config. + +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/dev/context-budget.thresholds.json b/docs/dev/context-budget.thresholds.json index 8ead4f118..7704a10db 100644 --- a/docs/dev/context-budget.thresholds.json +++ b/docs/dev/context-budget.thresholds.json @@ -1,6 +1,6 @@ { "global_agents": 0, - "project_agents": 64762, + "project_agents": 66381, "skills_prose": 0, - "total": 64762 + "total": 66381 } 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/docs/dev/storyline-editor.md b/docs/dev/storyline-editor.md new file mode 100644 index 000000000..9238f799b --- /dev/null +++ b/docs/dev/storyline-editor.md @@ -0,0 +1,275 @@ +# Story-line Editor — developer and content-designer walkthrough + +The **story-line editor** is a local, plain-DOM authoring tool for the +branching story-line mechanic. It loads a game's content CSV through a +**content adapter**, renders the arc as an interactive graph, and lets a +content designer make **guided, validated edits** — rename a card, retarget a +successor, add a card, regroup a story-line — without touching CSV internals. + +It is part of the core authoring toolkit +([`src/story-line/content/`](../../src/story-line/content/) and +[`src/story-line/editor/`](../../src/story-line/editor/)) and is **not** a +shipped game feature: it has its own Vite root and config +(`tools/storyline-editor/`, `vite.editor.config.ts`), is excluded from the +production game and Electron builds (asserted by +`tests/story-line/editor/build-exclusion.test.ts`), and adds no runtime +dependency. + +See also: + +- [Core: Generic Story-Line System](../core-engine/story-line.md) — the runtime + and authoring seams this tool drives (adapter contract, validator codes, + manifest shape, author API). +- `tests/story-line/editor/` — the viewer and round-trip browser tests that pin + everything documented below. + +--- + +## 1. Quick start (Gym content) + +From the core repo root: + +```bash +npm run storylines:editor +``` + +This serves the Gym demo content +(`example-games/gym/content/storylines.csv`) on `http://127.0.0.1:5174`. The +page is loopback-only by design. Stop it with `Ctrl-C`. + +```bash +npm run storylines:editor -- --help # all flags and exit codes +``` + +The command exits `0` when the server is up (and on `--help`), and `1` on a bad +option, an unknown adapter, unreadable content or a host failure. + +## 2. What the editor surfaces + +Three surfaces share one conservative workflow — **draft, preview, apply, +discard**: + +| Surface | Purpose | +|---|---| +| **Viewer** | The manifest-driven graph: one column per story-line group, choice cards as diamonds, plain incidents as rounded rectangles, cycle edges/members highlighted, option labels on every link. Drag to pan, wheel/toolbar to zoom, click a node to inspect its links and metadata. | +| **Card forms** | **Edit card** (the selected node's form) and **Add card** (guided add form). Fields come from the adapter's **field descriptors**, so a game's columns appear as plain fields without core knowing what they mean. | +| **Story-line management** | **Manage story-lines** lists, creates, renames and reassigns groups. A story-line exists through its member cards, so every operation is one validated **batch** edit of those cards' story-line fields. | + +Every operation previews a **diff** and runs the **validator** before anything +is written, then writes through the single transactional author API +(`applyContentEditToFile` in `src/story-line/content/author.ts`). The editor +page never writes the content CSV itself; that property is asserted by the +write-path guard (`tests/story-line/editor/write-path-guard.test.ts`). + +## 3. Supplying a content adapter + +The editor is game-agnostic: it reads a game's content through a +**`ContentAdapter`** — declarative data that maps the game's CSV columns onto +the generic story-line fields. Only `id` and `name` mappings are required. + +```ts +// my-game-adapter.ts +import type { ContentAdapter } from '@story-line/content'; + +export const TAVERN_ADAPTER: ContentAdapter = { + gameId: 'tavern', + columns: ['card_key', 'card_name', 'flavour', 'arc', 'arc_name', 'choices', 'payoff'], + fields: { + id: 'card_key', + name: 'card_name', + effect: 'flavour', + options: 'choices', + storylineId: 'arc', + storylineTitle: 'arc_name', + }, + editableColumns: ['payoff'], + newCardDefaults: { flavour: '', choices: '', payoff: '0' }, + // Optional: a human-readable impact label for manifest nodes/diagrams. + describeImpact: (card) => (card.fields.payoff ? `payoff ${card.fields.payoff}` : ''), +}; +``` + +Notes on the contract (`ContentAdapter` in +[`src/story-line/content/types.ts`](../../src/story-line/content/types.ts)): + +- **Required:** `gameId`, `columns` (the CSV's columns in file order) and the + `fields` mapping with at least `id` and `name`. +- **Optional mappings:** `effect`, `hasChoices`, `acceptNextCardId`, + `rejectNextCardId`, `options`, `storylineId`, `storylineTitle`. Omit a field + mapping when the game has no such column — omitting means "no such column", + never an error. +- **Every column not in `fields`** lands in `ContentCard.fields`, keyed by + column name. Those game columns are editable/inspectable by default (coin and + reputation deltas, renown, payoff, …) without core knowing what they mean. +- **`editableColumns`** — an explicit allow-list of extra columns the forms may + edit; default is every non-mapped column. +- **`newCardDefaults`** — per-column defaults for a newly authored card row. +- **Optional hooks:** `baseId` (strip a chain-instance serial such as + `evt-x-3` → `evt-x`; identity by default) and `describeImpact` (impact text + shown on manifest labels/diagrams). + +Core ships one adapter (the Gym demo, `GYM_CONTENT_ADAPTER`) as the default. +Tests also exercise a differently shaped adapter, proving the seam is not +shaped around one game. + +## 4. Running the editor against your game's content + +```bash +npm run storylines:editor -- --content path/to/storylines.csv \ + --adapter ./my-game-adapter.ts +``` + +- `--content` is the game's content CSV. +- `--adapter` is a **shipped adapter id** (`gym`, the default) or a module + path/specifier whose default (or `adapter` / `contentAdapter`) export is a + `ContentAdapter` — the same declarative mapping the CLIs use. Relative + paths resolve against the core repo root. +- `--port` / `--host` default to `5174` and `127.0.0.1`. + +The host builds the deterministic manifest from that CSV + adapter at launch +and serves it from memory (`GET /manifest.json`), so the graph the designer +sees is exactly the manifest the `storylines:graph` CLI would write. + +### Artefact paths — what the editor writes + +After an **accepted** edit the bridge regenerates the committed Mermaid and +JSON artefacts, then re-reads them to verify (`writeArtefacts` → `checkArtefacts` +in `src/story-line/content/artefacts.ts`). The paths default to the committed +**Gym** artefacts (`DEFAULT_ARTEFACT_PATHS`); the interactive host does not yet +expose a CLI flag to point them elsewhere (a follow-up work item). So: + +- For content that lives in the core repo with artefacts at the default paths + (the Gym demo), the served editor is the complete workflow. +- For a game whose artefacts live elsewhere, keep the served editor + **read-only for inspection** and drive writes headlessly with `--apply` and an + explicit `artefactPaths` (below), or regenerate the game's artefacts with its + own `storylines:graph` (or `storylines:graph-svg`) invocation afterwards and + let its `--check` drift guard gate the result. + +You can pass explicit paths in the headless JSON payload (see §7). + +## 5. Edit workflow, step by step + +1. **Select a card** in the graph, then open **Edit card** (or **Add card**). + The form is built from the adapter's field descriptors: core fields first + (id read-only when editing), then every adapter column. +2. **Change fields.** The change is a *pending draft*; nothing is written. +3. **Preview** — post the derived edit to the bridge; it applies the edit **in + memory**, validates it and diffs the affected rows (column by column, + before → after). The preview panel renders the diff **and** the validator's + report (the same text/JSON the `storylines:validate` CLI renders). +4. **Apply** — previews again first (an apply can never happen without a fresh, + valid preview), then writes the content CSV atomically through the author + API and regenerates the artefacts. +5. **Discard** — drops the draft; the content file is untouched. + +An invalid edit is **refused**: the panel shows the validation errors +(attributed to the offending field, marked inline), nothing is written, and the +draft stays pending so the author fixes exactly that field. + +### Adding a card and linking it into a story-line + +The **Add card** form guides both decisions: + +- **Story-line** — pick an existing group (which fills the id/title fields) or + author a new one. +- **Incoming link** — optionally choose the existing card that leads to the new + card. This makes the form emit a single transactional `add-linked-card` edit: + the card is appended **and** the predecessor gains an option pointing at it, + validated once and written once. + +A new card that nothing links to in a story-line is refused by the validator +(`unreachable-card`) — the guided form makes the missing-link case obvious +before the write. + +## 6. Story-line management + +A story-line has no declaration file of its own: it exists through the cards +that declare its `storylineId`/`storylineTitle`. **Manage story-lines** lists +every group the generated manifest reports, plus any group created in the +session, and drives: + +- **Create** — register a new id/title group. With member cards selected the + grouping is written immediately as one validated batch; without members it + stays in-session (**pending**, dashed outline) until cards are moved into it — + there is nothing to write yet. +- **Rename** — retitle (and optionally re-id) a group by applying the new + story-line fields to every member card in one batch. +- **Reassign** — move selected member cards into another group. + +Every operation is one `set-storyline-batch` edit: combined members validated +**once** and written **once** — renaming a four-member group is a single +author-API call, never one write per member. + +## 7. Headless edits (`--apply`) + +Scripted or CI edits use the same write bridge without a dev server: + +```bash +npm run storylines:editor -- --content path/to/storylines.csv \ + --adapter ./my-game-adapter.ts \ + --apply '{"edit":{"kind":"update-card","spec":{"id":"evt-tax","name":"Tax Audit · revised"}}}' +``` + +The payload is `{ "edit": { "kind", "spec" }, "expectedChecksum"?, "dryRun"?, +"artefactPaths"? }`. Edit kinds: `add-card`, `add-linked-card`, `update-card`, +`set-storyline`, `set-storyline-batch`. `--dry-run` previews and validates +without writing; `--apply @file.json` reads the payload from a file. The command +prints the JSON result and exits `0` on success and `1` on a refused edit +(invalid or **stale** content — see below). + +A game's own committed artefacts are passed explicitly: + +```bash +npm run storylines:editor -- --content path/to/storylines.csv \ + --adapter ./my-game-adapter.ts \ + --apply '{"edit":{"kind":"update-card","spec":{"id":"evt-x","name":"X"}}, + "artefactPaths":{"json":"docs/dev/assets/storyline-manifest.json", + "mermaid":"docs/dev/assets/storyline-graph.mmd"}}' +``` + +After any write, run the game's artefact drift guards to confirm nothing is out +of step (`npm run storylines:graph -- --format json --check`, Mermaid +`--check`, and any game-side generated-doc `--check`). + +## 8. Concurrency guard and manual documentation + +- **Stale content is never clobbered.** The session captures the content + checksum at load; the writer compares its own read against that token and + refuses (HTTP 409, `stale-content`) when the CSV changed on disk since the + page loaded, returning a reload instruction instead of overwriting a + concurrent edit. +- **Manual documentation is reported, not rewritten.** Generated artefacts + (manifest/Mermaid) are regenerated automatically, but hand-written + documentation is not machine-generated. An edit touching a row that a + documented section describes by hand returns an explicit **"manual + documentation update required"** result naming the check that now fails. The + registry is `src/story-line/editor/manual-docs.ts`; the shipped entry is the + Gym demo's hand-maintained card catalogue. + +After an accepted edit the artefacts are regenerated and **verified** (the +post-write drift check re-reads them), so a "regenerated" claim is never +assumed. + +## 9. Troubleshooting + +| Symptom | Cause / fix | +|---|---| +| `unknown --adapter "…"` | Use a shipped id (`gym`) or a module path/specifier whose default/`adapter`/`contentAdapter` export is a `ContentAdapter`. Check the export name. | +| `cannot read ` | `--content` did not resolve; paths are relative to the core repo root. | +| The page loads but forms are disabled / a "no editable content" note | The host middleware is absent (e.g. Vite was run directly on `vite.editor.config.ts` without the editor host). Launch via `npm run storylines:editor`. | +| Apply is refused with `stale-content` | The CSV changed on disk since load (maybe a branch switch or an external edit). Reload the page and retry. | +| An edit is refused with a validator error | Read the report (and the inline-attributed field). Typical causes: `unknown-successor`, `unreachable-card`, malformed `options` encoding, `missing-storyline-id` on a new choice card. | +| Port already in use | `npm run storylines:editor -- --port 5180`. | +| The server won't bind | The host is loopback by default; `--host 0.0.0.0` is allowed but the tool is designed for local authoring. | + +## 10. Testing + +The editor's renderers are plain-DOM modules, so the core `browser` project +drives them **directly** in Vitest's real Chromium document — no second dev +server, page boot or game loop. See the "Testing" section of +[Core: Generic Story-Line System](../core-engine/story-line.md) for the full +list (`tests/story-line/editor/`): view-model fidelity against the manifest, +bridge/host/CLI behaviour, concurrency, preview-validate refusals, edit +round-trips, story-line batch writes, build exclusion and the manifest schema +guard. diff --git a/docs/gym/GYM_INDEX.md b/docs/gym/GYM_INDEX.md index d55967780..92dc2cde5 100644 --- a/docs/gym/GYM_INDEX.md +++ b/docs/gym/GYM_INDEX.md @@ -42,6 +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`; 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). 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/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/docs/ui-animations.md b/docs/ui-animations.md index 6b62c6c08..c5ae91294 100644 --- a/docs/ui-animations.md +++ b/docs/ui-animations.md @@ -11,6 +11,7 @@ The Tableau Card Engine provides reusable animation helpers in `src/ui/` for com | `discardCard` | Card discard animation with fade/shrink | 400ms | | `flipCard` | Two-phase card flip (scaleX → 0 → texture swap → scaleX → 1) | 300ms | | `moveGameObject` | Positional movement tween | 700ms | +| `shakeIllegalMove` | Illegal-move feedback: tint + shake, plus an optional transient reason toast | n/a (50ms × repeats) | | `createDragDropManager` | Reusable drag-and-drop lifecycle (dragstart/drag/dragend/drop, snap-back, drop-zone hit-testing) | n/a | ## dealCard @@ -204,6 +205,106 @@ import { moveGameObject } from '@ui/moveGameObject'; | `reducedMotion` | `boolean` | undefined | When true, target snaps to destination instantly | | `sfx` | `object` | undefined | Optional SFX configuration (start/move/end keys) | +## shakeIllegalMove (illegal-move feedback + reason toast) + +Shared illegal-move feedback routine: tint + horizontal shake + the +`sfx-illegal-move` SFX. Pass a `reason` and the routine **also** renders a +transient toast containing that text, so a refused move explains itself +instead of only shaking (CG-0MVPXFHBZ004LGEB). + +### Import + +```ts +import { shakeIllegalMove } from '@ui'; +``` + +> **Node-safe to import.** `shakeIllegalMove` — and the toast view it composes +> (`@ui/illegalMoveToastView`) plus `@ui/dragDrop`, which imports +> `shakeIllegalMove` — are free of Phaser **value** imports, so they load in a +> plain Node environment (no `window`, no browser). Downstream game unit suites +> import these deep paths safely; `tests/ui/phaser-free-imports.test.ts` guards +> the graph with a throwing `phaser` mock (CG-0MVQQ2M8E003FQMZ). Do **not** +> add a Phaser value import (or an import of a Phaser-bearing module like +> `@ui/Renderer`) to these helpers without updating that guard. The broader +> documented Phaser-free node surface is tracked in CG-0MVS9HBN3009HIQN. + +### Usage + +```ts +const result = rules.placeFigure(state, cardId, slotIndex); +if (!result.legal) { + shakeIllegalMove({ + scene: this, + target: cardContainer, // Image, Sprite or Container + reason: result.reason, // rules-layer copy, shown verbatim + }); +} +``` + +### Options (`ShakeIllegalMoveOptions`) + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `scene` | `Phaser.Scene` | required | Scene that owns the tween/toast | +| `target` | `Image \| Sprite \| Container \| null` | required | Target to shake; `null` still shows the reason toast | +| `tint` | `number` | `0xff4444` | Tint applied during the shake (tintable targets only) | +| `shakeDistance` | `number` | 5 | Horizontal offset per oscillation (px) | +| `duration` | `number` | 50 | Oscillation duration (ms) | +| `repeat` | `number` | 2 | Extra oscillation repeats | +| `ease` | `string` | `'Sine.inOut'` | Oscillation easing | +| `soundKey` | `string` | `sfx-illegal-move` | SFX key; `''` suppresses the sound | +| `reason` | `string` | — | Player-facing rejection text rendered as a toast (verbatim) | +| `reasonDwellMs` | `number` | 2500 | Toast dwell before auto-dismissal (ms) | +| `reducedMotion` | `boolean` | `getEffectiveReducedMotion()` | Skip the shake + SFX; the toast still renders | +| `onComplete` | `function` | undefined | Called after the shake (or immediately under reduced motion) | + +### Reason-toast behaviour + +- **Independent of the shake branch.** The toast is rendered *before* every + shake early-return, so it appears for tintable `Image`/`Sprite` targets, + non-tintable `Container` targets (position shake only), `null` targets, and + when reduced motion suppresses the shake entirely. +- **Non-blocking.** The toast is a plain non-interactive text object (built + inline via `scene.add.text` with the same style `createHudText` produced — + `FONT_FAMILY`, bold, colour, font size, centre-aligned, origin `(0.5, 0.5)`); + it never swallows pointer input aimed at cards, slots or buttons. +- **Depth.** `ILLEGAL_MOVE_TOAST_DEPTH = 1900` — above gameplay, the HUD + container (1000) and dialog overlays (200), below game-state overlays (2000: + win / loss / game-over). Styling lives in `src/ui/constants.ts` + (`ILLEGAL_MOVE_TOAST_*`); callers never position it. +- **Timing.** Dwell defaults to `ILLEGAL_MOVE_TOAST_DWELL_MS = 2500` and is + configurable per call via `reasonDwellMs` (or on + `IllegalMoveToastModel`/`showIllegalMoveToast`). A rejection inside the dwell + window **replaces** the text and restarts the timer — toasts never stack. +- **No animation.** The toast appears, holds for its full dwell and disappears + instantly in every mode (reduced motion included) — no slide, fade or scale, + and no per-frame tween left running after dismissal. +- **Ephemeral and scene-scoped.** It is never persisted and is not part of + undo/redo or the transcript; it is destroyed on scene `shutdown`/`destroy` so + it cannot leak across scenes. `dismissIllegalMoveToast(scene)` (or the + handle returned by `showIllegalMoveToast`) dismisses it early. +- **Text is verbatim.** The rules layer owns the wording (`LegalityResult + .reason`); the toast never re-words or truncates it (long reasons wrap at + `ILLEGAL_MOVE_TOAST_MAX_WIDTH`). + +### Direct API + +The underlying model is framework-free and separately exported: + +```ts +import { IllegalMoveToastModel } from '@ui'; + +const model = new IllegalMoveToastModel({ dwellMs: 3000 }); +model.show('That figure is already exhausted.'); +model.visible; // true +model.remaining; // 3000 +model.tick(3000); // true — expired, model hides itself +``` + +`showIllegalMoveToast({ scene, text, dwellMs? })` renders the toast directly +(returns an `IllegalMoveToastHandle` with `dismiss()`), and +`dismissIllegalMoveToast(scene)` dismisses any live toast early. + ## createDragDropManager (drag-and-drop lifecycle) Encapsulates the Phaser drag lifecycle into a single configurable core-engine @@ -278,6 +379,7 @@ draggable on every board render (`makeDraggable`). | `hitArea` | `Phaser.Geom.Rectangle` | texture frame | Local-coordinate hit area (required for Containers) | | `canPickUp` | `(payload) => boolean` | — | Pickup veto: `false` keeps the object at its origin and fires illegal feedback | | `onDrop` | `(payload) => void` | — | Called when dropped on an accepting zone | +| `illegalReason` | `(payload) => string \| undefined` | — | Rejection text for a pickup veto / invalid snap-back (rendered as a toast) | ### `DragDropZoneConfig` @@ -286,6 +388,7 @@ draggable on every board render (`makeDraggable`). | `zone` | `Phaser.GameObjects.Zone` | required | A drop zone (created via `setRectangleDropZone`) | | `data` | `unknown` | — | Caller zone data (e.g. slot index) via `payload.zoneData` | | `canAccept` | `(payload) => boolean` | — | Zone acceptance: `false` snap-backs + illegal feedback | +| `illegalReason` | `(payload) => string \| undefined` | — | Rejection text for this zone (wins over the draggable's resolver) | ### Behaviour @@ -304,9 +407,13 @@ draggable on every board render (`makeDraggable`). - **Invalid drop** (no zone, rejected `canAccept`, or released outside any zone): the object snap-backs to its captured origin (position **and** depth) with illegal feedback — the "illegal move" case. -- **Illegal feedback**: defaults to `shakeIllegalMove` (objects with `setTint`) - or a container-safe position shake + `safePlaySound` - (`COMMON_SFX_KEYS.ILLEGAL_MOVE` = `sfx-illegal-move`) for Containers. +- **Illegal feedback**: defaults to `shakeIllegalMove` for Images **and** + Containers (Containers get the position shake only), playing + `COMMON_SFX_KEYS.ILLEGAL_MOVE` (`sfx-illegal-move`). A reason supplied by the + draggable's or zone's `illegalReason` resolver (or set inline on + `payload.reason` inside `canPickUp`/`canAccept`) is rendered as the + transient reason toast described above; without a reason the behaviour is + unchanged. Custom `onIllegal` hooks receive `payload.reason` too. - **Reduced motion**: snap-back restores position/depth instantly (no tween); runtime toggling via `setReducedMotion()`. 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/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/content/storyline-graph.mmd b/example-games/gym/content/storyline-graph.mmd new file mode 100644 index 000000000..de66f26c2 --- /dev/null +++ b/example-games/gym/content/storyline-graph.mmd @@ -0,0 +1,15 @@ +%%{ init: { "flowchart": { "curve": "basis" } } }%% +flowchart TD + gym_story_end("New Beginning
gym-story-end
Gain 60 reputation
+60 reputation") + gym_story_ignore{"Neglected Roads
gym-story-ignore
Lose 80 coins
−80 coins"} + gym_story_invest{"Public Investment
gym-story-invest
Gain 120 coins
+120 coins"} + gym_story_start{"Town Meeting
gym-story-start
Lose 50 coins
−50 coins"} + + gym_story_ignore_Let_it_be_end(["end"]) + gym_story_ignore -->|Let it be| gym_story_ignore_Let_it_be_end + gym_story_ignore -->|Repair| gym_story_end + gym_story_invest -->|Celebrate| gym_story_end + gym_story_invest_Double_down_end(["end"]) + gym_story_invest -->|Double down| gym_story_invest_Double_down_end + gym_story_start -->|Ignore| gym_story_ignore + gym_story_start -->|Invest| gym_story_invest diff --git a/example-games/gym/content/storyline-manifest.json b/example-games/gym/content/storyline-manifest.json new file mode 100644 index 000000000..d7716e701 --- /dev/null +++ b/example-games/gym/content/storyline-manifest.json @@ -0,0 +1,176 @@ +{ + "version": 1, + "source": "Generated from the gym content CSV via the story-line content adapter (deterministic).", + "gameId": "gym", + "storylineIds": [ + "gym-story" + ], + "nodes": [ + { + "id": "gym-story-end", + "name": "New Beginning", + "storylineId": "gym-story", + "storylineTitle": "Gym Storyline", + "hasChoices": false, + "optionCount": 0, + "options": [], + "effect": "Gain 60 reputation", + "impact": "+60 reputation", + "fields": { + "trigger": "Incident", + "cost": "0", + "coinDelta": "0", + "reputationDelta": "60" + } + }, + { + "id": "gym-story-ignore", + "name": "Neglected Roads", + "storylineId": "gym-story", + "storylineTitle": "Gym Storyline", + "hasChoices": true, + "optionCount": 2, + "options": [ + { + "label": "Repair", + "successorIds": [ + "gym-story-end" + ], + "effectPolicy": "apply" + }, + { + "label": "Let it be", + "successorIds": [], + "effectPolicy": "apply" + } + ], + "effect": "Lose 80 coins", + "impact": "−80 coins", + "fields": { + "trigger": "Incident", + "cost": "0", + "coinDelta": "-80", + "reputationDelta": "0" + } + }, + { + "id": "gym-story-invest", + "name": "Public Investment", + "storylineId": "gym-story", + "storylineTitle": "Gym Storyline", + "hasChoices": true, + "optionCount": 2, + "options": [ + { + "label": "Celebrate", + "successorIds": [ + "gym-story-end" + ], + "effectPolicy": "apply" + }, + { + "label": "Double down", + "successorIds": [], + "effectPolicy": "apply" + } + ], + "effect": "Gain 120 coins", + "impact": "+120 coins", + "fields": { + "trigger": "Incident", + "cost": "0", + "coinDelta": "120", + "reputationDelta": "0" + } + }, + { + "id": "gym-story-start", + "name": "Town Meeting", + "storylineId": "gym-story", + "storylineTitle": "Gym Storyline", + "hasChoices": true, + "optionCount": 2, + "options": [ + { + "label": "Invest", + "successorIds": [ + "gym-story-invest" + ], + "effectPolicy": "apply" + }, + { + "label": "Ignore", + "successorIds": [ + "gym-story-ignore" + ], + "effectPolicy": "apply" + } + ], + "effect": "Lose 50 coins", + "impact": "−50 coins", + "fields": { + "trigger": "Incident", + "cost": "0", + "coinDelta": "-50", + "reputationDelta": "0" + } + } + ], + "edges": [ + { + "from": "gym-story-ignore", + "label": "Let it be", + "to": null, + "random": false, + "candidateIndex": 0, + "cycle": false, + "effectPolicy": "apply" + }, + { + "from": "gym-story-ignore", + "label": "Repair", + "to": "gym-story-end", + "random": false, + "candidateIndex": 0, + "cycle": false, + "effectPolicy": "apply" + }, + { + "from": "gym-story-invest", + "label": "Celebrate", + "to": "gym-story-end", + "random": false, + "candidateIndex": 0, + "cycle": false, + "effectPolicy": "apply" + }, + { + "from": "gym-story-invest", + "label": "Double down", + "to": null, + "random": false, + "candidateIndex": 0, + "cycle": false, + "effectPolicy": "apply" + }, + { + "from": "gym-story-start", + "label": "Ignore", + "to": "gym-story-ignore", + "random": false, + "candidateIndex": 0, + "cycle": false, + "effectPolicy": "apply" + }, + { + "from": "gym-story-start", + "label": "Invest", + "to": "gym-story-invest", + "random": false, + "candidateIndex": 0, + "cycle": false, + "effectPolicy": "apply" + } + ], + "cycles": [] +} diff --git a/example-games/gym/content/storylines.csv b/example-games/gym/content/storylines.csv new file mode 100644 index 000000000..3f9ad3706 --- /dev/null +++ b/example-games/gym/content/storylines.csv @@ -0,0 +1,5 @@ +id,name,effect,options,hasChoices,acceptNextCardId,rejectNextCardId,storylineId,storylineTitle,trigger,cost,coinDelta,reputationDelta +gym-story-start,Town Meeting,Lose 50 coins,Invest|gym-story-invest;Ignore|gym-story-ignore,false,,,gym-story,Gym Storyline,Incident,0,-50,0 +gym-story-invest,Public Investment,Gain 120 coins,Celebrate|gym-story-end;Double down|,false,,,gym-story,Gym Storyline,Incident,0,120,0 +gym-story-ignore,Neglected Roads,Lose 80 coins,Repair|gym-story-end;Let it be|,false,,,gym-story,Gym Storyline,Incident,0,-80,0 +gym-story-end,New Beginning,Gain 60 reputation,,false,,,gym-story,Gym Storyline,Incident,0,0,60 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..f98f68712 --- /dev/null +++ b/example-games/gym/scenes/GymStorylineScene.ts @@ -0,0 +1,238 @@ +/** + * 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 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 + */ + +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 { + 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'; + +const LOG_MAX_LINES = 6; + +export class GymStorylineScene extends GymSceneBase { + constructor() { + super({ key: GYM_STORYLINE_KEY }); + } + + 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; + 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; 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.demo = createDemoRuntime(); + + 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.demo = createDemoRuntime(); + this.finished = false; + this.pending = null; + this.currentCard = null; + this.clearOptionButtons(); + this.renderDraw(startDemo(this.demo)); + } + + private clearOptionButtons(): void { + for (const button of this.optionButtons) button.destroy(); + this.optionButtons = []; + } + + /** + * 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 = 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'}`); + + if (!draw.finished) { + this.renderOptions(card, draw.options); + } else { + 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 = applyDemoChoice( + this.system, + this.state, + card, + option, + this.pending, + ); + + // 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/package.json b/package.json index c141c4baf..eaf7f2a37 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tableau-card-engine", - "version": "0.1.21", + "version": "0.1.22", "description": "Tableau Card Engine (TCE) -- a modular game engine for building single-player tableau card games using Phaser 4 RC and TypeScript", "author": "TheWizardsCode", "private": true, @@ -22,6 +22,10 @@ "monte-carlo": "vite-node ../tce-main-street/src/scripts/monte-carlo.ts --seeds 200 --seed-prefix mc-balance --maxTurns 25 --strategy greedy --out results/main-street-monte-carlo.json --csv-out results/main-street-monte-carlo.csv", "monte-carlo-sweep": "vite-node ../tce-main-street/src/scripts/monte-carlo.ts --sweep --seeds 100 --seed-prefix mc-balance --maxTurns 25 --out results/sweep.json --csv-out results/sweep.csv", "replay": "tsx scripts/replay.ts", + "storylines:author": "tsx scripts/storyline-author.ts", + "storylines:editor": "tsx scripts/storyline-editor-cli.ts", + "storylines:graph": "tsx scripts/storyline-graph.ts", + "storylines:validate": "tsx scripts/validate-storylines.ts", "publish:repos": "tsx scripts/publish-repos.ts", "setup:distribution": "tsx scripts/setup-distribution.ts", "scaffold:game": "tsx scripts/game-repo-scaffold.ts", diff --git a/schemas/story-line-manifest.schema.json b/schemas/story-line-manifest.schema.json new file mode 100644 index 000000000..536dfd9e5 --- /dev/null +++ b/schemas/story-line-manifest.schema.json @@ -0,0 +1,140 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://tableau-card-engine.example/schemas/story-line-manifest.schema.json", + "title": "TCE Story-Line Content Manifest", + "description": "Game-agnostic, machine-readable manifest of a story-line content graph, generated from a game's content CSV through its content adapter (src/story-line/content/manifest.ts). Deterministic: sorted nodes/edges and no timestamps, so regeneration from the same content is byte-identical.", + "type": "object", + "required": ["version", "source", "gameId", "storylineIds", "nodes", "edges", "cycles"], + "additionalProperties": false, + "properties": { + "version": { + "type": "integer", + "minimum": 1, + "description": "Manifest schema version." + }, + "source": { + "type": "string", + "minLength": 1, + "description": "Generator provenance (deterministic — no timestamp)." + }, + "gameId": { + "type": "string", + "minLength": 1, + "description": "Game id of the content adapter that produced the manifest." + }, + "storylineIds": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "uniqueItems": true, + "description": "Sorted story-line ids present in the graph." + }, + "nodes": { + "type": "array", + "description": "Card nodes, sorted by base card id.", + "items": { "$ref": "#/definitions/node" } + }, + "edges": { + "type": "array", + "description": "Option edges, sorted by source, label, candidate index and target.", + "items": { "$ref": "#/definitions/edge" } + }, + "cycles": { + "type": "array", + "description": "Detected cycles (a first-class shape); each entry is a sorted cycle path of base card ids.", + "items": { + "type": "array", + "minItems": 1, + "items": { "type": "string", "minLength": 1 }, + "uniqueItems": true + } + } + }, + "definitions": { + "node": { + "type": "object", + "required": [ + "id", + "name", + "storylineId", + "storylineTitle", + "hasChoices", + "optionCount", + "options", + "effect", + "impact", + "fields" + ], + "additionalProperties": false, + "properties": { + "id": { "type": "string", "minLength": 1, "description": "Base card id." }, + "name": { "type": "string", "minLength": 1 }, + "storylineId": { "type": ["string", "null"] }, + "storylineTitle": { "type": ["string", "null"] }, + "hasChoices": { "type": "boolean" }, + "optionCount": { "type": "integer", "minimum": 0 }, + "options": { + "type": "array", + "description": "Ordered labelled option list; empty for plain incidents.", + "items": { "$ref": "#/definitions/option" } + }, + "effect": { "type": "string", "description": "Human-readable effect text shown to the player." }, + "impact": { + "type": "string", + "description": "Adapter-provided game impact description (e.g. '-50 coins'); empty when the adapter supplies none." + }, + "fields": { + "type": "object", + "description": "Every game-specific column for this card, keyed by CSV column name (raw cell text).", + "additionalProperties": { "type": "string" } + } + } + }, + "option": { + "type": "object", + "required": ["label", "successorIds", "effectPolicy"], + "additionalProperties": false, + "description": "One labelled player option of a choice node.", + "properties": { + "label": { "type": "string", "minLength": 1, "description": "Player-visible option label; also the stable transcript/save token." }, + "successorIds": { + "type": "array", + "description": "Ordered candidate successor base card ids; empty = terminal chain-end. The engine draws one uniformly at random at resolution time.", + "items": { "type": "string", "minLength": 1 } + }, + "effectPolicy": { + "type": "string", + "enum": ["apply", "skip"], + "description": "Whether the source card's own effect applies when this option is chosen." + } + } + }, + "edge": { + "type": "object", + "required": ["from", "label", "to", "random", "candidateIndex", "cycle", "effectPolicy"], + "additionalProperties": false, + "properties": { + "from": { "type": "string", "minLength": 1, "description": "Source base card id." }, + "label": { "type": "string", "minLength": 1, "description": "Option label." }, + "to": { + "type": ["string", "null"], + "description": "Target base card id (one edge per candidate successor); null when the chain ends." + }, + "random": { + "type": "boolean", + "description": "True when this edge is one of several random-branch candidates of the same option (candidate set size > 1)." + }, + "candidateIndex": { + "type": "integer", + "minimum": 0, + "description": "0-based index of this candidate within its option's ordered candidate set." + }, + "cycle": { "type": "boolean", "description": "True when this edge participates in a cycle." }, + "effectPolicy": { + "type": "string", + "enum": ["apply", "skip"], + "description": "Whether the source card's own effect applies when this option is chosen." + } + } + } + } +} 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..d3ec27d95 --- /dev/null +++ b/scripts/engine-sync.d.mts @@ -0,0 +1,108 @@ +/** + * 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; +} + +/** Whether the pin file has a value (i.e. this repo is pinned). */ +export declare function writePin(repoRoot: string, pinRel: string, sha: string): boolean; + +/** Stage the `core` gitlink and (when given) the pin file together. */ +export declare function stageEngineState( + repoRoot: string, + coreRel: string, + pinRel?: string | null, +): void; + +/** 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; + /** True when this run rewrote `configs/engine-pin.json` to the new HEAD. */ + pinWritten: boolean; + 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; pin travels. */ +export declare function syncEngine(opts: { + repoRoot: string; + coreRel?: string; + pinRel?: 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..7cda4c644 --- /dev/null +++ b/scripts/engine-sync.mjs @@ -0,0 +1,441 @@ +#!/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: + * + * **Pin policy (CG-0MVO268500071BPI):** the pin tracks the engine's tracked + * branch tip (the core `dev` tip). A pin that lags the tip is a genuine + * failure, and **the pin and the `./core` gitlink always travel together** — + * one actor, one moment, one commit. + * + * - **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 checkout moves + * and the repo has a `configs/engine-pin.json`, the pin is rewritten to the + * new HEAD and both the pin and the gitlink are staged together, so a + * commit can never record them divergent (MS-0MVMOH2N3000T63F). When the + * remote is unreachable (network failure) the build falls back to the + * current checkout with a warning instead of failing, and the pin is left + * untouched (risk note: offline builds keep working). Repos without a pin + * file are never given one. + * - **verify** (release builds, `npm run engine:verify`): refuse loudly when + * `./core` has local modifications, read `configs/engine-pin.json` (a + * single `sha` field), and compare the pinned SHA with the remote-resolved + * target (the tip of the branch `core` tracks). A pin that lags the tip + * and a pin that does not resolve on the tracked branch both fail, with + * distinct, actionable diagnoses (lag → re-pin; foreign → fix the pin). + * On success the exact pinned SHA is checked out. Release builds require + * the remote to be reachable so the pin can be compared. + * + * 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 ./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`) 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 ] + */ + +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; +} + +/** + * Rewrite the engine pin file to `sha`, preserving any other fields already + * present. Returns true when the file's bytes changed. + * + * The pin contract is a single `sha` field; preservation only matters for + * repos that have added metadata alongside it. The file is normalised to + * two-space JSON with a trailing newline so a re-written pin is stable. + */ +export function writePin(repoRoot, pinRel, sha) { + const pinPath = path.join(repoRoot, pinRel); + let data = {}; + try { + const parsed = JSON.parse(fs.readFileSync(pinPath, 'utf-8')); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) data = parsed; + } catch { + // Missing/malformed pin: write a fresh single-field pin. + } + const next = `${JSON.stringify({ ...data, sha }, null, 2)}\n`; + const prev = fs.existsSync(pinPath) ? fs.readFileSync(pinPath, 'utf-8') : null; + fs.mkdirSync(path.dirname(pinPath), { recursive: true }); + fs.writeFileSync(pinPath, next); + return prev !== next; +} + +/** + * Stage the engine state (`./core` gitlink and, when present, the pin file) + * so both are recorded by one commit — the "pin and gitlink travel together" + * invariant. + */ +export function stageEngineState(repoRoot, coreRel, pinRel) { + const paths = pinRel ? [coreRel, pinRel] : [coreRel]; + const res = runGit(repoRoot, ['add', '--', ...paths]); + if (!res.ok) { + throw new EngineSyncError( + `engine:sync: could not stage ${paths.join(' + ')}:\n${res.stderr}\n` + + `Commit the ${coreRel} gitlink and the engine pin together; if the ` + + `index is locked, resolve that (e.g. a stale index.lock) and rerun.`, + ); + } +} + +/** 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, then rewrite the pin (when the repo has one) to the new HEAD and + * stage the pin and the gitlink together. On network failure, warn and fall + * back to the current checkout (exit 0) per the documented offline-build + * assumption; the pin is left untouched because nothing moved. + * + * @returns {{ updated: boolean, from: string, to: string, pinWritten: boolean, warning?: string }} + */ +export function syncEngine({ repoRoot, coreRel = DEFAULT_CORE_REL, pinRel = DEFAULT_PIN_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 ?? '', + pinWritten: false, + 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'); + if (!after) { + throw new EngineSyncError( + `engine:sync: could not resolve ${coreRel}'s HEAD after the update — inspect the submodule and retry.`, + ); + } + log(`engine:sync: ${coreRel} updated ${(before ?? '').slice(0, 10)} → ${after.slice(0, 10)}`); + const updated = before !== after; + + // Pin travels with the gitlink. Repos with no pin file stay unpinned — sync + // must never invent one (a spurious file would be committed by accident). + const pinPath = path.join(repoRoot, pinRel); + if (!fs.existsSync(pinPath)) { + log(`engine:sync: no engine pin at ${pinRel} — leaving the repo unpinned.`); + return { updated, from: before ?? '', to: after, pinWritten: false }; + } + let pinFull = null; + try { + pinFull = resolveShaFull(repoRoot, coreRel, readPin(repoRoot, pinRel)); + } catch { + pinFull = null; // malformed pin → rewrite it to the synced HEAD + } + const pinWritten = pinFull !== after; + if (pinWritten) { + writePin(repoRoot, pinRel, after); + log( + `engine:sync: engine pin ${pinRel} ${(pinFull ?? '(unreadable)').slice(0, 10)} → ` + + `${after.slice(0, 10)} (staged with the ${coreRel} gitlink)`, + ); + } + if (updated || pinWritten) stageEngineState(repoRoot, coreRel, pinRel); + return { updated, from: before ?? '', to: after, pinWritten }; +} + +/** + * 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); + const branch = resolveTrackedBranch(repoRoot, coreRel) || 'HEAD'; + const rePinHint = + `Re-pin: set ${pinRel} to {"sha": "${target}"} and commit it together with ` + + `the ${coreRel} gitlink — the pin and the gitlink travel together.`; + if (pinnedFull !== target) { + // Lag: the pin resolves on the tracked branch but sits behind its tip. + // Bad/foreign pin: it does not resolve on the tracked branch at all. + const isAncestor = + pinnedFull !== null && + runGit(corePath(repoRoot, coreRel), ['merge-base', '--is-ancestor', pinnedFull, target]).ok; + if (isAncestor) { + const behind = runGit(corePath(repoRoot, coreRel), [ + 'rev-list', + '--count', + `${pinnedFull}..${target}`, + ]).stdout; + const count = behind || '?'; + throw new EngineSyncError( + `engine:verify: PIN MISMATCH — the engine pin is an ancestor of the tracked ` + + `${branch} tip by ${count} commit(s).\n` + + ` pinned: ${pinnedFull} (${pinRel})\n` + + ` tip: ${target} (${branch})\n` + + `${rePinHint}`, + ); + } + throw new EngineSyncError( + `engine:verify: PIN MISMATCH — the pinned engine ${pinnedFull ?? pinned} (${pinRel}) ` + + `does not resolve on the tracked branch ${branch} (tip ${target}).\n` + + `This is a bad or foreign pin: it is not an ancestor of the tracked tip.\n` + + `${rePinHint}`, + ); + } + // 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, pinRel }); + 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..892792a6d 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}". ` + @@ -220,8 +284,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', @@ -256,7 +329,7 @@ import { gameDiscoveryPlugin, resolveCoreAliases, } from '${coreRel}/scripts/vite-game-discovery-plugin'; -import { DEV_WATCH_IGNORE_PATTERNS } from '${coreRel}/scripts/vite-transcript-plugin'; +import { transcriptPersistPlugin, DEV_WATCH_IGNORE_PATTERNS } from '${coreRel}/scripts/vite-transcript-plugin'; const coreRoot = path.resolve(__dirname, '${coreRel}'); const pkg = JSON.parse( @@ -289,7 +362,7 @@ const presetEnv = { GAMES_CONFIG: process.env.GAMES_CONFIG ?? '${DEFAULT_GAME_PRESET}', }; -export default defineConfig(({ mode }) => ({ +export default defineConfig(({ command, mode }) => ({ define: { __APP_VERSION__: JSON.stringify(pkg.version), }, @@ -300,9 +373,21 @@ export default defineConfig(({ mode }) => ({ // Reads ./configs/${DEFAULT_GAME_PRESET}.json and generates // 'virtual:game-registry' (Gym from the core + this one game). gameDiscoveryPlugin({ projectRoot: __dirname, coreRoot, env: presetEnv }), + // Persist transcripts to data/transcripts// during a normal dev + // session: this is the middleware behind POST /api/transcripts, so without + // it every game-over 404s in the browser console. Dev-serve only, and never + // under Vitest (the plugin writes to disk — see the core docs/DEVELOPER.md + // section on Transcript Persistence). Mirrors core/vite.config.ts. + ...(command === 'serve' && !process.env.VITEST ? [transcriptPersistPlugin()] : []), ], resolve: { alias: resolveCoreAliases(coreRoot), + // Shared runtime deps must resolve to exactly ONE copy (phaser, tone): a + // second copy (e.g. an npm install inside the core submodule) becomes a + // distinct module id and the Node-env unit tests' vi.mock('phaser') stops + // applying, evaluating real Phaser on collection. Mirrors the + // core/vite.config.ts dedupe. + dedupe: ['phaser', 'tone'], }, build: { outDir: 'dist', @@ -379,6 +464,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/*`], @@ -722,6 +809,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); @@ -743,7 +840,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( @@ -813,6 +910,8 @@ export function scaffoldGameRepo(options: ScaffoldOptions): ScaffoldResult { rewritten, assetLinks, publicLinks, + version, + versionSource, }; } @@ -826,6 +925,7 @@ interface CliArgs { reposDir?: string; all?: boolean; layout?: string; + version?: string; } function parseArgs(argv: string[]): CliArgs { @@ -850,6 +950,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; @@ -885,6 +989,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.', @@ -939,11 +1045,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/scripts/run-ci-tests.sh b/scripts/run-ci-tests.sh index 47e39741f..6ca826c75 100755 --- a/scripts/run-ci-tests.sh +++ b/scripts/run-ci-tests.sh @@ -12,22 +12,38 @@ # npm run test:smoke # ~2 min, one representative test per game # npm run test:dev # ~3.5 min, smoke + key E2E per game # +# The unit and browser steps opt into the runner's project-coverage gate +# (--expect-project-coverage, CG-0MVL21CVM003VK5H): before the run the runner +# derives the expected test-file set from a collection-only +# `vitest list --filesOnly --json` over the same project, captures the files +# each attempt actually executed (injected JSON reporter), and fails the stage +# if the sets differ. A run that silently dropped part of the suite (e.g. a +# Vitest group-loop abort after a pool rejection) can no longer be accepted as +# green via the transient path. A pre-flight collection failure fails closed +# (exit 2). +# # The unit and browser steps run through scripts/vitest-run-with-retry.ts, which # retries once on Vitest's transient contention-induced failures # ([vitest-worker]: Timeout calling "onTaskUpdate" for the worker RPC layer, # [vitest] Browser connection was closed while running tests for the browser-mode -# WebSocket drop) — a non-zero exit that can happen even when every test passed. -# The retry is masked against genuine failures (see that script's -# shouldRetryOnce): it qualifies when every file passed, or when the summary -# reports failures but every error is attributable to the transient signature -# (the failed-file variant, CG-0MUIMM28K001W88F). If the retry is ALSO a pure -# transient failure the run is accepted as green — no genuine failure was -# masked (CG-0MUF0LU4X006IXXU). See CG-0MS9M5UJP005PWD3 and -# CG-0MSCI73RH004VPCE. +# WebSocket drop, and "Failed to fetch dynamically imported module" for the Vite +# dev server failing to serve a test module under load) — a non-zero exit that +# can happen even when every test passed. The retry is masked against genuine +# failures (see that script's shouldRetryOnce): it qualifies when every file +# passed, or when the summary reports failures but every error is attributable +# to the transient signature (the failed-file variant, CG-0MUIMM28K001W88F). If +# the retry is ALSO a pure transient failure of one of the two original +# signatures the run is accepted as green — no genuine failure was masked +# (CG-0MUF0LU4X006IXXU). A module-fetch failure is deliberately RETRY-ONLY: a +# genuine module-resolution regression is deterministic, so a both-attempts +# module-fetch run keeps the retry's own failing status +# (CG-0MVNDK56G007C1U7). See CG-0MS9M5UJP005PWD3, CG-0MSCI73RH004VPCE and +# CG-0MVNDK56G007C1U7. # # The runner retries once when a run is attributable to the transient # contention signatures and emits a final -# `[vitest-runner] attempts=N status=S outcome=...` line after every run. +# `[vitest-runner] attempts=N status=S outcome=... expected=N ran=M` line after +# every run (expected=unset when no expectation was supplied). # Stage output is NOT truncated (CG-0MUIMM28K001W88F), so a recurrence names # the failed test/file and shows any distinct assertion error alongside the # transient signature instead of only the reporter's summary counts. @@ -62,11 +78,11 @@ npx tsx scripts/check-browser-test-env.ts echo "" echo "=== Unit Tests ===" -npx tsx scripts/vitest-run-with-retry.ts --project unit --timeout-ms 300000 2>&1 +npx tsx scripts/vitest-run-with-retry.ts --expect-project-coverage --project unit --timeout-ms 300000 2>&1 echo "" echo "=== Browser Tests (non-tutorial) ===" -npx tsx scripts/vitest-run-with-retry.ts --project browser --timeout-ms 1200000 2>&1 +npx tsx scripts/vitest-run-with-retry.ts --expect-project-coverage --project browser --timeout-ms 1200000 2>&1 echo "" echo "=== Tutorial E2E Tests ===" diff --git a/scripts/storyline-author.ts b/scripts/storyline-author.ts new file mode 100644 index 000000000..f422e6257 --- /dev/null +++ b/scripts/storyline-author.ts @@ -0,0 +1,204 @@ +#!/usr/bin/env node +/** + * Story-line content authoring CLI. + * + * Safe, self-validating content edits through the **single** transactional + * author API (`src/story-line/content/author.ts`): + * + * - `add-card` appends a new card row (correct column count, no duplicate + * id) and validates the resulting content. + * - `update-card` edits an existing card (name, effect, choice links, options + * encoding, story-line id/title and game columns). + * + * Every edit is built and validated **in memory** first; an invalid result + * leaves the content file byte-identical and exits non-zero. `--dry-run` + * prints the candidate content without writing it. + * + * ```bash + * npm run storylines:author -- add-card --id evt-x --name "X" --effect "…" \ + * --options "Invest|evt-y;Walk away|" --storyline-id storyline-x \ + * --storyline-title "X Arc" + * npm run storylines:author -- update-card --id evt-x --storyline-id storyline-y + * npm run storylines:author -- add-card --id evt-z --name "Z" --dry-run + * ``` + * + * Runs under `tsx`; nothing here imports a game or Phaser. + * + * @module + */ + +import { pathToFileURL } from 'node:url'; +import { resolve } from 'node:path'; + +import { + DEFAULT_CONTENT_PATH, + resolveContentAdapter, +} from '../src/story-line/content/adapters'; +import { + addContentCard, + applyContentEditToFile, + updateContentCard, + type AddContentCardSpec, + type ContentEditResult, + type UpdateContentCardSpec, +} from '../src/story-line/content/author'; +import { + booleanFlag, + nullableFlag, + parseArgv, +} from './storyline-cli-support'; + +/** CLI usage text. */ +export const AUTHOR_USAGE = `Transactional story-line content authoring. + +Usage: + npx tsx scripts/storyline-author.ts add-card [options] + npx tsx scripts/storyline-author.ts update-card [options] + +Common options: + --csv Content CSV (default: ${DEFAULT_CONTENT_PATH}) + --game Content adapter id (default: gym) + --dry-run Validate and print the candidate; write nothing + --field Set a game-specific column (repeatable) + --help Show this help + +add-card options: + --id --name [--effect ] [--options ] + [--has-choices] [--accept-next ] [--reject-next ] + [--storyline-id ] [--storyline-title ] + +update-card options: + --id <id> [--name <name>] [--effect <text>] [--options <encoding>] + [--clear-options] [--has-choices true|false] + [--accept-next <id> | --clear-accept-next] + [--reject-next <id> | --clear-reject-next] + [--storyline-id <id> | --clear-storyline] + [--storyline-title <title>] + +Exit codes: 0 = applied (or dry-run valid), 1 = refused (invalid content).`; + +/** Parsed author CLI invocation. */ +export type AuthorCliOptions = + | { readonly command: 'add-card'; readonly csvPath: string; readonly gameId?: string; readonly dryRun: boolean; readonly spec: AddContentCardSpec } + | { readonly command: 'update-card'; readonly csvPath: string; readonly gameId?: string; readonly dryRun: boolean; readonly spec: UpdateContentCardSpec } + | { readonly command: 'help' } + | { readonly error: string }; + +/** + * Parses author argv into an invocation, a help request, or an error. + * + * @param argv Raw arguments. + * @returns The parsed invocation. + */ +export function parseAuthorCliArgs(argv: readonly string[]): AuthorCliOptions { + const { flags, positionals, fieldPairs } = parseArgv(argv); + if (flags.has('help')) return { command: 'help' }; + if (flags.has('field-error')) { + return { error: `--field expects <column=value>, got "${flags.get('field-error')}"` }; + } + const command = positionals[0]; + if (command !== 'add-card' && command !== 'update-card') { + return { error: 'expected a command: add-card | update-card' }; + } + + const csvPath = resolve(process.cwd(), flags.get('csv') ?? DEFAULT_CONTENT_PATH); + const gameId = flags.get('game'); + const dryRun = flags.has('dry-run'); + + const fields: Record<string, string> = {}; + for (const [column, value] of fieldPairs) fields[column] = value; + + const optionalText = (key: string): string | undefined => flags.get(key); + + if (command === 'add-card') { + const spec: AddContentCardSpec = { + id: flags.get('id') ?? '', + name: flags.get('name') ?? '', + effect: optionalText('effect'), + hasChoices: booleanFlag(flags, 'has-choices'), + acceptNextCardId: nullableFlag(flags, 'accept-next', 'clear-accept-next'), + rejectNextCardId: nullableFlag(flags, 'reject-next', 'clear-reject-next'), + options: optionalText('options'), + storylineId: nullableFlag(flags, 'storyline-id', 'clear-storyline'), + storylineTitle: nullableFlag(flags, 'storyline-title', 'clear-storyline-title'), + fields, + }; + return { command, csvPath, gameId, dryRun, spec }; + } + + const spec: UpdateContentCardSpec = { + id: flags.get('id') ?? '', + name: optionalText('name'), + effect: optionalText('effect'), + hasChoices: booleanFlag(flags, 'has-choices'), + acceptNextCardId: nullableFlag(flags, 'accept-next', 'clear-accept-next'), + rejectNextCardId: nullableFlag(flags, 'reject-next', 'clear-reject-next'), + options: nullableFlag(flags, 'options', 'clear-options'), + storylineId: nullableFlag(flags, 'storyline-id', 'clear-storyline'), + storylineTitle: nullableFlag(flags, 'storyline-title', 'clear-storyline-title'), + fields, + }; + return { command, csvPath, gameId, dryRun, spec }; +} + +/** + * Runs the authoring CLI. + * + * @param argv Raw arguments (defaults to `process.argv.slice(2)`). + * @returns Exit code: 0 = applied or valid dry-run, 1 = refused. + */ +export function runStorylineAuthorCli( + argv: readonly string[] = process.argv.slice(2), +): number { + const parsed = parseAuthorCliArgs(argv); + if ('error' in parsed) { + process.stderr.write(`storyline-author: ${parsed.error}\n`); + return 1; + } + if (parsed.command === 'help') { + process.stdout.write(`${AUTHOR_USAGE}\n`); + return 0; + } + + const adapter = resolveContentAdapter(parsed.gameId); + if (!adapter) { + process.stderr.write( + `storyline-author: unknown --game "${parsed.gameId ?? ''}" (shipped adapters: gym)\n`, + ); + return 1; + } + + const mutate = (csv: string): ContentEditResult => + parsed.command === 'add-card' + ? addContentCard(csv, adapter, parsed.spec) + : updateContentCard(csv, adapter, parsed.spec); + + const result = applyContentEditToFile({ + contentPath: parsed.csvPath, + adapter, + mutate, + dryRun: parsed.dryRun, + }); + + if (!result.ok) { + process.stderr.write('storyline-author: refusing to write — validation failed:\n'); + for (const error of result.errors) process.stderr.write(` - ${error}\n`); + return 1; + } + + if (parsed.dryRun) { + process.stdout.write( + `storyline-author: dry-run OK (${parsed.command}); no file written\n`, + ); + process.stdout.write(result.csv); + } else { + process.stdout.write( + `storyline-author: ${parsed.command} applied to ${parsed.csvPath}\n`, + ); + } + return 0; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + process.exitCode = runStorylineAuthorCli(); +} diff --git a/scripts/storyline-cli-support.ts b/scripts/storyline-cli-support.ts new file mode 100644 index 000000000..e26a44336 --- /dev/null +++ b/scripts/storyline-cli-support.ts @@ -0,0 +1,78 @@ +/** + * Shared argument handling for the story-line authoring CLIs. + * + * Kept in one place so the three `tsx` entry points (`storyline-author.ts`, + * `storyline-graph.ts`, `validate-storylines.ts`) share identical, predictable + * parsing without duplicating it three times. + * + * @module + */ + +/** A parsed argv: `--flag value` pairs plus bare positionals. */ +export interface ParsedArgv { + /** Flag map; a flag without a value reads as `'true'`. */ + readonly flags: ReadonlyMap<string, string>; + /** Positional arguments in order (e.g. the author command). */ + readonly positionals: readonly string[]; + /** Every `--field key=value` occurrence, in order. */ + readonly fieldPairs: readonly (readonly [string, string])[]; +} + +/** + * Parses argv into flags, positionals and repeated `--field` pairs. + * + * @param argv Raw arguments (without the node/script prefix). + * @returns The parsed arguments. + */ +export function parseArgv(argv: readonly string[]): ParsedArgv { + const flags = new Map<string, string>(); + const positionals: string[] = []; + const fieldPairs: (readonly [string, string])[] = []; + + for (let i = 0; i < argv.length; i++) { + const token = argv[i]; + if (!token.startsWith('--')) { + positionals.push(token); + continue; + } + const key = token.slice(2); + const next = argv[i + 1]; + const hasValue = next !== undefined && !next.startsWith('--'); + const value = hasValue ? (next as string) : 'true'; + if (hasValue) i++; + + if (key === 'field') { + const separator = value.indexOf('='); + if (separator <= 0) { + flags.set('field-error', value); + continue; + } + fieldPairs.push([value.slice(0, separator), value.slice(separator + 1)]); + continue; + } + flags.set(key, value); + } + + return { flags, positionals, fieldPairs }; +} + +/** Reads a nullable id flag: `undefined` when absent, `null` when cleared. */ +export function nullableFlag( + flags: ReadonlyMap<string, string>, + valueKey: string, + clearKey: string, +): string | null | undefined { + if (flags.has(clearKey)) return null; + const value = flags.get(valueKey); + return value === undefined ? undefined : value; +} + +/** Reads a boolean flag (`--flag`, `--flag true`, `--flag false`). */ +export function booleanFlag( + flags: ReadonlyMap<string, string>, + key: string, +): boolean | undefined { + if (!flags.has(key)) return undefined; + const value = flags.get(key); + return value !== 'false' && value !== '0' && value !== 'no'; +} diff --git a/scripts/storyline-editor-cli.ts b/scripts/storyline-editor-cli.ts new file mode 100644 index 000000000..4a7c9cecf --- /dev/null +++ b/scripts/storyline-editor-cli.ts @@ -0,0 +1,533 @@ +#!/usr/bin/env node +/** + * Story-line editor CLI — the single launch point for the editor. + * + * ```bash + * npm run storylines:editor # Gym content, http://127.0.0.1:5174 + * npm run storylines:editor -- --port 5180 + * npm run storylines:editor -- --content path/to/storylines.csv + * npm run storylines:editor -- --adapter ./my-game-adapter.ts + * npm run storylines:editor -- --apply '{"edit":{"kind":"update-card","spec":{...}}}' + * npm run storylines:editor -- --help + * ``` + * + * The CLI resolves a game's **content adapter** (a shipped id such as `gym`, + * or a module exporting one), loads the content CSV into an **editor session** + * (the snapshot plus its concurrency checksum) and either + * + * - boots the editor host with that session (manifest + preview/validate/apply + * endpoints), or + * - with `--apply`, runs the same write bridge headlessly and exits non-zero + * when the edit is refused (invalid content, or content that changed on disk + * since load) — scripted/CI edits go through the same single write path. + * + * `tsx` is the core tooling runner (no Vite loader), and the content CSV is + * only ever written by the core author API: the host serves the manifest from + * memory, and the CLI's apply mode delegates to the bridge. + * + * @module + */ + +import { readFileSync } from 'node:fs'; +import { isAbsolute, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import { + DEFAULT_CONTENT_ADAPTER, + DEFAULT_CONTENT_PATH, + getContentAdapter, +} from '../src/story-line/content/adapters'; +import { applyEdit, EDITOR_EDIT_KINDS, type EditorEdit } from '../src/story-line/editor/api'; +import { + buildStorylineManifestFromCsv, + type StorylineManifest, +} from '../src/story-line/content/manifest'; +import type { ContentAdapter } from '../src/story-line/content/types'; +import { parseArgv } from './storyline-cli-support'; +import { EDITOR_SERVER_PORT } from '../vite.editor.config'; +import { + createEditorHost, + DEFAULT_EDITOR_HOST, + loadEditorSession, + type EditorHost, +} from './storyline-editor-server'; + +/** CLI usage text. */ +export const EDITOR_USAGE = `Open the story-line editor (a local dev tool) or apply one edit headlessly. + +Usage: + npm run storylines:editor -- [options] + +Options: + --content <path> Content CSV to load (default: ${DEFAULT_CONTENT_PATH}) + --adapter <module> Content adapter: a shipped id (default: gym) or a module + path/specifier exporting a ContentAdapter as its default, + \`adapter\` or \`contentAdapter\` export + --port <number> Dev-server port (default: ${EDITOR_SERVER_PORT}) + --host <host> Bind host (default: ${DEFAULT_EDITOR_HOST}; loopback only) + --apply <json|@file> Apply one edit through the write bridge, print the JSON + result and exit (no dev server). The JSON is + { "edit": { "kind": ..., "spec": { ... } }, + "expectedChecksum": "<hex, optional>", + "dryRun": false, "artefactPaths": { + "json": ..., "mermaid": ... } } + --dry-run With --apply: preview and validate without writing + --help Show this help + +Exit codes: 0 = serving / applied / help shown, 1 = bad option, unknown adapter, +unreadable content, host failure or a refused edit (invalid or stale content).`.trim(); + +/** Loads a module specifier (a real dynamic `import()` by default). */ +export type AdapterImporter = (specifier: string) => Promise<unknown>; + +/** Parsed editor CLI options. */ +export interface StorylineEditorCliOptions { + /** True when `--help` was requested. */ + readonly help: boolean; + /** Dev-server port. */ + readonly port: number; + /** Bind host. */ + readonly host: string; + /** Content CSV path, as written on the command line. */ + readonly contentPath: string; + /** Raw `--adapter` value (a shipped id or a module specifier). */ + readonly adapterRef: string | undefined; + /** Raw `--apply` value (inline JSON or `@file`), undefined when serving. */ + readonly apply: string | undefined; + /** With `--apply`: preview and validate, write nothing. */ + readonly dryRun: boolean; +} + +/** Optional seams for tests. */ +export interface StorylineEditorCliDeps { + /** Overrides the dynamic `import()` used to load an adapter module. */ + readonly importer?: AdapterImporter; +} + +/** The default adapter importer (a real dynamic ESM import). */ +const defaultImporter: AdapterImporter = (specifier) => import(specifier); + +/** + * Parses editor argv. + * + * @param argv Raw arguments (without the node/script prefix). + * @returns The parsed options. + * @throws Error when `--port` is not a valid TCP port. + */ +export function parseStorylineEditorArgs(argv: readonly string[]): StorylineEditorCliOptions { + const { flags } = parseArgv(argv); + + const portRaw = flags.get('port'); + let port = EDITOR_SERVER_PORT; + if (portRaw !== undefined) { + port = Number(portRaw); + if (!Number.isInteger(port) || port < 0 || port > 65535) { + throw new Error(`--port must be an integer between 0 and 65535 (got "${portRaw}")`); + } + } + + return { + help: flags.has('help'), + port, + host: flags.get('host') ?? DEFAULT_EDITOR_HOST, + contentPath: flags.get('content') ?? DEFAULT_CONTENT_PATH, + adapterRef: flags.get('adapter'), + apply: flags.get('apply'), + dryRun: flags.has('dry-run'), + }; +} + +/** + * Resolves an adapter module reference to a specifier `import()` accepts. + * + * A relative/absolute path (or a file with a script extension) becomes a file + * URL; a bare specifier (including a scoped package) is returned unchanged. + */ +export function resolveAdapterSpecifier(ref: string, baseDir: string): string { + const looksLikePath = + ref.startsWith('.') || + ref.startsWith('/') || + /^[A-Za-z]:[\\/]/.test(ref) || + /\.(?:ts|tsx|mts|cts|js|mjs|cjs)$/.test(ref); + if (!looksLikePath) return ref; + return pathToFileURL(isAbsolute(ref) ? ref : resolve(baseDir, ref)).href; +} + +/** Type guard for a structurally valid {@link ContentAdapter}. */ +export function isContentAdapter(value: unknown): value is ContentAdapter { + if (typeof value !== 'object' || value === null) return false; + const candidate = value as { + gameId?: unknown; + columns?: unknown; + fields?: { id?: unknown; name?: unknown } | null; + }; + return ( + typeof candidate.gameId === 'string' && + candidate.gameId !== '' && + Array.isArray(candidate.columns) && + typeof candidate.fields === 'object' && + candidate.fields !== null && + typeof candidate.fields.id === 'string' && + candidate.fields.id !== '' && + typeof candidate.fields.name === 'string' && + candidate.fields.name !== '' + ); +} + +/** Extracts an adapter from a loaded module's exports. */ +function extractAdapter(module: unknown): unknown { + if (typeof module !== 'object' || module === null) return undefined; + const record = module as Record<string, unknown>; + return record.default ?? record.adapter ?? record.contentAdapter; +} + +/** + * Resolves the editor's content adapter. + * + * @param adapterRef Shipped adapter id or module specifier (`undefined` → + * the shipped default adapter). + * @param importer Dynamic importer (overridable in tests). + * @param baseDir Directory relative adapter paths resolve against + * (defaults to `process.cwd()`). + * @returns The adapter, or `undefined` when it cannot be resolved. + */ +export async function resolveEditorAdapter( + adapterRef?: string, + importer: AdapterImporter = defaultImporter, + baseDir: string = process.cwd(), +): Promise<ContentAdapter | undefined> { + if (adapterRef === undefined || adapterRef === '') return DEFAULT_CONTENT_ADAPTER; + + const shipped = getContentAdapter(adapterRef); + if (shipped) return shipped; + + let loaded: unknown; + try { + loaded = await importer(resolveAdapterSpecifier(adapterRef, baseDir)); + } catch { + return undefined; + } + + const adapter = extractAdapter(loaded); + return isContentAdapter(adapter) ? adapter : undefined; +} + +/** A manifest loaded from a content CSV. */ +export interface LoadedEditorManifest { + /** The generated manifest. */ + readonly manifest: StorylineManifest; + /** Absolute path of the CSV it was generated from. */ + readonly csvPath: string; +} + +/** + * Reads a content CSV and builds its manifest through the content adapter. + * + * @param contentPath CSV path (relative paths resolve against `baseDir`). + * @param adapter The game content adapter. + * @param baseDir Base directory (defaults to `process.cwd()`). + * @returns The manifest plus the resolved CSV path. + * @throws Error with an actionable message when the file is unreadable. + */ +export function loadEditorManifest( + contentPath: string, + adapter: ContentAdapter, + baseDir: string = process.cwd(), +): LoadedEditorManifest { + const csvPath = isAbsolute(contentPath) ? contentPath : resolve(baseDir, contentPath); + let csv: string; + try { + csv = readFileSync(csvPath, 'utf-8'); + } catch (error) { + throw new Error(`cannot read ${csvPath}: ${(error as Error).message}`); + } + return { manifest: buildStorylineManifestFromCsv(csv, adapter), csvPath }; +} + +/** The running editor, or a user-facing failure message. */ +export type EditorLaunchResult = + | { readonly ok: true; readonly host: EditorHost } + | { readonly ok: false; readonly code: number; readonly message: string }; + +/** + * Launches the editor host and returns it, or a failure describing why not. + * + * Kept separate from {@link runStorylineEditorCli} so tests can assert the + * failure paths without binding a port. + */ +export async function launchEditor( + options: StorylineEditorCliOptions, + deps: StorylineEditorCliDeps = {}, +): Promise<EditorLaunchResult> { + const adapter = await resolveEditorAdapter(options.adapterRef, deps.importer); + if (!adapter) { + return { + ok: false, + code: 1, + message: + `unknown --adapter "${options.adapterRef ?? ''}" — use a shipped adapter id ` + + `(gym) or a module exporting a ContentAdapter`, + }; + } + + let session; + try { + session = loadEditorSession({ contentPath: options.contentPath, adapter }); + } catch (error) { + return { ok: false, code: 1, message: (error as Error).message }; + } + + try { + const host = await createEditorHost({ + session, + port: options.port, + host: options.host, + }); + return { ok: true, host }; + } catch (error) { + return { + ok: false, + code: 1, + message: `cannot start the editor host: ${(error as Error).message}`, + }; + } +} + +// ── Headless apply (the same bridge, no dev server) ───────── + +/** A parsed `--apply` payload. */ +export interface EditorApplyRequest { + /** The edit to apply. */ + readonly edit: EditorEdit; + /** Optional explicit concurrency token (defaults to the load-time checksum). */ + readonly expectedChecksum?: string; + /** When true, preview and validate without writing. */ + readonly dryRun?: boolean; + /** Optional generated-artefact paths (defaults to the committed Gym artefacts). */ + readonly artefactPaths?: { readonly json: string; readonly mermaid: string }; +} + +/** + * Parses an `--apply` payload (inline JSON, or `@path` to a JSON file). + * + * @param raw The `--apply` value from argv. + * @param baseDir Base directory for an `@path` (default `process.cwd()`). + * @returns The parsed request, or an actionable error. + */ +export function parseEditorApplyRequest( + raw: string, + baseDir: string = process.cwd(), +): { readonly request: EditorApplyRequest } | { readonly error: string } { + let text = raw; + if (raw.startsWith('@')) { + const file = resolve(baseDir, raw.slice(1)); + try { + text = readFileSync(file, 'utf-8'); + } catch (error) { + return { error: `cannot read --apply payload ${file}: ${(error as Error).message}` }; + } + } + + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch (error) { + return { error: `--apply must be JSON (inline or @file): ${(error as Error).message}` }; + } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + return { error: '--apply must be a JSON object' }; + } + + const record = parsed as Record<string, unknown>; + const edit = record.edit; + if (typeof edit !== 'object' || edit === null || Array.isArray(edit)) { + return { error: '--apply requires an "edit" object ({ kind, spec })' }; + } + const kind = (edit as Record<string, unknown>).kind; + const spec = (edit as Record<string, unknown>).spec; + if (typeof kind !== 'string' || EDITOR_EDIT_KINDS.every((known) => known !== kind)) { + return { + error: `--apply edit.kind "${String(kind)}" is not one of: ${EDITOR_EDIT_KINDS.join(', ')}`, + }; + } + if (typeof spec !== 'object' || spec === null || Array.isArray(spec)) { + return { error: '--apply edit.spec must be an object' }; + } + if (record.expectedChecksum !== undefined && typeof record.expectedChecksum !== 'string') { + return { error: '--apply expectedChecksum must be a string when supplied' }; + } + if (record.dryRun !== undefined && typeof record.dryRun !== 'boolean') { + return { error: '--apply dryRun must be a boolean when supplied' }; + } + if (record.artefactPaths !== undefined) { + const paths = record.artefactPaths; + if (typeof paths !== 'object' || paths === null || Array.isArray(paths)) { + return { error: '--apply artefactPaths must be an object { json, mermaid }' }; + } + for (const key of ['json', 'mermaid'] as const) { + if (typeof (paths as Record<string, unknown>)[key] !== 'string') { + return { error: `--apply artefactPaths.${key} must be a string` }; + } + } + } + + return { + request: { + edit: edit as unknown as EditorEdit, + ...(record.expectedChecksum !== undefined + ? { expectedChecksum: record.expectedChecksum as string } + : {}), + ...(record.dryRun !== undefined ? { dryRun: record.dryRun as boolean } : {}), + ...(record.artefactPaths !== undefined + ? { artefactPaths: record.artefactPaths as EditorApplyRequest['artefactPaths'] } + : {}), + }, + }; +} + +/** Runs one headless apply through the write bridge. */ +export interface EditorApplyOutcome { + /** True when the edit was accepted (written, or valid on a dry run). */ + readonly ok: boolean; + /** Process exit code (0 accepted, 1 refused/failed). */ + readonly code: number; + /** The bridge result, when the edit got that far. */ + readonly result?: import('../src/story-line/editor/api').EditApplyResult; + /** Actionable failure message. */ + readonly message: string; +} + +/** + * Applies one edit headlessly (`--apply`). + * + * The load-time checksum is the default concurrency token, so an external edit + * between load and apply is refused rather than clobbered. + * + * @param options Content path and CLI flags. + * @param adapter The game content adapter. + * @param request The parsed apply payload. + * @returns The bridge result plus an exit code. + */ +export function applyHeadlessEdit( + options: StorylineEditorCliOptions, + adapter: ContentAdapter, + request: EditorApplyRequest, +): EditorApplyOutcome { + let session; + try { + session = loadEditorSession({ contentPath: options.contentPath, adapter }); + } catch (error) { + return { ok: false, code: 1, message: (error as Error).message }; + } + + const result = applyEdit({ + contentPath: session.contentPath, + adapter, + edit: request.edit, + expectedChecksum: request.expectedChecksum ?? session.checksum, + dryRun: request.dryRun ?? options.dryRun, + artefactPaths: request.artefactPaths ?? session.artefactPaths, + manualDocs: session.manualDocs, + baseDir: session.baseDir, + }); + + return { ok: result.ok, code: result.ok ? 0 : 1, result, message: result.message }; +} + +/** + * Runs the `--apply` mode: resolve the adapter, parse the payload, apply once. + * + * @param options The CLI options (including the raw `--apply` value). + * @param deps Optional test seams. + * @returns Process exit code (0 accepted, 1 refused/failed). + */ +async function runApplyMode( + options: StorylineEditorCliOptions, + deps: StorylineEditorCliDeps, +): Promise<number> { + const adapter = await resolveEditorAdapter(options.adapterRef, deps.importer); + if (!adapter) { + process.stderr.write( + `storyline-editor: unknown --adapter "${options.adapterRef ?? ''}" — use a shipped adapter id ` + + '(gym) or a module exporting a ContentAdapter\n', + ); + return 1; + } + + const parsed = parseEditorApplyRequest(options.apply as string); + if ('error' in parsed) { + process.stderr.write(`storyline-editor: ${parsed.error}\n`); + return 1; + } + + const outcome = applyHeadlessEdit(options, adapter, parsed.request); + if (outcome.result) { + process.stdout.write(`${JSON.stringify(outcome.result, null, 2)}\n`); + } + if (!outcome.ok) process.stderr.write(`storyline-editor: ${outcome.message}\n`); + return outcome.code; +} + +/** + * Runs the editor CLI. + * + * @param argv Raw arguments (defaults to `process.argv.slice(2)`). + * @param deps Optional test seams. + * @returns Exit code: 0 when serving (the dev server keeps the process alive), + * when help was shown or when a headless apply succeeded; 1 on any + * failure, including a refused edit. + */ +export async function runStorylineEditorCli( + argv: readonly string[] = process.argv.slice(2), + deps: StorylineEditorCliDeps = {}, +): Promise<number> { + let options: StorylineEditorCliOptions; + try { + options = parseStorylineEditorArgs(argv); + } catch (error) { + process.stderr.write(`storyline-editor: ${(error as Error).message}\n\n${EDITOR_USAGE}\n`); + return 1; + } + + if (options.help) { + process.stdout.write(`${EDITOR_USAGE}\n`); + return 0; + } + + if (options.apply !== undefined) { + return runApplyMode(options, deps); + } + + const launched = await launchEditor(options, deps); + if (!launched.ok) { + process.stderr.write(`storyline-editor: ${launched.message}\n`); + return launched.code; + } + + const { host } = launched; + process.stdout.write( + `storyline-editor: serving the story-line graph at ${host.url ?? `http://${options.host}:${options.port}`}\n` + + 'storyline-editor: press Ctrl+C to stop\n', + ); + + const shutdown = (): void => { + void host.close().finally(() => { + process.exit(0); + }); + }; + process.once('SIGINT', shutdown); + process.once('SIGTERM', shutdown); + + return 0; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + runStorylineEditorCli() + .then((code) => { + process.exitCode = code; + }) + .catch((error: unknown) => { + process.stderr.write(`storyline-editor: ${(error as Error).message}\n`); + process.exitCode = 1; + }); +} diff --git a/scripts/storyline-editor-server.ts b/scripts/storyline-editor-server.ts new file mode 100644 index 000000000..307a88803 --- /dev/null +++ b/scripts/storyline-editor-server.ts @@ -0,0 +1,627 @@ +/** + * Story-line editor host: a Vite dev server that serves the editor page. + * + * The editor page (`tools/storyline-editor/`) is a plain-DOM development tool + * with its own dedicated Vite config (`vite.editor.config.ts`) so it never + * touches the game/build config. This module composes that config with the + * host wiring — a middleware that answers `GET /manifest.json` with the + * manifest built from the user's content CSV + adapter, and (when the host is + * given a session) the **write bridge API** for preview / validate / apply — + * and boots it. + * + * Because the manifest is served from memory (built at launch, no file write), + * the page makes a single request for the whole graph and stays responsive: + * node selection and pan/zoom never round-trip to the host. This is also what + * keeps the editor inside the single-write-path rule: nothing here writes a + * file; the transactional author API owns all writes, and the bridge + * (`src/story-line/editor/api.ts`) routes every edit through it. + * + * The manifest/URL contract with the page is pinned in + * `tools/storyline-editor/config.ts` and unit-guarded by + * `tests/story-line/editor/cli.test.ts`. + * + * @module + */ + +import { createServer, type Plugin, type PluginOption, type ViteDevServer } from 'vite'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { + EDITOR_EDIT_KINDS, + applyEdit, + previewEdit, + validateEdit, + type EditorEdit, +} from '../src/story-line/editor/api'; +import { + DEFAULT_ARTEFACT_PATHS, + type ArtefactPaths, +} from '../src/story-line/content/artefacts'; +import { + buildStorylineManifestFromCsv, + serialiseManifest, + type StorylineManifest, +} from '../src/story-line/content/manifest'; +import { computeContentChecksum } from '../src/story-line/content/csv'; +import { readContentCards } from '../src/story-line/content/mutate'; +import type { + ContentAdapter, + ContentCard, + ContentFieldMapping, +} from '../src/story-line/content/types'; +import { + EDITOR_API_PATHS, + EDITOR_CONTENT_PATH, + EDITOR_MANIFEST_PATH, + EDITOR_PREVIEW_PATH, + EDITOR_VALIDATE_PATH, +} from '../tools/storyline-editor/config'; +export { EDITOR_API_PATHS, EDITOR_CONTENT_PATH } from '../tools/storyline-editor/config'; +import editorConfig, { EDITOR_SERVER_PORT } from '../vite.editor.config'; +import { + DEFAULT_MANUAL_DOC_SECTIONS, + type ManualDocSection, +} from '../src/story-line/editor/manual-docs'; + +/** Default host the editor server binds to (loopback only — a local dev tool). */ +export const DEFAULT_EDITOR_HOST = '127.0.0.1'; + +/** The middleware plugin's Vite plugin id. */ +export const EDITOR_MANIFEST_PLUGIN_NAME = 'tce-storyline-editor-manifest'; + +/** Minimal request shape the handler needs from an HTTP request. */ +export interface EditorManifestRequest { + readonly url?: string; +} + +/** Minimal response shape the handler needs from an HTTP response. */ +export interface EditorManifestResponse { + setHeader(name: string, value: string): void; + end(body?: string): void; +} + +/** + * Serves the generated manifest, or reports it was not handled. + * + * @param manifest The manifest to serve. + * @param request The incoming request. + * @param response A writable response. + * @returns `true` when this middleware handled the request, `false` otherwise + * (the caller should chain to the next middleware). + */ +export function serveEditorManifest( + manifest: StorylineManifest, + request: EditorManifestRequest, + response: EditorManifestResponse, +): boolean { + const path = (request.url ?? '').split('?')[0]; + if (path !== EDITOR_MANIFEST_PATH) return false; + response.setHeader('Content-Type', 'application/json; charset=utf-8'); + response.setHeader('Cache-Control', 'no-store'); + response.end(serialiseManifest(manifest)); + return true; +} + +// ── Editable content (read-only, for the curated forms) ───── + +/** + * The content adapter's **declarative** projection: columns, the story-line + * field mapping and the authoring defaults. + * + * The optional adapter hooks (`baseId`, `describeImpact`) are functions and + * therefore not serialisable; the page only needs the data to derive form + * fields, so the projection deliberately omits them (`ContentAdapter`'s hooks + * are optional, so the projection is structurally assignable to it). + */ +export type EditorContentAdapter = Omit<ContentAdapter, 'baseId' | 'describeImpact'>; + +/** The read-only content payload the editor page fetches to prefill forms. */ +export interface EditorContentResponse { + /** The adapter's game id. */ + readonly gameId: string; + /** Checksum of the served content (the load-time concurrency token). */ + readonly checksum: string; + /** The adapter's declarative field mapping/defaults. */ + readonly adapter: EditorContentAdapter; + /** The raw content cards, in file order, including legacy link columns. */ + readonly cards: readonly ContentCard[]; +} + +/** + * Projects an adapter to its serialisable declarative part. + * + * @param adapter The game content adapter. + * @returns Columns, field mapping and authoring defaults (no hooks). + */ +export function serialisableContentAdapter( + adapter: ContentAdapter, +): EditorContentAdapter { + const fields: ContentFieldMapping = adapter.fields; + return { + gameId: adapter.gameId, + columns: adapter.columns, + fields, + ...(adapter.editableColumns !== undefined + ? { editableColumns: adapter.editableColumns } + : {}), + ...(adapter.newCardDefaults !== undefined + ? { newCardDefaults: adapter.newCardDefaults } + : {}), + }; +} + +/** + * Builds the read-only content payload for a session (adapter schema + cards). + * + * @param session The loaded editor session. + * @returns The content response the page fetches. + */ +export function buildEditorContent(session: EditorSession): EditorContentResponse { + return { + gameId: session.adapter.gameId, + checksum: computeContentChecksum(session.csv), + adapter: serialisableContentAdapter(session.adapter), + cards: readContentCards(session.csv, session.adapter), + }; +} + +/** + * Serves the editable content, or reports it was not handled. + * + * Read-only: it exposes the already-loaded session snapshot (the adapter's + * declarative schema plus the raw cards) so the curated forms can prefill + * themselves. Nothing here writes a file — every mutation still goes through + * the preview/validate/apply endpoints and the single author API. + * + * @param session The current editor session. + * @param request The incoming request. + * @param response A writable response. + * @returns `true` when this middleware handled the request. + */ +export function serveEditorContent( + session: EditorSession, + request: EditorManifestRequest, + response: EditorManifestResponse, +): boolean { + const path = (request.url ?? '').split('?')[0]; + if (path !== EDITOR_CONTENT_PATH) return false; + response.setHeader('Content-Type', 'application/json; charset=utf-8'); + response.setHeader('Cache-Control', 'no-store'); + response.end(JSON.stringify(buildEditorContent(session))); + return true; +} + +// ── Session (the loaded snapshot + its concurrency token) ─── + +/** Options for {@link loadEditorSession}. */ +export interface EditorSessionOptions { + /** Content CSV path (absolute, or relative to `baseDir`). */ + readonly contentPath: string; + /** The game content adapter. */ + readonly adapter: ContentAdapter; + /** Base directory relative paths resolve against (default `process.cwd()`). */ + readonly baseDir?: string; + /** Hand-written documentation registry (defaults to the shipped sections). */ + readonly manualDocs?: readonly ManualDocSection[]; + /** Generated-artefact paths (defaults to the committed Gym artefacts). */ + readonly artefactPaths?: ArtefactPaths; +} + +/** + * The editor's loaded content snapshot. + * + * `csv` is what previews are computed against — the same text the author sees — + * and `checksum` is the concurrency token handed to the write bridge. After an + * accepted edit the session is reloaded (`reload()`), so the next preview and + * the served manifest reflect the new content. + */ +export interface EditorSession { + /** Absolute path of the content CSV. */ + readonly contentPath: string; + /** The game content adapter. */ + readonly adapter: ContentAdapter; + /** The loaded content CSV text (the preview snapshot). */ + readonly csv: string; + /** The loaded content checksum (the write bridge's concurrency token). */ + readonly checksum: string; + /** The manifest of the loaded content. */ + readonly manifest: StorylineManifest; + /** Hand-written documentation registry for this game. */ + readonly manualDocs: readonly ManualDocSection[]; + /** Generated-artefact paths for this game. */ + readonly artefactPaths: ArtefactPaths; + /** Base directory relative paths resolve against. */ + readonly baseDir: string; + /** Builds the manifest from the **current on-disk** content (page refresh). */ + readManifest(): StorylineManifest; + /** Re-reads the content from disk, returning a fresh session. */ + reload(): EditorSession; +} + +/** + * Loads a content file into an editor session. + * + * @param options Content path, adapter and optional registries. + * @returns The loaded session. + * @throws Error with an actionable message when the CSV is unreadable. + */ +export function loadEditorSession(options: EditorSessionOptions): EditorSession { + const baseDir = options.baseDir ?? process.cwd(); + const contentPath = resolve(baseDir, options.contentPath); + + const build = (): EditorSession => { + let csv: string; + try { + csv = readFileSync(contentPath, 'utf-8'); + } catch (error) { + throw new Error(`cannot read ${contentPath}: ${(error as Error).message}`); + } + const manifest = buildStorylineManifestFromCsv(csv, options.adapter); + return { + contentPath, + adapter: options.adapter, + csv, + checksum: computeContentChecksum(csv), + manifest, + manualDocs: options.manualDocs ?? DEFAULT_MANUAL_DOC_SECTIONS, + artefactPaths: options.artefactPaths ?? DEFAULT_ARTEFACT_PATHS, + baseDir, + readManifest: () => + buildStorylineManifestFromCsv(readFileSync(contentPath, 'utf-8'), options.adapter), + reload: () => build(), + }; + }; + + return build(); +} + +// ── Host API (preview / validate / apply) ─────────────────── + +/** A manifest source: a fixed manifest, or a provider re-read per request. */ +export type EditorManifestSource = StorylineManifest | (() => StorylineManifest); + +/** Normalises a manifest source to a provider. */ +function manifestProvider(source: EditorManifestSource): () => StorylineManifest { + return typeof source === 'function' ? source : () => source; +} + +/** + * A Vite plugin serving the manifest — and, when a session is supplied, the + * read-only editable content — to the editor page. + * + * @param source A fixed manifest or a provider re-read per request. + * @param session The loaded session; enables `GET /content.json` for the forms. + * @returns The Vite plugin. + */ +export function storylineEditorManifestPlugin( + source: EditorManifestSource, + session?: EditorSession, +): Plugin { + const getManifest = manifestProvider(source); + return { + name: EDITOR_MANIFEST_PLUGIN_NAME, + configureServer(server) { + server.middlewares.use((req, res, next) => { + const response = { + setHeader: (name: string, value: string) => { + res.setHeader(name, value); + }, + end: (body?: string) => { + res.end(body); + }, + }; + if (serveEditorManifest(getManifest(), req, response)) return; + if (session && serveEditorContent(session, req, response)) return; + next(); + }); + }, + }; +} + +/** The Vite plugin id of the bridge API middleware. */ +export const EDITOR_API_PLUGIN_NAME = 'tce-storyline-editor-api'; + +/** A host API request, reduced to what the handler needs. */ +export interface EditorApiRequest { + /** HTTP method (only `POST` is accepted). */ + readonly method?: string; + /** Request path, query string included (it is stripped). */ + readonly path: string; + /** The already-parsed JSON body. */ + readonly body?: unknown; +} + +/** A host API response: status plus a JSON-serialisable body. */ +export interface EditorApiResponse { + /** HTTP status code (200 accepted, 400 bad request, 405 method, 409 stale, 422 invalid). */ + readonly status: number; + /** JSON body. */ + readonly body: unknown; +} + +/** The outcome of one API request: the response plus the session to continue with. */ +export interface EditorApiOutcome { + /** What to send back. */ + readonly response: EditorApiResponse; + /** + * The session the host should keep using. A successful apply replaces it + * with a reloaded one so subsequent previews see the written content. + */ + readonly session?: EditorSession; +} + +/** True when a value is a plain object. */ +function isRecord(value: unknown): value is Record<string, unknown> { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** Validates and extracts the `edit` field of a request body. */ +function readEdit(body: unknown): { readonly edit: EditorEdit } | { readonly error: string } { + if (!isRecord(body)) return { error: 'request body must be a JSON object' }; + const edit = body.edit; + if (!isRecord(edit)) return { error: 'request body must carry an "edit" object' }; + if (typeof edit.kind !== 'string') return { error: 'edit.kind must be a string' }; + if (!isRecord(edit.spec)) return { error: 'edit.spec must be an object' }; + if (!EDITOR_EDIT_KINDS.includes(edit.kind as (typeof EDITOR_EDIT_KINDS)[number])) { + return { + error: `unknown edit kind "${edit.kind}" (expected one of: ${EDITOR_EDIT_KINDS.join(', ')})`, + }; + } + return { edit: edit as unknown as EditorEdit }; +} + +/** Builds a 400 response for a malformed request. */ +function badRequest(message: string): EditorApiOutcome { + return { response: { status: 400, body: { ok: false, error: message } } }; +} + +/** + * Handles one host API request. + * + * Pure with respect to HTTP: it takes a parsed body and returns a status + + * body, so the endpoint contract is unit-testable without a socket. + * + * | Path | Method | Operation | + * |---|---|---| + * | `POST /api/preview` | preview | diff + validation report, nothing written | + * | `POST /api/validate` | validate | validator report only, nothing written | + * | `POST /api/apply` | apply | the transactional write + artefact regeneration | + * + * @param session The current editor session. + * @param request The request (method, path, parsed JSON body). + * @returns The response plus the session to continue with. + */ +export function handleEditorApiRequest( + session: EditorSession, + request: EditorApiRequest, +): EditorApiOutcome { + const path = request.path.split('?')[0]; + const known = Object.values(EDITOR_API_PATHS) as string[]; + if (!known.includes(path)) { + return { response: { status: 404, body: { ok: false, error: `unknown API path ${path}` } } }; + } + if ((request.method ?? 'POST').toUpperCase() !== 'POST') { + return { + response: { status: 405, body: { ok: false, error: `${path} accepts POST only` } }, + }; + } + + const parsed = readEdit(request.body); + if ('error' in parsed) return badRequest(parsed.error); + const { edit } = parsed; + + if (path === EDITOR_PREVIEW_PATH) { + return { + response: { status: 200, body: previewEdit(session.csv, session.adapter, edit) }, + }; + } + + if (path === EDITOR_VALIDATE_PATH) { + return { + response: { status: 200, body: validateEdit(session.csv, session.adapter, edit) }, + }; + } + + const body = isRecord(request.body) ? request.body : {}; + const result = applyEdit({ + contentPath: session.contentPath, + adapter: session.adapter, + edit, + // The session checksum is the load-time token; a caller may state its own + // (e.g. a client holding an older snapshot). + expectedChecksum: + typeof body.expectedChecksum === 'string' ? body.expectedChecksum : session.checksum, + dryRun: body.dryRun === true, + artefactPaths: session.artefactPaths, + manualDocs: session.manualDocs, + baseDir: session.baseDir, + }); + + if (!result.ok) return { response: { status: result.code, body: result } }; + + const reloaded = result.written ? session.reload() : undefined; + return { + response: { + status: result.code, + body: reloaded ? { ...result, manifest: reloaded.manifest } : result, + }, + ...(reloaded ? { session: reloaded } : {}), + }; +} + +/** A minimal readable request stream (Node's `IncomingMessage` satisfies it). */ +export interface EditorBodyStream { + [Symbol.asyncIterator](): AsyncIterator<string | Uint8Array>; +} + +/** + * Reads and parses a JSON request body. + * + * @param stream The request stream. + * @param limit Maximum accepted body size in bytes (default 1 MiB). + * @returns The parsed JSON value (`undefined` for an empty body). + * @throws Error when the body exceeds the limit or is not valid JSON. + */ +export async function readJsonBody( + stream: EditorBodyStream, + limit = 1_048_576, +): Promise<unknown> { + const chunks: Buffer[] = []; + let size = 0; + for await (const chunk of stream) { + const buffer = typeof chunk === 'string' ? Buffer.from(chunk, 'utf-8') : Buffer.from(chunk); + size += buffer.length; + if (size > limit) throw new Error(`request body exceeds ${limit} bytes`); + chunks.push(buffer); + } + const text = Buffer.concat(chunks).toString('utf-8').trim(); + if (text === '') return undefined; + return JSON.parse(text) as unknown; +} + +/** Minimal response shape the API middleware needs. */ +export interface EditorApiHttpResponse { + setHeader(name: string, value: string): void; + end(body?: string): void; + statusCode: number; +} + +/** Minimal request shape the API middleware needs. */ +export interface EditorApiHttpRequest extends EditorBodyStream, EditorManifestRequest { + readonly method?: string; +} + +/** + * A Vite plugin exposing the write bridge as host API endpoints. + * + * The session is held in the closure: an accepted apply reloads it, so the + * page's next preview (and the manifest it re-fetches) reflects the edit. + */ +export function storylineEditorApiPlugin(initial: EditorSession): Plugin { + let session = initial; + return { + name: EDITOR_API_PLUGIN_NAME, + configureServer(server) { + server.middlewares.use((req, res, next) => { + const url = (req as { url?: string }).url ?? ''; + const path = url.split('?')[0]; + if (!Object.values(EDITOR_API_PATHS).includes(path as never)) { + next(); + return; + } + + void (async () => { + let body: unknown; + try { + body = await readJsonBody(req as unknown as EditorBodyStream); + } catch (error) { + res.statusCode = 400; + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.end( + JSON.stringify({ + ok: false, + error: `invalid JSON body: ${(error as Error).message}`, + }), + ); + return; + } + + const outcome = handleEditorApiRequest(session, { + method: (req as { method?: string }).method, + path, + body, + }); + if (outcome.session) session = outcome.session; + res.statusCode = outcome.response.status; + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.setHeader('Cache-Control', 'no-store'); + res.end(JSON.stringify(outcome.response.body)); + })(); + }); + }, + }; +} + +/** Options for {@link createEditorHost}. */ +export interface EditorHostOptions { + /** The manifest to serve to the page (required unless `session` is given). */ + readonly manifest?: StorylineManifest; + /** A loaded editor session: serves its manifest and exposes the API endpoints. */ + readonly session?: EditorSession; + /** Dev-server port (defaults to the editor config's port 5174). */ + readonly port?: number; + /** Bind host (defaults to the loopback address). */ + readonly host?: string; + /** When `false`, creates the server without listening (tests). */ + readonly listen?: boolean; + /** Vite log level (defaults to `info`; tests use `silent`). */ + readonly logLevel?: 'info' | 'warn' | 'error' | 'silent'; +} + +/** A started editor host. */ +export interface EditorHost { + /** The underlying Vite dev server. */ + readonly server: ViteDevServer; + /** The first local URL, when the server is listening. */ + readonly url: string | undefined; + /** Shuts the dev server down. */ + close(): Promise<void>; +} + +/** + * Creates (and by default starts) the editor dev server. + * + * The page config is composed from the dedicated `vite.editor.config.ts` plus + * the manifest middleware (and, when a session is supplied, the bridge API + * middleware) — the game/build config is never involved. + * + * @param options Host options. + * @returns The started host handle. + * @throws Error when neither a manifest nor a session is supplied. + */ +export async function createEditorHost(options: EditorHostOptions): Promise<EditorHost> { + const port = options.port ?? EDITOR_SERVER_PORT; + const host = options.host ?? DEFAULT_EDITOR_HOST; + const listen = options.listen ?? true; + + if (!options.session && !options.manifest) { + throw new Error('createEditorHost requires a manifest or an editor session'); + } + + const session = options.session; + const source: EditorManifestSource = session + ? () => session.readManifest() + : (options.manifest as StorylineManifest); + + const plugins: PluginOption[] = [ + ...(editorConfig.plugins ?? []), + storylineEditorManifestPlugin(source, session), + ]; + if (session) plugins.push(storylineEditorApiPlugin(session)); + + const server = await createServer({ + ...editorConfig, + configFile: false, + logLevel: options.logLevel ?? 'info', + plugins, + server: { + ...editorConfig.server, + port, + host, + open: false, + }, + }); + + if (listen) { + await server.listen(); + } + + const url = server.resolvedUrls?.local[0] ?? (listen ? `http://${host}:${port}` : undefined); + + return { + server, + url, + close: () => server.close(), + }; +} diff --git a/scripts/storyline-graph.ts b/scripts/storyline-graph.ts new file mode 100644 index 000000000..f84bb13b0 --- /dev/null +++ b/scripts/storyline-graph.ts @@ -0,0 +1,188 @@ +#!/usr/bin/env node +/** + * Story-line graph / manifest export CLI. + * + * Writes a deterministic, machine-readable picture of a game's story-line + * content: + * + * - `--format mermaid` → a Mermaid flowchart (default + * `example-games/gym/content/storyline-graph.mmd`); + * - `--format json` → a manifest that validates against + * `schemas/story-line-manifest.schema.json` (default + * `example-games/gym/content/storyline-manifest.json`). + * + * `--check` compares the generated output with the committed artefact and + * exits non-zero on drift (a CI/pre-commit guard); `--out` targets any path. + * + * ```bash + * npm run storylines:graph -- --format mermaid + * npm run storylines:graph -- --format json --check + * npm run storylines:graph -- --csv other.csv --game tavern --out /tmp/g.json + * ``` + * + * Runs under `tsx`; nothing here imports a game or Phaser. + * + * @module + */ + +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import { + DEFAULT_CONTENT_PATH, + resolveContentAdapter, +} from '../src/story-line/content/adapters'; +import { + buildStorylineManifestFromCsv, + type StorylineManifest, +} from '../src/story-line/content/manifest'; +import { + DEFAULT_ARTEFACT_PATHS, + renderArtefacts, +} from '../src/story-line/content/artefacts'; +import { parseArgv } from './storyline-cli-support'; + +/** + * Default committed artefact paths, relative to the repository root. + * + * The canonical declaration lives with the generated-artefact module + * (`src/story-line/content/artefacts.ts`); this CLI re-exports it so existing + * importers keep working against one source of truth. + */ +export const DEFAULT_GRAPH_OUTPUTS = DEFAULT_ARTEFACT_PATHS; + +/** CLI usage text. */ +export const GRAPH_USAGE = `Export a deterministic story-line manifest or Mermaid flowchart. + +Usage: + npx tsx scripts/storyline-graph.ts [options] + +Options: + --format <mermaid|json> Output format (default: mermaid) + --csv <path> Content CSV to read (default: ${DEFAULT_CONTENT_PATH}) + --game <id> Content adapter id (default: gym) + --out <path> Output path (default: the committed Gym artefact) + --check Compare with the committed artefact; exit 1 on drift + --help Show this help + +Exit codes: 0 = written / no drift, 1 = drift, missing artefact or bad input.`; + +/** Parsed graph CLI options. */ +export interface StorylineGraphCliOptions { + readonly format: 'mermaid' | 'json'; + readonly csvPath: string; + readonly gameId?: string; + readonly outPath: string; + readonly check: boolean; + readonly help: boolean; +} + +/** + * Parses graph-exporter argv. + * + * @param argv Raw arguments. + * @returns The parsed options (with the format-appropriate default output). + */ +export function parseStorylineGraphArgs( + argv: readonly string[], +): StorylineGraphCliOptions { + const { flags } = parseArgv(argv); + const format = flags.get('format') === 'json' ? 'json' : 'mermaid'; + return { + format, + csvPath: flags.get('csv') ?? DEFAULT_CONTENT_PATH, + gameId: flags.get('game'), + outPath: flags.get('out') ?? DEFAULT_GRAPH_OUTPUTS[format], + check: flags.has('check'), + help: flags.has('help'), + }; +} + +/** + * Builds the artefact content for the given format. + * + * @param format Artefact format (`json` serialises the manifest). + * @param manifest The built manifest. + * @returns The artefact text. + */ +export function renderGraphArtefact( + format: 'mermaid' | 'json', + manifest: StorylineManifest, +): string { + return renderArtefacts(manifest)[format]; +} + +/** + * Runs the graph-export CLI. + * + * @param argv Raw arguments (defaults to `process.argv.slice(2)`). + * @returns Exit code: 0 on success / no drift, 1 on drift or bad input. + */ +export function runStorylineGraphCli( + argv: readonly string[] = process.argv.slice(2), +): number { + const opts = parseStorylineGraphArgs(argv); + if (opts.help) { + process.stdout.write(`${GRAPH_USAGE}\n`); + return 0; + } + + const adapter = resolveContentAdapter(opts.gameId); + if (!adapter) { + process.stderr.write( + `storyline-graph: unknown --game "${opts.gameId ?? ''}" (shipped adapters: gym)\n`, + ); + return 1; + } + + const csvPath = resolve(process.cwd(), opts.csvPath); + const outPath = resolve(process.cwd(), opts.outPath); + + let csv: string; + try { + csv = readFileSync(csvPath, 'utf-8'); + } catch (err) { + process.stderr.write( + `storyline-graph: cannot read ${csvPath}: ${(err as Error).message}\n`, + ); + return 1; + } + + let content: string; + try { + content = renderGraphArtefact( + opts.format, + buildStorylineManifestFromCsv(csv, adapter), + ); + } catch (err) { + process.stderr.write( + `storyline-graph: cannot build the manifest from ${csvPath}: ${(err as Error).message}\n`, + ); + return 1; + } + + if (opts.check) { + if (!existsSync(outPath)) { + process.stderr.write(`storyline-graph: missing committed artefact ${outPath}\n`); + return 1; + } + if (readFileSync(outPath, 'utf-8') !== content) { + process.stderr.write( + `storyline-graph: drift detected in ${outPath} — regenerate with ` + + `\`npm run storylines:graph -- --format ${opts.format}\`\n`, + ); + return 1; + } + process.stdout.write(`storyline-graph: ${outPath} is up to date\n`); + return 0; + } + + writeFileSync(outPath, content, 'utf-8'); + process.stdout.write(`storyline-graph: wrote ${outPath}\n`); + return 0; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + process.exitCode = runStorylineGraphCli(); +} 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/scripts/validate-storylines.ts b/scripts/validate-storylines.ts new file mode 100644 index 000000000..2d2c7765e --- /dev/null +++ b/scripts/validate-storylines.ts @@ -0,0 +1,123 @@ +#!/usr/bin/env node +/** + * Story-line content validator CLI. + * + * Runs the static content validator over a game's story-line content CSV and + * exits non-zero when the content is invalid: + * + * ```bash + * npm run storylines:validate + * npm run storylines:validate -- --json + * npm run storylines:validate -- --csv path/to/content.csv + * npm run storylines:validate -- --csv path/to/content.csv --game gym + * npm run storylines:validate -- --fail-on-cycle + * ``` + * + * Runs under `tsx` (`npx tsx scripts/validate-storylines.ts …`); the content + * module is Phaser-free and game-import-free, so no Vite loader is needed. + * + * @module + */ + +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import { + DEFAULT_CONTENT_PATH, + resolveContentAdapter, +} from '../src/story-line/content/adapters'; +import { + contentValidationToJson, + formatContentValidationReport, + validateContentCsv, +} from '../src/story-line/content/validate'; +import { parseArgv } from './storyline-cli-support'; + +/** CLI usage text. */ +export const VALIDATE_USAGE = `Validate story-line content against the generic story-line rules. + +Usage: + npx tsx scripts/validate-storylines.ts [options] + +Options: + --csv <path> Content CSV to validate (default: ${DEFAULT_CONTENT_PATH}) + --game <id> Content adapter id (default: gym) + --json Emit the machine-readable JSON report + --fail-on-cycle Treat cycles as hard errors (default: informational) + --help Show this help + +Exit codes: 0 = valid, 1 = invalid or unreadable content.`; + +/** Parsed validator CLI options. */ +export interface ValidateStorylinesCliOptions { + readonly csvPath: string; + readonly gameId?: string; + readonly json: boolean; + readonly failOnCycle: boolean; + readonly help: boolean; +} + +/** + * Parses validator argv. + * + * @param argv Raw arguments. + * @returns The parsed options. + */ +export function parseValidateStorylinesArgs( + argv: readonly string[], +): ValidateStorylinesCliOptions { + const { flags } = parseArgv(argv); + return { + csvPath: flags.get('csv') ?? DEFAULT_CONTENT_PATH, + gameId: flags.get('game'), + json: flags.has('json'), + failOnCycle: flags.has('fail-on-cycle'), + help: flags.has('help'), + }; +} + +/** + * Runs the validator CLI. + * + * @param argv Raw arguments (defaults to `process.argv.slice(2)`). + * @returns Exit code: 0 when valid, 1 when invalid or unreadable. + */ +export function runValidateStorylinesCli( + argv: readonly string[] = process.argv.slice(2), +): number { + const opts = parseValidateStorylinesArgs(argv); + if (opts.help) { + process.stdout.write(`${VALIDATE_USAGE}\n`); + return 0; + } + + const adapter = resolveContentAdapter(opts.gameId); + if (!adapter) { + process.stderr.write( + `validate-storylines: unknown --game "${opts.gameId ?? ''}" (shipped adapters: gym)\n`, + ); + return 1; + } + + const csvPath = resolve(process.cwd(), opts.csvPath); + let csv: string; + try { + csv = readFileSync(csvPath, 'utf-8'); + } catch (err) { + process.stderr.write( + `validate-storylines: cannot read ${csvPath}: ${(err as Error).message}\n`, + ); + return 1; + } + + const result = validateContentCsv(csv, adapter, { failOnCycle: opts.failOnCycle }); + process.stdout.write( + `${opts.json ? contentValidationToJson(result) : formatContentValidationReport(result)}\n`, + ); + return result.valid ? 0 : 1; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + process.exitCode = runValidateStorylinesCli(); +} diff --git a/scripts/vite-game-discovery-plugin.ts b/scripts/vite-game-discovery-plugin.ts index 990fcac17..faaab363d 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<string, string> { '@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'), @@ -571,6 +573,7 @@ const GYM_SCENE_NAMES = [ 'GymTokenPileViewScene', 'GymRuleEngineScene', 'GymDlcUnlockScene', + 'GymStorylineScene', ] as const; // ── The plugin ──────────────────────────────────────────────────────────── diff --git a/scripts/vitest-run-with-retry.ts b/scripts/vitest-run-with-retry.ts index 80c85a395..28041a17c 100644 --- a/scripts/vitest-run-with-retry.ts +++ b/scripts/vitest-run-with-retry.ts @@ -6,8 +6,8 @@ * into a diagnosable failure instead of an infinite stall. * * Problem (see CG-0MS9M5UJP005PWD3, CG-0MSCI73RH004VPCE, - * CG-0MT08R2QR0070F3N): Vitest has three distinct contention-induced - * failure signatures: + * CG-0MT08R2QR0070F3N, CG-0MVNDK56G007C1U7): Vitest has four distinct + * contention-induced failure signatures: * * 1. Worker RPC timeout — Vitest's worker RPC layer uses birpc with a * hard-coded 60s timeout (DEFAULT_TIMEOUT = 6e4 in @@ -26,6 +26,24 @@ * the worst case, the whole run never exits. Unlike signatures 1 and 2, * a hang never produces an exit code on its own — the runner needs a * bounded wall-clock timeout to detect and abort it. + * 4. Module fetch failure — the Vite dev server fails to serve a test + * module under load, so the browser cannot dynamically import it + * (`TypeError: Failed to fetch dynamically imported module: …`). + * Vitest reports it as a failed suite: + * + * FAIL |browser (chromium)| tests/foo.browser.test.ts [ tests/foo.browser.test.ts ] + * Error: Failed to import test file /abs/path/tests/foo.browser.test.ts + * Caused by: TypeError: Failed to fetch dynamically imported module: http://localhost:PORT/abs/path/tests/foo.browser.test.ts?import&browserv=… + * + * The `Caused by:` line stays inside the SAME error block as the + * `Error:` line it belongs to, so `errorBlocks` attributes the block to + * `MODULE_FETCH_SIGNATURE` (the incident behind CG-0MVNDK56G007C1U7 is + * pinned verbatim in the runner's regression suite). The text is + * Chromium-specific: Firefox emits a different message, and the browser + * project only runs headless Chromium + * (`scripts/check-browser-test-env.ts`), so the constant deliberately + * pins the Chromium substring rather than guessing a cross-browser + * pattern. * * The mitigation is at the runner level: * @@ -38,7 +56,12 @@ * as green: there is no test failure to mask, and the non-zero exit is * purely a post-completion teardown RPC artefact. This keeps the gate * resilient when CPU contention persists across both attempts - * (CG-0MUF0LU4X006IXXU). + * (CG-0MUF0LU4X006IXXU). This both-attempts acceptance is restricted to + * signatures 1 and 2: a module fetch failure (signature 4) is + * RETRY-ONLY recovery, because a genuine module-resolution regression + * (mistyped import path, deleted file, 404 on an asset module) produces + * the identical text deterministically and must surface + * (CG-0MVNDK56G007C1U7). * - Never retry or accept on genuine test failures — the masking guard * (shouldRetryOnce) attributes every reported failure to the transient * signature before a retry is allowed. @@ -52,19 +75,57 @@ * a hang diagnostic. Hangs are NEVER retried — a genuine hang must * surface, not be masked. * + * Op-in project-coverage gate (CG-0MVL21CVM003VK5H): + * + * The retry/acceptance logic above reasons purely about the run's output + * text. That leaves a residual gap: a run that silently dropped part of + * the suite (e.g. Vitest 3.2.7's `executeTests` group loop aborts when an + * earlier `sequence.groupOrder` group's pool run rejects, dropping every + * later group while still summarising the executed files as green) can + * still be accepted — the summary reads `Test Files N passed (N)` with the + * transient signature present. This runner therefore supports an OPTO-IN + * expectation of the test-file set the run is supposed to execute: + * + * npx tsx scripts/vitest-run-with-retry.ts --expect-project-coverage \ + * --project unit [--timeout-ms 300000] ... + * npx tsx scripts/vitest-run-with-retry.ts \ + * --expected-file tests/a.test.ts --expected-file tests/b.test.ts ... + * + * - `--expect-project-coverage` derives the expected set from a pre-flight + * collection-only `vitest list --filesOnly --json` over the forwarded + * project/args (no browser launch; `--staticParse` is used and retried + * once without it for Vitest 3.x consumers that reject the flag). + * - `--expected-file <path>` (repeatable) pins an explicit expected set — + * the low-level, deterministic seam for tests and non-standard layouts. + * - When an expectation is active, the executed file set is captured per + * attempt (an injected `--reporter=json --outputFile.json=<tmp>` is + * appended so the set is exact and independent of the caller's + * reporters), and any set difference — missing OR extra files — is a + * genuine failure: non-zero exit, never `accepted-transient`, never + * converted to green by the retry, regardless of the run's own exit + * code. A pre-flight collection failure, timeout or empty set fails + * closed with a diagnostic (exit 2). Hang semantics are unchanged + * (exit 124, never retried, reported before any coverage check). + * - Without the flags the runner is byte-for-byte identical to its + * pre-coverage behaviour (regression-pinned by the fixtures in + * tests/scripts/vitest-run-with-retry.test.ts). + * * Usage (mirrors `npx vitest run ...`): * npx tsx scripts/vitest-run-with-retry.ts --project unit * npx tsx scripts/vitest-run-with-retry.ts --project browser * npx tsx scripts/vitest-run-with-retry.ts --project browser --timeout-ms 600000 + * npx tsx scripts/vitest-run-with-retry.ts --expect-project-coverage --project unit * * The spawned vitest output is streamed straight through; the process exit - * code is vitest's exit code (after the optional single retry), or 124 - * (hang timeout) when the wall-clock bound is hit. + * code is vitest's exit code (after the optional single retry), 124 (hang + * timeout) when the wall-clock bound is hit, or 1 (coverage failure) / + * 2 (pre-flight failure) under the opt-in coverage gate. */ import { spawn } from 'node:child_process'; import fs from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; -import { pathToFileURL } from 'node:url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; /** The exact error signature Vitest's worker RPC layer emits on timeout. */ export const WORKER_TIMEOUT_SIGNATURE = @@ -77,6 +138,44 @@ export const WORKER_TIMEOUT_SIGNATURE = export const BROWSER_DROP_SIGNATURE = '[vitest] Browser connection was closed while running tests'; +/** + * The Chromium text emitted when the Vite dev server fails to serve a test + * module under load, so the browser cannot dynamically import it. Vitest + * wraps it in `Error: Failed to import test file <path>` + + * `Caused by: TypeError: Failed to fetch dynamically imported module: …` + * (CG-0MVNDK56G007C1U7). + * + * Chromium-specific by design: Firefox emits a different message, and the + * browser project only runs headless Chromium + * (`scripts/check-browser-test-env.ts`), so the exact Chromium substring is + * the contract this runner's fixtures pin. + */ +export const MODULE_FETCH_SIGNATURE = + 'Failed to fetch dynamically imported module'; + +/** + * Signatures that make a run retryable. A run is retried exactly once when + * every reported error block is attributable to one of these. + */ +export const TRANSIENT_SIGNATURES: readonly string[] = [ + WORKER_TIMEOUT_SIGNATURE, + BROWSER_DROP_SIGNATURE, + MODULE_FETCH_SIGNATURE, +]; + +/** + * Signatures whose persistence across BOTH attempts may be accepted as green + * (CG-0MUF0LU4X006IXXU). Deliberately excludes `MODULE_FETCH_SIGNATURE`: a + * module fetch failure is retry-only recovery, so a run poisoned by it on + * both attempts keeps the retry's own (failing) status — a deterministic + * module-resolution regression must never be accepted + * (CG-0MVNDK56G007C1U7). + */ +export const ACCEPTABLE_TRANSIENT_SIGNATURES: readonly string[] = [ + WORKER_TIMEOUT_SIGNATURE, + BROWSER_DROP_SIGNATURE, +]; + /** * Wall-clock bound for a single vitest run (milliseconds). When a run * exceeds this bound it is treated as a hang and aborted @@ -101,6 +200,20 @@ export const HANG_TIMEOUT_EXIT_CODE = 124; */ export const KILL_GRACE_MS = 5000; +/** + * Wall-clock bound for the pre-flight `vitest list` collection (ms). A + * static-parse list adds a few seconds; the bound only guarantees the + * pre-flight can never hang the gate. + */ +export const LIST_TIMEOUT_MS = 120_000; + +/** + * Exit code reported when the opt-in pre-flight (expected-set derivation) + * fails or times out. Distinct from a genuine test failure (1) and a hang + * (124) so a gate operator can tell a collection problem from a red run. + */ +export const PREFLIGHT_FAILURE_EXIT_CODE = 2; + /** * Message emitted when the wall-clock bound is exceeded and the run is * aborted. `elapsedSecs` is the wall time before the abort. @@ -117,6 +230,14 @@ export function hangTimeoutMessage(elapsedSecs: number): string { ].join('\n'); } +/** + * Abbreviated hang diagnostic for the pre-flight `vitest list` collection — + * enough to surface + fail closed without the full browser-stage essay. + */ +export function listHangMessage(elapsedSecs: number): string { + return `\n[hang-timeout] vitest list did not complete within ${elapsedSecs}s — aborting (exit ${HANG_TIMEOUT_EXIT_CODE}).`; +} + export interface VitestRunResult { /** Process exit code (non-zero on failure or timeout). */ status: number; @@ -135,53 +256,514 @@ export type VitestRunner = ( ) => Promise<VitestRunResult> | VitestRunResult; /** - * True when the output contains either transient contention signature - * (worker RPC timeout or browser WebSocket drop). + * A single pre-flight `vitest list` collection. `args` are the collection + * CLI arguments (subcommand `list` is appended by the production runner); + * `timeoutMs` is the wall-clock bound for the collection. Injectable so + * unit tests never need a real vitest process. */ -export function hasTransientSignature(output: string): boolean { +export type VitestListRunner = ( + args: string[], + timeoutMs: number, +) => Promise<VitestRunResult> | VitestRunResult; + +/** Thrown by the opt-in expected-set derivation; `main` exits with 2. */ +export class PreflightError extends Error { + constructor(message: string) { + super(message); + this.name = 'PreflightError'; + } +} + +// ── Argument parsing ───────────────────────────────────────── + +export interface ParsedRunnerArgs { + /** Wall-clock bound for each vitest attempt. */ + timeoutMs: number; + /** Every argument forwarded to vitest itself. */ + forwarded: string[]; + /** Explicit expected-file values (`--expected-file <path>`, repeatable). */ + expectedFiles: string[]; + /** True when `--expect-project-coverage` was passed. */ + expectProjectCoverage: boolean; +} + +/** + * Parse the runner's own flags out of the CLI arguments, forwarding + * everything else to vitest: + * + * --timeout-ms <n> | --timeout-ms=<n> run wall-clock bound + * --expected-file <path> | --expected-file=<p> explicit expected file + * (repeatable) + * --expect-project-coverage derive the expected set via + * vitest list pre-flight + * + * Throws on an invalid value so `main` can exit 2 with a clear message. + */ +export function parseRunnerArgs(rawArgs: string[]): ParsedRunnerArgs { + let timeoutMs = DEFAULT_RUN_TIMEOUT_MS; + const forwarded: string[] = []; + const expectedFiles: string[] = []; + let expectProjectCoverage = false; + + const parsePositiveInt = (raw: string | undefined, flag: string): number => { + const parsed = Number(raw); + if (raw === undefined || raw === '' || !Number.isFinite(parsed) || parsed <= 0) { + throw new Error( + `Invalid ${flag} value: "${raw}". Expected a positive number of milliseconds.`, + ); + } + return parsed; + }; + + for (let i = 0; i < rawArgs.length; i++) { + const arg = rawArgs[i]; + if (arg === '--timeout-ms') { + const raw = rawArgs[++i]; + timeoutMs = parsePositiveInt(raw, '--timeout-ms'); + continue; + } + if (arg.startsWith('--timeout-ms=')) { + const raw = arg.slice('--timeout-ms='.length); + timeoutMs = parsePositiveInt(raw, '--timeout-ms'); + continue; + } + if (arg === '--expected-file') { + const raw = rawArgs[++i]; + if (raw === undefined || raw === '') { + throw new Error('Invalid --expected-file: missing value. Expected a test-file path.'); + } + expectedFiles.push(raw); + continue; + } + if (arg.startsWith('--expected-file=')) { + const raw = arg.slice('--expected-file='.length); + if (raw === '') { + throw new Error('Invalid --expected-file: missing value. Expected a test-file path.'); + } + expectedFiles.push(raw); + continue; + } + if (arg === '--expect-project-coverage') { + expectProjectCoverage = true; + continue; + } + forwarded.push(arg); + } + return { timeoutMs, forwarded, expectedFiles, expectProjectCoverage }; +} + +/** Backwards-compatible subset parser kept for imports that referenced it. */ +export function parseTimeoutArgs(args: string[]): { + timeoutMs: number; + forwarded: string[]; +} { + const parsed = parseRunnerArgs(args); + return { timeoutMs: parsed.timeoutMs, forwarded: parsed.forwarded }; +} + +// ── Expected-set derivation (pre-flight `vitest list`) ─────── + +/** + * Normalise a single file reference to a repo-relative POSIX path. + * + * Browser mode reports modules with URL-ish names, so besides plain paths + * this accepts `file:///…`, `http(s)://<host>/@fs/<abs path>` (Vite's + * filesystem escape used by Vitest browser modules) and a trailing + * `?query`/`#hash` on any of them. + */ +export function toRepoRelativePosix(file: string, cwd: string = process.cwd()): string { + let resolved = file.trim().replace(/[?#].*$/, ''); + if (resolved.startsWith('file://')) { + resolved = fileURLToPath(resolved); + } else if (/^https?:\/\//.test(resolved)) { + const pathname = new URL(resolved).pathname; + // Vite serves out-of-root files under /@fs/<absolute path>. + resolved = pathname.startsWith('/@fs/') ? pathname.slice('/@fs'.length) : pathname; + } else if (resolved.startsWith('/@fs/')) { + resolved = resolved.slice('/@fs'.length); + } + let rel: string; + if (path.isAbsolute(resolved)) { + rel = path.relative(cwd, resolved); + } else { + // Already relative — strip a leading ./ and normalise separators. + rel = resolved; + } + return rel.split(path.sep).join('/').replace(/^\.\//, ''); +} + +/** Normalise, de-duplicate and sort a file list to repo-relative POSIX paths. */ +export function normaliseFiles( + files: Iterable<string>, + cwd: string = process.cwd(), +): string[] { + const set = new Set<string>(); + for (const file of files) { + set.add(toRepoRelativePosix(file, cwd)); + } + return [...set].sort(); +} + +/** + * Parse `vitest list --filesOnly --json` stdout into a normalised + * repo-relative POSIX set. Accepts both the array shape + * `[{ file, projectName }, …]` / `["path", …]` (Vitest 4.x) and a + * `{ testFiles: [...] }` wrapper for older consumers. Throws on JSON that + * is neither. + */ +export function parseExpectedSet( + stdout: string, + cwd: string = process.cwd(), +): string[] { + const data: unknown = JSON.parse(stdout); + const files: string[] = []; + const collect = (entry: unknown): string | null => { + if (typeof entry === 'string') return entry; + if (typeof entry === 'object' && entry !== null) { + const rec = entry as { file?: unknown }; + if (typeof rec.file === 'string') return rec.file; + } + return null; + }; + if (Array.isArray(data)) { + for (const entry of data) { + const file = collect(entry); + if (file !== null) files.push(file); + } + } else if (typeof data === 'object' && data !== null) { + const wrapper = data as { testFiles?: unknown }; + if (Array.isArray(wrapper.testFiles)) { + for (const entry of wrapper.testFiles) { + const file = collect(entry); + if (file !== null) files.push(file); + } + } + } + return normaliseFiles(files, cwd); +} + +/** + * True when a failed `vitest list` invocation most plausibly rejected the + * `--staticParse` flag (Vitest 3.x consumers) rather than some other error. + * Deliberately tolerant — a false positive merely triggers one extra + * collection attempt without the flag, which fails closed identically. + */ +export function looksLikeUnknownStaticParse(output: string): boolean { + const lower = output.toLowerCase(); return ( - output.includes(WORKER_TIMEOUT_SIGNATURE) || - output.includes(BROWSER_DROP_SIGNATURE) + /unknown option/i.test(lower) || + (lower.includes('staticparse') && /error|invalid|unexpected|not a valid|option/i.test(lower)) ); } /** - * Split the run output into error blocks — each is the text from an error - * line up to (but not including) the next error line. Vitest prints one such - * block per unhandled error and per failed test/suite, so attributing each - * block independently tells us whether the reported failures are the - * transient signature itself or a genuine assertion/original error. + * Derive the expected repo-relative test-file set from a collection-only + * `vitest list --filesOnly --json` over the forwarded args. Uses + * `--staticParse` (no test imports, so the browser stage pays no second + * Chromium start); if the consumer's vitest version rejects the flag it is + * retried once without it before failing closed. + * + * Fails closed on every failure mode (PreflightError): non-zero status + * after the degradation retry, hang (exit 124), an empty file set, or + * unparsable JSON. Callers must treat the error as "expectation unknown ⇒ + * refuse to run the gate" — they must never silently downgrade to no + * expectation (AC 9 of CG-0MVL21CVM003VK5H). */ -export function errorBlocks(output: string): string[] { - const starts: number[] = []; - const re = /^(?:[A-Za-z][\w.]*)?Error(?:\s*\[[^\]]*\])?:/gm; +export async function deriveExpectedFiles( + forwarded: string[], + runList: VitestListRunner, + cwd: string = process.cwd(), + timeoutMs: number = LIST_TIMEOUT_MS, +): Promise<string[]> { + const collectionArgs = (withStaticParse: boolean): string[] => { + const base = ['--filesOnly', '--json']; + const args = withStaticParse ? ['--staticParse', ...base] : base; + return [...args, ...forwarded]; + }; + + let result = await runList(collectionArgs(true), timeoutMs); + if (result.status !== 0 && looksLikeUnknownStaticParse(result.output)) { + // Degradation for Vitest 3.x consumers that reject --staticParse. + result = await runList(collectionArgs(false), timeoutMs); + } + + if (result.status === HANG_TIMEOUT_EXIT_CODE) { + throw new PreflightError( + `vitest list did not complete within ${Math.round(timeoutMs / 1000)}s (exit ${HANG_TIMEOUT_EXIT_CODE})`, + ); + } + if (result.status !== 0) { + const tail = result.output.trim().split('\n').slice(-6).join('\n'); + throw new PreflightError( + `vitest list exited ${result.status}${tail ? `:\n${tail}` : ''}`, + ); + } + let files: string[]; + try { + files = parseExpectedSet(result.output, cwd); + } catch (err) { + throw new PreflightError( + `vitest list produced unparsable JSON (${(err as Error).message})`, + ); + } + if (files.length === 0) { + throw new PreflightError( + 'vitest list returned an empty file set — failing closed (an empty project cannot be ' + + 'distinguished from a broken collection, so no expectation is assumed)', + ); + } + return files; +} + +/** Human-readable diagnostic for a failed pre-flight collection. */ +export function preflightFailureMessage(problem: string): string { + return [ + `[preflight-failed] Could not derive the expected test-file set (vitest list --filesOnly --json): ${problem}`, + 'The coverage gate fails closed: without a trustworthy expectation it cannot prove the run', + 'executed the intended suite, so no test run is attempted.', + ].join('\n'); +} + +// ── Executed-set capture ───────────────────────────────────── + +/** + * Captures the set of test files a vitest attempt actually executed. + * + * The preferred mechanism injects the JSON reporter alongside the default + * reporter: `--reporter=default --reporter=json --outputFile.json=<tmp>`, + * so the executed set is exact (it records every file that completed, + * passing or failing) and independent of the caller's own reporters, while + * the default reporter output (which the masking guard and the `Test Files + * N passed (N)` summary detection rely on) is preserved. The temp artifact + * is removed after the attempt. + * + * `readExecutedFiles` is injectable so unit tests can supply the set + * without touching the filesystem; when it is absent the produced JSON + * artifact is parsed, falling back to default-reporter per-file lines only + * if the artifact is missing/unreadable. + */ +export class ExecutedSetCapture { + private static counter = 0; + readonly artefactPath: string; + private readonly cwd: string; + private readonly reader: + | ((result: VitestRunResult, artefactPath: string) => string[] | null) + | undefined; + + constructor(options: { + /** Directory for the JSON artifact; defaults to os.tmpdir(). */ + workDir?: string; + cwd?: string; + readExecutedFiles?: (result: VitestRunResult, artefactPath: string) => string[] | null; + }) { + this.cwd = options.cwd ?? process.cwd(); + this.artefactPath = path.join( + options.workDir ?? os.tmpdir(), + `vitest-executed-${process.pid}-${ExecutedSetCapture.counter++}.json`, + ); + this.reader = options.readExecutedFiles; + } + + /** Append the capture flags to the forwarded vitest args. */ + augmentArgs(args: string[]): string[] { + return [ + ...args, + '--reporter=default', + '--reporter=json', + `--outputFile.json=${this.artefactPath}`, + ]; + } + + /** + * The repo-relative POSIX set of files executed by a finished attempt, + * or `null` when the set is indeterminable (hang, missing artifact and + * no parseable per-file lines). A caller that supplied an expectation + * must treat `null` as a coverage failure. + */ + executedFiles(result: VitestRunResult): string[] | null { + if (result.status === HANG_TIMEOUT_EXIT_CODE) return null; + if (this.reader) return this.reader(result, this.artefactPath); + return defaultReadExecutedFiles(result.output, this.artefactPath, this.cwd); + } + + /** Best-effort removal of the temp JSON artifact. */ + cleanup(): void { + try { + fs.rmSync(this.artefactPath, { force: true }); + } catch { + /* best-effort */ + } + } +} + +/** + * Default executed-file-set reader: parse the injected JSON reporter + * artifact first (exact), then fall back to default-reporter per-file lines + * only if the artifact is missing or yields an empty set. Returns `null` + * when neither source yields a set — "expectation supplied but executed set + * indeterminable". + */ +export function defaultReadExecutedFiles( + output: string, + artefactPath: string, + cwd: string = process.cwd(), +): string[] | null { + try { + if (fs.existsSync(artefactPath)) { + const data = JSON.parse(fs.readFileSync(artefactPath, 'utf8')) as { + testResults?: Array<{ name?: unknown }>; + }; + const names = (data.testResults ?? []) + .map((r) => r.name) + .filter((n): n is string => typeof n === 'string'); + if (names.length > 0) return normaliseFiles(names, cwd); + } + } catch { + /* fall through to per-file line parsing */ + } + const fromLines = parseExecutedFromOutput(output, cwd); + return fromLines; +} + +/** + * Fallback executed-set parser for default-reporter per-file lines: + * + * ✓ |unit| tests/foo.test.ts (2 tests) 65ms + * ❯ |unit| tests/foo.test.ts (1 test | 1 failed) 8ms + * + * Returns `null` when no per-file lines are found (weaker than the JSON + * artifact; sufficient only when the output still shows each executed file). + */ +export function parseExecutedFromOutput( + output: string, + cwd: string = process.cwd(), +): string[] | null { + const files = new Set<string>(); + const re = /^\s*[✓×❯✗↓]\s+(?:\|[^|\r\n]+\|\s+)?(\S+)\s+\(\d+ tests?/gm; let match: RegExpExecArray | null; while ((match = re.exec(output)) !== null) { - starts.push(match.index); + const file = match[1]; + if (file) files.add(toRepoRelativePosix(file, cwd)); } - return starts.map((start, i) => output.slice(start, starts[i + 1] ?? output.length)); + return files.size > 0 ? [...files].sort() : null; +} + +// ── Coverage evaluation ────────────────────────────────────── + +export interface CoverageReport { + ok: boolean; + /** Size of the expected set. */ + expected: number; + /** Size of the executed set; `null` when indeterminable. */ + ran: number | null; + /** Expected files that never ran (repo-relative POSIX, sorted). */ + missing: string[]; + /** Files that ran beyond the expected set (repo-relative POSIX, sorted). */ + extra: string[]; + /** True when an expectation was supplied but the executed set was unreadable. */ + indeterminate: boolean; } /** - * True when the run's failure evidence consists *only* of the transient - * signature: at least one error block is present, and every error block - * contains a transient signature. This is the attribution step that lets the - * masking guard recover a `1 failed` worker-timeout run (the poisoned file's - * only error is the timeout itself) while still refusing a genuine assertion - * failure that merely coincides with the signature - * (CG-0MUIMM28K001W88F approach (a)). - * - * Requiring at least one transient block keeps a bare failed summary with the - * signature string elsewhere (no attributable error) from being accepted. + * Compare the executed file set against the expectation. Any difference — + * missing OR extra — fails; an unreadable executed set (`ran === null`) + * fails closed as `indeterminate`. Lists are normalized/deduplicated so the + * comparison is set-based, not order-dependent. */ -export function failuresAreTransientOnly(output: string): boolean { - const blocks = errorBlocks(output); - if (blocks.length === 0) return false; - return blocks.every( - (block) => - block.includes(WORKER_TIMEOUT_SIGNATURE) || - block.includes(BROWSER_DROP_SIGNATURE), - ); +export function evaluateCoverage( + expected: string[], + ran: string[] | null, + cwd: string = process.cwd(), +): CoverageReport { + const normalisedRan = ran ? normaliseFiles(ran, cwd) : null; + const expectedN = normaliseFiles(expected, cwd); + if (normalisedRan === null) { + return { + ok: false, + expected: expectedN.length, + ran: null, + missing: [...expectedN], + extra: [], + indeterminate: true, + }; + } + const missing = expectedN.filter((f) => !normalisedRan.includes(f)); + const extra = normalisedRan.filter((f) => !expectedN.includes(f)); + return { + ok: missing.length === 0 && extra.length === 0, + expected: expectedN.length, + ran: normalisedRan.length, + missing, + extra, + indeterminate: false, + }; +} + +/** + * Multi-line coverage failure diagnostic (written to stderr alongside the + * non-zero exit): names every missing and every extra file, and states + * `expected=N ran=M` (AC 3 of CG-0MVL21CVM003VK5H). + */ +export function coverageDiagnostic(report: CoverageReport): string { + const ranLabel = report.indeterminate ? 'indeterminate' : String(report.ran); + const lines = [ + `[coverage-failed] Vitest run's executed file set differs from the expected project set (expected=${report.expected} ran=${ranLabel}).`, + ]; + if (report.indeterminate) { + lines.push( + 'The executed file set could not be determined from the attempt (the JSON reporter', + 'artifact and per-file report lines were both unavailable). An expectation was', + 'supplied, so an unverifiable run is treated as a genuine coverage failure.', + ); + } + if (report.missing.length > 0) { + lines.push(`Missing files (never executed; ${report.missing.length}):`); + for (const file of report.missing) lines.push(` - ${file}`); + } + if (report.extra.length > 0) { + lines.push(`Extra files (executed but not expected; ${report.extra.length}):`); + for (const file of report.extra) lines.push(` - ${file}`); + } + return lines.join('\n'); +} + +// ── Coverage-aware runWithRetry ────────────────────────────── + +export interface RunWithRetryOptions { + /** + * Expected repo-relative POSIX test-file set. Absent or empty = no + * expectation (legacy behaviour, byte-identical decisions). + */ + expectedFiles?: string[]; + /** Injectable executed-set reader (see ExecutedSetCapture). */ + readExecutedFiles?: (result: VitestRunResult, artefactPath: string) => string[] | null; + /** Directory for the captured JSON artifact (defaults to os.tmpdir()). */ + workDir?: string; +} + +/** How the `[vitest-runner]` summary line records the expectation. */ +export type RunCoverageLabel = + | { kind: 'unset' } + | { kind: 'set'; expected: number; ran: number } + | { kind: 'set-indeterminate'; expected: number }; + +export function coverageLabelFromReport(report: CoverageReport): RunCoverageLabel { + if (report.indeterminate || report.ran === null) { + return { kind: 'set-indeterminate', expected: report.expected }; + } + return { kind: 'set', expected: report.expected, ran: report.ran }; +} + +/** The auditable expectation suffix for the summary line. */ +export function coverageSuffix(label: RunCoverageLabel): string { + switch (label.kind) { + case 'unset': + return ' expected=unset'; + case 'set': + return ` expected=${label.expected} ran=${label.ran}`; + case 'set-indeterminate': + return ` expected=${label.expected} ran=indeterminate`; + } } /** @@ -197,20 +779,102 @@ export function failuresAreTransientOnly(output: string): boolean { * variant of the same contention transient that previously defeated the * all-passed-only guard and failed the gate spuriously. * - * Anything else — a genuine assertion/original error, a missing summary, or a - * failure whose errors cannot be attributed to the signature — returns false, - * so a retry can never mask a real failure. + * Any of `TRANSIENT_SIGNATURES` qualifies here, including + * `MODULE_FETCH_SIGNATURE` (CG-0MVNDK56G007C1U7). Anything else — a genuine + * assertion/original error, a missing summary, or a failure whose errors + * cannot be attributed to the signature — returns false, so a retry can never + * mask a real failure. */ export function shouldRetryOnce(output: string): boolean { - if (!hasTransientSignature(output)) return false; + return hasTransientRecoveryShape(output, TRANSIENT_SIGNATURES); +} + +/** + * True when a run that already failed once may be ACCEPTED as green after the + * retry. Same two attribution shapes as `shouldRetryOnce`, but restricted to + * `ACCEPTABLE_TRANSIENT_SIGNATURES` (signatures 1 and 2): a module fetch + * failure is retry-only recovery, so a both-attempts module-fetch run keeps + * the retry's own failing status (CG-0MVNDK56G007C1U7). The behaviour for the + * two original signatures is unchanged (CG-0MUF0LU4X006IXXU). + */ +export function shouldAcceptBothAttempts(output: string): boolean { + return hasTransientRecoveryShape(output, ACCEPTABLE_TRANSIENT_SIGNATURES); +} + +/** True when the output contains any of `TRANSIENT_SIGNATURES`. */ +export function hasTransientSignature(output: string): boolean { + return containsAny(output, TRANSIENT_SIGNATURES); +} + +/** True when `output` contains at least one of `signatures`. */ +function containsAny(output: string, signatures: readonly string[]): boolean { + return signatures.some((signature) => output.includes(signature)); +} + +/** + * The shared attribution shape behind `shouldRetryOnce` / + * `shouldAcceptBothAttempts`: the output must carry one of `signatures`, and + * either every file passed with that as the sole error, or the failed summary + * is attributable solely to `signatures`. + */ +function hasTransientRecoveryShape( + output: string, + signatures: readonly string[], +): boolean { + if (!containsAny(output, signatures)) return false; const hasFailedFiles = /Test Files\s+\d+\s+failed/.test(output); const hasFailedTests = /Tests\s+\d+\s+failed/.test(output); if (hasFailedFiles || hasFailedTests) { - return failuresAreTransientOnly(output); + return failuresAreTransientOnly(output, signatures); } return /Test Files\s+\d+\s+passed \(\d+\)/.test(output); } +/** + * Split the run output into error blocks — each is the text from an error + * line up to (but not including) the next error line. Vitest prints one such + * block per unhandled error and per failed test/suite, so attributing each + * block independently tells us whether the reported failures are the + * transient signature itself or a genuine assertion/original error. + */ +export function errorBlocks(output: string): string[] { + const starts: number[] = []; + const re = /^(?:[A-Za-z][\w.]*)?Error(?:\s*\[[^\]]*\])?:/gm; + let match: RegExpExecArray | null; + while ((match = re.exec(output)) !== null) { + starts.push(match.index); + } + return starts.map((start, i) => output.slice(start, starts[i + 1] ?? output.length)); +} + +/** + * True when the run's failure evidence consists *only* of a transient + * signature: at least one error block is present, and every error block + * contains one of `signatures` (default: every retryable signature in + * `TRANSIENT_SIGNATURES`). This is the attribution step that lets the masking + * guard recover a `1 failed` worker-timeout run (the poisoned file's only + * error is the timeout itself) while still refusing a genuine assertion + * failure that merely coincides with the signature + * (CG-0MUIMM28K001W88F approach (a)). + * + * A block is the text from an error line up to the next error line, so + * Vitest's `Error: <message>` + `Caused by: <cause>` pair (`Caused by:` is not + * an error-line start) counts as ONE block — the module-fetch cause therefore + * attributes the `Failed to import test file` block to + * `MODULE_FETCH_SIGNATURE` (CG-0MVNDK56G007C1U7). + * + * Requiring at least one transient block keeps a bare failed summary with the + * signature string elsewhere (no attributable error) from being accepted. + */ +export function failuresAreTransientOnly( + output: string, + signatures: readonly string[] = TRANSIENT_SIGNATURES, +): boolean { + const blocks = errorBlocks(output); + if (blocks.length === 0) return false; + return blocks.every((block) => containsAny(block, signatures)); +} + /** * Run vitest (with retry-once) and return the final exit code. * `run` is injectable for tests; the production runner is `runVitestAsync`. @@ -226,58 +890,162 @@ export function shouldRetryOnce(output: string): boolean { * itself (CG-0MUF0LU4X006IXXU, CG-0MUIMM28K001W88F): * * 1. Retry once after an attributable transient failure. - * 2. If the retry is ALSO an attributable transient failure, accept the run - * as green (exit 0). No test failure was masked — the guard attributed - * every reported failure to the transient signature. + * 2. If the retry is ALSO an attributable transient failure of an + * *acceptable* signature (worker RPC timeout or browser WebSocket drop), + * accept the run as green (exit 0). No test failure was masked — the + * guard attributed every reported failure to the transient signature. + * `MODULE_FETCH_SIGNATURE` is deliberately excluded from this + * acceptance (`shouldAcceptBothAttempts`): a module fetch failure is + * retry-only recovery, so its retry's own status stands + * (CG-0MVNDK56G007C1U7). + * + * Opt-in coverage gate (AC 2/4 of CG-0MVL21CVM003VK5H): when `options. + * expectedFiles` is supplied, every attempt's executed file set is captured + * and compared against the expectation — on the first attempt before the + * retry decision and again on the second before any acceptance. A set + * difference (missing OR extra files) is a genuine failure: non-zero exit + * (1), never `accepted-transient`, never converted to green by the retry, + * regardless of the attempt's own exit code (a dropped group can exit 0 + * with an all-passed summary). "Expectation supplied but executed set + * indeterminable" fails identically. When no expectation is supplied every + * decision and exit code is identical to the pre-coverage runner. * * A final `[vitest-runner] attempts=N status=S outcome=... args="..."` line * is emitted via `warn` after every run, so the attempt count and outcome - * stay visible without parsing the whole captured output. (`scripts/run-ci-tests.sh` - * no longer truncates stage output, but the one-line summary remains the - * canonical machine-readable diagnostic; CG-0MUIMM28K001W88F.) + * stay visible without parsing the whole captured output. When an + * expectation is active the line additionally records `expected=N ran=M` + * (or `expected=unset` when none), making the acceptance decision auditable. + * (`scripts/run-ci-tests.sh` no longer truncates stage output, but the + * one-line summary remains the canonical machine-readable diagnostic; + * CG-0MUIMM28K001W88F.) */ export async function runWithRetry( args: string[], run: VitestRunner, warn: (msg: string) => void = console.warn, timeoutMs: number = DEFAULT_RUN_TIMEOUT_MS, + options: RunWithRetryOptions = {}, ): Promise<number> { - const first = await run(args, timeoutMs); - if (first.status === HANG_TIMEOUT_EXIT_CODE) { - // A hang is already reported by the runner; retrying would only - // double the wall-clock cost. Surface it. - warn(runSummary(args, 1, first.status, 'hang')); - return first.status; - } - if (first.status !== 0 && shouldRetryOnce(first.output)) { + const { expectedFiles, readExecutedFiles, workDir } = options; + const hasExpectation = (expectedFiles?.length ?? 0) > 0; + + /** One attempt; when an expectation is active, capture the executed set. */ + const attemptOnce = async (): Promise<{ + result: VitestRunResult; + ran: string[] | null; + }> => { + if (!hasExpectation) return { result: await run(args, timeoutMs), ran: null }; + const capture = new ExecutedSetCapture({ workDir, readExecutedFiles }); + try { + const result = await run(capture.augmentArgs(args), timeoutMs); + return { result, ran: capture.executedFiles(result) }; + } finally { + capture.cleanup(); + } + }; + + const coverageReport = (ran: string[] | null): CoverageReport | null => + hasExpectation ? evaluateCoverage(expectedFiles!, ran) : null; + + const summary = ( + attempts: number, + status: number, + outcome: string, + label: RunCoverageLabel, + ): string => runSummary(args, attempts, status, outcome, label); + + const first = await attemptOnce(); + + // Hang semantics are unchanged and take precedence over any coverage + // check (AC 8): a hang is reported, never retried. + if (first.result.status === HANG_TIMEOUT_EXIT_CODE) { warn( - '\n[retry] Vitest transient failure (worker RPC timeout or browser connection drop) ' + - 'detected with no attributable test failure. Retrying once...\n', + summary( + 1, + first.result.status, + 'hang', + hasExpectation ? { kind: 'set-indeterminate', expected: expectedFiles!.length } : { kind: 'unset' }, + ), ); - const second = await run(args, timeoutMs); - if (second.status === 0) { - warn(runSummary(args, 2, second.status, 'retry-clean')); - return second.status; - } - if (shouldRetryOnce(second.output)) { - // Both attempts completed with every test file passing; the only - // error is the post-completion teardown RPC timeout (or browser - // WebSocket drop). There is no test failure to mask, so accept the - // run as green rather than failing the gate on a runner artefact. - warn( - '\n[transient-accepted] Vitest transient teardown failure on BOTH attempts, ' + - 'but no attributable test failure in either — treating the run as green. ' + - 'No genuine failure was masked (the masking guard attributed every error ' + - 'to the transient signature).\n', - ); - warn(runSummary(args, 2, 0, 'accepted-transient')); - return 0; + return first.result.status; + } + + const firstCoverage = coverageReport(first.ran); + + // Coverage violation on the first attempt: never accepted. With an + // attributable transient excuse, retry once (the drop may have been a + // one-off contention artefact); otherwise fail now. + if (firstCoverage && !firstCoverage.ok) { + if (!shouldRetryOnce(first.result.output)) { + warn(coverageDiagnostic(firstCoverage) + '\n'); + warn(summary(1, 1, 'coverage-failed', coverageLabelFromReport(firstCoverage))); + return 1; } - warn(runSummary(args, 2, second.status, 'retry-failed')); - return second.status; + } else if (first.result.status === 0) { + // Clean run: no retry, no coverage issue. + warn(summary(1, 0, 'clean', firstCoverage ? coverageLabelFromReport(firstCoverage) : { kind: 'unset' })); + return 0; + } else if (!shouldRetryOnce(first.result.output)) { + // Genuine failure (non-zero, not attributable to the transient) — no retry. + warn(summary(1, first.result.status, 'failed', firstCoverage ? coverageLabelFromReport(firstCoverage) : { kind: 'unset' })); + return first.result.status; + } + + // Recoverable: attributable transient failure, or a coverage violation + // with a transient excuse. Retry exactly once. + warn( + '\n[retry] Vitest transient failure (worker RPC timeout, browser connection drop, ' + + 'or module fetch failure) detected with no attributable test failure. Retrying once...\n', + ); + + const second = await attemptOnce(); + + if (second.result.status === HANG_TIMEOUT_EXIT_CODE) { + warn( + summary( + 2, + second.result.status, + 'hang', + hasExpectation ? { kind: 'set-indeterminate', expected: expectedFiles!.length } : { kind: 'unset' }, + ), + ); + return second.result.status; } - warn(runSummary(args, 1, first.status, first.status === 0 ? 'clean' : 'failed')); - return first.status; + + const secondCoverage = coverageReport(second.ran); + + // Coverage is checked before any acceptance — a violation on the second + // attempt is a genuine failure even if the run looks transient-recoverable. + if (secondCoverage && !secondCoverage.ok) { + warn(coverageDiagnostic(secondCoverage) + '\n'); + warn(summary(2, 1, 'coverage-failed', coverageLabelFromReport(secondCoverage))); + return 1; + } + + if (second.result.status === 0) { + warn(summary(2, 0, 'retry-clean', secondCoverage ? coverageLabelFromReport(secondCoverage) : { kind: 'unset' })); + return second.result.status; + } + if (shouldAcceptBothAttempts(second.result.output)) { + // Both attempts completed with every test file passing (and, when an + // expectation is active, the executed set matched it); the only error is + // the post-completion teardown RPC timeout (or browser WebSocket drop). + // There is no test failure to mask, so accept the run as green rather + // than failing the gate on a runner artefact. Deliberately NOT extended + // to MODULE_FETCH_SIGNATURE: a genuine module-resolution regression is + // deterministic, so its retry's own status must stand + // (CG-0MVNDK56G007C1U7). + warn( + '\n[transient-accepted] Vitest transient teardown failure on BOTH attempts, ' + + 'but no attributable test failure in either — treating the run as green. ' + + 'No genuine failure was masked (the masking guard attributed every error ' + + 'to the transient signature).\n', + ); + warn(summary(2, 0, 'accepted-transient', secondCoverage ? coverageLabelFromReport(secondCoverage) : { kind: 'unset' })); + return 0; + } + warn(summary(2, second.result.status, 'retry-failed', secondCoverage ? coverageLabelFromReport(secondCoverage) : { kind: 'unset' })); + return second.result.status; } /** @@ -285,14 +1053,22 @@ export async function runWithRetry( * the runner's output so the attempt count and outcome are easy to locate * (and were preserved under the CI wrapper's former `tail -20` truncation, * now removed; CG-0MUF0LU4X006IXXU / CG-0MUIMM28K001W88F). + * + * `coverage` (optional) records the expectation state for auditability: + * `expected=N ran=M` when a set was evaluated, `expected=N ran=indeterminate` + * when it existed but could not be read, or `expected=unset` when no + * expectation was supplied. Omitting it keeps the output byte-identical for + * callers that invoke runSummary directly. */ export function runSummary( args: string[], attempts: number, status: number, outcome: string, + coverage?: RunCoverageLabel, ): string { - return `[vitest-runner] attempts=${attempts} status=${status} outcome=${outcome} args="${args.join(' ')}"`; + const suffix = coverage ? coverageSuffix(coverage) : ''; + return `[vitest-runner] attempts=${attempts} status=${status} outcome=${outcome} args="${args.join(' ')}"${suffix}`; } /** @@ -315,52 +1091,62 @@ export function resolveVitestBin(cwd: string = process.cwd()): string { ); } +// ── Process spawning with a wall-clock bound ───────────────── + +interface SpawnOptions { + /** Stream the child's stdout/stderr to the parent as it is produced. */ + streamToParent: boolean; + /** Bounded tail retained in `output` ('tail') or full capture ('full'). */ + mode: 'tail' | 'full'; + timeoutMs: number; + /** SIGTERM → SIGKILL grace period when the bound elapses. */ + killGraceMs: number; +} + +const MAX_CAPTURED_TAIL = 8 * 1024 * 1024; + /** - * Production runner: spawn `node <vitest-bin> run <args>` asynchronously in - * its own process group, streaming combined output to the parent process - * while capturing a bounded tail for the masking-guard signature check. - * - * The wall-clock bound is enforced with an async timer: when it elapses the - * whole process group is SIGTERMed (graceful), and SIGKILLed after - * `killGraceMs` if it survives. An async `spawn` is required here — a - * `spawnSync` with a `timeout` hangs forever if the child ignores SIGTERM - * (a genuinely hung vitest would stall the runner instead of aborting). - * - * `binPath` and `killGraceMs` are injectable for tests. + * Shared spawn core: run `process.execPath <argv[0]...>` in its own process + * group with a bounded wall-clock timer. On timeout the whole group is + * SIGTERMed and SIGKILLed after `killGraceMs` if it survives; a timed-out + * return carries `HANG_TIMEOUT_EXIT_CODE`. Full capture is bounded for + * 'tail' mode only. */ -export async function runVitestAsync( - args: string[], - timeoutMs: number, - binPath: string = resolveVitestBin(), - killGraceMs: number = KILL_GRACE_MS, -): Promise<VitestRunResult> { +interface VitestProcessResult extends VitestRunResult { + timedOut: boolean; + /** Wall time in whole seconds before the attempt returned. */ + elapsedSecs: number; +} + +async function runVitestProcess( + argv: string[], + opts: SpawnOptions, +): Promise<VitestProcessResult> { const startedAt = Date.now(); - // Spawn into its own process group so the timeout can signal the whole - // tree (vitest + tinypool workers + Playwright Chromium), not just the - // direct child. - const child = spawn(process.execPath, [binPath, 'run', ...args], { + let captured = ''; + const child = spawn(process.execPath, argv, { cwd: process.cwd(), stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env }, detached: true, }); - // Bounded captured tail (the transient signatures and the reporter - // summary — the only things the masking guard reads — appear at the END - // of a run's output). Keeps memory bounded for a full suite (~10s of MB). - const MAX_CAPTURE = 8 * 1024 * 1024; - let captured = ''; - const sink = (stream: NodeJS.ReadableStream, target: NodeJS.WriteStream) => { + const sink = (stream: NodeJS.ReadableStream, target?: NodeJS.WriteStream) => { stream.on('data', (chunk: Buffer) => { - target.write(chunk); + if (target) target.write(chunk); captured += chunk.toString(); - if (captured.length > MAX_CAPTURE) { - captured = captured.slice(-MAX_CAPTURE); + if (opts.mode === 'tail' && captured.length > MAX_CAPTURED_TAIL) { + captured = captured.slice(-MAX_CAPTURED_TAIL); } }); }; - sink(child.stdout, process.stdout); - sink(child.stderr, process.stderr); + if (opts.streamToParent) { + sink(child.stdout, process.stdout); + sink(child.stderr, process.stderr); + } else { + sink(child.stdout); + sink(child.stderr); + } // Signal the whole process group. Negative pid = the group led by the // detached child. @@ -382,9 +1168,9 @@ export async function runVitestAsync( // process that ignores SIGTERM cannot stall the runner. const escalation = setTimeout(() => { signalGroup('SIGKILL'); - }, killGraceMs); + }, opts.killGraceMs); escalation.unref?.(); - }, timeoutMs); + }, opts.timeoutMs); killTimer.unref?.(); const status = await new Promise<number>((resolve) => { @@ -395,60 +1181,105 @@ export async function runVitestAsync( }); clearTimeout(killTimer); - if (timedOut) { - const elapsedSecs = Math.max(1, Math.round((Date.now() - startedAt) / 1000)); - const message = hangTimeoutMessage(elapsedSecs); + const elapsedSecs = Math.max(1, Math.round((Date.now() - startedAt) / 1000)); + return { status, output: captured, timedOut, elapsedSecs }; +} + +/** + * Production runner: spawn `node <vitest-bin> run <args>` asynchronously in + * its own process group, streaming combined output to the parent process + * while capturing a bounded tail for the masking-guard signature check. + * + * The wall-clock bound is enforced with an async timer: when it elapses the + * whole process group is SIGTERMed (graceful), and SIGKILLed after + * `killGraceMs` if it survives. An async `spawn` is required here — a + * `spawnSync` with a `timeout` hangs forever if the child ignores SIGTERM + * (a genuinely hung vitest would stall the runner instead of aborting). + * + * `binPath` and `killGraceMs` are injectable for tests. + */ +export async function runVitestAsync( + args: string[], + timeoutMs: number, + binPath: string = resolveVitestBin(), + killGraceMs: number = KILL_GRACE_MS, +): Promise<VitestRunResult> { + const attempted = await runVitestProcess([binPath, 'run', ...args], { + streamToParent: true, + mode: 'tail', + timeoutMs, + killGraceMs, + }); + if (attempted.timedOut) { + const message = hangTimeoutMessage(attempted.elapsedSecs); process.stderr.write(message + '\n'); - return { status: HANG_TIMEOUT_EXIT_CODE, output: captured + message }; + return { status: HANG_TIMEOUT_EXIT_CODE, output: attempted.output + message }; } - return { status, output: captured }; + return { status: attempted.status, output: attempted.output }; } /** Backwards-compatible alias kept for imports that referenced the sync name. */ export const runVitestSync = runVitestAsync; /** - * Parse the runner's own `--timeout-ms <n>` / `--timeout-ms=<n>` flag out - * of the CLI arguments, forwarding everything else to vitest. Throws on an - * invalid value so `main` can exit 2 with a clear message. + * Production pre-flight collector: spawn + * `node <vitest-bin> list <args>` in its own process group with the same + * wall-clock bound and SIGTERM/SIGKILL escalation as a run. Output is fully + * captured (not streamed — the collection is a quiet pre-check) and an + * overrun reports exit 124 with the abbreviated hang diagnostic. + * + * `binPath` and `killGraceMs` are injectable for tests. */ -export function parseTimeoutArgs(args: string[]): { - timeoutMs: number; - forwarded: string[]; -} { - let timeoutMs = DEFAULT_RUN_TIMEOUT_MS; - const forwarded: string[] = []; - for (let i = 0; i < args.length; i++) { - const arg = args[i]; - let raw: string | undefined; - if (arg === '--timeout-ms') { - raw = args[++i]; - } else if (arg.startsWith('--timeout-ms=')) { - raw = arg.split('=')[1]; - } else { - forwarded.push(arg); - continue; - } - const parsed = Number(raw); - if (raw === undefined || !Number.isFinite(parsed) || parsed <= 0) { - throw new Error( - `Invalid --timeout-ms value: "${raw}". Expected a positive number of milliseconds.`, - ); - } - timeoutMs = parsed; +export async function runVitestListAsync( + args: string[], + timeoutMs: number, + binPath: string = resolveVitestBin(), + killGraceMs: number = KILL_GRACE_MS, +): Promise<VitestRunResult> { + const attempted = await runVitestProcess([binPath, 'list', ...args], { + streamToParent: false, + mode: 'full', + timeoutMs, + killGraceMs, + }); + if (attempted.timedOut) { + const message = listHangMessage(attempted.elapsedSecs); + process.stderr.write(message + '\n'); + return { status: HANG_TIMEOUT_EXIT_CODE, output: attempted.output + message }; } - return { timeoutMs, forwarded }; + return { status: attempted.status, output: attempted.output }; } async function main(): Promise<void> { - let parsed: { timeoutMs: number; forwarded: string[] }; + let parsed: ParsedRunnerArgs; try { - parsed = parseTimeoutArgs(process.argv.slice(2)); + parsed = parseRunnerArgs(process.argv.slice(2)); } catch (err) { process.stderr.write(`${(err as Error).message}\n`); process.exit(2); } - process.exit(await runWithRetry(parsed.forwarded, runVitestAsync, console.warn, parsed.timeoutMs)); + + // Opt-in expectation (AC 1): derive from the project, or pin explicitly. + let expectedFiles: string[] | undefined; + if (parsed.expectProjectCoverage) { + try { + expectedFiles = await deriveExpectedFiles(parsed.forwarded, runVitestListAsync); + } catch (err) { + if (err instanceof PreflightError) { + process.stderr.write(preflightFailureMessage(err.message) + '\n'); + process.exit(PREFLIGHT_FAILURE_EXIT_CODE); + } + throw err; + } + } else if (parsed.expectedFiles.length > 0) { + expectedFiles = normaliseFiles(parsed.expectedFiles); + } + + process.exit( + await runWithRetry(parsed.forwarded, runVitestAsync, console.warn, parsed.timeoutMs, { + expectedFiles, + }), + ); } // Entry-point guard: run main() only when executed as a script, so the diff --git a/src/core-engine/GameEventEmitter.ts b/src/core-engine/GameEventEmitter.ts index d918525c8..d06781019 100644 --- a/src/core-engine/GameEventEmitter.ts +++ b/src/core-engine/GameEventEmitter.ts @@ -7,6 +7,34 @@ * * Games emit these events at key lifecycle points. Tools (replay, * testing, telemetry) subscribe to them for synchronization. + * + * # Game-specific events + * + * The engine's vocabulary is deliberately small and game-agnostic. A game + * (or any consumer) can declare its own typed event names without editing + * this module by augmenting the exported {@link GameEventMap} interface: + * + * ```ts + * // In the game's own source, next to its event declarations. + * declare module '@core-engine/GameEventEmitter' { + * interface GameEventMap { + * 'face-off-resolved': { slot: number; winner: 'player' | 'adversary' }; + * } + * } + * + * gameEvents.emit('face-off-resolved', { slot: 2, winner: 'player' }); + * gameEvents.on('face-off-resolved', (payload) => { … }); + * ``` + * + * Interface augmentation is used instead of a generic + * `GameEventEmitter<M extends GameEventMap>` parameter because it is + * additive and non-breaking: existing consumers keep the default map with + * no type parameter, declarations merge from any module in the program, + * and no game vocabulary ever enters the engine. The augmentation is a + * type-level contract only — the emitter stays name-agnostic at runtime, + * which is what lets {@link GameEventEmitter.onAny} forward every event + * (engine or game-specific) to the Phaser bridge and the debug event + * buffer without per-game name lists. */ import type { GamePhase } from './GameState'; @@ -304,6 +332,18 @@ export type GameEventListener<K extends GameEventName> = ( payload: GameEventMap[K], ) => void; +/** + * A callback for every emission, regardless of event name. + * + * Used by the Phaser bridge and the debug event buffer, which forward + * or record engine and game-specific events alike without maintaining + * per-game name lists. + */ +export type GameEventAnyListener = ( + eventName: GameEventName, + payload: GameEventMap[GameEventName], +) => void; + // ── Emitter ───────────────────────────────────────────────── /** @@ -323,6 +363,9 @@ export class GameEventEmitter { [K in GameEventName]?: Array<GameEventListener<K>>; } = {}; + /** Catch-all listeners, fired for every emission. */ + private anyListeners: GameEventAnyListener[] = []; + /** * Subscribe to an event. Returns an unsubscribe function. */ @@ -377,31 +420,70 @@ export class GameEventEmitter { } } + /** + * Subscribe to every emission, whatever its name — engine or + * game-specific (see the module doc for the augmentation mechanism). + * Returns an unsubscribe function. + * + * Catch-all listeners run after the per-name listeners, in + * registration order, with the same snapshot semantics as {@link on}: + * a listener may unsubscribe during an emission without skipping its + * peers. + */ + onAny(listener: GameEventAnyListener): () => void { + this.anyListeners.push(listener); + return () => this.offAny(listener); + } + + /** + * Remove a catch-all listener registered with {@link onAny}. + */ + offAny(listener: GameEventAnyListener): void { + const index = this.anyListeners.indexOf(listener); + if (index !== -1) { + this.anyListeners.splice(index, 1); + } + } + /** * Emit an event with the given payload. * Listeners are called synchronously in registration order. + * Emitting with no listeners attached is a no-op. */ emit<K extends GameEventName>(event: K, payload: GameEventMap[K]): void { const list = this.listeners[event] as | Array<GameEventListener<K>> | undefined; - if (!list || list.length === 0) return; - // Copy the array so listeners can safely unsubscribe during emission - const snapshot = [...list]; + if ((!list || list.length === 0) && this.anyListeners.length === 0) { + return; + } + + // Copy the arrays so listeners can safely unsubscribe during emission + const snapshot = list ? [...list] : []; for (const fn of snapshot) { fn(payload); } + + const anySnapshot = [...this.anyListeners]; + for (const fn of anySnapshot) { + fn(event, payload as GameEventMap[GameEventName]); + } } /** * Remove all listeners, optionally for a specific event only. + * + * Without an argument every listener (per-name and catch-all) is + * removed; with an event name only that event's per-name listeners + * are removed and catch-all subscribers stay attached. */ removeAllListeners(event?: GameEventName): void { if (event) { delete this.listeners[event]; } else { this.listeners = {}; + this.anyListeners = []; } } @@ -412,4 +494,11 @@ export class GameEventEmitter { const list = this.listeners[event]; return list ? list.length : 0; } + + /** + * Return the number of catch-all listeners (see {@link onAny}). + */ + anyListenerCount(): number { + return this.anyListeners.length; + } } diff --git a/src/core-engine/PhaserEventBridge.ts b/src/core-engine/PhaserEventBridge.ts index 72265a920..47fa02391 100644 --- a/src/core-engine/PhaserEventBridge.ts +++ b/src/core-engine/PhaserEventBridge.ts @@ -40,8 +40,17 @@ export interface PhaserLikeEventEmitter { // ── Bridge ────────────────────────────────────────────────── -/** All event names we bridge. */ -const ALL_EVENTS: GameEventName[] = [ +/** + * Event names bridged Phaser -> engine. + * + * A Phaser event emitter is un-typed, so there is no way to enumerate the + * names a game may emit on the Phaser side; this list therefore stays a + * hard-coded set of the engine's known names. The engine -> Phaser + * direction does NOT use this list: it subscribes with + * {@link GameEventEmitter.onAny} so every event, engine or game-specific, + * is forwarded without per-game name lists. + */ +const PHASER_TO_ENGINE_EVENTS: GameEventName[] = [ 'turn-started', 'turn-completed', 'animation-complete', @@ -91,24 +100,24 @@ export class PhaserEventBridge { // ── Engine -> Phaser ──────────────────────────────────── private wireEngineToPhaser(): void { - for (const event of ALL_EVENTS) { - const unsub = this.engine.on(event, (payload: GameEventMap[typeof event]) => { - if (this.forwarding) return; // prevent re-entry - this.forwarding = true; - try { - this.phaser.emit(event, payload); - } finally { - this.forwarding = false; - } - }); - this.engineUnsubs.push(unsub); - } + // Catch-all: every emitted event, engine or game-specific, is + // forwarded. No per-game name list is maintained here. + const unsub = this.engine.onAny((event, payload) => { + if (this.forwarding) return; // prevent re-entry + this.forwarding = true; + try { + this.phaser.emit(event, payload); + } finally { + this.forwarding = false; + } + }); + this.engineUnsubs.push(unsub); } // ── Phaser -> Engine ──────────────────────────────────── private wirePhaserToEngine(): void { - for (const event of ALL_EVENTS) { + for (const event of PHASER_TO_ENGINE_EVENTS) { const handler = (payload: unknown) => { if (this.forwarding) return; // prevent re-entry this.forwarding = true; 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/src/core-engine/transcript/TranscriptStore.ts b/src/core-engine/transcript/TranscriptStore.ts index 3a510daec..ada40f242 100644 --- a/src/core-engine/transcript/TranscriptStore.ts +++ b/src/core-engine/transcript/TranscriptStore.ts @@ -287,6 +287,8 @@ export class TranscriptStore { private readonly localStoragePrefix: string; private initPromise: Promise<void> | null = null; private seqCounter: number = 0; + /** Set once a disk-persistence failure has been reported (log-once gate). */ + private diskPersistenceIssueReported = false; constructor(options: TranscriptStoreOptions = {}) { this.maxPerGame = options.maxPerGame ?? DEFAULT_MAX_PER_GAME; @@ -436,12 +438,21 @@ export class TranscriptStore { * Fire-and-forget POST to the Vite dev-server transcript plugin. * * Sends { gameType, transcript } to /api/transcripts so the plugin - * can write the transcript to disk during development. Errors are - * logged as warnings but never propagate -- this must never break - * the save() return path. + * can write the transcript to disk during development. Errors never + * propagate -- this must never break the save() return path. + * + * Quiet by design (MS-0MVREB31W004F48D): the request is only issued where + * the endpoint can exist (`import.meta.env.DEV` — a Vite dev server), and + * a non-2xx / unreachable outcome is reported **at most once per store + * instance**. Repeating a warning on every game-over for a condition that + * is expected in some environments (a scaffolded repo that never registered + * the plugin, a Vitest browser run, a production/Electron build) trains + * readers to ignore the console; one quiet note keeps the contract + * diagnosable without the noise. */ private postToDisk<T>(gameType: string, transcript: T): void { if (typeof fetch === 'undefined') return; + if (!this.isDevMode()) return; fetch('/api/transcripts', { method: 'POST', @@ -449,17 +460,73 @@ export class TranscriptStore { body: JSON.stringify({ gameType, transcript }), }) .then((res) => { - if (!res.ok) { - console.warn( + if (res.ok) return; + if (res.status === 404 || res.status === 405) { + // Expected absence: the endpoint has not been registered (e.g. the + // generated vite.config.ts predates the registration, or this is a + // test/prod runtime with the plugin disabled). Report once, quietly. + this.logDiskPersistenceIssue( + 'debug', + `[TranscriptStore] Disk persistence endpoint unavailable (HTTP ${res.status}); further notices suppressed this session`, + ); + } else { + // Genuine server-side failure (5xx etc.) — still once, but louder. + this.logDiskPersistenceIssue( + 'warn', `[TranscriptStore] Disk persistence POST returned HTTP ${res.status}`, ); } }) .catch((err) => { - console.warn('[TranscriptStore] Disk persistence POST failed:', err); + // Unreachable dev server (restart, host pressure) — one quiet note. + this.logDiskPersistenceIssue( + 'debug', + '[TranscriptStore] Disk persistence POST failed; further notices suppressed this session', + err, + ); }); } + /** + * Whether the `/api/transcripts` endpoint can exist in this runtime. + * + * Only a Vite dev server registers the transcript-persist middleware, so + * the disk mirror is issued from `import.meta.env.DEV` builds only. Vite + * replaces `import.meta.env` with a literal env object at build time, so a + * production bundle reads `DEV: false` and never issues the POST; the + * optional access keeps a plain Node import (CLI/unit tests) from throwing + * when `import.meta.env` is not injected. Single point of truth: override + * this in tests rather than fabricating a Vite env. + */ + protected isDevMode(): boolean { + const env = (import.meta as { env?: { DEV?: string | boolean } }).env; + return Boolean(env?.DEV); + } + + /** + * Report a disk-persistence failure at most once per store instance. + * + * `postToDisk` runs once per `save()`; without this gate a misconfigured + * repo or a dying dev server would flood the console with an identical + * message on every game-over. + */ + private logDiskPersistenceIssue( + level: 'debug' | 'warn', + message: string, + detail?: unknown, + ): void { + if (this.diskPersistenceIssueReported) return; + this.diskPersistenceIssueReported = true; + if (level === 'warn') { + if (detail === undefined) console.warn(message); + else console.warn(message, detail); + } else if (detail === undefined) { + console.debug(message); + } else { + console.debug(message, detail); + } + } + /** * Evict oldest transcripts if the count exceeds maxPerGame. */ diff --git a/src/story-line/compile.ts b/src/story-line/compile.ts new file mode 100644 index 000000000..ee4f1297a --- /dev/null +++ b/src/story-line/compile.ts @@ -0,0 +1,252 @@ +/** + * 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, + OptionCost, + 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<TState> { + register(eventId: string, options: readonly StorylineOption<TState>[]): void; + reset(): void; + has(eventId: string): boolean; + get(eventId: string): StorylineOption<TState>[] | undefined; +} + +/** Creates an empty option registry keyed by base type id. */ +export function createStorylineOptionRegistry<TState>( + baseTypeId: (id: string) => string, +): StorylineOptionRegistry<TState> { + const map = new Map<string, StorylineOption<TState>[]>(); + return { + register(eventId: string, options: readonly StorylineOption<TState>[]): 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<TState>[] | 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<TState>( + encoding: string, +): StorylineOption<TState>[] { + const trimmed = encoding.trim(); + if (trimmed === '') return []; + + const options: StorylineOption<TState>[] = []; + 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; +} + +/** + * 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. + * + * 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` **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 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<TState, TCard extends StorylineCard>( + event: TCard, + registry: StorylineOptionRegistry<TState>, + state?: TState, + getResources?: (state: TState) => { coins: number; reputation: number }, +): CompiledStoryline<TState> { + const storylineId = event.storylineId ?? null; + const registered = registry.get(event.id); + if (registered) { + const options = registered.map((o) => ({ ...o })); + if (state) { + // 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)) && + (resources == null || isOptionAffordable(o, resources)), + ), + }; + } + 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<TState>(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<TState>[], + }; +} + +/** + * 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<TState, TCard extends StorylineCard>( + event: TCard, + registry: StorylineOptionRegistry<TState>, +): boolean { + return ( + Boolean(event.hasChoices) || + Boolean(event.options && event.options.trim() !== '') || + registry.has(event.id) + ); +} diff --git a/src/story-line/content/adapters.ts b/src/story-line/content/adapters.ts new file mode 100644 index 000000000..1cb3ecb70 --- /dev/null +++ b/src/story-line/content/adapters.ts @@ -0,0 +1,112 @@ +/** + * Shipped content adapters. + * + * Core ships one **default** adapter — the Gym demo content — so the authoring + * toolchain has a working, game-free target out of the box, plus a registry the + * `tsx` CLIs resolve `--game <id>` through. A game ships its own adapter (plain + * data, see {@link ContentAdapter}); nothing here imports a game repository. + * + * The Gym adapter also demonstrates the seam's game-column story: the Gym + * demo's coin/reputation deltas are ordinary extra columns, and its + * `describeImpact` hook turns them into a human-readable manifest label + * without core knowing what a coin is. + * + * @module + */ + +import type { ContentAdapter } from './types'; + +/** Relative path of the default (Gym) story-line content file. */ +export const DEFAULT_CONTENT_PATH = 'example-games/gym/content/storylines.csv'; + +/** + * The Gym demo content adapter. + * + * Columns mirror the Gym story fixture (`GymStorylineDemo`): the generic + * story-line fields plus `trigger`, `cost`, `coinDelta` and + * `reputationDelta` as game columns. + */ +export const GYM_CONTENT_ADAPTER: ContentAdapter = { + gameId: 'gym', + columns: [ + 'id', + 'name', + 'effect', + 'options', + 'hasChoices', + 'acceptNextCardId', + 'rejectNextCardId', + 'storylineId', + 'storylineTitle', + 'trigger', + 'cost', + 'coinDelta', + 'reputationDelta', + ], + fields: { + id: 'id', + name: 'name', + effect: 'effect', + hasChoices: 'hasChoices', + acceptNextCardId: 'acceptNextCardId', + rejectNextCardId: 'rejectNextCardId', + options: 'options', + storylineId: 'storylineId', + storylineTitle: 'storylineTitle', + }, + editableColumns: ['trigger', 'cost', 'coinDelta', 'reputationDelta'], + newCardDefaults: { + options: '', + hasChoices: 'false', + acceptNextCardId: '', + rejectNextCardId: '', + storylineId: '', + storylineTitle: '', + trigger: 'Incident', + cost: '0', + coinDelta: '0', + reputationDelta: '0', + }, + describeImpact: (card) => { + const parts: string[] = []; + const coins = Number(card.fields.coinDelta ?? ''); + const reputation = Number(card.fields.reputationDelta ?? ''); + const signed = (value: number): string => + value < 0 ? `−${Math.abs(value)}` : `+${value}`; + if (Number.isFinite(coins) && coins !== 0) parts.push(`${signed(coins)} coins`); + if (Number.isFinite(reputation) && reputation !== 0) { + parts.push(`${signed(reputation)} reputation`); + } + return parts.join(' · '); + }, +}; + +/** Every adapter shipped with the engine, keyed by game id. */ +export const CONTENT_ADAPTERS: Readonly<Record<string, ContentAdapter>> = { + [GYM_CONTENT_ADAPTER.gameId]: GYM_CONTENT_ADAPTER, +}; + +/** The adapter the CLIs use when `--game` is omitted. */ +export const DEFAULT_CONTENT_ADAPTER: ContentAdapter = GYM_CONTENT_ADAPTER; + +/** + * Looks up a shipped content adapter by game id. + * + * @param gameId The adapter's game id (e.g. `gym`). + * @returns The adapter, or `undefined` when no such adapter is shipped. + */ +export function getContentAdapter(gameId: string): ContentAdapter | undefined { + return CONTENT_ADAPTERS[gameId]; +} + +/** + * Resolves a `--game` argument to an adapter, defaulting to the Gym adapter + * when the argument is absent. + * + * @param gameId Optional game id. + * @returns The adapter, or `undefined` when an unknown id was supplied. + */ +export function resolveContentAdapter(gameId?: string): ContentAdapter | undefined { + if (gameId === undefined || gameId === '') return DEFAULT_CONTENT_ADAPTER; + return getContentAdapter(gameId); +} diff --git a/src/story-line/content/artefacts.ts b/src/story-line/content/artefacts.ts new file mode 100644 index 000000000..7048513b0 --- /dev/null +++ b/src/story-line/content/artefacts.ts @@ -0,0 +1,201 @@ +/** + * Generated story-line artefacts: the committed Mermaid graph and JSON manifest. + * + * The editor and the `storylines:graph` CLI both need the *same* two outputs + * for a content file, and both need the same drift answer ("are the committed + * artefacts still in step with the content?"). This module is that single + * implementation: + * + * - {@link renderArtefacts} derives both texts from a manifest (reusing the + * manifest module's renderers — there is no second Mermaid/JSON encoder); + * - {@link checkArtefacts} compares them with the committed files, reporting + * `missing` and `drift` separately so a caller can give an actionable + * message; + * - {@link writeArtefacts} regenerates both, then re-reads them and reports + * the drift check, so a "regenerated" claim is always verified rather than + * assumed. + * + * These are **generated** artefacts. Hand-written documentation is not + * generated here and is never rewritten: the write bridge reports a + * documented row as requiring a manual doc update instead. + * + * This module is Phaser-free and game-import-free. It writes only generated + * artefacts — the story-line content CSV itself is written exclusively by + * `author.ts`. + * + * @module + */ + +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { renderMermaid, serialiseManifest, type StorylineManifest } from './manifest'; + +/** The two generated artefact kinds, in a stable order. */ +export type ArtefactFormat = 'json' | 'mermaid'; + +/** Paths of the generated artefacts, in whatever base directory the caller uses. */ +export interface ArtefactPaths { + /** Path of the JSON manifest artefact. */ + readonly json: string; + /** Path of the Mermaid flowchart artefact. */ + readonly mermaid: string; +} + +/** + * The committed Gym artefact paths (repository-relative). + * + * The Gym is the default adapter's game, so these are the defaults every tool + * uses unless a game passes its own paths. + */ +export const DEFAULT_ARTEFACT_PATHS: ArtefactPaths = { + json: 'example-games/gym/content/storyline-manifest.json', + mermaid: 'example-games/gym/content/storyline-graph.mmd', +} as const; + +/** The rendered text of both artefacts for one manifest. */ +export interface ArtefactContent { + /** The JSON manifest text. */ + readonly json: string; + /** The Mermaid flowchart text. */ + readonly mermaid: string; +} + +/** One committed artefact that is not in step with the content. */ +export interface ArtefactDrift { + /** Which artefact drifted. */ + readonly format: ArtefactFormat; + /** The path that was checked (as supplied, resolved for reading). */ + readonly path: string; + /** Why it is out of step. */ + readonly reason: 'missing' | 'drift'; + /** Actionable message naming the artefact and the regeneration command. */ + readonly message: string; +} + +/** The result of regenerating the committed artefacts. */ +export interface ArtefactWriteResult { + /** The paths that were targeted. */ + readonly paths: ArtefactPaths; + /** The paths actually written. */ + readonly written: readonly string[]; + /** Post-write drift check — empty means the artefacts are in sync. */ + readonly drifts: readonly ArtefactDrift[]; + /** True when every artefact was written and verified in sync. */ + readonly ok: boolean; +} + +/** Renders both artefacts from a manifest (deterministic, timestamp-free). */ +export function renderArtefacts(manifest: StorylineManifest): ArtefactContent { + return { + json: serialiseManifest(manifest), + mermaid: renderMermaid(manifest), + }; +} + +/** Compares one artefact's committed text with the expected text. */ +function compareArtefact( + format: ArtefactFormat, + path: string, + expected: string, + baseDir: string, +): ArtefactDrift | undefined { + const absolute = resolve(baseDir, path); + if (!existsSync(absolute)) { + return { + format, + path, + reason: 'missing', + message: + `missing committed ${format} artefact ${path} — regenerate with ` + + `\`npm run storylines:graph -- --format ${format}\``, + }; + } + if (readFileSync(absolute, 'utf-8') !== expected) { + return { + format, + path, + reason: 'drift', + message: + `drift detected in ${path} — regenerate with ` + + `\`npm run storylines:graph -- --format ${format}\``, + }; + } + return undefined; +} + +/** + * Checks the committed artefacts against a manifest. + * + * @param manifest The manifest the content currently produces. + * @param paths Artefact paths (defaults to {@link DEFAULT_ARTEFACT_PATHS}). + * @param baseDir Base directory relative paths resolve against (default + * `process.cwd()`). + * @returns Every drift found (empty when both artefacts are in sync). + */ +export function checkArtefacts( + manifest: StorylineManifest, + paths: ArtefactPaths = DEFAULT_ARTEFACT_PATHS, + baseDir: string = process.cwd(), +): ArtefactDrift[] { + const content = renderArtefacts(manifest); + const drifts: ArtefactDrift[] = []; + const json = compareArtefact('json', paths.json, content.json, baseDir); + if (json) drifts.push(json); + const mermaid = compareArtefact('mermaid', paths.mermaid, content.mermaid, baseDir); + if (mermaid) drifts.push(mermaid); + return drifts; +} + +/** + * Regenerates both committed artefacts and verifies the result. + * + * The drift check is run **after** writing, by reading the files back, so the + * returned `ok` is evidence rather than an assumption. + * + * @param manifest The manifest the accepted content produces. + * @param paths Artefact paths (defaults to {@link DEFAULT_ARTEFACT_PATHS}). + * @param baseDir Base directory relative paths resolve against. + * @returns What was written plus the post-write drift check. + */ +export function writeArtefacts( + manifest: StorylineManifest, + paths: ArtefactPaths = DEFAULT_ARTEFACT_PATHS, + baseDir: string = process.cwd(), +): ArtefactWriteResult { + const content = renderArtefacts(manifest); + const written: string[] = []; + const targets: readonly (readonly [ArtefactFormat, string, string])[] = [ + ['json', paths.json, content.json], + ['mermaid', paths.mermaid, content.mermaid], + ]; + for (const [, path, text] of targets) { + writeFileSync(resolve(baseDir, path), text, 'utf-8'); + written.push(path); + } + const drifts = checkArtefacts(manifest, paths, baseDir); + return { paths, written, drifts, ok: drifts.length === 0 }; +} + +/** + * The artefacts an accepted edit *would* produce, without writing anything + * (the `--dry-run` preview of the regeneration step). + * + * @param manifest The manifest the candidate content produces. + * @param paths Artefact paths. + * @param baseDir Base directory relative paths resolve against. + * @returns The rendered artefact text. + */ +export function previewArtefacts( + manifest: StorylineManifest, + paths: ArtefactPaths = DEFAULT_ARTEFACT_PATHS, + baseDir: string = process.cwd(), +): { + readonly content: ArtefactContent; + readonly drifts: readonly ArtefactDrift[]; +} { + return { + content: renderArtefacts(manifest), + drifts: checkArtefacts(manifest, paths, baseDir), + }; +} diff --git a/src/story-line/content/author.ts b/src/story-line/content/author.ts new file mode 100644 index 000000000..a6cefa138 --- /dev/null +++ b/src/story-line/content/author.ts @@ -0,0 +1,169 @@ +/** + * Transactional story-line content authoring. + * + * This module is the **only writer** of story-line content CSVs in the engine. + * Every edit follows the same transaction: + * + * 1. parse the current content; + * 2. apply the mutation to an in-memory copy (see {@link ../mutate} — the + * pure half of this seam, Node-free); + * 3. validate the resulting content with {@link validateContentCsv}; + * 4. only when validation passes, write the file atomically + * (temp file + rename in the same directory); + * 5. on validation failure, return the errors and leave the file untouched. + * + * `--dry-run` support is part of the API (`dryRun`): the candidate text is + * returned for preview but nothing is written. + * + * The pure in-memory mutations (`addContentCard`, `updateContentCard`, + * `updateContentCards`, `readContentCards`, `hasContentCard` and their + * result/spec types) live in {@link ../mutate} and are re-exported here so + * existing callers keep one import surface — but a browser or form pipeline + * that only needs preview/validation should import the pure module directly, + * never this one (it binds `node:fs`). + * + * The module is Phaser-free and game-import-free: every game-specific detail + * arrives through the {@link ContentAdapter}. + * + * @module + */ + +import { readFileSync, renameSync, writeFileSync } from 'node:fs'; + +import { computeContentChecksum } from './csv'; +import { type ContentAdapter } from './types'; +import { validateContentCsv } from './validate'; +import type { ContentEditResult, ContentMutation } from './mutate'; + +export type { + AddContentCardSpec, + ContentEditResult, + ContentMutation, + UpdateContentCardSpec, +} from './mutate'; +export { + addContentCard, + hasContentCard, + readContentCards, + updateContentCard, + updateContentCards, +} from './mutate'; + +// ── File writer (the only content writer) ─────────────────── + +/** The outcome of a file-level content edit. */ +export interface ContentFileEditResult extends ContentEditResult { + /** The content path the edit targeted (absolute or caller-supplied). */ + readonly path: string; + /** True only when the file was actually written. */ + readonly written: boolean; + /** + * True when the writer refused the edit because the file's checksum no + * longer matches the caller's `expectedChecksum` (concurrency guard). + */ + readonly stale?: boolean; + /** True when the caller must reload the content before retrying. */ + readonly reloadRequired?: boolean; +} + +/** Options for {@link applyContentEditToFile}. */ +export interface ApplyContentEditOptions { + /** Absolute or caller-resolved content CSV path. */ + readonly contentPath: string; + /** The game content adapter. */ + readonly adapter: ContentAdapter; + /** The in-memory mutation to apply. */ + readonly mutate: ContentMutation; + /** When true, validate and return the candidate without writing. */ + readonly dryRun?: boolean; + /** + * Concurrency guard: the content checksum captured when the caller loaded + * the file. When the file on disk does not match, the writer refuses (no + * write) and returns `stale`/`reloadRequired` so the caller can instruct a + * reload instead of clobbering a concurrent edit. Reuses the single + * checksum implementation from the content model. + */ + readonly expectedChecksum?: string; +} + +/** + * Applies a content mutation to a file transactionally. + * + * The file is read, mutated in memory and validated; it is written via a temp + * file + rename **only** when validation passes and `dryRun` is false. On any + * failure (including a thrown I/O error while writing) the original file is + * left in place and `ok` is false. + * + * @param options The edit to apply. + * @returns The edit result plus `path` and `written`. + */ +export function applyContentEditToFile( + options: ApplyContentEditOptions, +): ContentFileEditResult { + const { contentPath, adapter, mutate, dryRun = false, expectedChecksum } = options; + const original = readFileSync(contentPath, 'utf-8'); + + // ── Concurrency guard (before any mutation, on the writer's own read): ── + // the caller loaded a snapshot; if the file moved on since, an edit would + // silently clobber someone else's change. Refuse instead, and instruct a + // reload. Reuses computeContentChecksum — there is no second hash. + if (expectedChecksum !== undefined) { + const actual = computeContentChecksum(original); + if (actual !== expectedChecksum) { + return { + csv: original, + ok: false, + errors: [ + `content changed on disk since load (checksum ${actual} ≠ expected ${expectedChecksum}) — ` + + 'reload the editor and retry your edit', + ], + path: contentPath, + written: false, + stale: true, + reloadRequired: true, + }; + } + } + + const result = mutate(original); + + if (!result.ok) { + return { ...result, path: contentPath, written: false }; + } + + // Defence in depth: the mutation validates already, but the writer never + // trusts that — invalid content can never reach the file, whoever the + // caller is. + const validation = validateContentCsv(result.csv, adapter); + if (!validation.valid) { + return { + csv: original, + ok: false, + errors: validation.issues.map((issue) => issue.message), + validation, + path: contentPath, + written: false, + }; + } + + if (dryRun) { + return { ...result, validation, path: contentPath, written: false }; + } + + const tmp = `${contentPath}.tmp-${process.pid}`; + try { + writeFileSync(tmp, result.csv, 'utf-8'); + renameSync(tmp, contentPath); + } catch (err) { + return { + csv: original, + ok: false, + errors: [`write failed: ${(err as Error).message}`], + validation: result.validation, + path: contentPath, + written: false, + }; + } + return { ...result, validation, path: contentPath, written: true }; +} + diff --git a/src/story-line/content/csv.ts b/src/story-line/content/csv.ts new file mode 100644 index 000000000..997c4d9f8 --- /dev/null +++ b/src/story-line/content/csv.ts @@ -0,0 +1,300 @@ +/** + * CSV parsing/serialising and content projection for story-line content. + * + * The parser is a minimal RFC-4180 subset (quoted fields, embedded commas, + * `""` escapes and embedded newlines) with two hard properties the tooling + * depends on: + * + * - **Round-trip stability** — for a canonically quoted file, + * `parseCsv` → `serialiseCsv` is byte-identical, including column order and + * the trailing newline. + * - **Change detection** — {@link computeContentChecksum} is a deterministic + * DJB2 hash of the raw text, so any byte change (even whitespace) yields a + * different checksum. + * + * {@link cardsFromCsv} projects parsed rows onto the generic + * {@link ContentCard} model through a game's {@link ContentAdapter}; + * {@link compileContentOptions} reuses the runtime engine's option semantics + * (CSV `options` encoding first, legacy `hasChoices` fields second) so + * authored content compiles exactly as the runtime will read it. + * + * This module is Phaser-free and game-import-free. + * + * @module + */ + +import { parseStorylineOptionsEncoding } from '../compile'; +import type { StorylineOption } from '../types'; +import { + baseIdOf, + type ContentAdapter, + type ContentCard, +} from './types'; + +/** A parsed CSV file: header names plus a row matrix aligned to the header. */ +export interface ParsedCsv { + /** Column names, in file order. */ + readonly header: readonly string[]; + /** Data rows; each row has one cell per header entry. */ + readonly rows: readonly (readonly string[])[]; +} + +/** + * Parses CSV text into a header + row matrix. + * + * Handles quoted fields with embedded commas, quotes (`""`) and newlines. A + * terminal newline does not produce an empty final record. + * + * @param text Raw CSV text. + * @returns The parsed header and rows. + */ +export function parseCsv(text: string): ParsedCsv { + const records: string[][] = []; + let field = ''; + let row: string[] = []; + let inQuotes = false; + + for (let i = 0; i < text.length; i++) { + const ch = text[i]; + if (inQuotes) { + if (ch === '"') { + if (text[i + 1] === '"') { + field += '"'; + i++; + } else { + inQuotes = false; + } + } else { + field += ch; + } + } else if (ch === '"') { + inQuotes = true; + } else if (ch === ',') { + row.push(field); + field = ''; + } else if (ch === '\n' || ch === '\r') { + if (ch === '\r' && text[i + 1] === '\n') i++; + row.push(field); + field = ''; + records.push(row); + row = []; + } else { + field += ch; + } + } + // Final field/row (when the file has no trailing newline). + if (field.length > 0 || row.length > 0) { + row.push(field); + records.push(row); + } + // Drop trailing empty records produced by terminal newline(s). + while (records.length > 0 && records[records.length - 1].every((cell) => cell === '')) { + records.pop(); + } + + const header = records.shift() ?? []; + return { header, rows: records }; +} + +/** + * Escapes one CSV field, quoting only when the value requires it. + * + * @param value Raw cell value. + * @returns The CSV-escaped cell. + */ +export function escapeCsvField(value: string): string { + if ( + value.includes(',') || + value.includes('"') || + value.includes('\n') || + value.includes('\r') + ) { + return `"${value.replace(/"/g, '""')}"`; + } + return value; +} + +/** + * Serialises a header + row matrix to CSV text (LF newlines, trailing + * newline). Round-trips a canonically quoted file byte-identically. + * + * @param parsed The header and rows to serialise. + * @returns CSV text. + */ +export function serialiseCsv(parsed: ParsedCsv): string { + const lines = [parsed.header.map(escapeCsvField).join(',')]; + for (const row of parsed.rows) { + lines.push(row.map(escapeCsvField).join(',')); + } + return `${lines.join('\n')}\n`; +} + +/** + * Computes a deterministic checksum (DJB2 hash, hex-encoded) of content text. + * + * Suitable for change detection, **not** for cryptographic security. The same + * text always yields the same checksum and any byte change yields a different + * one, which is what the authoring tooling uses to detect concurrent edits. + * + * @param csvText Raw content text. + * @returns An 8-character hex string. + */ +export function computeContentChecksum(csvText: string): string { + let hash = 5381; + for (let i = 0; i < csvText.length; i++) { + hash = ((hash << 5) + hash + csvText.charCodeAt(i)) | 0; + } + return (hash >>> 0).toString(16).padStart(8, '0'); +} + +/** + * Lists the adapter's **required** columns (the id and name mappings) that the + * file header does not contain. + * + * Optional mappings are never reported: a game without an `options` column is + * a valid shape, and the authoring layer appends an absent optional column on + * demand when a card sets it. + * + * @param header Parsed CSV header. + * @param adapter The game content adapter. + * @returns Missing required column names (empty when the file is usable). + */ +export function findMissingColumns( + header: readonly string[], + adapter: ContentAdapter, +): string[] { + const present = new Set(header); + const missing: string[] = []; + const { id, name } = adapter.fields; + if (!present.has(id)) missing.push(id); + if (!present.has(name)) missing.push(name); + return missing; +} + +/** + * Projects parsed CSV rows onto the generic content model through an adapter. + * + * Identifier-like fields (`id`, `name`, the storyline ids and the successor + * ids) and the effect text are trimmed; `fields` keeps raw cell text so a + * game column is never silently rewritten. A column the adapter does not map + * (or does not exist in the file) reads as absent — `null` for optional + * identifiers, `''` for text, `false` for the choice flag. + * + * @param parsed Parsed CSV. + * @param adapter The game content adapter. + * @returns One {@link ContentCard} per data row, in file order. + */ +export function cardsFromCsv( + parsed: ParsedCsv, + adapter: ContentAdapter, +): ContentCard[] { + const indexByColumn = new Map<string, number>(); + parsed.header.forEach((column, index) => indexByColumn.set(column, index)); + + const cell = (row: readonly string[], column: string | undefined): string => { + if (column === undefined) return ''; + const index = indexByColumn.get(column); + if (index === undefined) return ''; + return row[index] ?? ''; + }; + const text = (value: string): string => value.trim(); + const optional = (value: string): string | null => { + const trimmed = value.trim(); + return trimmed === '' ? null : trimmed; + }; + + const mappedColumns = new Set( + Object.values(adapter.fields).filter((column): column is string => column !== undefined), + ); + + return parsed.rows.map((row) => { + const fields: Record<string, string> = {}; + parsed.header.forEach((column, index) => { + if (mappedColumns.has(column)) return; + fields[column] = row[index] ?? ''; + }); + + const flag = text(cell(row, adapter.fields.hasChoices)).toLowerCase(); + return { + id: text(cell(row, adapter.fields.id)), + name: text(cell(row, adapter.fields.name)), + effect: text(cell(row, adapter.fields.effect)), + hasChoices: flag === 'true' || flag === '1' || flag === 'yes', + acceptNextCardId: optional(cell(row, adapter.fields.acceptNextCardId)), + rejectNextCardId: optional(cell(row, adapter.fields.rejectNextCardId)), + options: text(cell(row, adapter.fields.options)), + storylineId: optional(cell(row, adapter.fields.storylineId)), + storylineTitle: optional(cell(row, adapter.fields.storylineTitle)), + fields, + }; + }); +} + +/** + * Compiles a content card's options with the runtime engine's semantics. + * + * Precedence mirrors the runtime exactly (minus the programmatic registry, + * which is not content): + * + * 1. a non-blank CSV `options` encoding → + * {@link parseStorylineOptionsEncoding} (`successorIds`, `apply`); + * 2. legacy `hasChoices` → `Accept` (`apply`) / `Reject` (`skip`) using + * `successorId`; + * 3. otherwise no options (a plain incident). + * + * @param card The content card. + * @returns The compiled option list (empty for a plain card). + * @throws Error when the card's `options` encoding is malformed. + */ +export function compileContentOptions(card: ContentCard): StorylineOption<unknown>[] { + if (card.options.trim() !== '') { + return parseStorylineOptionsEncoding<unknown>(card.options); + } + if (!card.hasChoices) return []; + return [ + { + label: 'Accept', + successorId: card.acceptNextCardId ?? undefined, + effectPolicy: 'apply', + }, + { + label: 'Reject', + successorId: card.rejectNextCardId ?? undefined, + effectPolicy: 'skip', + }, + ]; +} + +/** + * True when a content card presents a choice — a non-empty `options` encoding + * or the legacy `hasChoices` flag. A bare storyline id does **not** make a + * card a choice (terminal escalation cards carry it for grouping). + * + * @param card The content card. + * @returns Whether the card branches. + */ +export function contentCardHasChoice(card: ContentCard): boolean { + return card.hasChoices || card.options.trim() !== ''; +} + +/** + * The successor ids an option can reach, in declaration order. + * + * A non-empty candidate set (`successorIds`) takes precedence over the legacy + * single `successorId`, mirroring runtime resolution precedence. An empty + * list is a terminal option. + * + * @param option The compiled option. + * @param adapter The game content adapter (for its base-id rule). + * @returns Candidate base ids, or `[]` for a terminal option. + */ +export function optionSuccessorIds( + option: StorylineOption<unknown>, + adapter: ContentAdapter, +): string[] { + if (option.successorIds !== undefined && option.successorIds.length > 0) { + return option.successorIds.map((id) => baseIdOf(adapter, id)); + } + if (option.successorId) return [baseIdOf(adapter, option.successorId)]; + return []; +} diff --git a/src/story-line/content/index.ts b/src/story-line/content/index.ts new file mode 100644 index 000000000..fe0a9e5cb --- /dev/null +++ b/src/story-line/content/index.ts @@ -0,0 +1,123 @@ +/** + * Story-line content and authoring toolkit. + * + * The runtime engine (`@story-line`) resolves a story-line; this barrel is the + * **authoring** side of the same module: + * + * | Concern | Module | Members | + * |---|---|---| + * | Content model + adapter seam | `types` | `ContentCard`, `ContentAdapter`, `ContentFieldMapping`, `baseIdOf`, … | + * | CSV + checksum | `csv` | `parseCsv`, `serialiseCsv`, `computeContentChecksum`, `cardsFromCsv`, `compileContentOptions` | + * | Manifest + Mermaid | `manifest` | `buildStorylineManifest`, `serialiseManifest`, `renderMermaid`, `filterManifestByStoryline`, label helpers | + * | Validation | `validate` | `validateContentCards`, `validateContentCsv`, text/JSON reports | + * | Transactional authoring | `author` | `addContentCard`, `updateContentCard`, `updateContentCards`, `applyContentEditToFile` | + * | Generated artefacts | `artefacts` | `renderArtefacts`, `checkArtefacts`, `writeArtefacts`, `previewArtefacts`, `DEFAULT_ARTEFACT_PATHS` | + * | Shipped adapters | `adapters` | `GYM_CONTENT_ADAPTER`, `DEFAULT_CONTENT_ADAPTER`, `getContentAdapter` | + * + * Nothing here imports a game repository or Phaser, + * `applyContentEditToFile` is the only content writer, and `writeArtefacts` is + * the only generated-artefact writer. + * + * @module + */ + +export type { + ContentAdapter, + ContentCard, + ContentFieldMapping, +} from './types'; +export { + baseIdOf, + editableColumnsOf, + mappedFieldColumns, +} from './types'; + +export type { ParsedCsv } from './csv'; +export { + cardsFromCsv, + compileContentOptions, + computeContentChecksum, + contentCardHasChoice, + escapeCsvField, + findMissingColumns, + optionSuccessorIds, + parseCsv, + serialiseCsv, +} from './csv'; + +export type { + ManifestBuildOptions, + ManifestEdge, + ManifestNode, + ManifestOption, + StorylineManifest, +} from './manifest'; +export { + DEFAULT_MANIFEST_SCHEMA_PATH, + briefEventDescription, + buildStorylineManifest, + buildStorylineManifestFromCsv, + describeChoiceRouting, + describeEventImpact, + filterManifestByStoryline, + nodeLabel, + renderMermaid, + serialiseManifest, +} from './manifest'; + +export type { + ContentFinding, + ContentFindingKind, + ContentIssue, + ContentIssueCode, + ContentValidationOptions, + ContentValidationResult, +} from './validate'; +export { + contentValidationToJson, + findCycles, + formatContentValidationReport, + validateContentCards, + validateContentCsv, +} from './validate'; + +export type { + AddContentCardSpec, + ApplyContentEditOptions, + ContentEditResult, + ContentFileEditResult, + ContentMutation, + UpdateContentCardSpec, +} from './author'; +export { + addContentCard, + applyContentEditToFile, + hasContentCard, + readContentCards, + updateContentCard, + updateContentCards, +} from './author'; + +export type { + ArtefactContent, + ArtefactDrift, + ArtefactFormat, + ArtefactPaths, + ArtefactWriteResult, +} from './artefacts'; +export { + DEFAULT_ARTEFACT_PATHS, + checkArtefacts, + previewArtefacts, + renderArtefacts, + writeArtefacts, +} from './artefacts'; + +export { + CONTENT_ADAPTERS, + DEFAULT_CONTENT_ADAPTER, + DEFAULT_CONTENT_PATH, + GYM_CONTENT_ADAPTER, + getContentAdapter, + resolveContentAdapter, +} from './adapters'; diff --git a/src/story-line/content/manifest.ts b/src/story-line/content/manifest.ts new file mode 100644 index 000000000..96fba32e0 --- /dev/null +++ b/src/story-line/content/manifest.ts @@ -0,0 +1,426 @@ +/** + * Deterministic story-line manifest builder and Mermaid renderer. + * + * The manifest is the **view model source** for every authoring surface: a + * flat, timestamp-free description of the story-line graph (nodes, option + * edges, story-line ids/titles and cycles) that validates against + * [`schemas/story-line-manifest.schema.json`](../../../schemas/story-line-manifest.schema.json). + * + * Determinism is a hard requirement — the committed artefacts are guarded by a + * `--check` drift comparison. All output ordering derives from sorted card + * ids, option declaration order and candidate index; there is no RNG, + * timestamp or wall-clock input, so regenerating from the same content always + * produces byte-identical output. + * + * Cycles are a first-class shape: they are reported and their edges are marked + * in both the manifest and the Mermaid render, never treated as an error here + * (the validator decides whether cycles fail a build). + * + * This module is Phaser-free and game-import-free. + * + * @module + */ + +import { + cardsFromCsv, + compileContentOptions, + optionSuccessorIds, + parseCsv, +} from './csv'; +import { baseIdOf, type ContentAdapter, type ContentCard } from './types'; +import { findCycles } from './validate'; + +/** + * Canonical repository-relative path of the generic manifest JSON schema. + * + * Shared by the authoring toolchain and the editor test harness so a schema + * move is caught loudly (the derived path stops resolving) instead of silently + * validating against a stale copy. + */ +export const DEFAULT_MANIFEST_SCHEMA_PATH = 'schemas/story-line-manifest.schema.json' as const; + +// ── Manifest types ────────────────────────────────────────── + +/** One option of a choice node, as it appears in the manifest. */ +export interface ManifestOption { + /** Player-visible option label. */ + readonly label: string; + /** Ordered candidate successor base card ids; empty means "chain ends". */ + readonly successorIds: readonly string[]; + /** Whether the card's own effect applies when the option is chosen. */ + readonly effectPolicy: 'apply' | 'skip'; +} + +/** One card node in the manifest. */ +export interface ManifestNode { + /** Base card id. */ + readonly id: string; + /** Display name. */ + readonly name: string; + /** Story-line id, or null when the card belongs to no story-line. */ + readonly storylineId: string | null; + /** Story-line title, or null. */ + readonly storylineTitle: string | null; + /** True when the card presents a choice. */ + readonly hasChoices: boolean; + /** Number of options (0 for plain incidents). */ + readonly optionCount: number; + /** Ordered option list (empty for plain incidents). */ + readonly options: readonly ManifestOption[]; + /** Human-readable effect text. */ + readonly effect: string; + /** Adapter-provided game impact description (`''` when the adapter has none). */ + readonly impact: string; + /** Every game-specific column for this card, keyed by CSV column name. */ + readonly fields: Readonly<Record<string, string>>; +} + +/** One graph edge: an option (and candidate index) leaving a card. */ +export interface ManifestEdge { + /** Source base card id. */ + readonly from: string; + /** Option label. */ + readonly label: string; + /** Target base card id, or null when the chain ends. */ + readonly to: string | null; + /** True when this edge is one of several random-branch candidates. */ + readonly random: boolean; + /** 0-based index of this candidate within the option's candidate set. */ + readonly candidateIndex: number; + /** True when this edge participates in a cycle. */ + readonly cycle: boolean; + /** Whether the source card's own effect applies when this option is chosen. */ + readonly effectPolicy: 'apply' | 'skip'; +} + +/** The complete deterministic story-line manifest. */ +export interface StorylineManifest { + /** Manifest schema version. */ + readonly version: number; + /** Generator provenance (deterministic — no timestamp). */ + readonly source: string; + /** The adapter's game id. */ + readonly gameId: string; + /** Sorted story-line ids present. */ + readonly storylineIds: readonly string[]; + /** Sorted graph nodes. */ + readonly nodes: readonly ManifestNode[]; + /** Sorted graph edges. */ + readonly edges: readonly ManifestEdge[]; + /** Detected cycles (first-class); each entry is a path of base ids. */ + readonly cycles: readonly (readonly string[])[]; +} + +/** Options controlling {@link buildStorylineManifest}. */ +export interface ManifestBuildOptions { + /** Overrides the deterministic `source` provenance string. */ + readonly source?: string; +} + +// ── Build ─────────────────────────────────────────────────── + +/** Compares two strings for deterministic (codepoint) ordering. */ +function byString(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0; +} + +/** Removes duplicate base ids, keeping the first (file-order) occurrence. */ +function dedupeByBaseId( + cards: readonly ContentCard[], + adapter: ContentAdapter, +): Map<string, ContentCard> { + const byBase = new Map<string, ContentCard>(); + for (const card of cards) { + const base = baseIdOf(adapter, card.id); + if (!byBase.has(base)) byBase.set(base, card); + } + return byBase; +} + +/** + * Builds the story-line manifest from content cards. + * + * @param cards Content cards (from `cardsFromCsv` or a fixture). + * @param adapter The game content adapter. + * @param options Build options (source provenance override). + * @returns A deterministic manifest. + */ +export function buildStorylineManifest( + cards: readonly ContentCard[], + adapter: ContentAdapter, + options: ManifestBuildOptions = {}, +): StorylineManifest { + const byBase = dedupeByBaseId(cards, adapter); + const bases = [...byBase.keys()].sort(byString); + + const storylineIds = new Set<string>(); + const nodes: ManifestNode[] = []; + const edges: ManifestEdge[] = []; + + for (const base of bases) { + const card = byBase.get(base) as ContentCard; + if (card.storylineId) storylineIds.add(card.storylineId); + const compiledOptions = compileContentOptions(card); + const hasChoices = card.hasChoices || compiledOptions.length > 0; + + nodes.push({ + id: base, + name: card.name, + storylineId: card.storylineId, + storylineTitle: card.storylineTitle, + hasChoices, + optionCount: compiledOptions.length, + options: compiledOptions.map((option) => ({ + label: option.label, + successorIds: optionSuccessorIds(option, adapter), + effectPolicy: option.effectPolicy, + })), + effect: card.effect, + impact: adapter.describeImpact ? adapter.describeImpact(card) : '', + fields: card.fields, + }); + + for (const option of compiledOptions) { + const targets = optionSuccessorIds(option, adapter); + if (targets.length === 0) { + // Terminal option: a single chain-end edge, never a dangling link. + edges.push({ + from: base, + label: option.label, + to: null, + random: false, + candidateIndex: 0, + cycle: false, + effectPolicy: option.effectPolicy, + }); + continue; + } + // One edge per candidate so random branches of the same option stay + // visible; `random` + `candidateIndex` distinguish them. + targets.forEach((to, candidateIndex) => { + edges.push({ + from: base, + label: option.label, + to, + random: targets.length > 1, + candidateIndex, + cycle: false, + effectPolicy: option.effectPolicy, + }); + }); + } + } + + // ── Cycle detection over the choice graph ── + const adjacency = new Map<string, string[]>(); + for (const edge of edges) { + if (!edge.to) continue; + const list = adjacency.get(edge.from) ?? []; + list.push(edge.to); + adjacency.set(edge.from, list); + } + const cycles = findCycles(adjacency) + .map((cycle) => [...cycle].sort(byString)) + .sort((a, b) => byString(a.join('|'), b.join('|'))); + + const cycleNodeSets = cycles.map((cycle) => new Set(cycle)); + const markedEdges = edges.map((edge) => { + const to = edge.to; + if (to === null) return edge; + const inCycle = cycleNodeSets.some((set) => set.has(edge.from) && set.has(to)); + return inCycle ? { ...edge, cycle: true } : edge; + }); + + // Deterministic edge ordering: from, label, candidate index, target. + markedEdges.sort((a, b) => { + const from = byString(a.from, b.from); + if (from !== 0) return from; + const label = byString(a.label, b.label); + if (label !== 0) return label; + if (a.candidateIndex !== b.candidateIndex) return a.candidateIndex - b.candidateIndex; + return byString(a.to ?? '', b.to ?? ''); + }); + + return { + version: 1, + source: + options.source ?? + `Generated from the ${adapter.gameId} content CSV via the story-line content adapter (deterministic).`, + gameId: adapter.gameId, + storylineIds: [...storylineIds].sort(byString), + nodes, + edges: markedEdges, + cycles, + }; +} + +/** + * Builds the manifest directly from content CSV text. + * + * @param csvText Raw content CSV text. + * @param adapter The game content adapter. + * @param options Build options (source provenance override). + * @returns A deterministic manifest. + */ +export function buildStorylineManifestFromCsv( + csvText: string, + adapter: ContentAdapter, + options: ManifestBuildOptions = {}, +): StorylineManifest { + return buildStorylineManifest(cardsFromCsv(parseCsv(csvText), adapter), adapter, options); +} + +/** Serialises a manifest to stable, pretty-printed JSON (trailing newline). */ +export function serialiseManifest(manifest: StorylineManifest): string { + return `${JSON.stringify(manifest, null, 2)}\n`; +} + +// ── Mermaid rendering ─────────────────────────────────────── + +/** Escapes a card id into a Mermaid-safe node id. */ +function mermaidId(base: string): string { + return base.replace(/[^A-Za-z0-9_]/g, '_'); +} + +/** Returns a brief (first-sentence, length-capped) form of an effect text. */ +export function briefEventDescription(effect: string, max = 64): string { + const trimmed = effect.trim(); + if (!trimmed) return ''; + const stop = trimmed.indexOf('. '); + const first = stop >= 0 ? trimmed.slice(0, stop + 1) : trimmed; + return first.length <= max ? first : `${first.slice(0, max - 1).trimEnd()}…`; +} + +/** + * Builds a multi-line node label for a diagram: name, id, brief effect text + * and the adapter-provided impact description. + * + * @param node The manifest node. + * @returns The `<br/>`-joined label. + */ +export function nodeLabel(node: ManifestNode): string { + const lines = [node.name, node.id]; + const brief = briefEventDescription(node.effect); + if (brief) lines.push(brief); + if (node.impact) lines.push(node.impact); + return lines.join('<br/>'); +} + +/** + * Describes a node's game impact (`''` when the adapter supplies none). + * + * @param node The manifest node. + * @returns The impact description, or `''`. + */ +export function describeEventImpact(node: ManifestNode): string { + return node.impact; +} + +/** + * Describes how a card routes through its options (apply/skip and successor), + * or that it resolves immediately when it has no choice. + * + * @param node The node. + * @param edges All manifest edges (filtered to this node's outgoing edges). + * @param nameById Display names for successor ids. + * @returns A readable routing summary. + */ +export function describeChoiceRouting( + node: ManifestNode, + edges: readonly ManifestEdge[], + nameById: ReadonlyMap<string, string>, +): string { + const outgoing = edges.filter((edge) => edge.from === node.id); + if (outgoing.length === 0) return 'Resolves immediately (no choice)'; + return outgoing + .map((edge) => { + const action = edge.effectPolicy === 'apply' ? 'effect applies' : 'effect skipped'; + const target = edge.to ? (nameById.get(edge.to) ?? edge.to) : 'chain ends'; + return `${edge.label}: ${action} → ${target}`; + }) + .join(' · '); +} + +/** + * Renders the story-line graph as a Mermaid flowchart. + * + * Choice cards render as diamonds, plain incidents as rounded rectangles. + * Random-branch candidate edges render dotted, cycle edges thickened (`==>`), + * and terminal options get an explicit `end` node so the shape is visible. + * Deterministic: node and edge order follow the manifest's sorted order. + * + * @param manifest The manifest to render. + * @returns Mermaid flowchart text (trailing newline). + */ +export function renderMermaid(manifest: StorylineManifest): string { + const lines: string[] = []; + lines.push('%%{ init: { "flowchart": { "curve": "basis" } } }%%'); + lines.push('flowchart TD'); + + for (const node of manifest.nodes) { + const id = mermaidId(node.id); + const label = nodeLabel(node); + if (node.hasChoices) { + lines.push(` ${id}{"${label}"}`); + } else { + lines.push(` ${id}("${label}")`); + } + } + lines.push(''); + + for (const edge of manifest.edges) { + const arrow = edge.cycle ? '==>' : edge.random ? '-.->' : '-->'; + const from = mermaidId(edge.from); + if (edge.to) { + lines.push(` ${from} ${arrow}|${edge.label}| ${mermaidId(edge.to)}`); + } else { + const endId = `${from}_${edge.label.replace(/[^A-Za-z0-9_]/g, '_')}_end`; + lines.push(` ${endId}(["end"])`); + lines.push(` ${from} ${arrow}|${edge.label}| ${endId}`); + } + } + + if (manifest.cycles.length > 0) { + lines.push(''); + lines.push(' %% Intentional cycle(s):'); + for (const cycle of manifest.cycles) { + lines.push(` %% ${cycle.join(' -> ')} -> ${cycle[0]}`); + } + } + + lines.push(''); + return lines.join('\n'); +} + +// ── Per-storyline views ───────────────────────────────────── + +/** + * Restricts a manifest to a single story-line's cards, edges and cycles. + * + * Used to render one readable diagram per story-line instead of the combined + * graph. Edges are kept when both endpoints are members (a terminal edge from + * a member is kept — it has no target to reject). + * + * @param manifest The full manifest. + * @param storylineId The story-line id to keep. + * @returns A manifest containing only that story-line. + */ +export function filterManifestByStoryline( + manifest: StorylineManifest, + storylineId: string, +): StorylineManifest { + const nodeIds = new Set( + manifest.nodes + .filter((node) => node.storylineId === storylineId) + .map((node) => node.id), + ); + return { + ...manifest, + storylineIds: [storylineId], + nodes: manifest.nodes.filter((node) => nodeIds.has(node.id)), + edges: manifest.edges.filter( + (edge) => nodeIds.has(edge.from) && (edge.to === null || nodeIds.has(edge.to)), + ), + cycles: manifest.cycles.filter((cycle) => cycle.every((id) => nodeIds.has(id))), + }; +} diff --git a/src/story-line/content/mutate.ts b/src/story-line/content/mutate.ts new file mode 100644 index 000000000..95a072069 --- /dev/null +++ b/src/story-line/content/mutate.ts @@ -0,0 +1,495 @@ +/** + * Pure, in-memory story-line content mutations. + * + * This module is the **pure** half of the authoring seam: applying a content + * edit to CSV text and validating the candidate — nothing touches the file + * system. It is what a form/preview pipeline runs in a browser: the editor + * page composes an edit (add/update card, add-and-link a card), previews the + * candidate and validates it, all in memory, before the Node-side writer + * (`../author.ts`, the **only** content writer) persists anything. + * + * Every mutation follows the same contract: + * + * 1. parse the current content; + * 2. apply the mutation to an in-memory copy; + * 3. validate the resulting content with {@link validateContentCsv}; + * 4. return the candidate text when valid (and nothing was written), or the + * unchanged input plus actionable errors when not. + * + * Phaser-free, game-import-free and **Node-free**: only the pure content + * model modules are imported, so this whole module is importable in a + * browser. + * + * @module + */ + +import { + cardsFromCsv, + findMissingColumns, + parseCsv, + serialiseCsv, + type ParsedCsv, +} from './csv'; +import { baseIdOf, type ContentAdapter, type ContentCard } from './types'; +import { validateContentCsv, type ContentValidationResult } from './validate'; + +// ── Result types ──────────────────────────────────────────── + +/** The outcome of an in-memory content edit. */ +export interface ContentEditResult { + /** The candidate CSV text — unchanged input when `ok` is false. */ + readonly csv: string; + /** True when the edit applied and the resulting content validated. */ + readonly ok: boolean; + /** Actionable error messages; empty when `ok` is true. */ + readonly errors: readonly string[]; + /** Validation detail for the candidate content (present once parsed). */ + readonly validation?: ContentValidationResult; +} + +/** A spec for appending a new card to the content. */ +export interface AddContentCardSpec { + /** New card id (required, must be unique). */ + readonly id: string; + /** New card name (required). */ + readonly name: string; + /** Human-readable effect text. */ + readonly effect?: string; + /** Legacy two-way choice flag. */ + readonly hasChoices?: boolean; + /** Legacy accept successor id (`null` clears). */ + readonly acceptNextCardId?: string | null; + /** Legacy reject successor id (`null` clears). */ + readonly rejectNextCardId?: string | null; + /** CSV `options` encoding (`label|a,b;label2|c`). */ + readonly options?: string; + /** Story-line id. */ + readonly storylineId?: string | null; + /** Story-line title. */ + readonly storylineTitle?: string | null; + /** Game-specific column values, keyed by CSV column name. */ + readonly fields?: Readonly<Record<string, string>>; +} + +/** + * A spec for updating an existing card. Every field is optional; a field that + * is `undefined` is left untouched, and a nullable field set to `null` is + * cleared. + */ +export interface UpdateContentCardSpec { + /** The card id to update (required). */ + readonly id: string; + /** New name. */ + readonly name?: string; + /** New effect text. */ + readonly effect?: string; + /** New choice flag. */ + readonly hasChoices?: boolean; + /** New accept successor id (`null` clears). */ + readonly acceptNextCardId?: string | null; + /** New reject successor id (`null` clears). */ + readonly rejectNextCardId?: string | null; + /** New `options` encoding (`null` or `''` clears). */ + readonly options?: string | null; + /** New story-line id (`null` clears). */ + readonly storylineId?: string | null; + /** New story-line title (`null` clears). */ + readonly storylineTitle?: string | null; + /** Game-specific column values, keyed by CSV column name. */ + readonly fields?: Readonly<Record<string, string>>; +} + +/** A pure in-memory content mutation (used by the file writer). */ +export type ContentMutation = (csvText: string) => ContentEditResult; + +// ── Matrix helpers ────────────────────────────────────────── + +/** Returns a fresh, all-empty row aligned to the header. */ +function emptyRow(parsed: ParsedCsv): string[] { + return new Array(parsed.header.length).fill(''); +} + +/** + * Sets a column's cell on a row. A column absent from the header is appended + * (and every existing row padded), so content authored before an optional + * column existed can still be edited without a separate migration. + */ +function setColumn( + header: string[], + rows: string[][], + row: string[], + column: string, + value: string, +): void { + let index = header.indexOf(column); + if (index < 0) { + index = header.length; + header.push(column); + for (const existing of rows) existing.push(''); + row.push(''); + } + row[index] = value; +} + +/** Returns the mutable row matrix (the parser's rows are readonly). */ +function mutableRows(parsed: ParsedCsv): string[][] { + return parsed.rows.map((row) => [...row]); +} + +/** Finds the row for a base card id, or `undefined`. */ +function findRow( + header: readonly string[], + rows: readonly (readonly string[])[], + adapter: ContentAdapter, + id: string, +): string[] | undefined { + const idIndex = header.indexOf(adapter.fields.id); + if (idIndex < 0) return undefined; + const wanted = baseIdOf(adapter, id); + return rows.find((row) => baseIdOf(adapter, row[idIndex] ?? '') === wanted) as + | string[] + | undefined; +} + +/** Validates a candidate CSV and folds the outcome into an edit result. */ +function finalise( + originalCsv: string, + candidateCsv: string, + adapter: ContentAdapter, +): ContentEditResult { + const validation = validateContentCsv(candidateCsv, adapter); + if (!validation.valid) { + return { + csv: originalCsv, + ok: false, + errors: validation.issues.map((issue) => issue.message), + validation, + }; + } + return { csv: candidateCsv, ok: true, errors: [], validation }; +} + +/** Rejects adapter columns the file does not contain. */ +function headerErrors(header: readonly string[], adapter: ContentAdapter): string[] { + const missing = findMissingColumns(header, adapter); + if (missing.length === 0) return []; + return [ + `content header is missing required column(s): ${missing.join(', ')} (found: ${header.join(', ') || '(empty header)'})`, + ]; +} + +/** Rejects game-field keys that are not real columns or are mapped fields. */ +function fieldKeyErrors( + header: readonly string[], + adapter: ContentAdapter, + fields: Readonly<Record<string, string>> | undefined, +): string[] { + if (!fields) return []; + const mapped = new Set( + Object.values(adapter.fields).filter( + (column): column is string => column !== undefined, + ), + ); + const errors: string[] = []; + for (const column of Object.keys(fields)) { + if (mapped.has(column)) { + errors.push( + `field "${column}" is a mapped story-line column; use the dedicated authoring option instead`, + ); + } else if (!header.includes(column)) { + errors.push(`unknown content column "${column}" (file columns: ${header.join(', ')})`); + } + } + return errors; +} + +// ── Pure author operations ────────────────────────────────── + +/** + * Applies an `add-card` spec to CSV text **without validating** the candidate. + * + * Structural problems (missing required values, duplicate id, unknown column) + * are still reported. Exposed for composing multi-step edits (a card added + * *and* linked in) that must be validated once, as a whole candidate — the + * public {@link addContentCard} wraps this with validation. + * + * @internal + * @param csvText Current content CSV text. + * @param adapter The game content adapter. + * @param spec The new card. + * @returns The unvalidated candidate (or the unchanged input + errors). + */ +export function applyAddContentCardSpec( + csvText: string, + adapter: ContentAdapter, + spec: AddContentCardSpec, +): ContentEditResult { + const parsed = parseCsv(csvText); + const header = [...parsed.header]; + const rows = mutableRows(parsed); + + const errors = headerErrors(header, adapter); + if (spec.id.trim() === '') errors.push('add-card: --id is required'); + if (spec.name.trim() === '') errors.push('add-card: --name is required'); + errors.push(...fieldKeyErrors(header, adapter, spec.fields)); + if (errors.length > 0) return { csv: csvText, ok: false, errors }; + + if (findRow(header, rows, adapter, spec.id)) { + return { + csv: csvText, + ok: false, + errors: [`add-card: card id "${spec.id}" already exists (duplicate id)`], + }; + } + + const row = emptyRow({ header, rows }); + for (const [column, value] of Object.entries(adapter.newCardDefaults ?? {})) { + if (header.includes(column)) setColumn(header, rows, row, column, value); + } + + const fields = adapter.fields; + setColumn(header, rows, row, fields.id, spec.id); + setColumn(header, rows, row, fields.name, spec.name); + if (fields.effect !== undefined) { + setColumn(header, rows, row, fields.effect, spec.effect ?? ''); + } + if (fields.hasChoices !== undefined) { + setColumn(header, rows, row, fields.hasChoices, spec.hasChoices ? 'true' : 'false'); + } + if (fields.acceptNextCardId !== undefined && spec.acceptNextCardId) { + setColumn(header, rows, row, fields.acceptNextCardId, spec.acceptNextCardId); + } + if (fields.rejectNextCardId !== undefined && spec.rejectNextCardId) { + setColumn(header, rows, row, fields.rejectNextCardId, spec.rejectNextCardId); + } + if (fields.options !== undefined && spec.options !== undefined) { + setColumn(header, rows, row, fields.options, spec.options); + } + if (fields.storylineId !== undefined && spec.storylineId) { + setColumn(header, rows, row, fields.storylineId, spec.storylineId); + } + if (fields.storylineTitle !== undefined && spec.storylineTitle) { + setColumn(header, rows, row, fields.storylineTitle, spec.storylineTitle); + } + for (const [column, value] of Object.entries(spec.fields ?? {})) { + setColumn(header, rows, row, column, value); + } + + rows.push(row); + return { csv: serialiseCsv({ header, rows }), ok: true, errors: [] }; +} + +/** + * Appends a new card and validates the resulting content. + * + * Structural problems (missing required values, duplicate id, unknown column) + * are reported before the candidate is validated; the returned `csv` is the + * unchanged input whenever `ok` is false. + * + * @param csvText Current content CSV text. + * @param adapter The game content adapter. + * @param spec The new card. + * @returns The edit result. + */ +export function addContentCard( + csvText: string, + adapter: ContentAdapter, + spec: AddContentCardSpec, +): ContentEditResult { + const applied = applyAddContentCardSpec(csvText, adapter, spec); + if (!applied.ok) return applied; + return finalise(csvText, applied.csv, adapter); +} + +/** + * Shared field-application step for {@link updateContentCard} and + * {@link updateContentCards}: applies one spec's supplied fields to a row. + */ +function applyUpdateFields( + header: string[], + rows: string[][], + row: string[], + adapter: ContentAdapter, + spec: UpdateContentCardSpec, +): void { + const fields = adapter.fields; + const setNullable = (column: string | undefined, value: string | null): void => { + if (column === undefined) return; + setColumn(header, rows, row, column, value ?? ''); + }; + + if (fields.name !== undefined && spec.name !== undefined) { + setColumn(header, rows, row, fields.name, spec.name); + } + if (fields.effect !== undefined && spec.effect !== undefined) { + setColumn(header, rows, row, fields.effect, spec.effect); + } + if (spec.hasChoices !== undefined && fields.hasChoices !== undefined) { + setColumn(header, rows, row, fields.hasChoices, spec.hasChoices ? 'true' : 'false'); + } + if (spec.acceptNextCardId !== undefined) { + setNullable(fields.acceptNextCardId, spec.acceptNextCardId); + // Setting a link implies a choice card (it would otherwise be inert at + // runtime); clearing a link leaves the flag alone. + if (spec.acceptNextCardId && spec.hasChoices === undefined && fields.hasChoices !== undefined) { + setColumn(header, rows, row, fields.hasChoices, 'true'); + } + } + if (spec.rejectNextCardId !== undefined) { + setNullable(fields.rejectNextCardId, spec.rejectNextCardId); + if (spec.rejectNextCardId && spec.hasChoices === undefined && fields.hasChoices !== undefined) { + setColumn(header, rows, row, fields.hasChoices, 'true'); + } + } + if (spec.options !== undefined) setNullable(fields.options, spec.options); + if (spec.storylineId !== undefined) setNullable(fields.storylineId, spec.storylineId); + if (spec.storylineTitle !== undefined) { + setNullable(fields.storylineTitle, spec.storylineTitle); + } + for (const [column, value] of Object.entries(spec.fields ?? {})) { + setColumn(header, rows, row, column, value); + } +} + +/** + * Applies an `update-card` spec to CSV text **without validating** the + * candidate. + * + * Structural problems (missing id, unknown field key, missing card) are still + * reported. Exposed for composing multi-step edits that must be validated + * once, as a whole candidate — the public {@link updateContentCard} wraps this + * with validation. + * + * @internal + * @param csvText Current content CSV text. + * @param adapter The game content adapter. + * @param spec The update (only the supplied fields are changed). + * @returns The unvalidated candidate (or the unchanged input + errors). + */ +export function applyUpdateContentCardSpec( + csvText: string, + adapter: ContentAdapter, + spec: UpdateContentCardSpec, +): ContentEditResult { + const parsed = parseCsv(csvText); + const header = [...parsed.header]; + const rows = mutableRows(parsed); + + const errors = headerErrors(header, adapter); + if (spec.id.trim() === '') errors.push('update-card: --id is required'); + errors.push(...fieldKeyErrors(header, adapter, spec.fields)); + if (errors.length > 0) return { csv: csvText, ok: false, errors }; + + const row = findRow(header, rows, adapter, spec.id); + if (!row) { + return { + csv: csvText, + ok: false, + errors: [`update-card: card id "${spec.id}" not found`], + }; + } + + applyUpdateFields(header, rows, row, adapter, spec); + return { csv: serialiseCsv({ header, rows }), ok: true, errors: [] }; +} + +/** + * Updates an existing card and validates the resulting content. + * + * @param csvText Current content CSV text. + * @param adapter The game content adapter. + * @param spec The update (only the supplied fields are changed). + * @returns The edit result. + */ +export function updateContentCard( + csvText: string, + adapter: ContentAdapter, + spec: UpdateContentCardSpec, +): ContentEditResult { + const applied = applyUpdateContentCardSpec(csvText, adapter, spec); + if (!applied.ok) return applied; + return finalise(csvText, applied.csv, adapter); +} + +/** + * Applies several card updates in one in-memory pass and validates the + * combined result once. + * + * This is the batch counterpart of {@link updateContentCard}: every spec is + * applied to the same row matrix, then the whole candidate is validated a + * single time (`finalise`). It is what a storyline-level rename uses — a + * group of member cards reassigned as one author operation, written once + * through the file writer. + * + * @param csvText Current content CSV text. + * @param adapter The game content adapter. + * @param specs The updates to apply, in order. + * @returns The edit result (one validation over the combined candidate). + */ +export function updateContentCards( + csvText: string, + adapter: ContentAdapter, + specs: readonly UpdateContentCardSpec[], +): ContentEditResult { + if (specs.length === 0) { + return { csv: csvText, ok: true, errors: [] }; + } + + const parsed = parseCsv(csvText); + const header = [...parsed.header]; + const rows = mutableRows(parsed); + + for (const spec of specs) { + const errors = headerErrors(header, adapter); + if (spec.id.trim() === '') errors.push('update-card: --id is required'); + errors.push(...fieldKeyErrors(header, adapter, spec.fields)); + if (errors.length > 0) return { csv: csvText, ok: false, errors }; + + const row = findRow(header, rows, adapter, spec.id); + if (!row) { + return { + csv: csvText, + ok: false, + errors: [`update-card: card id "${spec.id}" not found`], + }; + } + applyUpdateFields(header, rows, row, adapter, spec); + } + + return finalise(csvText, serialiseCsv({ header, rows }), adapter); +} + +// ── Read counterpart ───────────────────────────────────────── + +/** + * True when a card list contains a card with the given base id. + * + * @param cards Content cards. + * @param adapter The game content adapter. + * @param id The id to look for. + * @returns Whether the card exists. + */ +export function hasContentCard( + cards: readonly ContentCard[], + adapter: ContentAdapter, + id: string, +): boolean { + const wanted = baseIdOf(adapter, id); + return cards.some((card) => baseIdOf(adapter, card.id) === wanted); +} + +/** + * Re-projects a content CSV through the adapter (parse → cards), the read + * counterpart of the write path. Exposed so callers never need to reach into + * the CSV helpers just to read cards back after an edit. + * + * @param csvText Content CSV text. + * @param adapter The game content adapter. + * @returns The projected cards, in file order. + */ +export function readContentCards( + csvText: string, + adapter: ContentAdapter, +): ContentCard[] { + return cardsFromCsv(parseCsv(csvText), adapter); +} diff --git a/src/story-line/content/types.ts b/src/story-line/content/types.ts new file mode 100644 index 000000000..9cbb5ffe0 --- /dev/null +++ b/src/story-line/content/types.ts @@ -0,0 +1,210 @@ +/** + * Generic story-line *content* model and the content-adapter seam. + * + * The runtime engine ({@link ../types}) knows how to compile and resolve a + * story-line; it deliberately knows nothing about where the cards come from. + * This module supplies the authoring-side content model: a flat record that a + * game's CSV row projects onto, plus a **declarative adapter** that maps a + * game's own column names onto the generic story-line fields. + * + * The adapter is pure data — column names and defaults — so core never + * imports a game, Phaser, or Main Street. A game declares, for example: + * + * ```ts + * const adapter: ContentAdapter = { + * gameId: 'tavern', + * columns: ['card_key', 'card_name', 'flavour', 'arc', 'choices'], + * fields: { + * id: 'card_key', + * name: 'card_name', + * effect: 'flavour', + * options: 'choices', + * storylineId: 'arc', + * }, + * }; + * ``` + * + * Every column that is **not** part of the story-line field mapping lands in + * {@link ContentCard.fields}, so game-specific columns (coin/reputation + * deltas, renown, payoff, …) are editable and inspectable without core + * knowing what they mean. + * + * Nothing in this module depends on Phaser or on a game repository. + * + * @module + */ + +/** + * One story-line card as **content**: the generic story-line fields plus the + * game's own columns, keyed by their CSV column name. + * + * Structurally assignable to the runtime {@link StorylineCard} (it carries + * `id`, `name`, `effect`, `hasChoices`, the legacy link fields, `options` and + * the storyline metadata), so a compiled content card can be fed straight to + * the runtime system. + */ +export interface ContentCard { + /** Stable card template id (the adapter's `id` column). */ + readonly id: string; + /** Human-readable card name (the adapter's `name` column). */ + readonly name: string; + /** Human-readable effect text (`''` when the adapter maps no effect column). */ + readonly effect: string; + /** Legacy two-way choice flag (false when the adapter maps no such column). */ + readonly hasChoices: boolean; + /** Legacy accept successor card id (`null` when absent). */ + readonly acceptNextCardId: string | null; + /** Legacy reject successor card id (`null` when absent). */ + readonly rejectNextCardId: string | null; + /** Raw CSV `options` encoding (`''` when absent). */ + readonly options: string; + /** Story-arc identifier (`null` when the card belongs to no arc). */ + readonly storylineId: string | null; + /** Human-readable story-arc title (`null` when absent). */ + readonly storylineTitle: string | null; + /** + * Every column that is not part of the adapter's story-line field mapping, + * keyed by CSV column name, with the raw cell text (untrimmed). + */ + readonly fields: Readonly<Record<string, string>>; +} + +/** + * Maps a game's CSV column names onto the generic story-line fields. + * + * Only `id` and `name` are required. A game without a legacy two-way choice + * model simply omits `hasChoices` / `acceptNextCardId` / `rejectNextCardId`; + * a game without an `options` column omits `options`. An omitted mapping means + * "this content file has no such column" — never an error. + */ +export interface ContentFieldMapping { + /** Column carrying the card id. **Required.** */ + readonly id: string; + /** Column carrying the card name. **Required.** */ + readonly name: string; + /** Column carrying the human-readable effect text. */ + readonly effect?: string; + /** Column carrying the legacy `hasChoices` flag. */ + readonly hasChoices?: string; + /** Column carrying the legacy accept successor id. */ + readonly acceptNextCardId?: string; + /** Column carrying the legacy reject successor id. */ + readonly rejectNextCardId?: string; + /** Column carrying the CSV `options` encoding. */ + readonly options?: string; + /** Column carrying the storyline id. */ + readonly storylineId?: string; + /** Column carrying the storyline title. */ + readonly storylineTitle?: string; +} + +/** + * A game's content adapter: its CSV columns, the story-line field mapping and + * the authoring defaults/hooks the generic tooling needs. + * + * The adapter is declarative data plus two optional pure hooks + * ({@link ContentAdapter.baseId}, {@link ContentAdapter.describeImpact}), so + * the same seam works for any game shape and stays trivially testable. + */ +export interface ContentAdapter { + /** Stable game identifier (used in manifest provenance). */ + readonly gameId: string; + /** + * The content CSV's columns in file order. The parser trusts the file's own + * header for round-tripping; this list is what the authoring tooling treats + * as the expected schema (and what a new row is padded with). + */ + readonly columns: readonly string[]; + /** Column → story-line field mapping. */ + readonly fields: ContentFieldMapping; + /** + * Extra (game-specific) columns the editor/authoring tooling may edit, over + * and above the mapped story-line fields. Defaults to every non-mapped + * column declared in {@link ContentAdapter.columns}. + */ + readonly editableColumns?: readonly string[]; + /** + * Column defaults for a newly authored card row (e.g. `{ trigger: + * 'Incident', cost: '0' }`). The mapped fields are always initialised from + * the authored spec, never from these defaults. + */ + readonly newCardDefaults?: Readonly<Record<string, string>>; + /** + * Optional rule stripping a chain-instance serial from a card id + * (`evt-x-3` → `evt-x`). Core content files hold templates, so the default + * is the identity function. + */ + readonly baseId?: (id: string) => string; + /** + * Optional human-readable description of a card's game-specific impact + * (e.g. `−50 coins`), shown in manifest labels and diagrams. Pure and + * deterministic; `''`/undefined when the game has nothing to add. + */ + readonly describeImpact?: (card: ContentCard) => string; +} + +/** + * Resolves a card id to its base template id using the adapter's optional + * `baseId` rule (identity when the adapter declares none). + * + * @param adapter The game content adapter. + * @param id A card id (possibly carrying an instance serial). + * @returns The base template id. + */ +export function baseIdOf(adapter: ContentAdapter, id: string): string { + return adapter.baseId ? adapter.baseId(id) : id; +} + +/** + * The mapped story-line fields as `[field, column]` pairs, omitting any + * mapping the adapter does not declare. Used by the authoring layer to + * initialise and read rows without repeating the mapping table. + * + * @param adapter The game content adapter. + * @returns Deterministic list of mapped field/column pairs. + */ +export function mappedFieldColumns( + adapter: ContentAdapter, +): readonly (readonly [keyof ContentFieldMapping, string])[] { + const fields = adapter.fields; + const pairs: (readonly [keyof ContentFieldMapping, string])[] = [ + ['id', fields.id], + ['name', fields.name], + ]; + if (fields.effect !== undefined) pairs.push(['effect', fields.effect]); + if (fields.hasChoices !== undefined) pairs.push(['hasChoices', fields.hasChoices]); + if (fields.acceptNextCardId !== undefined) { + pairs.push(['acceptNextCardId', fields.acceptNextCardId]); + } + if (fields.rejectNextCardId !== undefined) { + pairs.push(['rejectNextCardId', fields.rejectNextCardId]); + } + if (fields.options !== undefined) pairs.push(['options', fields.options]); + if (fields.storylineId !== undefined) pairs.push(['storylineId', fields.storylineId]); + if (fields.storylineTitle !== undefined) { + pairs.push(['storylineTitle', fields.storylineTitle]); + } + return pairs; +} + +/** + * The columns the authoring tooling may edit: the mapped story-line columns + * plus the adapter's declared game columns. + * + * @param adapter The game content adapter. + * @returns Deterministic, de-duplicated column list in declaration order. + */ +export function editableColumnsOf(adapter: ContentAdapter): readonly string[] { + const mapped = mappedFieldColumns(adapter).map(([, column]) => column); + const extras = + adapter.editableColumns ?? + adapter.columns.filter((column) => !mapped.includes(column)); + const seen = new Set<string>(); + const result: string[] = []; + for (const column of [...mapped, ...extras]) { + if (seen.has(column)) continue; + seen.add(column); + result.push(column); + } + return result; +} diff --git a/src/story-line/content/validate.ts b/src/story-line/content/validate.ts new file mode 100644 index 000000000..a0cb069d7 --- /dev/null +++ b/src/story-line/content/validate.ts @@ -0,0 +1,530 @@ +/** + * Static validation of story-line *content*. + * + * Validates a game's content cards (parsed through its {@link ContentAdapter}) + * without starting a game or booting Phaser, so an authoring tool can reject a + * broken edit before it is written. Validation is a **pure function** over the + * card list; it never mutates the cards and never throws. + * + * Hard errors (`valid: false`, the authoring layer refuses to write): + * + * | Code | Meaning | + * |---|---| + * | `duplicate-id` | Two cards share the same (base) id. | + * | `missing-required-field` | The id, name or a required adapter column is empty. | + * | `missing-storyline-id` | A choice card declares no storyline id. | + * | `orphan-link` | A card declares accept/reject links but is not a choice card. | + * | `invalid-option` | Malformed `options` encoding, or an empty/duplicate option label. | + * | `unknown-successor` | An `options` candidate names a card id that does not exist. | + * | `dangling-link` | A legacy accept/reject link names a card id that does not exist. | + * | `unreachable-card` | A story-line member is unreachable from its story-line's entry card. | + * | `cycle-error` | A cycle treated as invalid — **opt-in** via `failOnCycle`. | + * + * Informational findings (never fail validation by default): + * + * | Kind | Meaning | + * |---|---| + * | `cycle` | A cycle in the choice graph (a first-class, intentional shape). | + * | `self-link` | A card whose successor is itself (still a cycle). | + * + * @module + */ + +import { + cardsFromCsv, + compileContentOptions, + contentCardHasChoice, + optionSuccessorIds, + parseCsv, +} from './csv'; +import type { StorylineOption } from '../types'; +import { baseIdOf, type ContentAdapter, type ContentCard } from './types'; + +// ── Result types ──────────────────────────────────────────── + +/** Machine-readable category of a hard validation issue. */ +export type ContentIssueCode = + | 'duplicate-id' + | 'missing-required-field' + | 'missing-storyline-id' + | 'orphan-link' + | 'invalid-option' + | 'unknown-successor' + | 'dangling-link' + | 'unreachable-card' + | 'cycle-error'; + +/** A single hard validation issue. */ +export interface ContentIssue { + /** Stable machine-readable category. */ + readonly code: ContentIssueCode; + /** The (base) card id the issue was found on. */ + readonly cardId: string; + /** The offending successor/link target, when applicable. */ + readonly target?: string; + /** Human-readable explanation naming the offending card/link. */ + readonly message: string; +} + +/** Machine-readable category of an informational finding. */ +export type ContentFindingKind = 'cycle' | 'self-link'; + +/** A single informational finding (cycles etc.). */ +export interface ContentFinding { + /** Stable machine-readable category. */ + readonly kind: ContentFindingKind; + /** The cycle path of base card ids, in traversal order. */ + readonly path: readonly string[]; + /** Human-readable explanation. */ + readonly message: string; +} + +/** Options controlling {@link validateContentCards}. */ +export interface ContentValidationOptions { + /** When true, cycles become hard `cycle-error` issues instead of findings. */ + readonly failOnCycle?: boolean; +} + +/** The result of validating a content card list. */ +export interface ContentValidationResult { + /** The adapter's game id (provenance for reports). */ + readonly gameId: string; + /** Number of cards inspected. */ + readonly cardCount: number; + /** Number of cards that present a choice. */ + readonly choiceCardCount: number; + /** Story-line ids observed, sorted. */ + readonly storylineIds: readonly string[]; + /** Hard errors — any entry means the content is invalid. */ + readonly issues: readonly ContentIssue[]; + /** Informational findings (cycles) — never fail validation by default. */ + readonly findings: readonly ContentFinding[]; + /** True when there are no hard errors. */ + readonly valid: boolean; +} + +// ── Pure validation ───────────────────────────────────────── + +/** + * Validates a list of content cards. + * + * Deterministic: issues and findings are reported in card declaration order + * (and cycle findings in adjacency traversal order), so two runs over the same + * content produce identical results. + * + * @param cards Content cards (from `cardsFromCsv` or a fixture). + * @param adapter The game content adapter (base-id rule and provenance). + * @param opts Validation options (cycle policy). + * @returns The validation result — never throws. + */ +export function validateContentCards( + cards: readonly ContentCard[], + adapter: ContentAdapter, + opts: ContentValidationOptions = {}, +): ContentValidationResult { + const issues: ContentIssue[] = []; + const findings: ContentFinding[] = []; + + const base = (id: string): string => baseIdOf(adapter, id); + + // ── Duplicate ids (before anything else, so later checks read cleanly) ── + const counts = new Map<string, number>(); + for (const card of cards) { + const id = base(card.id); + counts.set(id, (counts.get(id) ?? 0) + 1); + } + for (const [id, count] of counts) { + if (count > 1) { + issues.push({ + code: 'duplicate-id', + cardId: id, + message: `Duplicate card id "${id}" appears ${count} times.`, + }); + } + } + + // ── Required fields ── + for (const card of cards) { + const id = base(card.id); + if (card.id.trim() === '') { + issues.push({ + code: 'missing-required-field', + cardId: id, + message: 'A card row has an empty id.', + }); + } + if (card.name.trim() === '') { + issues.push({ + code: 'missing-required-field', + cardId: id, + message: `Card "${id}" has an empty name.`, + }); + } + } + + const knownIds = new Set(cards.map((card) => base(card.id))); + + // ── Per-card option/link validation ── + const optionsByCard = new Map<ContentCard, StorylineOption<unknown>[]>(); + const choiceCards: ContentCard[] = []; + const storylineIds = new Set<string>(); + + for (const card of cards) { + const id = base(card.id); + if (card.storylineId) storylineIds.add(card.storylineId); + + let options: StorylineOption<unknown>[] = []; + try { + options = compileContentOptions(card); + } catch (err) { + issues.push({ + code: 'invalid-option', + cardId: id, + message: `Choice card "${id}" has a malformed options column: ${(err as Error).message}`, + }); + continue; + } + optionsByCard.set(card, options); + + const isChoice = contentCardHasChoice(card); + if (isChoice) { + choiceCards.push(card); + if (!card.storylineId) { + issues.push({ + code: 'missing-storyline-id', + cardId: id, + message: `Choice card "${id}" declares no storyline id.`, + }); + } + } else if (card.acceptNextCardId || card.rejectNextCardId) { + // Legacy links on a non-choice card are inert at runtime AND invisible + // to option compilation, so they are reported explicitly instead. + issues.push({ + code: 'orphan-link', + cardId: id, + message: `Card "${id}" declares accept/reject links but is not marked as a choice card (no hasChoices flag and no options).`, + }); + for (const [field, target] of [ + ['acceptNextCardId', card.acceptNextCardId], + ['rejectNextCardId', card.rejectNextCardId], + ] as const) { + if (!target) continue; + const targetBase = base(target); + if (!knownIds.has(targetBase)) { + issues.push({ + code: 'dangling-link', + cardId: id, + target: targetBase, + message: `Card "${id}" ${field} links to unknown card "${targetBase}".`, + }); + } + } + } + + if (!isChoice) continue; + + // Duplicate labels make the label unusable as a stable choice token. + const seenLabels = new Set<string>(); + for (const option of options) { + const label = option.label.trim(); + const labelKey = label.toLowerCase(); + if (label === '') { + issues.push({ + code: 'invalid-option', + cardId: id, + message: `Choice card "${id}" has an option with an empty label.`, + }); + } else if (seenLabels.has(labelKey)) { + issues.push({ + code: 'invalid-option', + cardId: id, + message: `Choice card "${id}" declares the option label "${option.label}" more than once.`, + }); + } + seenLabels.add(labelKey); + + if (option.effectPolicy !== 'apply' && option.effectPolicy !== 'skip') { + issues.push({ + code: 'invalid-option', + cardId: id, + message: `Choice card "${id}" option "${option.label}" has invalid effect policy "${String(option.effectPolicy)}".`, + }); + } + + for (const target of optionSuccessorIds(option, adapter)) { + if (!knownIds.has(target)) { + issues.push({ + code: 'unknown-successor', + cardId: id, + target, + message: `Choice card "${id}" option "${option.label}" links to unknown card "${target}".`, + }); + } + } + } + } + + // ── Adjacency over the choice graph (base ids, known targets only) ── + const adjacency = new Map<string, string[]>(); + for (const card of choiceCards) { + const from = base(card.id); + const next: string[] = []; + for (const option of optionsByCard.get(card) ?? []) { + for (const target of optionSuccessorIds(option, adapter)) { + if (knownIds.has(target)) next.push(target); + } + } + if (next.length > 0) adjacency.set(from, next); + } + + // ── Cycle detection (informational unless failOnCycle) ── + for (const cycle of findCycles(adjacency)) { + if (cycle.length === 1) { + findings.push({ + kind: 'self-link', + path: cycle, + message: `Card "${cycle[0]}" links to itself (self-cycle).`, + }); + } else { + findings.push({ + kind: 'cycle', + path: cycle, + message: `Cycle detected: ${cycle.join(' -> ')} -> ${cycle[0]}`, + }); + } + } + if (opts.failOnCycle) { + for (const finding of findings) { + issues.push({ + code: 'cycle-error', + cardId: finding.path[0] ?? '', + message: finding.message, + }); + } + } + + // ── Unreachable story-line members ── + issues.push(...findUnreachableIssues(cards, adjacency, adapter)); + + return { + gameId: adapter.gameId, + cardCount: cards.length, + choiceCardCount: choiceCards.length, + storylineIds: [...storylineIds].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)), + issues, + findings, + valid: issues.length === 0, + }; +} + +/** + * Reports story-line members that cannot be reached from their story-line's + * entry card. + * + * Each storyline group's entry card is the first member (in file order) with + * no incoming edge from another member; a closed cycle has no such member, so + * its first card becomes the entry. Members outside the entry's reachable set + * are a disconnected fragment — an authoring error that no runtime path can + * ever visit. + * + * @param cards All content cards. + * @param adjacency The choice-graph adjacency (base ids). + * @param adapter The game content adapter. + * @returns One `unreachable-card` issue per unreachable member. + */ +function findUnreachableIssues( + cards: readonly ContentCard[], + adjacency: ReadonlyMap<string, readonly string[]>, + adapter: ContentAdapter, +): ContentIssue[] { + const base = (id: string): string => baseIdOf(adapter, id); + const membersByStoryline = new Map<string, string[]>(); + for (const card of cards) { + if (!card.storylineId) continue; + const members = membersByStoryline.get(card.storylineId) ?? []; + const id = base(card.id); + if (!members.includes(id)) members.push(id); + membersByStoryline.set(card.storylineId, members); + } + + const issues: ContentIssue[] = []; + for (const [storylineId, members] of membersByStoryline) { + if (members.length <= 1) continue; + const memberSet = new Set(members); + const hasIncoming = new Set<string>(); + for (const from of members) { + for (const to of adjacency.get(from) ?? []) { + if (memberSet.has(to) && to !== from) hasIncoming.add(to); + } + } + const entry = members.find((id) => !hasIncoming.has(id)) ?? members[0]; + + const reached = new Set<string>([entry]); + const queue = [entry]; + while (queue.length > 0) { + const current = queue.shift() as string; + for (const next of adjacency.get(current) ?? []) { + if (!memberSet.has(next) || reached.has(next)) continue; + reached.add(next); + queue.push(next); + } + } + + for (const id of members) { + if (reached.has(id)) continue; + issues.push({ + code: 'unreachable-card', + cardId: id, + message: `Card "${id}" in story-line "${storylineId}" is unreachable from the story-line's entry card "${entry}".`, + }); + } + } + return issues; +} + +// ── Cycle detection (iterative DFS, reports each elementary cycle once) ── + +/** + * Finds cycles in a directed graph. + * + * Returns each distinct cycle as an array of nodes in traversal order (the + * closing edge is implicit). Rotations of the same cycle are collapsed, so the + * result is deterministic regardless of adjacency insertion order. + * + * @param adjacency Node → successor nodes. + * @returns Distinct cycles, in discovery order. + */ +export function findCycles( + adjacency: ReadonlyMap<string, readonly string[]>, +): string[][] { + const cycles: string[][] = []; + const seenKeys = new Set<string>(); + const WHITE = 0; + const GREY = 1; + const BLACK = 2; + const colour = new Map<string, number>(); + const stack: string[] = []; + + const keyOf = (path: readonly string[]): string => { + // Rotate so the smallest node is first, then join — collapses rotations. + if (path.length === 0) return ''; + let min = 0; + for (let i = 1; i < path.length; i++) { + if ((path[i] as string) < (path[min] as string)) min = i; + } + const rotated = [...path.slice(min), ...path.slice(0, min)]; + return rotated.join('|'); + }; + + const visit = (node: string): void => { + colour.set(node, GREY); + stack.push(node); + for (const next of adjacency.get(node) ?? []) { + const c = colour.get(next) ?? WHITE; + if (c === GREY) { + const start = stack.indexOf(next); + const cycle = stack.slice(start); + const key = keyOf(cycle); + if (!seenKeys.has(key)) { + seenKeys.add(key); + cycles.push(cycle); + } + } else if (c === WHITE) { + visit(next); + } + } + stack.pop(); + colour.set(node, BLACK); + }; + + for (const node of adjacency.keys()) { + if ((colour.get(node) ?? WHITE) === WHITE) visit(node); + } + return cycles; +} + +// ── Reports ───────────────────────────────────────────────── + +/** + * Renders a human-readable validation report (CLI output). + * + * @param result The validation result. + * @returns A deterministic multi-line report. + */ +export function formatContentValidationReport( + result: ContentValidationResult, +): string { + const lines: string[] = []; + lines.push('Story-line content validation report'); + lines.push('====================================='); + lines.push(`Game: ${result.gameId}`); + lines.push(`Cards: ${result.cardCount}`); + lines.push(`Choice cards: ${result.choiceCardCount}`); + lines.push( + `Story-lines: ${result.storylineIds.length > 0 ? result.storylineIds.join(', ') : '(none)'}`, + ); + lines.push(''); + if (result.issues.length > 0) { + lines.push(`ERRORS (${result.issues.length}):`); + for (const issue of result.issues) { + lines.push(` ✗ [${issue.code}] ${issue.message}`); + } + } else { + lines.push('ERRORS (0): none'); + } + lines.push(''); + lines.push(`Informational findings (${result.findings.length}):`); + if (result.findings.length === 0) { + lines.push(' (none)'); + } else { + for (const finding of result.findings) { + lines.push(` ℹ [${finding.kind}] ${finding.message}`); + } + } + lines.push(''); + lines.push(result.valid ? 'RESULT: VALID' : 'RESULT: INVALID'); + return lines.join('\n'); +} + +/** + * Renders a stable JSON representation (for tooling/CI consumption). + * + * The `issues` and `findings` arrays are the same data the text report + * renders, so the two reports always agree. + * + * @param result The validation result. + * @returns Pretty-printed JSON. + */ +export function contentValidationToJson( + result: ContentValidationResult, +): string { + return JSON.stringify( + { + valid: result.valid, + gameId: result.gameId, + cardCount: result.cardCount, + choiceCardCount: result.choiceCardCount, + storylineIds: result.storylineIds, + issues: result.issues, + findings: result.findings, + }, + null, + 2, + ); +} + +/** + * Validates raw content CSV text through an adapter. + * + * @param csvText Raw CSV text. + * @param adapter The game content adapter. + * @param opts Validation options. + * @returns The validation result. + */ +export function validateContentCsv( + csvText: string, + adapter: ContentAdapter, + opts: ContentValidationOptions = {}, +): ContentValidationResult { + return validateContentCards(cardsFromCsv(parseCsv(csvText), adapter), adapter, opts); +} diff --git a/src/story-line/editor/api.ts b/src/story-line/editor/api.ts new file mode 100644 index 000000000..b749771cd --- /dev/null +++ b/src/story-line/editor/api.ts @@ -0,0 +1,356 @@ +/** + * Story-line editor write bridge. + * + * The editor (page → host endpoints) never touches the content CSV itself. + * This module is the bridge's **Node side**: the transactional write, the + * concurrency guard, artefact regeneration and honest documentation + * reporting. The **pure** side (edit application, diff, preview, validation) + * lives in {@link ./edits} and is safe to run in a browser; it is re-exported + * here so host code and tooling keep one import surface. + * + * | Concern | Reused core seam | + * |---|---| + * | Applying an edit in memory | `addContentCard`, `updateContentCard`, `updateContentCards`, `addLinkedContentCard` (`content/mutate.ts`) | + * | Validating + reports | `validateContentCsv`, `formatContentValidationReport`, `contentValidationToJson` (`content/validate.ts`) | + * | Manifest + Mermaid | `buildStorylineManifestFromCsv`, `serialiseManifest`, `renderMermaid` (`content/manifest.ts`) | + * | Content checksum | `computeContentChecksum` (`content/csv.ts`) | + * | Transactional write | `applyContentEditToFile` (`content/author.ts`) — the only content writer | + * | Generated artefacts | `writeArtefacts` / `checkArtefacts` (`content/artefacts.ts`) | + * + * Nothing here writes the CSV: `applyEdit` delegates to the author API, which + * validates and writes atomically. The bridge adds the three things the editor + * needs on top of that: + * + * 1. **Preview/diff** — before/after CSV rows for the affected cards plus the + * validation report, computed in memory (`previewEdit`). + * 2. **A concurrency guard** — the checksum captured at load is handed to the + * writer, which compares it with its own read and refuses on mismatch, + * returning a reload instruction instead of clobbering a concurrent edit. + * 3. **Artefact regeneration + honest reporting** — after an accepted edit the + * committed Mermaid/JSON artefacts are regenerated and verified by a + * post-write drift check; hand-written documentation is never claimed as + * machine-synced, so an edit touching a documented row reports an explicit + * "manual documentation update required" naming the check that now fails. + * + * Game-import-free; Node builtins only where the file system is genuinely + * required (reading/writing the content the caller asked about). + * + * @module + */ + +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { + DEFAULT_ARTEFACT_PATHS, + checkArtefacts, + previewArtefacts, + writeArtefacts, + type ArtefactDrift, + type ArtefactPaths, + type ArtefactWriteResult, +} from '../content/artefacts'; +import { + applyContentEditToFile, + type ContentFileEditResult, +} from '../content/author'; +import { computeContentChecksum } from '../content/csv'; +import { + buildStorylineManifestFromCsv, + type StorylineManifest, +} from '../content/manifest'; +import type { ContentAdapter } from '../content/types'; +import { + DEFAULT_MANUAL_DOC_SECTIONS, + findManualDocUpdates, + type ManualDocSection, + type ManualDocUpdate, +} from './manual-docs'; +import { applyEditToCsv, previewEdit, type EditPreview, type EditorEdit } from './edits'; + +// The pure edit/preview seam is re-exported so existing callers (host, CLI, +// tests, page modules importing types) keep a single import surface. +export * from './edits'; + +// ── Apply ─────────────────────────────────────────────────── + +/** Why an apply request ended the way it did. */ +export type EditApplyStatus = 'applied' | 'dry-run' | 'invalid' | 'stale-content'; + +/** The artefact outcome of an accepted edit. */ +export interface EditArtefactOutcome { + /** The artefact paths involved. */ + readonly paths: ArtefactPaths; + /** True only when the artefacts were (re)generated and verified in sync. */ + readonly ok: boolean; + /** True when the artefacts were actually written (`false` on a dry run). */ + readonly regenerated: boolean; + /** Paths written (empty on a dry run). */ + readonly written: readonly string[]; + /** Post-write drift check (`missing`/`drift` entries when not in sync). */ + readonly drifts: readonly ArtefactDrift[]; + /** Actionable one-line summary of the artefact state. */ + readonly message: string; +} + +/** Options for {@link applyEdit}. */ +export interface ApplyEditOptions { + /** Content CSV path (absolute or relative to `baseDir`). */ + readonly contentPath: string; + /** The game content adapter. */ + readonly adapter: ContentAdapter; + /** The edit to apply. */ + readonly edit: EditorEdit; + /** + * The content checksum captured when the editor loaded the file. Required: + * the concurrency guard must never be silently disabled by an edit request. + */ + readonly expectedChecksum: string; + /** When true, preview and validate but write nothing. */ + readonly dryRun?: boolean; + /** Generated-artefact paths (defaults to the committed Gym artefacts). */ + readonly artefactPaths?: ArtefactPaths; + /** Hand-written documentation registry (defaults to the shipped sections). */ + readonly manualDocs?: readonly ManualDocSection[]; + /** When false, skip artefact regeneration (tests/tooling that only want a CSV). */ + readonly regenerateArtefacts?: boolean; + /** Base directory relative paths resolve against (default `process.cwd()`). */ + readonly baseDir?: string; +} + +/** The outcome of an apply request. */ +export interface EditApplyResult { + /** Why the request ended the way it did. */ + readonly status: EditApplyStatus; + /** True when the content was accepted (`applied` or `dry-run`). */ + readonly ok: boolean; + /** HTTP-style status code for the host endpoints (200/409/422/500). */ + readonly code: number; + /** True only when the content file was actually written. */ + readonly written: boolean; + /** The content path targeted. */ + readonly contentPath: string; + /** The preview/diff and validation report for the attempted edit. */ + readonly preview: EditPreview; + /** Actionable errors (empty when `ok`). */ + readonly errors: readonly string[]; + /** Human-readable outcome summary (used verbatim by the CLI/host). */ + readonly message: string; + /** True when the caller must reload the content before retrying. */ + readonly reloadRequired: boolean; + /** The checksum of the content after the attempt (candidate when accepted). */ + readonly checksum: string; + /** The artefact outcome (present only when the content was accepted). */ + readonly artefacts?: EditArtefactOutcome; + /** Documented sections whose rows this edit touched (manual update required). */ + readonly manualDocUpdates: readonly ManualDocUpdate[]; + /** True when at least one hand-written doc section needs a human edit. */ + readonly manualDocumentationRequired: boolean; +} + +/** Maps an author file-edit result to the bridge's status. */ +function statusOf(result: ContentFileEditResult, dryRun: boolean): EditApplyStatus { + if (!result.ok) return result.stale ? 'stale-content' : 'invalid'; + return dryRun ? 'dry-run' : 'applied'; +} + +/** Builds the artefact outcome for an accepted candidate. */ +function artefactOutcome( + result: ContentFileEditResult, + options: ApplyEditOptions, +): EditArtefactOutcome { + const paths = options.artefactPaths ?? DEFAULT_ARTEFACT_PATHS; + const baseDir = options.baseDir ?? process.cwd(); + const manifest = buildStorylineManifestFromCsv(result.csv, options.adapter); + + if (options.dryRun) { + const preview = previewArtefacts(manifest, paths, baseDir); + return { + paths, + ok: preview.drifts.length === 0, + regenerated: false, + written: [], + drifts: preview.drifts, + message: + preview.drifts.length === 0 + ? 'dry run: the committed artefacts would remain in sync' + : `dry run: the edit would leave ${preview.drifts.length} artefact(s) out of sync until regenerated`, + }; + } + + const written: ArtefactWriteResult = writeArtefacts(manifest, paths, baseDir); + return { + paths: written.paths, + ok: written.ok, + regenerated: true, + written: written.written, + drifts: written.drifts, + message: written.ok + ? `regenerated and verified ${written.written.length} artefact(s) — no drift` + : `regenerated ${written.written.length} artefact(s) but ${written.drifts.length} still drifted`, + }; +} + +/** Builds the outcome message for an accepted edit. */ +function successMessage( + status: EditApplyStatus, + result: ContentFileEditResult, + artefacts: EditArtefactOutcome | undefined, + manualUpdates: readonly ManualDocUpdate[], +): string { + const parts: string[] = []; + if (status === 'dry-run') { + parts.push(`dry run: the edit is valid and would apply to ${result.path}; nothing written`); + } else { + parts.push(`applied the edit to ${result.path}`); + } + if (artefacts) parts.push(artefacts.message); + + if (manualUpdates.length > 0) { + parts.push( + `manual documentation update required — ${manualUpdates.length} hand-written section(s) ` + + `document an edited row: ${manualUpdates + .map( + (update) => + `${update.check} (${update.path}; check that now fails: ${update.verifyCommand})`, + ) + .join('; ')}`, + ); + } else { + parts.push('no hand-written documentation section documents the edited rows'); + } + return `${parts.join('; ')}.`; +} + +/** + * Adds the `write-error` outcome: an accepted-looking edit that the writer + * could not persist is a server-side failure (500), not a content error. + */ +function failureCode(result: ContentFileEditResult): number { + if (result.stale) return 409; + return result.errors.some((error) => error.startsWith('write failed:')) ? 500 : 422; +} + +/** + * Applies an edit through the core author API, regenerating the committed + * artefacts and reporting hand-written documentation honestly. + * + * The concurrency guard runs inside the writer's own read (via + * `expectedChecksum`), so a concurrent external edit can never be clobbered by + * a stale editor snapshot. + * + * @param options The edit, the adapter and the concurrency token. + * @returns The apply outcome (never throws for validation/IO refusals). + */ +export function applyEdit(options: ApplyEditOptions): EditApplyResult { + const { adapter, edit, expectedChecksum, dryRun = false } = options; + const baseDir = options.baseDir ?? process.cwd(); + const resolvedPath = resolve(baseDir, options.contentPath); + + // The content the writer actually sees, captured for the diff below. + let beforeCsv = ''; + const fileResult = applyContentEditToFile({ + contentPath: resolvedPath, + adapter, + dryRun, + expectedChecksum, + mutate: (csv) => { + beforeCsv = csv; + return applyEditToCsv(csv, adapter, edit); + }, + }); + + const status = statusOf(fileResult, dryRun); + // The writer refuses before mutating on a stale token, so read the current + // content to report what the edit would have done against it. + const snapshot = beforeCsv === '' ? readFileSync(resolvedPath, 'utf-8') : beforeCsv; + const preview = previewEdit(snapshot, adapter, edit); + + if (!fileResult.ok) { + const stale = fileResult.stale === true; + return { + status: stale ? 'stale-content' : 'invalid', + ok: false, + code: failureCode(fileResult), + written: false, + contentPath: resolvedPath, + preview, + errors: fileResult.errors, + message: stale + ? `refused to write: ${fileResult.errors.join('; ')}` + : `refused to write — validation failed: ${fileResult.errors.join('; ')}`, + reloadRequired: fileResult.reloadRequired === true, + checksum: computeContentChecksum(snapshot), + manualDocUpdates: [], + manualDocumentationRequired: false, + }; + } + + const artefacts = + options.regenerateArtefacts === false ? undefined : artefactOutcome(fileResult, { ...options, baseDir }); + const manualDocUpdates = findManualDocUpdates( + preview.affectedIds, + options.manualDocs ?? DEFAULT_MANUAL_DOC_SECTIONS, + ); + + return { + status, + ok: true, + code: 200, + written: fileResult.written, + contentPath: resolvedPath, + preview, + errors: [], + message: successMessage(status, fileResult, artefacts, manualDocUpdates), + reloadRequired: false, + checksum: computeContentChecksum(fileResult.csv), + ...(artefacts ? { artefacts } : {}), + manualDocUpdates, + manualDocumentationRequired: manualDocUpdates.length > 0, + }; +} + +/** + * Regenerates the committed artefacts for a content file. + * + * Exposed separately so tooling can repair artefacts without editing content; + * the content itself is never written here. + * + * @param csvText The accepted content CSV text. + * @param adapter The game content adapter. + * @param options Artefact paths and base directory. + * @returns The manifest plus the regeneration result. + */ +export function regenerateArtefacts( + csvText: string, + adapter: ContentAdapter, + options: { readonly artefactPaths?: ArtefactPaths; readonly baseDir?: string } = {}, +): { readonly manifest: StorylineManifest; readonly artefacts: ArtefactWriteResult } { + const manifest = buildStorylineManifestFromCsv(csvText, adapter); + const artefacts = writeArtefacts( + manifest, + options.artefactPaths ?? DEFAULT_ARTEFACT_PATHS, + options.baseDir ?? process.cwd(), + ); + return { manifest, artefacts }; +} + +/** + * Checks the committed artefacts for a content file without writing anything. + * + * @param csvText The content CSV text. + * @param adapter The game content adapter. + * @param options Artefact paths and base directory. + * @returns The drift check (empty when in sync). + */ +export function checkCommittedArtefacts( + csvText: string, + adapter: ContentAdapter, + options: { readonly artefactPaths?: ArtefactPaths; readonly baseDir?: string } = {}, +): ArtefactDrift[] { + return checkArtefacts( + buildStorylineManifestFromCsv(csvText, adapter), + options.artefactPaths ?? DEFAULT_ARTEFACT_PATHS, + options.baseDir ?? process.cwd(), + ); +} diff --git a/src/story-line/editor/edits.ts b/src/story-line/editor/edits.ts new file mode 100644 index 000000000..b68c6ad70 --- /dev/null +++ b/src/story-line/editor/edits.ts @@ -0,0 +1,506 @@ +/** + * Story-line editor edits: in-memory application, diff and preview. + * + * The editor (page → host endpoints) never touches the content CSV itself. + * This module is the **pure** editor edit seam — what a preview can run in a + * browser with no file system — while the transactional write lives in + * {@link ../api} (which routes every accepted edit through the core author + * `applyContentEditToFile`, the only content writer). + * + * | Concern | Reused core seam | + * |---|---| + * | Applying an edit in memory | `addContentCard`, `updateContentCard`, `updateContentCards`, `addLinkedContentCard` (`content/mutate.ts`) | + * | Validating + reports | `validateContentCsv`, `formatContentValidationReport`, `contentValidationToJson` (`content/validate.ts`) | + * | Content checksum | `computeContentChecksum` (`content/csv.ts`) | + * + * Editor edit kinds (each mapped onto one core author function): + * + * - `update-card` — edit an existing card's fields (the F3 edit form); + * - `add-card` — append a new card (the F3 add-card form); + * - `add-linked-card` — append a new card **and** link it into a chain from + * an existing card in one in-memory pass (the F3 guided add/link form); + * - `set-storyline` / `set-storyline-batch` — story-line group membership + * (F4, exposed through the same author functions). + * + * No module here writes the CSV; everything is pure over CSV text. + * + * @module + */ + +import { + addContentCard, + applyAddContentCardSpec, + applyUpdateContentCardSpec, + updateContentCard, + updateContentCards, + readContentCards, + type AddContentCardSpec, + type ContentEditResult, + type UpdateContentCardSpec, +} from '../content/mutate'; +import { computeContentChecksum, parseCsv } from '../content/csv'; +import { baseIdOf, type ContentAdapter } from '../content/types'; +import { + contentValidationToJson, + formatContentValidationReport, + validateContentCsv, + type ContentValidationResult, +} from '../content/validate'; + +// ── Edits ─────────────────────────────────────────────────── + +/** A storyline assignment for one card (the `setStoryline` bridge for F4). */ +export interface SetStorylineSpec { + /** The card to reassign. */ + readonly id: string; + /** New story-line id (`null` clears it). */ + readonly storylineId: string | null; + /** New story-line title (`undefined` leaves it alone, `null` clears it). */ + readonly storylineTitle?: string | null; +} + +/** A storyline-level batch assignment (rename/reassign a whole group once). */ +export interface SetStorylineBatchSpec { + /** The cards to reassign, in order. */ + readonly ids: readonly string[]; + /** New story-line id (`null` clears it). */ + readonly storylineId: string | null; + /** New story-line title (`undefined` leaves it alone, `null` clears it). */ + readonly storylineTitle?: string | null; +} + +/** + * A spec for adding a card **and** linking it in from an existing card in one + * transaction: the new card is appended with its own fields, then the chosen + * predecessor gains an option (or legacy accept/reject link) pointing at it. + * This is what makes a guided add into an existing multi-member story-line + * valid (`unreachable-card` never fires because the new card is reachable). + */ +export interface AddLinkedCardSpec { + /** The new card (the same spec {@link addContentCard} accepts). */ + readonly card: AddContentCardSpec; + /** The predecessor that gains a link to the new card. */ + readonly linkFrom: { + /** The predecessor's card id. */ + readonly id: string; + /** + * Option label shown to the player. Required when the adapter maps an + * `options` column; defaults to the new card's name. + */ + readonly label?: string; + /** + * Which legacy link column to set when the adapter maps no `options` + * column (`accept` default). Ignored when `options` is mapped. + */ + readonly linkKind?: 'accept' | 'reject'; + }; +} + +/** + * An editor edit: the curated operations the guided forms and the storyline + * manager drive, each mapped onto a core author function. + */ +export type EditorEdit = + | { readonly kind: 'add-card'; readonly spec: AddContentCardSpec } + | { readonly kind: 'add-linked-card'; readonly spec: AddLinkedCardSpec } + | { readonly kind: 'update-card'; readonly spec: UpdateContentCardSpec } + | { readonly kind: 'set-storyline'; readonly spec: SetStorylineSpec } + | { readonly kind: 'set-storyline-batch'; readonly spec: SetStorylineBatchSpec }; + +/** The `kind` values an edit can carry. */ +export const EDITOR_EDIT_KINDS = [ + 'add-card', + 'add-linked-card', + 'update-card', + 'set-storyline', + 'set-storyline-batch', +] as const; + +/** Renders a card's storyline fields as an {@link UpdateContentCardSpec}. */ +function storylineUpdate(spec: SetStorylineSpec): UpdateContentCardSpec { + return { + id: spec.id, + storylineId: spec.storylineId, + ...(spec.storylineTitle !== undefined ? { storylineTitle: spec.storylineTitle } : {}), + }; +} + +/** + * Assigns a card to a story-line group (the single `setStoryline` wrapper). + * + * A story-line exists implicitly through its members, so assignment is a plain + * update of the card's storyline id/title fields — routed through the same + * author function as every other field edit. + * + * @param csvText Current content CSV text. + * @param adapter The game content adapter. + * @param spec The assignment. + * @returns The edit result. + */ +export function setStoryline( + csvText: string, + adapter: ContentAdapter, + spec: SetStorylineSpec, +): ContentEditResult { + return updateContentCard(csvText, adapter, storylineUpdate(spec)); +} + +/** + * Assigns many cards to a story-line group in a single in-memory pass and + * validates the combined result once (the F4 batch rename/reassign). + * + * @param csvText Current content CSV text. + * @param adapter The game content adapter. + * @param spec The batch assignment. + * @returns The edit result. + */ +export function setStorylineBatch( + csvText: string, + adapter: ContentAdapter, + spec: SetStorylineBatchSpec, +): ContentEditResult { + return updateContentCards( + csvText, + adapter, + spec.ids.map((id) => + storylineUpdate({ + id, + storylineId: spec.storylineId, + storylineTitle: spec.storylineTitle, + }), + ), + ); +} + +/** + * Appends an option (or legacy link) to `options`/`accept`/`reject` on the + * predecessor card, pointing at the new card's id. Returns the update spec. + */ +function linkUpdateFrom( + adapter: ContentAdapter, + predecessor: { readonly id: string; readonly options: string }, + newId: string, + link: AddLinkedCardSpec['linkFrom'], +): UpdateContentCardSpec { + const label = (link.label ?? '').trim(); + if (adapter.fields.options !== undefined) { + const existing = predecessor.options.trim(); + return { + id: predecessor.id, + options: existing === '' ? `${label}|${newId}` : `${existing};${label}|${newId}`, + // Appending an options encoding makes the predecessor a choice card; + // keep the raw flag in step so the column is not a stale `false`. + ...(adapter.fields.hasChoices !== undefined ? { hasChoices: true } : {}), + }; + } + if (adapter.fields.acceptNextCardId !== undefined && (link.linkKind ?? 'accept') === 'accept') { + return { id: predecessor.id, acceptNextCardId: newId }; + } + return { id: predecessor.id, rejectNextCardId: newId }; +} + +/** Folds a candidate CSV's validation outcome into an edit result. */ +function finaliseCandidate( + originalCsv: string, + candidateCsv: string, + adapter: ContentAdapter, +): ContentEditResult { + const validation = validateContentCsv(candidateCsv, adapter); + if (!validation.valid) { + return { + csv: originalCsv, + ok: false, + errors: validation.issues.map((issue) => issue.message), + validation, + }; + } + return { csv: candidateCsv, ok: true, errors: [], validation }; +} + +/** + * Appends a new card and links it in from an existing card, validating the + * combined candidate **once**. + * + * The new card is applied through the pure add step; then the chosen + * predecessor gains an option (`label|newId` appended to its `options` + * encoding) or a legacy accept/reject link pointing at the new card, applied + * through the pure update step. Validating only the combined candidate is + * deliberate: validating the intermediate candidate (the new card alone) would + * reject it as unreachable from its story-line before the link existed. The + * returned `csv` is the unchanged input whenever either step fails — so the + * write path stays single, transactional and validator-gated. + * + * @param csvText Current content CSV text. + * @param adapter The game content adapter. + * @param spec The new card plus the predecessor link. + * @returns The edit result. + */ +export function addLinkedContentCard( + csvText: string, + adapter: ContentAdapter, + spec: AddLinkedCardSpec, +): ContentEditResult { + const added = applyAddContentCardSpec(csvText, adapter, spec.card); + if (!added.ok) return added; + + const newId = baseIdOf(adapter, spec.card.id); + const predecessorId = baseIdOf(adapter, spec.linkFrom.id); + const cards = readContentCards(added.csv, adapter); + const predecessor = cards.find((card) => baseIdOf(adapter, card.id) === predecessorId); + if (!predecessor) { + return { + csv: csvText, + ok: false, + errors: [`add-linked-card: predecessor card "${spec.linkFrom.id}" not found`], + }; + } + + const linked = applyUpdateContentCardSpec( + added.csv, + adapter, + linkUpdateFrom(adapter, predecessor, newId, spec.linkFrom), + ); + if (!linked.ok) { + // Never hand a partially-applied candidate to the writer. + return { ...linked, csv: csvText }; + } + return finaliseCandidate(csvText, linked.csv, adapter); +} + +/** + * Applies one editor edit to content text in memory (no validation folding is + * added here — the author functions validate the candidate themselves). + * + * @param csvText Current content CSV text. + * @param adapter The game content adapter. + * @param edit The edit to apply. + * @returns The author result (candidate text only when `ok`). + */ +export function applyEditToCsv( + csvText: string, + adapter: ContentAdapter, + edit: EditorEdit, +): ContentEditResult { + switch (edit.kind) { + case 'add-card': + return addContentCard(csvText, adapter, edit.spec); + case 'add-linked-card': + return addLinkedContentCard(csvText, adapter, edit.spec); + case 'update-card': + return updateContentCard(csvText, adapter, edit.spec); + case 'set-storyline': + return setStoryline(csvText, adapter, edit.spec); + case 'set-storyline-batch': + return setStorylineBatch(csvText, adapter, edit.spec); + default: { + // Exhaustiveness: an unknown kind is a caller/protocol error, never a + // silent no-op that would claim success without an edit. + const unknown = edit as { kind?: unknown }; + return { + csv: csvText, + ok: false, + errors: [`unknown edit kind "${String(unknown.kind)}"`], + }; + } + } +} + +// ── Diff / preview ────────────────────────────────────────── + +/** How an affected row changed between the loaded and candidate content. */ +export type EditRowStatus = 'added' | 'removed' | 'changed'; + +/** One affected content row, with its before and after cell values. */ +export interface EditRowDiff { + /** Base card id (chain serials stripped through the adapter). */ + readonly id: string; + /** What happened to the row. */ + readonly status: EditRowStatus; + /** The row's cells keyed by column before the edit (`null` for an added row). */ + readonly before: Readonly<Record<string, string>> | null; + /** The row's cells keyed by column after the edit (`null` for a removed row). */ + readonly after: Readonly<Record<string, string>> | null; + /** Columns whose value differs (added/removed rows list every affected column). */ + readonly changedColumns: readonly string[]; +} + +/** One parsed row, indexed by its base card id. */ +interface IndexedRow { + readonly id: string; + readonly cells: Readonly<Record<string, string>>; +} + +/** Indexes CSV rows by base card id, keeping the first occurrence of an id. */ +function indexRows(csvText: string, adapter: ContentAdapter): Map<string, IndexedRow> { + const parsed = parseCsv(csvText); + const idIndex = parsed.header.indexOf(adapter.fields.id); + const index = new Map<string, IndexedRow>(); + for (const row of parsed.rows) { + const rawId = idIndex >= 0 ? (row[idIndex] ?? '') : ''; + const id = baseIdOf(adapter, rawId); + if (index.has(id)) continue; + const cells: Record<string, string> = {}; + parsed.header.forEach((column, columnIndex) => { + cells[column] = row[columnIndex] ?? ''; + }); + index.set(id, { id, cells }); + } + return index; +} + +/** + * Diffs two content CSVs row-by-row, by base card id. + * + * Only affected rows are returned (`added`, `removed`, `changed`), in the + * candidate's file order followed by removed ids — a row-order-only + * difference is not an edit, so it never shows up as a change. + * + * @param beforeCsv The content the editor loaded. + * @param afterCsv The candidate content. + * @param adapter The game content adapter. + * @returns The affected rows with their before/after cells. + */ +export function diffContentRows( + beforeCsv: string, + afterCsv: string, + adapter: ContentAdapter, +): EditRowDiff[] { + const before = indexRows(beforeCsv, adapter); + const after = indexRows(afterCsv, adapter); + const diffs: EditRowDiff[] = []; + + for (const [id, row] of after) { + const previous = before.get(id); + if (!previous) { + diffs.push({ + id, + status: 'added', + before: null, + after: row.cells, + changedColumns: Object.keys(row.cells), + }); + continue; + } + const columns = [...new Set([...Object.keys(previous.cells), ...Object.keys(row.cells)])]; + const changedColumns = columns.filter((column) => previous.cells[column] !== row.cells[column]); + if (changedColumns.length > 0) { + diffs.push({ + id, + status: 'changed', + before: previous.cells, + after: row.cells, + changedColumns, + }); + } + } + + for (const [id, row] of before) { + if (after.has(id)) continue; + diffs.push({ + id, + status: 'removed', + before: row.cells, + after: null, + changedColumns: Object.keys(row.cells), + }); + } + + return diffs; +} + +/** A preview of one editor edit, with its diff and validation report. */ +export interface EditPreview { + /** True when the edit applied in memory and the candidate validates. */ + readonly ok: boolean; + /** The edit kind that was previewed. */ + readonly kind: string; + /** Checksum of the content the edit was previewed against (the reload token). */ + readonly checksum: string; + /** Checksum of the candidate content (absent when the edit was refused). */ + readonly candidateChecksum?: string; + /** The candidate CSV text (absent when the edit was refused). */ + readonly csv?: string; + /** Affected rows, before and after (empty when the edit was refused). */ + readonly rows: readonly EditRowDiff[]; + /** The base ids the edit touched. */ + readonly affectedIds: readonly string[]; + /** The validator's machine-readable result for the candidate. */ + readonly validation: ContentValidationResult; + /** Actionable error messages (empty when `ok`), naming the offending card/link. */ + readonly errors: readonly string[]; + /** The validator's text report (identical to the CLI's rendering). */ + readonly report: string; + /** The validator's JSON report (identical to the CLI's rendering). */ + readonly reportJson: string; +} + +/** + * Previews an edit: applies it in memory, validates the candidate and diffs the + * affected rows. Nothing is written. + * + * @param csvText Current (loaded) content CSV text. + * @param adapter The game content adapter. + * @param edit The edit to preview. + * @returns The preview/diff plus the validation report. + */ +export function previewEdit( + csvText: string, + adapter: ContentAdapter, + edit: EditorEdit, +): EditPreview { + const result = applyEditToCsv(csvText, adapter, edit); + const validation = result.validation ?? validateContentCsv(result.csv, adapter); + const rows = result.ok ? diffContentRows(csvText, result.csv, adapter) : []; + return { + ok: result.ok, + kind: edit.kind, + checksum: computeContentChecksum(csvText), + ...(result.ok ? { candidateChecksum: computeContentChecksum(result.csv), csv: result.csv } : {}), + rows, + affectedIds: rows.map((row) => row.id), + validation, + errors: result.errors, + report: formatContentValidationReport(validation), + reportJson: contentValidationToJson(validation), + }; +} + +/** The outcome of validating one edit (no diff, no write). */ +export interface EditValidation { + /** True when the candidate is valid and the edit would be accepted. */ + readonly ok: boolean; + /** The checksum of the content that was validated. */ + readonly checksum: string; + /** Actionable error messages (empty when `ok`). */ + readonly errors: readonly string[]; + /** The validator's machine-readable result. */ + readonly validation: ContentValidationResult; + /** The validator's text report. */ + readonly report: string; + /** The validator's JSON report. */ + readonly reportJson: string; +} + +/** + * Validates an edit without writing (and without computing a diff). + * + * @param csvText Current (loaded) content CSV text. + * @param adapter The game content adapter. + * @param edit The edit to validate. + * @returns The validation result and its reports. + */ +export function validateEdit( + csvText: string, + adapter: ContentAdapter, + edit: EditorEdit, +): EditValidation { + const preview = previewEdit(csvText, adapter, edit); + return { + ok: preview.ok, + checksum: preview.checksum, + errors: preview.errors, + validation: preview.validation, + report: preview.report, + reportJson: preview.reportJson, + }; +} diff --git a/src/story-line/editor/field-descriptors.ts b/src/story-line/editor/field-descriptors.ts new file mode 100644 index 000000000..1b113e1ba --- /dev/null +++ b/src/story-line/editor/field-descriptors.ts @@ -0,0 +1,545 @@ +/** + * Adapter-driven card form field descriptors. + * + * The curated editor forms are **not** built from hard-coded column names: a + * game's {@link ContentAdapter} declares its columns and the story-line field + * mapping, and this module turns that declaration (plus a card's current + * values) into an ordered list of field descriptors the forms render. Core + * edits the generic story-line fields; every adapter-declared column renders + * as an additional plain text field without core knowing what it means (a + * Main Street coin delta is just a column). + * + * The same derivation is reused for a **new** card: `id` becomes editable + * (instead of read-only), values start from the adapter's `newCardDefaults`, + * and the mapped story-line fields start empty. That is what keeps the add + * form adapter-driven too. + * + * Everything here is pure data transformation — no DOM, no Node, no Phaser — + * so the derivation, the draft → edit-spec mapping and the validator-error + * attribution are all unit-testable without a browser, and the same code + * drives the page in a browser. + * + * @module + */ + +import { + editableColumnsOf, + mappedFieldColumns, + type ContentAdapter, + type ContentCard, + type ContentFieldMapping, +} from '../content/types'; +import type { AddContentCardSpec, UpdateContentCardSpec } from '../content/mutate'; +import type { ContentIssue } from '../content/validate'; + +/** How a form control renders a field. */ +export type CardFieldKind = 'read-only' | 'text' | 'long-text' | 'boolean'; + +/** Where a field comes from: the story-line mapping or a game column. */ +export type CardFieldGroup = 'core' | 'adapter'; + +/** One field the card forms render, derived from the adapter. */ +export interface CardFieldDescriptor { + /** + * Stable logical field key: the story-line field name for mapped fields + * (`id`, `name`, `effect`, `options`, …) or `column:<name>` for an adapter + * column. + */ + readonly key: string; + /** Human-readable label (UK English) shown next to the control. */ + readonly label: string; + /** Control kind. */ + readonly kind: CardFieldKind; + /** Whether the field is a mapped story-line field or a game column. */ + readonly group: CardFieldGroup; + /** The CSV column the value round-trips through. */ + readonly column: string; + /** The current value as the control's string form (`'true'`/`'false'` flags). */ + readonly value: string; + /** True when the field may be edited (the card id never is on an edit). */ + readonly editable: boolean; + /** Optional short hint shown under the control. */ + readonly hint?: string; + /** Known card ids offered as datalist suggestions (link fields). */ + readonly suggestions?: readonly string[]; +} + +/** The mapped story-line fields, in form order, with their presentation. */ +interface CoreFieldSpec { + readonly key: Exclude<keyof ContentFieldMapping, never>; + readonly label: string; + readonly kind: CardFieldKind; + readonly hint?: string; +} + +/** + * The core-generic (story-line) fields, in the order the forms render them. + * + * `options` sits between the choice flag and the legacy links on purpose: it + * is the modern successor-link encoding and takes precedence at compile time, + * while accept/reject are the legacy two-way links. The hint says so, so an + * author is never surprised that editing the legacy links has no effect on a + * card that already carries an `options` encoding. + */ +const CORE_FIELDS: readonly CoreFieldSpec[] = [ + { key: 'id', label: 'Card id', kind: 'read-only', hint: 'The card id is the chain link target; it cannot be renamed here.' }, + { key: 'name', label: 'Name', kind: 'text' }, + { key: 'effect', label: 'Effect', kind: 'long-text' }, + { + key: 'hasChoices', + label: 'Choice card', + kind: 'boolean', + hint: 'A choice card presents the legacy two-way accept/reject links.', + }, + { + key: 'options', + label: 'Options', + kind: 'long-text', + hint: 'Options encoding: label|target;label2|target2 — a comma separates random candidates.', + }, + { + key: 'acceptNextCardId', + label: 'Accept link', + kind: 'text', + hint: 'Legacy two-way link — ignored when Options is set.', + }, + { + key: 'rejectNextCardId', + label: 'Reject link', + kind: 'text', + hint: 'Legacy two-way link — ignored when Options is set.', + }, + { key: 'storylineId', label: 'Story-line id', kind: 'text' }, + { key: 'storylineTitle', label: 'Story-line title', kind: 'text' }, +]; + +/** Options for {@link buildCardFieldDescriptors}. */ +export interface FieldDescriptorOptions { + /** Known card ids, offered as suggestions on link fields. */ + readonly knownCardIds?: readonly string[]; +} + +/** Reads one mapped field's current value from a content card. */ +function coreValue(card: ContentCard, key: CoreFieldSpec['key']): string { + switch (key) { + case 'id': + return card.id; + case 'name': + return card.name; + case 'effect': + return card.effect; + case 'hasChoices': + return String(card.hasChoices); + case 'acceptNextCardId': + return card.acceptNextCardId ?? ''; + case 'rejectNextCardId': + return card.rejectNextCardId ?? ''; + case 'options': + return card.options; + case 'storylineId': + return card.storylineId ?? ''; + case 'storylineTitle': + return card.storylineTitle ?? ''; + } +} + +/** True when the adapter maps a given story-line field. */ +function isMapped(adapter: ContentAdapter, key: CoreFieldSpec['key']): boolean { + return adapter.fields[key] !== undefined; +} + +/** Whether a core field offers card-id suggestions. */ +function suggestsCards(key: CoreFieldSpec['key']): boolean { + return ( + key === 'acceptNextCardId' || key === 'rejectNextCardId' || key === 'options' + ); +} + +/** Appends the adapter-declared (non-mapped) columns as text descriptors. */ +function adapterFields( + adapter: ContentAdapter, + valueOf: (column: string) => string, +): CardFieldDescriptor[] { + const mappedColumns = new Set( + mappedFieldColumns(adapter).map(([, column]) => column), + ); + return editableColumnsOf(adapter) + .filter((column) => !mappedColumns.has(column)) + .map((column): CardFieldDescriptor => ({ + key: `column:${column}`, + label: column, + kind: 'text', + group: 'adapter', + column, + value: valueOf(column), + editable: true, + })); +} + +/** + * Derives the field descriptors for editing an existing card. + * + * Only fields the adapter actually maps are included (a game without a legacy + * choice model simply has no choice/link fields), followed by every + * adapter-declared column the file carries. + * + * @param adapter The game content adapter. + * @param card The card being edited. + * @param options Optional known card ids for link suggestions. + * @returns The ordered field descriptors. + */ +export function buildCardFieldDescriptors( + adapter: ContentAdapter, + card: ContentCard, + options: FieldDescriptorOptions = {}, +): CardFieldDescriptor[] { + const descriptors: CardFieldDescriptor[] = []; + for (const spec of CORE_FIELDS) { + if (!isMapped(adapter, spec.key)) continue; + descriptors.push({ + key: spec.key, + label: spec.label, + kind: spec.kind, + group: 'core', + column: adapter.fields[spec.key] as string, + value: coreValue(card, spec.key), + editable: spec.key !== 'id', + ...(spec.hint !== undefined ? { hint: spec.hint } : {}), + ...(suggestsCards(spec.key) && options.knownCardIds + ? { suggestions: options.knownCardIds } + : {}), + }); + } + descriptors.push(...adapterFields(adapter, (column) => card.fields[column] ?? '')); + return descriptors; +} + +/** + * Derives the field descriptors for authoring a **new** card. + * + * The card id is editable (there is nothing to protect yet) and every value + * starts from the adapter's `newCardDefaults`; the required id and name start + * blank. + * + * @param adapter The game content adapter. + * @param options Optional known card ids and initial story-line membership. + * @returns The ordered field descriptors. + */ +export function buildNewCardFieldDescriptors( + adapter: ContentAdapter, + options: FieldDescriptorOptions & { + readonly storylineId?: string | null; + readonly storylineTitle?: string | null; + } = {}, +): CardFieldDescriptor[] { + const defaults = adapter.newCardDefaults ?? {}; + const defaultValue = (key: CoreFieldSpec['key']): string => { + if (key === 'id' || key === 'name' || key === 'effect') return ''; + if (key === 'storylineId') return options.storylineId ?? ''; + if (key === 'storylineTitle') return options.storylineTitle ?? ''; + if (key === 'hasChoices') return 'false'; + return defaults[adapter.fields[key] ?? ''] ?? ''; + }; + + const descriptors: CardFieldDescriptor[] = []; + for (const spec of CORE_FIELDS) { + if (!isMapped(adapter, spec.key)) continue; + descriptors.push({ + key: spec.key, + label: spec.label, + kind: spec.kind === 'read-only' ? 'text' : spec.kind, + group: 'core', + column: adapter.fields[spec.key] as string, + value: defaultValue(spec.key), + editable: true, + ...(spec.hint !== undefined ? { hint: spec.hint } : {}), + ...(suggestsCards(spec.key) && options.knownCardIds + ? { suggestions: options.knownCardIds } + : {}), + }); + } + descriptors.push(...adapterFields(adapter, (column) => defaults[column] ?? '')); + return descriptors; +} + +/** + * Builds the initial draft (field key → string value) from descriptors. + * + * @param descriptors The field descriptors. + * @returns A mutable-by-copy record of the loaded values. + */ +export function draftFromDescriptors( + descriptors: readonly CardFieldDescriptor[], +): Record<string, string> { + const draft: Record<string, string> = {}; + for (const descriptor of descriptors) draft[descriptor.key] = descriptor.value; + return draft; +} + +/** + * The keys whose draft value differs from the descriptor's loaded value. + * + * @param descriptors The field descriptors (the loaded baseline). + * @param draft The current draft. + * @returns Changed, editable field keys in descriptor order. + */ +export function changedFieldKeys( + descriptors: readonly CardFieldDescriptor[], + draft: Readonly<Record<string, string>>, +): string[] { + return descriptors + .filter((descriptor) => descriptor.editable) + .filter((descriptor) => (draft[descriptor.key] ?? descriptor.value) !== descriptor.value) + .map((descriptor) => descriptor.key); +} + +/** A mutable view of an update spec, used while building it incrementally. */ +type MutableUpdateSpec = { + -readonly [K in keyof UpdateContentCardSpec]: UpdateContentCardSpec[K]; +}; + +/** Converts one draft value into the update-spec field for a core key. */ +function applyCoreValue( + spec: MutableUpdateSpec, + key: CoreFieldSpec['key'], + value: string, +): void { + const trimmed = value.trim(); + switch (key) { + case 'name': + spec.name = value; + break; + case 'effect': + spec.effect = value; + break; + case 'hasChoices': + spec.hasChoices = value === 'true'; + break; + case 'options': + spec.options = value; + break; + case 'acceptNextCardId': + spec.acceptNextCardId = trimmed === '' ? null : trimmed; + break; + case 'rejectNextCardId': + spec.rejectNextCardId = trimmed === '' ? null : trimmed; + break; + case 'storylineId': + spec.storylineId = trimmed === '' ? null : trimmed; + break; + case 'storylineTitle': + spec.storylineTitle = trimmed === '' ? null : trimmed; + break; + case 'id': + // The id is the row selector, not an editable field. + break; + } +} + +/** + * Builds an `update-card` spec from a draft, including only changed fields. + * + * @param descriptors The field descriptors (the loaded baseline). + * @param draft The current draft. + * @returns The update spec, or `null` when nothing changed (or no id). + */ +export function buildUpdateSpec( + descriptors: readonly CardFieldDescriptor[], + draft: Readonly<Record<string, string>>, +): UpdateContentCardSpec | null { + const idDescriptor = descriptors.find((descriptor) => descriptor.key === 'id'); + if (!idDescriptor) return null; + const changed = changedFieldKeys(descriptors, draft); + if (changed.length === 0) return null; + + const spec: MutableUpdateSpec = { id: idDescriptor.value }; + const fields: Record<string, string> = {}; + for (const key of changed) { + const descriptor = descriptors.find((candidate) => candidate.key === key); + if (!descriptor) continue; + const value = draft[key] ?? descriptor.value; + if (descriptor.group === 'adapter') { + fields[descriptor.column] = value; + continue; + } + applyCoreValue(spec, key as CoreFieldSpec['key'], value); + } + if (Object.keys(fields).length > 0) spec.fields = fields; + return spec; +} + +/** + * Builds an `add-card` spec from a new-card draft. + * + * Required values (id and name) are trimmed; nullable story-line fields are + * `null` when blank so the adapter defaults apply. Adapter-declared columns + * are always carried (they start from `newCardDefaults`). + * + * @param descriptors The new-card field descriptors. + * @param draft The current draft. + * @returns The add spec. + */ +export function buildAddSpec( + descriptors: readonly CardFieldDescriptor[], + draft: Readonly<Record<string, string>>, +): AddContentCardSpec { + const value = (key: string): string => { + const descriptor = descriptors.find((candidate) => candidate.key === key); + return draft[key] ?? descriptor?.value ?? ''; + }; + const optional = (key: string): string | null => { + const trimmed = value(key).trim(); + return trimmed === '' ? null : trimmed; + }; + const has = (key: string): boolean => + descriptors.some((descriptor) => descriptor.key === key); + + const fields: Record<string, string> = {}; + for (const descriptor of descriptors) { + if (descriptor.group !== 'adapter') continue; + fields[descriptor.column] = value(descriptor.key); + } + + return { + id: value('id').trim(), + name: value('name').trim(), + ...(has('effect') ? { effect: value('effect') } : {}), + ...(has('hasChoices') ? { hasChoices: value('hasChoices') === 'true' } : {}), + ...(has('options') ? { options: value('options') } : {}), + ...(has('acceptNextCardId') + ? { acceptNextCardId: optional('acceptNextCardId') } + : {}), + ...(has('rejectNextCardId') + ? { rejectNextCardId: optional('rejectNextCardId') } + : {}), + ...(has('storylineId') ? { storylineId: optional('storylineId') } : {}), + ...(has('storylineTitle') ? { storylineTitle: optional('storylineTitle') } : {}), + ...(Object.keys(fields).length > 0 ? { fields } : {}), + }; +} + +/** One validator/author error, attributed to a form field where possible. */ +export interface CardFormError { + /** Descriptor key the error belongs under, or `null` for a form-level error. */ + readonly field: string | null; + /** Stable machine code (the validator issue code, or `author`/`preview`). */ + readonly code: string; + /** Human-readable message. */ + readonly message: string; +} + +/** Input for {@link attributeEditErrors}. */ +export interface EditErrorAttribution { + /** The form's field descriptors. */ + readonly descriptors: readonly CardFieldDescriptor[]; + /** The current draft. */ + readonly draft: Readonly<Record<string, string>>; + /** The edited card's id (`null` when the form authors a new card). */ + readonly cardId: string | null; + /** Plain error strings returned by the author/preview seam. */ + readonly errors: readonly string[]; + /** Structured validator issues for the candidate, when available. */ + readonly issues?: readonly ContentIssue[]; +} + +/** True when the descriptors contain the given key. */ +function hasField(descriptors: readonly CardFieldDescriptor[], key: string): boolean { + return descriptors.some((descriptor) => descriptor.key === key); +} + +/** Finds the first field whose draft value mentions the offending target. */ +function fieldForTarget( + descriptors: readonly CardFieldDescriptor[], + draft: Readonly<Record<string, string>>, + target: string, +): string | null { + const preference = ['options', 'acceptNextCardId', 'rejectNextCardId']; + for (const key of preference) { + const descriptor = descriptors.find((candidate) => candidate.key === key); + if (!descriptor) continue; + const value = draft[key] ?? descriptor.value; + if (value.includes(target)) return key; + } + for (const descriptor of descriptors) { + if (!descriptor.editable) continue; + const value = draft[descriptor.key] ?? descriptor.value; + if (value.includes(target)) return descriptor.key; + } + return null; +} + +/** Attributes one validator issue to a form field (or `null`). */ +function attributeIssue( + issue: ContentIssue, + input: EditErrorAttribution, +): string | null { + const { descriptors, draft, cardId } = input; + if (cardId !== null && issue.cardId !== cardId) return null; + + switch (issue.code) { + case 'duplicate-id': + return hasField(descriptors, 'id') ? 'id' : null; + case 'missing-required-field': { + if ((draft.name ?? '').trim() === '' && hasField(descriptors, 'name')) return 'name'; + if ((draft.id ?? '').trim() === '' && hasField(descriptors, 'id')) return 'id'; + return null; + } + case 'missing-storyline-id': + return hasField(descriptors, 'storylineId') ? 'storylineId' : null; + case 'orphan-link': + return hasField(descriptors, 'hasChoices') ? 'hasChoices' : null; + case 'invalid-option': + return hasField(descriptors, 'options') ? 'options' : null; + case 'unknown-successor': + case 'dangling-link': + return issue.target ? fieldForTarget(descriptors, draft, issue.target) : null; + case 'unreachable-card': + case 'cycle-error': + return null; + } +} + +/** + * Attributes author/validator errors to form fields so the forms can render + * them **inline**, next to the control the author must fix. + * + * Structured validator issues are preferred (they carry the card, code and — + * for link errors — the offending target); plain author error strings are + * mapped by their documented phrasing so a missing required value or a + * duplicate id lands on the right control too. Automated validation errors are + * additionally rendered in the preview panel's full report, so nothing is + * hidden when it cannot be attributed to one field. + * + * @param input The descriptors, draft, edited card id and errors to attribute. + * @returns One entry per distinct error, in input order. + */ +export function attributeEditErrors(input: EditErrorAttribution): CardFormError[] { + const attributed: CardFormError[] = []; + const seen = new Set<string>(); + const push = (error: CardFormError): void => { + const key = `${error.field ?? ''}|${error.message}`; + if (seen.has(key)) return; + seen.add(key); + attributed.push(error); + }; + + for (const issue of input.issues ?? []) { + push({ + field: attributeIssue(issue, input), + code: issue.code, + message: issue.message, + }); + } + + for (const message of input.errors) { + if (input.issues?.some((issue) => issue.message === message)) continue; + let field: string | null = null; + if (message.includes('--id is required')) field = 'id'; + else if (message.includes('--name is required')) field = 'name'; + else if (message.includes('duplicate id') || message.includes('already exists')) field = 'id'; + else if (message.includes('--options')) field = 'options'; + push({ field, code: 'author', message }); + } + + return attributed; +} diff --git a/src/story-line/editor/manual-docs.ts b/src/story-line/editor/manual-docs.ts new file mode 100644 index 000000000..397bb77d1 --- /dev/null +++ b/src/story-line/editor/manual-docs.ts @@ -0,0 +1,93 @@ +/** + * Hand-written documentation sections that mirror content rows. + * + * Generated artefacts (JSON manifest, Mermaid graph) are rewritten + * automatically after an accepted edit — but hand-written documentation is + * **not** machine-generated, so nothing can rewrite it. When an edit touches a + * row that a documented section describes by hand, the editor's bridge must + * tell the author "manual documentation update required" and name the check + * that now fails, instead of claiming the drift guard covers the docs. + * + * This module is that registry. Each entry declares a stable check id, the + * repository-relative path of the hand-written section, a command that + * verifies the section's parity with the content (the "check that now fails"), + * and the base card ids the section documents by hand. + * + * The Gym ships one such section: the Gym storyline demo's card catalogue + * (`example-games/gym/GymStorylineDemo.ts`) hand-maintains the four content + * cards, and the Gym suite pins that fixture — an edit to a documented row + * (a renamed id, a changed effect, a dropped card) silently breaks it unless + * the demo is updated by hand. + * + * Pure data; Phaser-free, game-import-free and Node-free (browser-safe). + * + * @module + */ + +/** One hand-written documentation section mirroring content rows. */ +export interface ManualDocSection { + /** Stable, human-readable id of the section/check. */ + readonly check: string; + /** Repository-relative path of the hand-written section. */ + readonly path: string; + /** Command that fails when the section drifts from the content. */ + readonly verifyCommand: string; + /** Base card ids the section documents by hand. */ + readonly documentedIds: readonly string[]; +} + +/** The Gym's hand-documented row set (see the module comment). */ +export const GYM_MANUAL_DOC_SECTIONS: readonly ManualDocSection[] = [ + { + check: 'gym-storyline-demo-catalogue', + path: 'example-games/gym/GymStorylineDemo.ts', + verifyCommand: 'npx vitest run --project unit tests/gym/GymStorylineDemo.test.ts', + documentedIds: ['gym-story-start', 'gym-story-invest', 'gym-story-ignore', 'gym-story-end'], + }, +]; + +/** The sections used when the caller supplies none. */ +export const DEFAULT_MANUAL_DOC_SECTIONS: readonly ManualDocSection[] = GYM_MANUAL_DOC_SECTIONS; + +/** The sections whose documented rows an edit touched, plus which ids. */ +export interface ManualDocUpdate { + /** The section's stable check id (the check that now fails). */ + readonly check: string; + /** Repository-relative path of the hand-written section. */ + readonly path: string; + /** Command that fails when the section drifts. */ + readonly verifyCommand: string; + /** The edited base ids the section documents by hand. */ + readonly touchedIds: readonly string[]; +} + +/** + * Returns every documented section whose rows intersect the edited ids. + * + * An edit that does not touch a documented row produces no update — the + * generated drift guards remain the only checks that matter. + * + * @param editedIds Base ids the edit affected (added, updated or removed). + * @param sections The documented-section registry (defaults to the shipped + * Gym sections). + * @returns The affected sections with the touched ids, in registry order. + */ +export function findManualDocUpdates( + editedIds: readonly string[], + sections: readonly ManualDocSection[] = DEFAULT_MANUAL_DOC_SECTIONS, +): ManualDocUpdate[] { + const edited = new Set(editedIds); + const updates: ManualDocUpdate[] = []; + for (const section of sections) { + const touched = section.documentedIds.filter((id) => edited.has(id)); + if (touched.length > 0) { + updates.push({ + check: section.check, + path: section.path, + verifyCommand: section.verifyCommand, + touchedIds: touched, + }); + } + } + return updates; +} diff --git a/src/story-line/editor/view-model.ts b/src/story-line/editor/view-model.ts new file mode 100644 index 000000000..764facf0c --- /dev/null +++ b/src/story-line/editor/view-model.ts @@ -0,0 +1,290 @@ +/** + * Manifest → editor view model. + * + * The story-line editor renders the **generated manifest** + * (`src/story-line/content/manifest.ts`), never the raw CSV and never a + * re-derived graph. This module is the single mapping step between the two: a + * pure, deterministic function that turns a {@link StorylineManifest} into a + * render-ready {@link EditorViewModel}. + * + * The mapper is deliberately **faithful**: every node and every edge of the + * manifest appears exactly once, with the manifest's own ids, names, + * story-line ids/titles, choice flags and cycle flags. Nothing is invented and + * nothing is dropped, so a diff of the view model against the manifest is the + * read-only fidelity guarantee (`tests/story-line/editor/view-model.test.ts`). + * + * Presentation strings are **not** re-derived here: the manifest module's own + * `nodeLabel`, `briefEventDescription`, `describeEventImpact` and + * `describeChoiceRouting` helpers produce them, and `filterManifestByStoryline` + * produces each story-line group. If a label helper changes, the editor + * changes with it — there is no second copy of that logic. + * + * Pure and Phaser-free, with no DOM or Node dependency: the same module is + * consumed by the editor page, the SVG renderer and the unit tests. + * + * @module + */ + +import { + briefEventDescription, + describeChoiceRouting, + describeEventImpact, + filterManifestByStoryline, + nodeLabel, + type ManifestEdge, + type ManifestNode, + type StorylineManifest, +} from '../content/manifest'; + +/** Which effect policy an option uses. */ +export type EditorEffectPolicy = 'apply' | 'skip'; + +/** One outgoing link of a card (one manifest edge). */ +export interface EditorSuccessorLink { + /** Player-visible option label. */ + readonly label: string; + /** Target base card id, or null when the chain ends. */ + readonly targetId: string | null; + /** Display name of the target (`chain ends` for a terminal link). */ + readonly targetName: string; + /** Whether the card's own effect applies when this option is chosen. */ + readonly effectPolicy: EditorEffectPolicy; + /** True when this edge is one of several random-branch candidates. */ + readonly random: boolean; + /** 0-based candidate index within the option's candidate set. */ + readonly candidateIndex: number; + /** True when this edge participates in a cycle. */ + readonly cycle: boolean; +} + +/** One incoming link of a card (one manifest edge pointing at it). */ +export interface EditorPredecessorLink { + /** Source base card id. */ + readonly fromId: string; + /** Display name of the source card. */ + readonly fromName: string; + /** Player-visible option label that leads here. */ + readonly label: string; + /** True when this edge participates in a cycle. */ + readonly cycle: boolean; +} + +/** Display strings for a card, all produced by the manifest label helpers. */ +export interface EditorNodeLabels { + /** Multi-line Mermaid node label (`name<br/>id<br/>brief effect<br/>impact`). */ + readonly node: string; + /** First-sentence, length-capped effect text. */ + readonly brief: string; + /** Adapter-provided impact description (`''` when the adapter has none). */ + readonly impact: string; + /** How the card routes through its options (or a no-choice note). */ + readonly routing: string; +} + +/** One render-ready card node. */ +export interface EditorNode { + /** Base card id (identical to the manifest node id). */ + readonly id: string; + /** Display name. */ + readonly name: string; + /** Story-line id, or null when the card belongs to no story-line. */ + readonly storylineId: string | null; + /** Story-line title, or null. */ + readonly storylineTitle: string | null; + /** True when the card presents a choice. */ + readonly hasChoices: boolean; + /** Number of options (0 for plain incidents). */ + readonly optionCount: number; + /** Human-readable effect text. */ + readonly effect: string; + /** Adapter-provided game impact description. */ + readonly impact: string; + /** Every game-specific column for this card, keyed by CSV column name. */ + readonly fields: Readonly<Record<string, string>>; + /** True when the card participates in a detected cycle. */ + readonly inCycle: boolean; + /** Presentation strings from the manifest label helpers. */ + readonly labels: EditorNodeLabels; + /** Outgoing links, in manifest edge order. */ + readonly successors: readonly EditorSuccessorLink[]; + /** Incoming links, in manifest edge order. */ + readonly predecessors: readonly EditorPredecessorLink[]; +} + +/** One render-ready graph edge (a faithful projection of a manifest edge). */ +export interface EditorEdge { + /** Source base card id. */ + readonly from: string; + /** Option label. */ + readonly label: string; + /** Target base card id, or null when the chain ends. */ + readonly to: string | null; + /** True when this edge is one of several random-branch candidates. */ + readonly random: boolean; + /** 0-based candidate index within the option's candidate set. */ + readonly candidateIndex: number; + /** True when this edge participates in a cycle. */ + readonly cycle: boolean; + /** Whether the source card's effect applies when this option is chosen. */ + readonly effectPolicy: EditorEffectPolicy; +} + +/** One story-line group: the cards and internal links of a single story-line. */ +export interface EditorStorylineGroup { + /** Story-line id (`null` for the trailing "ungrouped" bucket). */ + readonly storylineId: string | null; + /** Display title for the group. */ + readonly title: string; + /** Member card ids, in manifest node order. */ + readonly nodeIds: readonly string[]; + /** Member card nodes (the same objects as {@link EditorViewModel.nodes}). */ + readonly nodes: readonly EditorNode[]; + /** Links whose endpoints are both members (terminal links included). */ + readonly edges: readonly EditorEdge[]; +} + +/** The complete render-ready editor view model. */ +export interface EditorViewModel { + /** The adapter's game id (manifest provenance). */ + readonly gameId: string; + /** Manifest provenance string (`''` never occurs — copied from the manifest). */ + readonly source: string; + /** Every manifest node, in manifest order. */ + readonly nodes: readonly EditorNode[]; + /** Every manifest edge, in manifest order. */ + readonly edges: readonly EditorEdge[]; + /** + * Story-line groups in `manifest.storylineIds` order, followed by a single + * `null` "ungrouped" group when any card has no story-line. Empty groups are + * impossible: a story-line id exists only because a card declares it. + */ + readonly storylines: readonly EditorStorylineGroup[]; + /** Detected cycles (paths of base card ids), copied from the manifest. */ + readonly cycles: readonly (readonly string[])[]; +} + +/** Title used for the bucket of cards that declare no story-line. */ +export const UNGROUPED_STORYLINE_TITLE = 'Ungrouped'; + +/** Projects a manifest edge onto the view-model edge shape. */ +function toEditorEdge(edge: ManifestEdge): EditorEdge { + return { + from: edge.from, + label: edge.label, + to: edge.to, + random: edge.random, + candidateIndex: edge.candidateIndex, + cycle: edge.cycle, + effectPolicy: edge.effectPolicy, + }; +} + +/** Builds the successor links of one node from the manifest's edges. */ +function successorsOf( + node: ManifestNode, + edges: readonly EditorEdge[], + nameById: ReadonlyMap<string, string>, +): EditorSuccessorLink[] { + return edges + .filter((edge) => edge.from === node.id) + .map((edge) => ({ + label: edge.label, + targetId: edge.to, + targetName: edge.to === null ? 'chain ends' : (nameById.get(edge.to) ?? edge.to), + effectPolicy: edge.effectPolicy, + random: edge.random, + candidateIndex: edge.candidateIndex, + cycle: edge.cycle, + })); +} + +/** Builds the predecessor links of one node from the manifest's edges. */ +function predecessorsOf( + node: ManifestNode, + edges: readonly EditorEdge[], + nameById: ReadonlyMap<string, string>, +): EditorPredecessorLink[] { + return edges + .filter((edge) => edge.to === node.id) + .map((edge) => ({ + fromId: edge.from, + fromName: nameById.get(edge.from) ?? edge.from, + label: edge.label, + cycle: edge.cycle, + })); +} + +/** Builds the editor view model from a generated manifest. */ +export function buildEditorViewModel(manifest: StorylineManifest): EditorViewModel { + const nameById = new Map(manifest.nodes.map((node) => [node.id, node.name])); + const cycleNodeIds = new Set<string>(); + for (const cycle of manifest.cycles) { + for (const id of cycle) cycleNodeIds.add(id); + } + + const edges = manifest.edges.map(toEditorEdge); + + const nodes = manifest.nodes.map((node): EditorNode => { + const impact = describeEventImpact(node); + return { + id: node.id, + name: node.name, + storylineId: node.storylineId, + storylineTitle: node.storylineTitle, + hasChoices: node.hasChoices, + optionCount: node.optionCount, + effect: node.effect, + impact, + fields: node.fields, + inCycle: cycleNodeIds.has(node.id), + labels: { + node: nodeLabel(node), + brief: briefEventDescription(node.effect), + impact, + routing: describeChoiceRouting(node, manifest.edges, nameById), + }, + successors: successorsOf(node, edges, nameById), + predecessors: predecessorsOf(node, edges, nameById), + }; + }); + + const nodeById = new Map(nodes.map((node) => [node.id, node])); + + const storylines: EditorStorylineGroup[] = manifest.storylineIds.map((storylineId) => { + // Reuse the manifest's own per-story-line view rather than re-filtering: + // grouping and the member-link rule stay defined in one place. + const filtered = filterManifestByStoryline(manifest, storylineId); + const storylineTitle = filtered.nodes.find((node) => node.storylineTitle)?.storylineTitle; + const nodeIds = filtered.nodes.map((node) => node.id); + return { + storylineId, + title: storylineTitle ?? storylineId, + nodeIds, + nodes: nodeIds.map((id) => nodeById.get(id) as EditorNode), + edges: filtered.edges.map(toEditorEdge), + }; + }); + + const ungrouped = nodes.filter((node) => node.storylineId === null); + if (ungrouped.length > 0) { + const ungroupedIds = new Set(ungrouped.map((node) => node.id)); + storylines.push({ + storylineId: null, + title: UNGROUPED_STORYLINE_TITLE, + nodeIds: ungrouped.map((node) => node.id), + nodes: ungrouped, + edges: edges.filter( + (edge) => ungroupedIds.has(edge.from) && (edge.to === null || ungroupedIds.has(edge.to)), + ), + }); + } + + return { + gameId: manifest.gameId, + source: manifest.source, + nodes, + edges, + storylines, + cycles: manifest.cycles, + }; +} diff --git a/src/story-line/index.ts b/src/story-line/index.ts new file mode 100644 index 000000000..69eb887ea --- /dev/null +++ b/src/story-line/index.ts @@ -0,0 +1,208 @@ +/** + * 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, + Condition, + ConditionalSuccessorRule, + OptionCost, + OptionExtraEffect, + 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 { + evaluateCondition, + pushChainCard, + resolveStorylineOption, +} from './resolve'; +export { validateStorylineOptions } from './validate'; +export type { + StorylineValidationCode, + StorylineValidationIssue, + StorylineValidationResult, + ValidateStorylineOptionsOptions, +} from './validate'; +export { + buildJournal, + journalIsEmpty, + journalTitle, +} from './journal'; +export { + compileStorylineFromEvent, + createStorylineOptionRegistry, + eventHasStoryline, + isOptionAffordable, +} 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<TState, TCard>; + /** + * Optional presentation adapter. Defaults to core's default UI adapter + * (dialog titles, choice-clarity labels, story-update lines, journal). + */ + ui?: StorylineUiAdapter<TState, TCard>; +} + +/** + * 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<TState, TCard>, +): StorylineSystem<TState, TCard> { + const engine = config.engine; + const ui = + config.ui ?? createDefaultStorylineUiAdapter<TState, TCard>(); + const registry = createStorylineOptionRegistry<TState>((id) => + engine.getBaseTypeId(id), + ); + + const compile = ( + card: TCard, + state?: TState, + ): CompiledStoryline<TState> => + compileStorylineFromEvent(card, registry, state, (s) => + engine.getResources(s), + ); + + const getStorylineOptions = ( + card: TCard, + state?: TState, + ): StorylineOption<TState>[] => 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<TState>(encoding), + registerStorylineOptions: (eventId, options) => + registry.register(eventId, options), + resetStorylineRegistry: () => registry.reset(), + hasRegisteredStorylineOptions: (eventId) => registry.has(eventId), + + // ── Resolution ──────────────────────────────────────────── + resolveStorylineOption: (state, card, option): StorylineResolution<TCard> => + 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<TState, TCard> = { + 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<TState, TCard extends import('./types').StorylineCard>( + state: TState, + adapter: StorylineEngineAdapter<TState, TCard>, +): 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<TState, TCard extends import('./types').StorylineCard>( + state: TState, + adapter: StorylineEngineAdapter<TState, TCard>, +): boolean { + return buildJournal(state, adapter).length === 0; +} + +/** Journal title: names the active story-line(s) when available. */ +export function journalTitle<TState, TCard extends import('./types').StorylineCard>( + state: TState, + adapter: StorylineEngineAdapter<TState, TCard>, + ui: StorylineUiAdapter<TState, TCard>, +): 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..3c5544de4 --- /dev/null +++ b/src/story-line/resolve.ts @@ -0,0 +1,255 @@ +/** + * 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: + * + * 1. `successorResolver` callback (when present) — invoked for the successor + * id; a `null`/`undefined` return ends the chain. Consumes no 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. + * 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 `, `). + */ +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<TState, TCard extends StorylineCard>( + state: TState, + adapter: StorylineEngineAdapter<TState, TCard>, + 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. + * + * 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<TState, TCard extends StorylineCard>( + state: TState, + adapter: StorylineEngineAdapter<TState, TCard>, + 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, paying its + * cost, applying its extra effect and pushing the successor card. + * + * 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/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 across all three steps). + */ +export function resolveStorylineOption<TState, TCard extends StorylineCard>( + state: TState, + adapter: StorylineEngineAdapter<TState, TCard>, + card: TCard, + option: StorylineOption<TState>, +): StorylineResolution<TCard> { + const before = adapter.getResources(state); + + // 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; + + // 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.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 = resolveRawSuccessor(state, adapter, option); + } + + // Push successor card if one exists. + const pushedCard = pushChainCard(state, adapter, resolvedSuccessor); + + return { pushedCard, coinChange, repChange }; +} + +/** + * Legacy successor resolution: the candidate-set random draw when + * `successorIds` is declared, otherwise the single `successorId`. + */ +function resolveRawSuccessor<TState, TCard extends StorylineCard>( + state: TState, + adapter: StorylineEngineAdapter<TState, TCard>, + option: StorylineOption<TState>, +): 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/src/story-line/types.ts b/src/story-line/types.ts new file mode 100644 index 000000000..3bfa28cba --- /dev/null +++ b/src/story-line/types.ts @@ -0,0 +1,410 @@ +/** + * 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; +} + +/** + * 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. + * + * 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. */ + 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. + * + * 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<TState = unknown> { + /** 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. + * + * 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. */ + 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 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. */ +export interface CompiledStoryline<TState = unknown> { + /** The story-line identifier this definition belongs to, or null. */ + readonly storylineId: string | null; + /** Ordered options; empty for non-choice cards. */ + readonly compiledOptions: StorylineOption<TState>[]; +} + +/** 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). + * + * 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<TState>[]; +} + +/** Result of resolving a story-line option. */ +export interface StorylineResolution<TCard extends StorylineCard = StorylineCard> { + /** 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<TState, TCard extends StorylineCard> { + /** 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; + /** + * 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. */ + 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<TState>[], + ): 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<TState, TCard extends StorylineCard> { + readonly engine: StorylineEngineAdapter<TState, TCard>; + readonly ui: StorylineUiAdapter<TState, TCard>; + + // ── Compilation ───────────────────────────────────────────── + /** Compile a card's story-line into the generalised option model. */ + compileStorylineFromEvent(card: TCard, state?: TState): CompiledStoryline<TState>; + /** 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<TState>[]; + /** 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<TState>[]; + /** Register an explicit ordered option list for an event id. */ + registerStorylineOptions(eventId: string, options: readonly StorylineOption<TState>[]): 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<TState>, + ): StorylineResolution<TCard>; + /** 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<TState, TCard> | null; + /** Options presented for a pending choice (snapshot preferred). */ + getPendingStorylineOptions( + pending: PendingStorylineChoice<TState, TCard>, + state?: TState, + ): StorylineOption<TState>[]; + /** Mark a pending choice resolved with the chosen option label. */ + markChoiceResolved(pending: PendingStorylineChoice<TState, TCard>, 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<TState>( + card: StorylineCard, + options: readonly StorylineOption<TState>[], +): 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<TState, TCard> { + 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<TState>(card, options), + journalTitle: (entries) => defaultJournalTitle(entries), + storylineSummary: (card) => defaultStorylineSummary(card), + }; +} + +/** The default UI adapter, typed for the generic (unparameterised) case. */ +export const defaultStorylineUiAdapter: StorylineUiAdapter<unknown, StorylineCard> = + createDefaultStorylineUiAdapter<unknown, StorylineCard>(); diff --git a/src/story-line/validate.ts b/src/story-line/validate.ts new file mode 100644 index 000000000..8c31d77f5 --- /dev/null +++ b/src/story-line/validate.ts @@ -0,0 +1,253 @@ +/** + * Static validation for declarative story-line option metadata. + * + * 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. + * + * Content-level tooling (CSV parsing, the manifest/graph exporter, the JSON + * schema and the static content validator) lives in + * [`./content/`](./content/index.ts); this module stays focused on the + * declarative option metadata the runtime consumes. + * + * 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<string>; +} + +const CONDITION_FIELDS: ReadonlySet<string> = new Set(['coins', 'reputation']); +const CONDITION_OPS: ReadonlySet<string> = new Set(['>=', '<=', '>', '<', '==']); +const COST_KEYS = ['coins', 'reputation'] as const; + +function isRecord(value: unknown): value is Record<string, unknown> { + 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<TState = unknown>( + options: readonly StorylineOption<TState>[], + config: ValidateStorylineOptionsOptions = {}, +): StorylineValidationResult { + const issues: StorylineValidationIssue[] = []; + const knownTemplateIds = + config.knownTemplateIds === undefined + ? undefined + : new Set<string>(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/src/ui/constants.ts b/src/ui/constants.ts index cca2c818a..c36ad2d14 100644 --- a/src/ui/constants.ts +++ b/src/ui/constants.ts @@ -20,3 +20,53 @@ export const GAME_H = 720; /** Default font family used for in-game text. */ export const FONT_FAMILY = 'Arial, sans-serif'; + +// --------------------------------------------------------------------------- +// Illegal-move reason toast (see `shakeIllegalMove` / `illegalMoveToastView`) +// --------------------------------------------------------------------------- + +/** + * Default dwell time (milliseconds) for the illegal-move reason toast. + * + * Long enough to read a full rules-layer sentence ("You cannot play that + * card while the market is blocked"), short enough not to linger over the + * board. + */ +export const ILLEGAL_MOVE_TOAST_DWELL_MS = 2500; + +/** + * Vertical position (pixels from the top) of the illegal-move reason toast. + * + * Sits below the scene header and above the hand/button rows so the + * explanation is never clipped by the board chrome. + */ +export const ILLEGAL_MOVE_TOAST_Y = 520; + +/** + * Display depth of the illegal-move reason toast. + * + * Above gameplay (0–999), the HUD container (1000) and dialog overlays + * (200), but below game-state overlays (2000: win / loss / game-over) so a + * finished game is never obscured by a stale explanation. + */ +export const ILLEGAL_MOVE_TOAST_DEPTH = 1900; + +/** Text colour of the illegal-move reason toast. */ +export const ILLEGAL_MOVE_TOAST_COLOR = '#ffdd88'; + +/** Font size of the illegal-move reason toast. */ +export const ILLEGAL_MOVE_TOAST_FONT_SIZE = '16px'; + +/** + * Maximum wrap width (pixels) of the illegal-move reason toast. + * + * Long rules reasons wrap onto multiple lines instead of running off-screen; + * the text is never truncated (AC "copy length"). + */ +export const ILLEGAL_MOVE_TOAST_MAX_WIDTH = 720; + +/** Stroke colour behind the toast text (readability over the board). */ +export const ILLEGAL_MOVE_TOAST_STROKE_COLOR = '#000000'; + +/** Stroke thickness (pixels) behind the toast text. */ +export const ILLEGAL_MOVE_TOAST_STROKE_THICKNESS = 4; diff --git a/src/ui/debug/GlobalEventBuffer.ts b/src/ui/debug/GlobalEventBuffer.ts index e85ec341e..f6bd9e0b3 100644 --- a/src/ui/debug/GlobalEventBuffer.ts +++ b/src/ui/debug/GlobalEventBuffer.ts @@ -8,7 +8,7 @@ * @module @ui/debug/GlobalEventBuffer */ -import type { GameEventEmitter, GameEventName } from '../../core-engine/GameEventEmitter'; +import type { GameEventEmitter } from '../../core-engine/GameEventEmitter'; export interface BufferedEvent { timestamp: string; @@ -24,8 +24,8 @@ export class GlobalEventBuffer { private static instance: GlobalEventBuffer | null = null; private _entries: BufferedEvent[] = []; - private _subscribed = false; - private _listeners: Array<{ event: string; fn: (payload: unknown) => void }> = []; + private _unsubscribe: (() => void) | null = null; + private _subscribedEmitter: GameEventEmitter | null = null; private constructor() { // singleton @@ -38,32 +38,29 @@ export class GlobalEventBuffer { return GlobalEventBuffer.instance; } - /** Subscribe to the given emitter (idempotent). */ + /** + * Subscribe to the given emitter (idempotent). + * + * Uses the emitter's catch-all subscription, so every event — engine or + * game-specific (declared by the game via `GameEventMap` interface + * augmentation) — is buffered from session start without a per-game + * name list here. + */ subscribe(emitter: GameEventEmitter): void { - if (this._subscribed) return; - this._subscribed = true; + if (this._subscribedEmitter === emitter) return; - const eventNames: GameEventName[] = [ - 'turn-started', 'turn-completed', 'animation-complete', - 'state-settled', 'game-ended', 'card-drawn', 'card-flipped', - 'card-swapped', 'card-discarded', 'card:discarded', 'card:dealt', - 'card:placed', 'ui-interaction', 'income-gained', - 'card-to-foundation', 'card-to-tableau', 'card-pickup', - 'card-snap-back', 'auto-complete-start', 'auto-complete-card', - 'undo', 'redo', 'card-selected', 'card-deselected', 'deal-card', - ]; + // Re-binding to a different emitter (e.g. after a scene restart) replaces + // the previous subscription instead of silently missing the new session. + this._unsubscribe?.(); + this._subscribedEmitter = emitter; - for (const name of eventNames) { - const fn = (payload: unknown) => { - this._entries.push({ - timestamp: new Date().toISOString().slice(11, 23), - eventName: name as string, - payload, - }); - }; - emitter.on(name, fn); - this._listeners.push({ event: name as string, fn }); - } + this._unsubscribe = emitter.onAny((eventName, payload) => { + this._entries.push({ + timestamp: new Date().toISOString().slice(11, 23), + eventName, + payload, + }); + }); } /** Get all buffered events. */ @@ -78,6 +75,6 @@ export class GlobalEventBuffer { /** Whether the buffer has been subscribed. */ get subscribed(): boolean { - return this._subscribed; + return this._subscribedEmitter !== null; } } diff --git a/src/ui/dragDrop.ts b/src/ui/dragDrop.ts index 0bacf93eb..c6a223d02 100644 --- a/src/ui/dragDrop.ts +++ b/src/ui/dragDrop.ts @@ -16,8 +16,10 @@ * raise while dragging; * - valid-drop highlight callbacks (fired on dragstart, cleared on dragend); * - snap-back animation with reduced-motion fallback; - * - illegal-feedback hooks (shake + `sfx-illegal-move`), defaulting to a - * container-safe shake that works for both Images and Containers. + * - illegal-feedback hooks (shake + `sfx-illegal-move` + optional reason + * toast), defaulting to a container-safe `shakeIllegalMove` that works for + * both Images and Containers and explains a rejection when an + * `illegalReason` resolver (or an inline `payload.reason`) is present. * * Main Street consumes this module for drag-to-buy/place * (CG-0MSKSAREE007AYSZ) and Beleaguered Castle is refactored onto it @@ -29,6 +31,7 @@ import { safePlaySound, COMMON_SFX_KEYS } from '../core-engine/SoundManager'; import { applyDefaults } from '../core-engine/config-defaults'; import { shakeIllegalMove } from './shakeIllegalMove'; +import type { IllegalMoveTarget } from './shakeIllegalMove'; import { emitEventOrCallback } from '../core-engine/event-emission'; /** @@ -61,6 +64,17 @@ export interface DragDropPayload<TData = unknown> { zone?: Phaser.GameObjects.Zone; /** Caller-attached zone data (e.g. slot index) for the drop target. */ zoneData?: unknown; + /** + * Player-facing explanation for the rejection (e.g. `LegalityResult.reason`). + * + * Populated by the per-registration `illegalReason` resolvers when a drag + * is vetoed or a drop is refused; validation callbacks (`canPickUp` / + * `canAccept`) may also set it directly on the payload they receive. When + * present, the default illegal feedback renders it as a toast (in addition + * to the shake + `sfx-illegal-move`); custom `onIllegal` hooks receive it + * too. + */ + reason?: string; } /** Payload for the 'card:drag-started' event. */ @@ -112,6 +126,14 @@ export interface DragDropObjectConfig<TData = unknown> { * `canAccept` validation passes. */ onDrop?: (payload: DragDropPayload<TData>) => void; + /** + * Resolves the player-facing rejection reason when this object's drag is + * vetoed (`canPickUp` returns `false`) or an invalid drop snap-backs. The + * resolved string lands on `payload.reason`, where the default illegal + * feedback renders it as a toast. Mutating `payload.reason` inside + * `canPickUp` works as an alternative. + */ + illegalReason?: (payload: DragDropPayload<TData>) => string | undefined; } /** Config for a single drop zone. */ @@ -125,6 +147,13 @@ export interface DragDropZoneConfig<TZoneData = unknown> { * (snap-back + illegal feedback). */ canAccept?: (payload: DragDropPayload) => boolean; + /** + * Resolves the player-facing rejection reason when this zone refuses the + * drop (`canAccept` returns `false`). Takes precedence over the draggable's + * own `illegalReason`. Mutating `payload.reason` inside `canAccept` works + * as an alternative. + */ + illegalReason?: (payload: DragDropPayload) => string | undefined; } /** Options for {@link createDragDropManager}. */ @@ -159,9 +188,10 @@ export interface DragDropManagerConfig { onDragEnd?: (payload: DragDropPayload) => void; /** * Illegal-feedback hook (shake + `sfx-illegal-move`). Fired on pickup - * veto, snap-back (invalid drop), and drop-zone rejection. Defaults to a - * container-safe shake via {@link shakeIllegalMove} (or a position shake - * for objects without `setTint`). + * veto, snap-back (invalid drop), and drop-zone rejection. When + * `payload.reason` is set (via the `illegalReason` resolvers or inline), the + * default hook also renders it as a toast. Defaults to a container-safe + * {@link shakeIllegalMove} that works for both Images and Containers. */ onIllegal?: (payload: DragDropPayload) => void; } @@ -205,25 +235,7 @@ interface DropZoneEntry { zone: Phaser.GameObjects.Zone; data: unknown; canAccept?: (payload: DragDropPayload) => boolean; -} - -/** Container-safe illegal-move shake for objects without `setTint`. */ -function shakeContainer( - scene: Phaser.Scene, - target: DraggableGameObject, -): void { - const originalX = target.x; - scene.tweens.add({ - targets: target, - x: originalX - 5, - duration: 50, - yoyo: true, - repeat: 2, - ease: 'Sine.inOut', - onComplete: () => { - target.x = originalX; - }, - }); + illegalReason?: (payload: DragDropPayload) => string | undefined; } /** @@ -278,6 +290,24 @@ function rectangleContains( ); } +/** + * Assign `payload.reason` from a resolver when it is not already set. + * + * Inline mutations inside `canPickUp`/`canAccept` win; the resolvers only + * fill the gap. Empty strings never clobber an existing reason. + */ +function resolveReason( + payload: DragDropPayload, + resolver: ((payload: DragDropPayload) => string | undefined) | undefined, +): void { + if (payload.reason === undefined && resolver) { + const reason = resolver(payload); + if (typeof reason === 'string' && reason.trim() !== '') { + payload.reason = reason; + } + } +} + /** * Create a drag-drop manager on the given scene. * @@ -314,14 +344,13 @@ export function createDragDropManager(config: DragDropManagerConfig): DragDropMa } const defaultIllegal = (payload: DragDropPayload): void => { - const target = payload.gameObject as Phaser.GameObjects.Image; try { - if (typeof target.setTint === 'function') { - shakeIllegalMove({ scene, target }); - } else { - safePlaySound(scene as never, COMMON_SFX_KEYS.ILLEGAL_MOVE); - shakeContainer(scene, payload.gameObject); - } + shakeIllegalMove({ + scene, + target: payload.gameObject as IllegalMoveTarget, + reason: payload.reason, + reducedMotion, + }); } catch { // Illegal feedback must never crash the drag lifecycle (e.g. in // headless / test environments lacking a rectangle factory). @@ -358,6 +387,7 @@ export function createDragDropManager(config: DragDropManagerConfig): DragDropMa gameObject: DraggableGameObject, payload: DragDropPayload, ): void => { + resolveReason(payload, entry.config.illegalReason); if (reducedMotion) { restoreOrigin(entry, gameObject); onIllegal(payload); @@ -399,6 +429,7 @@ export function createDragDropManager(config: DragDropManagerConfig): DragDropMa if (entry.config.canPickUp && !entry.config.canPickUp(payload)) { entry.vetoed = true; gameObject.setDepth(entry.originDepth); + resolveReason(payload, entry.config.illegalReason); onIllegal(payload); return; } @@ -465,8 +496,10 @@ export function createDragDropManager(config: DragDropManagerConfig): DragDropMa } // Invalid drop (unregistered zone, rejected by canAccept, or no drop - // handler): snap-back + illegal feedback (illegal-move case). + // handler): snap-back + illegal feedback (illegal-move case). The zone's + // rejection reason (if any) wins over the draggable's own resolver. entry.dragging = false; + resolveReason(payload, zoneEntry?.illegalReason ?? entry.config.illegalReason); // Emit card:drag-ended for invalid drop if (config.gameEvents && entry.config.data) { const data = entry.config.data as Record<string, unknown>; @@ -567,6 +600,7 @@ export function createDragDropManager(config: DragDropManagerConfig): DragDropMa zone: zoneConfig.zone, data: zoneConfig.data, canAccept: zoneConfig.canAccept, + illegalReason: zoneConfig.illegalReason, }); }, diff --git a/src/ui/illegalMoveToast.ts b/src/ui/illegalMoveToast.ts new file mode 100644 index 000000000..5fadd0d97 --- /dev/null +++ b/src/ui/illegalMoveToast.ts @@ -0,0 +1,120 @@ +/** + * illegalMoveToast -- framework-free text/timing model for the illegal-move + * reason toast. + * + * The model owns the only state that matters for the reason toast: the text + * currently displayed and how long it has been visible. It has no Phaser (or + * DOM) dependency, so the text/timing contract — show, replace-on-show, + * dismiss, auto-expiry — is unit-testable without a scene. + * + * The Phaser rendering half lives in + * {@link module:ui/illegalMoveToastView} and is what `shakeIllegalMove` + * composes, so every game that reuses the illegal-move feedback routine + * explains a rejected move with the same transient toast + * (CG-0MVPXFHBZ004LGEB). + * + * @module ui/illegalMoveToast + */ + +import { ILLEGAL_MOVE_TOAST_DWELL_MS } from './constants'; + +/** Options for {@link IllegalMoveToastModel}. */ +export interface IllegalMoveToastModelOptions { + /** + * How long (milliseconds) the toast stays visible after `show()`. + * + * Non-finite or non-positive values fall back to + * {@link ILLEGAL_MOVE_TOAST_DWELL_MS}. + */ + dwellMs?: number; +} + +/** + * Framework-free text/timing model for a single transient toast. + * + * Semantics: + * - `show(text)` makes the toast visible and **restarts** the dwell timer. + * Calling it again inside the dwell window replaces the text instead of + * stacking a second toast (AC4). + * - `tick(deltaMs)` advances the dwell clock; once the dwell has elapsed the + * model hides itself and `tick` reports `true` exactly once. + * - `dismiss()` hides the toast early (AC2). + * - `visible`, `text` and `remaining` describe the current state. + * + * The model never truncates, re-words or otherwise transforms the text: the + * rules layer owns the player-facing copy (AC8). + */ +export class IllegalMoveToastModel { + /** Effective dwell time in milliseconds. */ + readonly dwellMs: number; + + private currentText: string | null = null; + private elapsedMs = 0; + + constructor(options: IllegalMoveToastModelOptions = {}) { + const requested = options.dwellMs; + this.dwellMs = + typeof requested === 'number' && Number.isFinite(requested) && requested > 0 + ? requested + : ILLEGAL_MOVE_TOAST_DWELL_MS; + } + + /** The text currently shown, or `null` when the toast is hidden. */ + get text(): string | null { + return this.currentText; + } + + /** Whether the toast is currently visible. */ + get visible(): boolean { + return this.currentText !== null; + } + + /** Milliseconds left before auto-dismissal (`0` when hidden). */ + get remaining(): number { + if (this.currentText === null) return 0; + return Math.max(0, this.dwellMs - this.elapsedMs); + } + + /** + * Show `text` and restart the dwell timer. + * + * Empty or whitespace-only text is treated as "nothing to explain": any + * visible toast is dismissed and no new one is created. + * + * @returns `true` when the toast is now showing `text`. + */ + show(text: string): boolean { + if (typeof text !== 'string' || text.trim() === '') { + this.dismiss(); + return false; + } + this.currentText = text; + this.elapsedMs = 0; + return true; + } + + /** Hide the toast immediately (early dismissal). */ + dismiss(): void { + this.currentText = null; + this.elapsedMs = 0; + } + + /** + * Advance the dwell clock. + * + * @param deltaMs Elapsed milliseconds since the previous tick. Non-positive + * or non-finite values are ignored (the clock only moves forward). + * @returns `true` when this tick caused the toast to expire. + */ + tick(deltaMs: number): boolean { + if (this.currentText === null) return false; + if (typeof deltaMs === 'number' && Number.isFinite(deltaMs) && deltaMs > 0) { + this.elapsedMs += deltaMs; + } + if (this.elapsedMs >= this.dwellMs) { + this.dismiss(); + return true; + } + return false; + } +} diff --git a/src/ui/illegalMoveToastView.ts b/src/ui/illegalMoveToastView.ts new file mode 100644 index 000000000..9ef7925e4 --- /dev/null +++ b/src/ui/illegalMoveToastView.ts @@ -0,0 +1,265 @@ +/** + * illegalMoveToastView -- Phaser rendering half of the illegal-move reason + * toast. + * + * `showIllegalMoveToast` renders the rules-layer rejection reason (from + * {@link module:ui/shakeIllegalMove}'s `reason` option, itself supplied by + * the caller) as a single **transient** toast: non-interactive text that sits + * above the board, auto-dismisses after the dwell, can be dismissed early, + * and is destroyed on scene shutdown so it can never leak across scenes. + * + * Guarantees (CG-0MVPXFHBZ004LGEB): + * - At most **one** live toast per scene (AC "performance"): a second + * `showIllegalMoveToast` inside the dwell window replaces the text and + * restarts the timer instead of stacking (AC4). + * - The toast is never interactive and renders with a plain text object, so + * it never swallows pointer input intended for cards, slots or buttons + * (AC2). Text objects are not interactive by default; no `input` code is + * touched. + * - No slide/fade/scale animation in **any** mode — not even reduced motion. + * The toast appears instantly, holds for its full dwell, and disappears + * instantly, so the explanation channel never depends on animation + * (AC3). Dismissal schedules a single `delayedCall`; no per-frame tweens + * are ever left running (AC "performance"). + * - Rendering is best-effort: in headless/test environments that lack + * `scene.add.text` or `scene.time` the model clock still ticks correctly + * and the function degrades to `undefined` instead of throwing. + * + * The text/timing model is framework-free and lives in + * {@link module:ui/illegalMoveToast}. + * + * ## Node-safe import graph (CG-0MVQQ2M8E003FQMZ) + * + * This module is imported **statically** by + * {@link module:ui/shakeIllegalMove} (and via it by + * {@link module:ui/dragDrop}), which downstream game unit suites import in a + * plain Node environment. It therefore must never evaluate Phaser at import + * time: `phaser` is referenced **only** through `import type` (elided at + * compile time) and the module must not import a Phaser-bearing module such + * as `./Renderer` (`createHudText`) — importing that is what broke every + * downstream node unit suite with `ReferenceError: window is not defined`. + * The toast text object is therefore built directly with `scene.add.text` + * using the same style `createHudText` produced. `tests/ui/phaser-free- + * imports.test.ts` guards this graph; the broader documented Phaser-free + * surface is tracked in CG-0MVS9HBN3009HIQN. + * + * @module ui/illegalMoveToastView + */ + +import type Phaser from 'phaser'; +import { IllegalMoveToastModel } from './illegalMoveToast'; +import { + FONT_FAMILY, + GAME_W, + ILLEGAL_MOVE_TOAST_COLOR, + ILLEGAL_MOVE_TOAST_DEPTH, + ILLEGAL_MOVE_TOAST_FONT_SIZE, + ILLEGAL_MOVE_TOAST_MAX_WIDTH, + ILLEGAL_MOVE_TOAST_STROKE_COLOR, + ILLEGAL_MOVE_TOAST_STROKE_THICKNESS, + ILLEGAL_MOVE_TOAST_Y, +} from './constants'; + +/** Options for {@link showIllegalMoveToast}. */ +export interface ShowIllegalMoveToastOptions { + /** The Phaser scene that owns the toast text object. */ + scene: Phaser.Scene; + /** + * The player-facing explanation to display. Shown verbatim — the rules + * layer owns the copy (AC8). Empty/whitespace-only text is a no-op. + */ + text: string; + /** + * Dwell time in milliseconds before auto-dismissal. + * @default 2500 ({@link ILLEGAL_MOVE_TOAST_DWELL_MS}) + */ + dwellMs?: number; +} + +/** + * Handle to a live illegal-move toast, returned by + * {@link showIllegalMoveToast}. Lets the caller inspect and dismiss the + * toast early (AC2). + */ +export interface IllegalMoveToastHandle { + /** The text currently shown (the model's view of it). */ + readonly text: string | null; + /** The underlying text/timing model. */ + readonly model: IllegalMoveToastModel; + /** The Phaser text object backing the toast. */ + readonly gameObject: Phaser.GameObjects.Text; + /** Hide the toast immediately and destroy its text object. */ + dismiss(): void; +} + +/** Per-scene live toast state. */ +interface ToastEntry { + model: IllegalMoveToastModel; + gameObject: Phaser.GameObjects.Text; + timer?: Phaser.Time.TimerEvent; +} + +/** Scene events after which any live toast must be destroyed (AC4). */ +const SHUTDOWN_EVENTS = ['shutdown', 'destroy'] as const; + +/** At most one live toast per scene, keyed weakly so scenes can be GC'd. */ +const entries = new WeakMap<Phaser.Scene, ToastEntry>(); + +/** Scenes whose shutdown listeners are already registered (dedupe). */ +const cleanupBound = new WeakSet<Phaser.Scene>(); + +/** Drain a scene's live toast (timer + text object), no-throw. */ +function drainEntry(entry: ToastEntry): void { + try { + entry.timer?.remove?.(false); + } catch { + // ignore: timer may already be gone + } + entry.timer = undefined; + try { + entry.gameObject.destroy(); + } catch { + // ignore: headless test doubles may lack destroy + } + entry.model.dismiss(); +} + +/** Destroy the toast for `scene` if it is still the live one. */ +function dismissEntry(scene: Phaser.Scene, entry: ToastEntry): void { + if (entries.get(scene) !== entry) return; + entries.delete(scene); + drainEntry(entry); +} + +/** Schedule auto-dismissal for `entry`, replacing any pending timer. */ +function schedule(scene: Phaser.Scene, entry: ToastEntry): void { + try { + entry.timer?.remove?.(false); + } catch { + // ignore + } + entry.timer = undefined; + const time = (scene as SceneWithTime).time; + if (!time || typeof time.delayedCall !== 'function') return; + entry.timer = time.delayedCall(entry.model.dwellMs, () => dismissEntry(scene, entry)); +} + +/** Minimal time-plugin surface used by the toast (headless-tolerant). */ +interface SceneWithTime { + time?: { delayedCall?: (delay: number, callback: () => void) => Phaser.Time.TimerEvent }; +} + +/** Register once-per-scene shutdown cleanup so toasts cannot leak (AC4). */ +function bindSceneCleanup(scene: Phaser.Scene): void { + if (cleanupBound.has(scene)) return; + const events = (scene as Phaser.Scene).events as + | { once?: (event: string, listener: () => void) => void } + | undefined; + if (!events || typeof events.once !== 'function') return; + cleanupBound.add(scene); + for (const event of SHUTDOWN_EVENTS) { + events.once(event, () => { + dismissIllegalMoveToast(scene); + cleanupBound.delete(scene); + }); + } +} + +/** + * Show (or replace) the illegal-move reason toast on `scene`. + * + * Non-blocking by construction: the rendered text object is never made + * interactive, so pointer input targeted at cards, slots or buttons passes + * straight through (AC2). A second call inside the dwell window replaces the + * current text and restarts the timer — duplicates never stack (AC4). + * + * @returns A handle to the live toast, or `undefined` when nothing was shown + * (empty text, or the scene lacks the primitives to render). + */ +export function showIllegalMoveToast( + options: ShowIllegalMoveToastOptions, +): IllegalMoveToastHandle | undefined { + const { scene, text, dwellMs } = options; + const model = new IllegalMoveToastModel({ dwellMs }); + if (!model.show(text)) { + // Nothing to explain: clear any stale toast (replace-with-empty). + dismissIllegalMoveToast(scene); + return undefined; + } + + try { + const existing = entries.get(scene); + if (existing) { + // Replace-on-show: same single toast object, new text + timer (AC4). + existing.model = model; + existing.gameObject.setText(text); + schedule(scene, existing); + return makeHandle(scene, existing); + } + + // Built inline (rather than via `@ui/Renderer`'s `createHudText`) so this + // module stays free of Phaser value imports — see the module doc. The + // style reproduces `createHudText` exactly: FONT_FAMILY + bold + colour + + // font size (+ align when supplied), origin (0.5, 0.5). + const style: Phaser.Types.GameObjects.Text.TextStyle = { + fontFamily: FONT_FAMILY, + fontStyle: 'bold', + color: ILLEGAL_MOVE_TOAST_COLOR, + fontSize: ILLEGAL_MOVE_TOAST_FONT_SIZE, + align: 'center', + }; + const gameObject = scene.add + .text(GAME_W / 2, ILLEGAL_MOVE_TOAST_Y, text, style) + .setOrigin(0.5, 0.5); + gameObject + .setDepth(ILLEGAL_MOVE_TOAST_DEPTH) + .setWordWrapWidth(ILLEGAL_MOVE_TOAST_MAX_WIDTH) + .setStroke(ILLEGAL_MOVE_TOAST_STROKE_COLOR, ILLEGAL_MOVE_TOAST_STROKE_THICKNESS); + + const entry: ToastEntry = { model, gameObject }; + entries.set(scene, entry); + bindSceneCleanup(scene); + schedule(scene, entry); + return makeHandle(scene, entry); + } catch { + // Best-effort: a headless/test scene without add.text must not break the + // caller's illegal-feedback flow. Drain any partial state and degrade. + const stale = entries.get(scene); + if (stale) { + entries.delete(scene); + drainEntry(stale); + } + return undefined; + } +} + +/** Build a stable handle for the live toast. */ +function makeHandle(scene: Phaser.Scene, entry: ToastEntry): IllegalMoveToastHandle { + return { + get text() { + return entry.model.text; + }, + get model() { + return entry.model; + }, + get gameObject() { + return entry.gameObject; + }, + dismiss() { + dismissEntry(scene, entry); + }, + }; +} + +/** + * Dismiss the live illegal-move toast on `scene` immediately (early + * dismissal — AC2). The toast's text object is destroyed and the handling for + * that scene is cleared; a later show starts fresh. + */ +export function dismissIllegalMoveToast(scene: Phaser.Scene): void { + const entry = entries.get(scene); + if (entry) { + entries.delete(scene); + drainEntry(entry); + } +} diff --git a/src/ui/index.ts b/src/ui/index.ts index ba8b4e02f..93121c6b4 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -29,9 +29,21 @@ export type { PhaseManagerConfig } from './PhaseManager'; export { flipCard } from './flipCard'; export type { FlipCardOptions } from './flipCard'; -// Illegal move shake animation +// Illegal move shake animation + reason toast +// (`shakeIllegalMove` renders `reason` as a transient toast independently of +// the shake branch — see docs/ui-animations.md) export { shakeIllegalMove } from './shakeIllegalMove'; -export type { ShakeIllegalMoveOptions } from './shakeIllegalMove'; +export type { ShakeIllegalMoveOptions, IllegalMoveTarget } from './shakeIllegalMove'; +export { IllegalMoveToastModel } from './illegalMoveToast'; +export type { IllegalMoveToastModelOptions } from './illegalMoveToast'; +export { + showIllegalMoveToast, + dismissIllegalMoveToast, +} from './illegalMoveToastView'; +export type { + ShowIllegalMoveToastOptions, + IllegalMoveToastHandle, +} from './illegalMoveToastView'; // Canvas-compatible persistent card highlight (selection/target marker) export { createCardHighlight } from './cardHighlight'; diff --git a/src/ui/shakeIllegalMove.ts b/src/ui/shakeIllegalMove.ts index 377268119..9def92ff4 100644 --- a/src/ui/shakeIllegalMove.ts +++ b/src/ui/shakeIllegalMove.ts @@ -1,5 +1,5 @@ /** - * shakeIllegalMove -- reusable illegal-move shake animation helper. + * shakeIllegalMove -- reusable illegal-move feedback routine. * * Applies a red tint, shakes the target horizontally, then clears the * tint and resets the x position. Encapsulates the common @@ -10,10 +10,42 @@ * By default this plays the `sfx-illegal-move` key if the asset has * been loaded. Callers can suppress the sound by passing `soundKey: ''`. * + * ## Explaining the rejection (reason toast) + * + * Pass {@link ShakeIllegalMoveOptions.reason} — normally the rules layer's + * player-facing {@link LegalityResult.reason} — and the routine also renders a + * transient toast containing that text via + * {@link showIllegalMoveToast} (`./illegalMoveToastView`). The toast is + * rendered **independently of the shake branch**, so it still appears when: + * + * - the target is a non-tintable `Container` (position shake only); + * - the target is `null`/`undefined` (nothing to shake, but the player still + * deserves to know why the move was refused); + * - reduced motion suppresses the shake entirely. + * + * The reason is optional: callers that pass no reason keep the historical + * tint + shake + `sfx-illegal-move` behaviour (AC1), and the rules layer is + * never touched — the routine only displays text that already exists (AC8). + * * @module ui/shakeIllegalMove */ import { safePlaySound, COMMON_SFX_KEYS } from '../core-engine/SoundManager'; +import { getEffectiveReducedMotion } from './ReducedMotion'; +import { showIllegalMoveToast } from './illegalMoveToastView'; + +/** + * A target that can receive illegal-move feedback. + * + * `Image`/`Sprite` targets are tinted and shaken; `Container` targets (which + * have no `setTint`) are shaken by position only. The routine is + * container-safe because `createDragDropManager` feeds it whatever the + * draggable happens to be. + */ +export type IllegalMoveTarget = + | Phaser.GameObjects.Image + | Phaser.GameObjects.Sprite + | Phaser.GameObjects.Container; /** Options for the {@link shakeIllegalMove} animation. */ export interface ShakeIllegalMoveOptions { @@ -21,10 +53,12 @@ export interface ShakeIllegalMoveOptions { scene: Phaser.Scene; /** - * The sprite (or Image) to shake. - * Must support `setTint()`, `clearTint()`, and `setX()`. + * The sprite (or Image) to shake, or a Container. + * `Image`/`Sprite` must support `setTint()`/`clearTint()`; `Container` is + * shaken by position only. May be `null` when only the reason toast is + * wanted. */ - target: Phaser.GameObjects.Image | Phaser.GameObjects.Sprite | null | undefined; + target: IllegalMoveTarget | null | undefined; /** * Tint colour applied during the shake. @@ -71,25 +105,60 @@ export interface ShakeIllegalMoveOptions { soundKey?: string; /** - * Called after the tint is cleared and x is reset. + * Player-facing explanation for the rejection (for example + * `LegalityResult.reason`). When present — and non-empty — a transient + * toast containing this exact text is rendered (AC1/AC3). + * + * Optional: omitting it preserves the existing animation-only behaviour for + * every current caller (AC1). + */ + reason?: string; + + /** + * Dwell time in milliseconds for the reason toast. + * @default 2500 ({@link ILLEGAL_MOVE_TOAST_DWELL_MS}) + */ + reasonDwellMs?: number; + + /** + * Suppress the shake/ tint animation (the reason toast and the sound still + * happen). Callers that already resolve the preference should pass it; + * otherwise {@link getEffectiveReducedMotion} is consulted (in-game setting + * → `prefers-reduced-motion`), so the toast is the feedback channel for + * players who prefer reduced motion (AC3). + */ + reducedMotion?: boolean; + + /** + * Called after the tint is cleared and x is reset (or immediately when + * reduced motion suppresses the shake). Under reduced motion it still fires + * so callers that chain on it are not stranded. */ onComplete?: () => void; } /** - * Play an illegal-move shake animation on a sprite. + * Play an illegal-move feedback routine on a sprite or container. * - * 1. (Optional) Plays the `soundKey` SFX if provided (defaults to + * 1. (When `reason` is provided) Renders the reason toast — independently of + * every shake early-return, so `null`/`Container`/reduced-motion targets + * still explain the rejection (AC3). + * 2. Returns `undefined` for a `null`/`undefined` target (nothing to shake). + * 3. (Optional) Plays the `soundKey` SFX if provided (defaults to * {@link COMMON_SFX_KEYS.ILLEGAL_MOVE}). - * 2. Applies a red tint (configurable via `tint`). - * 3. Shakes the sprite left by `shakeDistance` pixels with `yoyo: true`. - * 4. On completion, clears the tint and resets the x position. + * 4. Under reduced motion, skips steps 5–6, calls `onComplete` and returns + * `undefined` (no tween created). + * 5. Applies a red tint (configurable via `tint`) for tintable targets. + * 6. Shakes the target left by `shakeDistance` pixels with `yoyo: true`; on + * completion clears the tint, destroys the Canvas tint overlay and resets + * the x position. * * If `target` is `null` or `undefined` the function returns `undefined` * without creating a tween, making it safe to call without a guard. * * @returns The shake tween so the caller can cancel/chain, or `undefined` - * if no tween was created (null target). + * if no tween was created (null target, reduced motion, or a + * container-free branch). */ export function shakeIllegalMove( opts: ShakeIllegalMoveOptions, @@ -103,35 +172,67 @@ export function shakeIllegalMove( repeat = 2, ease = 'Sine.inOut', soundKey = COMMON_SFX_KEYS.ILLEGAL_MOVE, + reason, + reasonDwellMs, onComplete, } = opts; + // The reason toast is rendered first and independently of the shake branch: + // a missing target, a non-tintable Container, or reduced motion must never + // cost the player the explanation (AC3). + if (typeof reason === 'string' && reason.trim() !== '') { + try { + showIllegalMoveToast({ scene, text: reason, dwellMs: reasonDwellMs }); + } catch { + // Best-effort: illegal feedback must never crash the game loop. + } + } + if (!target) return undefined; + // Reduced motion: the toast already carried the explanation, and the + // project-wide reduced-motion contract also suppresses SFX. Skip both the + // shake and the sound so we never animate or talk over the preference + // (AC3); callbacks still fire so callers are not stranded. + const shouldReduce = opts.reducedMotion ?? getEffectiveReducedMotion(); + if (shouldReduce) { + onComplete?.(); + return undefined; + } + // Play the illegal-move sound if a key is provided if (soundKey) { safePlaySound(scene as any, soundKey); } const originalX = target.x; + // `setTint` is the capability probe: Image/Sprite have it, Container does + // not (a Container has no tint component). + const tintable = typeof (target as { setTint?: unknown }).setTint === 'function'; // Apply tint. In Phaser 4 Canvas renderer setTint on Image/Sprite // does not render visibly, so we add a Rectangle overlay as well. - target.setTint(tint); + if (tintable) { + (target as Phaser.GameObjects.Image).setTint(tint); + } - // Canvas-compatible tint overlay (also works under WebGL) + // Canvas-compatible tint overlay (also works under WebGL). Only meaningful + // for tintable targets — a Container has no texture frame to overlay. const tgt = target as any; - const overlayW = tgt.displayWidth ?? tgt.width ?? 96; - const overlayH = tgt.displayHeight ?? tgt.height ?? 130; - const tintOverlay = scene.add.rectangle( - target.x, target.y, - overlayW, overlayH, - tint, - ) - .setAlpha(0.4) - .setOrigin(tgt.originX ?? 0.5, tgt.originY ?? 0.5) - .setRotation(tgt.rotation ?? 0) - .setDepth((tgt.depth ?? 0) + 0.1); + let tintOverlay: Phaser.GameObjects.Rectangle | undefined; + if (tintable) { + const overlayW = tgt.displayWidth ?? tgt.width ?? 96; + const overlayH = tgt.displayHeight ?? tgt.height ?? 130; + tintOverlay = scene.add.rectangle( + target.x, target.y, + overlayW, overlayH, + tint, + ) + .setAlpha(0.4) + .setOrigin(tgt.originX ?? 0.5, tgt.originY ?? 0.5) + .setRotation(tgt.rotation ?? 0) + .setDepth((tgt.depth ?? 0) + 0.1); + } return scene.tweens.add({ targets: target, @@ -141,9 +242,17 @@ export function shakeIllegalMove( repeat, ease, onComplete: () => { - target.clearTint(); - tintOverlay.destroy(); - target.setX(originalX); + if (tintable) { + (target as Phaser.GameObjects.Image).clearTint(); + } + tintOverlay?.destroy(); + // Prefer the Transform setter; fall back to direct assignment for + // container-like test doubles that only expose `x`. + if (typeof (target as { setX?: unknown }).setX === 'function') { + target.setX(originalX); + } else { + target.x = originalX; + } onComplete?.(); }, }); diff --git a/tests/core-engine/GameEventEmitter.gameEvents.test.ts b/tests/core-engine/GameEventEmitter.gameEvents.test.ts new file mode 100644 index 000000000..8655ae227 --- /dev/null +++ b/tests/core-engine/GameEventEmitter.gameEvents.test.ts @@ -0,0 +1,156 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + GameEventEmitter, + type GameEventName, + type TurnStartedPayload, +} from '../../src/core-engine/GameEventEmitter'; +import { + SYNTHETIC_EVENT, + type SyntheticFaceOffResolvedPayload, +} from './fixtures/augmentedGameEventMap'; + +/** + * Tests for the engine extension mechanism: games register their own + * typed events via `GameEventMap` interface augmentation, and the + * catch-all `onAny` subscription covers both engine and game-specific + * names. + */ +describe('GameEventEmitter game-specific events', () => { + let emitter: GameEventEmitter; + + beforeEach(() => { + emitter = new GameEventEmitter(); + }); + + const syntheticPayload: SyntheticFaceOffResolvedPayload = { + slot: 2, + playerTotal: 11, + adversaryTotal: 8, + winner: 'player', + }; + + const turnPayload: TurnStartedPayload = { + turnNumber: 0, + playerIndex: 0, + playerName: 'Alice', + isAI: false, + }; + + // ── Augmented map (AC1) ───────────────────────────────── + + it('emits and receives an augmented game-specific event via on', () => { + const listener = vi.fn(); + emitter.on(SYNTHETIC_EVENT, listener); + + emitter.emit(SYNTHETIC_EVENT, syntheticPayload); + + expect(listener).toHaveBeenCalledOnce(); + expect(listener).toHaveBeenCalledWith(syntheticPayload); + }); + + it('supports once over the merged (augmented) map', () => { + const listener = vi.fn(); + emitter.once(SYNTHETIC_EVENT, listener); + + emitter.emit(SYNTHETIC_EVENT, syntheticPayload); + emitter.emit(SYNTHETIC_EVENT, syntheticPayload); + + expect(listener).toHaveBeenCalledOnce(); + }); + + // ── Catch-all (AC2) ───────────────────────────────────── + + it('onAny receives engine and game-specific events with name and payload', () => { + const anyListener = vi.fn(); + emitter.onAny(anyListener); + + emitter.emit('turn-started', turnPayload); + emitter.emit(SYNTHETIC_EVENT, syntheticPayload); + + expect(anyListener).toHaveBeenCalledTimes(2); + expect(anyListener).toHaveBeenNthCalledWith(1, 'turn-started', turnPayload); + expect(anyListener).toHaveBeenNthCalledWith( + 2, + SYNTHETIC_EVENT, + syntheticPayload, + ); + }); + + it('onAny fires even when no per-name listener is attached', () => { + const anyListener = vi.fn(); + emitter.onAny(anyListener); + + emitter.emit(SYNTHETIC_EVENT, syntheticPayload); + + expect(anyListener).toHaveBeenCalledOnce(); + }); + + it('returns an unsubscribe function that stops delivery', () => { + const anyListener = vi.fn(); + const offAny = emitter.onAny(anyListener); + + emitter.emit('game-ended', { finalTurnNumber: 3, winnerIndex: 0 }); + offAny(); + emitter.emit(SYNTHETIC_EVENT, syntheticPayload); + + expect(anyListener).toHaveBeenCalledOnce(); + }); + + it('unsubscribing from inside an onAny handler does not skip the remaining peer', () => { + const seen: Array<[GameEventName, unknown]> = []; + let firstOff: (() => void) | undefined; + let secondOff: (() => void) | undefined; + + const first: Parameters<GameEventEmitter['onAny']>[0] = (name, payload) => { + seen.push([name, payload]); + firstOff?.(); + secondOff?.(); + }; + const second: Parameters<GameEventEmitter['onAny']>[0] = (name, payload) => { + seen.push([name, payload]); + }; + + firstOff = emitter.onAny(first); + secondOff = emitter.onAny(second); + + emitter.emit('turn-started', turnPayload); + // Snapshot semantics: both handlers still ran for the emission during + // which they unsubscribed... + expect(seen.map(([name]) => name)).toEqual([ + 'turn-started', + 'turn-started', + ]); + + // ...and neither receives subsequent emissions. + emitter.emit('game-ended', { finalTurnNumber: 3, winnerIndex: 0 }); + expect(seen).toHaveLength(2); + }); + + it('emit stays a no-op when no listeners are attached', () => { + expect(() => emitter.emit(SYNTHETIC_EVENT, syntheticPayload)).not.toThrow(); + }); + + it('removeAllListeners() without an argument clears the catch-all too', () => { + const anyListener = vi.fn(); + emitter.onAny(anyListener); + emitter.emit(SYNTHETIC_EVENT, syntheticPayload); + + emitter.removeAllListeners(); + + emitter.emit(SYNTHETIC_EVENT, syntheticPayload); + expect(anyListener).toHaveBeenCalledOnce(); + }); + + it('removeAllListeners(event) leaves the catch-all intact', () => { + const anyListener = vi.fn(); + const namedListener = vi.fn(); + emitter.onAny(anyListener); + emitter.on('turn-started', namedListener); + + emitter.removeAllListeners('turn-started'); + + emitter.emit('turn-started', turnPayload); + expect(namedListener).not.toHaveBeenCalled(); + expect(anyListener).toHaveBeenCalledOnce(); + }); +}); diff --git a/tests/core-engine/GameEventEmitter.test.ts b/tests/core-engine/GameEventEmitter.test.ts index 2aa7e0dc7..da9742f42 100644 --- a/tests/core-engine/GameEventEmitter.test.ts +++ b/tests/core-engine/GameEventEmitter.test.ts @@ -696,12 +696,21 @@ describe('GameEventEmitter', () => { describe('cross-game payload consistency', () => { /** - * A minimal, representative payload for every event name in + * A minimal, representative payload for every engine event name in * `GameEventMap`. Used to drive the emitter through each key of the map * via the public API. Optional fields are omitted to prove they are * genuinely optional at runtime. + * + * `GameEventMap` is open to consumer augmentation (a game declares its + * own typed names — see `GameEventEmitter.gameEvents.test.ts`). This map + * covers the engine's own vocabulary, so augmented names are excluded; + * adding an engine event still fails compilation until a sample is + * added here. */ - const MINIMAL_PAYLOADS: Record<GameEventName, Record<string, unknown>> = { + const MINIMAL_PAYLOADS: Record< + Exclude<GameEventName, 'synthetic-face-off-resolved'>, + Record<string, unknown> + > = { 'turn-started': { turnNumber: 0, playerIndex: 0, playerName: 'A', isAI: false }, 'turn-completed': { turnNumber: 0, playerIndex: 0, playerName: 'A', phase: 'playing' }, 'animation-complete': { turnNumber: 0 }, @@ -730,7 +739,7 @@ describe('GameEventEmitter', () => { }; it('round-trips a representative payload for every event name in GameEventMap', () => { - const names = Object.keys(MINIMAL_PAYLOADS) as GameEventName[]; + const names = Object.keys(MINIMAL_PAYLOADS) as (keyof typeof MINIMAL_PAYLOADS)[]; expect(names.length).toBeGreaterThan(0); for (const name of names) { @@ -771,7 +780,7 @@ describe('GameEventEmitter', () => { it('listenerCount tracks each event independently across the whole map', () => { const local = new GameEventEmitter(); - const names = Object.keys(MINIMAL_PAYLOADS) as GameEventName[]; + const names = Object.keys(MINIMAL_PAYLOADS) as (keyof typeof MINIMAL_PAYLOADS)[]; // Subscribe every event exactly once for (const name of names) { diff --git a/tests/core-engine/PhaserEventBridge.test.ts b/tests/core-engine/PhaserEventBridge.test.ts index 440da3a20..1bd1abc74 100644 --- a/tests/core-engine/PhaserEventBridge.test.ts +++ b/tests/core-engine/PhaserEventBridge.test.ts @@ -236,20 +236,15 @@ describe('PhaserEventBridge', () => { }); it('should remove bridge listeners from engine', () => { - // Bridge registers 5 listeners on the engine (one per event) - expect(engine.listenerCount('turn-started')).toBe(1); - expect(engine.listenerCount('turn-completed')).toBe(1); - expect(engine.listenerCount('animation-complete')).toBe(1); - expect(engine.listenerCount('state-settled')).toBe(1); - expect(engine.listenerCount('game-ended')).toBe(1); + // The engine -> Phaser direction is a single catch-all subscription + // (`onAny`), not one listener per event name, so every engine event — + // including game-specific ones — is forwarded without a name list. + expect(engine.listenerCount('turn-started')).toBe(0); + expect(engine.anyListenerCount()).toBe(1); bridge.destroy(); - expect(engine.listenerCount('turn-started')).toBe(0); - expect(engine.listenerCount('turn-completed')).toBe(0); - expect(engine.listenerCount('animation-complete')).toBe(0); - expect(engine.listenerCount('state-settled')).toBe(0); - expect(engine.listenerCount('game-ended')).toBe(0); + expect(engine.anyListenerCount()).toBe(0); }); it('should remove bridge listeners from phaser', () => { diff --git a/tests/core-engine/TranscriptStore.test.ts b/tests/core-engine/TranscriptStore.test.ts index da3c184ea..f4cf7937f 100644 --- a/tests/core-engine/TranscriptStore.test.ts +++ b/tests/core-engine/TranscriptStore.test.ts @@ -12,6 +12,18 @@ function fakeTranscript(id: number = 1) { }; } +/** + * Disk-persistence log lines only. + * + * The store also warns about unrelated conditions (e.g. the localStorage + * fallback), so log-volume assertions filter to the disk-mirror messages. + */ +function diskLogs(spy: { mock: { calls: unknown[][] } }): string[] { + return spy.mock.calls + .map((call) => String(call[0])) + .filter((line) => line.includes('Disk persistence') || line.includes('persistence endpoint')); +} + // ── localStorage mock ────────────────────────────────────── /** @@ -59,6 +71,7 @@ describe('TranscriptStore', () => { afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); + vi.unstubAllEnvs(); }); it('should export TranscriptStore from barrel', async () => { @@ -319,7 +332,33 @@ describe('TranscriptStore', () => { }); }); - it('should still return StoredTranscript when POST returns HTTP 500', async () => { + it('skips the POST entirely when not running under a Vite dev server', async () => { + // The endpoint only exists behind the dev server's transcript plugin + // (production/Electron bundles, and Vitest browser runs, have no + // `/api/transcripts` middleware). Issuing the request there can only + // produce noise, so the DEV gate removes it entirely + // (MS-0MVREB31W004F48D). The gate reads `import.meta.env.DEV` through + // `isDevMode()`, which is overridden here so the test does not depend on + // the runner's injected Vite env. + const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 }); + vi.stubGlobal('fetch', fetchMock); + + class NonDevStore extends TranscriptStore { + protected override isDevMode(): boolean { + return false; + } + } + const store = new NonDevStore(); + const result = await store.save('golf', fakeTranscript(1)); + + // Save still succeeds — only the disk mirror is skipped. + expect(result).not.toBeNull(); + // Give any stray microtask a chance to run before asserting absence. + await Promise.resolve(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('should still return StoredTranscript when POST returns HTTP 500, warning once', async () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); const fetchMock = vi.fn().mockResolvedValue({ ok: false, status: 500 }); vi.stubGlobal('fetch', fetchMock); @@ -335,29 +374,50 @@ describe('TranscriptStore', () => { await vi.waitFor(() => expect(warnSpy).toHaveBeenCalledWith( expect.stringContaining('Disk persistence POST returned HTTP 500'), )); - - warnSpy.mockRestore(); }); - it('should still return StoredTranscript when POST throws a network error', async () => { + it('logs an absent endpoint (HTTP 404) at most once, at debug, across repeated saves', async () => { + // A missing `/api/transcripts` middleware (a game repo whose generated + // `vite.config.ts` does not register the plugin, or any runtime without + // it) is an *expected* absence: report it once, quietly, and stay silent + // afterwards instead of warning on every single game-over. const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - const fetchMock = vi.fn().mockRejectedValue(new Error('Network error')); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {}); + const fetchMock = vi.fn().mockResolvedValue({ ok: false, status: 404 }); vi.stubGlobal('fetch', fetchMock); const store = new TranscriptStore(); - const transcript = fakeTranscript(1); - const result = await store.save('golf', transcript); + for (let i = 0; i < 3; i += 1) { + await store.save('main-street', fakeTranscript(i + 1)); + } - expect(result).not.toBeNull(); - expect(result!.gameType).toBe('golf'); + await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(3)); + expect(debugSpy).toHaveBeenCalledTimes(1); + expect(String(debugSpy.mock.calls[0]?.[0])).toContain('HTTP 404'); + // No warning or error for an expected absence (the store's unrelated + // localStorage-fallback note is not a disk-persistence log). + expect(diskLogs(warnSpy)).toEqual([]); + expect(diskLogs(errorSpy)).toEqual([]); + }); - // Allow the fire-and-forget promise to settle and check warning - await vi.waitFor(() => expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining('Disk persistence POST failed'), - expect.any(Error), - )); + it('logs a network failure at most once, at debug, across repeated saves', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {}); + const fetchMock = vi.fn().mockRejectedValue(new Error('Network error')); + vi.stubGlobal('fetch', fetchMock); + + const store = new TranscriptStore(); + for (let i = 0; i < 3; i += 1) { + await store.save('golf', fakeTranscript(i + 1)); + } - warnSpy.mockRestore(); + await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(3)); + expect(debugSpy).toHaveBeenCalledTimes(1); + expect(String(debugSpy.mock.calls[0]?.[0])).toContain('Disk persistence POST failed'); + expect(diskLogs(warnSpy)).toEqual([]); + expect(diskLogs(errorSpy)).toEqual([]); }); it('should not call fetch when fetch is undefined', async () => { diff --git a/tests/core-engine/fixtures/augmentedGameEventMap.ts b/tests/core-engine/fixtures/augmentedGameEventMap.ts new file mode 100644 index 000000000..2ec0fa157 --- /dev/null +++ b/tests/core-engine/fixtures/augmentedGameEventMap.ts @@ -0,0 +1,38 @@ +/** + * Test fixture: a game declares its own typed event names by + * augmenting the engine's `GameEventMap` interface (module + * augmentation). + * + * This mirrors exactly what a consumer repository does — e.g. a game + * declares `declare module '@core-engine/GameEventEmitter'` in its own + * source — and is used by the engine extension tests to prove the + * mechanism works for engine and game-specific names alike, with zero + * engine edits. The example lives in a fixture (rather than inline in a + * test) so the augmentation is applied once and shared by every test + * that needs the synthetic event. + */ +import '@core-engine/GameEventEmitter'; + +/** Payload of the synthetic game event used by the extension tests. */ +export interface SyntheticFaceOffResolvedPayload { + /** Index of the contested slot. */ + readonly slot: number; + /** Total points played by the player in the face-off. */ + readonly playerTotal: number; + /** Total points played by the adversary in the face-off. */ + readonly adversaryTotal: number; + /** Which side won the face-off. */ + readonly winner: 'player' | 'adversary'; +} + +/** + * The synthetic game-specific event name, fixed as a literal type so + * tests can reference it without widening the literal-key constraint. + */ +export const SYNTHETIC_EVENT = 'synthetic-face-off-resolved' as const; + +declare module '@core-engine/GameEventEmitter' { + interface GameEventMap { + 'synthetic-face-off-resolved': SyntheticFaceOffResolvedPayload; + } +} diff --git a/tests/core-engine/gameSpecificEventForwarding.test.ts b/tests/core-engine/gameSpecificEventForwarding.test.ts new file mode 100644 index 000000000..a10c8442d --- /dev/null +++ b/tests/core-engine/gameSpecificEventForwarding.test.ts @@ -0,0 +1,159 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + GameEventEmitter, + type StateSettledPayload, +} from '../../src/core-engine/GameEventEmitter'; +import { + PhaserEventBridge, + type PhaserLikeEventEmitter, +} from '../../src/core-engine/PhaserEventBridge'; +import { GlobalEventBuffer } from '../../src/ui/debug/GlobalEventBuffer'; +import { + SYNTHETIC_EVENT, + type SyntheticFaceOffResolvedPayload, +} from './fixtures/augmentedGameEventMap'; + +/** + * Minimal mock that implements the `PhaserLikeEventEmitter` subset the + * bridge needs. Deliberately name-agnostic: it accepts any event name, + * which is what lets a game-specific event cross the bridge. + */ +class MockPhaserEmitter implements PhaserLikeEventEmitter { + private handlers = new Map<string, Array<(...args: unknown[]) => void>>(); + + on(event: string, fn: (...args: unknown[]) => void): this { + const list = this.handlers.get(event) ?? []; + list.push(fn); + this.handlers.set(event, list); + return this; + } + + off(event: string, fn: (...args: unknown[]) => void): this { + const list = this.handlers.get(event); + if (!list) return this; + const idx = list.indexOf(fn); + if (idx !== -1) list.splice(idx, 1); + return this; + } + + emit(event: string, ...args: unknown[]): boolean { + const list = this.handlers.get(event); + if (!list || list.length === 0) return false; + for (const fn of [...list]) { + fn(...args); + } + return true; + } +} + +const syntheticPayload: SyntheticFaceOffResolvedPayload = { + slot: 1, + playerTotal: 9, + adversaryTotal: 9, + winner: 'adversary', +}; + +const settledPayload: StateSettledPayload = { + turnNumber: 4, + phase: 'playing', +}; + +describe('game-specific event forwarding', () => { + let engine: GameEventEmitter; + let phaser: MockPhaserEmitter; + let bridge: PhaserEventBridge; + + beforeEach(() => { + engine = new GameEventEmitter(); + phaser = new MockPhaserEmitter(); + bridge = new PhaserEventBridge(engine, phaser); + GlobalEventBuffer.getInstance().clear(); + }); + + // ── PhaserEventBridge (AC3) ───────────────────────────── + + it('forwards a game-specific event from the engine to Phaser', () => { + const listener = vi.fn(); + phaser.on(SYNTHETIC_EVENT, listener); + + engine.emit(SYNTHETIC_EVENT, syntheticPayload); + + expect(listener).toHaveBeenCalledOnce(); + expect(listener).toHaveBeenCalledWith(syntheticPayload); + }); + + it('still forwards engine events to Phaser (regression)', () => { + const listener = vi.fn(); + phaser.on('state-settled', listener); + + engine.emit('state-settled', settledPayload); + + expect(listener).toHaveBeenCalledWith(settledPayload); + }); + + it('stops forwarding game-specific events after destroy()', () => { + const listener = vi.fn(); + phaser.on(SYNTHETIC_EVENT, listener); + + bridge.destroy(); + engine.emit(SYNTHETIC_EVENT, syntheticPayload); + + expect(listener).not.toHaveBeenCalled(); + }); + + it('does not re-forward an event that arrived from Phaser (no loop)', () => { + const engineListener = vi.fn(); + engine.on('state-settled', engineListener); + + // Phaser -> engine direction keeps working; the catch-all + // engine -> Phaser subscription must not bounce it back. + phaser.emit('state-settled', settledPayload); + + expect(engineListener).toHaveBeenCalledOnce(); + }); + + // ── GlobalEventBuffer (AC4) ───────────────────────────── + + it('buffers game-specific events alongside engine events', () => { + const buffer = GlobalEventBuffer.getInstance(); + buffer.subscribe(engine); + + engine.emit('state-settled', settledPayload); + engine.emit(SYNTHETIC_EVENT, syntheticPayload); + + const entries = buffer.getEntries(); + expect(entries.map((entry) => entry.eventName)).toEqual([ + 'state-settled', + SYNTHETIC_EVENT, + ]); + expect(entries[1].payload).toEqual(syntheticPayload); + }); + + it('re-binds to a new emitter instead of missing the new session', () => { + const buffer = GlobalEventBuffer.getInstance(); + const first = new GameEventEmitter(); + const second = new GameEventEmitter(); + + buffer.subscribe(first); + buffer.subscribe(second); + + first.emit(SYNTHETIC_EVENT, syntheticPayload); + second.emit(SYNTHETIC_EVENT, syntheticPayload); + + // Only the currently-subscribed emitter feeds the buffer; re-binding does + // not leave a stale subscription behind (or duplicate entries). + expect(buffer.getEntries()).toHaveLength(1); + }); + + it('buffers game-specific events emitted before the overlay opens', () => { + const buffer = GlobalEventBuffer.getInstance(); + buffer.subscribe(engine); + + engine.emit(SYNTHETIC_EVENT, syntheticPayload); + + // The overlay reads from the buffer, so an event emitted before it + // opened is still visible. + expect(buffer.getEntries()).toHaveLength(1); + expect(buffer.getEntries()[0].eventName).toBe(SYNTHETIC_EVENT); + }); +}); 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', () => { 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..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 -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,,,,, diff --git a/tests/fixtures/story-line/broken-dangling.csv b/tests/fixtures/story-line/broken-dangling.csv new file mode 100644 index 000000000..7be9334ab --- /dev/null +++ b/tests/fixtures/story-line/broken-dangling.csv @@ -0,0 +1,2 @@ +card_key,card_name,flavour,arc,arc_name,choices,payoff +tav-open,Open the Door,A stranger knocks at the tavern door.,tav,Tavern Nights,Admit|tav-missing,2 diff --git a/tests/fixtures/story-line/broken-duplicate.csv b/tests/fixtures/story-line/broken-duplicate.csv new file mode 100644 index 000000000..09ebf088b --- /dev/null +++ b/tests/fixtures/story-line/broken-duplicate.csv @@ -0,0 +1,3 @@ +card_key,card_name,flavour,arc,arc_name,choices,payoff +tav-open,Open the Door,A stranger knocks at the tavern door.,tav,Tavern Nights,,2 +tav-open,Open the Door Again,A second row reuses the id.,tav,Tavern Nights,,3 diff --git a/tests/fixtures/story-line/broken-missing-storyline.csv b/tests/fixtures/story-line/broken-missing-storyline.csv new file mode 100644 index 000000000..a850124b7 --- /dev/null +++ b/tests/fixtures/story-line/broken-missing-storyline.csv @@ -0,0 +1,3 @@ +card_key,card_name,flavour,arc,arc_name,choices,payoff +tav-open,Open the Door,A stranger knocks at the tavern door.,,,Admit|tav-end,2 +tav-end,The End,The night ends.,,,, diff --git a/tests/fixtures/story-line/broken-options.csv b/tests/fixtures/story-line/broken-options.csv new file mode 100644 index 000000000..407f644a5 --- /dev/null +++ b/tests/fixtures/story-line/broken-options.csv @@ -0,0 +1,2 @@ +card_key,card_name,flavour,arc,arc_name,choices,payoff +tav-open,Open the Door,A stranger knocks at the tavern door.,tav,Tavern Nights,AdmitWithoutPipe,2 diff --git a/tests/fixtures/story-line/broken-unreachable.csv b/tests/fixtures/story-line/broken-unreachable.csv new file mode 100644 index 000000000..f1bace18a --- /dev/null +++ b/tests/fixtures/story-line/broken-unreachable.csv @@ -0,0 +1,4 @@ +card_key,card_name,flavour,arc,arc_name,choices,payoff +tav-open,Open the Door,A stranger knocks at the tavern door.,tav,Tavern Nights,Go|tav-end,2 +tav-end,The End,The night ends.,tav,Tavern Nights,,3 +tav-lost,Lost Scene,Nothing links to this card.,tav,Tavern Nights,,1 diff --git a/tests/fixtures/story-line/gym-broken-dangling.csv b/tests/fixtures/story-line/gym-broken-dangling.csv new file mode 100644 index 000000000..2388635ac --- /dev/null +++ b/tests/fixtures/story-line/gym-broken-dangling.csv @@ -0,0 +1,2 @@ +id,name,effect,options,hasChoices,acceptNextCardId,rejectNextCardId,storylineId,storylineTitle,trigger,cost,coinDelta,reputationDelta +gym-open,Gym Open,A card that links nowhere.,Go|gym-missing,false,,,gym-arc,Gym Arc,Incident,0,0,0 diff --git a/tests/fixtures/story-line/gym-cycle.csv b/tests/fixtures/story-line/gym-cycle.csv new file mode 100644 index 000000000..ee04c480b --- /dev/null +++ b/tests/fixtures/story-line/gym-cycle.csv @@ -0,0 +1,3 @@ +id,name,effect,options,hasChoices,acceptNextCardId,rejectNextCardId,storylineId,storylineTitle,trigger,cost,coinDelta,reputationDelta +gym-loop-a,Loop A,The argument returns to the start.,Again|gym-loop-b,false,,,gym-arc,Gym Arc,Incident,0,0,0 +gym-loop-b,Loop B,The argument circles back again.,Back|gym-loop-a,false,,,gym-arc,Gym Arc,Incident,0,0,0 diff --git a/tests/fixtures/story-line/tavern-cycle.csv b/tests/fixtures/story-line/tavern-cycle.csv new file mode 100644 index 000000000..02e65478d --- /dev/null +++ b/tests/fixtures/story-line/tavern-cycle.csv @@ -0,0 +1,3 @@ +card_key,card_name,flavour,arc,arc_name,choices,payoff +tav-loop-a,Loop A,The argument returns to the start.,tav,Tavern Nights,Again|tav-loop-b,1 +tav-loop-b,Loop B,The argument circles back again.,tav,Tavern Nights,Back|tav-loop-a,1 diff --git a/tests/fixtures/story-line/tavern-storylines.csv b/tests/fixtures/story-line/tavern-storylines.csv new file mode 100644 index 000000000..507fba2ce --- /dev/null +++ b/tests/fixtures/story-line/tavern-storylines.csv @@ -0,0 +1,4 @@ +card_key,card_name,flavour,arc,arc_name,choices,payoff +tav-open,Open the Door,A stranger knocks at the tavern door.,tav,Tavern Nights,Admit|tav-inn;Turn away|,2 +tav-inn,The Common Room,The fire is warm and the room falls quiet.,tav,Tavern Nights,Listen|tav-tale;Retire|,5 +tav-tale,A Tale Worth Telling,The stranger speaks of the long road south.,tav,Tavern Nights,,8 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', () => { 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<DemoState>; result: ReturnType<typeof applyDemoChoice> } { + 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']); + }); +}); 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<void>): Promise<Error> { + 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 <label> after <budget>ms + * (cap <capMs>ms)`), keeping triage greps working. + * + * @module tests/helpers/waitForCondition + */ + +/** Default widening applied to the nominal production cap. */ +export const DEFAULT_STARVATION_FACTOR = 1.5; + +/** Default fixed scheduling margin added to the nominal production cap. */ +export const DEFAULT_FLOOR_MS = 5_000; + +/** Default poll interval (matches the per-file copies this replaces). */ +export const DEFAULT_INTERVAL_MS = 25; + +/** Default label used when a caller passes none. */ +export const DEFAULT_LABEL = 'condition'; + +/** + * Default raw budget for callers that pass `timeoutMs`/nothing at all + * (matches the per-file copies this replaces). + */ +export const DEFAULT_TIMEOUT_MS = 10_000; + +/** Options for {@link resolveWaitBudget}. */ +export interface WaitBudgetOptions { + /** Nominal production cap (ms) the wait races. */ + capMs: number; + /** Proportional widening for rAF starvation (default 1.5). */ + starvationFactor?: number; + /** Fixed scheduling margin added to the cap (default 5 000). */ + floorMs?: number; +} + +/** + * Derive a load-tolerant wait budget from the production cap it races. + * + * `budget = max(round(capMs * starvationFactor), capMs + floorMs)` + * + * @throws {Error} when the derived budget is not strictly greater than + * `capMs` — the invariant that stops a caller configuring a poll shorter + * than the cap it races. + */ +export function resolveWaitBudget({ + capMs, + starvationFactor = DEFAULT_STARVATION_FACTOR, + floorMs = DEFAULT_FLOOR_MS, +}: WaitBudgetOptions): number { + const budgetMs = Math.max( + Math.round(capMs * starvationFactor), + capMs + floorMs, + ); + + // Written as a negated `>` so a non-finite cap or factor (NaN) also throws + // instead of silently deriving a budget that is not a number. + if (!(budgetMs > capMs)) { + throw new Error( + `Wait budget ${budgetMs}ms is not strictly greater than the ${capMs}ms ` + + `cap it races — raise starvationFactor or floorMs.`, + ); + } + + return budgetMs; +} + +/** Options for {@link waitForCondition}. */ +export interface WaitForConditionOptions { + /** Nominal production cap (ms) the wait races; enables budget derivation. */ + capMs?: number; + /** Proportional widening for rAF starvation (default 1.5). */ + starvationFactor?: number; + /** Fixed scheduling margin added to the cap (default 5 000). */ + floorMs?: number; + /** Raw budget (ms) for waits that race no production cap. */ + timeoutMs?: number; + /** Poll interval in ms (default 25). */ + intervalMs?: number; + /** Human-readable label used in the timeout message (default `condition`). */ + label?: string; +} + +/** + * Poll `predicate` until it returns true, or reject once the derived budget + * elapses. + * + * The predicate is polled immediately and then every `intervalMs`. When + * `capMs` is supplied the budget is derived via {@link resolveWaitBudget}; + * otherwise `timeoutMs` (default 10 000 ms) is used verbatim. + * + * @returns a promise that resolves (with `undefined`) on the first truthy + * predicate. + * @throws {Error} `Timed out waiting for <label> after <budget>ms + * (cap <capMs>ms)` — the `(cap …)` suffix appears only when a cap was + * supplied, so legacy callers keep the original message shape. + */ +export async function waitForCondition( + predicate: () => boolean, + options: WaitForConditionOptions = {}, +): Promise<void> { + const { + capMs, + starvationFactor, + floorMs, + timeoutMs, + intervalMs = DEFAULT_INTERVAL_MS, + label = DEFAULT_LABEL, + } = options; + + const racesProductionCap = capMs !== undefined; + const budgetMs = racesProductionCap + ? resolveWaitBudget({ capMs, starvationFactor, floorMs }) + : (timeoutMs ?? DEFAULT_TIMEOUT_MS); + const capDetail = racesProductionCap ? ` (cap ${capMs}ms)` : ''; + const startedAt = Date.now(); + + while (Date.now() - startedAt < budgetMs) { + if (predicate()) return; + await new Promise<void>((resolve) => setTimeout(resolve, intervalMs)); + } + + // Boundary re-check: the predicate may have become true while the final + // interval elapsed, so a scene that just finished is never reported as a + // timeout. (The derived budget makes this rare, not impossible.) + if (predicate()) return; + + throw new Error( + `Timed out waiting for ${label} after ${budgetMs}ms${capDetail}`, + ); +} 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 new file mode 100644 index 000000000..bbcaef15e --- /dev/null +++ b/tests/scripts/engine-sync.test.ts @@ -0,0 +1,382 @@ +/** + * Unit tests for the engine submodule sync/verify script + * (`scripts/engine-sync.mjs`, MS-0MVCRGKZY007DIED; pin policy + * CG-0MVO268500071BPI). + * + * The script implements the dev/release engine-composition policies for game + * repos under one policy: **the pin tracks the tracked branch tip, and the pin + * and the `./core` gitlink travel together**. + * + * - **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. When the checkout moves and the repo + * has a pin, the pin is rewritten to the new HEAD and both are staged + * together. Unreachable remote → fall back to the current checkout with a + * warning (offline build), leaving the pin untouched. + * - **verify** (release build): refuse loudly on local modifications, read + * `configs/engine-pin.json`, compare it with the remote-resolved target, and + * check out exactly that SHA. A lagging pin and a foreign pin both fail, with + * distinct diagnoses. + * + * 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 { + 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; + offBranchCommit: () => 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(); + }; + + /** Create a commit on a branch other than `dev` (foreign to the tracked branch). */ + const offBranchCommit = (): string => { + git(engine, ['checkout', '-q', '-b', 'other']); + fs.appendFileSync(path.join(engine, 'version.txt'), `other${Date.now()}\n`); + git(engine, ['add', 'version.txt']); + commit(engine, 'foreign'); + const sha = devTip(); + git(engine, ['checkout', '-q', 'dev']); + return sha; + }; + + const pinTo = (sha: string): void => { + fs.writeFileSync(pinPath, JSON.stringify({ sha })); + }; + + return { root, engine, game, pinPath, devTip, advanceEngine, offBranchCommit, 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 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(); + f.pinTo(f.devTip()); // pin equals the checkout, not `tip` + 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; + const pinBefore = fs.readFileSync(f.pinPath, 'utf-8'); + expect(before).not.toBe(tip); + const res = syncEngine({ repoRoot: f.game }); + expect(res.warning).toMatch(/remote unreachable|WARNING/); + expect(res.updated).toBe(false); + expect(res.pinWritten).toBe(false); + // Offline fallback never clobbers: the checkout and the pin are untouched. + expect(git(path.join(f.game, 'core'), ['rev-parse', 'HEAD']).stdout).toBe(before); + expect(fs.readFileSync(f.pinPath, 'utf-8')).toBe(pinBefore); + }); +}); + +describe('syncEngine — the pin travels with the gitlink (AC2)', () => { + it('writes the pin to the new HEAD and stages it alongside the gitlink', () => { + const f = makeFixture(); + f.pinTo(f.devTip()); + const tip = f.advanceEngine(); + const res = syncEngine({ repoRoot: f.game }); + expect(res.updated).toBe(true); + expect(res.pinWritten).toBe(true); + + // Pin value == new submodule HEAD. + const pin = JSON.parse(fs.readFileSync(f.pinPath, 'utf-8')) as { sha: string }; + expect(pin.sha).toBe(tip); + expect(readPin(f.game)).toBe(tip); + + // Both staged together: the index records the new gitlink and the pin. + const staged = git(f.game, ['diff', '--cached', '--name-only']).stdout.split('\n'); + expect(staged).toContain(DEFAULT_PIN_REL.split(path.sep).join('/')); + expect(staged).toContain(DEFAULT_CORE_REL); + const indexGitlink = git(f.game, ['ls-files', '-s', DEFAULT_CORE_REL]).stdout.split(/\s+/)[1]; + expect(indexGitlink).toBe(tip); + }); + + it('leaves the pin byte-identical on a second (no-op) sync', () => { + const f = makeFixture(); + f.pinTo(f.devTip()); + f.advanceEngine(); + syncEngine({ repoRoot: f.game }); // advances + writes the pin + const bytes = fs.readFileSync(f.pinPath, 'utf-8'); + + const res = syncEngine({ repoRoot: f.game }); // no-op + expect(res.updated).toBe(false); + expect(res.pinWritten).toBe(false); + expect(fs.readFileSync(f.pinPath, 'utf-8')).toBe(bytes); + }); + + it('never creates a pin for a repo that has none (no spurious file)', () => { + const f = makeFixture(); + fs.rmSync(f.pinPath, { force: true }); + const tip = f.advanceEngine(); + const res = syncEngine({ repoRoot: f.game }); + expect(res.updated).toBe(true); + expect(res.pinWritten).toBe(false); + expect(fs.existsSync(f.pinPath)).toBe(false); + const staged = git(f.game, ['diff', '--cached', '--name-only']).stdout; + expect(staged).not.toContain('engine-pin.json'); + expect(tip).toMatch(/^[0-9a-f]{40}$/); + }); +}); + +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 — failure paths (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('verifyEngine — lag vs bad/foreign pin diagnostics (AC2)', () => { + it('diagnoses LAG: pin is an ancestor of the tracked tip by N commits — re-pin', () => { + const f = makeFixture(); + const oldTip = f.devTip(); + f.advanceEngine(); + f.advanceEngine(); // the tip is now 2 commits ahead of the pin + f.pinTo(oldTip); + expect(() => verifyEngine({ repoRoot: f.game })).toThrow( + /is an ancestor of the tracked dev tip by 2 commit/, + ); + expect(() => verifyEngine({ repoRoot: f.game })).toThrow(/Re-pin: .*travel together/); + // Still a hard failure (not a warning), and nothing was checked out. + expect(() => verifyEngine({ repoRoot: f.game })).toThrow(EngineSyncError); + }); + + it('diagnoses a BAD/FOREIGN pin: it does not resolve on the tracked branch', () => { + const f = makeFixture(); + const foreign = f.offBranchCommit(); + expect(foreign).toMatch(/^[0-9a-f]{40}$/); + f.pinTo(foreign); + expect(() => verifyEngine({ repoRoot: f.game })).toThrow( + /does not resolve on the tracked branch dev/, + ); + expect(() => verifyEngine({ repoRoot: f.game })).toThrow(/bad or foreign pin/); + expect(() => verifyEngine({ repoRoot: f.game })).toThrow(EngineSyncError); + }); +}); + +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..35c7fa7cd 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, @@ -86,6 +88,48 @@ const MANIFEST: CoreManifest = { devDependencies: { vite: '^6.1.0', vitest: '^3.0.0', typescript: '^5.7.0' }, }; +/** + * Extract the balanced `resolve: { … }` body from a rendered/scaffolded Vite + * config. Locating the block keeps the dedupe assertions placement-aware: a + * bare `toContain` on the whole file would also pass if the guard drifted into + * an unrelated option (e.g. `build.dedupe`). + */ +function extractResolveBlock(src: string): string { + const key = src.indexOf('resolve: {'); + expect(key, 'resolve block missing from the rendered config').toBeGreaterThanOrEqual(0); + const open = src.indexOf('{', key); + let depth = 0; + for (let i = open; i < src.length; i += 1) { + if (src[i] === '{') depth += 1; + else if (src[i] === '}') { + depth -= 1; + if (depth === 0) return src.slice(open + 1, i); + } + } + throw new Error('unbalanced resolve block in the rendered config'); +} + +/** + * Extract the balanced `plugins: [ … ]` body from a rendered/scaffolded Vite + * config. Placement-aware like {@link extractResolveBlock}: a bare `toContain` + * on the whole file would also pass if the registration drifted into an + * unrelated array (e.g. a plugin's own `plugins` option). + */ +function extractPluginsBlock(src: string): string { + const key = src.indexOf('plugins: ['); + expect(key, 'plugins array missing from the rendered config').toBeGreaterThanOrEqual(0); + const open = src.indexOf('[', key); + let depth = 0; + for (let i = open; i < src.length; i += 1) { + if (src[i] === '[') depth += 1; + else if (src[i] === ']') { + depth -= 1; + if (depth === 0) return src.slice(open + 1, i); + } + } + throw new Error('unbalanced plugins array in the rendered config'); +} + let tmpRoot: string; beforeEach(() => { @@ -292,7 +336,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'); @@ -305,6 +348,47 @@ describe('renderPackageJson', () => { expect(pkg.dependencies).toEqual(MANIFEST.dependencies); 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 + // (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', () => { @@ -317,14 +401,69 @@ describe('renderViteConfig', () => { expect(src).toContain("name: 'unit'"); }); + it('emits the single-copy resolve.dedupe guard inside the resolve block', () => { + const src = renderViteConfig('golf', DEFAULT_CORE_REL); + const block = extractResolveBlock(src); + + // Shared runtime deps must resolve to exactly one copy: a second `phaser` + // (e.g. from an `npm install` inside the core submodule) becomes a distinct + // module id, so Node-env unit tests stop mocking it and real Phaser is + // evaluated on collection (`window is not defined`, MS-0MVK3SF54006Y6A6). + // Mirrors `resolve.dedupe` in core/vite.config.ts. + expect(block).toContain("dedupe: ['phaser', 'tone']"); + // Placement contract: the guard sits after the alias entry, in the same + // `resolve` block. + expect(block.indexOf("dedupe: ['phaser', 'tone']")).toBeGreaterThan( + block.indexOf('resolveCoreAliases(coreRoot)'), + ); + // The list stays exactly the two shared runtime deps. + const dedupeLine = block + .split('\n') + .find((line) => line.trimStart().startsWith('dedupe:')); + expect(dedupeLine?.trim()).toBe("dedupe: ['phaser', 'tone'],"); + // The rationale comment explains why, so a future maintainer does not drop + // it as noise. + expect(block.toLowerCase()).toContain('one copy'); + expect(block).toContain('core/vite.config.ts'); + }); + + it('registers the transcript-persist middleware for dev serve only', () => { + const src = renderViteConfig('main-street', DEFAULT_CORE_REL); + const plugins = extractPluginsBlock(src); + + // A generated game repo must wire the POST /api/transcripts middleware. + // Without it every game-over 404s in a normal dev session + // (`[TranscriptStore] Disk persistence POST returned HTTP 404`, + // MS-0MVREB31W004F48D). + expect(src).toContain( + "import { transcriptPersistPlugin, DEV_WATCH_IGNORE_PATTERNS } from '../Tableau-Card-Engine/scripts/vite-transcript-plugin'", + ); + // Dev-serve only, and never during a Vitest run: the plugin writes to disk, + // so registering it under test would add file-system side effects (see + // docs/DEVELOPER.md § Transcript Persistence). Mirrors core/vite.config.ts. + expect(plugins).toContain( + "...(command === 'serve' && !process.env.VITEST ? [transcriptPersistPlugin()] : []),", + ); + // Placement contract: after the game-discovery plugin, so discovery still + // runs first (the order core/vite.config.ts uses). + expect(plugins.indexOf('transcriptPersistPlugin()')).toBeGreaterThan( + plugins.indexOf('gameDiscoveryPlugin('), + ); + // The factory must receive `command`, otherwise `command === 'serve'` can + // never be true and the middleware is silently never registered. + expect(src).toContain('export default defineConfig(({ command, mode }) => ({'); + }); + it('excludes .worklog and dev-output trees from the Vite file watcher (ENOSPC guard)', () => { const src = renderViteConfig('golf', DEFAULT_CORE_REL); // The generated config must wire the shared ignore list into the watcher so // a recursive watch does not follow .worklog/worktrees symlinks into other // project checkouts and exhaust fs.inotify.max_user_watches // (CG-0MV5SEHHG000ZKUA). - expect(src).toContain( - "import { DEV_WATCH_IGNORE_PATTERNS } from '../Tableau-Card-Engine/scripts/vite-transcript-plugin'", + // Named import (order-tolerant: the transcript-persist plugin is imported + // from the same module since MS-0MVREB31W004F48D). + expect(src).toMatch( + /import \{[^}]*DEV_WATCH_IGNORE_PATTERNS[^}]*\} from '\.\.\/Tableau-Card-Engine\/scripts\/vite-transcript-plugin'/, ); expect(src).toContain('ignored: [...DEV_WATCH_IGNORE_PATTERNS]'); // ...and the shared list must actually cover .worklog (the failure mode). @@ -375,6 +514,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'], @@ -428,6 +569,33 @@ describe('findGameInPreset', () => { // ── End-to-end scaffold over a fixture ──────────────────────────────────── describe('scaffoldGameRepo', () => { + it('writes a vite.config.ts carrying the single-copy dedupe guard', () => { + const { core, gameRepo } = makeLayout('golf'); + scaffoldGameRepo({ game: 'golf', gameRepoRoot: gameRepo, coreRoot: core }); + + // Assert the real on-disk write: a re-scaffold must not drop the guard the + // checked-in generated configs (e.g. tce-main-street) rely on. + const written = fs.readFileSync(path.join(gameRepo, 'vite.config.ts'), 'utf-8'); + const block = extractResolveBlock(written); + expect(block).toContain("dedupe: ['phaser', 'tone']"); + expect(block.indexOf("dedupe: ['phaser', 'tone']")).toBeGreaterThan( + block.indexOf('resolveCoreAliases(coreRoot)'), + ); + }); + + it('writes a vite.config.ts carrying the transcript-persist registration', () => { + const { core, gameRepo } = makeLayout('golf'); + scaffoldGameRepo({ game: 'golf', gameRepoRoot: gameRepo, coreRoot: core }); + + // Assert the real on-disk write: a re-scaffold must not drop the + // registration that keeps a dev session's console free of + // `/api/transcripts` 404s (MS-0MVREB31W004F48D). + const written = fs.readFileSync(path.join(gameRepo, 'vite.config.ts'), 'utf-8'); + expect(extractPluginsBlock(written)).toContain( + "command === 'serve' && !process.env.VITEST ? [transcriptPersistPlugin()] : []", + ); + }); + it('writes the root configs, preset and only the core link', () => { const { core, gameRepo } = makeLayout('golf'); const result = scaffoldGameRepo({ game: 'golf', gameRepoRoot: gameRepo, coreRoot: core }); @@ -476,6 +644,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); diff --git a/tests/scripts/vitest-run-with-retry.test.ts b/tests/scripts/vitest-run-with-retry.test.ts index bc51e5388..738cfbb46 100644 --- a/tests/scripts/vitest-run-with-retry.test.ts +++ b/tests/scripts/vitest-run-with-retry.test.ts @@ -2,7 +2,7 @@ * Unit tests for the vitest transient-failure retry mitigation * (scripts/vitest-run-with-retry.ts). * - * Vitest has three distinct contention-induced failure signatures: + * Vitest has four distinct contention-induced failure signatures: * * 1. Worker RPC timeout — Vitest's worker RPC layer uses birpc with a * hard-coded 60s timeout (DEFAULT_TIMEOUT = 6e4 in @@ -16,6 +16,13 @@ * (e.g. a requestAnimationFrame loop starved of frames); the run never * exits. The runner aborts it with a bounded wall-clock timeout and * exit 124. See CG-0MT08R2QR0070F3N. + * 4. Module fetch failure — the Vite dev server fails to serve a test + * module under load, so the browser cannot dynamically import it + * (`Failed to fetch dynamically imported module`); Vitest reports the + * file as a failed suite. Retry-only recovery: a genuine + * module-resolution regression is deterministic, so a both-attempts + * module-fetch run keeps the retry's own failing status, never + * `accepted-transient`. See CG-0MVNDK56G007C1U7. * * These tests verify the masking guard: retry is ONLY allowed when the * run reports all files passed AND the sole error is a transient signature @@ -29,16 +36,38 @@ import path from 'node:path'; import { WORKER_TIMEOUT_SIGNATURE, BROWSER_DROP_SIGNATURE, + MODULE_FETCH_SIGNATURE, + TRANSIENT_SIGNATURES, + ACCEPTABLE_TRANSIENT_SIGNATURES, shouldRetryOnce, + shouldAcceptBothAttempts, + failuresAreTransientOnly, + hasTransientSignature, runWithRetry, runSummary, runVitestSync, parseTimeoutArgs, + parseRunnerArgs, hangTimeoutMessage, + listHangMessage, HANG_TIMEOUT_EXIT_CODE, DEFAULT_RUN_TIMEOUT_MS, + LIST_TIMEOUT_MS, resolveVitestBin, + deriveExpectedFiles, + parseExpectedSet, + normaliseFiles, + looksLikeUnknownStaticParse, + evaluateCoverage, + coverageDiagnostic, + coverageSuffix, + coverageLabelFromReport, + ExecutedSetCapture, + defaultReadExecutedFiles, + parseExecutedFromOutput, + PreflightError, type VitestRunner, + type VitestListRunner, } from '../../scripts/vitest-run-with-retry'; // ── Output fixtures (match real vitest reporter output) ───── @@ -117,6 +146,63 @@ const TRANSIENT_OUTPUT = `${PASS_OUTPUT}\nError: ${WORKER_TIMEOUT_SIGNATURE}`; /** A passing run whose exit was poisoned by the transient browser WebSocket drop. */ const BROWSER_DROP_OUTPUT = `${PASS_OUTPUT}\nError: ${BROWSER_DROP_SIGNATURE}. Was the page closed unexpectedly?`; +/** + * The recorded module-fetch failing-browser-run shape for CG-0MVNDK56G007C1U7, + * verbatim from the incident cache (`.git/test-cache/513dd2a255a543ca721aef90e207f1c5/stdout.txt`, commit + * f9762665, load avg ~8): the Vite dev server refused to serve the test modules + * under load, so Chromium could not dynamically import them. Two of the 64 + * identical `Failed Suites` blocks are pinned below; divider lines and all are + * verbatim. The `Caused by:` line belongs to the SAME error block as its + * `Error:` line, so the block is attributable to `MODULE_FETCH_SIGNATURE`. The + * same commit's browser stage passed 37/37 files on a quiet machine — the + * failure is contention, not a code defect. + */ +const INCIDENT_MODULE_FETCH_BLOCKS = [ + ' FAIL |browser (chromium)| tests/smoke.phaser4.browser.test.ts [ tests/smoke.phaser4.browser.test.ts ]', + 'Error: Failed to import test file /home/rgardler/projects/Tableau-Card-Engine/.worklog/worktrees/wl-CG-0MUI9K4F8009DYJL-add-an-alpha-banner-and-version-number-t/tests/smoke.phaser4.browser.test.ts', + 'Caused by: TypeError: Failed to fetch dynamically imported module: http://localhost:63315/home/rgardler/projects/Tableau-Card-Engine/.worklog/worktrees/wl-CG-0MUI9K4F8009DYJL-add-an-alpha-banner-and-version-number-t/tests/smoke.phaser4.browser.test.ts?import&browserv=1790503874887', + ' FAIL |browser (chromium)| tests/core-engine/PhaserEventBridge.browser.test.ts [ tests/core-engine/PhaserEventBridge.browser.test.ts ]', + 'Error: Failed to import test file /home/rgardler/projects/Tableau-Card-Engine/.worklog/worktrees/wl-CG-0MUI9K4F8009DYJL-add-an-alpha-banner-and-version-number-t/tests/core-engine/PhaserEventBridge.browser.test.ts', + 'Caused by: TypeError: Failed to fetch dynamically imported module: http://localhost:63315/home/rgardler/projects/Tableau-Card-Engine/.worklog/worktrees/wl-CG-0MUI9K4F8009DYJL-add-an-alpha-banner-and-version-number-t/tests/core-engine/PhaserEventBridge.browser.test.ts?import&browserv=1790503874704', +].join('\n'); + +/** + * The full attributable module-fetch run: verbatim incident blocks plus the + * real summary (64 failed suites / 450 tests passed — every failure is a + * failed dynamic import, nothing else). + */ +const MODULE_FETCH_OUTPUT = [ + '\u2500\u2500\u2500\u2500\u2500\u2500\u2500 Failed Suites 64 \u2500\u2500\u2500\u2500\u2500\u2500\u2500', + '', + INCIDENT_MODULE_FETCH_BLOCKS, + '', + '', + ' Test Files 64 failed | 56 passed (120)', + ' Tests 450 passed (450)', + '', + '[vitest-runner] attempts=1 status=1 outcome=failed args="--project browser"', +].join('\n'); + +/** + * A genuine assertion failure reported alongside the module-fetch signature. + * Vitest can collect some files and fail on an assertion in one of them while + * other files failed to import; the assertion block cannot be attributed to + * the transient, so the masking guard must refuse a retry. + */ +const GENUINE_ASSERTION_AND_MODULE_FETCH_OUTPUT = [ + ' FAIL |browser (chromium)| tests/foo.browser.test.ts [ tests/foo.browser.test.ts ]', + `Error: Failed to import test file /repo/tests/foo.browser.test.ts`, + `Caused by: TypeError: ${MODULE_FETCH_SIGNATURE}: http://localhost:63315/tests/foo.browser.test.ts?import&browserv=1`, + '', + '\u2500\u2500\u2500\u2500\u2500\u2500\u2500 Failed Tests 1 \u2500\u2500\u2500\u2500\u2500\u2500\u2500', + '', + ' FAIL |browser (chromium)| tests/bar.browser.test.ts > does the thing', + 'AssertionError: expected 1 to be 2 // Object.is equality', + '', + ' Test Files 2 failed | 56 passed (58)', + ' Tests 1 failed | 450 passed (451)', +].join('\n'); + /** A run with genuine test failures. */ const FAIL_OUTPUT = [ ' Test Files 1 failed | 245 passed (246)', @@ -189,6 +275,82 @@ describe('shouldRetryOnce (masking-guarded transient detection)', () => { }); }); +// ── Module fetch signature (CG-0MVNDK56G007C1U7) ───────────── +// +// The Vite dev server can fail to serve a test module under load; Chromium +// then cannot dynamically import it and Vitest reports the file as a failed +// suite. It is retryable under the same every-error-block attribution rule as +// the two original signatures, but its recovery is RETRY-ONLY: a genuine +// module-resolution regression emits the identical text deterministically, so +// a both-attempts module-fetch run is never `accepted-transient`. + +describe('module fetch signature (retry-only transient)', () => { + it('is a named exported constant for the Chromium module-fetch text', () => { + expect(MODULE_FETCH_SIGNATURE).toBe('Failed to fetch dynamically imported module'); + }); + + it('is retryable but NOT acceptable on both attempts', () => { + expect(TRANSIENT_SIGNATURES).toContain(MODULE_FETCH_SIGNATURE); + expect(ACCEPTABLE_TRANSIENT_SIGNATURES).not.toContain(MODULE_FETCH_SIGNATURE); + // The two original signatures keep their both-attempts acceptance path. + expect(ACCEPTABLE_TRANSIENT_SIGNATURES).toEqual([ + WORKER_TIMEOUT_SIGNATURE, + BROWSER_DROP_SIGNATURE, + ]); + }); + + it('attributes the verbatim incident blocks to the module-fetch signature', () => { + // `Caused by:` is not an error-line start, so the `Error:` + `Caused by:` + // pair is ONE block — the incident shape is attributable as recorded. + expect(failuresAreTransientOnly(MODULE_FETCH_OUTPUT)).toBe(true); + expect(shouldRetryOnce(MODULE_FETCH_OUTPUT)).toBe(true); + expect(hasTransientSignature(MODULE_FETCH_OUTPUT)).toBe(true); + }); + + it('retries a module-fetch-only failing run', () => { + expect(shouldRetryOnce(MODULE_FETCH_OUTPUT)).toBe(true); + }); + + it('retries an all-passed run poisoned by a module fetch error', () => { + expect(shouldRetryOnce(`${PASS_OUTPUT}\nError: ${MODULE_FETCH_SIGNATURE}: http://x/y`)).toBe( + true, + ); + }); + + it('refuses a retry when a genuine assertion failure accompanies the module-fetch signature', () => { + expect(shouldRetryOnce(GENUINE_ASSERTION_AND_MODULE_FETCH_OUTPUT)).toBe(false); + expect(failuresAreTransientOnly(GENUINE_ASSERTION_AND_MODULE_FETCH_OUTPUT)).toBe(false); + }); + + it('refuses a retry for a failed-import block whose cause is not the module-fetch text', () => { + // A `Failed to import test file` block with a different cause (e.g. a + // syntax error) is a genuine defect, not the transient signature. + const syntaxError = [ + ' FAIL |browser (chromium)| tests/broken.browser.test.ts [ tests/broken.browser.test.ts ]', + 'Error: Failed to import test file /repo/tests/broken.browser.test.ts', + 'Caused by: SyntaxError: Unexpected token', + '', + ' Test Files 1 failed (1)', + ' Tests no tests', + ].join('\n'); + expect(shouldRetryOnce(syntaxError)).toBe(false); + }); + + it('refuses a retry when the module-fetch signature appears without a summary', () => { + expect(shouldRetryOnce(`Error: ${MODULE_FETCH_SIGNATURE}: http://x/y`)).toBe(false); + }); + + it('does not accept both attempts when both show the module-fetch signature', () => { + expect(shouldAcceptBothAttempts(MODULE_FETCH_OUTPUT)).toBe(false); + }); + + it('keeps the both-attempts acceptance path for the two original signatures', () => { + expect(shouldAcceptBothAttempts(TRANSIENT_OUTPUT)).toBe(true); + expect(shouldAcceptBothAttempts(BROWSER_DROP_OUTPUT)).toBe(true); + expect(shouldAcceptBothAttempts(INCIDENT_FAILED_FILE_OUTPUT)).toBe(true); + }); +}); + // ── runWithRetry ───────────────────────────────────────────── describe('runWithRetry (retry-once orchestration)', () => { @@ -331,8 +493,50 @@ describe('runWithRetry (retry-once orchestration)', () => { }); }); -// ── Final runner summary (attempt/outcome diagnostic) ──────── +// ── runWithRetry (module-fetch transient) ──────────────────── + +describe('runWithRetry (module-fetch transient, CG-0MVNDK56G007C1U7)', () => { + const warnSpy: (msg: string) => void = () => {}; + + it('retries a module-fetch-only failing run and returns the retry\'s status', async () => { + let calls = 0; + const runner: VitestRunner = () => { + calls += 1; + return calls === 1 + ? { status: 1, output: MODULE_FETCH_OUTPUT } + : { status: 0, output: PASS_OUTPUT }; + }; + expect(await runWithRetry(['--project', 'browser'], runner, warnSpy)).toBe(0); + expect(calls).toBe(2); + }); + + it('does NOT accept when both attempts show the module-fetch signature (retry-failed, retry status stands)', async () => { + const messages: string[] = []; + let calls = 0; + const runner: VitestRunner = () => { + calls += 1; + return { status: 1, output: MODULE_FETCH_OUTPUT }; + }; + expect(await runWithRetry(['--project', 'browser'], runner, (m) => messages.push(m))).toBe(1); + expect(calls).toBe(2); + const joined = messages.join('\n'); + expect(joined).toContain('outcome=retry-failed'); + expect(joined).not.toContain('outcome=accepted-transient'); + expect(joined).not.toContain('[transient-accepted]'); + }); + + it('does not retry a module-fetch signature alongside a genuine assertion failure', async () => { + let calls = 0; + const runner: VitestRunner = () => { + calls += 1; + return { status: 1, output: GENUINE_ASSERTION_AND_MODULE_FETCH_OUTPUT }; + }; + expect(await runWithRetry([], runner, warnSpy)).toBe(1); + expect(calls).toBe(1); + }); +}); +// ── Final runner summary (attempt/outcome diagnostic) ──────── describe('runSummary (final diagnostic line)', () => { it('formats the attempt count, status, outcome and forwarded args', () => { expect(runSummary(['--project', 'unit'], 2, 0, 'accepted-transient')).toBe( @@ -490,3 +694,660 @@ describe('resolveVitestBin (local bin resolution)', () => { expect(fs.existsSync(bin)).toBe(true); }); }); + +// ── Opt-in project-coverage gate (CG-0MVL21CVM003VK5H) ────── +// +// The runner previously decided acceptance purely from output text, so a run +// that silently dropped part of the suite could be accepted as green. The +// opt-in expectation makes any missing (or extra) executed file a genuine +// failure that neither retry nor `accepted-transient` can mask. + +const CWD = process.cwd(); + +/** The incident shape: transient signature + all-passed summary + a dropped file. */ +const INCIDENT_DROPPED_GROUP_OUTPUT = [ + `${PASS_OUTPUT}\nError: ${WORKER_TIMEOUT_SIGNATURE}`, +].join('\n'); + +describe('parseRunnerArgs (opt-in expectation flags)', () => { + it('defaults to no expectation when the flags are absent', () => { + expect(parseRunnerArgs(['--project', 'unit'])).toEqual({ + timeoutMs: DEFAULT_RUN_TIMEOUT_MS, + forwarded: ['--project', 'unit'], + expectedFiles: [], + expectProjectCoverage: false, + }); + }); + + it('parses a repeatable --expected-file <path> in both spellings', () => { + const parsed = parseRunnerArgs([ + '--expected-file', + 'tests/a.test.ts', + '--expected-file=tests/b.test.ts', + '--project', + 'unit', + ]); + expect(parsed.expectedFiles).toEqual(['tests/a.test.ts', 'tests/b.test.ts']); + expect(parsed.forwarded).toEqual(['--project', 'unit']); + expect(parsed.expectProjectCoverage).toBe(false); + }); + + it('parses --expect-project-coverage and keeps it out of the forwarded args', () => { + const parsed = parseRunnerArgs(['--expect-project-coverage', '--project', 'browser']); + expect(parsed.expectProjectCoverage).toBe(true); + expect(parsed.expectedFiles).toEqual([]); + expect(parsed.forwarded).toEqual(['--project', 'browser']); + }); + + it('combines --timeout-ms with the expectation flags', () => { + const parsed = parseRunnerArgs([ + '--expect-project-coverage', + '--timeout-ms=120000', + '--expected-file', + 'tests/a.test.ts', + ]); + expect(parsed.timeoutMs).toBe(120_000); + expect(parsed.expectProjectCoverage).toBe(true); + expect(parsed.expectedFiles).toEqual(['tests/a.test.ts']); + expect(parsed.forwarded).toEqual([]); + }); + + it('rejects a missing --expected-file value', () => { + expect(() => parseRunnerArgs(['--expected-file'])).toThrow(/Invalid --expected-file/); + expect(() => parseRunnerArgs(['--expected-file='])).toThrow(/Invalid --expected-file/); + }); + + it('still rejects an invalid --timeout-ms value', () => { + expect(() => parseRunnerArgs(['--timeout-ms', 'soon'])).toThrow(/Invalid --timeout-ms/); + }); + + it('parseTimeoutArgs remains a forwarding subset parser', () => { + expect(parseTimeoutArgs(['--expected-file', 'tests/a.test.ts', '--project', 'unit'])).toEqual({ + timeoutMs: DEFAULT_RUN_TIMEOUT_MS, + forwarded: ['--project', 'unit'], + }); + }); +}); + +describe('expected-set normalisation and parsing', () => { + it('normalises absolute and file:// paths to repo-relative POSIX', () => { + expect( + normaliseFiles( + [ + path.join(CWD, 'tests', 'b.test.ts'), + path.join(CWD, 'tests', 'a.test.ts'), + path.join(CWD, 'tests', 'a.test.ts'), + `file://${path.join(CWD, 'tests', 'c.test.ts')}`, + ], + CWD, + ), + ).toEqual(['tests/a.test.ts', 'tests/b.test.ts', 'tests/c.test.ts']); + }); + + it('normalises browser-mode http(s) names via the /@fs/ Vite escape', () => { + expect( + normaliseFiles( + [ + `http://localhost:5173/@fs${path.join(CWD, 'tests', 'a.test.ts')}`, + `https://example.test/@fs${path.join(CWD, 'tests', 'b.test.ts')}?browser=1`, + ], + CWD, + ), + ).toEqual(['tests/a.test.ts', 'tests/b.test.ts']); + }); + + it('normalises a relative path with a leading ./', () => { + expect(normaliseFiles(['./tests/a.test.ts'], CWD)).toEqual(['tests/a.test.ts']); + }); + + it('parses the Vitest 4.x vitest list array shape', () => { + const stdout = JSON.stringify([ + { file: path.join(CWD, 'tests/b.test.ts'), projectName: 'unit' }, + { file: path.join(CWD, 'tests/a.test.ts'), projectName: 'unit' }, + ]); + expect(parseExpectedSet(stdout, CWD)).toEqual(['tests/a.test.ts', 'tests/b.test.ts']); + }); + + it('parses plain string entries and the { testFiles } wrapper shape', () => { + expect(parseExpectedSet(JSON.stringify(['tests/a.test.ts']), CWD)).toEqual([ + 'tests/a.test.ts', + ]); + expect( + parseExpectedSet( + JSON.stringify({ testFiles: [{ file: path.join(CWD, 'tests/b.test.ts') }] }), + CWD, + ), + ).toEqual(['tests/b.test.ts']); + }); + + it('throws on unparsable JSON so callers can fail closed', () => { + expect(() => parseExpectedSet('not json')).toThrow(); + }); + + it('detects only plausible --staticParse rejections', () => { + expect(looksLikeUnknownStaticParse('CACError: Unknown option `--staticParse`')).toBe(true); + expect(looksLikeUnknownStaticParse('error: option --staticParse is invalid')).toBe(true); + expect(looksLikeUnknownStaticParse('Test Files 1 failed (1)')).toBe(false); + expect(looksLikeUnknownStaticParse('')).toBe(false); + }); +}); + +describe('deriveExpectedFiles (pre-flight vitest list)', () => { + const listOutput = JSON.stringify([ + { file: path.join(CWD, 'tests/b.test.ts'), projectName: 'unit' }, + { file: path.join(CWD, 'tests/a.test.ts'), projectName: 'unit' }, + ]); + + it('derives the normalised set from a successful collection', async () => { + const seen: string[][] = []; + const runList: VitestListRunner = (args) => { + seen.push(args); + return { status: 0, output: listOutput }; + }; + await expect(deriveExpectedFiles(['--project', 'unit'], runList, CWD)).resolves.toEqual([ + 'tests/a.test.ts', + 'tests/b.test.ts', + ]); + // Collection-only: --filesOnly --json --staticParse + forwarded filters, + // and no browser launch flags. + expect(seen[0]).toEqual(['--staticParse', '--filesOnly', '--json', '--project', 'unit']); + }); + + it('retries once without --staticParse when the consumer rejects the flag', async () => { + const seen: string[][] = []; + const runList: VitestListRunner = (args) => { + seen.push(args); + return seen.length === 1 + ? { status: 1, output: 'CACError: Unknown option `--staticParse`' } + : { status: 0, output: listOutput }; + }; + await expect(deriveExpectedFiles(['--project', 'unit'], runList, CWD)).resolves.toEqual([ + 'tests/a.test.ts', + 'tests/b.test.ts', + ]); + expect(seen).toHaveLength(2); + expect(seen[1]).not.toContain('--staticParse'); + }); + + it('fails closed on a non-zero collection that is not a --staticParse rejection', async () => { + const runList: VitestListRunner = () => ({ status: 1, output: 'boom: cannot load config' }); + await expect(deriveExpectedFiles([], runList, CWD)).rejects.toBeInstanceOf(PreflightError); + }); + + it('fails closed on a collection hang (exit 124)', async () => { + const runList: VitestListRunner = () => ({ + status: HANG_TIMEOUT_EXIT_CODE, + output: listHangMessage(120), + }); + await expect(deriveExpectedFiles([], runList, CWD)).rejects.toThrow(/exit 124/); + }); + + it('fails closed on an empty file set', async () => { + const runList: VitestListRunner = () => ({ status: 0, output: '[]' }); + await expect(deriveExpectedFiles([], runList, CWD)).rejects.toThrow(/empty file set/); + }); + + it('fails closed on unparsable collection output', async () => { + const runList: VitestListRunner = () => ({ status: 0, output: '<html>oops</html>' }); + await expect(deriveExpectedFiles([], runList, CWD)).rejects.toThrow(/unparsable JSON/); + }); + + it('bounds the collection with the list timeout', async () => { + const timeouts: number[] = []; + const runList: VitestListRunner = (_args, timeoutMs) => { + timeouts.push(timeoutMs); + return { status: 0, output: listOutput }; + }; + await deriveExpectedFiles([], runList, CWD); + expect(timeouts).toEqual([LIST_TIMEOUT_MS]); + }); +}); + +describe('evaluateCoverage / coverageDiagnostic', () => { + it('passes when the sets match (order and duplicates ignored)', () => { + const report = evaluateCoverage( + ['tests/a.test.ts', 'tests/b.test.ts'], + ['tests/b.test.ts', 'tests/a.test.ts', 'tests/a.test.ts'], + CWD, + ); + expect(report).toEqual({ + ok: true, + expected: 2, + ran: 2, + missing: [], + extra: [], + indeterminate: false, + }); + }); + + it('fails and names a missing file', () => { + const report = evaluateCoverage(['tests/a.test.ts', 'tests/b.test.ts'], ['tests/a.test.ts'], CWD); + expect(report.ok).toBe(false); + expect(report.missing).toEqual(['tests/b.test.ts']); + expect(report.extra).toEqual([]); + expect(report).toMatchObject({ expected: 2, ran: 1 }); + }); + + it('fails and names an extra file', () => { + const report = evaluateCoverage(['tests/a.test.ts'], ['tests/a.test.ts', 'tests/z.test.ts'], CWD); + expect(report.ok).toBe(false); + expect(report.extra).toEqual(['tests/z.test.ts']); + expect(report.missing).toEqual([]); + }); + + it('fails closed as indeterminate when the executed set is unreadable', () => { + const report = evaluateCoverage(['tests/a.test.ts'], null, CWD); + expect(report.ok).toBe(false); + expect(report.indeterminate).toBe(true); + expect(report.missing).toEqual(['tests/a.test.ts']); + expect(coverageDiagnostic(report)).toContain('indeterminate'); + }); + + it('diagnostic names every missing/extra file and states expected=N ran=M', () => { + const report = evaluateCoverage( + ['tests/a.test.ts', 'tests/b.test.ts'], + ['tests/a.test.ts', 'tests/z.test.ts'], + CWD, + ); + const diagnostic = coverageDiagnostic(report); + expect(diagnostic).toContain('expected=2 ran=2'); + expect(diagnostic).toContain('tests/b.test.ts'); + expect(diagnostic).toContain('tests/z.test.ts'); + expect(diagnostic).toContain('Missing files'); + expect(diagnostic).toContain('Extra files'); + }); + + it('records the expectation state for the summary line', () => { + expect(coverageSuffix({ kind: 'unset' })).toBe(' expected=unset'); + expect(coverageSuffix({ kind: 'set', expected: 3, ran: 2 })).toBe(' expected=3 ran=2'); + expect(coverageLabelFromReport(evaluateCoverage(['a.test.ts'], ['a.test.ts'], CWD))).toEqual({ + kind: 'set', + expected: 1, + ran: 1, + }); + expect(coverageLabelFromReport(evaluateCoverage(['a.test.ts'], null, CWD)).kind).toBe( + 'set-indeterminate', + ); + }); +}); + +describe('ExecutedSetCapture (attempt file-set capture)', () => { + function tmpDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), 'vitest-capture-')); + } + + it('appends the JSON reporter alongside the default reporter', () => { + const dir = tmpDir(); + const capture = new ExecutedSetCapture({ workDir: dir, cwd: CWD }); + const augmented = capture.augmentArgs(['--project', 'unit']); + expect(augmented[0]).toBe('--project'); + expect(augmented).toContain('--reporter=default'); + expect(augmented).toContain('--reporter=json'); + expect(augmented).toContain(`--outputFile.json=${capture.artefactPath}`); + expect(capture.artefactPath.startsWith(dir)).toBe(true); + capture.cleanup(); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('reads the executed set from the JSON artifact and cleans it up', () => { + const dir = tmpDir(); + const capture = new ExecutedSetCapture({ workDir: dir, cwd: CWD }); + fs.writeFileSync( + capture.artefactPath, + JSON.stringify({ + testResults: [ + { name: path.join(CWD, 'tests', 'b.test.ts'), status: 'passed' }, + { name: path.join(CWD, 'tests', 'a.test.ts'), status: 'failed' }, + ], + }), + ); + expect(capture.executedFiles({ status: 1, output: '' })).toEqual([ + 'tests/a.test.ts', + 'tests/b.test.ts', + ]); + capture.cleanup(); + expect(fs.existsSync(capture.artefactPath)).toBe(false); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('falls back to default-reporter per-file lines when the artifact is absent', () => { + const dir = tmpDir(); + const capture = new ExecutedSetCapture({ workDir: dir, cwd: CWD }); + const output = [ + ' ✓ |unit| tests/scripts/a.test.ts (18 tests) 55ms', + ' ❯ |unit| tests/scripts/b.test.ts (3 tests | 1 failed) 8ms', + ' Test Files 1 failed | 1 passed (2)', + ].join('\n'); + expect(capture.executedFiles({ status: 1, output })).toEqual([ + 'tests/scripts/a.test.ts', + 'tests/scripts/b.test.ts', + ]); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('returns null (indeterminable) when neither source yields a set', () => { + const dir = tmpDir(); + const capture = new ExecutedSetCapture({ workDir: dir, cwd: CWD }); + expect(capture.executedFiles({ status: 1, output: 'Test Files 1 passed (1)' })).toBeNull(); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('returns null for a hang regardless of any artifact', () => { + const dir = tmpDir(); + const capture = new ExecutedSetCapture({ workDir: dir, cwd: CWD }); + expect( + capture.executedFiles({ status: HANG_TIMEOUT_EXIT_CODE, output: '[hang-timeout]' }), + ).toBeNull(); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('parseExecutedFromOutput returns null when no per-file lines exist', () => { + expect(parseExecutedFromOutput(PASS_OUTPUT, CWD)).toBeNull(); + }); + + it('defaultReadExecutedFiles prefers the artifact over per-file lines', () => { + const dir = tmpDir(); + const artefact = path.join(dir, 'report.json'); + fs.writeFileSync(artefact, JSON.stringify({ testResults: [{ name: '/repo/tests/art.test.ts' }] })); + expect(defaultReadExecutedFiles(' ✓ |unit| tests/line.test.ts (1 test) 3ms', artefact, '/repo')).toEqual([ + 'tests/art.test.ts', + ]); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + // ── Coverage-gate evidence for the module-fetch incident ──── + // + // AC: a file that failed to dynamically import must still appear in the + // captured executed set, so the coverage gate cannot turn a retried-green + // run into a `[coverage-failed]` exit. Vitest's JSON reporter + // (`onTestRunEnd` in node_modules/vitest) iterates EVERY collected test + // module task and emits `{ name: file.filepath, status: 'failed', + // assertionResults: [] }` for a suite whose import failed — the file is + // known to the run, so it is captured. (The default-reporter fallback does + // NOT see such files: they are printed as `FAIL … [ file ]`, not + // `✓ |project| file (N tests)`, which is why the JSON artifact is the + // primary mechanism.) + + it('captures a failed-to-import suite from the JSON artifact (coverage gate is unaffected)', () => { + const dir = tmpDir(); + const capture = new ExecutedSetCapture({ workDir: dir, cwd: CWD }); + // The shape Vitest emits for a module that failed to dynamically import: + // the suite is present, failed, with no assertion results. + fs.writeFileSync( + capture.artefactPath, + JSON.stringify({ + testResults: [ + { + name: path.join(CWD, 'tests', 'smoke.phaser4.browser.test.ts'), + status: 'failed', + assertionResults: [], + message: `Failed to fetch dynamically imported module: http://localhost:63315/@fs${path.join( + CWD, + 'tests', + 'smoke.phaser4.browser.test.ts', + )}?import&browserv=1`, + }, + { + name: path.join(CWD, 'tests', 'passed.browser.test.ts'), + status: 'passed', + assertionResults: [{ status: 'passed', title: 'ok' }], + }, + ], + }), + ); + expect(capture.executedFiles({ status: 1, output: MODULE_FETCH_OUTPUT })).toEqual([ + 'tests/passed.browser.test.ts', + 'tests/smoke.phaser4.browser.test.ts', + ]); + capture.cleanup(); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('the default-reporter fallback cannot see a failed-to-import suite (why the artifact is primary)', () => { + // Documents the limitation the JSON artifact exists to close: the incident + // reports failed imports as `FAIL … [ file ]` headers, which the per-file + // line parser does not match. + expect(parseExecutedFromOutput(MODULE_FETCH_OUTPUT, CWD)).toBeNull(); + }); +}); + +describe('runWithRetry (opt-in coverage gate)', () => { + const warnSpy: (msg: string) => void = () => {}; + const expected = ['tests/a.test.ts', 'tests/b.test.ts']; + + /** An injected reader returning a fixed executed set for every attempt. */ + const readerFor = + (ran: string[] | null) => + (): string[] | null => + ran; + + it('parity: with no expectation, decisions and exit codes match the legacy runner', async () => { + const fixtures = [PASS_OUTPUT, TRANSIENT_OUTPUT, BROWSER_DROP_OUTPUT, FAIL_OUTPUT]; + for (const fixture of fixtures) { + const legacy = await runWithRetry([], () => ({ status: 1, output: fixture }), warnSpy); + const withEmptyOptions = await runWithRetry( + [], + () => ({ status: 1, output: fixture }), + warnSpy, + DEFAULT_RUN_TIMEOUT_MS, + {}, + ); + const withEmptyExpected = await runWithRetry( + [], + () => ({ status: 1, output: fixture }), + warnSpy, + DEFAULT_RUN_TIMEOUT_MS, + { expectedFiles: [] }, + ); + expect(withEmptyOptions).toBe(legacy); + expect(withEmptyExpected).toBe(legacy); + } + }); + + it('parity: expectation-free summaries record expected=unset', async () => { + const messages: string[] = []; + await runWithRetry([], () => ({ status: 0, output: PASS_OUTPUT }), (m) => messages.push(m)); + expect(messages.some((m) => m.includes('outcome=clean'))).toBe(true); + expect(messages.some((m) => m.includes('expected=unset'))).toBe(true); + }); + + it('AC5 incident shape: transient + all-passed summary + one missing file fails, names the file, never accepted', async () => { + const warnings: string[] = []; + let calls = 0; + const runner: VitestRunner = () => { + calls += 1; + return { status: 1, output: INCIDENT_DROPPED_GROUP_OUTPUT }; + }; + const status = await runWithRetry([], runner, (m) => warnings.push(m), DEFAULT_RUN_TIMEOUT_MS, { + expectedFiles: expected, + readExecutedFiles: readerFor(['tests/a.test.ts']), + }); + expect(status).not.toBe(0); + expect(calls).toBe(2); + const joined = warnings.join('\n'); + expect(joined).toContain('tests/b.test.ts'); + expect(joined).toContain('expected=2 ran=1'); + expect(joined).not.toContain('outcome=accepted-transient'); + expect(joined).toContain('outcome=coverage-failed'); + }); + + it('fails on an extra file (any set difference is a defect)', async () => { + const warnings: string[] = []; + const status = await runWithRetry([], () => ({ status: 0, output: PASS_OUTPUT }), (m) => warnings.push(m), DEFAULT_RUN_TIMEOUT_MS, { + expectedFiles: expected, + readExecutedFiles: readerFor(['tests/a.test.ts', 'tests/b.test.ts', 'tests/z.test.ts']), + }); + expect(status).toBe(1); + expect(warnings.join('\n')).toContain('tests/z.test.ts'); + expect(warnings.join('\n')).toContain('outcome=coverage-failed'); + }); + + it('fails closed when an expectation is supplied but the executed set is indeterminable', async () => { + const warnings: string[] = []; + const status = await runWithRetry([], () => ({ status: 0, output: PASS_OUTPUT }), (m) => warnings.push(m), DEFAULT_RUN_TIMEOUT_MS, { + expectedFiles: expected, + readExecutedFiles: readerFor(null), + }); + expect(status).toBe(1); + expect(warnings.join('\n')).toContain('indeterminate'); + expect(warnings.join('\n')).toContain('outcome=coverage-failed'); + }); + + it('never converts a coverage violation to green via the retry', async () => { + let calls = 0; + const runner: VitestRunner = () => { + calls += 1; + return calls === 1 + ? { status: 1, output: INCIDENT_DROPPED_GROUP_OUTPUT } + : { status: 0, output: PASS_OUTPUT }; + }; + const status = await runWithRetry([], runner, warnSpy, DEFAULT_RUN_TIMEOUT_MS, { + expectedFiles: expected, + readExecutedFiles: readerFor(['tests/a.test.ts']), + }); + expect(status).toBe(1); + expect(calls).toBe(2); + }); + + it('accepts a transient run when both attempts executed exactly the expected set', async () => { + const warnings: string[] = []; + const runner: VitestRunner = () => ({ status: 1, output: TRANSIENT_OUTPUT }); + const status = await runWithRetry([], runner, (m) => warnings.push(m), DEFAULT_RUN_TIMEOUT_MS, { + expectedFiles: expected, + readExecutedFiles: readerFor(expected), + }); + expect(status).toBe(0); + const joined = warnings.join('\n'); + expect(joined).toContain('outcome=accepted-transient'); + expect(joined).toContain('expected=2 ran=2'); + }); + + it('records expected=N ran=M in the summary of a clean covered run', async () => { const warnings: string[] = []; + const status = await runWithRetry([], () => ({ status: 0, output: PASS_OUTPUT }), (m) => warnings.push(m), DEFAULT_RUN_TIMEOUT_MS, { + expectedFiles: expected, + readExecutedFiles: readerFor(expected), + }); + expect(status).toBe(0); + expect(warnings.join('\n')).toContain('outcome=clean'); + expect(warnings.join('\n')).toContain('expected=2 ran=2'); + }); + + it('injects the JSON reporter args only when an expectation is active', async () => { + const seen: string[][] = []; + const runner: VitestRunner = (args) => { + seen.push(args); + return { status: 0, output: PASS_OUTPUT }; + }; + await runWithRetry(['--project', 'unit'], runner, warnSpy); + await runWithRetry(['--project', 'unit'], runner, warnSpy, DEFAULT_RUN_TIMEOUT_MS, { + expectedFiles: expected, + readExecutedFiles: readerFor(expected), + }); + expect(seen[0]).toEqual(['--project', 'unit']); + expect(seen[1]).toContain('--reporter=json'); + expect(seen[1]).toContain('--reporter=default'); + expect(seen[1].some((a) => a.startsWith('--outputFile.json='))).toBe(true); + // The forwarded filters survive alongside the injected capture flags. + expect(seen[1].slice(0, 2)).toEqual(['--project', 'unit']); + }); + + it('checks coverage on the second attempt before accepting it', async () => { + const warnings: string[] = []; + let calls = 0; + const runner: VitestRunner = () => { + calls += 1; + return calls === 1 + ? { status: 1, output: INCIDENT_DROPPED_GROUP_OUTPUT } + : { status: 1, output: TRANSIENT_OUTPUT }; + }; + const status = await runWithRetry([], runner, (m) => warnings.push(m), DEFAULT_RUN_TIMEOUT_MS, { + expectedFiles: expected, + readExecutedFiles: readerFor(['tests/a.test.ts']), + }); + expect(status).toBe(1); + expect(calls).toBe(2); + expect(warnings.join('\n')).toContain('outcome=coverage-failed'); + expect(warnings.join('\n')).not.toContain('outcome=accepted-transient'); + }); + + it('hang precedence: a hang returns 124 and is never reported as a coverage failure', async () => { + const warnings: string[] = []; + const status = await runWithRetry( + [], + () => ({ status: HANG_TIMEOUT_EXIT_CODE, output: hangTimeoutMessage(60) }), + (m) => warnings.push(m), + DEFAULT_RUN_TIMEOUT_MS, + { expectedFiles: expected, readExecutedFiles: readerFor(['tests/a.test.ts']) }, + ); + expect(status).toBe(HANG_TIMEOUT_EXIT_CODE); + const joined = warnings.join('\n'); + expect(joined).toContain('outcome=hang'); + expect(joined).not.toContain('coverage-failed'); + }); +}); + +// ── Coverage gate × module-fetch transient (CG-0MVNDK56G007C1U7) ── +// +// A module-fetch failure is retry-only recovery, so the coverage gate must +// not turn a retried-green run into a `[coverage-failed]` exit: the failed +// import is retried, and a clean retry whose executed set matches the +// expectation is green. + +describe('runWithRetry (coverage gate × module-fetch)', () => { + const expected = ['tests/a.test.ts', 'tests/b.test.ts']; + const readerFor = + (ran: string[] | null) => + (): string[] | null => + ran; + + it('retries a module-fetch run whose executed set matches, then returns the clean retry', async () => { + const warnings: string[] = []; + let calls = 0; + const runner: VitestRunner = () => { + calls += 1; + return calls === 1 + ? { status: 1, output: MODULE_FETCH_OUTPUT } + : { status: 0, output: PASS_OUTPUT }; + }; + const status = await runWithRetry([], runner, (m) => warnings.push(m), DEFAULT_RUN_TIMEOUT_MS, { + expectedFiles: expected, + readExecutedFiles: readerFor(expected), + }); + expect(status).toBe(0); + expect(calls).toBe(2); + const joined = warnings.join('\n'); + expect(joined).toContain('outcome=retry-clean'); + expect(joined).not.toContain('coverage-failed'); + }); + + it('still retries when the module-fetch attempt dropped a file from the executed set (transient excuse)', async () => { + const warnings: string[] = []; + let calls = 0; + const runner: VitestRunner = () => { + calls += 1; + return calls === 1 + ? { status: 1, output: MODULE_FETCH_OUTPUT } + : { status: 0, output: PASS_OUTPUT }; + }; + const status = await runWithRetry( + [], + runner, + (m) => warnings.push(m), + DEFAULT_RUN_TIMEOUT_MS, + { + expectedFiles: expected, + // First attempt could not report the full set (the import failed), the + // retry executed exactly the expected set. + readExecutedFiles: (() => { + let attempt = 0; + return () => (attempt++ === 0 ? ['tests/a.test.ts'] : expected); + })(), + }, + ); + expect(status).toBe(0); + expect(calls).toBe(2); + const joined = warnings.join('\n'); + expect(joined).toContain('outcome=retry-clean'); + expect(joined).not.toContain('outcome=coverage-failed'); + }); +}); diff --git a/tests/story-line/compile.test.ts b/tests/story-line/compile.test.ts new file mode 100644 index 000000000..9a1be88ed --- /dev/null +++ b/tests/story-line/compile.test.ts @@ -0,0 +1,474 @@ +/** + * 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']); + }); +}); + +// ── 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', () => { + 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/content/adapters.test.ts b/tests/story-line/content/adapters.test.ts new file mode 100644 index 000000000..0c4061aa8 --- /dev/null +++ b/tests/story-line/content/adapters.test.ts @@ -0,0 +1,141 @@ +/** + * Content adapter seam tests. + * + * The seam's value is proven by two shapes, not one: the shipped Gym adapter + * and a deliberately different fixture adapter. These tests assert the parts + * of the contract the toolchain depends on — the default adapter/registry, the + * mapping helpers, the optional base-id rule and the adapter-provided impact + * description — for both shapes. + * + * @module + */ +import { describe, expect, it } from 'vitest'; + +import { + CONTENT_ADAPTERS, + DEFAULT_CONTENT_ADAPTER, + GYM_CONTENT_ADAPTER, + getContentAdapter, + resolveContentAdapter, +} from '../../../src/story-line/content/adapters'; +import type { ContentAdapter } from '../../../src/story-line/content/types'; +import { + baseIdOf, + editableColumnsOf, + mappedFieldColumns, +} from '../../../src/story-line/content/types'; +import { TAVERN_CONTENT_ADAPTER } from './fixture-adapter'; +import { GYM_CONTENT_PATH, readFixture, readText } from './test-support'; +import { cardsFromCsv, parseCsv } from '../../../src/story-line/content/csv'; + +describe('shipped adapters', () => { + it('ships the Gym content as the default adapter', () => { + expect(DEFAULT_CONTENT_ADAPTER).toBe(GYM_CONTENT_ADAPTER); + expect(GYM_CONTENT_ADAPTER.gameId).toBe('gym'); + expect(CONTENT_ADAPTERS.gym).toBe(GYM_CONTENT_ADAPTER); + expect(getContentAdapter('gym')).toBe(GYM_CONTENT_ADAPTER); + expect(getContentAdapter('nope')).toBeUndefined(); + expect(resolveContentAdapter()).toBe(GYM_CONTENT_ADAPTER); + expect(resolveContentAdapter('gym')).toBe(GYM_CONTENT_ADAPTER); + expect(resolveContentAdapter('nope')).toBeUndefined(); + }); + + it('maps every declared Gym column onto a story-line field', () => { + for (const column of GYM_CONTENT_ADAPTER.columns) { + const mapped = Object.values(GYM_CONTENT_ADAPTER.fields).includes(column); + const editable = GYM_CONTENT_ADAPTER.editableColumns?.includes(column) ?? false; + expect(mapped || editable).toBe(true); + } + expect(GYM_CONTENT_ADAPTER.columns).toEqual( + expect.arrayContaining([ + 'id', + 'name', + 'effect', + 'options', + 'hasChoices', + 'acceptNextCardId', + 'rejectNextCardId', + 'storylineId', + 'storylineTitle', + ]), + ); + }); + + it('describes the Gym impact from its game columns', () => { + const [start] = cardsFromCsv(parseCsv(readText(GYM_CONTENT_PATH)), GYM_CONTENT_ADAPTER); + expect(GYM_CONTENT_ADAPTER.describeImpact?.(start)).toBe('−50 coins'); + + const [end] = cardsFromCsv(parseCsv(readText(GYM_CONTENT_PATH)), GYM_CONTENT_ADAPTER).slice(-1); + expect(GYM_CONTENT_ADAPTER.describeImpact?.(end)).toBe('+60 reputation'); + }); +}); + +describe('adapter seam with two differently shaped adapters', () => { + it('projects two different column layouts onto the same generic model', () => { + const gymCards = cardsFromCsv(parseCsv(readText(GYM_CONTENT_PATH)), GYM_CONTENT_ADAPTER); + const tavernCards = cardsFromCsv( + parseCsv(readFixture('tavern-storylines.csv')), + TAVERN_CONTENT_ADAPTER, + ); + + // Different files, different columns, identical generic keys. + expect(TAVERN_CONTENT_ADAPTER.columns).not.toEqual(GYM_CONTENT_ADAPTER.columns); + const keys = (card: object): string[] => Object.keys(card).sort(); + expect(keys(gymCards[0])).toEqual(keys(tavernCards[0])); + expect(keys(gymCards[0])).toEqual([ + 'acceptNextCardId', + 'effect', + 'fields', + 'hasChoices', + 'id', + 'name', + 'options', + 'rejectNextCardId', + 'storylineId', + 'storylineTitle', + ]); + + // Each adapter's own game columns stay in `fields`. + expect(Object.keys(gymCards[0].fields)).toContain('coinDelta'); + expect(tavernCards[0].fields).toEqual({ payoff: '2' }); + }); + + it('reports mapped fields and editable columns per adapter shape', () => { + expect(mappedFieldColumns(TAVERN_CONTENT_ADAPTER)).toEqual([ + ['id', 'card_key'], + ['name', 'card_name'], + ['effect', 'flavour'], + ['options', 'choices'], + ['storylineId', 'arc'], + ['storylineTitle', 'arc_name'], + ]); + + expect(editableColumnsOf(TAVERN_CONTENT_ADAPTER)).toEqual([ + 'card_key', + 'card_name', + 'flavour', + 'choices', + 'arc', + 'arc_name', + 'payoff', + ]); + expect(editableColumnsOf(GYM_CONTENT_ADAPTER)).toContain('coinDelta'); + }); + + it('treats the base-id rule as optional (identity by default)', () => { + expect(baseIdOf(GYM_CONTENT_ADAPTER, 'evt-x-3')).toBe('evt-x-3'); + const serialAdapter: ContentAdapter = { + ...TAVERN_CONTENT_ADAPTER, + baseId: (id) => id.replace(/-\d+$/, ''), + }; + expect(baseIdOf(serialAdapter, 'evt-x-3')).toBe('evt-x'); + }); + + it('leaves the impact description empty when the adapter supplies none', () => { + const noImpact: ContentAdapter = { + ...TAVERN_CONTENT_ADAPTER, + describeImpact: undefined, + }; + expect(noImpact.describeImpact).toBeUndefined(); + }); +}); diff --git a/tests/story-line/content/author.test.ts b/tests/story-line/content/author.test.ts new file mode 100644 index 000000000..fee9344ad --- /dev/null +++ b/tests/story-line/content/author.test.ts @@ -0,0 +1,379 @@ +/** + * Transactional authoring tests. + * + * The author API is the single write path for story-line content, so these + * tests assert the transaction rather than just the happy path: an invalid + * edit writes nothing, a dry run leaves the file byte-identical, a valid edit + * lands atomically, and only the supplied fields change. + * + * @module + */ +import { copyFileSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { GYM_CONTENT_ADAPTER } from '../../../src/story-line/content/adapters'; +import { + addContentCard, + applyContentEditToFile, + hasContentCard, + readContentCards, + updateContentCard, +} from '../../../src/story-line/content/author'; +import { validateContentCsv } from '../../../src/story-line/content/validate'; +import { runStorylineAuthorCli } from '../../../scripts/storyline-author'; +import { TAVERN_CONTENT_ADAPTER } from './fixture-adapter'; +import { + GYM_CONTENT_PATH, + captureOutput, + fixturePath, + makeTempDir, + readFixture, + readText, + type TempDir, +} from './test-support'; + +const tavernCsv = readFixture('tavern-storylines.csv'); + +describe('addContentCard', () => { + it('appends a standalone card without rewriting existing bytes', () => { + const result = addContentCard(tavernCsv, TAVERN_CONTENT_ADAPTER, { + id: 'tav-extra', + name: 'An Extra Card', + effect: 'Nothing much happens.', + fields: { payoff: '4' }, + }); + expect(result.ok).toBe(true); + expect(result.errors).toEqual([]); + expect(result.csv.startsWith(tavernCsv)).toBe(true); + + const cards = readContentCards(result.csv, TAVERN_CONTENT_ADAPTER); + expect(cards.map((entry) => entry.id)).toContain('tav-extra'); + expect(cards[cards.length - 1]).toMatchObject({ + id: 'tav-extra', + name: 'An Extra Card', + fields: { payoff: '4' }, + }); + expect(validateContentCsv(result.csv, TAVERN_CONTENT_ADAPTER).valid).toBe(true); + }); + + it('refuses a new unlinked card that would be unreachable in its story-line', () => { + const result = addContentCard(tavernCsv, TAVERN_CONTENT_ADAPTER, { + id: 'tav-orphan', + name: 'An Orphan', + storylineId: 'tav', + }); + expect(result.ok).toBe(false); + expect(result.csv).toBe(tavernCsv); + expect(result.errors.join(' ')).toContain('unreachable'); + }); + + it('refuses a duplicate id', () => { + const result = addContentCard(tavernCsv, TAVERN_CONTENT_ADAPTER, { + id: 'tav-open', + name: 'Another Open', + }); + expect(result.ok).toBe(false); + expect(result.errors.join(' ')).toContain('duplicate id'); + expect(result.csv).toBe(tavernCsv); + }); + + it('refuses a missing id or name', () => { + expect( + addContentCard(tavernCsv, TAVERN_CONTENT_ADAPTER, { id: '', name: 'X' }).errors, + ).toContain('add-card: --id is required'); + expect( + addContentCard(tavernCsv, TAVERN_CONTENT_ADAPTER, { id: 'tav-x', name: '' }).errors, + ).toContain('add-card: --name is required'); + }); + + it('refuses unknown or mapped columns passed as game fields', () => { + const unknown = addContentCard(tavernCsv, TAVERN_CONTENT_ADAPTER, { + id: 'tav-x', + name: 'X', + fields: { nope: '1' }, + }); + expect(unknown.ok).toBe(false); + expect(unknown.errors.join(' ')).toContain('unknown content column "nope"'); + + const mapped = addContentCard(tavernCsv, TAVERN_CONTENT_ADAPTER, { + id: 'tav-x', + name: 'X', + fields: { choices: 'Go|tav-inn' }, + }); + expect(mapped.ok).toBe(false); + expect(mapped.errors.join(' ')).toContain('mapped story-line column'); + }); + + it('refuses content that fails validation (dangling successor)', () => { + const result = addContentCard(tavernCsv, TAVERN_CONTENT_ADAPTER, { + id: 'tav-x', + name: 'X', + options: 'Go|tav-nowhere', + }); + expect(result.ok).toBe(false); + expect(result.csv).toBe(tavernCsv); + expect(result.errors.join(' ')).toContain('tav-nowhere'); + }); +}); + +describe('updateContentCard', () => { + it('changes only the supplied fields and leaves the content valid', () => { + const result = updateContentCard(tavernCsv, TAVERN_CONTENT_ADAPTER, { + id: 'tav-tale', + name: 'A Longer Tale', + fields: { payoff: '9' }, + }); + expect(result.ok).toBe(true); + + const cards = readContentCards(result.csv, TAVERN_CONTENT_ADAPTER); + const tale = cards.find((entry) => entry.id === 'tav-tale'); + expect(tale).toMatchObject({ name: 'A Longer Tale', fields: { payoff: '9' } }); + expect(tale?.effect).toBe('The stranger speaks of the long road south.'); + expect(validateContentCsv(result.csv, TAVERN_CONTENT_ADAPTER).valid).toBe(true); + }); + + it('links a card into a story-line in a second edit', () => { + const added = addContentCard(tavernCsv, TAVERN_CONTENT_ADAPTER, { + id: 'tav-extra', + name: 'An Extra Card', + }); + expect(added.ok).toBe(true); + + const linked = updateContentCard(added.csv, TAVERN_CONTENT_ADAPTER, { + id: 'tav-tale', + options: 'Visit|tav-extra', + }); + expect(linked.ok).toBe(true); + const tale = readContentCards(linked.csv, TAVERN_CONTENT_ADAPTER).find( + (entry) => entry.id === 'tav-tale', + ); + expect(tale?.options).toBe('Visit|tav-extra'); + }); + + it('clears a nullable field with an explicit null', () => { + const result = updateContentCard(tavernCsv, TAVERN_CONTENT_ADAPTER, { + id: 'tav-tale', + storylineId: null, + }); + expect(result.ok).toBe(true); + const tale = readContentCards(result.csv, TAVERN_CONTENT_ADAPTER).find( + (entry) => entry.id === 'tav-tale', + ); + expect(tale?.storylineId).toBeNull(); + }); + + it('refuses an unknown card id and an edit that breaks validation', () => { + const missing = updateContentCard(tavernCsv, TAVERN_CONTENT_ADAPTER, { + id: 'tav-nope', + name: 'Nope', + }); + expect(missing.ok).toBe(false); + expect(missing.csv).toBe(tavernCsv); + + const dangling = updateContentCard(tavernCsv, TAVERN_CONTENT_ADAPTER, { + id: 'tav-open', + options: 'Admit|tav-gone', + }); + expect(dangling.ok).toBe(false); + expect(dangling.errors.join(' ')).toContain('tav-gone'); + expect(dangling.csv).toBe(tavernCsv); + }); + + it('marks a card as a choice when a legacy link is set', () => { + const gymCsv = readText(GYM_CONTENT_PATH); + const result = updateContentCard(gymCsv, GYM_CONTENT_ADAPTER, { + id: 'gym-story-end', + acceptNextCardId: 'gym-story-start', + }); + expect(result.ok).toBe(true); + const end = readContentCards(result.csv, GYM_CONTENT_ADAPTER).find( + (entry) => entry.id === 'gym-story-end', + ); + expect(end).toMatchObject({ hasChoices: true, acceptNextCardId: 'gym-story-start' }); + }); +}); + +describe('applyContentEditToFile', () => { + let temp: TempDir; + let contentPath: string; + + beforeEach(() => { + temp = makeTempDir(); + contentPath = join(temp.path, 'tavern.csv'); + copyFileSync(fixturePath('tavern-storylines.csv'), contentPath); + }); + + afterEach(() => temp.cleanup()); + + it('writes a valid edit atomically', () => { + const result = applyContentEditToFile({ + contentPath, + adapter: TAVERN_CONTENT_ADAPTER, + mutate: (csv) => + updateContentCard(csv, TAVERN_CONTENT_ADAPTER, { + id: 'tav-tale', + name: 'A Longer Tale', + }), + }); + expect(result).toMatchObject({ ok: true, written: true, path: contentPath }); + expect(readText(contentPath)).toBe(result.csv); + expect(readText(contentPath)).toContain('A Longer Tale'); + expect(validateContentCsv(readText(contentPath), TAVERN_CONTENT_ADAPTER).valid).toBe(true); + }); + + it('leaves the file byte-identical on a dry run', () => { + const before = readFileSync(contentPath, 'utf-8'); + const result = applyContentEditToFile({ + contentPath, + adapter: TAVERN_CONTENT_ADAPTER, + dryRun: true, + mutate: (csv) => + updateContentCard(csv, TAVERN_CONTENT_ADAPTER, { + id: 'tav-tale', + name: 'Preview Only', + }), + }); + expect(result).toMatchObject({ ok: true, written: false }); + expect(result.csv).toContain('Preview Only'); + expect(readFileSync(contentPath, 'utf-8')).toBe(before); + }); + + it('writes nothing when validation fails', () => { + const before = readFileSync(contentPath, 'utf-8'); + const result = applyContentEditToFile({ + contentPath, + adapter: TAVERN_CONTENT_ADAPTER, + mutate: (csv) => + updateContentCard(csv, TAVERN_CONTENT_ADAPTER, { + id: 'tav-open', + options: 'Admit|tav-gone', + }), + }); + expect(result).toMatchObject({ ok: false, written: false }); + expect(result.errors.join(' ')).toContain('tav-gone'); + expect(readFileSync(contentPath, 'utf-8')).toBe(before); + }); + + it('writes nothing when the target card does not exist', () => { + const before = readFileSync(contentPath, 'utf-8'); + const result = applyContentEditToFile({ + contentPath, + adapter: TAVERN_CONTENT_ADAPTER, + mutate: (csv) => + updateContentCard(csv, TAVERN_CONTENT_ADAPTER, { id: 'tav-nope', name: 'X' }), + }); + expect(result).toMatchObject({ ok: false, written: false }); + expect(readFileSync(contentPath, 'utf-8')).toBe(before); + }); +}); + +describe('content read helpers', () => { + it('projects cards back from CSV and reports membership', () => { + const cards = readContentCards(tavernCsv, TAVERN_CONTENT_ADAPTER); + expect(cards).toHaveLength(3); + expect(hasContentCard(cards, TAVERN_CONTENT_ADAPTER, 'tav-inn')).toBe(true); + expect(hasContentCard(cards, TAVERN_CONTENT_ADAPTER, 'tav-nope')).toBe(false); + }); +}); + +describe('storyline-author CLI', () => { + let temp: TempDir; + let contentPath: string; + + beforeEach(() => { + temp = makeTempDir(); + contentPath = join(temp.path, 'storylines.csv'); + copyFileSync(GYM_CONTENT_PATH, contentPath); + }); + + afterEach(() => temp.cleanup()); + + it('applies add-card and update-card against the content file', () => { + const added = captureOutput(() => + runStorylineAuthorCli([ + 'add-card', + '--csv', + contentPath, + '--id', + 'gym-extra', + '--name', + 'An Extra Card', + '--field', + 'coinDelta=5', + ]), + ); + expect(added.code).toBe(0); + expect(readText(contentPath)).toContain('gym-extra'); + expect(readText(contentPath)).toContain('An Extra Card'); + + const updated = captureOutput(() => + runStorylineAuthorCli([ + 'update-card', + '--csv', + contentPath, + '--id', + 'gym-story-end', + '--name', + 'A New Beginning', + ]), + ); + expect(updated.code).toBe(0); + expect(readText(contentPath)).toContain('A New Beginning'); + }); + + it('prints the candidate and writes nothing on --dry-run', () => { + const before = readText(contentPath); + const { code, stdout } = captureOutput(() => + runStorylineAuthorCli([ + 'update-card', + '--csv', + contentPath, + '--id', + 'gym-story-end', + '--name', + 'Preview Only', + '--dry-run', + ]), + ); + expect(code).toBe(0); + expect(stdout).toContain('dry-run OK'); + expect(stdout).toContain('Preview Only'); + expect(readText(contentPath)).toBe(before); + }); + + it('refuses an invalid edit with a non-zero exit and no write', () => { + const before = readText(contentPath); + const { code, stderr } = captureOutput(() => + runStorylineAuthorCli([ + 'update-card', + '--csv', + contentPath, + '--id', + 'gym-story-start', + '--options', + 'Invest|gym-gone', + ]), + ); + expect(code).toBe(1); + expect(stderr).toContain('refusing to write'); + expect(stderr).toContain('gym-gone'); + expect(readText(contentPath)).toBe(before); + }); + + it('rejects an unknown command, a bad --field and an unknown adapter', () => { + expect(captureOutput(() => runStorylineAuthorCli(['frobnicate'])).code).toBe(1); + expect( + captureOutput(() => runStorylineAuthorCli(['add-card', '--field', 'nope'])).code, + ).toBe(1); + expect( + captureOutput(() => + runStorylineAuthorCli(['add-card', '--game', 'nope', '--csv', contentPath]), + ).code, + ).toBe(1); + expect(captureOutput(() => runStorylineAuthorCli(['--help'])).stdout).toContain( + 'add-card', + ); + }); +}); diff --git a/tests/story-line/content/cli.test.ts b/tests/story-line/content/cli.test.ts new file mode 100644 index 000000000..16d49da63 --- /dev/null +++ b/tests/story-line/content/cli.test.ts @@ -0,0 +1,159 @@ +/** + * Validator CLI tests. + * + * Exit codes are the CLI's contract (0 = valid, 1 = invalid), so they are + * asserted directly alongside the report content. One end-to-end subprocess + * case proves the three thin entry points really run under `tsx` with no Vite + * loader or game dependency. + * + * @module + */ +import { spawnSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { DEFAULT_CONTENT_PATH } from '../../../src/story-line/content/adapters'; +import { DEFAULT_GRAPH_OUTPUTS } from '../../../scripts/storyline-graph'; +import { + parseValidateStorylinesArgs, + runValidateStorylinesCli, +} from '../../../scripts/validate-storylines'; +import { REPO_ROOT, captureOutput, fixturePath, makeTempDir } from './test-support'; + +const GYM_CONTENT = join(REPO_ROOT, DEFAULT_CONTENT_PATH); + +/** Runs a `tsx` script in a subprocess and returns its outcome. */ +function runTsx(script: string, args: readonly string[]): { + status: number | null; + stdout: string; + stderr: string; +} { + const npx = process.platform === 'win32' ? 'npx.cmd' : 'npx'; + const result = spawnSync(npx, ['--no-install', 'tsx', join('scripts', script), ...args], { + cwd: REPO_ROOT, + encoding: 'utf-8', + timeout: 120_000, + }); + return { status: result.status, stdout: result.stdout, stderr: result.stderr }; +} + +describe('parseValidateStorylinesArgs', () => { + it('defaults to the Gym content and the text report', () => { + const opts = parseValidateStorylinesArgs([]); + expect(opts.csvPath).toBe('example-games/gym/content/storylines.csv'); + expect(opts.json).toBe(false); + expect(opts.failOnCycle).toBe(false); + expect(opts.help).toBe(false); + }); + + it('parses flags', () => { + const opts = parseValidateStorylinesArgs([ + '--csv', + 'a.csv', + '--game', + 'gym', + '--json', + '--fail-on-cycle', + ]); + expect(opts).toMatchObject({ + csvPath: 'a.csv', + gameId: 'gym', + json: true, + failOnCycle: true, + }); + }); +}); + +describe('validate-storylines CLI', () => { + it('exits 0 and reports VALID for valid content', () => { + const { code, stdout } = captureOutput(() => + runValidateStorylinesCli(['--csv', GYM_CONTENT]), + ); + expect(code).toBe(0); + expect(stdout).toContain('RESULT: VALID'); + }); + + it('exits 1 with a JSON report naming the offending card', () => { + const { code, stdout } = captureOutput(() => + runValidateStorylinesCli(['--csv', fixturePath('gym-broken-dangling.csv'), '--json']), + ); + expect(code).toBe(1); + const report = JSON.parse(stdout); + expect(report.valid).toBe(false); + expect(report.issues[0]).toMatchObject({ + code: 'unknown-successor', + cardId: 'gym-open', + target: 'gym-missing', + }); + }); + + it('fails on a cycle only with --fail-on-cycle', () => { + const csv = fixturePath('gym-cycle.csv'); + expect(captureOutput(() => runValidateStorylinesCli(['--csv', csv])).code).toBe(0); + const strict = captureOutput(() => + runValidateStorylinesCli(['--csv', csv, '--fail-on-cycle']), + ); + expect(strict.code).toBe(1); + expect(strict.stdout).toContain('cycle-error'); + }); + + it('exits 1 for an unknown adapter, a missing file and prints usage for --help', () => { + expect( + captureOutput(() => runValidateStorylinesCli(['--csv', GYM_CONTENT, '--game', 'nope'])) + .code, + ).toBe(1); + + const temp = makeTempDir(); + try { + const missing = captureOutput(() => + runValidateStorylinesCli(['--csv', join(temp.path, 'absent.csv')]), + ); + expect(missing.code).toBe(1); + expect(missing.stderr).toContain('cannot read'); + } finally { + temp.cleanup(); + } + + const help = captureOutput(() => runValidateStorylinesCli(['--help'])); + expect(help.code).toBe(0); + expect(help.stdout).toContain('Validate story-line content'); + }); +}); + +describe('tsx entry points', () => { + it('runs validate-storylines.ts under tsx and emits parseable JSON', () => { + const result = runTsx('validate-storylines.ts', ['--csv', GYM_CONTENT, '--json']); + expect(result.stderr).not.toContain('ERR_MODULE_NOT_FOUND'); + expect(result.status).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ valid: true, gameId: 'gym' }); + }); + + it('runs storyline-graph.ts --check under tsx against the committed artefacts', () => { + const result = runTsx('storyline-graph.ts', ['--format', 'json', '--check']); + expect(result.status).toBe(0); + expect(result.stdout).toContain('is up to date'); + }); +}); + +describe('shipped content location', () => { + it('keeps the default content and its artefacts out of any ignore rule', () => { + // A repo-wide `data/` ignore rule once swallowed these files while the + // in-worktree suite still passed, so the committed demo content silently + // disappeared. `git check-ignore` exits 1 when a path is *not* ignored; + // that is the property the shipped artefacts need (they are committed). + const paths = [ + DEFAULT_CONTENT_PATH, + DEFAULT_GRAPH_OUTPUTS.json, + DEFAULT_GRAPH_OUTPUTS.mermaid, + ]; + for (const path of paths) { + expect(existsSync(join(REPO_ROOT, path))).toBe(true); + const ignored = spawnSync('git', ['check-ignore', '-q', path], { + cwd: REPO_ROOT, + }); + expect(ignored.status, `${path} must not be git-ignored`).toBe(1); + } + }); +}); diff --git a/tests/story-line/content/csv.test.ts b/tests/story-line/content/csv.test.ts new file mode 100644 index 000000000..9243ac981 --- /dev/null +++ b/tests/story-line/content/csv.test.ts @@ -0,0 +1,240 @@ +/** + * Story-line content CSV tests. + * + * Covers the two properties the authoring tooling relies on — byte-identical + * round-tripping of a canonically quoted file and a deterministic content + * checksum — plus projection of rows onto the generic content model through + * two differently shaped adapters. + * + * @module + */ +import { describe, expect, it } from 'vitest'; + +import { GYM_CONTENT_ADAPTER } from '../../../src/story-line/content/adapters'; +import type { ContentAdapter, ContentCard } from '../../../src/story-line/content/types'; +import { + cardsFromCsv, + compileContentOptions, + computeContentChecksum, + escapeCsvField, + findMissingColumns, + optionSuccessorIds, + parseCsv, + serialiseCsv, +} from '../../../src/story-line/content/csv'; +import { TAVERN_CONTENT_ADAPTER } from './fixture-adapter'; +import { GYM_CONTENT_PATH, readFixture, readText } from './test-support'; + +/** A content card with only the fields a test needs. */ +function card(overrides: Partial<ContentCard> = {}): ContentCard { + return { + id: 'card-a', + name: 'Card A', + effect: '', + hasChoices: false, + acceptNextCardId: null, + rejectNextCardId: null, + options: '', + storylineId: null, + storylineTitle: null, + fields: {}, + ...overrides, + }; +} + +describe('parseCsv / serialiseCsv', () => { + it('round-trips the shipped Gym content byte-identically', () => { + const text = readText(GYM_CONTENT_PATH); + expect(serialiseCsv(parseCsv(text))).toBe(text); + }); + + it('round-trips a differently shaped fixture byte-identically', () => { + const text = readFixture('tavern-storylines.csv'); + expect(serialiseCsv(parseCsv(text))).toBe(text); + }); + + it('preserves column order when the file reorders columns', () => { + const text = 'card_name,card_key,payoff\nName,key-1,3\n'; + const parsed = parseCsv(text); + expect(parsed.header).toEqual(['card_name', 'card_key', 'payoff']); + expect(serialiseCsv(parsed)).toBe(text); + }); + + it('round-trips quoted fields containing commas, quotes and newlines', () => { + const text = 'a,b,c\n"x, y","he said ""hi""","line one\nline two"\n'; + const parsed = parseCsv(text); + expect(parsed.rows[0]).toEqual(['x, y', 'he said "hi"', 'line one\nline two']); + expect(serialiseCsv(parsed)).toBe(text); + }); + + it('normalises CRLF input to LF output (canonical form)', () => { + expect(serialiseCsv(parseCsv('a,b\r\n1,2\r\n'))).toBe('a,b\n1,2\n'); + }); + + it('does not invent a final record for a terminal newline', () => { + expect(parseCsv('a,b\n1,2\n').rows).toHaveLength(1); + }); + + it('quotes a field only when it needs quoting', () => { + expect(escapeCsvField('plain')).toBe('plain'); + expect(escapeCsvField('a,b')).toBe('"a,b"'); + expect(escapeCsvField('say "hi"')).toBe('"say ""hi"""'); + }); +}); + +describe('computeContentChecksum', () => { + it('is stable for identical content and differs for any change', () => { + const text = readText(GYM_CONTENT_PATH); + const checksum = computeContentChecksum(text); + expect(checksum).toMatch(/^[0-9a-f]{8}$/); + expect(computeContentChecksum(text)).toBe(checksum); + expect(computeContentChecksum(`${text} `)).not.toBe(checksum); + expect(computeContentChecksum(text.replace('Town Meeting', 'Town Gathering'))).not.toBe( + checksum, + ); + }); +}); + +describe('findMissingColumns', () => { + it('accepts a header that carries every mapped column', () => { + expect(findMissingColumns(GYM_CONTENT_ADAPTER.columns, GYM_CONTENT_ADAPTER)).toEqual([]); + }); + + it('names the required columns a foreign header lacks', () => { + expect(findMissingColumns(['card_key', 'card_name'], GYM_CONTENT_ADAPTER)).toEqual([ + 'id', + 'name', + ]); + }); +}); + +describe('cardsFromCsv', () => { + it('projects the Gym content onto the generic model plus its game columns', () => { + const cards = cardsFromCsv(parseCsv(readText(GYM_CONTENT_PATH)), GYM_CONTENT_ADAPTER); + expect(cards.map((entry) => entry.id)).toEqual([ + 'gym-story-start', + 'gym-story-invest', + 'gym-story-ignore', + 'gym-story-end', + ]); + + const start = cards[0]; + expect(start.name).toBe('Town Meeting'); + expect(start.effect).toBe('Lose 50 coins'); + expect(start.options).toBe('Invest|gym-story-invest;Ignore|gym-story-ignore'); + expect(start.hasChoices).toBe(false); + expect(start.acceptNextCardId).toBeNull(); + expect(start.storylineId).toBe('gym-story'); + expect(start.storylineTitle).toBe('Gym Storyline'); + expect(start.fields).toMatchObject({ + trigger: 'Incident', + cost: '0', + coinDelta: '-50', + reputationDelta: '0', + }); + }); + + it('projects a column-mapped fixture through a differently shaped adapter', () => { + const cards = cardsFromCsv( + parseCsv(readFixture('tavern-storylines.csv')), + TAVERN_CONTENT_ADAPTER, + ); + expect(cards.map((entry) => entry.id)).toEqual(['tav-open', 'tav-inn', 'tav-tale']); + + const first = cards[0]; + expect(first.name).toBe('Open the Door'); + expect(first.effect).toBe('A stranger knocks at the tavern door.'); + expect(first.options).toBe('Admit|tav-inn;Turn away|'); + // No hasChoices / accept / reject columns in this shape. + expect(first.hasChoices).toBe(false); + expect(first.acceptNextCardId).toBeNull(); + expect(first.rejectNextCardId).toBeNull(); + expect(first.storylineId).toBe('tav'); + expect(first.storylineTitle).toBe('Tavern Nights'); + expect(first.fields).toEqual({ payoff: '2' }); + }); + + it('reads a missing optional column as absent rather than failing', () => { + const sparse: ContentAdapter = { + gameId: 'sparse', + columns: ['key', 'title'], + fields: { id: 'key', name: 'title' }, + }; + const [parsed] = cardsFromCsv(parseCsv('key,title\nk-1,A Card\n'), sparse); + expect(parsed).toMatchObject({ + id: 'k-1', + name: 'A Card', + effect: '', + hasChoices: false, + options: '', + storylineId: null, + }); + }); +}); + +describe('compileContentOptions', () => { + it('compiles legacy hasChoices fields into Accept/Reject options', () => { + const options = compileContentOptions( + card({ + hasChoices: true, + acceptNextCardId: 'card-b', + rejectNextCardId: 'card-c', + }), + ); + expect(options).toEqual([ + { label: 'Accept', successorId: 'card-b', effectPolicy: 'apply' }, + { label: 'Reject', successorId: 'card-c', effectPolicy: 'skip' }, + ]); + }); + + it('prefers the options encoding over legacy fields', () => { + const options = compileContentOptions( + card({ + hasChoices: true, + acceptNextCardId: 'card-b', + options: 'Invest|card-c,card-d;Walk|', + }), + ); + expect(options).toEqual([ + { label: 'Invest', successorIds: ['card-c', 'card-d'], effectPolicy: 'apply' }, + { label: 'Walk', successorIds: [], effectPolicy: 'apply' }, + ]); + }); + + it('rejects a malformed options encoding', () => { + expect(() => compileContentOptions(card({ options: 'NoSeparator' }))).toThrow( + /missing the "\|" separator/, + ); + }); + + it('returns no options for a plain incident', () => { + expect(compileContentOptions(card())).toEqual([]); + }); +}); + +describe('optionSuccessorIds', () => { + it('prefers the candidate set and applies the adapter base-id rule', () => { + const adapter: ContentAdapter = { + ...TAVERN_CONTENT_ADAPTER, + baseId: (id) => id.replace(/-\d+$/, ''), + }; + expect( + optionSuccessorIds( + { label: 'A', successorIds: ['tav-inn-3'], effectPolicy: 'apply' }, + adapter, + ), + ).toEqual(['tav-inn']); + expect( + optionSuccessorIds( + { label: 'A', successorId: 'tav-tale-2', effectPolicy: 'apply' }, + adapter, + ), + ).toEqual(['tav-tale']); + }); + + it('reports a terminal option with no successors', () => { + expect(optionSuccessorIds({ label: 'End', effectPolicy: 'apply' }, TAVERN_CONTENT_ADAPTER)).toEqual( + [], + ); + }); +}); diff --git a/tests/story-line/content/fixture-adapter.ts b/tests/story-line/content/fixture-adapter.ts new file mode 100644 index 000000000..722776d90 --- /dev/null +++ b/tests/story-line/content/fixture-adapter.ts @@ -0,0 +1,43 @@ +/** + * A second, deliberately differently shaped content adapter used by the tests. + * + * It proves the content seam is game-agnostic: different column names, no + * legacy `hasChoices` / accept-reject columns at all, a different game column + * (`payoff`) and its own impact wording. Nothing here is shipped by the + * engine — it exists so the tests exercise two adapters, not one. + * + * @module + */ + +import type { ContentAdapter } from '../../../src/story-line/content/types'; + +/** Column names used by the tavern fixture CSVs. */ +export const TAVERN_COLUMNS = [ + 'card_key', + 'card_name', + 'flavour', + 'arc', + 'arc_name', + 'choices', + 'payoff', +] as const; + +/** The tavern fixture adapter (see `tests/fixtures/story-line/tavern-*.csv`). */ +export const TAVERN_CONTENT_ADAPTER: ContentAdapter = { + gameId: 'tavern-fixture', + columns: [...TAVERN_COLUMNS], + fields: { + id: 'card_key', + name: 'card_name', + effect: 'flavour', + options: 'choices', + storylineId: 'arc', + storylineTitle: 'arc_name', + }, + editableColumns: ['payoff'], + newCardDefaults: { flavour: '', choices: '', payoff: '0' }, + describeImpact: (card) => { + const payoff = Number(card.fields.payoff ?? ''); + return Number.isFinite(payoff) && payoff !== 0 ? `payoff ${payoff}` : ''; + }, +}; diff --git a/tests/story-line/content/import-boundary.test.ts b/tests/story-line/content/import-boundary.test.ts new file mode 100644 index 000000000..052c3bafe --- /dev/null +++ b/tests/story-line/content/import-boundary.test.ts @@ -0,0 +1,109 @@ +/** + * Import-boundary tests for the story-line content toolkit. + * + * The content module must stay loadable by plain `tsx`/Node and must never + * acquire a game or Phaser dependency — the whole point of the content + * adapter seam is that core generalises the toolchain while games supply only + * data. That is a property of the **module graph**, not of a runtime value, so + * it is asserted by scanning the modules' import specifiers: + * + * - every import in `src/story-line/content/**` and in the authoring CLIs is a + * relative path or a `node:` builtin (no `phaser`, no game alias, no package + * that a game-only toolchain would pull in); + * - `node:fs` importing is confined to the two sanctioned writers, declared in + * the editor write-path guard: `author.ts` (the content CSV) and + * `artefacts.ts` (the generated mermaid/json artefacts). + * + * @module + */ +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { join } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { SANCTIONED_WRITE_MODULES } from '../editor/write-path-guard'; +import { REPO_ROOT } from './test-support'; + +/** The basenames of the sanctioned writers (they live in this content dir). */ +const SANCTIONED_BASENAMES = new Set(SANCTIONED_WRITE_MODULES.map((file) => file.split('/').pop())); + +/** Recursively lists `.ts` files under a directory. */ +function listTypeScriptFiles(dir: string): string[] { + const files: string[] = []; + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + if (statSync(full).isDirectory()) { + files.push(...listTypeScriptFiles(full)); + } else if (entry.endsWith('.ts')) { + files.push(full); + } + } + return files; +} + +/** Extracts every static `from '…'` import/export specifier in a file. */ +function importSpecifiers(source: string): string[] { + const specifiers: string[] = []; + const pattern = /\bfrom\s+['"]([^'"]+)['"]/g; + let match: RegExpExecArray | null; + while ((match = pattern.exec(source)) !== null) { + specifiers.push(match[1]); + } + return specifiers; +} + +const CONTENT_DIR = join(REPO_ROOT, 'src/story-line/content'); +const CONTENT_FILES = listTypeScriptFiles(CONTENT_DIR); +const CLI_FILES = [ + join(REPO_ROOT, 'scripts/storyline-author.ts'), + join(REPO_ROOT, 'scripts/storyline-graph.ts'), + join(REPO_ROOT, 'scripts/validate-storylines.ts'), + join(REPO_ROOT, 'scripts/storyline-cli-support.ts'), +]; + +describe('story-line content import boundaries', () => { + it('scans the modules this suite claims to protect', () => { + expect(CONTENT_FILES.length).toBeGreaterThanOrEqual(6); + expect(CONTENT_FILES.map((file) => file.split('/').pop())).toEqual( + expect.arrayContaining([ + 'types.ts', + 'csv.ts', + 'manifest.ts', + 'validate.ts', + 'author.ts', + 'adapters.ts', + 'index.ts', + ]), + ); + }); + + it('imports only relative modules and node builtins (no Phaser, no game repo)', () => { + const offenders: string[] = []; + for (const file of [...CONTENT_FILES, ...CLI_FILES]) { + const source = readFileSync(file, 'utf-8'); + for (const specifier of importSpecifiers(source)) { + const allowed = specifier.startsWith('.') || specifier.startsWith('node:'); + if (!allowed) offenders.push(`${file} → ${specifier}`); + } + } + expect(offenders).toEqual([]); + }); + + it('confines file writing to the sanctioned writers (author + artefacts)', () => { + const importFs = CONTENT_FILES.filter( + (file) => + !SANCTIONED_BASENAMES.has(file.split('/').pop() ?? '') && + importSpecifiers(readFileSync(file, 'utf-8')).some( + (specifier) => specifier === 'node:fs', + ), + ); + expect(importFs).toEqual([]); + + // The two sanctioned writers really write. + const authorSource = readFileSync(join(CONTENT_DIR, 'author.ts'), 'utf-8'); + expect(authorSource).toContain('writeFileSync'); + expect(authorSource).toContain('renameSync'); + const artefactsSource = readFileSync(join(CONTENT_DIR, 'artefacts.ts'), 'utf-8'); + expect(artefactsSource).toContain('writeFileSync'); + }); +}); diff --git a/tests/story-line/content/manifest.test.ts b/tests/story-line/content/manifest.test.ts new file mode 100644 index 000000000..46ba809a1 --- /dev/null +++ b/tests/story-line/content/manifest.test.ts @@ -0,0 +1,284 @@ +/** + * Story-line manifest and Mermaid rendering tests. + * + * The manifest is the editor's view-model source and its committed artefacts + * are drift-guarded, so the important properties are: the documented shape, + * byte-identical determinism (including input-order independence), cycle + * marking, per-story-line filtering, and agreement between the generator and + * the committed artefacts / JSON schema. + * + * @module + */ +import { writeFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import Ajv from 'ajv'; +import { afterAll, describe, expect, it } from 'vitest'; + +import { GYM_CONTENT_ADAPTER } from '../../../src/story-line/content/adapters'; +import { cardsFromCsv, parseCsv } from '../../../src/story-line/content/csv'; +import { + briefEventDescription, + buildStorylineManifest, + buildStorylineManifestFromCsv, + describeChoiceRouting, + describeEventImpact, + filterManifestByStoryline, + nodeLabel, + renderMermaid, + serialiseManifest, +} from '../../../src/story-line/content/manifest'; +import { runStorylineGraphCli } from '../../../scripts/storyline-graph'; +import { TAVERN_CONTENT_ADAPTER } from './fixture-adapter'; +import { + GYM_CONTENT_PATH, + MANIFEST_SCHEMA_PATH, + makeTempDir, + readFixture, + readText, + captureOutput, +} from './test-support'; + +const gymManifest = buildStorylineManifestFromCsv( + readText(GYM_CONTENT_PATH), + GYM_CONTENT_ADAPTER, +); + +const tempDir = makeTempDir(); +afterAll(() => tempDir.cleanup()); + +describe('buildStorylineManifest', () => { + it('produces the documented node/edge/story-line shape', () => { + expect(gymManifest.version).toBe(1); + expect(gymManifest.gameId).toBe('gym'); + expect(gymManifest.storylineIds).toEqual(['gym-story']); + expect(gymManifest.cycles).toEqual([]); + + expect(gymManifest.nodes.map((node) => node.id)).toEqual([ + 'gym-story-end', + 'gym-story-ignore', + 'gym-story-invest', + 'gym-story-start', + ]); + + const start = gymManifest.nodes.find((node) => node.id === 'gym-story-start'); + expect(start).toMatchObject({ + name: 'Town Meeting', + storylineId: 'gym-story', + storylineTitle: 'Gym Storyline', + hasChoices: true, + optionCount: 2, + effect: 'Lose 50 coins', + impact: '−50 coins', + }); + expect(start?.options).toEqual([ + { label: 'Invest', successorIds: ['gym-story-invest'], effectPolicy: 'apply' }, + { label: 'Ignore', successorIds: ['gym-story-ignore'], effectPolicy: 'apply' }, + ]); + + const end = gymManifest.nodes.find((node) => node.id === 'gym-story-end'); + expect(end).toMatchObject({ hasChoices: false, optionCount: 0, impact: '+60 reputation' }); + + // One edge per candidate; terminal options become a null-target edge. + expect(gymManifest.edges).toHaveLength(6); + expect(gymManifest.edges.filter((edge) => edge.to === null)).toHaveLength(2); + expect( + gymManifest.edges.filter((edge) => edge.from === 'gym-story-start').map((edge) => [ + edge.label, + edge.to, + edge.random, + ]), + ).toEqual([ + ['Ignore', 'gym-story-ignore', false], + ['Invest', 'gym-story-invest', false], + ]); + }); + + it('marks random candidate branches', () => { + const csv = [ + 'id,name,effect,options,hasChoices,acceptNextCardId,rejectNextCardId,storylineId,storylineTitle,trigger,cost,coinDelta,reputationDelta', + 'evt-a,Event A,Text,"Gamble|evt-b,evt-c",false,,,arc,Arc,Incident,0,0,0', + 'evt-b,Event B,Text,,false,,,arc,Arc,Incident,0,0,0', + 'evt-c,Event C,Text,,false,,,arc,Arc,Incident,0,0,0', + ].join('\n'); + const manifest = buildStorylineManifestFromCsv(`${csv}\n`, GYM_CONTENT_ADAPTER); + const branches = manifest.edges.filter((edge) => edge.from === 'evt-a'); + expect(branches.map((edge) => [edge.to, edge.random, edge.candidateIndex])).toEqual([ + ['evt-b', true, 0], + ['evt-c', true, 1], + ]); + }); + + it('is deterministic and independent of input card order', () => { + const first = serialiseManifest(gymManifest); + const second = serialiseManifest( + buildStorylineManifestFromCsv(readText(GYM_CONTENT_PATH), GYM_CONTENT_ADAPTER), + ); + expect(second).toBe(first); + + const reversed = cardsFromCsv(parseCsv(readText(GYM_CONTENT_PATH)), GYM_CONTENT_ADAPTER).reverse(); + expect(serialiseManifest(buildStorylineManifest(reversed, GYM_CONTENT_ADAPTER))).toBe(first); + }); + + it('records cycles as a first-class shape and marks their edges', () => { + const manifest = buildStorylineManifestFromCsv( + readFixture('tavern-cycle.csv'), + TAVERN_CONTENT_ADAPTER, + ); + expect(manifest.cycles).toEqual([['tav-loop-a', 'tav-loop-b']]); + expect(manifest.edges.every((edge) => edge.cycle)).toBe(true); + expect(manifest.edges.map((edge) => edge.label).sort()).toEqual(['Again', 'Back']); + }); +}); + +describe('filterManifestByStoryline', () => { + it('keeps only the requested story-line nodes, edges and cycles', () => { + const filtered = filterManifestByStoryline(gymManifest, 'gym-story'); + expect(filtered.storylineIds).toEqual(['gym-story']); + expect(filtered.nodes).toHaveLength(gymManifest.nodes.length); + expect(filtered.edges).toHaveLength(gymManifest.edges.length); + expect(filtered.cycles).toEqual([]); + }); + + it('returns an empty graph for an unknown story-line', () => { + const filtered = filterManifestByStoryline(gymManifest, 'no-such-arc'); + expect(filtered.nodes).toEqual([]); + expect(filtered.edges).toEqual([]); + expect(filtered.storylineIds).toEqual(['no-such-arc']); + }); +}); + +describe('rendering helpers', () => { + it('renders choice cards as diamonds, plain cards as rounded boxes and terminal ends', () => { + const mermaid = renderMermaid(gymManifest); + expect(mermaid).toContain('flowchart TD'); + expect(mermaid).toMatch(/gym_story_start\{"Town Meeting<br\/>gym-story-start/); + expect(mermaid).toMatch(/gym_story_end\("/); + expect(mermaid).toContain('gym_story_start -->|Invest| gym_story_invest'); + expect(mermaid).toContain('_end(["end"])'); + expect(mermaid).not.toContain('Intentional cycle'); + }); + + it('marks cycle edges with a thickened arrow and lists the cycle', () => { + const manifest = buildStorylineManifestFromCsv( + readFixture('tavern-cycle.csv'), + TAVERN_CONTENT_ADAPTER, + ); + const mermaid = renderMermaid(manifest); + expect(mermaid).toContain('tav_loop_a ==>|Again| tav_loop_b'); + expect(mermaid).toContain('%% Intentional cycle(s):'); + expect(mermaid).toContain('%% tav-loop-a -> tav-loop-b -> tav-loop-a'); + }); + + it('builds a node label from the name, id, brief effect and impact', () => { + const node = gymManifest.nodes.find((entry) => entry.id === 'gym-story-start'); + expect(node && nodeLabel(node)).toBe( + 'Town Meeting<br/>gym-story-start<br/>Lose 50 coins<br/>−50 coins', + ); + expect(node && describeEventImpact(node)).toBe('−50 coins'); + }); + + it('truncates a long effect at the first sentence or the length cap', () => { + expect(briefEventDescription('Short effect')).toBe('Short effect'); + expect(briefEventDescription('First sentence. Second sentence.')).toBe('First sentence.'); + expect(briefEventDescription('x'.repeat(80))).toHaveLength(64); + expect(briefEventDescription('x'.repeat(80)).endsWith('…')).toBe(true); + expect(briefEventDescription(' ')).toBe(''); + }); + + it('describes choice routing, including a chain-end option', () => { + const nameById = new Map(gymManifest.nodes.map((node) => [node.id, node.name])); + const invest = gymManifest.nodes.find((node) => node.id === 'gym-story-invest'); + const routing = invest + ? describeChoiceRouting(invest, gymManifest.edges, nameById) + : ''; + expect(routing).toBe( + 'Celebrate: effect applies → New Beginning · Double down: effect applies → chain ends', + ); + + const end = gymManifest.nodes.find((node) => node.id === 'gym-story-end'); + expect(end && describeChoiceRouting(end, gymManifest.edges, nameById)).toBe( + 'Resolves immediately (no choice)', + ); + }); +}); + +describe('manifest JSON schema', () => { + const ajv = new Ajv({ allErrors: true, strict: false }); + const validate = ajv.compile(JSON.parse(readText(MANIFEST_SCHEMA_PATH))); + + it('accepts the generated manifest', () => { + expect(validate(JSON.parse(serialiseManifest(gymManifest)))).toBe(true); + expect(validate.errors).toBeNull(); + }); + + it('rejects a manifest missing a required node field', () => { + const broken = JSON.parse(serialiseManifest(gymManifest)); + delete broken.nodes[0].impact; + expect(validate(broken)).toBe(false); + }); +}); + +describe('storyline-graph CLI', () => { + it('reports the committed Gym manifest as up to date (--check)', () => { + const { code, stdout } = captureOutput(() => + runStorylineGraphCli(['--format', 'json', '--csv', GYM_CONTENT_PATH, '--check']), + ); + expect(code).toBe(0); + expect(stdout).toContain('is up to date'); + }); + + it('reports the committed Gym Mermaid artefact as up to date (--check)', () => { + const { code } = captureOutput(() => + runStorylineGraphCli(['--format', 'mermaid', '--csv', GYM_CONTENT_PATH, '--check']), + ); + expect(code).toBe(0); + }); + + it('detects drift against a differing artefact', () => { + const out = join(tempDir.path, 'drifted.json'); + writeFileSync(out, '{"version": 1}\n', 'utf-8'); + const { code, stderr } = captureOutput(() => + runStorylineGraphCli(['--format', 'json', '--csv', GYM_CONTENT_PATH, '--out', out, '--check']), + ); + expect(code).toBe(1); + expect(stderr).toContain('drift detected'); + }); + + it('fails --check when the committed artefact is missing', () => { + const { code, stderr } = captureOutput(() => + runStorylineGraphCli([ + '--format', + 'json', + '--csv', + GYM_CONTENT_PATH, + '--out', + join(tempDir.path, 'absent.json'), + '--check', + ]), + ); + expect(code).toBe(1); + expect(stderr).toContain('missing committed artefact'); + }); + + it('writes the artefact when --check is absent', () => { + const out = join(tempDir.path, 'written.json'); + const { code } = captureOutput(() => + runStorylineGraphCli(['--format', 'json', '--csv', GYM_CONTENT_PATH, '--out', out]), + ); + expect(code).toBe(0); + expect(readText(out)).toBe(serialiseManifest(gymManifest)); + }); + + it('rejects an unknown adapter id and prints usage for --help', () => { + const bad = captureOutput(() => + runStorylineGraphCli(['--game', 'nope', '--csv', GYM_CONTENT_PATH]), + ); + expect(bad.code).toBe(1); + expect(bad.stderr).toContain('unknown --game'); + + const help = captureOutput(() => runStorylineGraphCli(['--help'])); + expect(help.code).toBe(0); + expect(help.stdout).toContain('storyline-graph'); + }); +}); diff --git a/tests/story-line/content/test-support.ts b/tests/story-line/content/test-support.ts new file mode 100644 index 000000000..115369968 --- /dev/null +++ b/tests/story-line/content/test-support.ts @@ -0,0 +1,95 @@ +/** + * Shared helpers for the story-line content test-suite. + * + * Paths are resolved from this file's location rather than `process.cwd()` so + * the tests behave identically no matter where Vitest is invoked from. + * + * @module + */ + +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +/** Absolute path of the repository root (this file lives three levels down). */ +export const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); + +/** Absolute path of the story-line content fixtures directory. */ +export const FIXTURES_DIR = join(REPO_ROOT, 'tests/fixtures/story-line'); + +/** Absolute path of the shipped Gym story-line content. */ +export const GYM_CONTENT_PATH = join(REPO_ROOT, 'example-games/gym/content/storylines.csv'); + +/** Absolute path of the committed Gym manifest artefact. */ +export const GYM_MANIFEST_PATH = join(REPO_ROOT, 'example-games/gym/content/storyline-manifest.json'); + +/** Absolute path of the committed Gym Mermaid artefact. */ +export const GYM_MERMAID_PATH = join(REPO_ROOT, 'example-games/gym/content/storyline-graph.mmd'); + +/** Absolute path of the generic manifest JSON schema. */ +export const MANIFEST_SCHEMA_PATH = join(REPO_ROOT, 'schemas/story-line-manifest.schema.json'); + +/** Reads a UTF-8 file. */ +export function readText(path: string): string { + return readFileSync(path, 'utf-8'); +} + +/** Reads a named fixture CSV. */ +export function readFixture(name: string): string { + return readText(join(FIXTURES_DIR, name)); +} + +/** Absolute path of a named fixture CSV. */ +export function fixturePath(name: string): string { + return join(FIXTURES_DIR, name); +} + +/** A temp directory plus a cleanup function (call in `afterEach`/`finally`). */ +export interface TempDir { + readonly path: string; + cleanup(): void; +} + +/** Creates a throwaway directory for file-writing tests. */ +export function makeTempDir(): TempDir { + const path = mkdtempSync(join(tmpdir(), 'tce-storyline-content-')); + return { + path, + cleanup: () => rmSync(path, { recursive: true, force: true }), + }; +} + +/** + * Captures everything written to `process.stdout` / `process.stderr` while + * `fn` runs, restoring the real streams afterwards. + * + * The CLIs write to the process streams by design (they are CLI entry + * points), so tests drive them through this helper instead of a subprocess + * whenever only the exit code and report text matter. + */ +export function captureOutput(fn: () => number): { + code: number; + stdout: string; + stderr: string; +} { + const stdoutWrite = process.stdout.write.bind(process.stdout); + const stderrWrite = process.stderr.write.bind(process.stderr); + let stdout = ''; + let stderr = ''; + process.stdout.write = ((chunk: string | Uint8Array) => { + stdout += chunk.toString(); + return true; + }) as typeof process.stdout.write; + process.stderr.write = ((chunk: string | Uint8Array) => { + stderr += chunk.toString(); + return true; + }) as typeof process.stderr.write; + try { + const code = fn(); + return { code, stdout, stderr }; + } finally { + process.stdout.write = stdoutWrite; + process.stderr.write = stderrWrite; + } +} diff --git a/tests/story-line/content/validate.test.ts b/tests/story-line/content/validate.test.ts new file mode 100644 index 000000000..df8c29c24 --- /dev/null +++ b/tests/story-line/content/validate.test.ts @@ -0,0 +1,250 @@ +/** + * Story-line content validator tests. + * + * Each test targets one documented failure class, so a regression in one rule + * cannot hide behind another. The text and JSON reports are asserted to agree + * (they render the same issue/finding arrays), and the validator is asserted + * not to mutate the cards it inspects. + * + * @module + */ +import { describe, expect, it } from 'vitest'; + +import { GYM_CONTENT_ADAPTER } from '../../../src/story-line/content/adapters'; +import type { ContentCard } from '../../../src/story-line/content/types'; +import { + contentValidationToJson, + formatContentValidationReport, + validateContentCards, + validateContentCsv, +} from '../../../src/story-line/content/validate'; +import { TAVERN_CONTENT_ADAPTER } from './fixture-adapter'; +import { GYM_CONTENT_PATH, readFixture, readText } from './test-support'; + +/** A minimal content card for inline fixtures. */ +function card(overrides: Partial<ContentCard> = {}): ContentCard { + return { + id: 'card-a', + name: 'Card A', + effect: 'An effect', + hasChoices: false, + acceptNextCardId: null, + rejectNextCardId: null, + options: '', + storylineId: 'arc', + storylineTitle: 'Arc', + fields: {}, + ...overrides, + }; +} + +/** Codes reported for an inline card list. */ +function codes(cards: readonly ContentCard[]): string[] { + return validateContentCards(cards, TAVERN_CONTENT_ADAPTER).issues.map( + (issue) => issue.code, + ); +} + +describe('valid content', () => { + it('accepts the shipped Gym content', () => { + const result = validateContentCsv(readText(GYM_CONTENT_PATH), GYM_CONTENT_ADAPTER); + expect(result.valid).toBe(true); + expect(result.issues).toEqual([]); + expect(result.cardCount).toBe(4); + expect(result.choiceCardCount).toBe(3); + expect(result.storylineIds).toEqual(['gym-story']); + expect(result.gameId).toBe('gym'); + }); + + it('accepts the differently shaped tavern fixture', () => { + const result = validateContentCsv( + readFixture('tavern-storylines.csv'), + TAVERN_CONTENT_ADAPTER, + ); + expect(result.valid).toBe(true); + expect(result.choiceCardCount).toBe(2); + }); + + it('does not mutate the cards it inspects', () => { + const cards = [card({ options: 'Go|card-b' }), card({ id: 'card-b', storylineId: 'arc' })]; + const before = JSON.parse(JSON.stringify(cards)); + validateContentCards(cards, TAVERN_CONTENT_ADAPTER); + expect(JSON.parse(JSON.stringify(cards))).toEqual(before); + }); +}); + +describe('hard errors', () => { + it('catches an unknown successor id (dangling chain)', () => { + const result = validateContentCsv( + readFixture('broken-dangling.csv'), + TAVERN_CONTENT_ADAPTER, + ); + expect(result.valid).toBe(false); + expect(result.issues).toHaveLength(1); + expect(result.issues[0]).toMatchObject({ + code: 'unknown-successor', + cardId: 'tav-open', + target: 'tav-missing', + }); + expect(result.issues[0].message).toContain('tav-missing'); + }); + + it('catches duplicate card ids', () => { + const result = validateContentCsv( + readFixture('broken-duplicate.csv'), + TAVERN_CONTENT_ADAPTER, + ); + expect(result.valid).toBe(false); + expect(result.issues).toEqual([ + expect.objectContaining({ code: 'duplicate-id', cardId: 'tav-open' }), + ]); + }); + + it('catches a choice card with no storyline id', () => { + const result = validateContentCsv( + readFixture('broken-missing-storyline.csv'), + TAVERN_CONTENT_ADAPTER, + ); + expect(result.valid).toBe(false); + expect(result.issues).toEqual([ + expect.objectContaining({ code: 'missing-storyline-id', cardId: 'tav-open' }), + ]); + }); + + it('catches an unreachable story-line card', () => { + const result = validateContentCsv( + readFixture('broken-unreachable.csv'), + TAVERN_CONTENT_ADAPTER, + ); + expect(result.valid).toBe(false); + expect(result.issues).toEqual([ + expect.objectContaining({ code: 'unreachable-card', cardId: 'tav-lost' }), + ]); + expect(result.issues[0].message).toContain('tav-open'); + }); + + it('catches a malformed options encoding without throwing', () => { + const result = validateContentCsv( + readFixture('broken-options.csv'), + TAVERN_CONTENT_ADAPTER, + ); + expect(result.valid).toBe(false); + expect(result.issues[0]).toMatchObject({ + code: 'invalid-option', + cardId: 'tav-open', + }); + expect(result.issues[0].message).toContain('malformed options column'); + }); + + it('catches a missing required field', () => { + expect(codes([card({ name: ' ' })])).toEqual(['missing-required-field']); + }); + + it('catches an unknown legacy link target on a choice card', () => { + const result = validateContentCards( + [ + card({ hasChoices: true, acceptNextCardId: 'gone' }), + card({ id: 'card-b', storylineId: null }), + ], + TAVERN_CONTENT_ADAPTER, + ); + expect(result.issues).toEqual([ + expect.objectContaining({ code: 'unknown-successor', cardId: 'card-a', target: 'gone' }), + ]); + }); + + it('catches links declared on a card that is not a choice card', () => { + expect( + codes([ + card({ acceptNextCardId: 'card-b' }), + card({ id: 'card-b', storylineId: null }), + ]), + ).toEqual(['orphan-link']); + }); + + it('reports an orphan link and its unknown target together', () => { + expect( + codes([ + card({ acceptNextCardId: 'gone' }), + card({ id: 'card-b', storylineId: null }), + ]), + ).toEqual(['orphan-link', 'dangling-link']); + }); + + it('catches an empty and a duplicate option label', () => { + expect(codes([card({ options: '|card-b;Go|card-b' })])).toEqual(['invalid-option']); + expect(codes([card({ options: 'Go|;go|' })])).toEqual(['invalid-option']); + }); +}); + +describe('cycles', () => { + it('reports a cycle as informational by default', () => { + const result = validateContentCsv( + readFixture('tavern-cycle.csv'), + TAVERN_CONTENT_ADAPTER, + ); + expect(result.valid).toBe(true); + expect(result.issues).toEqual([]); + expect(result.findings).toEqual([ + expect.objectContaining({ kind: 'cycle', path: ['tav-loop-a', 'tav-loop-b'] }), + ]); + }); + + it('reports a self-link as a self-link finding', () => { + const result = validateContentCards( + [card({ options: 'Again|card-a' })], + TAVERN_CONTENT_ADAPTER, + ); + expect(result.valid).toBe(true); + expect(result.findings).toEqual([ + expect.objectContaining({ kind: 'self-link', path: ['card-a'] }), + ]); + }); + + it('turns cycles into hard errors when failOnCycle is set', () => { + const result = validateContentCsv( + readFixture('tavern-cycle.csv'), + TAVERN_CONTENT_ADAPTER, + { failOnCycle: true }, + ); + expect(result.valid).toBe(false); + expect(result.issues).toEqual([ + expect.objectContaining({ code: 'cycle-error', cardId: 'tav-loop-a' }), + ]); + }); +}); + +describe('reports', () => { + it('renders the same findings in the text and JSON reports', () => { + const result = validateContentCsv( + readFixture('broken-dangling.csv'), + TAVERN_CONTENT_ADAPTER, + ); + const text = formatContentValidationReport(result); + const json = JSON.parse(contentValidationToJson(result)); + + expect(text).toContain('RESULT: INVALID'); + expect(json.valid).toBe(false); + expect(json.issues).toEqual(result.issues); + expect(json.findings).toEqual(result.findings); + expect(json.cardCount).toBe(result.cardCount); + for (const issue of result.issues) { + expect(text).toContain(issue.message); + expect(json.issues.map((entry: { message: string }) => entry.message)).toContain( + issue.message, + ); + } + }); + + it('renders a valid summary with story-lines and no findings', () => { + const text = formatContentValidationReport( + validateContentCsv(readText(GYM_CONTENT_PATH), GYM_CONTENT_ADAPTER), + ); + expect(text).toContain('Game: gym'); + expect(text).toContain('Choice cards: 3'); + expect(text).toContain('Story-lines: gym-story'); + expect(text).toContain('ERRORS (0): none'); + expect(text).toContain('Informational findings (0)'); + expect(text).toContain('RESULT: VALID'); + }); +}); diff --git a/tests/story-line/editor/bridge.test.ts b/tests/story-line/editor/bridge.test.ts new file mode 100644 index 000000000..d83b89e6a --- /dev/null +++ b/tests/story-line/editor/bridge.test.ts @@ -0,0 +1,295 @@ +/** + * The editor write bridge: artefacts, documentation honesty and the + * single-write-path guard. + * + * The bridge's whole reason to exist is that an accepted edit is followed by + * artefact regeneration *and* an honest statement about what is not + * machine-regenerable: + * + * - the committed Mermaid/JSON artefacts are rewritten and re-checked (the + * `--check` drift guards report no drift after an accepted edit); + * - hand-written documentation is never claimed to be in sync: an edit that + * touches a documented row (the Gym demo's card catalogue) returns an + * explicit "manual documentation update required" naming the check that now + * fails; + * - no editor module writes the content CSV itself — the T1 write-path guard + * still passes, and the arcade artefacts live outside the editor roots + * precisely so that guard stays meaningful (it is exercised below). + * + * @module + */ +import { readFileSync, writeFileSync } from 'node:fs'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { GYM_CONTENT_ADAPTER } from '../../../src/story-line/content/adapters'; +import { computeContentChecksum } from '../../../src/story-line/content/csv'; +import { + setStoryline, + setStorylineBatch, + checkCommittedArtefacts, + applyEdit, + previewEdit, + regenerateArtefacts, +} from '../../../src/story-line/editor/api'; +import { DEFAULT_MANUAL_DOC_SECTIONS, findManualDocUpdates } from '../../../src/story-line/editor/manual-docs'; +import { + assertSingleWritePath, + scanWritePaths, +} from './write-path-guard'; +import { + CONTENT_CSV_PATH, + REPO_ROOT, + copyEditorFixtureFiles, + type EditorFixtureFiles, +} from './fixtures'; + +/** The artefact paths of a fixture copy. */ +function artefactPaths(fixture: EditorFixtureFiles) { + return { json: fixture.manifestPath, mermaid: fixture.mermaidPath }; +} + +/** Loads the fixture content and returns its checksum. */ +function loadChecksum(fixture: EditorFixtureFiles): string { + return computeContentChecksum(readFileSync(fixture.csvPath, 'utf-8')); +} + +describe('applyEdit — artefact regeneration after an accepted edit', () => { + let fixture: EditorFixtureFiles; + + beforeEach(() => { + fixture = copyEditorFixtureFiles(); + }); + + afterEach(() => { + fixture.cleanup(); + }); + + it('regenerates the Mermaid/JSON artefacts and the drift guards report no drift', () => { + const result = applyEdit({ + contentPath: fixture.csvPath, + adapter: GYM_CONTENT_ADAPTER, + edit: { + kind: 'update-card', + spec: { id: 'gym-story-invest', name: 'Municipal Investment' }, + }, + expectedChecksum: loadChecksum(fixture), + artefactPaths: artefactPaths(fixture), + manualDocs: [], + }); + + expect(result.ok).toBe(true); + expect(result.artefacts?.regenerated).toBe(true); + expect([...result.artefacts?.written ?? []].sort()).toEqual( + [fixture.manifestPath, fixture.mermaidPath].sort(), + ); + expect(result.artefacts?.ok).toBe(true); + expect(result.artefacts?.drifts).toEqual([]); + + // Both committed artefacts now mention the renamed card… + expect(readFileSync(fixture.manifestPath, 'utf-8')).toContain('Municipal Investment'); + expect(readFileSync(fixture.mermaidPath, 'utf-8')).toContain('Municipal Investment'); + + // …and the `--check`-equivalent drift guard passes for both. + const edited = readFileSync(fixture.csvPath, 'utf-8'); + expect(checkCommittedArtefacts(edited, GYM_CONTENT_ADAPTER, { artefactPaths: artefactPaths(fixture) })).toEqual([]); + }); + + it('reports drift before content is edited away from the committed artefacts', () => { + const before = readFileSync(fixture.csvPath, 'utf-8'); + const drifts = checkCommittedArtefacts(before, GYM_CONTENT_ADAPTER, { + artefactPaths: artefactPaths(fixture), + }); + // The fixture copies are byte-identical to the committed artefacts, so no drift. + expect(drifts).toEqual([]); + + // A stale artefact (committed content ahead of the artefact) is reported. + writeFileSync( + fixture.manifestPath, + readFileSync(fixture.manifestPath, 'utf-8').replace('Public Investment', 'Renamed In Stale'), + 'utf-8', + ); + const stale = checkCommittedArtefacts(before, GYM_CONTENT_ADAPTER, { + artefactPaths: artefactPaths(fixture), + }); + expect(stale.map((drift) => drift.reason)).toEqual(['drift']); + expect(stale[0].format).toBe('json'); + expect(stale[0].message).toContain('regenerate'); + }); + + it('regenerateArtefacts repairs stale artefacts without touching content', () => { + const before = readFileSync(fixture.csvPath, 'utf-8'); + writeFileSync(fixture.manifestPath, '{"stale": true}\n', 'utf-8'); + + const { manifest, artefacts } = regenerateArtefacts(before, GYM_CONTENT_ADAPTER, { + artefactPaths: artefactPaths(fixture), + }); + + expect(manifest.gameId).toBe('gym'); + expect(artefacts.ok).toBe(true); + expect(artefacts.written).toHaveLength(2); + expect(readFileSync(fixture.csvPath, 'utf-8')).toBe(before); + }); +}); + +describe('manual documentation updates (hand-written rows are never claimed in sync)', () => { + let fixture: EditorFixtureFiles; + + beforeEach(() => { + fixture = copyEditorFixtureFiles(); + }); + + afterEach(() => { + fixture.cleanup(); + }); + + it('reports a manual doc update naming the failing check when a documented row changes', () => { + const result = applyEdit({ + contentPath: fixture.csvPath, + adapter: GYM_CONTENT_ADAPTER, + edit: { kind: 'update-card', spec: { id: 'gym-story-invest', name: 'Municipal Investment' } }, + expectedChecksum: loadChecksum(fixture), + artefactPaths: artefactPaths(fixture), + manualDocs: DEFAULT_MANUAL_DOC_SECTIONS, + }); + + expect(result.ok).toBe(true); + expect(result.artefacts?.ok).toBe(true); // generated artefacts ARE in sync + expect(result.manualDocumentationRequired).toBe(true); + expect(result.manualDocUpdates).toHaveLength(1); + + const update = result.manualDocUpdates[0]; + expect(update.check).toBe('gym-storyline-demo-catalogue'); + expect(update.touchedIds).toEqual(['gym-story-invest']); + // The check that now fails is named, and the generated artefacts are + // explicitly NOT claimed to cover it. + expect(update.path).toContain('GymStorylineDemo.ts'); + expect(update.verifyCommand).toContain('GymStorylineDemo.test.ts'); + expect(result.message).toContain('manual documentation update required'); + expect(result.message).toContain('gym-storyline-demo-catalogue'); + expect(result.message).not.toContain('no hand-written documentation'); + }); + + it('reports nothing when the edited row is not hand-documented', () => { + // Add a brand-new card that no documented section lists. + const result = applyEdit({ + contentPath: fixture.csvPath, + adapter: GYM_CONTENT_ADAPTER, + edit: { + kind: 'add-card', + spec: { + id: 'gym-story-unique', + name: 'A Unique New Card', + effect: 'Nothing else references it.', + hasChoices: false, + // A single-member storyline of its own: an unlinked card inside an + // existing storyline would be unreachable and refused. + storylineId: 'gym-story-lone', + storylineTitle: 'Lone Arc', + }, + }, + expectedChecksum: loadChecksum(fixture), + artefactPaths: artefactPaths(fixture), + manualDocs: DEFAULT_MANUAL_DOC_SECTIONS, + }); + + expect(result.ok).toBe(true); + expect(result.manualDocumentationRequired).toBe(false); + expect(result.manualDocUpdates).toEqual([]); + expect(result.message).toContain('no hand-written documentation section'); + }); + + it('findManualDocUpdates is a pure filter over the registry', () => { + expect(findManualDocUpdates(['gym-story-start'])).toHaveLength(1); + expect(findManualDocUpdates(['something-else'])).toEqual([]); + expect(findManualDocUpdates(['gym-story-start', 'gym-story-end'])).toHaveLength(1); + }); +}); + +describe('setStoryline bridge (the F4 wrapper over the author API)', () => { + let fixture: EditorFixtureFiles; + + beforeEach(() => { + fixture = copyEditorFixtureFiles(); + }); + + afterEach(() => { + fixture.cleanup(); + }); + + it('reassigns a card through setStoryline and validates it', () => { + const csv = readFileSync(fixture.csvPath, 'utf-8'); + const result = setStoryline(csv, GYM_CONTENT_ADAPTER, { + id: 'gym-story-invest', + storylineId: 'gym-story-alt', + storylineTitle: 'Alt Arc', + }); + + expect(result.ok).toBe(true); + expect(result.csv).toContain('gym-story-alt'); + // The old group still has cards; the reassigned card left it. + const preview = previewEdit(csv, GYM_CONTENT_ADAPTER, { + kind: 'set-storyline', + spec: { id: 'gym-story-invest', storylineId: 'gym-story-alt', storylineTitle: 'Alt Arc' }, + }); + expect(preview.ok).toBe(true); + expect(preview.rows[0].after?.storylineId).toBe('gym-story-alt'); + }); + + it('batch-reassigns every member in one author-pass and validates once', () => { + const csv = readFileSync(fixture.csvPath, 'utf-8'); + // Reassign the *whole* group: splitting a storyline without retargeting + // its links would leave members unreachable, which validation refuses. + const ids = ['gym-story-start', 'gym-story-invest', 'gym-story-ignore', 'gym-story-end']; + const result = setStorylineBatch(csv, GYM_CONTENT_ADAPTER, { + ids, + storylineId: 'gym-story-merged', + storylineTitle: 'Merged Arc', + }); + + expect(result.ok).toBe(true); + expect(result.csv).toContain('gym-story-merged'); + // The whole group moved in one candidate that validates. + const preview = previewEdit(csv, GYM_CONTENT_ADAPTER, { + kind: 'set-storyline-batch', + spec: { ids, storylineId: 'gym-story-merged', storylineTitle: 'Merged Arc' }, + }); + expect(preview.ok).toBe(true); + expect(preview.rows.map((row) => row.id).sort()).toEqual([...ids].sort()); + expect(preview.rows.every((row) => row.after?.storylineId === 'gym-story-merged')).toBe(true); + expect(preview.rows.every((row) => row.after?.storylineTitle === 'Merged Arc')).toBe(true); + }); + + it('is a path to the write bridge: applyEdit accepts the set-storyline kind', () => { + const result = applyEdit({ + contentPath: fixture.csvPath, + adapter: GYM_CONTENT_ADAPTER, + edit: { + kind: 'set-storyline', + spec: { id: 'gym-story-end', storylineId: 'gym-story-alt', storylineTitle: 'Alt Arc' }, + }, + expectedChecksum: loadChecksum(fixture), + artefactPaths: artefactPaths(fixture), + manualDocs: [], + }); + + expect(result.ok).toBe(true); + expect(result.written).toBe(true); + expect(readFileSync(fixture.csvPath, 'utf-8')).toContain('gym-story-alt'); + }); +}); + +describe('single-write-path guard (T1) with the bridge in place', () => { + it('passes: no editor module writes the content CSV itself', () => { + const scan = scanWritePaths({ baseDir: REPO_ROOT }); + expect(() => assertSingleWritePath(scan)).not.toThrow(); + expect(scan.skipped).toEqual([]); // every editor root now exists and was scanned + }); + + it('the committed Gym content is byte-identical after all bridge tests ran', () => { + const committed = readFileSync(CONTENT_CSV_PATH, 'utf-8'); + expect(committed).toContain('Public Investment'); + expect(committed).not.toContain('Municipal Investment'); + expect(committed).not.toContain('gym-story-alt'); + }); +}); diff --git a/tests/story-line/editor/build-exclusion.test.ts b/tests/story-line/editor/build-exclusion.test.ts new file mode 100644 index 000000000..01bd9ff6d --- /dev/null +++ b/tests/story-line/editor/build-exclusion.test.ts @@ -0,0 +1,135 @@ +/** + * Build-exclusion guard for the story-line editor (epic AC6). + * + * The editor is a development tool. It must never be reachable from the + * production game entry graph (`index.html` / `main.ts`) and must never be + * produced by — or packaged into — the game/Electron build. The test asserts + * the three facts that make that true: + * + * 1. the production entry documents reference no editor module; + * 2. no production source file (`src/`, `electron/`, `main.ts`) imports the + * editor (only the editor's own modules and its tooling may); + * 3. the build/packaging scripts and the Electron packaging file list never + * mention the editor or its dedicated Vite config. + * + * If a future change wires the editor into the game build, one of these fails + * here rather than bloating the shipped bundle. + * + * @module + */ +import { existsSync, readFileSync, readdirSync } from 'node:fs'; +import { join, relative } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { EDITOR_BUILD_OUT_DIR, EDITOR_PAGE_ROOT } from '../../../vite.editor.config'; +import { REPO_ROOT } from './fixtures'; + +/** Tokens that only ever appear in the editor's own (development-only) files. */ +const EDITOR_TOKENS = [ + 'tools/storyline-editor', + 'storyline-editor-cli', + 'storyline-editor-server', + 'vite.editor.config', + 'src/story-line/editor', + 'story-line/editor', + 'editor/view-model', +]; + +/** The editor's files; their absence would make the scan vacuous. */ +const EDITOR_FILES = [ + 'src/story-line/editor/view-model.ts', + 'src/story-line/editor/field-descriptors.ts', + 'tools/storyline-editor/index.html', + 'tools/storyline-editor/app.ts', + 'tools/storyline-editor/app-shell.ts', + 'tools/storyline-editor/graph-renderer.ts', + 'tools/storyline-editor/config.ts', + 'tools/storyline-editor/bridge-client.ts', + 'tools/storyline-editor/preview-panel.ts', + 'tools/storyline-editor/edit-forms.ts', + 'tools/storyline-editor/add-card-form.ts', + 'tools/storyline-editor/storyline-manager.ts', + 'scripts/storyline-editor-cli.ts', + 'scripts/storyline-editor-server.ts', + 'vite.editor.config.ts', +]; + +/** Recursively lists every `.ts` file under a directory. */ +function listTsFiles(dir: string): string[] { + const files: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + files.push(...listTsFiles(full)); + } else if (entry.name.endsWith('.ts') || entry.name.endsWith('.tsx')) { + files.push(full); + } + } + return files; +} + +/** Finds the editor tokens in a text, returning the matches. */ +function tokensIn(text: string): string[] { + return EDITOR_TOKENS.filter((token) => text.includes(token)); +} + +describe('story-line editor build exclusion', () => { + it('keeps every editor file in place (a move would make the exclusion scan vacuous)', () => { + for (const file of EDITOR_FILES) { + expect(existsSync(join(REPO_ROOT, file)), `${file} exists`).toBe(true); + } + }); + + it('references no editor module from the production game entry documents', () => { + for (const entry of ['index.html', 'main.ts']) { + const text = readFileSync(join(REPO_ROOT, entry), 'utf-8'); + expect(tokensIn(text), `${entry} must not reference the editor`).toEqual([]); + } + }); + + it('is never imported by production source (src/ or electron/) outside its own module', () => { + const offenders: string[] = []; + const roots = ['src', 'electron']; + for (const root of roots) { + for (const file of listTsFiles(join(REPO_ROOT, root))) { + const repoRelative = relative(REPO_ROOT, file).split('\\').join('/'); + if (repoRelative.startsWith('src/story-line/editor/')) continue; + const tokens = tokensIn(readFileSync(file, 'utf-8')); + if (tokens.length > 0) offenders.push(`${repoRelative} (${tokens.join(', ')})`); + } + } + expect(offenders).toEqual([]); + }); + + it('is never built or packaged by the production scripts', () => { + const pkg = JSON.parse(readFileSync(join(REPO_ROOT, 'package.json'), 'utf-8')) as { + scripts?: Record<string, string>; + }; + const productionScripts = Object.entries(pkg.scripts ?? {}).filter( + ([name]) => name === 'build' || name === 'build:electron' || name.startsWith('package'), + ); + expect(productionScripts.length).toBeGreaterThan(0); + for (const [name, command] of productionScripts) { + expect(tokensIn(command), `script "${name}" must not build the editor`).toEqual([]); + expect(command).not.toContain(EDITOR_PAGE_ROOT); + expect(command).not.toContain(EDITOR_BUILD_OUT_DIR); + } + }); + + it('is not shipped by the Electron packaging file list', () => { + const builder = readFileSync(join(REPO_ROOT, 'electron-builder.yml'), 'utf-8'); + expect(tokensIn(builder)).toEqual([]); + + // The packaged application is the built renderer + main process only; the + // editor page lives in an unbuilt source directory and its own outDir. + const filesBlock = builder.split(/^files:\s*$/m)[1]; + expect(filesBlock).toBeDefined(); + const listed = (filesBlock ?? '') + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.startsWith('- ')); + expect(listed).toContain('- dist/**'); + expect(listed.some((entry) => entry.includes('tools/'))).toBe(false); + }); +}); diff --git a/tests/story-line/editor/cli.test.ts b/tests/story-line/editor/cli.test.ts new file mode 100644 index 000000000..3186fd01c --- /dev/null +++ b/tests/story-line/editor/cli.test.ts @@ -0,0 +1,567 @@ +/** + * Unit tests for the story-line editor CLI and its host wiring. + * + * The editor must launch from the core repo root (`npm run storylines:editor + * -- --help`, epic AC1), load a content CSV through a content adapter (AC2) + * and keep the page/manifest URL contract in sync. The host is not bound to a + * port in these tests: the middleware is exercised directly and the Vite + * server is created without listening, so the suite stays fast and hermetic. + * + * @module + */ +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { + DEFAULT_CONTENT_PATH, + GYM_CONTENT_ADAPTER, +} from '../../../src/story-line/content/adapters'; +import { + buildStorylineManifestFromCsv, + type StorylineManifest, +} from '../../../src/story-line/content/manifest'; +import { + parseStorylineEditorArgs, + parseEditorApplyRequest, + isContentAdapter, + loadEditorManifest, + resolveAdapterSpecifier, + resolveEditorAdapter, + runStorylineEditorCli, + EDITOR_USAGE, + type StorylineEditorCliDeps, +} from '../../../scripts/storyline-editor-cli'; +import { + createEditorHost, + serveEditorManifest, + storylineEditorManifestPlugin, + EDITOR_MANIFEST_PLUGIN_NAME, +} from '../../../scripts/storyline-editor-server'; +import { + EDITOR_CONTENT_PATH, + EDITOR_CONTENT_URL, + EDITOR_MANIFEST_PATH, + EDITOR_MANIFEST_URL, +} from '../../../tools/storyline-editor/config'; +import { EDITOR_PAGE_ROOT, EDITOR_SERVER_PORT } from '../../../vite.editor.config'; +import { CONTENT_CSV_PATH, REPO_ROOT, copyEditorFixtureFiles } from './fixtures'; + +/** Builds the Gym manifest from the committed content CSV. */ +function gymManifest(): StorylineManifest { + return buildStorylineManifestFromCsv(readFileSync(CONTENT_CSV_PATH, 'utf-8'), GYM_CONTENT_ADAPTER); +} + +/** A structurally valid adapter for injected-importer tests. */ +const FIXTURE_ADAPTER = { + gameId: 'fixture', + columns: ['id', 'name', 'effect'], + fields: { id: 'id', name: 'name', effect: 'effect' }, +}; + +/** Captures everything written to stdout/stderr while an argv is parsed. */ +function captureParseError(argv: readonly string[]): string { + try { + parseStorylineEditorArgs(argv); + return ''; + } catch (error) { + return (error as Error).message; + } +} + +/** Runs the async CLI with its process streams captured. */ +async function captureCli( + argv: readonly string[], + deps: StorylineEditorCliDeps = {}, +): Promise<{ code: number; stdout: string; stderr: string }> { + const stdoutWrite = process.stdout.write.bind(process.stdout); + const stderrWrite = process.stderr.write.bind(process.stderr); + let stdout = ''; + let stderr = ''; + process.stdout.write = ((chunk: string | Uint8Array) => { + stdout += chunk.toString(); + return true; + }) as typeof process.stdout.write; + process.stderr.write = ((chunk: string | Uint8Array) => { + stderr += chunk.toString(); + return true; + }) as typeof process.stderr.write; + try { + const code = await runStorylineEditorCli(argv, deps); + return { code, stdout, stderr }; + } finally { + process.stdout.write = stdoutWrite; + process.stderr.write = stderrWrite; + } +} + +describe('parseStorylineEditorArgs', () => { + it('defaults to the Gym content, the editor port and loopback', () => { + expect(parseStorylineEditorArgs([])).toEqual({ + help: false, + port: EDITOR_SERVER_PORT, + host: '127.0.0.1', + contentPath: DEFAULT_CONTENT_PATH, + adapterRef: undefined, + apply: undefined, + dryRun: false, + }); + }); + + it('parses every documented flag', () => { + expect( + parseStorylineEditorArgs([ + '--content', + 'custom/storylines.csv', + '--adapter', + './my-adapter.ts', + '--port', + '5180', + '--host', + '0.0.0.0', + '--apply', + '{"edit":{"kind":"update-card","spec":{"id":"a","name":"b"}}}', + '--dry-run', + '--help', + ]), + ).toEqual({ + help: true, + port: 5180, + host: '0.0.0.0', + contentPath: 'custom/storylines.csv', + adapterRef: './my-adapter.ts', + apply: '{"edit":{"kind":"update-card","spec":{"id":"a","name":"b"}}}', + dryRun: true, + }); + }); + + it('rejects a non-numeric or out-of-range port', () => { + expect(captureParseError(['--port', 'nope'])).toContain('--port must be an integer'); + expect(captureParseError(['--port', '70000'])).toContain('--port must be an integer'); + expect(captureParseError(['--port'])).toContain('--port must be an integer'); + }); +}); + +describe('editor page/host contract', () => { + it('serves the manifest at the path the page fetches', () => { + expect(EDITOR_MANIFEST_URL).toBe('./manifest.json'); + expect(EDITOR_MANIFEST_PATH).toBe('/manifest.json'); + expect(`.${EDITOR_MANIFEST_PATH}`).toBe(EDITOR_MANIFEST_URL); + }); + + it('serves the editable content at the path the page fetches', () => { + expect(EDITOR_CONTENT_URL).toBe('./content.json'); + expect(EDITOR_CONTENT_PATH).toBe('/content.json'); + expect(`.${EDITOR_CONTENT_PATH}`).toBe(EDITOR_CONTENT_URL); + }); +}); + +describe('resolveAdapterSpecifier', () => { + it('turns relative and absolute paths into file URLs', () => { + expect(resolveAdapterSpecifier('./adapters/my-game.ts', '/repo')).toBe( + 'file:///repo/adapters/my-game.ts', + ); + expect(resolveAdapterSpecifier('/abs/adapter.mjs', '/repo')).toBe('file:///abs/adapter.mjs'); + }); + + it('leaves bare and scoped package specifiers untouched', () => { + expect(resolveAdapterSpecifier('gym', '/repo')).toBe('gym'); + expect(resolveAdapterSpecifier('@scope/tce-adapter', '/repo')).toBe('@scope/tce-adapter'); + }); +}); + +describe('isContentAdapter', () => { + it('accepts the shipped Gym adapter and a valid fixture', () => { + expect(isContentAdapter(GYM_CONTENT_ADAPTER)).toBe(true); + expect(isContentAdapter(FIXTURE_ADAPTER)).toBe(true); + }); + + it('rejects anything that is not a declarative content adapter', () => { + expect(isContentAdapter(undefined)).toBe(false); + expect(isContentAdapter('gym')).toBe(false); + expect(isContentAdapter({ gameId: 'x', columns: [] })).toBe(false); + expect( + isContentAdapter({ gameId: '', columns: [], fields: { id: 'id', name: 'name' } }), + ).toBe(false); + expect(isContentAdapter({ gameId: 'x', columns: [] })).toBe(false); + }); +}); + +describe('resolveEditorAdapter', () => { + it('defaults to the shipped Gym adapter and resolves shipped ids', async () => { + expect(await resolveEditorAdapter()).toBe(GYM_CONTENT_ADAPTER); + expect(await resolveEditorAdapter('gym')).toBe(GYM_CONTENT_ADAPTER); + }); + + it('loads an adapter module through the injected importer (default / adapter / contentAdapter exports)', async () => { + const seen: string[] = []; + const importer = async (specifier: string): Promise<unknown> => { + seen.push(specifier); + return { default: FIXTURE_ADAPTER }; + }; + + expect(await resolveEditorAdapter('./my-adapter.ts', importer, '/repo')).toEqual(FIXTURE_ADAPTER); + expect(seen).toEqual(['file:///repo/my-adapter.ts']); + + expect( + await resolveEditorAdapter('some-package', async () => ({ adapter: FIXTURE_ADAPTER })), + ).toEqual(FIXTURE_ADAPTER); + expect( + await resolveEditorAdapter('some-package', async () => ({ contentAdapter: FIXTURE_ADAPTER })), + ).toEqual(FIXTURE_ADAPTER); + }); + + it('reports an unresolvable adapter instead of inventing one', async () => { + expect( + await resolveEditorAdapter('./missing.ts', async () => { + throw new Error('not found'); + }), + ).toBeUndefined(); + expect(await resolveEditorAdapter('./bad.ts', async () => ({ default: { gameId: 'x' } }))).toBeUndefined(); + expect(await resolveEditorAdapter('unknown-id', async () => undefined)).toBeUndefined(); + }); +}); + +describe('loadEditorManifest (content CSV through the adapter)', () => { + it('builds the manifest from the committed Gym content and adapter', () => { + const loaded = loadEditorManifest(CONTENT_CSV_PATH, GYM_CONTENT_ADAPTER); + expect(loaded.csvPath).toBe(CONTENT_CSV_PATH); + expect(loaded.manifest.gameId).toBe('gym'); + expect(loaded.manifest.nodes).toHaveLength(4); + expect(loaded.manifest.edges).toHaveLength(6); + }); + + it('reads a synthetic CSV through a synthetic adapter', () => { + const temp = mkdtempSync(join(tmpdir(), 'tce-editor-adapter-')); + try { + const csvPath = join(temp, 'cards.csv'); + writeFileSync( + csvPath, + 'key,title,flavour,arc\none,First,It starts,arc-1\ntwo,Second,It ends,arc-1\n', + 'utf-8', + ); + const adapter = { + gameId: 'fixture', + columns: ['key', 'title', 'flavour', 'arc'], + fields: { id: 'key', name: 'title', effect: 'flavour', storylineId: 'arc' }, + }; + const { manifest } = loadEditorManifest(csvPath, adapter); + expect(manifest.gameId).toBe('fixture'); + expect(manifest.nodes.map((node) => node.id)).toEqual(['one', 'two']); + expect(manifest.storylineIds).toEqual(['arc-1']); + } finally { + rmSync(temp, { recursive: true, force: true }); + } + }); + + it('fails with an actionable message when the CSV is unreadable', () => { + expect(() => loadEditorManifest('/no/such/storylines.csv', GYM_CONTENT_ADAPTER)).toThrow( + /cannot read .*storylines\.csv/, + ); + }); +}); + +describe('serveEditorManifest', () => { + it('serves the manifest JSON at the editor manifest path (query strings included)', () => { + const manifest = gymManifest(); + const calls: { headers: Record<string, string>; body: string }[] = []; + let headers: Record<string, string> = {}; + const response = { + setHeader: (name: string, value: string) => { + headers[name] = value; + }, + end: (body?: string) => { + calls.push({ headers: { ...headers }, body: body ?? '' }); + headers = {}; + }, + }; + + expect(serveEditorManifest(manifest, { url: '/manifest.json' }, response)).toBe(true); + expect(serveEditorManifest(manifest, { url: '/manifest.json?v=2' }, response)).toBe(true); + expect(calls).toHaveLength(2); + expect(calls[0].headers['Content-Type']).toContain('application/json'); + expect(calls[0].headers['Cache-Control']).toBe('no-store'); + expect(calls[0].body).toBe(`${JSON.stringify(manifest, null, 2)}\n`); + expect(JSON.parse(calls[1].body)).toEqual(manifest); + }); + + it('hands other requests to the next middleware', () => { + let ended = false; + const handled = serveEditorManifest(gymManifest(), { url: '/app.ts' }, { + setHeader: () => undefined, + end: () => { + ended = true; + }, + }); + expect(handled).toBe(false); + expect(ended).toBe(false); + }); +}); + +describe('storylineEditorManifestPlugin', () => { + it('registers a middleware that serves the manifest and chains other URLs', () => { + const manifest = gymManifest(); + const middlewares: ((req: { url?: string }, res: unknown, next: () => void) => void)[] = []; + const plugin = storylineEditorManifestPlugin(manifest); + expect(plugin.name).toBe(EDITOR_MANIFEST_PLUGIN_NAME); + const configureServer = plugin.configureServer as unknown as + | ((server: unknown) => void) + | undefined; + configureServer?.({ + middlewares: { + use: (fn: (req: { url?: string }, res: unknown, next: () => void) => void) => + middlewares.push(fn), + }, + }); + + expect(middlewares).toHaveLength(1); + + const bodies: string[] = []; + const response = { + setHeader: () => undefined, + end: (body?: string) => bodies.push(body ?? ''), + }; + let chained = 0; + middlewares[0]({ url: '/manifest.json' }, response, () => { + chained += 1; + }); + middlewares[0]({ url: '/app.ts' }, response, () => { + chained += 1; + }); + + expect(bodies).toHaveLength(1); + expect(JSON.parse(bodies[0])).toEqual(manifest); + expect(chained).toBe(1); + }); +}); + +describe('createEditorHost', () => { + it('composes the dedicated editor config with the manifest middleware (no listen)', async () => { + const host = await createEditorHost({ + manifest: gymManifest(), + port: 5199, + host: '127.0.0.1', + listen: false, + logLevel: 'silent', + }); + try { + expect(host.server.config.root.endsWith(EDITOR_PAGE_ROOT)).toBe(true); + expect(host.server.config.server.port).toBe(5199); + expect(host.server.config.plugins.some((plugin) => plugin.name === EDITOR_MANIFEST_PLUGIN_NAME)).toBe(true); + expect(host.url).toBeUndefined(); + } finally { + await host.close(); + } + }); +}); + +describe('runStorylineEditorCli', () => { + it('prints usage and exits 0 on --help', async () => { + const { code, stdout, stderr } = await captureCli(['--help']); + expect(code).toBe(0); + expect(stderr).toBe(''); + + // The usage text is the user-facing contract: every documented flag. + expect(stdout).toContain('Usage:'); + for (const flag of ['--content', '--adapter', '--port', '--host', '--apply', '--dry-run', '--help']) { + expect(stdout).toContain(flag); + } + expect(stdout).toBe(`${EDITOR_USAGE}\n`); + }); + + it('exits 1 with the usage text when an option is invalid', async () => { + const { code, stderr } = await captureCli(['--port', 'nope']); + expect(code).toBe(1); + expect(stderr).toContain('--port must be an integer'); + expect(stderr).toContain('Usage:'); + }); + + it('exits 1 for an unknown adapter and for unreadable content', async () => { + const unknown = await captureCli(['--adapter', 'no-such-adapter'], { + importer: async () => undefined, + }); + expect(unknown.code).toBe(1); + expect(unknown.stderr).toContain('unknown --adapter'); + + const missing = await captureCli(['--content', '/no/such/file.csv']); + expect(missing.code).toBe(1); + expect(missing.stderr).toContain('cannot read'); + }); + + it('is the script wired into package.json', () => { + const pkg = JSON.parse(readFileSync(join(REPO_ROOT, 'package.json'), 'utf-8')) as { + scripts?: Record<string, string>; + }; + expect(pkg.scripts?.['storylines:editor']).toContain('scripts/storyline-editor-cli.ts'); + }); + + it('runs `npm run storylines:editor -- --help` from the repo root and exits 0 (AC1)', () => { + const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm'; + const result = spawnSync(npm, ['run', '--silent', 'storylines:editor', '--', '--help'], { + cwd: REPO_ROOT, + encoding: 'utf-8', + timeout: 120_000, + }); + expect(result.stderr).not.toContain('ERR_MODULE_NOT_FOUND'); + expect(result.status).toBe(0); + expect(result.stdout).toContain('Usage:'); + expect(result.stdout).toContain('--adapter'); + }); +}); + +describe('headless --apply (the same bridge, no dev server)', () => { + it('is wired into the CLI run: a refused edit exits non-zero and leaves the content byte-identical', async () => { + const before = readFileSync(CONTENT_CSV_PATH, 'utf-8'); + const payload = JSON.stringify({ + edit: { kind: 'update-card', spec: { id: 'gym-story-start', options: 'Invest|gym-story-invest;Ignore|gym-story-ghost' } }, + }); + + const { code, stdout, stderr } = await captureCli([ + '--content', + DEFAULT_CONTENT_PATH, + '--apply', + payload, + ]); + + expect(code).toBe(1); + expect(stderr).toContain('refused to write'); + expect(stderr).toContain('gym-story-ghost'); + + const result = JSON.parse(stdout) as { + ok: boolean; + status: string; + written: boolean; + errors: string[]; + }; + expect(result.ok).toBe(false); + expect(result.status).toBe('invalid'); + expect(result.written).toBe(false); + expect(result.errors.join(' ')).toContain('gym-story-ghost'); + + // The content CSV is byte-identical after the refused edit. + expect(readFileSync(CONTENT_CSV_PATH, 'utf-8')).toBe(before); + }); + + it('applies a valid edit against a fixture copy and regenerates the artefacts', async () => { + const fixture = copyEditorFixtureFiles(); + try { + const before = readFileSync(fixture.csvPath, 'utf-8'); + const payload = JSON.stringify({ + edit: { kind: 'update-card', spec: { id: 'gym-story-invest', name: 'Municipal Investment' } }, + artefactPaths: { json: fixture.manifestPath, mermaid: fixture.mermaidPath }, + }); + + const { code, stdout, stderr } = await captureCli([ + '--content', + fixture.csvPath, + '--apply', + payload, + ]); + + expect(stderr).toBe(''); + expect(code).toBe(0); + const result = JSON.parse(stdout) as { + ok: boolean; + status: string; + written: boolean; + artefacts: { ok: boolean; regenerated: boolean }; + manualDocumentationRequired: boolean; + }; + expect(result.ok).toBe(true); + expect(result.status).toBe('applied'); + expect(result.written).toBe(true); + expect(result.artefacts.ok).toBe(true); + expect(result.artefacts.regenerated).toBe(true); + // The Gym demo section documents the edited row by hand. + expect(result.manualDocumentationRequired).toBe(true); + + expect(readFileSync(fixture.csvPath, 'utf-8')).toBe( + before.replace('Public Investment', 'Municipal Investment'), + ); + expect(readFileSync(fixture.manifestPath, 'utf-8')).toContain('Municipal Investment'); + } finally { + fixture.cleanup(); + } + }); + + it('supports dry-run: exit 0 and nothing written', async () => { + const before = readFileSync(CONTENT_CSV_PATH, 'utf-8'); + const payload = JSON.stringify({ + edit: { kind: 'update-card', spec: { id: 'gym-story-invest', name: 'Municipal Investment' } }, + dryRun: true, + }); + + const { code, stdout } = await captureCli([ + '--content', + DEFAULT_CONTENT_PATH, + '--apply', + payload, + ]); + + expect(code).toBe(0); + const result = JSON.parse(stdout) as { status: string; written: boolean }; + expect(result.status).toBe('dry-run'); + expect(result.written).toBe(false); + expect(readFileSync(CONTENT_CSV_PATH, 'utf-8')).toBe(before); + }); + + it('rejects a malformed payload with a non-zero exit', async () => { + const { code, stderr } = await captureCli([ + '--content', + DEFAULT_CONTENT_PATH, + '--apply', + '{not json', + ]); + expect(code).toBe(1); + expect(stderr).toContain('--apply must be JSON'); + }); +}); + +describe('parseEditorApplyRequest', () => { + it('parses inline JSON, an @file payload and rejects bad shapes', () => { + const inline = parseEditorApplyRequest( + '{"edit":{"kind":"update-card","spec":{"id":"a","name":"b"}},"dryRun":true}', + ); + expect('request' in inline).toBe(true); + if ('request' in inline) { + expect(inline.request.edit.kind).toBe('update-card'); + expect(inline.request.dryRun).toBe(true); + expect(inline.request.expectedChecksum).toBeUndefined(); + } + + const badJson = parseEditorApplyRequest('nope'); + expect('error' in badJson ? badJson.error : '').toContain('JSON'); + const missingEdit = parseEditorApplyRequest('{"a":1}'); + expect('error' in missingEdit ? missingEdit.error : '').toContain('"edit"'); + const badKind = parseEditorApplyRequest('{"edit":{"kind":"boom","spec":{}}}'); + expect('error' in badKind ? badKind.error : '').toContain('boom'); + const bare = parseEditorApplyRequest( + '{"edit":{"kind":"update-card","spec":{"id":"a","name":"b"}}}', + ); + if (!('error' in bare)) { + expect(bare.request.artefactPaths).toBeUndefined(); + } + }); + + it('reads an @file payload relative to baseDir', () => { + const temp = mkdtempSync(join(tmpdir(), 'tce-editor-apply-')); + try { + const file = join(temp, 'edit.json'); + writeFileSync( + file, + '{"edit":{"kind":"set-storyline","spec":{"id":"a","storylineId":"arc","storylineTitle":"Arc"}}}', + 'utf-8', + ); + const parsed = parseEditorApplyRequest(`@${file}`, temp); + expect('request' in parsed).toBe(true); + if ('request' in parsed) { + expect(parsed.request.edit.kind).toBe('set-storyline'); + } + } finally { + rmSync(temp, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/story-line/editor/concurrency.test.ts b/tests/story-line/editor/concurrency.test.ts new file mode 100644 index 000000000..2db3057dc --- /dev/null +++ b/tests/story-line/editor/concurrency.test.ts @@ -0,0 +1,202 @@ +/** + * The editor write bridge's concurrency guard. + * + * The guard exists so an editor that loaded a content CSV can never clobber an + * edit someone else made on disk in the meantime: the checksum captured at load + * is compared, by the writer itself, with the checksum of the file it just read. + * On a mismatch nothing is written and the caller is told to reload. + * + * These tests pin the three properties that matter: + * + * - a stale token is refused with a reload instruction and a byte-identical file; + * - the guard lives in the single writer (`applyContentEditToFile`), so no + * caller can bypass it; + * - the checksum is the content model's own `computeContentChecksum` — there is + * no second hash to drift out of step. + * + * All edits run against temp copies (`copyEditorFixtureFiles`), never the + * committed Gym content. + * + * @module + */ +import { readFileSync, writeFileSync } from 'node:fs'; + + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { GYM_CONTENT_ADAPTER } from '../../../src/story-line/content/adapters'; +import { + applyContentEditToFile, + updateContentCard, +} from '../../../src/story-line/content/author'; +import { computeContentChecksum } from '../../../src/story-line/content/csv'; +import { applyEdit, previewEdit } from '../../../src/story-line/editor/api'; +import { CONTENT_CSV_PATH, copyEditorFixtureFiles, type EditorFixtureFiles } from './fixtures'; + +/** The edit used by these tests: rename one documented Gym card. */ +const RENAME = { + kind: 'update-card', + spec: { id: 'gym-story-invest', name: 'Municipal Investment' }, +} as const; + +/** The artefact paths of a fixture copy (same temp directory, real basenames). */ +function artefactPaths(fixture: EditorFixtureFiles) { + return { json: fixture.manifestPath, mermaid: fixture.mermaidPath }; +} + +/** Loads the fixture content and returns its checksum. */ +function loadChecksum(fixture: EditorFixtureFiles): string { + return computeContentChecksum(readFileSync(fixture.csvPath, 'utf-8')); +} + +describe('editor write bridge concurrency guard', () => { + let fixture: EditorFixtureFiles; + + beforeEach(() => { + fixture = copyEditorFixtureFiles(); + }); + + afterEach(() => { + fixture.cleanup(); + }); + + it('refuses to write when the content changed on disk since load and instructs a reload', () => { + const loadedChecksum = loadChecksum(fixture); + + // Someone else edits the file after the editor loaded it. + const external = `${readFileSync(fixture.csvPath, 'utf-8')}`.replace( + 'Town Meeting', + 'Town Assembly', + ); + writeFileSync(fixture.csvPath, external, 'utf-8'); + const externalChecksum = computeContentChecksum(external); + expect(externalChecksum).not.toBe(loadedChecksum); + + const result = applyEdit({ + contentPath: fixture.csvPath, + adapter: GYM_CONTENT_ADAPTER, + edit: RENAME, + expectedChecksum: loadedChecksum, + artefactPaths: artefactPaths(fixture), + manualDocs: [], + }); + + expect(result.ok).toBe(false); + expect(result.status).toBe('stale-content'); + expect(result.code).toBe(409); + expect(result.written).toBe(false); + expect(result.reloadRequired).toBe(true); + expect(result.message).toMatch(/reload/i); + expect(result.errors.join(' ')).toContain(loadedChecksum); + expect(result.errors.join(' ')).toContain(externalChecksum); + + // Nothing was clobbered: the external edit survives byte for byte. + expect(readFileSync(fixture.csvPath, 'utf-8')).toBe(external); + }); + + it('accepts the edit when the token matches and reports the new checksum', () => { + const loadedChecksum = loadChecksum(fixture); + + const result = applyEdit({ + contentPath: fixture.csvPath, + adapter: GYM_CONTENT_ADAPTER, + edit: RENAME, + expectedChecksum: loadedChecksum, + artefactPaths: artefactPaths(fixture), + manualDocs: [], + }); + + expect(result.ok).toBe(true); + expect(result.status).toBe('applied'); + expect(result.written).toBe(true); + + const written = readFileSync(fixture.csvPath, 'utf-8'); + expect(result.checksum).toBe(computeContentChecksum(written)); + expect(result.checksum).not.toBe(loadedChecksum); + expect(written).toContain('Municipal Investment'); + + // The old token is now itself stale — a retry cannot double-apply. + const retry = applyEdit({ + contentPath: fixture.csvPath, + adapter: GYM_CONTENT_ADAPTER, + edit: RENAME, + expectedChecksum: loadedChecksum, + artefactPaths: artefactPaths(fixture), + manualDocs: [], + }); + expect(retry.status).toBe('stale-content'); + expect(readFileSync(fixture.csvPath, 'utf-8')).toBe(written); + }); + + it('enforces the guard inside the writer, so no caller can bypass it', () => { + const before = readFileSync(fixture.csvPath, 'utf-8'); + const stale = applyContentEditToFile({ + contentPath: fixture.csvPath, + adapter: GYM_CONTENT_ADAPTER, + expectedChecksum: 'deadbeef', + mutate: (csv) => updateContentCard(csv, GYM_CONTENT_ADAPTER, RENAME.spec), + }); + + expect(stale.ok).toBe(false); + expect(stale.stale).toBe(true); + expect(stale.reloadRequired).toBe(true); + expect(stale.written).toBe(false); + expect(readFileSync(fixture.csvPath, 'utf-8')).toBe(before); + + const accepted = applyContentEditToFile({ + contentPath: fixture.csvPath, + adapter: GYM_CONTENT_ADAPTER, + expectedChecksum: computeContentChecksum(before), + mutate: (csv) => updateContentCard(csv, GYM_CONTENT_ADAPTER, RENAME.spec), + }); + expect(accepted.ok).toBe(true); + expect(accepted.written).toBe(true); + expect(accepted.stale).toBeUndefined(); + }); + + it('uses the content model checksum (no second hash) for both load and write', () => { + const loaded = previewEdit( + readFileSync(fixture.csvPath, 'utf-8'), + GYM_CONTENT_ADAPTER, + RENAME, + ); + expect(loaded.checksum).toBe(loadChecksum(fixture)); + + // A byte-only change (whitespace) is still a change to the token. + writeFileSync(fixture.csvPath, `${readFileSync(fixture.csvPath, 'utf-8')}\n`, 'utf-8'); + expect(computeContentChecksum(readFileSync(fixture.csvPath, 'utf-8'))).not.toBe(loaded.checksum); + }); + + it('never writes on a refused preview (the token is read-only there)', () => { + const before = readFileSync(fixture.csvPath, 'utf-8'); + const preview = previewEdit(before, GYM_CONTENT_ADAPTER, { + kind: 'update-card', + spec: { id: 'gym-story-start', options: 'Invest|gym-story-invest;Ignore|gym-story-ghost' }, + }); + + expect(preview.ok).toBe(false); + expect(preview.errors.join(' ')).toContain('gym-story-ghost'); + expect(readFileSync(fixture.csvPath, 'utf-8')).toBe(before); + }); +}); + +describe('concurrency fixture isolation', () => { + it('runs against a temp copy, leaving the committed Gym content untouched', () => { + const committed = readFileSync(CONTENT_CSV_PATH, 'utf-8'); + const fixture = copyEditorFixtureFiles(); + try { + applyEdit({ + contentPath: fixture.csvPath, + adapter: GYM_CONTENT_ADAPTER, + edit: RENAME, + expectedChecksum: computeContentChecksum(committed), + artefactPaths: artefactPaths(fixture), + manualDocs: [], + }); + expect(readFileSync(fixture.csvPath, 'utf-8')).toContain('Municipal Investment'); + } finally { + fixture.cleanup(); + } + expect(readFileSync(CONTENT_CSV_PATH, 'utf-8')).toBe(committed); + }); +}); diff --git a/tests/story-line/editor/edit-forms.test.ts b/tests/story-line/editor/edit-forms.test.ts new file mode 100644 index 000000000..ee9722fe1 --- /dev/null +++ b/tests/story-line/editor/edit-forms.test.ts @@ -0,0 +1,606 @@ +/** + * Curated, adapter-driven card edit forms (F3). + * + * These tests pin the slice's contract without a browser: + * + * - the form field set is **derived from the content adapter** — every + * core-generic story-line field the adapter maps plus every adapter-declared + * column, and nothing hard-coded; + * - an edit touching **every** core-generic field *and* every adapter-declared + * field validates and is written through the core author API (a round-trip + * against a temp fixture copy); + * - the add-card spec (and the guided add-and-link spec) create a card that + * belongs to a story-line and — when linked — is reachable from it; + * - validator errors are attributed to the offending field and a refused + * preview writes nothing; + * - pending (unwritten) changes never reach the file until `applyEdit`. + * + * The DOM behaviour (rendering, inline error slots, discard, the full UI + * round-trip) is covered in `edit-roundtrip.browser.test.ts`, which drives the + * real plain-DOM forms in Chromium against an in-memory bridge running the same + * pure preview/validation seam. + * + * All edits run against temp copies; the committed Gym content is never + * mutated (asserted at the end). + * + * @module + */ +import { readFileSync } from 'node:fs'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { GYM_CONTENT_ADAPTER } from '../../../src/story-line/content/adapters'; +import { buildStorylineManifestFromCsv } from '../../../src/story-line/content/manifest'; +import { readContentCards } from '../../../src/story-line/content/mutate'; +import type { ContentAdapter } from '../../../src/story-line/content/types'; +import { applyEdit } from '../../../src/story-line/editor/api'; +import { previewEdit } from '../../../src/story-line/editor/edits'; +import { + attributeEditErrors, + buildAddSpec, + buildCardFieldDescriptors, + buildNewCardFieldDescriptors, + buildUpdateSpec, + draftFromDescriptors, +} from '../../../src/story-line/editor/field-descriptors'; +import { + CONTENT_CSV_PATH, + copyEditorFixtureFiles, + type EditorFixtureFiles, +} from './fixtures'; +import { + buildEditorContent, + loadEditorSession, + serveEditorContent, +} from '../../../scripts/storyline-editor-server'; +import { EDITOR_CONTENT_PATH } from '../../../tools/storyline-editor/config'; + +/** The artefact paths of a fixture copy. */ +function artefactPaths(fixture: EditorFixtureFiles) { + return { json: fixture.manifestPath, mermaid: fixture.mermaidPath }; +} + +/** Loads a card from a fixture CSV by id. */ +function card(adapter: ContentAdapter, csv: string, id: string) { + const found = readContentCards(csv, adapter).find((candidate) => candidate.id === id); + if (!found) throw new Error(`fixture card "${id}" not found`); + return found; +} + +/** A narrow adapter with no legacy choice/link columns. */ +const NARROW_ADAPTER: ContentAdapter = { + gameId: 'fixture', + columns: ['card_id', 'card_name', 'flavour', 'tone'], + fields: { id: 'card_id', name: 'card_name', effect: 'flavour' }, +}; + +describe('card field descriptors — adapter-driven, not hard-coded', () => { + it('renders every core-generic field and every adapter-declared column', () => { + const csv = readFileSync(CONTENT_CSV_PATH, 'utf-8'); + const target = card(GYM_CONTENT_ADAPTER, csv, 'gym-story-start'); + const descriptors = buildCardFieldDescriptors(GYM_CONTENT_ADAPTER, target, { + knownCardIds: ['gym-story-start', 'gym-story-invest'], + }); + + expect(descriptors.map((descriptor) => descriptor.key)).toEqual([ + 'id', + 'name', + 'effect', + 'hasChoices', + 'options', + 'acceptNextCardId', + 'rejectNextCardId', + 'storylineId', + 'storylineTitle', + 'column:trigger', + 'column:cost', + 'column:coinDelta', + 'column:reputationDelta', + ]); + + const id = descriptors[0]; + expect(id.kind).toBe('read-only'); + expect(id.editable).toBe(false); + expect(id.value).toBe('gym-story-start'); + + const fields = new Map(descriptors.map((descriptor) => [descriptor.key, descriptor])); + expect(fields.get('name')?.value).toBe('Town Meeting'); + expect(fields.get('effect')?.value).toBe('Lose 50 coins'); + expect(fields.get('hasChoices')?.kind).toBe('boolean'); + expect(fields.get('hasChoices')?.value).toBe('false'); + expect(fields.get('options')?.value).toBe( + 'Invest|gym-story-invest;Ignore|gym-story-ignore', + ); + expect(fields.get('storylineId')?.value).toBe('gym-story'); + expect(fields.get('storylineTitle')?.value).toBe('Gym Storyline'); + // Adapter columns are plain text fields, carrying their raw values. + expect(fields.get('column:coinDelta')?.group).toBe('adapter'); + expect(fields.get('column:coinDelta')?.value).toBe('-50'); + // Link fields offer the known card ids as suggestions. + expect(fields.get('options')?.suggestions).toEqual([ + 'gym-story-start', + 'gym-story-invest', + ]); + }); + + it('omits choice/link fields when the adapter does not map them', () => { + const deal = { + card_id: 'a', + card_name: 'Alpha', + flavour: 'Alpha happens.', + tone: 'calm', + }; + const csv = `card_id,card_name,flavour,tone\n${deal.card_id},${deal.card_name},${deal.flavour},${deal.tone}\n`; + const descriptors = buildNewCardFieldDescriptors(NARROW_ADAPTER); + expect(descriptors.map((descriptor) => descriptor.key)).toEqual([ + 'id', + 'name', + 'effect', + 'column:tone', + ]); + + // The same derivation reads a real card through the adapter. + const read = readContentCards(csv, NARROW_ADAPTER)[0]; + const cardDescriptors = buildCardFieldDescriptors(NARROW_ADAPTER, read); + expect(cardDescriptors.find((descriptor) => descriptor.key === 'column:tone')?.value).toBe( + 'calm', + ); + expect(cardDescriptors.some((descriptor) => descriptor.key === 'options')).toBe(false); + }); + + it('makes the card id editable and applies the adapter defaults for a new card', () => { + const descriptors = buildNewCardFieldDescriptors(GYM_CONTENT_ADAPTER); + const byKey = new Map(descriptors.map((descriptor) => [descriptor.key, descriptor])); + expect(byKey.get('id')?.editable).toBe(true); + expect(byKey.get('id')?.value).toBe(''); + expect(byKey.get('column:trigger')?.value).toBe('Incident'); + expect(byKey.get('column:cost')?.value).toBe('0'); + expect(byKey.get('hasChoices')?.value).toBe('false'); + }); +}); + +describe('edit round-trip — every core-generic and adapter field', () => { + let fixture: EditorFixtureFiles; + + beforeEach(() => { + fixture = copyEditorFixtureFiles(); + }); + + afterEach(() => { + fixture.cleanup(); + }); + + it('edits, validates and writes every field through the author API', () => { + const before = readFileSync(fixture.csvPath, 'utf-8'); + const target = card(GYM_CONTENT_ADAPTER, before, 'gym-story-end'); + const descriptors = buildCardFieldDescriptors(GYM_CONTENT_ADAPTER, target); + const draft = draftFromDescriptors(descriptors); + + // Touch every editable field — every core-generic one and every game + // column — with valid successor targets. + const edits: Record<string, string> = { + name: 'The Endgame', + effect: 'Everything changes.', + hasChoices: 'true', + options: 'Reinvest|gym-story-invest;Finish|', + acceptNextCardId: 'gym-story-invest', + rejectNextCardId: 'gym-story-ignore', + storylineId: 'edited-arc', + storylineTitle: 'Edited Arc', + 'column:trigger': 'Event', + 'column:cost': '3', + 'column:coinDelta': '-25', + 'column:reputationDelta': '10', + }; + for (const [key, value] of Object.entries(edits)) { + const descriptor = descriptors.find((candidate) => candidate.key === key); + expect(descriptor, `field ${key} is rendered`).toBeDefined(); + expect(descriptor?.editable).toBe(true); + draft[key] = value; + } + + const spec = buildUpdateSpec(descriptors, draft); + expect(spec).not.toBeNull(); + if (!spec) return; + expect(spec.id).toBe('gym-story-end'); + expect(spec.fields).toEqual({ + trigger: 'Event', + cost: '3', + coinDelta: '-25', + reputationDelta: '10', + }); + + // Preview first: it validates and shows the diff, writing nothing. + const preview = previewEdit(before, GYM_CONTENT_ADAPTER, { + kind: 'update-card', + spec, + }); + expect(preview.ok).toBe(true); + expect(preview.rows[0].status).toBe('changed'); + expect(preview.rows[0].changedColumns).toEqual( + expect.arrayContaining([ + 'name', + 'effect', + 'hasChoices', + 'options', + 'acceptNextCardId', + 'rejectNextCardId', + 'storylineId', + 'storylineTitle', + 'trigger', + 'cost', + 'coinDelta', + 'reputationDelta', + ]), + ); + expect(readFileSync(fixture.csvPath, 'utf-8')).toBe(before); + + // Apply through the bridge (the single author path). + const result = applyEdit({ + contentPath: fixture.csvPath, + adapter: GYM_CONTENT_ADAPTER, + edit: { kind: 'update-card', spec }, + expectedChecksum: preview.checksum, + artefactPaths: artefactPaths(fixture), + manualDocs: [], + }); + expect(result.ok).toBe(true); + expect(result.written).toBe(true); + + // Read the written card back: every edited field round-tripped. + const written = card(GYM_CONTENT_ADAPTER, readFileSync(fixture.csvPath, 'utf-8'), 'gym-story-end'); + expect(written.name).toBe('The Endgame'); + expect(written.effect).toBe('Everything changes.'); + expect(written.hasChoices).toBe(true); + expect(written.options).toBe('Reinvest|gym-story-invest;Finish|'); + expect(written.acceptNextCardId).toBe('gym-story-invest'); + expect(written.rejectNextCardId).toBe('gym-story-ignore'); + expect(written.storylineId).toBe('edited-arc'); + expect(written.storylineTitle).toBe('Edited Arc'); + expect(written.fields.trigger).toBe('Event'); + expect(written.fields.cost).toBe('3'); + expect(written.fields.coinDelta).toBe('-25'); + expect(written.fields.reputationDelta).toBe('10'); + + // The regenerated manifest reflects the edit. + const manifest = buildStorylineManifestFromCsv( + readFileSync(fixture.csvPath, 'utf-8'), + GYM_CONTENT_ADAPTER, + ); + expect(manifest.storylineIds).toContain('edited-arc'); + }); + + it('returns no spec when nothing changed (a no-op never reaches the bridge)', () => { + const csv = readFileSync(fixture.csvPath, 'utf-8'); + const target = card(GYM_CONTENT_ADAPTER, csv, 'gym-story-invest'); + const descriptors = buildCardFieldDescriptors(GYM_CONTENT_ADAPTER, target); + expect(buildUpdateSpec(descriptors, draftFromDescriptors(descriptors))).toBeNull(); + }); +}); + +describe('add-card form — creates a card and links it into a story-line', () => { + let fixture: EditorFixtureFiles; + + beforeEach(() => { + fixture = copyEditorFixtureFiles(); + }); + + afterEach(() => { + fixture.cleanup(); + }); + + it('adds a card into a new story-line group through the author API', () => { + const before = readFileSync(fixture.csvPath, 'utf-8'); + const descriptors = buildNewCardFieldDescriptors(GYM_CONTENT_ADAPTER); + const draft = draftFromDescriptors(descriptors); + Object.assign(draft, { + id: 'gym-story-bonus', + name: 'A Bonus', + effect: 'Gain 10 coins.', + storylineId: 'bonus-arc', + storylineTitle: 'Bonus Arc', + 'column:coinDelta': '10', + }); + + const spec = buildAddSpec(descriptors, draft); + expect(spec.id).toBe('gym-story-bonus'); + expect(spec.storylineId).toBe('bonus-arc'); + expect(spec.storylineTitle).toBe('Bonus Arc'); + + const preview = previewEdit(before, GYM_CONTENT_ADAPTER, { kind: 'add-card', spec }); + // A single-member story-line is reachable by definition, so a new group + // needs no incoming link (adding to an existing chain does — see below). + expect(preview.ok).toBe(true); + const added = preview.rows.find((row) => row.id === 'gym-story-bonus'); + expect(added?.status).toBe('added'); + expect(added?.after?.storylineId).toBe('bonus-arc'); + + const result = applyEdit({ + contentPath: fixture.csvPath, + adapter: GYM_CONTENT_ADAPTER, + edit: { kind: 'add-card', spec }, + expectedChecksum: preview.checksum, + artefactPaths: artefactPaths(fixture), + manualDocs: [], + }); + expect(result.ok).toBe(true); + expect(result.written).toBe(true); + + const csv = readFileSync(fixture.csvPath, 'utf-8'); + const written = card(GYM_CONTENT_ADAPTER, csv, 'gym-story-bonus'); + expect(written.name).toBe('A Bonus'); + expect(written.storylineId).toBe('bonus-arc'); + expect(written.storylineTitle).toBe('Bonus Arc'); + expect(written.fields.coinDelta).toBe('10'); + + // The card is a member of its own story-line group in the manifest. + const manifest = buildStorylineManifestFromCsv(csv, GYM_CONTENT_ADAPTER); + expect(manifest.storylineIds).toContain('bonus-arc'); + expect( + manifest.nodes.find((node) => node.id === 'gym-story-bonus')?.storylineId, + ).toBe('bonus-arc'); + }); +}); + +describe('guided add-and-link — one transactional write', () => { + let fixture: EditorFixtureFiles; + + beforeEach(() => { + fixture = copyEditorFixtureFiles(); + }); + + afterEach(() => { + fixture.cleanup(); + }); + + it('refuses an unlinked addition to an existing chain (unreachable)', () => { + const csv = readFileSync(fixture.csvPath, 'utf-8'); + const preview = previewEdit(csv, GYM_CONTENT_ADAPTER, { + kind: 'add-card', + spec: { + id: 'gym-story-detour', + name: 'A Detour', + storylineId: 'gym-story', + storylineTitle: 'Gym Storyline', + }, + }); + expect(preview.ok).toBe(false); + expect( + preview.validation.issues.some( + (issue) => issue.code === 'unreachable-card' && issue.cardId === 'gym-story-detour', + ), + ).toBe(true); + }); + + it('adds the card and links it in from an existing card atomically', () => { + const before = readFileSync(fixture.csvPath, 'utf-8'); + const spec = { + card: { + id: 'gym-story-detour', + name: 'A Detour', + effect: 'Take the scenic route.', + storylineId: 'gym-story', + storylineTitle: 'Gym Storyline', + }, + linkFrom: { id: 'gym-story-invest', label: 'Take the detour' }, + } as const; + + const preview = previewEdit(before, GYM_CONTENT_ADAPTER, { + kind: 'add-linked-card', + spec, + }); + expect(preview.ok).toBe(true); + expect(preview.affectedIds).toEqual( + expect.arrayContaining(['gym-story-detour', 'gym-story-invest']), + ); + expect(readFileSync(fixture.csvPath, 'utf-8')).toBe(before); + + const result = applyEdit({ + contentPath: fixture.csvPath, + adapter: GYM_CONTENT_ADAPTER, + edit: { kind: 'add-linked-card', spec }, + expectedChecksum: preview.checksum, + artefactPaths: artefactPaths(fixture), + manualDocs: [], + }); + expect(result.ok).toBe(true); + expect(result.written).toBe(true); + + const csv = readFileSync(fixture.csvPath, 'utf-8'); + const added = card(GYM_CONTENT_ADAPTER, csv, 'gym-story-detour'); + expect(added.storylineId).toBe('gym-story'); + const predecessor = card(GYM_CONTENT_ADAPTER, csv, 'gym-story-invest'); + expect(predecessor.options).toBe( + 'Celebrate|gym-story-end;Double down|;Take the detour|gym-story-detour', + ); + + // The linked card is reachable: the regenerated validator verdict is valid. + const after = previewEdit(csv, GYM_CONTENT_ADAPTER, { + kind: 'update-card', + spec: { id: 'gym-story-detour', effect: 'Take the scenic route, again.' }, + }); + expect(after.ok).toBe(true); + }); +}); + +describe('validation errors — attributed inline and the write is blocked', () => { + let fixture: EditorFixtureFiles; + + beforeEach(() => { + fixture = copyEditorFixtureFiles(); + }); + + afterEach(() => { + fixture.cleanup(); + }); + + it('attributes a dangling successor to the Options field and blocks the write', () => { + const before = readFileSync(fixture.csvPath, 'utf-8'); + const target = card(GYM_CONTENT_ADAPTER, before, 'gym-story-start'); + const descriptors = buildCardFieldDescriptors(GYM_CONTENT_ADAPTER, target); + const draft = draftFromDescriptors(descriptors); + draft.options = 'Invest|gym-story-invest;Ignore|gym-story-ghost'; + + const spec = buildUpdateSpec(descriptors, draft); + expect(spec).not.toBeNull(); + if (!spec) return; + + const preview = previewEdit(before, GYM_CONTENT_ADAPTER, { kind: 'update-card', spec }); + expect(preview.ok).toBe(false); + + const errors = attributeEditErrors({ + descriptors, + draft, + cardId: 'gym-story-start', + errors: preview.errors, + issues: preview.validation.issues, + }); + const inline = errors.filter((error) => error.field !== null); + expect(inline.some((error) => error.field === 'options')).toBe(true); + expect(inline.find((error) => error.field === 'options')?.message).toContain( + 'gym-story-ghost', + ); + + // The blocked write leaves the CSV byte-identical. + expect(readFileSync(fixture.csvPath, 'utf-8')).toBe(before); + }); + + it('attributes a missing name, a dropped story-line id and an orphan link', () => { + const csv = readFileSync(fixture.csvPath, 'utf-8'); + const start = card(GYM_CONTENT_ADAPTER, csv, 'gym-story-start'); + const descriptors = buildCardFieldDescriptors(GYM_CONTENT_ADAPTER, start); + + const blankName = attributeEditErrors({ + descriptors, + draft: { ...draftFromDescriptors(descriptors), name: '' }, + cardId: start.id, + errors: [], + issues: [ + { + code: 'missing-required-field', + cardId: start.id, + message: 'Card "gym-story-start" has an empty name.', + }, + ], + }); + expect(blankName[0].field).toBe('name'); + + const droppedStoryline = attributeEditErrors({ + descriptors, + draft: { ...draftFromDescriptors(descriptors), storylineId: '' }, + cardId: start.id, + errors: [], + issues: [ + { + code: 'missing-storyline-id', + cardId: start.id, + message: 'Choice card "gym-story-start" declares no storyline id.', + }, + ], + }); + expect(droppedStoryline[0].field).toBe('storylineId'); + + const orphan = attributeEditErrors({ + descriptors, + draft: { ...draftFromDescriptors(descriptors), hasChoices: 'false', options: '' }, + cardId: start.id, + errors: [], + issues: [ + { + code: 'orphan-link', + cardId: start.id, + message: 'Card "gym-story-start" declares accept/reject links but is not a choice card.', + }, + ], + }); + expect(orphan[0].field).toBe('hasChoices'); + }); + + it('attributes a duplicate new-card id to the id field', () => { + const descriptors = buildNewCardFieldDescriptors(GYM_CONTENT_ADAPTER); + const draft = draftFromDescriptors(descriptors); + draft.id = 'gym-story-start'; + draft.name = 'Copycat'; + + const preview = previewEdit(readFileSync(fixture.csvPath, 'utf-8'), GYM_CONTENT_ADAPTER, { + kind: 'add-card', + spec: buildAddSpec(descriptors, draft), + }); + expect(preview.ok).toBe(false); + const errors = attributeEditErrors({ + descriptors, + draft, + cardId: null, + errors: preview.errors, + issues: preview.validation.issues, + }); + expect(errors.some((error) => error.field === 'id')).toBe(true); + }); +}); + +describe('host content endpoint — the editable content the forms prefill from', () => { + let fixture: EditorFixtureFiles; + + beforeEach(() => { + fixture = copyEditorFixtureFiles(); + }); + + afterEach(() => { + fixture.cleanup(); + }); + + it('serves the serialisable adapter schema plus the raw cards', () => { + const session = loadEditorSession({ + contentPath: fixture.csvPath, + adapter: GYM_CONTENT_ADAPTER, + artefactPaths: artefactPaths(fixture), + }); + const content = buildEditorContent(session); + + expect(content.gameId).toBe('gym'); + expect(content.cards).toHaveLength(4); + // Legacy link columns are present on the raw cards (the manifest omits + // them, the forms need them to prefill). + expect(Object.keys(content.cards[0].fields)).toEqual( + expect.arrayContaining(['trigger', 'cost', 'coinDelta', 'reputationDelta']), + ); + // The adapter projection is declarative data only — no functions cross the + // wire. + expect(content.adapter.fields.options).toBe('options'); + expect(content.adapter.newCardDefaults?.trigger).toBe('Incident'); + expect('describeImpact' in content.adapter).toBe(false); + expect('baseId' in content.adapter).toBe(false); + + // The endpoint answers only its own path. + const headers: Record<string, string> = {}; + let body = ''; + const handled = serveEditorContent( + session, + { url: EDITOR_CONTENT_PATH }, + { setHeader: (name, value) => { headers[name] = value; }, end: (value) => { body = value ?? ''; } }, + ); + expect(handled).toBe(true); + expect(headers['Content-Type']).toContain('application/json'); + const parsed = JSON.parse(body) as { cards: unknown[]; checksum: string }; + expect(parsed.cards).toHaveLength(4); + expect(parsed.checksum).toBe(session.checksum); + + expect( + serveEditorContent( + session, + { url: '/manifest.json' }, + { setHeader: () => {}, end: () => {} }, + ), + ).toBe(false); + }); +}); + +describe('the committed Gym content is never mutated', () => { + it('still holds the original card names after every fixture edit above', () => { + const csv = readFileSync(CONTENT_CSV_PATH, 'utf-8'); + expect(csv).toContain('Public Investment'); + expect(csv).toContain('New Beginning'); + expect(csv).not.toContain('The Endgame'); + expect(csv).not.toContain('gym-story-detour'); + }); +}); diff --git a/tests/story-line/editor/edit-roundtrip.browser.test.ts b/tests/story-line/editor/edit-roundtrip.browser.test.ts new file mode 100644 index 000000000..93296f43f --- /dev/null +++ b/tests/story-line/editor/edit-roundtrip.browser.test.ts @@ -0,0 +1,411 @@ +/** + * Browser round-trip for the curated card edit forms (F3). + * + * The editor page is a plain-DOM dev tool (no Phaser, no game loop), so its + * forms are mounted straight into Vitest's real Chromium document — the same + * decision recorded for the viewer harness (CG-0MVHZ2LS3008ATY9). + * + * This test drives a **real** edit round-trip on the committed Gym fixture: + * + * - the shell, the forms and the preview panel are the shipped modules; + * - the bridge is an in-memory double that runs the **real** pure preview / + * validation seam (`src/story-line/editor/edits.ts`) over the fixture CSV and + * only stubs the file write — the Node-only part, already proven through the + * author API in `edit-forms.test.ts`; + * - so choosing fields, previewing, reading the diff, seeing an inline + * validation error block the write, applying and discarding are all + * exercised end to end, against content that really validates. + * + * @module + */ +import { afterEach, describe, expect, it } from 'vitest'; + +import gymCsvRaw from '../../../example-games/gym/content/storylines.csv?raw'; +import gymManifestJson from '../../../example-games/gym/content/storyline-manifest.json'; +import { GYM_CONTENT_ADAPTER } from '../../../src/story-line/content/adapters'; +import { cardsFromCsv, parseCsv } from '../../../src/story-line/content/csv'; +import type { StorylineManifest } from '../../../src/story-line/content/manifest'; +import { previewEdit, validateEdit, type EditorEdit } from '../../../src/story-line/editor/edits'; +import { mountEditorShell, type EditorShell } from '../../../tools/storyline-editor/app-shell'; +import type { EditorBridge } from '../../../tools/storyline-editor/bridge-client'; +import { waitForCondition } from '../../helpers/waitForCondition'; + +/** The committed Gym manifest — exactly what the editor host serves. */ +const gymManifest = gymManifestJson as StorylineManifest; + +/** The committed Gym content — the cards behind those manifest nodes. */ +const gymCards = cardsFromCsv(parseCsv(gymCsvRaw), GYM_CONTENT_ADAPTER); + +/** One recorded bridge call. */ +interface BridgeCall { + readonly type: 'preview' | 'validate' | 'apply'; + readonly edit: EditorEdit; +} + +/** + * An in-memory bridge: the real preview/validation seam over a CSV string, with + * only the file write stubbed (it replaces the in-memory content). + */ +function createInMemoryBridge(initialCsv: string): { + readonly bridge: EditorBridge; + readonly calls: BridgeCall[]; + csv(): string; +} { + let csv = initialCsv; + const calls: BridgeCall[] = []; + return { + csv: () => csv, + calls, + bridge: { + async preview(edit) { + calls.push({ type: 'preview', edit }); + return previewEdit(csv, GYM_CONTENT_ADAPTER, edit); + }, + async validate(edit) { + calls.push({ type: 'validate', edit }); + return validateEdit(csv, GYM_CONTENT_ADAPTER, edit); + }, + async apply(edit) { + calls.push({ type: 'apply', edit }); + const preview = previewEdit(csv, GYM_CONTENT_ADAPTER, edit); + if (!preview.ok || preview.csv === undefined) { + return { + ok: false, + message: 'refused', + preview, + errors: preview.errors, + written: false, + }; + } + csv = preview.csv; + return { + ok: true, + message: `applied ${edit.kind}`, + preview, + errors: [], + written: true, + checksum: preview.candidateChecksum, + }; + }, + }, + }; +} + +let mounted: { container: HTMLDivElement; shell: EditorShell } | null = null; + +/** Mounts the editor shell with the Gym fixture and in-memory editing. */ +function mount(editing = true): { + container: HTMLDivElement; + shell: EditorShell; + store: ReturnType<typeof createInMemoryBridge>; +} { + const store = createInMemoryBridge(gymCsvRaw); + const container = document.createElement('div'); + document.body.append(container); + const shell = mountEditorShell( + container, + gymManifest, + editing + ? { + adapter: GYM_CONTENT_ADAPTER, + cards: gymCards, + bridge: store.bridge, + } + : {}, + ); + mounted = { container, shell }; + return { container, shell, store }; +} + +/** Clicks an element the way a user would. */ +function click(element: Element | null): void { + if (!element) throw new Error('expected an element to click'); + element.dispatchEvent(new MouseEvent('click', { bubbles: true })); +} + +/** Finds a field wrapper's control. */ +function control(container: HTMLElement, field: string): HTMLInputElement | HTMLTextAreaElement { + const element = container.querySelector(`[data-field="${field}"] [data-role="field-input"]`); + if (!(element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement)) { + throw new Error(`field "${field}" has no input control`); + } + return element; +} + +/** Types a value into a text field. */ +function type(container: HTMLElement, field: string, value: string): void { + const element = control(container, field); + element.value = value; + element.dispatchEvent(new Event('input', { bubbles: true })); +} + +/** Toggles a checkbox field. */ +function toggle(container: HTMLElement, field: string, checked: boolean): void { + const element = control(container, field); + if (!(element instanceof HTMLInputElement)) throw new Error(`field "${field}" is not a checkbox`); + element.checked = checked; + element.dispatchEvent(new Event('change', { bubbles: true })); +} + +/** Selects an option in a `<select>` and fires `change`. */ +function select(container: HTMLElement, role: string, value: string): void { + const element = container.querySelector(`[data-role="${role}"]`); + if (!(element instanceof HTMLSelectElement)) throw new Error(`"${role}" is not a select`); + element.value = value; + element.dispatchEvent(new Event('change', { bubbles: true })); +} + +/** Waits for a predicate with the shared load-tolerant helper. */ +function waitFor(predicate: () => boolean, label: string): Promise<void> { + return waitForCondition(predicate, { timeoutMs: 5_000, intervalMs: 20, label }); +} + +afterEach(() => { + mounted?.shell.dispose(); + mounted?.container.remove(); + mounted = null; +}); + +describe('story-line editor — curated card edit round-trip', () => { + it('edits every core-generic field and every adapter column, previewing and writing once', async () => { + const { container, shell, store } = mount(); + shell.selectNode('gym-story-end'); + click(container.querySelector('[data-role="edit-card"]')); + + expect(container.querySelector('[data-role="card-edit-form"]')).not.toBeNull(); + const renderedFields = [ + ...container.querySelectorAll('[data-role="card-edit-form"] [data-field]'), + ].map((element) => element.getAttribute('data-field')); + expect(renderedFields).toEqual( + expect.arrayContaining([ + 'id', + 'name', + 'effect', + 'hasChoices', + 'options', + 'acceptNextCardId', + 'rejectNextCardId', + 'storylineId', + 'storylineTitle', + 'column:trigger', + 'column:cost', + 'column:coinDelta', + 'column:reputationDelta', + ]), + ); + + // The id is read-only and prefilled; every other control is editable. + const idControl = control(container, 'id'); + expect(idControl.value).toBe('gym-story-end'); + expect(idControl instanceof HTMLInputElement && idControl.readOnly).toBe(true); + expect(control(container, 'name').value).toBe('New Beginning'); + expect(control(container, 'column:coinDelta').value).toBe('0'); + + // Edit every core-generic field and every game column. + type(container, 'name', 'The Endgame'); + type(container, 'effect', 'Everything changes.'); + toggle(container, 'hasChoices', true); + type(container, 'options', 'Reinvest|gym-story-invest;Finish|'); + type(container, 'acceptNextCardId', 'gym-story-invest'); + type(container, 'rejectNextCardId', 'gym-story-ignore'); + type(container, 'storylineId', 'edited-arc'); + type(container, 'storylineTitle', 'Edited Arc'); + type(container, 'column:trigger', 'Event'); + type(container, 'column:cost', '3'); + type(container, 'column:coinDelta', '-25'); + type(container, 'column:reputationDelta', '10'); + + // Preview: the diff renders before anything is written. + click(container.querySelector('[data-action="preview-edit"]')); + await waitFor( + () => container.querySelector('[data-role="diff-row"][data-card-id="gym-story-end"]') !== null, + 'the affected row diff', + ); + expect(store.csv()).toBe(gymCsvRaw); + expect(store.calls.filter((call) => call.type === 'apply')).toHaveLength(0); + + const nameChange = container.querySelector( + '[data-role="diff-change"][data-column="name"]', + ); + expect(nameChange?.querySelector('[data-role="diff-before"]')?.textContent).toBe( + 'New Beginning', + ); + expect(nameChange?.querySelector('[data-role="diff-after"]')?.textContent).toBe('The Endgame'); + const coinChange = container.querySelector( + '[data-role="diff-change"][data-column="coinDelta"]', + ); + expect(coinChange?.querySelector('[data-role="diff-before"]')?.textContent).toBe('0'); + expect(coinChange?.querySelector('[data-role="diff-after"]')?.textContent).toBe('-25'); + expect(container.querySelector('[data-role="preview-status"]')?.getAttribute('data-tone')).toBe( + 'success', + ); + + // Apply: writes through the bridge exactly once and reports the outcome. + click(container.querySelector('[data-action="apply-edit"]')); + await waitFor( + () => + container.querySelector('[data-role="apply-outcome"]')?.getAttribute('data-tone') === + 'success', + 'the apply outcome', + ); + expect(store.calls.filter((call) => call.type === 'apply')).toHaveLength(1); + expect(store.csv()).toContain('The Endgame'); + expect(store.csv()).toContain('edited-arc'); + + // Round-trip: a further preview now treats the written values as `before`. + type(container, 'name', 'The Final Endgame'); + click(container.querySelector('[data-action="preview-edit"]')); + await waitFor( + () => + container.querySelector('[data-column="name"] [data-role="diff-before"]')?.textContent === + 'The Endgame', + 'the second preview to see the applied value as before', + ); + const secondChange = container.querySelector('[data-column="name"]'); + expect(secondChange?.querySelector('[data-role="diff-after"]')?.textContent).toBe( + 'The Final Endgame', + ); + }); + + it('shows validation errors inline and blocks the write', async () => { + const { container, shell, store } = mount(); + shell.selectNode('gym-story-start'); + click(container.querySelector('[data-role="edit-card"]')); + + type(container, 'options', 'Invest|gym-story-invest;Ignore|gym-story-ghost'); + + click(container.querySelector('[data-action="preview-edit"]')); + await waitFor(() => { + const error = container.querySelector('[data-field="options"] [data-role="field-error"]'); + return error instanceof HTMLElement && !error.hidden; + }, 'the inline options error'); + const inline = container.querySelector('[data-field="options"] [data-role="field-error"]'); + expect(inline?.textContent).toContain('gym-story-ghost'); + expect(container.querySelector('[data-field="options"]')?.getAttribute('data-invalid')).toBe( + 'true', + ); + expect(container.querySelector('[data-role="preview-status"]')?.getAttribute('data-tone')).toBe( + 'error', + ); + // The panel keeps the full validator report; the offending card/link is named. + expect(container.querySelector('[data-role="preview-report"]')?.textContent).toContain( + 'RESULT: INVALID', + ); + expect(container.querySelector('[data-role="preview-report"]')?.textContent).toContain( + 'gym-story-ghost', + ); + + // Applying re-previews and refuses: the write never happens. + click(container.querySelector('[data-action="apply-edit"]')); + await waitFor( + () => store.calls.filter((call) => call.type === 'preview').length >= 2, + 'the apply-time preview', + ); + expect(store.calls.filter((call) => call.type === 'apply')).toHaveLength(0); + expect(store.csv()).toBe(gymCsvRaw); + }); + + it('discards pending changes without writing anything', () => { + const { container, shell, store } = mount(); + shell.selectNode('gym-story-invest'); + click(container.querySelector('[data-role="edit-card"]')); + + type(container, 'name', 'Never Written'); + type(container, 'column:coinDelta', '999'); + + click(container.querySelector('[data-action="discard-edit"]')); + + expect(control(container, 'name').value).toBe('Public Investment'); + expect(control(container, 'column:coinDelta').value).toBe('120'); + expect(container.querySelector('[data-role="edit-status"]')?.textContent).toContain( + 'discarded', + ); + expect(store.calls.filter((call) => call.type === 'apply')).toHaveLength(0); + expect(store.csv()).toBe(gymCsvRaw); + }); +}); + +describe('story-line editor — guided add-card form', () => { + it('creates a card and links it into a story-line in one validated write', async () => { + const { container, store } = mount(); + click(container.querySelector('[data-action="add-card"]')); + expect(container.querySelector('[data-role="add-card-form"]')).not.toBeNull(); + + type(container, 'id', 'gym-story-detour'); + type(container, 'name', 'A Detour'); + type(container, 'effect', 'Take the scenic route.'); + // Guided membership: choose the existing story-line (fills id + title). + select(container, 'storyline-select', 'gym-story'); + expect(control(container, 'storylineId').value).toBe('gym-story'); + expect(control(container, 'storylineTitle').value).toBe('Gym Storyline'); + // Guided incoming link: the new card becomes reachable from this card. + select(container, 'link-from', 'gym-story-invest'); + const label = container.querySelector('[data-role="link-label"]'); + if (!(label instanceof HTMLInputElement)) throw new Error('expected a link label input'); + label.value = 'Take the detour'; + label.dispatchEvent(new Event('input', { bubbles: true })); + + click(container.querySelector('[data-action="preview-add"]')); + await waitFor( + () => container.querySelector('[data-role="diff-row"][data-card-id="gym-story-detour"]') !== null, + 'the new-card diff', + ); + expect(container.querySelector('[data-role="diff-row"][data-card-id="gym-story-invest"]')).not.toBeNull(); + expect(store.csv()).toBe(gymCsvRaw); + + click(container.querySelector('[data-action="apply-add"]')); + await waitFor( + () => + container.querySelector('[data-role="apply-outcome"]')?.getAttribute('data-tone') === + 'success', + 'the create outcome', + ); + + expect(store.csv()).toContain('gym-story-detour'); + expect(store.csv()).toContain('A Detour'); + // Both the membership and the incoming link round-tripped. + const written = cardsFromCsv(parseCsv(store.csv()), GYM_CONTENT_ADAPTER); + const added = written.find((card) => card.id === 'gym-story-detour'); + expect(added?.storylineId).toBe('gym-story'); + expect(added?.storylineTitle).toBe('Gym Storyline'); + const predecessor = written.find((card) => card.id === 'gym-story-invest'); + expect(predecessor?.options).toContain('Take the detour|gym-story-detour'); + }); + + it('refuses a guided add that stays unreachable (unlinked into an existing chain)', async () => { + const { container, store } = mount(); + click(container.querySelector('[data-action="add-card"]')); + + type(container, 'id', 'gym-story-orphan'); + type(container, 'name', 'An Orphan'); + select(container, 'storyline-select', 'gym-story'); + + click(container.querySelector('[data-action="preview-add"]')); + await waitFor( + () => + container.querySelector('[data-role="preview-status"]')?.getAttribute('data-tone') === + 'error', + 'the unreachable refusal', + ); + expect(container.querySelector('[data-role="preview-report"]')?.textContent).toContain( + 'unreachable', + ); + + click(container.querySelector('[data-action="apply-add"]')); + await waitFor( + () => store.calls.filter((call) => call.type === 'preview').length >= 2, + 'the create-time preview', + ); + expect(store.calls.filter((call) => call.type === 'apply')).toHaveLength(0); + expect(store.csv()).toBe(gymCsvRaw); + }); +}); + +describe('story-line editor — read-only mode', () => { + it('hides the editing affordances when no bridge is wired', () => { + const { container } = mount(false); + expect(container.querySelector('[data-role="edit-card"]')).toBeNull(); + expect(container.querySelector('[data-action="add-card"]')).toBeNull(); + expect(container.querySelector('[data-role="editor-form-host"]')?.childElementCount).toBe(0); + }); +}); diff --git a/tests/story-line/editor/fixtures.test.ts b/tests/story-line/editor/fixtures.test.ts new file mode 100644 index 000000000..c5dafa43d --- /dev/null +++ b/tests/story-line/editor/fixtures.test.ts @@ -0,0 +1,105 @@ +/** + * Tests for the editor fixture helper (`tests/story-line/editor/fixtures.ts`). + * + * The core contract: edit tests run against isolated temp copies and **never** + * mutate the committed content CSV or artefacts. Underlying paths derive from + * the generator's own exported defaults, so an artefact/schema move makes an + * existence assertion fail loudly instead of silently copying stale files. + * + * @module + */ +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { basename, dirname } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { GYM_CONTENT_ADAPTER } from '../../../src/story-line/content/adapters'; +import { + buildStorylineManifestFromCsv, + serialiseManifest, +} from '../../../src/story-line/content/manifest'; +import { + CONTENT_CSV_PATH, + MANIFEST_ARTEFACT_PATH, + MERMAID_ARTEFACT_PATH, + copyEditorFixtureFiles, +} from './fixtures'; + +describe('story-line editor fixture helper', () => { + // Committed originals, captured once: every test then proves they are + // byte-identical afterwards no matter what the copies were mutated with. + const originals = { + csv: readFileSync(CONTENT_CSV_PATH, 'utf-8'), + manifest: readFileSync(MANIFEST_ARTEFACT_PATH, 'utf-8'), + mermaid: readFileSync(MERMAID_ARTEFACT_PATH, 'utf-8'), + }; + + it('points every fixture source at existing committed files (a moved artefact fails this test)', () => { + // The paths are derived from the generator's exported defaults; if an + // artefact or the content file moves, these existence assertions fail — + // the "one obvious test" the item requires. + expect(CONTENT_CSV_PATH.endsWith('/example-games/gym/content/storylines.csv')).toBe(true); + expect(MANIFEST_ARTEFACT_PATH.endsWith('/example-games/gym/content/storyline-manifest.json')).toBe(true); + expect(MERMAID_ARTEFACT_PATH.endsWith('/example-games/gym/content/storyline-graph.mmd')).toBe(true); + expect(existsSync(CONTENT_CSV_PATH)).toBe(true); + expect(existsSync(MANIFEST_ARTEFACT_PATH)).toBe(true); + expect(existsSync(MERMAID_ARTEFACT_PATH)).toBe(true); + }); + + it('copies the content CSV and artefacts into an isolated temp directory', () => { + const fixture = copyEditorFixtureFiles(); + let dir = ''; + try { + dir = fixture.dir; + // The copies live under the OS temp dir, outside the repository, and + // keep their source filenames (derived from the defaults). + expect(dir.startsWith(tmpdir())).toBe(true); + expect(dirname(fixture.csvPath)).toBe(dir); + expect(basename(fixture.csvPath)).toBe(basename(CONTENT_CSV_PATH)); + expect(basename(fixture.manifestPath)).toBe(basename(MANIFEST_ARTEFACT_PATH)); + expect(basename(fixture.mermaidPath)).toBe(basename(MERMAID_ARTEFACT_PATH)); + + // Copies are distinct paths, but byte-identical to the originals. + expect(fixture.csvPath).not.toBe(CONTENT_CSV_PATH); + expect(fixture.manifestPath).not.toBe(MANIFEST_ARTEFACT_PATH); + expect(fixture.mermaidPath).not.toBe(MERMAID_ARTEFACT_PATH); + expect(readFileSync(fixture.csvPath, 'utf-8')).toBe(originals.csv); + expect(readFileSync(fixture.manifestPath, 'utf-8')).toBe(originals.manifest); + expect(readFileSync(fixture.mermaidPath, 'utf-8')).toBe(originals.mermaid); + } finally { + fixture.cleanup(); + } + expect(existsSync(dir)).toBe(false); + }); + + it('leaves the committed originals byte-identical after editing the copies', () => { + const fixture = copyEditorFixtureFiles(); + try { + // Simulate an editor edit against the isolated copies. + writeFileSync(fixture.csvPath, originals.csv.replace('Town Meeting', 'Renamed Meeting'), 'utf-8'); + writeFileSync(fixture.manifestPath, originals.manifest.replace('Town Meeting', 'Renamed Meeting'), 'utf-8'); + // The committed originals are untouched. + expect(readFileSync(CONTENT_CSV_PATH, 'utf-8')).toBe(originals.csv); + expect(readFileSync(MANIFEST_ARTEFACT_PATH, 'utf-8')).toBe(originals.manifest); + expect(readFileSync(MERMAID_ARTEFACT_PATH, 'utf-8')).toBe(originals.mermaid); + } finally { + fixture.cleanup(); + } + }); + + it('keeps the fixture copies coherent: the manifest matches a fresh generation from the copy', () => { + // The editor consumes the manifest as its view model; a fixture that pairs + // a CSV with a stale artefact would corrupt every later slice. Regenerating + // from the copied CSV must reproduce the copied manifest byte-for-byte. + const fixture = copyEditorFixtureFiles(); + try { + const generated = serialiseManifest( + buildStorylineManifestFromCsv(readFileSync(fixture.csvPath, 'utf-8'), GYM_CONTENT_ADAPTER), + ); + expect(generated).toBe(originals.manifest); + } finally { + fixture.cleanup(); + } + }); +}); diff --git a/tests/story-line/editor/fixtures.ts b/tests/story-line/editor/fixtures.ts new file mode 100644 index 000000000..2e398a4f0 --- /dev/null +++ b/tests/story-line/editor/fixtures.ts @@ -0,0 +1,85 @@ +/** + * Shared fixture helpers for the story-line editor tests. + * + * The editor edits a game's content CSV and regenerates the manifest/Mermaid + * artefacts, so every edit test must run against **copies** in an isolated + * temp directory — never against the committed files. All source paths here + * derive from the generator's own exported defaults (`DEFAULT_CONTENT_PATH`, + * `DEFAULT_GRAPH_OUTPUTS`, `DEFAULT_MANIFEST_SCHEMA_PATH`) rather than + * hard-coded strings: if an artefact or the schema moves, the derived path + * stops resolving and the tests fail loudly instead of silently copying a + * stale file into the fixture. + * + * The copied filenames keep their source basenames, so an artefact rename is + * likewise surfaced by an existence assertion in the test suite. + * + * @module + */ + +import { copyFileSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { basename, dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { DEFAULT_CONTENT_PATH } from '../../../src/story-line/content/adapters'; +import { DEFAULT_MANIFEST_SCHEMA_PATH } from '../../../src/story-line/content'; +import { DEFAULT_GRAPH_OUTPUTS } from '../../../scripts/storyline-graph'; + +/** Absolute path of the repository root (this file lives three levels down). */ +export const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); + +/** Resolved path of the committed default (Gym) content CSV. */ +export const CONTENT_CSV_PATH = resolve(REPO_ROOT, DEFAULT_CONTENT_PATH); + +/** Resolved path of the committed manifest artefact. */ +export const MANIFEST_ARTEFACT_PATH = resolve(REPO_ROOT, DEFAULT_GRAPH_OUTPUTS.json); + +/** Resolved path of the committed Mermaid artefact. */ +export const MERMAID_ARTEFACT_PATH = resolve(REPO_ROOT, DEFAULT_GRAPH_OUTPUTS.mermaid); + +/** Resolved path of the generic manifest JSON schema. */ +export const MANIFEST_SCHEMA_PATH = resolve(REPO_ROOT, DEFAULT_MANIFEST_SCHEMA_PATH); + +/** + * An isolated copy of the editor's content + artefact files. + * + * The returned paths always point **outside** the repository, so an edit test + * can mutate them freely and the committed originals stay byte-identical. + */ +export interface EditorFixtureFiles { + /** The isolated temp directory holding the copies. */ + readonly dir: string; + /** Temp copy of the content CSV. */ + readonly csvPath: string; + /** Temp copy of the manifest artefact. */ + readonly manifestPath: string; + /** Temp copy of the Mermaid artefact. */ + readonly mermaidPath: string; + /** Removes the temp directory (call in `afterEach`/`finally`). */ + cleanup(): void; +} + +/** + * Copies the committed content CSV and generated artefacts into a fresh temp + * directory, keeping each copy's source filename. + * + * @returns The copy paths plus a cleanup function. + */ +export function copyEditorFixtureFiles(): EditorFixtureFiles { + const dir = mkdtempSync(join(tmpdir(), 'tce-editor-fixtures-')); + const csvPath = join(dir, basename(CONTENT_CSV_PATH)); + const manifestPath = join(dir, basename(MANIFEST_ARTEFACT_PATH)); + const mermaidPath = join(dir, basename(MERMAID_ARTEFACT_PATH)); + + copyFileSync(CONTENT_CSV_PATH, csvPath); + copyFileSync(MANIFEST_ARTEFACT_PATH, manifestPath); + copyFileSync(MERMAID_ARTEFACT_PATH, mermaidPath); + + return { + dir, + csvPath, + manifestPath, + mermaidPath, + cleanup: () => rmSync(dir, { recursive: true, force: true }), + }; +} diff --git a/tests/story-line/editor/harness.browser.test.ts b/tests/story-line/editor/harness.browser.test.ts new file mode 100644 index 000000000..05fc13758 --- /dev/null +++ b/tests/story-line/editor/harness.browser.test.ts @@ -0,0 +1,55 @@ +/** + * Placeholder browser wiring for the story-line editor harness. + * + * This is the "non-Phaser dev page" path in the core `browser` project: the + * future editor renderer is a plain-DOM module, so the browser tests import and + * mount it directly into Vitest's real Chromium document — proving the path + * works before any editor feature code exists (the harness decision is + * recorded on CG-0MVHZ2LS3008ATY9). + * + * @module + */ +import { describe, expect, it } from 'vitest'; + +import { mountEditorHarness } from './harness'; + +describe('story-line editor browser harness (non-Phaser dev page)', () => { + it('mounts a plain-DOM renderer into the core browser project document', () => { + const harness = mountEditorHarness(); + try { + expect(harness.root.isConnected).toBe(true); + expect(harness.root.dataset.editorHarness).toBe('true'); + expect(harness.title.textContent).toBe('Story-line editor harness'); + expect(harness.status.dataset.status).toBe('standby'); + } finally { + harness.dispose(); + } + expect(harness.root.isConnected).toBe(false); + }); + + it('observes real DOM events without a game loop or page boot', () => { + const harness = mountEditorHarness(); + try { + const accept = document.createElement('button'); + accept.textContent = 'Accept'; + harness.root.append(accept); + + let accepted = 0; + accept.addEventListener('click', () => { + accepted += 1; + harness.status.dataset.status = 'accepted'; + harness.status.textContent = 'accepted'; + }); + + accept.click(); + accept.click(); + expect(accepted).toBe(2); + expect(harness.status.textContent).toBe('accepted'); + + // The renderer stays plain DOM: no canvas, no Phaser boot in the page. + expect(harness.root.querySelector('canvas')).toBeNull(); + } finally { + harness.dispose(); + } + }); +}); diff --git a/tests/story-line/editor/harness.ts b/tests/story-line/editor/harness.ts new file mode 100644 index 000000000..582ac5313 --- /dev/null +++ b/tests/story-line/editor/harness.ts @@ -0,0 +1,58 @@ +/** + * Shared plain-DOM harness for the story-line editor browser tests. + * + * Harness decision (recorded on CG-0MVHZ2LS3008ATY9): the editor is a + * **non-Phaser, non-game** dev page, so its renderer modules are **plain DOM** + * and are driven directly inside the core `browser` Vitest project — imported, + * mounted into the real test document and exercised, with no page boot, no + * game loop and no runtime dependency beyond what Vitest's Playwright browser + * already provides. The editor *page* is served separately by the dedicated + * `vite.editor.config.ts`; this module mirrors the mount/dispose lifecycle a + * future renderer will expose so browser tests do not depend on the page + * server at all. + * + * This file must stay importable in the browser test environment: **no Node + * builtins, no `node:fs`**, no Phaser. + * + * @module + */ + +/** A mounted editor harness region. */ +export interface EditorHarnessMount { + /** The harness container, appended to `document.body`. */ + readonly root: HTMLDivElement; + /** The harness title heading. */ + readonly title: HTMLHeadingElement; + /** A mutable status line (models the editor's live validation/preview text). */ + readonly status: HTMLParagraphElement; + /** Detaches the harness from the document. */ + dispose(): void; +} + +/** + * Mounts the plain-DOM harness into the browser test document. + * + * @param label Title text for the harness (defaults to a placeholder). + * @returns The mounted harness plus a `dispose` that detaches it. + */ +export function mountEditorHarness(label = 'Story-line editor harness'): EditorHarnessMount { + const root = document.createElement('div'); + root.dataset.editorHarness = 'true'; + + const title = document.createElement('h1'); + title.textContent = label; + + const status = document.createElement('p'); + status.dataset.status = 'standby'; + status.textContent = 'standby'; + + root.append(title, status); + document.body.append(root); + + return { + root, + title, + status, + dispose: () => root.remove(), + }; +} diff --git a/tests/story-line/editor/host-api.test.ts b/tests/story-line/editor/host-api.test.ts new file mode 100644 index 000000000..6fd063a6c --- /dev/null +++ b/tests/story-line/editor/host-api.test.ts @@ -0,0 +1,267 @@ +/** + * The editor host's write-bridge API endpoints. + * + * The host serves three `POST` endpoints over the same bridge the page will + * call: `/api/preview` (diff + validation report), `/api/validate` (report + * only) and `/api/apply` (the only write). These tests drive the handler + * without a socket (`handleEditorApiRequest`) — the endpoint contract for + * status codes, bodies and session refresh — plus the body reader and the + * plugin wiring. + * + * Everything runs against a temp fixture copy; the committed Gym content and + * artefacts are never touched. + * + * @module + */ +import { readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { GYM_CONTENT_ADAPTER } from '../../../src/story-line/content/adapters'; +import { computeContentChecksum } from '../../../src/story-line/content/csv'; +import { + EDITOR_API_PLUGIN_NAME, + EDITOR_API_PATHS, + handleEditorApiRequest, + loadEditorSession, + readJsonBody, + storylineEditorApiPlugin, + type EditorSession, +} from '../../../scripts/storyline-editor-server'; +import { CONTENT_CSV_PATH, copyEditorFixtureFiles, type EditorFixtureFiles } from './fixtures'; + +/** A valid rename edit. */ +const RENAME = { + edit: { kind: 'update-card', spec: { id: 'gym-story-invest', name: 'Municipal Investment' } }, +} as const; + +/** A dangling edit (an option retargeted at a card that does not exist). */ +const DANGLING = { + edit: { + kind: 'update-card', + spec: { id: 'gym-story-start', options: 'Invest|gym-story-invest;Ignore|gym-story-ghost' }, + }, +} as const; + +/** A fake request stream for {@link readJsonBody}. */ +function stream(text: string, chunkSize = 7): AsyncIterable<string> { + const chunks: string[] = []; + for (let i = 0; i < text.length; i += chunkSize) chunks.push(text.slice(i, i + chunkSize)); + return { + async *[Symbol.asyncIterator]() { + for (const chunk of chunks) yield chunk; + }, + }; +} + +describe('loadEditorSession', () => { + let fixture: EditorFixtureFiles; + + beforeEach(() => { + fixture = copyEditorFixtureFiles(); + }); + + afterEach(() => { + fixture.cleanup(); + }); + + it('captures the content snapshot, its manifest and the concurrency checksum', () => { + const session = loadEditorSession({ + contentPath: fixture.csvPath, + adapter: GYM_CONTENT_ADAPTER, + artefactPaths: { json: fixture.manifestPath, mermaid: fixture.mermaidPath }, + }); + + expect(session.contentPath).toBe(fixture.csvPath); + expect(session.checksum).toBe(computeContentChecksum(session.csv)); + expect(session.manifest.gameId).toBe('gym'); + expect(session.manifest.nodes).toHaveLength(4); + expect(session.readManifest().nodes).toHaveLength(4); + }); + + it('reload() re-reads the file and produces a fresh snapshot', () => { + const session = loadEditorSession({ contentPath: fixture.csvPath, adapter: GYM_CONTENT_ADAPTER }); + writeFileSync(fixture.csvPath, `${session.csv}extra\n`, 'utf-8'); + + const reloaded = session.reload(); + expect(reloaded.csv).not.toBe(session.csv); + expect(reloaded.checksum).not.toBe(session.checksum); + }); + + it('fails with an actionable message when the CSV is unreadable', () => { + expect(() => + loadEditorSession({ contentPath: join(fixture.dir, 'missing.csv'), adapter: GYM_CONTENT_ADAPTER }), + ).toThrow(/cannot read/); + }); +}); + +describe('handleEditorApiRequest', () => { + let fixture: EditorFixtureFiles; + let session: EditorSession; + + beforeEach(() => { + fixture = copyEditorFixtureFiles(); + session = loadEditorSession({ + contentPath: fixture.csvPath, + adapter: GYM_CONTENT_ADAPTER, + artefactPaths: { json: fixture.manifestPath, mermaid: fixture.mermaidPath }, + }); + }); + + afterEach(() => { + fixture.cleanup(); + }); + + const post = (path: string, body?: unknown) => + handleEditorApiRequest(session, { method: 'POST', path, body }); + + it('preview returns 200 with the diff and validation report, nothing written', () => { + const before = readFileSync(fixture.csvPath, 'utf-8'); + const outcome = post(EDITOR_API_PATHS.preview, RENAME); + + expect(outcome.response.status).toBe(200); + expect(outcome.response.body).toMatchObject({ ok: true, kind: 'update-card' }); + const preview = outcome.response.body as { + rows: { id: string; before: { name: string } }[]; + report: string; + }; + expect(preview.rows[0].id).toBe('gym-story-invest'); + expect(preview.rows[0].before.name).toBe('Public Investment'); + expect(preview.report).toContain('RESULT: VALID'); + // Previews and validations never produce a new session. + expect(outcome.session).toBeUndefined(); + expect(readFileSync(fixture.csvPath, 'utf-8')).toBe(before); + }); + + it('validate returns 200 with the report and no diff', () => { + const outcome = post(EDITOR_API_PATHS.validate, DANGLING); + expect(outcome.response.status).toBe(200); + const body = outcome.response.body as { ok: boolean; errors: string[]; report: string }; + expect(body.ok).toBe(false); + expect(body.errors.join(' ')).toContain('gym-story-ghost'); + expect(body.report).toContain('RESULT: INVALID'); + }); + + it('apply accepts a valid edit, reloads the session and serves the new manifest', () => { + const outcome = post(EDITOR_API_PATHS.apply, RENAME); + expect(outcome.response.status).toBe(200); + const body = outcome.response.body as { ok: boolean; written: boolean; manifest: { nodes: { id: string }[] } }; + expect(body.ok).toBe(true); + expect(body.written).toBe(true); + // The regenerated manifest is included so the page can refresh without a + // second fetch, and the session is swapped for a reloaded one. + expect(body.manifest.nodes.some((node) => node.id === 'gym-story-invest')).toBe(true); + + const reloaded = outcome.session as EditorSession; + expect(reloaded).toBeDefined(); + expect(reloaded.checksum).not.toBe(session.checksum); + expect(reloaded.csv).toContain('Municipal Investment'); + + // The next preview runs against the reloaded session, so the applied + // rename is now the base: a further edit sees it as `before`. + const next = handleEditorApiRequest(reloaded, { + method: 'POST', + path: EDITOR_API_PATHS.preview, + body: { + edit: { kind: 'update-card', spec: { id: 'gym-story-invest', name: 'Civic Investment' } }, + }, + }); + const nextBody = next.response.body as { + ok: boolean; + rows: { before: { name: string } | null; status: string }[]; + }; + expect(nextBody.ok).toBe(true); + expect(nextBody.rows[0].status).toBe('changed'); + expect(nextBody.rows[0].before?.name).toBe('Municipal Investment'); + }); + + it('apply refuses an invalid edit with 422 and a byte-identical file', () => { + const before = readFileSync(fixture.csvPath, 'utf-8'); + const outcome = post(EDITOR_API_PATHS.apply, DANGLING); + + expect(outcome.response.status).toBe(422); + const body = outcome.response.body as { ok: boolean; written: boolean; status: string }; + expect(body.ok).toBe(false); + expect(body.written).toBe(false); + expect(body.status).toBe('invalid'); + expect(outcome.session).toBeUndefined(); + expect(readFileSync(fixture.csvPath, 'utf-8')).toBe(before); + }); + + it('apply refuses a stale snapshot with 409 and a reload instruction', () => { + const before = readFileSync(fixture.csvPath, 'utf-8'); + // Someone else edits the file after the session was loaded. + writeFileSync(fixture.csvPath, before.replace('Public Investment', 'Public Works'), 'utf-8'); + + const outcome = post(EDITOR_API_PATHS.apply, RENAME); + expect(outcome.response.status).toBe(409); + const body = outcome.response.body as { ok: boolean; reloadRequired: boolean; message: string }; + expect(body.ok).toBe(false); + expect(body.reloadRequired).toBe(true); + expect(body.message).toMatch(/reload/i); + expect(readFileSync(fixture.csvPath, 'utf-8')).toContain('Public Works'); + }); + + it('applies with an explicit caller-supplied checksum (client-side snapshot token)', () => { + const body = { ...RENAME, expectedChecksum: session.checksum }; + const outcome = post(EDITOR_API_PATHS.apply, body); + expect(outcome.response.status).toBe(200); + }); + + it('rejects unknown paths (404), wrong methods (405) and malformed bodies (400)', () => { + expect(post('/api/nope').response.status).toBe(404); + expect( + handleEditorApiRequest(session, { method: 'GET', path: EDITOR_API_PATHS.apply }).response + .status, + ).toBe(405); + + const bad = handleEditorApiRequest(session, { + method: 'POST', + path: EDITOR_API_PATHS.apply, + body: { edit: { kind: 'update-card' } }, + }); + expect(bad.response.status).toBe(400); + expect((bad.response.body as { error: string }).error).toContain('spec'); + + const unknownKind = handleEditorApiRequest(session, { + method: 'POST', + path: EDITOR_API_PATHS.apply, + body: { edit: { kind: 'explode', spec: {} } }, + }); + expect(unknownKind.response.status).toBe(400); + expect((unknownKind.response.body as { error: string }).error).toContain('explode'); + }); +}); + +describe('readJsonBody', () => { + it('parses chunked JSON and rejects empty bodies as undefined', async () => { + expect(await readJsonBody(stream('{"a":1}'))).toEqual({ a: 1 }); + expect(await readJsonBody(stream(' '))).toBeUndefined(); + }); + + it('rejects bodies over the byte limit', async () => { + await expect(readJsonBody(stream('{"pad":"xxxxx"}'), 8)).rejects.toThrow(/exceeds 8 bytes/); + }); +}); + +describe('storylineEditorApiPlugin', () => { + it('registers one middleware with the documented name', () => { + const session = loadEditorSession({ + contentPath: CONTENT_CSV_PATH, + adapter: GYM_CONTENT_ADAPTER, + }); + const plugin = storylineEditorApiPlugin(session); + expect(plugin.name).toBe(EDITOR_API_PLUGIN_NAME); + const middlewares: ((req: unknown, res: unknown, next: () => void) => void)[] = []; + const configureServer = plugin.configureServer as unknown as + | ((server: unknown) => void) + | undefined; + configureServer?.({ + middlewares: { + use: (fn: (req: unknown, res: unknown, next: () => void) => void) => middlewares.push(fn), + }, + }); + expect(middlewares).toHaveLength(1); + }); +}); diff --git a/tests/story-line/editor/manifest-schema.test.ts b/tests/story-line/editor/manifest-schema.test.ts new file mode 100644 index 000000000..e38c7239c --- /dev/null +++ b/tests/story-line/editor/manifest-schema.test.ts @@ -0,0 +1,68 @@ +/** + * Manifest schema guard for the story-line editor harness. + * + * The editor consumes the generated manifest as its view model, so the harness + * must fail loudly if the generic JSON schema or the committed manifest + * artefact moves out from under it. Both paths are derived from the + * generator's own exported defaults (`DEFAULT_GRAPH_OUTPUTS`, + * `DEFAULT_MANIFEST_SCHEMA_PATH`) — a move breaks the derived path and the + * existence assertion throws instead of silently validating a stale file. + * + * @module + */ +import { existsSync, readFileSync } from 'node:fs'; + +import Ajv from 'ajv'; +import { describe, expect, it } from 'vitest'; + +import { GYM_CONTENT_ADAPTER } from '../../../src/story-line/content/adapters'; +import { + buildStorylineManifestFromCsv, + serialiseManifest, +} from '../../../src/story-line/content/manifest'; +import { + MANIFEST_ARTEFACT_PATH, + MANIFEST_SCHEMA_PATH, + copyEditorFixtureFiles, +} from './fixtures'; + +describe('story-line editor manifest schema guard', () => { + it('derives a schema path that still resolves (a schema move fails this test loudly)', () => { + // ENOENT here would fail the whole describe block; the explicit existence + // assertion keeps the failure message readable. + expect(MANIFEST_SCHEMA_PATH.endsWith('/schemas/story-line-manifest.schema.json')).toBe(true); + expect(existsSync(MANIFEST_SCHEMA_PATH)).toBe(true); + expect(JSON.parse(readFileSync(MANIFEST_SCHEMA_PATH, 'utf-8')).title).toBe( + 'TCE Story-Line Content Manifest', + ); + }); + + it('validates the committed manifest artefact against the generic schema (ajv)', () => { + const validate = new Ajv({ allErrors: true, strict: false }).compile( + JSON.parse(readFileSync(MANIFEST_SCHEMA_PATH, 'utf-8')), + ); + const artefact = JSON.parse(readFileSync(MANIFEST_ARTEFACT_PATH, 'utf-8')); + expect(validate(artefact)).toBe(true); + expect(validate.errors).toBeNull(); + }); + + it('validates a manifest generated from the isolated fixture CSV copy (harness chain)', () => { + // The editor's round-trip starts from a temp CSV copy and regenerates the + // artefact; that generated manifest must satisfy the same generic schema. + const fixture = copyEditorFixtureFiles(); + try { + const validate = new Ajv({ allErrors: true, strict: false }).compile( + JSON.parse(readFileSync(MANIFEST_SCHEMA_PATH, 'utf-8')), + ); + const generated = JSON.parse( + serialiseManifest( + buildStorylineManifestFromCsv(readFileSync(fixture.csvPath, 'utf-8'), GYM_CONTENT_ADAPTER), + ), + ); + expect(validate(generated)).toBe(true); + expect(validate.errors).toBeNull(); + } finally { + fixture.cleanup(); + } + }); +}); diff --git a/tests/story-line/editor/preview-validate.test.ts b/tests/story-line/editor/preview-validate.test.ts new file mode 100644 index 000000000..fe7264b9e --- /dev/null +++ b/tests/story-line/editor/preview-validate.test.ts @@ -0,0 +1,324 @@ +/** + * Preview/diff and validate-before-write for the editor write bridge. + * + * The epic's core promise: **every edit returns a preview/diff and runs the + * validator before writing**. These tests pin that contract against the Gym + * fixture: + * + * - `previewEdit` reports the before/after CSV rows of the affected cards plus + * the validation report (text and JSON agree); + * - an invalid edit (a dangling successor) is surfaced by name, produces no + * candidate text, and writes nothing — the CSV stays byte-identical; + * - `validateEdit` gives the same verdict as the preview, nothing written; + * - `applyEdit` on a valid edit writes once, on an invalid one refuses with a + * non-2xx-style status and leaves the file untouched. + * + * All edits run against temp copies; the committed Gym content is never + * mutated (asserted at the end). + * + * @module + */ +import { readFileSync } from 'node:fs'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { GYM_CONTENT_ADAPTER } from '../../../src/story-line/content/adapters'; +import { computeContentChecksum } from '../../../src/story-line/content/csv'; +import { + applyEdit, + previewEdit, + validateEdit, +} from '../../../src/story-line/editor/api'; +import { + CONTENT_CSV_PATH, + copyEditorFixtureFiles, + type EditorFixtureFiles, +} from './fixtures'; + +/** The artefact paths of a fixture copy. */ +function artefactPaths(fixture: EditorFixtureFiles) { + return { json: fixture.manifestPath, mermaid: fixture.mermaidPath }; +} + +/** A valid, documented edit: rename the `Public Investment` card. */ +const VALID_RENAME = { + kind: 'update-card', + spec: { id: 'gym-story-invest', name: 'Municipal Investment', effect: 'Gain 150 coins' }, +} as const; + +/** + * A deliberately dangling edit: add an option that targets a card that does + * not exist, while keeping every existing successor. `gym-story-start` keeps + * its other options, so the only validator finding is the unknown successor + * (no card becomes unreachable). + */ +const DANGLING_EDIT = { + kind: 'update-card', + spec: { + id: 'gym-story-start', + options: 'Invest|gym-story-invest;Ignore|gym-story-ignore;Bold move|gym-story-ghost', + }, +} as const; + +describe('previewEdit — the diff', () => { + let fixture: EditorFixtureFiles; + + beforeEach(() => { + fixture = copyEditorFixtureFiles(); + }); + + afterEach(() => { + fixture.cleanup(); + }); + + it('returns the before/after CSV rows of the affected card and nothing else', () => { + const csv = readFileSync(fixture.csvPath, 'utf-8'); + const preview = previewEdit(csv, GYM_CONTENT_ADAPTER, VALID_RENAME); + + expect(preview.ok).toBe(true); + expect(preview.kind).toBe('update-card'); + expect(preview.errors).toEqual([]); + expect(preview.affectedIds).toEqual(['gym-story-invest']); + + const row = preview.rows[0]; + expect(row.id).toBe('gym-story-invest'); + expect(row.status).toBe('changed'); + expect(row.before?.name).toBe('Public Investment'); + expect(row.after?.name).toBe('Municipal Investment'); + expect(row.before?.effect).toBe('Gain 120 coins'); + expect(row.after?.effect).toBe('Gain 150 coins'); + expect(row.changedColumns).toEqual(expect.arrayContaining(['name', 'effect'])); + + // Unaffected fields carry through unchanged. + expect(row.after?.storylineId).toBe('gym-story'); + expect(row.before?.storylineId).toBe('gym-story'); + }); + + it('shows an added row for a new card, with the adapter defaults and no before', () => { + const csv = readFileSync(fixture.csvPath, 'utf-8'); + const preview = previewEdit(csv, GYM_CONTENT_ADAPTER, { + kind: 'add-card', + spec: { + id: 'gym-story-endgame', + name: 'The Endgame', + effect: 'Everything ends.', + hasChoices: false, + storylineId: 'gym-story-lone', + storylineTitle: 'Lone Arc', + }, + }); + + expect(preview.ok).toBe(true); + const row = preview.rows.find((candidate) => candidate.id === 'gym-story-endgame'); + expect(row).toBeDefined(); + expect(row?.status).toBe('added'); + expect(row?.before).toBeNull(); + // The adapter's newCardDefaults fill the game columns. + expect(row?.after?.trigger).toBe('Incident'); + expect(row?.after?.cost).toBe('0'); + expect(preview.affectedIds).toContain('gym-story-endgame'); + }); + + it('never writes during a preview (fixture stays byte-identical)', () => { + const before = readFileSync(fixture.csvPath, 'utf-8'); + previewEdit(before, GYM_CONTENT_ADAPTER, VALID_RENAME); + expect(readFileSync(fixture.csvPath, 'utf-8')).toBe(before); + }); +}); + +describe('previewEdit — validation before write', () => { + let fixture: EditorFixtureFiles; + + beforeEach(() => { + fixture = copyEditorFixtureFiles(); + }); + + afterEach(() => { + fixture.cleanup(); + }); + + it('surfaces a dangling successor by name and refuses the candidate', () => { + const csv = readFileSync(fixture.csvPath, 'utf-8'); + const preview = previewEdit(csv, GYM_CONTENT_ADAPTER, DANGLING_EDIT); + + expect(preview.ok).toBe(false); + expect(preview.csv).toBeUndefined(); + expect(preview.validation.valid).toBe(false); + expect(preview.validation.issues).toHaveLength(1); + expect(preview.validation.issues[0].code).toBe('unknown-successor'); + expect(preview.validation.issues[0].cardId).toBe('gym-story-start'); + expect(preview.validation.issues[0].target).toBe('gym-story-ghost'); + expect(preview.errors.join(' ')).toContain('gym-story-ghost'); + expect(preview.rows).toEqual([]); + expect(preview.affectedIds).toEqual([]); + }); + + it('runs the validator against the full candidate, not just the edited row', () => { + const csv = readFileSync(fixture.csvPath, 'utf-8'); + // Dropping the storyline id from a choice card is `missing-storyline-id`. + const preview = previewEdit(csv, GYM_CONTENT_ADAPTER, { + kind: 'update-card', + spec: { id: 'gym-story-start', storylineId: null }, + }); + + expect(preview.ok).toBe(false); + expect( + preview.validation.issues.some((issue) => issue.code === 'missing-storyline-id'), + ).toBe(true); + }); + + it('text and JSON reports agree for a valid candidate', () => { + const csv = readFileSync(fixture.csvPath, 'utf-8'); + const preview = previewEdit(csv, GYM_CONTENT_ADAPTER, VALID_RENAME); + + expect(preview.report).toContain('RESULT: VALID'); + const json = JSON.parse(preview.reportJson) as { + valid: boolean; + issues: unknown[]; + findings: unknown[]; + }; + expect(json.valid).toBe(true); + expect(json.issues).toHaveLength(preview.validation.issues.length); + expect(json.findings).toHaveLength(preview.validation.findings.length); + }); + + it('text and JSON reports agree for an invalid candidate', () => { + const csv = readFileSync(fixture.csvPath, 'utf-8'); + const preview = previewEdit(csv, GYM_CONTENT_ADAPTER, DANGLING_EDIT); + + expect(preview.report).toContain('RESULT: INVALID'); + const json = JSON.parse(preview.reportJson) as { valid: boolean; issues: unknown[] }; + expect(json.valid).toBe(false); + expect(json.issues).toHaveLength(preview.validation.issues.length); + }); +}); + +describe('validateEdit — reports without a diff', () => { + let fixture: EditorFixtureFiles; + + beforeEach(() => { + fixture = copyEditorFixtureFiles(); + }); + + afterEach(() => { + fixture.cleanup(); + }); + + it('agrees with the preview verdict and writes nothing', () => { + const csv = readFileSync(fixture.csvPath, 'utf-8'); + + const good = validateEdit(csv, GYM_CONTENT_ADAPTER, VALID_RENAME); + expect(good.ok).toBe(true); + expect(good.validation.valid).toBe(true); + expect(good.checksum).toBe(computeContentChecksum(csv)); + + const bad = validateEdit(csv, GYM_CONTENT_ADAPTER, DANGLING_EDIT); + expect(bad.ok).toBe(false); + expect(bad.errors.join(' ')).toContain('gym-story-ghost'); + + expect(readFileSync(fixture.csvPath, 'utf-8')).toBe(csv); + }); +}); + +describe('applyEdit — validate before write', () => { + let fixture: EditorFixtureFiles; + + beforeEach(() => { + fixture = copyEditorFixtureFiles(); + }); + + afterEach(() => { + fixture.cleanup(); + }); + + it('writes a valid edit and regenerates the artefacts', () => { + const loaded = computeContentChecksum(readFileSync(fixture.csvPath, 'utf-8')); + const result = applyEdit({ + contentPath: fixture.csvPath, + adapter: GYM_CONTENT_ADAPTER, + edit: VALID_RENAME, + expectedChecksum: loaded, + artefactPaths: artefactPaths(fixture), + manualDocs: [], + }); + + expect(result.ok).toBe(true); + expect(result.status).toBe('applied'); + expect(result.code).toBe(200); + expect(result.written).toBe(true); + expect(result.errors).toEqual([]); + expect(readFileSync(fixture.csvPath, 'utf-8')).toContain('Municipal Investment'); + }); + + it('refuses an invalid edit with a non-2xx code and leaves the CSV byte-identical', () => { + const before = readFileSync(fixture.csvPath, 'utf-8'); + const loaded = computeContentChecksum(before); + + const result = applyEdit({ + contentPath: fixture.csvPath, + adapter: GYM_CONTENT_ADAPTER, + edit: DANGLING_EDIT, + expectedChecksum: loaded, + artefactPaths: artefactPaths(fixture), + manualDocs: [], + }); + + expect(result.ok).toBe(false); + expect(result.status).toBe('invalid'); + expect(result.code).toBe(422); + expect(result.written).toBe(false); + expect(result.errors.join(' ')).toContain('gym-story-ghost'); + expect(result.preview.errors.join(' ')).toContain('gym-story-ghost'); + expect(readFileSync(fixture.csvPath, 'utf-8')).toBe(before); + // No artefacts were (re)written either. + expect(result.artefacts).toBeUndefined(); + expect(result.manualDocUpdates).toEqual([]); + }); + + it('dry run previews and validates without writing content or artefacts', () => { + const before = readFileSync(fixture.csvPath, 'utf-8'); + const beforeManifest = readFileSync(fixture.manifestPath, 'utf-8'); + const beforeMermaid = readFileSync(fixture.mermaidPath, 'utf-8'); + const loaded = computeContentChecksum(before); + + const result = applyEdit({ + contentPath: fixture.csvPath, + adapter: GYM_CONTENT_ADAPTER, + edit: VALID_RENAME, + expectedChecksum: loaded, + dryRun: true, + artefactPaths: artefactPaths(fixture), + manualDocs: [], + }); + + expect(result.ok).toBe(true); + expect(result.status).toBe('dry-run'); + expect(result.written).toBe(false); + expect(result.preview.csv).toContain('Municipal Investment'); + expect(result.artefacts?.regenerated).toBe(false); + expect(result.artefacts?.written).toEqual([]); + expect(readFileSync(fixture.csvPath, 'utf-8')).toBe(before); + expect(readFileSync(fixture.manifestPath, 'utf-8')).toBe(beforeManifest); + expect(readFileSync(fixture.mermaidPath, 'utf-8')).toBe(beforeMermaid); + // The dry run reports the drift the accepted edit would introduce … + expect(result.artefacts?.drifts.length).toBeGreaterThan(0); + expect(result.artefacts?.ok).toBe(false); + }); + + it('rejects an unknown edit kind instead of silently doing nothing', () => { + const before = readFileSync(fixture.csvPath, 'utf-8'); + const result = previewEdit(before, GYM_CONTENT_ADAPTER, { + kind: 'reticulate-splines', + spec: {}, + } as never); + + expect(result.ok).toBe(false); + expect(result.errors.join(' ')).toContain('unknown edit kind "reticulate-splines"'); + }); + + it('the committed Gym content is never mutated by any of the above', () => { + // Sanity: the suite's edits were all against temp copies. + expect(readFileSync(CONTENT_CSV_PATH, 'utf-8')).toContain('Public Investment'); + expect(readFileSync(CONTENT_CSV_PATH, 'utf-8')).not.toContain('Municipal Investment'); + }); +}); diff --git a/tests/story-line/editor/storyline-manager.browser.test.ts b/tests/story-line/editor/storyline-manager.browser.test.ts new file mode 100644 index 000000000..b96b28802 --- /dev/null +++ b/tests/story-line/editor/storyline-manager.browser.test.ts @@ -0,0 +1,462 @@ +/** + * Browser round-trip for the story-line management panel (F4). + * + * The editor page is a plain-DOM dev tool, so the panel is mounted straight into + * Vitest's real Chromium document (the same decision recorded for the viewer and + * the curated forms harnesses). The bridge is an in-memory double that runs the + * **real** pure preview/validation seam over the Gym fixture and only stubs the + * file write — the Node-only part is proven against a temp CSV in + * `storyline-manager.test.ts`. + * + * Covered end to end: + * + * - the group list is derived from the manifest (title, id, member cards); + * - creating a group with selected cards is **one** batch write, and the list + * afterwards equals the groups derived from the regenerated manifest; + * - creating a group with no cards writes nothing (a story-line exists through + * its members) and the pending group is listed until cards are moved in; + * - renaming a group previews, blocks a duplicate id locally, and writes every + * member in a single `set-storyline-batch` call; + * - reassigning selected cards into another group is likewise one write. + * + * @module + */ +import { afterEach, describe, expect, it } from 'vitest'; + +import gymCsvRaw from '../../../example-games/gym/content/storylines.csv?raw'; +import gymManifestJson from '../../../example-games/gym/content/storyline-manifest.json'; +import { GYM_CONTENT_ADAPTER } from '../../../src/story-line/content/adapters'; +import { cardsFromCsv, parseCsv } from '../../../src/story-line/content/csv'; +import { + buildStorylineManifestFromCsv, + type StorylineManifest, +} from '../../../src/story-line/content/manifest'; +import { previewEdit, validateEdit, type EditorEdit } from '../../../src/story-line/editor/edits'; +import { mountEditorShell, type EditorShell } from '../../../tools/storyline-editor/app-shell'; +import type { EditorBridge } from '../../../tools/storyline-editor/bridge-client'; +import { + buildStorylineGroups, + type StorylineGroupSource, + type StorylineGroupView, + type StorylineMemberSource, +} from '../../../tools/storyline-editor/storyline-manager'; +import { waitForCondition } from '../../helpers/waitForCondition'; + +/** The committed Gym manifest — exactly what the editor host serves. */ +const gymManifest = gymManifestJson as StorylineManifest; + +/** The committed Gym content — the cards behind those manifest nodes. */ +const gymCards = cardsFromCsv(parseCsv(gymCsvRaw), GYM_CONTENT_ADAPTER); + +/** One recorded bridge call. */ +interface BridgeCall { + readonly type: 'preview' | 'validate' | 'apply'; + readonly edit: EditorEdit; +} + +/** + * An in-memory bridge: the real preview/validation seam over a CSV string, with + * only the file write stubbed (it replaces the in-memory content). + */ +function createInMemoryBridge(initialCsv: string): { + readonly bridge: EditorBridge; + readonly calls: BridgeCall[]; + csv(): string; +} { + let csv = initialCsv; + const calls: BridgeCall[] = []; + return { + csv: () => csv, + calls, + bridge: { + async preview(edit) { + calls.push({ type: 'preview', edit }); + return previewEdit(csv, GYM_CONTENT_ADAPTER, edit); + }, + async validate(edit) { + calls.push({ type: 'validate', edit }); + return validateEdit(csv, GYM_CONTENT_ADAPTER, edit); + }, + async apply(edit) { + calls.push({ type: 'apply', edit }); + const preview = previewEdit(csv, GYM_CONTENT_ADAPTER, edit); + if (!preview.ok || preview.csv === undefined) { + return { ok: false, message: 'refused', preview, errors: preview.errors, written: false }; + } + csv = preview.csv; + return { + ok: true, + message: `applied ${edit.kind}`, + preview, + errors: [], + written: true, + checksum: preview.candidateChecksum, + }; + }, + }, + }; +} + +/** The groups derived from the content a write produced (a regenerated manifest). */ +function groupsFromCsv(csv: string): StorylineGroupView[] { + const manifest = buildStorylineManifestFromCsv(csv, GYM_CONTENT_ADAPTER); + const groups: StorylineGroupSource[] = manifest.storylineIds.map((storylineId) => ({ + storylineId, + title: + manifest.nodes.find((node) => node.storylineId === storylineId && node.storylineTitle) + ?.storylineTitle ?? storylineId, + })); + const members: StorylineMemberSource[] = manifest.nodes.map((node) => ({ + id: node.id, + name: node.name, + storylineId: node.storylineId, + storylineTitle: node.storylineTitle, + })); + return buildStorylineGroups(groups, members); +} + +/** A comparable projection of a group list. */ +function summarise(groups: readonly StorylineGroupView[]) { + return groups + .map((group) => ({ + storylineId: group.storylineId, + title: group.title, + memberIds: group.members.map((member) => member.id).sort(), + pending: group.pending, + })) + .sort((a, b) => (a.storylineId < b.storylineId ? -1 : a.storylineId > b.storylineId ? 1 : 0)); +} + +let mounted: { container: HTMLDivElement; shell: EditorShell } | null = null; + +/** Mounts the editor shell with the Gym fixture and in-memory editing. */ +function mount(): { + container: HTMLDivElement; + shell: EditorShell; + store: ReturnType<typeof createInMemoryBridge>; +} { + const store = createInMemoryBridge(gymCsvRaw); + const container = document.createElement('div'); + document.body.append(container); + const shell = mountEditorShell(container, gymManifest, { + adapter: GYM_CONTENT_ADAPTER, + cards: gymCards, + bridge: store.bridge, + }); + mounted = { container, shell }; + return { container, shell, store }; +} + +/** Clicks an element the way a user would. */ +function click(element: Element | null): void { + if (!element) throw new Error('expected an element to click'); + element.dispatchEvent(new MouseEvent('click', { bubbles: true })); +} + +/** Types a value into a text input found by its data role. */ +function type(container: HTMLElement, role: string, value: string): void { + const element = container.querySelector(`[data-role="${role}"]`); + if (!(element instanceof HTMLInputElement)) throw new Error(`"${role}" is not an input`); + element.value = value; + element.dispatchEvent(new Event('input', { bubbles: true })); +} + +/** Ticks or unticks a member checkbox. */ +function toggleMember(container: HTMLElement, cardId: string, checked: boolean): void { + const element = container.querySelector( + `[data-role="storyline-member-toggle"][data-card-id="${cardId}"]`, + ); + if (!(element instanceof HTMLInputElement)) throw new Error(`no member toggle for "${cardId}"`); + element.checked = checked; + element.dispatchEvent(new Event('change', { bubbles: true })); +} + +/** Waits for a predicate with the shared load-tolerant helper. */ +function waitFor(predicate: () => boolean, label: string): Promise<void> { + return waitForCondition(predicate, { timeoutMs: 5_000, intervalMs: 20, label }); +} + +/** The last apply call the bridge recorded. */ +function lastApply(store: ReturnType<typeof createInMemoryBridge>): EditorEdit { + const applies = store.calls.filter((call) => call.type === 'apply'); + const last = applies[applies.length - 1]; + if (!last) throw new Error('expected an apply call'); + return last.edit; +} + +afterEach(() => { + mounted?.shell.dispose(); + mounted?.container.remove(); + mounted = null; +}); + +describe('story-line management — list and create', () => { + it('lists the manifest groups, creating one with selected cards in a single write', async () => { + const { container, shell, store } = mount(); + const manager = shell.openStorylineManager(); + expect(manager).not.toBeNull(); + if (!manager) return; + + // The list is derived from the manifest: one group with its four members. + expect(summarise(manager.groups())).toEqual([ + { + storylineId: 'gym-story', + title: 'Gym Storyline', + memberIds: ['gym-story-end', 'gym-story-ignore', 'gym-story-invest', 'gym-story-start'], + pending: false, + }, + ]); + const group = container.querySelector('[data-role="storyline-group"][data-storyline-id="gym-story"]'); + expect(group?.querySelector('[data-role="storyline-group-title"]')?.textContent).toBe( + 'Gym Storyline', + ); + expect(group?.querySelector('[data-role="storyline-group-meta"]')?.textContent).toBe( + 'gym-story · 4 cards', + ); + const toggles = container.querySelectorAll('[data-role="storyline-member-toggle"]'); + expect(toggles).toHaveLength(4); + + // Create a group and move one member into it. + type(container, 'new-storyline-id', 'gym-story-bonus'); + type(container, 'new-storyline-title', 'Bonus Arc'); + toggleMember(container, 'gym-story-end', true); + expect(container.querySelector('[data-role="selection-summary"]')?.textContent).toBe( + '1 member card selected.', + ); + click(container.querySelector('[data-action="create-group"]')); + + await waitFor( + () => + container.querySelector('[data-role="manager-status"]')?.getAttribute('data-tone') === + 'success', + 'the create outcome', + ); + + // Exactly one apply, carrying the whole batch (one id in this case). + expect(store.calls.filter((call) => call.type === 'apply')).toHaveLength(1); + const created = lastApply(store); + expect(created.kind).toBe('set-storyline-batch'); + expect(created.kind === 'set-storyline-batch' && created.spec.ids).toEqual(['gym-story-end']); + expect(store.csv()).toContain('gym-story-bonus'); + + // AC: after an accepted edit the list reflects the regenerated manifest. + expect(summarise(manager.groups())).toEqual(summarise(groupsFromCsv(store.csv()))); + expect(summarise(manager.groups())).toEqual([ + { + storylineId: 'gym-story', + title: 'Gym Storyline', + memberIds: ['gym-story-ignore', 'gym-story-invest', 'gym-story-start'], + pending: false, + }, + { + storylineId: 'gym-story-bonus', + title: 'Bonus Arc', + memberIds: ['gym-story-end'], + pending: false, + }, + ]); + expect( + container.querySelector( + '[data-role="storyline-group"][data-storyline-id="gym-story-bonus"] [data-role="storyline-group-title"]', + )?.textContent, + ).toBe('Bonus Arc'); + }); + + it('keeps a group created with no cards pending, then writes it when cards are moved in', async () => { + const { container, shell, store } = mount(); + const manager = shell.openStorylineManager(); + if (!manager) throw new Error('expected the manager to open'); + + type(container, 'new-storyline-id', 'gym-story-bonus'); + type(container, 'new-storyline-title', 'Bonus Arc'); + click(container.querySelector('[data-action="create-group"]')); + + // Nothing is written: a story-line exists only through its member cards. + expect(store.calls.filter((call) => call.type === 'apply')).toHaveLength(0); + expect(manager.groups().map((group) => [group.storylineId, group.members.length, group.pending])).toEqual([ + ['gym-story', 4, false], + ['gym-story-bonus', 0, true], + ]); + expect( + container.querySelector( + '[data-role="storyline-group"][data-storyline-id="gym-story-bonus"] [data-role="storyline-group-pending"]', + ), + ).not.toBeNull(); + expect(container.querySelector('[data-role="manager-status"]')?.textContent).toContain( + 'in this session', + ); + + // Moving a selected card into the pending group writes it once. + toggleMember(container, 'gym-story-end', true); + click( + container.querySelector( + '[data-action="reassign-selected"][data-storyline-id="gym-story-bonus"]', + ), + ); + await waitFor( + () => + container.querySelector('[data-role="manager-status"]')?.getAttribute('data-tone') === + 'success', + 'the reassignment outcome', + ); + + expect(store.calls.filter((call) => call.type === 'apply')).toHaveLength(1); + expect(summarise(manager.groups())).toEqual(summarise(groupsFromCsv(store.csv()))); + expect(manager.groups().find((group) => group.storylineId === 'gym-story-bonus')).toMatchObject({ + title: 'Bonus Arc', + pending: false, + members: [expect.objectContaining({ id: 'gym-story-end' })], + }); + }); +}); + +describe('story-line management — rename and reassign', () => { + it('renames a group with one batch write over every member', async () => { + const { container, shell, store } = mount(); + const manager = shell.openStorylineManager(); + if (!manager) throw new Error('expected the manager to open'); + + click( + container.querySelector('[data-action="rename-group"][data-storyline-id="gym-story"]'), + ); + const renamePanel = container.querySelector('[data-role="rename-panel"]'); + expect(renamePanel instanceof HTMLElement && renamePanel.hidden).toBe(false); + expect( + (container.querySelector('[data-role="rename-storyline-id"]') as HTMLInputElement).value, + ).toBe('gym-story'); + + // A duplicate id is caught locally — no bridge call, no write. + type(container, 'rename-storyline-id', 'gym-story-bonus'); + await manager.createGroup({ id: 'gym-story-bonus', title: 'Bonus Arc' }); + const callsBefore = store.calls.length; + type(container, 'rename-storyline-id', 'gym-story-bonus'); + click(container.querySelector('[data-action="apply-rename"]')); + expect(container.querySelector('[data-role="manager-status"]')?.getAttribute('data-tone')).toBe( + 'error', + ); + expect(store.calls.length).toBe(callsBefore); + expect(store.csv()).toBe(gymCsvRaw); + + // A valid retitle renames every member in a single batch. + type(container, 'rename-storyline-id', 'gym-story-renamed'); + type(container, 'rename-storyline-title', 'Renamed Arc'); + click(container.querySelector('[data-action="preview-rename"]')); + await waitFor( + () => container.querySelectorAll('[data-role="diff-row"]').length === 4, + 'the four-member rename diff', + ); + expect(store.calls.filter((call) => call.type === 'apply')).toHaveLength(0); + + click(container.querySelector('[data-action="apply-rename"]')); + await waitFor( + () => + store.calls.filter((call) => call.type === 'apply').length === 1 && + manager.groups().some((group) => group.storylineId === 'gym-story-renamed'), + 'the rename write to land', + ); + expect(container.querySelector('[data-role="manager-status"]')?.getAttribute('data-tone')).toBe( + 'success', + ); + + // Exactly one apply: one author-API call for the whole group. + expect(store.calls.filter((call) => call.type === 'apply')).toHaveLength(1); + const edit = lastApply(store); + expect(edit.kind).toBe('set-storyline-batch'); + expect(edit.kind === 'set-storyline-batch' && [...edit.spec.ids].sort()).toEqual([ + 'gym-story-end', + 'gym-story-ignore', + 'gym-story-invest', + 'gym-story-start', + ]); + expect(store.csv()).toContain('gym-story-renamed'); + expect(store.csv()).toContain('Renamed Arc'); + + // AC: the list reflects the regenerated manifest after the accepted edit. + // The pending `gym-story-bonus` group created above for the duplicate-id + // check has no member cards, so it is deliberately *not* in the content and + // stays listed as a session group alongside the regenerated manifest. + expect(summarise(manager.groups())).toEqual( + summarise([ + ...groupsFromCsv(store.csv()), + { storylineId: 'gym-story-bonus', title: 'Bonus Arc', members: [], pending: true }, + ]), + ); + expect(manager.groups().map((group) => [group.storylineId, group.title])).toEqual([ + ['gym-story-bonus', 'Bonus Arc'], + ['gym-story-renamed', 'Renamed Arc'], + ]); + expect(container.querySelector('[data-role="rename-panel"]')?.hasAttribute('hidden')).toBe(true); + }); + + it('reassigns selected member cards into another group with one write', async () => { + const { container, shell, store } = mount(); + const manager = shell.openStorylineManager(); + if (!manager) throw new Error('expected the manager to open'); + + // Split off a leaf into a new group, then move another card into it. + type(container, 'new-storyline-id', 'gym-story-bonus'); + type(container, 'new-storyline-title', 'Bonus Arc'); + toggleMember(container, 'gym-story-end', true); + click(container.querySelector('[data-action="create-group"]')); + await waitFor( + () => + container.querySelector('[data-role="manager-status"]')?.getAttribute('data-tone') === + 'success', + 'the create outcome', + ); + + toggleMember(container, 'gym-story-invest', true); + click( + container.querySelector( + '[data-action="reassign-selected"][data-storyline-id="gym-story-bonus"]', + ), + ); + // Wait on the write itself: the status tone is already `success` from the + // create above, so it cannot signal this second operation completing. + await waitFor( + () => + store.calls.filter((call) => call.type === 'apply').length === 2 && + manager + .groups() + .find((group) => group.storylineId === 'gym-story-bonus') + ?.members.some((member) => member.id === 'gym-story-invest') === true, + 'the reassignment write to land', + ); + + expect(store.calls.filter((call) => call.type === 'apply')).toHaveLength(2); + const edit = lastApply(store); + expect(edit.kind === 'set-storyline-batch' && edit.spec.ids).toEqual(['gym-story-invest']); + expect(summarise(manager.groups())).toEqual(summarise(groupsFromCsv(store.csv()))); + expect(manager.groups().find((group) => group.storylineId === 'gym-story-bonus')).toMatchObject({ + members: expect.arrayContaining([ + expect.objectContaining({ id: 'gym-story-end' }), + expect.objectContaining({ id: 'gym-story-invest' }), + ]), + }); + }); + + it('blocks a regroup that would orphan members and writes nothing', async () => { + const { container, shell, store } = mount(); + const manager = shell.openStorylineManager(); + if (!manager) throw new Error('expected the manager to open'); + + // Moving the entry card out leaves the rest of the arc unreachable — the + // validator refuses the preview, so apply never runs. + const createdGroup = await manager.createGroup({ + id: 'gym-story-bonus', + title: 'Bonus Arc', + memberIds: ['gym-story-start'], + }); + // The pending group is still listed (nothing was written), so the author can + // adjust the selection and retry. + expect(createdGroup).toMatchObject({ storylineId: 'gym-story-bonus', pending: true }); + expect(store.calls.filter((call) => call.type === 'apply')).toHaveLength(0); + expect(store.csv()).toBe(gymCsvRaw); + expect(container.querySelector('[data-role="manager-status"]')?.getAttribute('data-tone')).toBe( + 'error', + ); + expect(container.querySelector('[data-role="preview-report"]')?.textContent).toContain( + 'unreachable', + ); + }); +}); diff --git a/tests/story-line/editor/storyline-manager.test.ts b/tests/story-line/editor/storyline-manager.test.ts new file mode 100644 index 000000000..4d162006f --- /dev/null +++ b/tests/story-line/editor/storyline-manager.test.ts @@ -0,0 +1,326 @@ +/** + * Story-line management (F4): the group list, draft validation and the single + * batch write behind create / rename / reassign. + * + * The panel is a plain-DOM module, so its interaction behaviour is covered by + * the browser round-trip (`storyline-manager.browser.test.ts`). This Node suite + * pins the parts that are pure or write content: + * + * - the rendered list is derived from the manifest (its story-line ids plus each + * member's declared id/title) and additionally shows a group created in this + * session that has no member cards yet; + * - a create/rename draft is validated locally (id/title required, id unique); + * - **every** management operation is one `set-storyline-batch` edit, validated + * once for the whole batch and written once through the author API — never one + * write per member card; + * - create, rename and reassign round-trip against a temp fixture copy, and the + * list derived from the **regenerated manifest** matches the list the manager + * keeps after the write; + * - an invalid regroup (one that would orphan members) is refused and the file + * stays byte-identical; + * - the committed Gym CSV is never mutated. + * + * @module + */ +import { readFileSync } from 'node:fs'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { GYM_CONTENT_ADAPTER } from '../../../src/story-line/content/adapters'; +import { computeContentChecksum } from '../../../src/story-line/content/csv'; +import { buildStorylineManifestFromCsv } from '../../../src/story-line/content/manifest'; +import { applyEdit, previewEdit } from '../../../src/story-line/editor/api'; +import { + buildStorylineGroups, + setStorylineMembersEdit, + validateStorylineDraft, + type StorylineGroupSource, + type StorylineGroupView, + type StorylineMemberSource, +} from '../../../tools/storyline-editor/storyline-manager'; +import { + CONTENT_CSV_PATH, + copyEditorFixtureFiles, + type EditorFixtureFiles, +} from './fixtures'; + +/** The artefact paths of a fixture copy. */ +function artefactPaths(fixture: EditorFixtureFiles) { + return { json: fixture.manifestPath, mermaid: fixture.mermaidPath }; +} + +/** Loads the fixture content and returns its checksum. */ +function loadChecksum(fixture: EditorFixtureFiles): string { + return computeContentChecksum(readFileSync(fixture.csvPath, 'utf-8')); +} + +/** The story-line groups the generated manifest reports for a content CSV. */ +function manifestGroups(csv: string): StorylineGroupView[] { + const manifest = buildStorylineManifestFromCsv(csv, GYM_CONTENT_ADAPTER); + const groups: StorylineGroupSource[] = manifest.storylineIds.map((storylineId) => ({ + storylineId, + title: + manifest.nodes.find((node) => node.storylineId === storylineId && node.storylineTitle) + ?.storylineTitle ?? storylineId, + })); + const members: StorylineMemberSource[] = manifest.nodes.map((node) => ({ + id: node.id, + name: node.name, + storylineId: node.storylineId, + storylineTitle: node.storylineTitle, + })); + return buildStorylineGroups(groups, members); +} + +/** A comparable projection of a group list (ids, titles, members, pending). */ +function summarise(groups: readonly StorylineGroupView[]) { + return groups + .map((group) => ({ + storylineId: group.storylineId, + title: group.title, + memberIds: group.members.map((member) => member.id).sort(), + pending: group.pending, + })) + .sort((a, b) => (a.storylineId < b.storylineId ? -1 : a.storylineId > b.storylineId ? 1 : 0)); +} + +/** The Gym fixture's four story-line members. */ +const GYM_MEMBERS = [ + 'gym-story-start', + 'gym-story-invest', + 'gym-story-ignore', + 'gym-story-end', +] as const; + +describe('buildStorylineGroups — derived from the manifest, plus session groups', () => { + it('lists every declared group with its members and titles', () => { + const csv = readFileSync(CONTENT_CSV_PATH, 'utf-8'); + const groups = manifestGroups(csv); + + expect(summarise(groups)).toEqual([ + { + storylineId: 'gym-story', + title: 'Gym Storyline', + memberIds: [...GYM_MEMBERS].sort(), + pending: false, + }, + ]); + }); + + it('keeps a group created in this session even before it has members', () => { + const members: StorylineMemberSource[] = [ + { id: 'a', name: 'A', storylineId: 'arc', storylineTitle: 'Arc' }, + ]; + const groups = buildStorylineGroups( + [{ storylineId: 'arc', title: 'Arc' }], + members, + [{ storylineId: 'new-arc', title: 'New Arc', written: false }], + ); + + expect(summarise(groups)).toEqual([ + { storylineId: 'arc', title: 'Arc', memberIds: ['a'], pending: false }, + { storylineId: 'new-arc', title: 'New Arc', memberIds: [], pending: true }, + ]); + }); + + it('drops a written group that has no members, and never duplicates a declared one', () => { + const groups = buildStorylineGroups( + [{ storylineId: 'arc', title: 'Arc' }], + [{ id: 'a', name: 'A', storylineId: 'arc', storylineTitle: 'Arc' }], + [ + { storylineId: 'arc', title: 'Arc (stale session copy)', written: true }, + { storylineId: 'empty-arc', title: 'Empty Arc', written: true }, + ], + ); + + // A story-line exists only through its member cards: a written group with no + // members is not in the content, and the declared entry wins the duplicate. + expect(summarise(groups)).toEqual([ + { storylineId: 'arc', title: 'Arc', memberIds: ['a'], pending: false }, + ]); + }); + + it('derives membership from each card’s declared storyline id, not from the group entry', () => { + const members: StorylineMemberSource[] = [ + { id: 'a', name: 'A', storylineId: 'arc', storylineTitle: 'Arc' }, + { id: 'b', name: 'B', storylineId: 'other', storylineTitle: 'Other' }, + { id: 'c', name: 'C', storylineId: null, storylineTitle: null }, + ]; + const groups = buildStorylineGroups( + [ + { storylineId: 'arc', title: 'Arc' }, + { storylineId: 'other', title: 'Other' }, + ], + members, + ); + + expect(summarise(groups)).toEqual([ + { storylineId: 'arc', title: 'Arc', memberIds: ['a'], pending: false }, + { storylineId: 'other', title: 'Other', memberIds: ['b'], pending: false }, + ]); + }); +}); + +describe('validateStorylineDraft — authoring errors the content validator cannot see', () => { + it('requires an id and a title', () => { + const errors = validateStorylineDraft({ id: ' ', title: '' }, []); + expect(errors.map((error) => error.code)).toEqual(['missing-id', 'missing-title']); + }); + + it('rejects an id another group already uses', () => { + const errors = validateStorylineDraft({ id: 'gym-story', title: 'Copy' }, [ + 'gym-story', + 'other', + ]); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ code: 'duplicate-id' }); + expect(errors[0].message).toContain('gym-story'); + }); + + it('exempts the group being renamed from the uniqueness check', () => { + expect( + validateStorylineDraft({ id: 'gym-story', title: 'Gym Storyline' }, ['gym-story'], { + allowId: 'gym-story', + }), + ).toEqual([]); + // Re-id'ing to another group's id is still refused. + expect( + validateStorylineDraft({ id: 'other', title: 'Other' }, ['gym-story', 'other'], { + allowId: 'gym-story', + }).map((error) => error.code), + ).toEqual(['duplicate-id']); + }); +}); + +describe('setStorylineMembersEdit — one batch edit for the whole group', () => { + it('carries every member id in a single set-storyline-batch edit', () => { + const edit = setStorylineMembersEdit([...GYM_MEMBERS], 'gym-story-renamed', 'Renamed Arc'); + expect(edit).toEqual({ + kind: 'set-storyline-batch', + spec: { + ids: [...GYM_MEMBERS], + storylineId: 'gym-story-renamed', + storylineTitle: 'Renamed Arc', + }, + }); + // The edit is one object regardless of member count: no per-card edit list. + expect(Array.isArray(edit)).toBe(false); + }); +}); + +describe('management operations round-trip against a fixture CSV', () => { + let fixture: EditorFixtureFiles; + + beforeEach(() => { + fixture = copyEditorFixtureFiles(); + }); + + afterEach(() => { + fixture.cleanup(); + }); + + /** Applies one edit through the author API, asserting a single write. */ + function write(edit: ReturnType<typeof setStorylineMembersEdit>, expectRows: number) { + const before = readFileSync(fixture.csvPath, 'utf-8'); + const preview = previewEdit(before, GYM_CONTENT_ADAPTER, edit); + expect(preview.ok).toBe(true); + expect(preview.rows).toHaveLength(expectRows); + + const result = applyEdit({ + contentPath: fixture.csvPath, + adapter: GYM_CONTENT_ADAPTER, + edit, + expectedChecksum: loadChecksum(fixture), + artefactPaths: artefactPaths(fixture), + manualDocs: [], + }); + + // One author-API call, one file write, every member in the same candidate. + expect(result.ok).toBe(true); + expect(result.written).toBe(true); + expect(result.preview.rows).toHaveLength(expectRows); + expect(result.preview.rows.every((row) => row.status === 'changed')).toBe(true); + return readFileSync(fixture.csvPath, 'utf-8'); + } + + it('creates a group by assigning member cards in one validated write', () => { + const csv = write( + setStorylineMembersEdit(['gym-story-end'], 'gym-story-bonus', 'Bonus Arc'), + 1, + ); + + // The list derived from the regenerated manifest shows the new group. + expect(summarise(manifestGroups(csv))).toEqual([ + { storylineId: 'gym-story', title: 'Gym Storyline', memberIds: ['gym-story-ignore', 'gym-story-invest', 'gym-story-start'], pending: false }, + { storylineId: 'gym-story-bonus', title: 'Bonus Arc', memberIds: ['gym-story-end'], pending: false }, + ]); + }); + + it('renames a group with exactly one batch write for every member', () => { + const csv = write( + setStorylineMembersEdit([...GYM_MEMBERS], 'gym-story-renamed', 'Renamed Arc'), + GYM_MEMBERS.length, + ); + + expect(csv).toContain('gym-story-renamed'); + expect(csv).toContain('Renamed Arc'); + expect(csv).not.toContain('gym-story,Gym Storyline'); + + expect(summarise(manifestGroups(csv))).toEqual([ + { + storylineId: 'gym-story-renamed', + title: 'Renamed Arc', + memberIds: [...GYM_MEMBERS].sort(), + pending: false, + }, + ]); + }); + + it('reassigns selected member cards between existing groups', () => { + write(setStorylineMembersEdit(['gym-story-end'], 'gym-story-bonus', 'Bonus Arc'), 1); + const csv = write( + setStorylineMembersEdit(['gym-story-invest'], 'gym-story-bonus', 'Bonus Arc'), + 1, + ); + + expect(summarise(manifestGroups(csv))).toEqual([ + { storylineId: 'gym-story', title: 'Gym Storyline', memberIds: ['gym-story-ignore', 'gym-story-start'], pending: false }, + { storylineId: 'gym-story-bonus', title: 'Bonus Arc', memberIds: ['gym-story-end', 'gym-story-invest'], pending: false }, + ]); + }); + + it('refuses a regroup that would orphan members and writes nothing', () => { + const before = readFileSync(fixture.csvPath, 'utf-8'); + const edit = setStorylineMembersEdit(['gym-story-start'], 'gym-story-bonus', 'Bonus Arc'); + + // Moving the entry card out leaves the rest of the arc unreachable from its + // new entry point — the validator refuses the candidate. + const preview = previewEdit(before, GYM_CONTENT_ADAPTER, edit); + expect(preview.ok).toBe(false); + expect( + preview.validation.issues.some((issue) => issue.code === 'unreachable-card'), + ).toBe(true); + + const result = applyEdit({ + contentPath: fixture.csvPath, + adapter: GYM_CONTENT_ADAPTER, + edit, + expectedChecksum: loadChecksum(fixture), + artefactPaths: artefactPaths(fixture), + manualDocs: [], + }); + expect(result.ok).toBe(false); + expect(result.written).toBe(false); + expect(readFileSync(fixture.csvPath, 'utf-8')).toBe(before); + }); +}); + +describe('the committed Gym content is never mutated', () => { + it('still holds the original story-line after every fixture operation', () => { + const csv = readFileSync(CONTENT_CSV_PATH, 'utf-8'); + expect(csv).toContain('gym-story,Gym Storyline'); + expect(csv).not.toContain('gym-story-bonus'); + expect(csv).not.toContain('gym-story-renamed'); + expect(csv).not.toContain('Renamed Arc'); + }); +}); diff --git a/tests/story-line/editor/view-model.test.ts b/tests/story-line/editor/view-model.test.ts new file mode 100644 index 000000000..b41de30aa --- /dev/null +++ b/tests/story-line/editor/view-model.test.ts @@ -0,0 +1,247 @@ +/** + * Read-only fidelity tests for the story-line editor view model. + * + * The editor renders the generated manifest and nothing else, so the core + * contract is a **diff**: the view model's nodes, edges, story-line ids/titles + * and cycle flags must be identical to the manifest it was built from + * (epic AC4). The remaining tests pin the derived presentation data — the + * story-line grouping, successor/predecessor links and the manifest label + * helpers — plus determinism. + * + * @module + */ +import { readFileSync } from 'node:fs'; + +import { describe, expect, it } from 'vitest'; + +import { GYM_CONTENT_ADAPTER } from '../../../src/story-line/content/adapters'; +import { + briefEventDescription, + buildStorylineManifestFromCsv, + describeChoiceRouting, + describeEventImpact, + nodeLabel, + type StorylineManifest, +} from '../../../src/story-line/content/manifest'; +import { + UNGROUPED_STORYLINE_TITLE, + buildEditorViewModel, + type EditorEdge, + type EditorNode, +} from '../../../src/story-line/editor/view-model'; +import { CONTENT_CSV_PATH } from './fixtures'; + +/** Builds the Gym manifest from the committed content CSV. */ +function gymManifest(): StorylineManifest { + return buildStorylineManifestFromCsv(readFileSync(CONTENT_CSV_PATH, 'utf-8'), GYM_CONTENT_ADAPTER); +} + +/** Projects a view-model node onto the fields the manifest also carries. */ +function nodeProjection(node: EditorNode) { + return { + id: node.id, + name: node.name, + storylineId: node.storylineId, + storylineTitle: node.storylineTitle, + hasChoices: node.hasChoices, + optionCount: node.optionCount, + effect: node.effect, + impact: node.impact, + fields: node.fields, + }; +} + +/** Projects a view-model edge onto the manifest edge shape. */ +function edgeProjection(edge: EditorEdge) { + return { + from: edge.from, + label: edge.label, + to: edge.to, + random: edge.random, + candidateIndex: edge.candidateIndex, + cycle: edge.cycle, + effectPolicy: edge.effectPolicy, + }; +} + +/** A deliberately shaped manifest: two groups (one ungrouped card), a cycle. */ +function syntheticManifest(): StorylineManifest { + return { + version: 1, + source: 'synthetic fixture', + gameId: 'fixture', + storylineIds: ['arc-a'], + nodes: [ + { + id: 'a', + name: 'Alpha', + storylineId: 'arc-a', + storylineTitle: 'Arc A', + hasChoices: true, + optionCount: 2, + options: [ + { label: 'go', successorIds: ['b'], effectPolicy: 'apply' }, + { label: 'stop', successorIds: [], effectPolicy: 'skip' }, + ], + effect: 'Alpha happens.', + impact: '+1 alpha', + fields: { coin: '1' }, + }, + { + id: 'b', + name: 'Beta', + storylineId: 'arc-a', + storylineTitle: 'Arc A', + hasChoices: true, + optionCount: 1, + options: [{ label: 'back', successorIds: ['a'], effectPolicy: 'apply' }], + effect: 'Beta follows.', + impact: '', + fields: {}, + }, + { + id: 'loner', + name: 'Loner', + storylineId: null, + storylineTitle: null, + hasChoices: false, + optionCount: 0, + options: [], + effect: 'Loner stands alone.', + impact: '', + fields: { token: 'x' }, + }, + ], + edges: [ + { from: 'a', label: 'go', to: 'b', random: false, candidateIndex: 0, cycle: true, effectPolicy: 'apply' }, + { from: 'b', label: 'back', to: 'a', random: false, candidateIndex: 0, cycle: true, effectPolicy: 'apply' }, + { from: 'a', label: 'stop', to: null, random: false, candidateIndex: 0, cycle: false, effectPolicy: 'skip' }, + ], + cycles: [['a', 'b']], + }; +} + +describe('buildEditorViewModel — read-only fidelity (diff against the manifest)', () => { + it('projects every manifest node identically (id, name, story-line, choice flag, effect, impact, columns)', () => { + const manifest = gymManifest(); + const viewModel = buildEditorViewModel(manifest); + + expect(viewModel.nodes.map(nodeProjection)).toEqual(manifest.nodes.map((node) => ({ + id: node.id, + name: node.name, + storylineId: node.storylineId, + storylineTitle: node.storylineTitle, + hasChoices: node.hasChoices, + optionCount: node.optionCount, + effect: node.effect, + impact: node.impact, + fields: node.fields, + }))); + // Nothing dropped, nothing invented. + expect(viewModel.nodes).toHaveLength(manifest.nodes.length); + }); + + it('projects every manifest edge identically (from, label, target, random, candidate, cycle, policy)', () => { + const manifest = gymManifest(); + const viewModel = buildEditorViewModel(manifest); + + expect(viewModel.edges.map(edgeProjection)).toEqual(manifest.edges.map(edgeProjection)); + expect(viewModel.edges).toHaveLength(manifest.edges.length); + }); + + it('keeps the manifest story-line ids and titles', () => { + const manifest = gymManifest(); + const viewModel = buildEditorViewModel(manifest); + + const grouped = viewModel.storylines.filter((group) => group.storylineId !== null); + expect(grouped.map((group) => group.storylineId)).toEqual([...manifest.storylineIds]); + for (const group of grouped) { + const manifestTitle = manifest.nodes.find( + (node) => node.storylineId === group.storylineId && node.storylineTitle, + )?.storylineTitle; + expect(group.title).toBe(manifestTitle ?? group.storylineId); + } + }); + + it('derives cycle flags exactly from the manifest cycles (synthetic cycle)', () => { + const manifest = syntheticManifest(); + const viewModel = buildEditorViewModel(manifest); + + const inCycle = viewModel.nodes.filter((node) => node.inCycle).map((node) => node.id); + expect(inCycle).toEqual(['a', 'b']); + expect(viewModel.nodes.find((node) => node.id === 'loner')?.inCycle).toBe(false); + + expect(viewModel.edges.map((edge) => [edge.from, edge.to, edge.cycle])).toEqual( + manifest.edges.map((edge) => [edge.from, edge.to, edge.cycle]), + ); + expect(viewModel.cycles).toEqual(manifest.cycles); + }); + + it('gives every card exactly one story-line group, with ungrouped cards last', () => { + const viewModel = buildEditorViewModel(syntheticManifest()); + + const groupedIds = viewModel.storylines.flatMap((group) => group.nodeIds); + expect(groupedIds.sort()).toEqual(viewModel.nodes.map((node) => node.id).sort()); + expect(new Set(groupedIds).size).toBe(groupedIds.length); + + const last = viewModel.storylines[viewModel.storylines.length - 1]; + expect(last.storylineId).toBeNull(); + expect(last.title).toBe(UNGROUPED_STORYLINE_TITLE); + expect(last.nodeIds).toEqual(['loner']); + // A terminal link from a grouped card stays in its group; a link to an + // ungrouped card never appears in a story-line group's internal edges. + const groupA = viewModel.storylines.find((group) => group.storylineId === 'arc-a'); + expect(groupA?.edges.map((edge) => [edge.from, edge.to])).toEqual([ + ['a', 'b'], + ['b', 'a'], + ['a', null], + ]); + }); + + it('reuses the manifest label helpers rather than re-deriving presentation text', () => { + const manifest = gymManifest(); + const viewModel = buildEditorViewModel(manifest); + const nameById = new Map(manifest.nodes.map((node) => [node.id, node.name])); + + for (const manifestNode of manifest.nodes) { + const node = viewModel.nodes.find((candidate) => candidate.id === manifestNode.id); + expect(node).toBeDefined(); + if (!node) continue; + expect(node.labels.node).toBe(nodeLabel(manifestNode)); + expect(node.labels.brief).toBe(briefEventDescription(manifestNode.effect)); + expect(node.labels.impact).toBe(describeEventImpact(manifestNode)); + expect(node.labels.routing).toBe( + describeChoiceRouting(manifestNode, manifest.edges, nameById), + ); + } + }); + + it('builds successor and predecessor links with target names and chain ends', () => { + const viewModel = buildEditorViewModel(gymManifest()); + + const start = viewModel.nodes.find((node) => node.id === 'gym-story-start'); + expect(start?.successors.map((link) => [link.label, link.targetId, link.targetName])).toEqual([ + ['Ignore', 'gym-story-ignore', 'Neglected Roads'], + ['Invest', 'gym-story-invest', 'Public Investment'], + ]); + + const ignore = viewModel.nodes.find((node) => node.id === 'gym-story-ignore'); + expect(ignore?.successors[0]).toMatchObject({ + label: 'Let it be', + targetId: null, + targetName: 'chain ends', + }); + + const end = viewModel.nodes.find((node) => node.id === 'gym-story-end'); + expect(end?.successors).toEqual([]); + expect(end?.predecessors.map((link) => [link.label, link.fromId, link.fromName])).toEqual([ + ['Repair', 'gym-story-ignore', 'Neglected Roads'], + ['Celebrate', 'gym-story-invest', 'Public Investment'], + ]); + }); + + it('is deterministic: the same manifest always produces the same view model', () => { + const manifest = gymManifest(); + expect(buildEditorViewModel(manifest)).toEqual(buildEditorViewModel(manifest)); + }); +}); diff --git a/tests/story-line/editor/viewer.browser.test.ts b/tests/story-line/editor/viewer.browser.test.ts new file mode 100644 index 000000000..413fd3c39 --- /dev/null +++ b/tests/story-line/editor/viewer.browser.test.ts @@ -0,0 +1,342 @@ +/** + * Browser tests for the read-only story-line editor. + * + * The editor's page modules are plain DOM (no Phaser, no game loop), so they + * are mounted straight into the real Chromium document and exercised against + * the **committed Gym manifest** — the same artefact the editor host serves. + * This covers the slice's rendering contract: every manifest node and edge is + * drawn, choice cards and cycle edges are visually distinct, story-line + * grouping is shown, pan/zoom and node selection work, and selecting a card + * shows its links and metadata (epic AC2/AC3/AC5). + * + * @module + */ +import { afterEach, describe, expect, it } from 'vitest'; + +import gymManifestJson from '../../../example-games/gym/content/storyline-manifest.json'; +import type { StorylineManifest } from '../../../src/story-line/content/manifest'; +import { mountEditorShell, type EditorShell } from '../../../tools/storyline-editor/app-shell'; + +/** The committed Gym manifest — exactly what the editor host serves. */ +const gymManifest = gymManifestJson as StorylineManifest; + +let mounted: { container: HTMLDivElement; shell: EditorShell } | null = null; + +/** Mounts the editor shell into a fresh container in the test document. */ +function mount(manifest: StorylineManifest): { container: HTMLDivElement; shell: EditorShell } { + const container = document.createElement('div'); + document.body.append(container); + const shell = mountEditorShell(container, manifest); + mounted = { container, shell }; + return mounted; +} + +/** Clicks an element the way a user would. */ +function click(element: Element): void { + element.dispatchEvent(new MouseEvent('click', { bubbles: true })); +} + +/** A manifest with cycles, a random branch, a terminal link and an ungrouped card. */ +function syntheticManifest(): StorylineManifest { + return { + version: 1, + source: 'synthetic fixture', + gameId: 'fixture', + storylineIds: ['arc-a'], + nodes: [ + { + id: 'a', + name: 'Alpha', + storylineId: 'arc-a', + storylineTitle: 'Arc A', + hasChoices: true, + optionCount: 3, + options: [ + { label: 'go', successorIds: ['b'], effectPolicy: 'apply' }, + { label: 'split', successorIds: ['b', 'c'], effectPolicy: 'skip' }, + { label: 'stop', successorIds: [], effectPolicy: 'apply' }, + ], + effect: 'Alpha happens.', + impact: '', + fields: {}, + }, + { + id: 'b', + name: 'Beta', + storylineId: 'arc-a', + storylineTitle: 'Arc A', + hasChoices: true, + optionCount: 1, + options: [{ label: 'back', successorIds: ['a'], effectPolicy: 'apply' }], + effect: 'Beta follows.', + impact: '', + fields: {}, + }, + { + id: 'c', + name: 'Gamma', + storylineId: 'arc-a', + storylineTitle: 'Arc A', + hasChoices: false, + optionCount: 0, + options: [], + effect: 'Gamma ends.', + impact: '', + fields: {}, + }, + { + id: 'loner', + name: 'Loner', + storylineId: null, + storylineTitle: null, + hasChoices: false, + optionCount: 0, + options: [], + effect: 'Loner stands alone.', + impact: '', + fields: {}, + }, + ], + edges: [ + { from: 'a', label: 'go', to: 'b', random: false, candidateIndex: 0, cycle: true, effectPolicy: 'apply' }, + { from: 'a', label: 'split', to: 'b', random: true, candidateIndex: 0, cycle: false, effectPolicy: 'skip' }, + { from: 'a', label: 'split', to: 'c', random: true, candidateIndex: 1, cycle: false, effectPolicy: 'skip' }, + { from: 'a', label: 'stop', to: null, random: false, candidateIndex: 0, cycle: false, effectPolicy: 'apply' }, + { from: 'b', label: 'back', to: 'a', random: false, candidateIndex: 0, cycle: true, effectPolicy: 'apply' }, + ], + cycles: [['a', 'b']], + }; +} + +afterEach(() => { + mounted?.shell.dispose(); + mounted?.container.remove(); + mounted = null; +}); + +describe('story-line editor viewer — manifest rendering', () => { + it('renders every node and every edge of the generated manifest', () => { + const { container } = mount(gymManifest); + + const nodeElements = container.querySelectorAll('[data-node-id]'); + expect(nodeElements.length).toBe(gymManifest.nodes.length); + for (const node of gymManifest.nodes) { + const element = container.querySelector(`[data-node-id="${node.id}"]`); + expect(element, `node ${node.id} is rendered`).not.toBeNull(); + expect(element?.getAttribute('data-has-choices')).toBe(String(node.hasChoices)); + expect(element?.getAttribute('data-storyline-id')).toBe(node.storylineId ?? ''); + } + + const edgeElements = [...container.querySelectorAll('[data-edge="true"]')]; + expect(edgeElements.length).toBe(gymManifest.edges.length); + for (const edge of gymManifest.edges) { + const rendered = edgeElements.some( + (element) => + element.getAttribute('data-edge-from') === edge.from && + element.getAttribute('data-edge-to') === (edge.to ?? '') && + element.getAttribute('data-edge-label') === edge.label, + ); + expect(rendered, `edge ${edge.from} -${edge.label}-> ${edge.to ?? 'end'} is rendered`).toBe(true); + } + // Every edge carries its option label on the canvas. + expect(container.querySelectorAll('[data-role="edge-label"]').length).toBe( + gymManifest.edges.length, + ); + + // The toolbar summary reports what is on screen. + const summary = container.querySelector('[data-role="graph-summary"]')?.textContent ?? ''; + expect(summary).toContain(`${gymManifest.nodes.length} cards`); + expect(summary).toContain(`${gymManifest.edges.length} links`); + expect(summary).toContain('1 story-line'); + }); + + it('distinguishes choice cards from plain incidents and groups cards by story-line', () => { + const { container } = mount(gymManifest); + + const start = container.querySelector('[data-node-id="gym-story-start"]'); + const end = container.querySelector('[data-node-id="gym-story-end"]'); + expect(start?.classList.contains('node--choice')).toBe(true); + expect(end?.classList.contains('node--incident')).toBe(true); + expect(container.querySelectorAll('.node--choice').length).toBe( + gymManifest.nodes.filter((node) => node.hasChoices).length, + ); + + const column = container.querySelector('[data-role="graph-column"][data-storyline-id="gym-story"]'); + expect(column).not.toBeNull(); + expect(column?.textContent).toContain('Gym Storyline'); + + const legend = container.querySelector('[data-role="storyline-legend-item"][data-storyline-id="gym-story"]'); + expect(legend).not.toBeNull(); + expect(legend?.textContent).toContain('Gym Storyline · 4 cards'); + }); + + it('marks cycle edges and cycle members, and shows random-branch candidates', () => { + const { container } = mount(syntheticManifest()); + + const cycleEdges = container.querySelectorAll('[data-edge="true"][data-cycle="true"]'); + expect(cycleEdges.length).toBe(2); + expect(container.querySelectorAll('[data-edge="true"][data-cycle="false"]').length).toBe(3); + + expect(container.querySelector('[data-node-id="a"]')?.getAttribute('data-in-cycle')).toBe('true'); + expect(container.querySelector('[data-node-id="b"]')?.getAttribute('data-in-cycle')).toBe('true'); + expect(container.querySelector('[data-node-id="c"]')?.getAttribute('data-in-cycle')).toBe('false'); + expect(container.querySelector('[data-node-id="a"]')?.classList.contains('node--cycle')).toBe(true); + + const randomEdges = container.querySelectorAll('[data-edge="true"][data-random="true"]'); + expect(randomEdges.length).toBe(2); + const labels = [...container.querySelectorAll('[data-role="edge-label"]')].map( + (element) => element.textContent, + ); + expect(labels).toContain('split (1)'); + expect(labels).toContain('split (2)'); + + // The ungrouped card still renders (it is not dropped from the graph). + expect(container.querySelector('[data-node-id="loner"]')).not.toBeNull(); + }); +}); + +describe('story-line editor viewer — pan/zoom controls', () => { + it('zooms, pans and resets through the renderer API and the toolbar', () => { + const { container, shell } = mount(gymManifest); + const viewport = shell.renderer.viewport; + expect(shell.renderer.getTransform()).toEqual({ scale: 1, x: 0, y: 0 }); + + shell.renderer.zoomBy(2); + expect(shell.renderer.getTransform().scale).toBe(2); + expect(viewport.getAttribute('transform')).toContain('scale(2)'); + + const beforePan = shell.renderer.getTransform(); + shell.renderer.panBy(40, -15); + expect(shell.renderer.getTransform().x).toBeCloseTo(beforePan.x + 40, 5); + expect(shell.renderer.getTransform().y).toBeCloseTo(beforePan.y - 15, 5); + expect(viewport.getAttribute('transform')).toContain('translate'); + + shell.renderer.resetView(); + expect(shell.renderer.getTransform()).toEqual({ scale: 1, x: 0, y: 0 }); + + click(container.querySelector('[data-action="zoom-in"]') as Element); + expect(shell.renderer.getTransform().scale).toBeGreaterThan(1); + click(container.querySelector('[data-action="reset-view"]') as Element); + expect(shell.renderer.getTransform().scale).toBe(1); + + // "Fit view" picks a positive, in-range scale for the whole graph. + click(container.querySelector('[data-action="fit-view"]') as Element); + const fitted = shell.renderer.getTransform().scale; + expect(fitted).toBeGreaterThan(0); + expect(fitted).toBeLessThanOrEqual(4); + }); + + it('zooms with the wheel and pans with a pointer drag', () => { + const { shell } = mount(gymManifest); + const svg = shell.renderer.svg; + + svg.dispatchEvent( + new WheelEvent('wheel', { + deltaY: -120, + clientX: 120, + clientY: 90, + bubbles: true, + cancelable: true, + }), + ); + expect(shell.renderer.getTransform().scale).toBeGreaterThan(1); + shell.renderer.resetView(); + + const before = shell.renderer.getTransform(); + svg.dispatchEvent( + new PointerEvent('pointerdown', { pointerId: 11, clientX: 50, clientY: 50, bubbles: true }), + ); + svg.dispatchEvent( + new PointerEvent('pointermove', { pointerId: 11, clientX: 90, clientY: 70, bubbles: true }), + ); + svg.dispatchEvent( + new PointerEvent('pointerup', { pointerId: 11, clientX: 90, clientY: 70, bubbles: true }), + ); + expect(shell.renderer.getTransform().x).toBeCloseTo(before.x + 40, 5); + expect(shell.renderer.getTransform().y).toBeCloseTo(before.y + 20, 5); + }); +}); + +describe('story-line editor viewer — node selection and details', () => { + it('shows the selected card’s links and metadata', () => { + const { container, shell } = mount(gymManifest); + const start = gymManifest.nodes.find((node) => node.id === 'gym-story-start'); + expect(start).toBeDefined(); + if (!start) return; + + click(container.querySelector('[data-node-id="gym-story-start"]') as Element); + + expect(shell.renderer.selectedNodeId).toBe('gym-story-start'); + expect(container.querySelector('[data-role="details"]')?.hasAttribute('hidden')).toBe(false); + expect(container.querySelector('[data-role="details-name"]')?.textContent).toBe(start.name); + expect(container.querySelector('[data-role="details-id"]')?.textContent).toBe(start.id); + expect(container.querySelector('[data-role="details-kind"]')?.textContent).toBe( + 'Choice card · 2 options', + ); + expect(container.querySelector('[data-role="details-storyline"]')?.textContent).toBe( + 'Story-line: Gym Storyline (gym-story)', + ); + expect(container.querySelector('[data-role="details-effect"]')?.textContent).toBe(start.effect); + expect(container.querySelector('[data-role="details-impact"]')?.textContent).toBe(start.impact); + expect(container.querySelector('[data-role="details-routing"]')?.textContent).toContain('Invest'); + + // Successor targets are listed as links. + const links = [...container.querySelectorAll('[data-role="details-links"] [data-link-to]')]; + expect(links.map((link) => link.getAttribute('data-link-to'))).toEqual([ + 'gym-story-ignore', + 'gym-story-invest', + ]); + expect(links[0].textContent).toContain('Ignore → Neglected Roads'); + + // Adapter-declared columns are shown, sorted by column name. + const fields = [...container.querySelectorAll('[data-role="details-fields"] [data-field]')]; + expect(fields.map((field) => field.getAttribute('data-field'))).toEqual([ + 'coinDelta', + 'cost', + 'reputationDelta', + 'trigger', + ]); + expect(container.querySelector('[data-field="coinDelta"] dd')?.textContent).toBe( + start.fields.coinDelta, + ); + + // Selecting another card replaces the details and moves the highlight. + click(container.querySelector('[data-node-id="gym-story-end"]') as Element); + expect(shell.renderer.selectedNodeId).toBe('gym-story-end'); + expect(container.querySelector('[data-role="details-name"]')?.textContent).toBe('New Beginning'); + expect(container.querySelector('[data-node-id="gym-story-end"]')?.classList.contains('is-selected')).toBe( + true, + ); + expect(container.querySelector('[data-node-id="gym-story-start"]')?.classList.contains('is-selected')).toBe( + false, + ); + + // A terminal card has no outgoing links but lists where it is reached from. + expect((container.querySelector('[data-role="details-links"]') as HTMLElement).hidden).toBe(true); + expect((container.querySelector('[data-role="details-no-links"]') as HTMLElement).hidden).toBe( + false, + ); + const incoming = [ + ...container.querySelectorAll('[data-role="details-incoming"] [data-link-from]'), + ]; + expect(incoming.map((link) => link.getAttribute('data-link-from'))).toEqual([ + 'gym-story-ignore', + 'gym-story-invest', + ]); + }); + + it('supports programmatic selection and selecting through the story-line legend', () => { + const { container, shell } = mount(gymManifest); + + shell.selectNode('gym-story-invest'); + expect(shell.renderer.selectedNodeId).toBe('gym-story-invest'); + expect(container.querySelector('[data-role="details-name"]')?.textContent).toBe( + 'Public Investment', + ); + + // The legend selects the first card of its story-line group. + click(container.querySelector('[data-role="storyline-legend-item"]') as Element); + expect(shell.renderer.selectedNodeId).toBe('gym-story-end'); + expect(container.querySelector('[data-role="details-name"]')?.textContent).toBe('New Beginning'); + }); +}); diff --git a/tests/story-line/editor/vite-editor-config.test.ts b/tests/story-line/editor/vite-editor-config.test.ts new file mode 100644 index 000000000..d69bd9ff2 --- /dev/null +++ b/tests/story-line/editor/vite-editor-config.test.ts @@ -0,0 +1,43 @@ +/** + * Unit guard for the dedicated story-line editor Vite config. + * + * The editor is a development-only, non-Phaser page: it must keep its own + * config (root, port, build output) and must not inherit the game build's + * discovery/assets plugins. This test is the wiring guard for the harness + * decision recorded on CG-0MVHZ2LS3008ATY9 — if the config drifts back towards + * the game config, it fails here rather than in the production bundle. + * + * @module + */ +import { describe, expect, it } from 'vitest'; + +import editorConfig, { + EDITOR_BUILD_OUT_DIR, + EDITOR_PAGE_ROOT, + EDITOR_SERVER_PORT, +} from '../../../vite.editor.config'; + +describe('story-line editor Vite config', () => { + it('serves the editor page from its own root on a dedicated port', () => { + expect(editorConfig.root).toBe(EDITOR_PAGE_ROOT); + expect(editorConfig.root).toBe('tools/storyline-editor'); + expect(editorConfig.server?.port).toBe(EDITOR_SERVER_PORT); + expect(editorConfig.server?.port).toBe(5174); + // Not the game dev server's port (3000) — the two servers can run together. + expect(editorConfig.server?.port).not.toBe(3000); + }); + + it('builds to a separate output directory so the game bundle never includes the editor', () => { + expect(editorConfig.build?.outDir).toBe(EDITOR_BUILD_OUT_DIR); + expect(editorConfig.build?.outDir).not.toBe('dist'); + }); + + it('resolves the core aliases without registering the game build plugins', () => { + const alias = editorConfig.resolve?.alias as Record<string, string>; + expect(alias['@story-line']).toContain('/src/story-line'); + expect(alias['@core-engine']).toContain('/src/core-engine'); + + const plugins = (editorConfig.plugins ?? []) as { name?: string }[]; + expect(plugins.some((plugin) => plugin.name === 'tce-game-discovery')).toBe(false); + }); +}); diff --git a/tests/story-line/editor/write-path-guard.test.ts b/tests/story-line/editor/write-path-guard.test.ts new file mode 100644 index 000000000..56dfe5f2d --- /dev/null +++ b/tests/story-line/editor/write-path-guard.test.ts @@ -0,0 +1,107 @@ +/** + * Tests for the story-line editor write-path guard. + * + * The guard encodes the epic's single-write-path constraint: only the core + * author module may write content files; every editor module must route edits + * through it. It must be green while the editor does not exist yet (later + * slices), and it must actually catch a direct writer — proven here with a + * synthetic negative case rather than by waiting for a real one to ship. + * + * @module + */ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { + DEFAULT_EDITOR_ROOTS, + SANCTIONED_WRITE_MODULES, + assertSingleWritePath, + scanWritePaths, +} from './write-path-guard'; +import { REPO_ROOT } from './fixtures'; + +describe('story-line editor write-path guard', () => { + it('is green while the editor modules do not exist yet (tolerant, nothing silently dropped)', () => { + const scan = scanWritePaths({ + baseDir: REPO_ROOT, + // Pin the roots to the documented editor locations so this assertion + // stays meaningful once feature code lands. + roots: DEFAULT_EDITOR_ROOTS, + }); + + expect(scan.violations).toEqual([]); + expect(() => assertSingleWritePath(scan)).not.toThrow(); + + // Every root is accounted for individually: absent roots are reported as + // skipped (never silently dropped), and once a root exists its files are + // scanned for violations. + for (const root of DEFAULT_EDITOR_ROOTS) { + const rootScan = scanWritePaths({ baseDir: REPO_ROOT, roots: [root] }); + expect(rootScan.violations).toEqual([]); + if (rootScan.skipped.length === 1) { + expect(rootScan.scanned).toEqual([]); + } else { + expect(rootScan.scanned.length).toBeGreaterThanOrEqual(1); + } + } + }); + + it('passes on the shipped content modules when the sanctioned writers are exempted', () => { + // The single-write-path property of the content layer: only author.ts + // (the content CSV) and artefacts.ts (the generated mermaid/json) write; + // scanning everything else is clean. + const scan = scanWritePaths({ + baseDir: REPO_ROOT, + roots: ['src/story-line/content'], + exclude: SANCTIONED_WRITE_MODULES, + }); + + expect(scan.scanned.length).toBeGreaterThanOrEqual(7); + expect(scan.violations).toEqual([]); + expect(() => assertSingleWritePath(scan)).not.toThrow(); + }); + + it('detects a synthetic editor module that writes the content CSV directly', () => { + const dir = mkdtempSync(join(tmpdir(), 'tce-editor-write-guard-')); + try { + const offender = join(dir, 'fake-editor.ts'); + writeFileSync( + offender, + [ + "import { writeFileSync } from 'node:fs';", + "import { join } from 'node:path';", + '', + 'export function saveCsv(csvPath: string, csv: string): void {', + " const direct = join(csvPath, 'storylines.csv');", + ' writeFileSync(direct, csv, "utf-8");', + '}', + '', + ].join('\n'), + 'utf-8', + ); + // A clean sibling module in the same root must not be reported. + writeFileSync( + join(dir, 'clean-editor.ts'), + 'export function preview(csv: string): string { return csv.trim(); }\n', + 'utf-8', + ); + + const scan = scanWritePaths({ baseDir: dir, roots: ['.'] }); + + expect(scan.scanned).toHaveLength(2); + expect(scan.violations).toHaveLength(1); + expect(scan.violations[0]).toMatchObject({ + file: offender, + call: 'writeFileSync', + line: 6, + }); + expect(() => assertSingleWritePath(scan)).toThrow(/Single-write-path violation/); + expect(() => assertSingleWritePath(scan)).toThrow(/writeFileSync/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/story-line/editor/write-path-guard.ts b/tests/story-line/editor/write-path-guard.ts new file mode 100644 index 000000000..bdba72f9e --- /dev/null +++ b/tests/story-line/editor/write-path-guard.ts @@ -0,0 +1,197 @@ +/** + * Reusable write-path guard for the story-line editor. + * + * Contract: a content CSV is edited through exactly one module — the core + * author API (`applyContentEditToFile` in `src/story-line/content/author.ts`). + * No editor module may open a file-write handle of its own, otherwise that + * single write path silently dies. This helper statically scans the editor's + * source roots and reports any fs write/delete call; the consumer then asserts + * there are none (`assertSingleWritePath`). + * + * Two sanctioned writers are exempted from a scan of the **content** roots + * (they live outside the editor roots anyway): `author.ts`, which owns the + * content CSV, and `artefacts.ts`, which owns the **generated** Mermaid/JSON + * artefacts — regenerating a generated file is not a CSV edit path. + * + * The scan is **tolerant of the editor modules not existing yet**: an absent + * root is reported as `skipped` (with the future path), so the guard is green + * before the feature code lands and meaningful the moment it does. + * + * @module + */ + +import { + existsSync, + readFileSync, + readdirSync, + statSync, +} from 'node:fs'; +import { isAbsolute, join, resolve } from 'node:path'; + +/** + * The editor's future source roots, repository-relative. Kept central so the + * guard follows the editor wherever later slices put it. + */ +export const DEFAULT_EDITOR_ROOTS = [ + 'src/story-line/editor', + 'tools/storyline-editor', + 'scripts/storyline-editor-cli.ts', + 'scripts/storyline-editor-server.ts', +] as const; + +/** One forbidden file-write/delete call found in a scanned module. */ +export interface WritePathViolation { + /** Absolute path of the offending file. */ + readonly file: string; + /** The matched call (e.g. `writeFileSync`). */ + readonly call: string; + /** 1-based line number of the call. */ + readonly line: number; + /** The matching source line, trimmed, for actionable messages. */ + readonly preview: string; +} + +/** The result of scanning a set of roots. */ +export interface WritePathScan { + /** Files actually scanned (existing roots only). */ + readonly scanned: readonly string[]; + /** Roots that do not exist yet (editor modules land in later slices). */ + readonly skipped: readonly string[]; + /** Calling-styles matched against {@link forbiddenCalls}. */ + readonly violations: readonly WritePathViolation[]; +} + +/** FS write/delete call shapes that bypass the transactional author API. */ +const FORBIDDEN_WRITE_CALLS: readonly { readonly name: string; readonly pattern: RegExp }[] = [ + { name: 'writeFileSync', pattern: /\bwriteFileSync\s*\(/ }, + { name: 'writeFile', pattern: /\bwriteFile\s*\(/ }, + { name: 'appendFileSync', pattern: /\bappendFileSync\s*\(/ }, + { name: 'appendFile', pattern: /\bappendFile\s*\(/ }, + { name: 'createWriteStream', pattern: /\bcreateWriteStream\s*\(/ }, + { name: 'renameSync', pattern: /\brenameSync\s*\(/ }, + { name: 'rename', pattern: /\brename\s*\(/ }, + { name: 'unlinkSync', pattern: /\bunlinkSync\s*\(/ }, + { name: 'unlink', pattern: /\bunlink\s*\(/ }, + { name: 'rmSync', pattern: /\brmSync\s*\(/ }, + { name: 'rm', pattern: /\brm\s*\(/ }, +]; + +/** Options for {@link scanWritePaths}. */ +export interface WritePathScanOptions { + /** Roots to scan (repository-relative unless absolute). Defaults to the editor roots. */ + readonly roots?: readonly string[]; + /** Extra roots appended to the defaults (used for synthetic negative cases). */ + readonly extraRoots?: readonly string[]; + /** Repository root used to resolve relative roots (defaults to `process.cwd()`). */ + readonly baseDir?: string; + /** + * Files to skip, matched by absolute path. The core author module is the + * single sanctioned writer, so callers scanning it pass the dedicated + * {@link CONTENT_AUTHOR_MODULE} here. + */ + readonly exclude?: readonly string[]; +} + +/** Absolute path of the core author module — the single sanctioned writer. */ +export const CONTENT_AUTHOR_MODULE = 'src/story-line/content/author.ts'; + +/** Absolute path of the generated-artefact writer (mermaid/json, never a CSV). */ +export const CONTENT_ARTEFACT_MODULE = 'src/story-line/content/artefacts.ts'; + +/** Every module allowed to write files (content CSV + generated artefacts). */ +export const SANCTIONED_WRITE_MODULES = [ + CONTENT_AUTHOR_MODULE, + CONTENT_ARTEFACT_MODULE, +] as const; + +/** Recursively lists every `.ts` file under a directory. */ +function listTsFiles(dir: string): string[] { + const files: string[] = []; + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + if (statSync(full).isDirectory()) { + files.push(...listTsFiles(full)); + } else if (entry.endsWith('.ts') || entry.endsWith('.tsx')) { + files.push(full); + } + } + return files; +} + +/** + * Scans the given roots for forbidden write/delete calls. + * + * @param options Scan options (roots default to the editor roots). + * @returns The scan result; absent roots are reported under `skipped`, never + * treated as a violation. + */ +export function scanWritePaths( + options: WritePathScanOptions = {}, +): WritePathScan { + const baseDir = options.baseDir ?? process.cwd(); + const roots = [...(options.roots ?? DEFAULT_EDITOR_ROOTS), ...(options.extraRoots ?? [])]; + const exclude = new Set( + (options.exclude ?? []).map((file) => (isAbsolute(file) ? file : resolve(baseDir, file))), + ); + + const scanned: string[] = []; + const skipped: string[] = []; + const violations: WritePathViolation[] = []; + + for (const root of roots) { + const abs = isAbsolute(root) ? root : resolve(baseDir, root); + if (!existsSync(abs)) { + skipped.push(root); + continue; + } + if (statSync(abs).isFile()) { + if (abs.endsWith('.ts') || abs.endsWith('.tsx')) scanned.push(abs); + else skipped.push(root); + continue; + } + const files = listTsFiles(abs); + if (files.length === 0) { + skipped.push(root); + continue; + } + scanned.push(...files); + } + + for (const file of scanned) { + if (exclude.has(file)) continue; + const source = readFileSync(file, 'utf-8'); + const lines = source.split('\n'); + lines.forEach((line, index) => { + const forbidden = FORBIDDEN_WRITE_CALLS.find(({ pattern }) => pattern.test(line)); + if (forbidden) { + violations.push({ + file, + call: forbidden.name, + line: index + 1, + preview: line.trim(), + }); + } + }); + } + + return { scanned, skipped, violations }; +} + +/** + * Asserts a scan found no forbidden write calls, throwing a detailed error + * otherwise. + * + * @param scan The scan result. + * @throws Error listing every violation with file/line/context. + */ +export function assertSingleWritePath(scan: WritePathScan): void { + if (scan.violations.length === 0) return; + const detail = scan.violations + .map((v) => ` ${v.file}:${v.line} — ${v.call} (${v.preview})`) + .join('\n'); + throw new Error( + `Single-write-path violation: editor modules must not write files directly; ` + + `every edit goes through the transactional author API ` + + `(${CONTENT_AUTHOR_MODULE}).\n${detail}`, + ); +} diff --git a/tests/story-line/helpers.ts b/tests/story-line/helpers.ts new file mode 100644 index 000000000..afd46a3d4 --- /dev/null +++ b/tests/story-line/helpers.ts @@ -0,0 +1,119 @@ +/** + * 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<TestCard> & { 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> = {}): 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<string, TestCard>; + +/** 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<TestState, TestCard> { + 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; + }, + applyResourceDelta(state, delta) { + state.coins += delta.coins ?? 0; + state.reputation += delta.reputation ?? 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<string, TestCard>(); + 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/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<TestState>[] = [ + { + 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<TestState>[] = [ + { + 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(); + } + }); +}); diff --git a/tests/story-line/resolve.test.ts b/tests/story-line/resolve.test.ts new file mode 100644 index 000000000..8e225c389 --- /dev/null +++ b/tests/story-line/resolve.test.ts @@ -0,0 +1,672 @@ +/** + * 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, evaluateCondition } 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); + }); +}); + +// ── 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(52); + }); + + 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); + }); +}); + +// ── 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); + }); +}); 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<Parameters<typeof makeCard>[0], 'id'> & { id?: string }) { + return makeCard({ id: 'evt-x', name: 'X', ...overrides }); +} + +/** A system with an optional UI override. */ +function makeSystem(ui?: Parameters<typeof createStorylineSystem>[0]['ui']) { + const engine = makeAdapter(new Map<string, TestCard>()); + 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/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 }; +} diff --git a/tests/ui/CardGameScene.test.ts b/tests/ui/CardGameScene.test.ts index b8d54fc94..f577da3b4 100644 --- a/tests/ui/CardGameScene.test.ts +++ b/tests/ui/CardGameScene.test.ts @@ -57,6 +57,9 @@ const { removeAllListeners: mockRemoveAllListeners, on: vi.fn(), off: vi.fn(), + // The engine's catch-all subscription; CardGameScene's debug event + // buffer subscribes with it in DEV. + onAny: vi.fn(() => vi.fn()), }; }); diff --git a/tests/ui/dragDrop.test.ts b/tests/ui/dragDrop.test.ts index 908479117..b249edecf 100644 --- a/tests/ui/dragDrop.test.ts +++ b/tests/ui/dragDrop.test.ts @@ -13,8 +13,14 @@ */ import { describe, expect, it, vi, beforeEach } from 'vitest'; -import Phaser from 'phaser'; - +import type Phaser from 'phaser'; + +// No `vi.mock('phaser')` stub here on purpose (CG-0MVQQ2M8E003FQMZ): the +// drag-drop graph (`dragDrop` → `shakeIllegalMove` → `illegalMoveToastView`) +// is free of Phaser **value** imports, so it loads in plain Node. A +// reintroduced value import would throw `ReferenceError: window is not +// defined` at load and fail this file loudly instead of being masked. +// `tests/ui/phaser-free-imports.test.ts` asserts the same invariant explicitly. import { createDragDropManager, type DragDropManager, @@ -122,7 +128,10 @@ function makeManager( return { manager, mock }; } -function runTweenComplete(mock: MockScene, index = 0): void { +function runTweenComplete( + mock: { tweenConfigs: Phaser.Types.Tweens.TweenBuilderConfig[] }, + index = 0, +): void { const config = mock.tweenConfigs[index]; expect(config).toBeDefined(); (config.onComplete as (tween: Phaser.Tweens.Tween) => void)?.({} as Phaser.Tweens.Tween); @@ -757,3 +766,201 @@ describe('dragDrop error paths', () => { expect(mock.scene.sound.play).toHaveBeenCalledWith('sfx-illegal-move'); }); }); + +// ── Reason toast plumbing (CG-0MVPXFHBZ004LGEB) ───────────── + +describe('dragDrop illegal-reason toast plumbing', () => { + /** Toast-capable scene: add.text, time.delayedCall and a scene emitter. */ + interface ToastMock { + scene: Phaser.Scene; + events: Record<string, (...args: any[]) => void>; + tweenConfigs: Phaser.Types.Tweens.TweenBuilderConfig[]; + texts: string[]; + timers: Array<{ delay: number; callback: () => void }>; + addTextMock: ReturnType<typeof vi.fn>; + } + + function makeToastMock(): ToastMock { + const base = createMockScene(); + const texts: string[] = []; + const timers: Array<{ delay: number; callback: () => void }> = []; + const addTextMock = vi.fn((_x: number, _y: number, text: string) => { + texts.push(text); + const obj: any = { + text, + setText: vi.fn((value: string) => { obj.text = value; return obj; }), + setOrigin: vi.fn().mockReturnThis(), + setDepth: vi.fn().mockReturnThis(), + setWordWrapWidth: vi.fn().mockReturnThis(), + setStroke: vi.fn().mockReturnThis(), + destroy: vi.fn(), + }; + return obj; + }); + (base.scene as any).add = { text: addTextMock }; + (base.scene as any).time = { + delayedCall: vi.fn((delay: number, callback: () => void) => { + timers.push({ delay, callback }); + return { remove: vi.fn() }; + }), + }; + (base.scene as any).events = { once: vi.fn(), off: vi.fn() }; + return { + scene: base.scene, + events: base.events, + tweenConfigs: base.tweenConfigs, + texts, + timers, + addTextMock, + }; + } + + function makeToastManager(mock: ToastMock, config: Partial<DragDropManagerConfig> = {}) { + return makeManager(config, mock as unknown as MockScene); + } + + function makeDraggable() { + const go = createMockGameObject(); + go.input = { draggable: true }; + return go; + } + + it('pickup veto: draggable illegalReason resolver renders the toast', () => { + const toastMock = makeToastMock(); + const { manager, mock } = makeToastManager(toastMock); + const go = makeDraggable(); + + manager.registerDraggable({ + gameObject: go as unknown as DraggableGameObject, + canPickUp: () => false, + illegalReason: () => 'You cannot pick that card up this turn.', + }); + + mock.events.dragstart(pointer, go); + + expect(toastMock.texts).toEqual(['You cannot pick that card up this turn.']); + }); + + it('pickup veto: inline payload.reason mutation inside canPickUp renders the toast', () => { + const toastMock = makeToastMock(); + const { manager, mock } = makeToastManager(toastMock); + const go = makeDraggable(); + + manager.registerDraggable({ + gameObject: go as unknown as DraggableGameObject, + canPickUp: (payload) => { + payload.reason = 'Inline reason wins.'; + return false; + }, + }); + + mock.events.dragstart(pointer, go); + + expect(toastMock.texts).toEqual(['Inline reason wins.']); + }); + + it('drop rejection: zone illegalReason wins over the draggable resolver', () => { + const toastMock = makeToastMock(); + const { manager, mock } = makeToastManager(toastMock); + const go = makeDraggable(); + const zone = { + name: 'slot', + x: 0, y: 0, width: 10, height: 10, + setData: vi.fn(), + getData: vi.fn(() => undefined), + } as unknown as Phaser.GameObjects.Zone; + + manager.registerDraggable({ + gameObject: go as unknown as DraggableGameObject, + illegalReason: () => 'Draggable fallback reason.', + }); + manager.registerDropZone({ + zone, + data: 3, + canAccept: () => false, + illegalReason: () => 'That slot is blocked by the figure.', + }); + + mock.events.dragstart(pointer, go); + mock.events.drop(pointer, go, zone); + // Rejected drops snap back first; illegal feedback fires on completion. + runTweenComplete(mock); + + expect(toastMock.texts).toEqual(['That slot is blocked by the figure.']); + }); + + it('drop rejection: draggable illegalReason is used when the zone has none', () => { + const toastMock = makeToastMock(); + const { manager, mock } = makeToastManager(toastMock); + const go = makeDraggable(); + const zone = { + name: 'slot', + x: 0, y: 0, width: 10, height: 10, + setData: vi.fn(), + getData: vi.fn(() => undefined), + } as unknown as Phaser.GameObjects.Zone; + + manager.registerDraggable({ + gameObject: go as unknown as DraggableGameObject, + illegalReason: () => 'Draggable fallback reason.', + }); + manager.registerDropZone({ zone, canAccept: () => false }); + + mock.events.dragstart(pointer, go); + mock.events.drop(pointer, go, zone); + runTweenComplete(mock); + + expect(toastMock.texts).toEqual(['Draggable fallback reason.']); + }); + + it('release outside any zone: draggable illegalReason renders the toast on snap-back', () => { + const toastMock = makeToastMock(); + const { manager, mock } = makeToastManager(toastMock); + const go = makeDraggable(); + + manager.registerDraggable({ + gameObject: go as unknown as DraggableGameObject, + illegalReason: () => 'There is no legal slot for that card.', + }); + + mock.events.dragstart(pointer, go); + mock.events.dragend(pointer, go, false); + // Snap-back completes, then the illegal feedback (and toast) fires. + runTweenComplete(mock); + + expect(toastMock.texts).toEqual(['There is no legal slot for that card.']); + }); + + it('no reason available: no toast is rendered (unchanged behaviour)', () => { + const toastMock = makeToastMock(); + const { manager, mock } = makeToastManager(toastMock); + const go = makeDraggable(); + + manager.registerDraggable({ + gameObject: go as unknown as DraggableGameObject, + canPickUp: () => false, + }); + + mock.events.dragstart(pointer, go); + + expect(toastMock.addTextMock).not.toHaveBeenCalled(); + }); + + it('custom onIllegal hook receives the resolved payload.reason', () => { + const onIllegal = vi.fn(); + const toastMock = makeToastMock(); + const { manager, mock } = makeToastManager(toastMock, { onIllegal }); + const go = makeDraggable(); + + manager.registerDraggable({ + gameObject: go as unknown as DraggableGameObject, + canPickUp: () => false, + illegalReason: () => 'Flagged by the gate.', + }); + + mock.events.dragstart(pointer, go); + + expect(onIllegal).toHaveBeenCalledTimes(1); + expect(onIllegal.mock.calls[0][0].reason).toBe('Flagged by the gate.'); + }); +}); diff --git a/tests/ui/illegalMoveToast.test.ts b/tests/ui/illegalMoveToast.test.ts new file mode 100644 index 000000000..379f1ce28 --- /dev/null +++ b/tests/ui/illegalMoveToast.test.ts @@ -0,0 +1,304 @@ +/** + * Unit tests for the illegal-move reason toast view + * (`src/ui/illegalMoveToastView.ts`). + * + * The Phaser text/time/event surface is mocked: each test drives the toast + * through its public API and asserts the rendered text, depth/wrap/stroke, + * dwell timer, replace-on-show, early dismissal and scene-shutdown cleanup. + * + * The view is deliberately free of Phaser **value** imports (CG-0MVQQ2M8E003FQMZ) + * so it — and `@ui/shakeIllegalMove` / `@ui/dragDrop`, which import it + * statically — resolve in a plain Node environment. No `vi.mock('phaser')` + * stub is therefore needed here: a reintroduced Phaser value import would + * crash at module load and fail every test (loud red, not silent masking). + * `tests/ui/phaser-free-imports.test.ts` guards the same graph explicitly. + */ + +import { describe, expect, it, vi } from 'vitest'; + +import { + dismissIllegalMoveToast, + showIllegalMoveToast, +} from '../../src/ui/illegalMoveToastView'; +import { + FONT_FAMILY, + GAME_W, + ILLEGAL_MOVE_TOAST_COLOR, + ILLEGAL_MOVE_TOAST_DEPTH, + ILLEGAL_MOVE_TOAST_DWELL_MS, + ILLEGAL_MOVE_TOAST_FONT_SIZE, + ILLEGAL_MOVE_TOAST_MAX_WIDTH, + ILLEGAL_MOVE_TOAST_STROKE_COLOR, + ILLEGAL_MOVE_TOAST_STROKE_THICKNESS, + ILLEGAL_MOVE_TOAST_Y, +} from '../../src/ui/constants'; + +// ── Mock helpers ──────────────────────────────────────────── + +interface MockTimer { + delay: number; + callback: () => void; + remove: ReturnType<typeof vi.fn>; + fired: boolean; +} + +interface MockToastText { + text: string; + depth: number | null; + wrapWidth: number | null; + stroke: [string, number] | null; + setText: ReturnType<typeof vi.fn>; + setOrigin: ReturnType<typeof vi.fn>; + setDepth: ReturnType<typeof vi.fn>; + setWordWrapWidth: ReturnType<typeof vi.fn>; + setStroke: ReturnType<typeof vi.fn>; + setInteractive: ReturnType<typeof vi.fn>; + destroy: ReturnType<typeof vi.fn>; + destroyed: boolean; +} + +interface MockToastScene { + scene: any; + texts: MockToastText[]; + timers: MockTimer[]; + shutdownHandlers: Record<string, Array<() => void>>; + addTextCalls: any[][]; +} + +function createMockText(): MockToastText { + const text: MockToastText = { + text: '', + depth: null, + wrapWidth: null, + stroke: null, + destroyed: false, + setText: vi.fn((value: string) => { text.text = value; return text; }), + setOrigin: vi.fn().mockReturnThis(), + setDepth: vi.fn((value: number) => { text.depth = value; return text; }), + setWordWrapWidth: vi.fn((value: number) => { text.wrapWidth = value; return text; }), + setStroke: vi.fn((color: string, thickness: number) => { + text.stroke = [color, thickness]; + return text; + }), + setInteractive: vi.fn().mockReturnThis(), + destroy: vi.fn(() => { text.destroyed = true; }), + }; + return text; +} + +function createMockScene(options: { withTime?: boolean } = {}): MockToastScene { + const texts: MockToastText[] = []; + const timers: MockTimer[] = []; + const shutdownHandlers: Record<string, Array<() => void>> = {}; + const addTextCalls: any[][] = []; + const withTime = options.withTime ?? true; + + const scene: any = { + add: { + text: vi.fn((...args: any[]) => { + addTextCalls.push(args); + const text = createMockText(); + text.text = String(args[2]); + texts.push(text); + return text; + }), + }, + events: { + once: vi.fn((event: string, handler: () => void) => { + (shutdownHandlers[event] ??= []).push(handler); + }), + }, + }; + if (withTime) { + scene.time = { + delayedCall: vi.fn((delay: number, callback: () => void) => { + const timer: MockTimer = { delay, callback, remove: vi.fn(), fired: false }; + timers.push(timer); + return timer; + }), + }; + } + + return { scene, texts, timers, shutdownHandlers, addTextCalls }; +} + +/** Fire everything registered for a scene event (e.g. 'shutdown'). */ +function emitSceneEvent(mock: MockToastScene, event: string): void { + for (const handler of mock.shutdownHandlers[event] ?? []) handler(); +} + +// ── Tests ─────────────────────────────────────────────────── + +describe('showIllegalMoveToast', () => { + it('renders the reason verbatim with the shared toast styling constants', () => { + const mock = createMockScene(); + const reason = 'That slot can only take a business card you can afford.'; + + const handle = showIllegalMoveToast({ scene: mock.scene, text: reason }); + + expect(handle).toBeDefined(); + expect(mock.texts).toHaveLength(1); + const text = mock.texts[0]; + expect(text.text).toBe(reason); // exact rules-layer copy (AC1/AC8) + expect(mock.addTextCalls[0][0]).toBe(GAME_W / 2); + expect(mock.addTextCalls[0][1]).toBe(ILLEGAL_MOVE_TOAST_Y); + // Style equivalence with the previous `createHudText` call: every key + // `createHudText` would have produced is asserted, so the inline builder + // cannot silently drift from `@ui/Renderer`'s defaults (risk mitigation + // for CG-0MVQQ2M8E003FQMZ). + expect(mock.addTextCalls[0][3]).toEqual({ + fontFamily: FONT_FAMILY, + fontStyle: 'bold', + color: ILLEGAL_MOVE_TOAST_COLOR, + fontSize: ILLEGAL_MOVE_TOAST_FONT_SIZE, + align: 'center', + }); + expect(text.depth).toBe(ILLEGAL_MOVE_TOAST_DEPTH); + expect(text.wrapWidth).toBe(ILLEGAL_MOVE_TOAST_MAX_WIDTH); + expect(text.stroke).toEqual([ + ILLEGAL_MOVE_TOAST_STROKE_COLOR, + ILLEGAL_MOVE_TOAST_STROKE_THICKNESS, + ]); + expect(handle!.text).toBe(reason); + }); + + it('centres the toast text at origin (0.5, 0.5)', () => { + const mock = createMockScene(); + showIllegalMoveToast({ scene: mock.scene, text: 'Rejected.' }); + + expect(mock.texts[0].setOrigin).toHaveBeenCalledWith(0.5, 0.5); + }); + + it('sits above the board/HUD but below game-state overlays', () => { + // HUD container is 1000, OverlayManager game-state overlays are 2000. + expect(ILLEGAL_MOVE_TOAST_DEPTH).toBeGreaterThan(1000); + expect(ILLEGAL_MOVE_TOAST_DEPTH).toBeLessThan(2000); + }); + + it('never makes the toast interactive (non-blocking input, AC2)', () => { + const mock = createMockScene(); + showIllegalMoveToast({ scene: mock.scene, text: 'Rejected.' }); + + expect(mock.texts[0].setInteractive).not.toHaveBeenCalled(); + }); + + it('schedules auto-dismissal after the default dwell', () => { + const mock = createMockScene(); + showIllegalMoveToast({ scene: mock.scene, text: 'Rejected.' }); + + expect(mock.timers).toHaveLength(1); + expect(mock.timers[0].delay).toBe(ILLEGAL_MOVE_TOAST_DWELL_MS); + + mock.timers[0].callback(); + expect(mock.texts[0].destroy).toHaveBeenCalledOnce(); + expect(mock.texts[0].destroyed).toBe(true); + }); + + it('honours a custom dwell', () => { + const mock = createMockScene(); + showIllegalMoveToast({ scene: mock.scene, text: 'Rejected.', dwellMs: 900 }); + + expect(mock.timers[0].delay).toBe(900); + }); + + it('replaces the current toast instead of stacking (AC4)', () => { + const mock = createMockScene(); + const firstHandle = showIllegalMoveToast({ scene: mock.scene, text: 'First reason.' }); + + const secondHandle = showIllegalMoveToast({ scene: mock.scene, text: 'Second reason.' }); + + // One live toast object only; its text is updated in place. + expect(mock.texts).toHaveLength(1); + expect(mock.texts[0].text).toBe('Second reason.'); + expect(mock.texts[0].destroy).not.toHaveBeenCalled(); + // The old timer is cancelled and a fresh dwell starts. + expect(mock.timers).toHaveLength(2); + expect(mock.timers[0].remove).toHaveBeenCalledWith(false); + expect(mock.timers[1].remove).not.toHaveBeenCalled(); + expect(secondHandle!.text).toBe('Second reason.'); + expect(firstHandle!.text).toBe('Second reason.'); + }); + + it('can be dismissed early through the handle (AC2)', () => { + const mock = createMockScene(); + const handle = showIllegalMoveToast({ scene: mock.scene, text: 'Rejected.' }); + + handle!.dismiss(); + + expect(mock.texts[0].destroy).toHaveBeenCalledOnce(); + expect(mock.timers[0].remove).toHaveBeenCalledWith(false); + }); + + it('can be dismissed early through dismissIllegalMoveToast (AC2)', () => { + const mock = createMockScene(); + showIllegalMoveToast({ scene: mock.scene, text: 'Rejected.' }); + + dismissIllegalMoveToast(mock.scene); + + expect(mock.texts[0].destroyed).toBe(true); + }); + + it('dismissing a stale handle does not destroy a newer toast', () => { + const mock = createMockScene(); + const staleHandle = showIllegalMoveToast({ scene: mock.scene, text: 'First.' })!; + dismissIllegalMoveToast(mock.scene); + showIllegalMoveToast({ scene: mock.scene, text: 'Second.' }); + + staleHandle.dismiss(); + + expect(mock.texts).toHaveLength(2); + expect(mock.texts[1].destroyed).toBe(false); + expect(mock.texts[1].text).toBe('Second.'); + }); + + it('renders nothing for empty / whitespace-only text and clears a stale toast', () => { + const mock = createMockScene(); + showIllegalMoveToast({ scene: mock.scene, text: 'Rejected.' }); + + const handle = showIllegalMoveToast({ scene: mock.scene, text: ' ' }); + + expect(handle).toBeUndefined(); + expect(mock.texts).toHaveLength(1); + expect(mock.texts[0].destroyed).toBe(true); + }); + + it('destroys the toast on scene shutdown so it cannot leak across scenes (AC4)', () => { + const mock = createMockScene(); + showIllegalMoveToast({ scene: mock.scene, text: 'Rejected.' }); + + emitSceneEvent(mock, 'shutdown'); + + expect(mock.texts[0].destroyed).toBe(true); + expect(mock.timers[0].remove).toHaveBeenCalledWith(false); + }); + + it('re-binds shutdown cleanup after a scene restart', () => { + const mock = createMockScene(); + showIllegalMoveToast({ scene: mock.scene, text: 'Before restart.' }); + emitSceneEvent(mock, 'shutdown'); + + showIllegalMoveToast({ scene: mock.scene, text: 'After restart.' }); + const handlers = mock.shutdownHandlers['shutdown']; + emitSceneEvent(mock, 'shutdown'); + + expect(mock.texts[1].destroyed).toBe(true); + // Two bindings in total: one per scene lifetime, not one per show. + expect(handlers.length).toBeGreaterThanOrEqual(2); + }); + + it('degrades without throwing when the scene has no time plugin', () => { + const mock = createMockScene({ withTime: false }); + + expect(() => + showIllegalMoveToast({ scene: mock.scene, text: 'Rejected.' }), + ).not.toThrow(); + expect(mock.texts).toHaveLength(1); + expect(mock.texts[0].text).toBe('Rejected.'); + }); + + it('degrades to undefined (no throw) when the scene cannot render text', () => { + const scene = { add: {} } as any; + + expect(showIllegalMoveToast({ scene, text: 'Rejected.' })).toBeUndefined(); + }); +}); diff --git a/tests/ui/illegalMoveToastModel.test.ts b/tests/ui/illegalMoveToastModel.test.ts new file mode 100644 index 000000000..1dbd494da --- /dev/null +++ b/tests/ui/illegalMoveToastModel.test.ts @@ -0,0 +1,121 @@ +/** + * Unit tests for IllegalMoveToastModel -- the framework-free text/timing model + * behind the illegal-move reason toast. + * + * Covers the ACs that are pure enough to test without a Phaser scene: default + * and custom dwell, show/replace, early dismissal, expiry via tick, empty + * text handling and the verbatim-text contract. + */ + +import { describe, expect, it } from 'vitest'; +import { + IllegalMoveToastModel, + type IllegalMoveToastModelOptions, +} from '../../src/ui/illegalMoveToast'; +import { ILLEGAL_MOVE_TOAST_DWELL_MS } from '../../src/ui/constants'; + +function makeModel(options?: IllegalMoveToastModelOptions): IllegalMoveToastModel { + return new IllegalMoveToastModel(options); +} + +describe('IllegalMoveToastModel', () => { + it('defaults the dwell to the shared constant (2500 ms)', () => { + expect(makeModel().dwellMs).toBe(ILLEGAL_MOVE_TOAST_DWELL_MS); + expect(ILLEGAL_MOVE_TOAST_DWELL_MS).toBe(2500); + }); + + it('accepts a custom dwell', () => { + expect(makeModel({ dwellMs: 1200 }).dwellMs).toBe(1200); + }); + + it('falls back to the default for non-finite or non-positive dwells', () => { + expect(makeModel({ dwellMs: 0 }).dwellMs).toBe(ILLEGAL_MOVE_TOAST_DWELL_MS); + expect(makeModel({ dwellMs: -5 }).dwellMs).toBe(ILLEGAL_MOVE_TOAST_DWELL_MS); + expect(makeModel({ dwellMs: Number.NaN }).dwellMs).toBe(ILLEGAL_MOVE_TOAST_DWELL_MS); + expect(makeModel({ dwellMs: Number.POSITIVE_INFINITY }).dwellMs).toBe(ILLEGAL_MOVE_TOAST_DWELL_MS); + expect(makeModel({ dwellMs: undefined as unknown as number }).dwellMs).toBe(ILLEGAL_MOVE_TOAST_DWELL_MS); + }); + + it('starts hidden with no text and zero remaining', () => { + const model = makeModel(); + expect(model.visible).toBe(false); + expect(model.text).toBeNull(); + expect(model.remaining).toBe(0); + }); + + it('show makes the toast visible with the exact text (verbatim, AC8)', () => { + const model = makeModel(); + const reason = 'The market slot is blocked by the guard figure.'; + expect(model.show(reason)).toBe(true); + expect(model.visible).toBe(true); + expect(model.text).toBe(reason); + expect(model.remaining).toBe(ILLEGAL_MOVE_TOAST_DWELL_MS); + }); + + it('tick consumes the dwell and reports expiry exactly once', () => { + const model = makeModel({ dwellMs: 1000 }); + model.show('reason'); + expect(model.tick(600)).toBe(false); + expect(model.visible).toBe(true); + expect(model.remaining).toBe(400); + expect(model.tick(600)).toBe(true); + expect(model.visible).toBe(false); + expect(model.text).toBeNull(); + expect(model.remaining).toBe(0); + // tick after expiry is a no-op + expect(model.tick(600)).toBe(false); + }); + + it('tick ignores non-positive or non-finite deltas', () => { + const model = makeModel({ dwellMs: 1000 }); + model.show('reason'); + model.tick(-100); + expect(model.remaining).toBe(1000); + model.tick(Number.NaN); + expect(model.remaining).toBe(1000); + model.tick(0); + expect(model.remaining).toBe(1000); + }); + + it('replace-on-show restarts the dwell and swaps the text (AC4)', () => { + const model = makeModel({ dwellMs: 2500 }); + model.show('first rejection'); + model.tick(2000); + expect(model.visible).toBe(true); + + // A second rejection inside the dwell window replaces rather than stacks. + const replaced = model.show('second rejection'); + expect(replaced).toBe(true); + expect(model.text).toBe('second rejection'); + expect(model.remaining).toBe(2500); // timer restarted, not stacked + model.tick(2400); + expect(model.visible).toBe(true); + model.tick(200); + expect(model.visible).toBe(false); + }); + + it('dismiss hides the toast early (AC2)', () => { + const model = makeModel({ dwellMs: 2500 }); + model.show('reason'); + expect(model.visible).toBe(true); + model.dismiss(); + expect(model.visible).toBe(false); + expect(model.text).toBeNull(); + expect(model.remaining).toBe(0); + }); + + it('show with empty or whitespace text dismisses and returns false', () => { + const model = makeModel(); + model.show('a visible reason'); + expect(model.show('')).toBe(false); + expect(model.visible).toBe(false); + expect(model.show(' ')).toBe(false); + expect(model.visible).toBe(false); + }); + + it('tick when hidden is a no-op', () => { + const model = makeModel({ dwellMs: 1000 }); + expect(model.tick(5000)).toBe(false); + expect(model.visible).toBe(false); + }); +}); diff --git a/tests/ui/phaser-free-imports.test.ts b/tests/ui/phaser-free-imports.test.ts new file mode 100644 index 000000000..a9274bf0b --- /dev/null +++ b/tests/ui/phaser-free-imports.test.ts @@ -0,0 +1,96 @@ +/** + * Node-environment import-guard tests for the framework-free `@ui` helper + * graph (CG-0MVQQ2M8E003FQMZ). + * + * Downstream game unit suites import `@ui/shakeIllegalMove`, `@ui/dragDrop` + * and `@ui/illegalMoveToastView` in a plain Node environment (no `window`). + * Each of those entry points must resolve **without evaluating Phaser** at + * import time — a Phaser value import crashes at `phaser/dist/phaser.esm.js` + * with `ReferenceError: window is not defined`, which is exactly the + * regression this suite guards against. + * + * The guard mechanism: a file-scoped `vi.mock('phaser', …)` whose factory + * **throws** when Phaser is actually imported. If (and only if) some module in + * a guard target's import graph evaluates Phaser, the import below throws and + * the test fails — so a reintroduced Phaser value import can never silently + * pass. The mock is deliberately file-scoped: `vi.mock` applies per test file + * only, so it cannot mask or disturb other unit tests, and it must never be + * moved into a global setup file. + * + * Negative control: importing a known Phaser-bearing module + * (`@ui/Renderer` — `new Phaser.Geom.Rectangle(...)` at module scope) under + * the same mock **fails**, proving the guard has teeth (a guard that passes + * because the mock never fires would otherwise be worthless). + * + * The broader documented Phaser-free node surface + static import-graph check + * is tracked separately: CG-0MVS9HBN3009HIQN. + */ + +import { describe, expect, it, vi } from 'vitest'; + +/** Distinctive marker so the negative control can attribute the failure. */ +const PHASER_EVALUATED = 'phaser evaluated at import time under node guard'; + +/** + * How many times the throwing mock factory actually ran. + * + * `vi.hoisted` keeps this counter usable from inside the hoisted `vi.mock` + * factory. The guard targets must leave it at `0` (Phaser never imported); the + * negative control must push it up by one (the mock is genuinely wired, so the + * rejection is attributable to Phaser evaluation and not to an unrelated + * module-resolution error). + */ +const phaserMock = vi.hoisted(() => ({ calls: 0 })); + +vi.mock('phaser', () => { + phaserMock.calls += 1; + throw new Error(PHASER_EVALUATED); +}); + +// Static imports must succeed: these graphs never evaluate Phaser. +import * as toastView from '../../src/ui/illegalMoveToastView'; +import * as shakeIllegalMove from '../../src/ui/shakeIllegalMove'; +import * as dragDrop from '../../src/ui/dragDrop'; +import * as toastModel from '../../src/ui/illegalMoveToast'; + +describe('Phaser-free @ui helper graph (node import guard)', () => { + it('runs in a Node environment without `window`', () => { + expect(typeof window).toBe('undefined'); + }); + + it('imports @ui/illegalMoveToastView without evaluating Phaser', () => { + expect(typeof toastView.showIllegalMoveToast).toBe('function'); + expect(typeof toastView.dismissIllegalMoveToast).toBe('function'); + }); + + it('imports @ui/shakeIllegalMove without evaluating Phaser', () => { + expect(typeof shakeIllegalMove.shakeIllegalMove).toBe('function'); + }); + + it('imports @ui/dragDrop without evaluating Phaser', () => { + expect(typeof dragDrop.createDragDropManager).toBe('function'); + expect(dragDrop.DEFAULT_DRAG_DEPTH).toBe(1000); + }); + + it('imports @ui/illegalMoveToast (framework-free model) without evaluating Phaser', () => { + expect(typeof toastModel.IllegalMoveToastModel).toBe('function'); + }); + + it('never evaluates Phaser for any guard target', () => { + // The throwing factory above has not run at all: none of the static imports + // above (nor the model module) touched Phaser, not even for its types. + expect(phaserMock.calls).toBe(0); + }); + + it('negative control: a Phaser-bearing @ui module fails under the same mock', async () => { + // `@ui/Renderer` (`createHudText`) evaluates `new Phaser.Geom.Rectangle(...)` + // at module scope. If the guard's throwing mock has teeth, this import must + // reject *and* the factory must have run — proving the positive results + // above are not vacuous. + const callsBefore = phaserMock.calls; + + await expect(import('../../src/ui/Renderer')).rejects.toThrow(); + + expect(phaserMock.calls).toBeGreaterThan(callsBefore); + }); +}); diff --git a/tests/ui/shakeIllegalMove.test.ts b/tests/ui/shakeIllegalMove.test.ts index 111726e2d..ca6aa01ea 100644 --- a/tests/ui/shakeIllegalMove.test.ts +++ b/tests/ui/shakeIllegalMove.test.ts @@ -6,6 +6,14 @@ */ import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// No `vi.mock('phaser')` stub here on purpose (CG-0MVQQ2M8E003FQMZ): +// `shakeIllegalMove` (and the toast view it imports statically) is free of +// Phaser **value** imports, so it loads in plain Node. If a Phaser value import +// is ever reintroduced into this graph the import below throws +// `ReferenceError: window is not defined` and every test in this file fails +// loudly instead of being masked. `tests/ui/phaser-free-imports.test.ts` +// asserts the same invariant explicitly. import { shakeIllegalMove } from '../../src/ui/shakeIllegalMove'; // ── Mock helpers ──────────────────────────────────────────── @@ -282,4 +290,153 @@ describe('shakeIllegalMove', () => { expect(sprite.clearTint).toHaveBeenCalledOnce(); expect(sprite.setX).toHaveBeenCalledWith(450); }); + + // ── Reason toast (CG-0MVPXFHBZ004LGEB) ───────────────── + + /** Toast-capable mock scene: add.text + time.delayedCall + events.once. */ + function createToastMockScene(): { + scene: Phaser.Scene; + texts: Array<{ text: string; depth: number | null; setText: ReturnType<typeof vi.fn> }>; + timers: Array<{ delay: number; callback: () => void }>; + addTextMock: ReturnType<typeof vi.fn>; + } { + const texts: Array<{ text: string; depth: number | null; setText: ReturnType<typeof vi.fn> }> = []; + const timers: Array<{ delay: number; callback: () => void }> = []; + const base = createMockScene(); + const addTextMock = vi.fn((_x: number, _y: number, text: string) => { + const obj: any = { + text, + depth: null, + setText: vi.fn((value: string) => { obj.text = value; return obj; }), + setOrigin: vi.fn().mockReturnThis(), + setDepth: vi.fn((d: number) => { obj.depth = d; return obj; }), + setWordWrapWidth: vi.fn().mockReturnThis(), + setStroke: vi.fn().mockReturnThis(), + destroy: vi.fn(), + }; + texts.push(obj); + return obj; + }); + (base as any).add.text = addTextMock; + (base as any).time = { + delayedCall: vi.fn((delay: number, callback: () => void) => { + timers.push({ delay, callback }); + return { remove: vi.fn() }; + }), + }; + (base as any).events = { + once: vi.fn(), + }; + return { scene: base as Phaser.Scene, texts, timers, addTextMock: addTextMock as any }; + } + + it('renders the reason as a toast when a reason is provided (AC1)', () => { + const { scene: toastScene, texts } = createToastMockScene(); + const toastTarget = createMockTarget(120); + + shakeIllegalMove({ + scene: toastScene, + target: toastTarget, + reason: 'That figure cannot be played here.', + }); + + expect(texts).toHaveLength(1); + expect(texts[0].text).toBe('That figure cannot be played here.'); + expect(tweenConfigs).toHaveLength(1); // the shake still runs + }); + + it('does not create a toast when no reason is given (AC1 backwards compat)', () => { + const { scene: toastScene, addTextMock } = createToastMockScene(); + + shakeIllegalMove({ scene: toastScene, target: createMockTarget(120) }); + + expect(addTextMock).not.toHaveBeenCalled(); + }); + + it('shows the toast even when the target is null (AC3)', () => { + const { scene: toastScene, texts } = createToastMockScene(); + + const result = shakeIllegalMove({ + scene: toastScene, + target: null, + reason: 'No eligible slot on the street.', + }); + + expect(result).toBeUndefined(); + expect(texts).toHaveLength(1); + expect(texts[0].text).toBe('No eligible slot on the street.'); + expect(tweenConfigs).toHaveLength(0); + }); + + it('handles a non-tintable Container: position shake only, toast shown (AC3)', () => { + const { scene: toastScene, texts } = createToastMockScene(); + const container = { + x: 200, + depth: 3, + setX: vi.fn((x: number) => { container.x = x; }), + } as unknown as Phaser.GameObjects.Container; + + const tween = shakeIllegalMove({ + scene: toastScene, + target: container, + reason: 'The street is full.', + }); + + expect(tween).toBeDefined(); + expect(texts).toHaveLength(1); + expect(texts[0].text).toBe('The street is full.'); + // Container has no setTint: neither tint nor the Canvas overlay is used. + const config = tweenConfigs[0]; + expect(config.targets).toBe(container); + expect(config.x).toBe(195); // 200 - 5 + (config.onComplete as Function)(); + expect(container.setX).toHaveBeenCalledWith(200); + }); + + it('suppresses the shake (but not the toast) under reduced motion (AC3)', () => { + const { scene: toastScene, texts } = createToastMockScene(); + const onComplete = vi.fn(); + + const result = shakeIllegalMove({ + scene: toastScene, + target: createMockTarget(200), + reason: 'That split costs more than you hold.', + reducedMotion: true, + onComplete, + }); + + expect(result).toBeUndefined(); + expect(tweenConfigs).toHaveLength(0); + expect(texts).toHaveLength(1); + expect(texts[0].text).toBe('That split costs more than you hold.'); + // The completion hook still fires so callers that chain on it are safe. + expect(onComplete).toHaveBeenCalledOnce(); + // The project-wide reduced-motion contract also suppresses SFX: the + // toast is the feedback channel. + expect((toastScene as unknown as { sound: { play: ReturnType<typeof vi.fn> } }).sound.play).not.toHaveBeenCalled(); + }); + + it('passes a custom reasonDwellMs to the toast timer', () => { + const { scene: toastScene, timers } = createToastMockScene(); + + shakeIllegalMove({ + scene: toastScene, + target: null, + reason: 'Blocked by the gate figure.', + reasonDwellMs: 1250, + }); + + expect(timers).toHaveLength(1); + expect(timers[0].delay).toBe(1250); + }); + + it('repeat rejects replace, never stack (AC4)', () => { + const { scene: toastScene, texts } = createToastMockScene(); + + shakeIllegalMove({ scene: toastScene, target: null, reason: 'First reason.' }); + shakeIllegalMove({ scene: toastScene, target: null, reason: 'Second reason.' }); + + expect(texts).toHaveLength(1); + expect(texts[0].text).toBe('Second reason.'); + }); }); 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/tools/storyline-editor/add-card-form.ts b/tools/storyline-editor/add-card-form.ts new file mode 100644 index 000000000..fcaca8182 --- /dev/null +++ b/tools/storyline-editor/add-card-form.ts @@ -0,0 +1,496 @@ +/** + * Guided "add card" form for the story-line editor. + * + * Adding a card is not just appending a row: a story-line card belongs to a + * group (`storylineId`/`storylineTitle`) and — for anything added to an + * existing multi-member arc — must be *reachable*, or the validator refuses + * the candidate (`unreachable-card`). So the form guides both decisions: + * + * - **Story-line** — pick an existing story-line (which fills the id/title + * fields) or author a new one; membership is what "links it into a + * story-line" means in the content model. + * - **Incoming link** — optionally choose the card that leads to the new one, + * which makes the form emit a single transactional `add-linked-card` edit: + * the card is appended and the predecessor gains an option pointing at it, + * validated as one candidate and written once. + * + * Fields themselves are adapter-driven, exactly like the edit form + * ({@link buildNewCardFieldDescriptors}): the card id is editable, values + * start from the adapter's `newCardDefaults`, and every adapter-declared + * column is an additional text field. + * + * Preview → apply → discard behave as in the edit form: the diff and validator + * report are shown before anything is written, inline validation errors block + * the write, and discarding leaves the content file untouched. + * + * @module + */ + +import type { ContentAdapter } from '../../src/story-line/content/types'; +import type { EditPreview, EditorEdit } from '../../src/story-line/editor/api'; +import { + attributeEditErrors, + buildAddSpec, + buildNewCardFieldDescriptors, + draftFromDescriptors, + type CardFieldDescriptor, + type CardFormError, +} from '../../src/story-line/editor/field-descriptors'; +import type { EditApplyOutcome, EditorBridge } from './bridge-client'; +import { createFieldControl, type FieldControl } from './edit-forms'; +import { createPreviewPanel, type PreviewPanelHandle } from './preview-panel'; + +/** A story-line group offered by the guided membership picker. */ +export interface StorylineOption { + /** The story-line id. */ + readonly id: string; + /** The story-line title. */ + readonly title: string; +} + +/** Options for {@link createAddCardForm}. */ +export interface AddCardFormOptions { + /** The game content adapter (drives the fields and defaults). */ + readonly adapter: ContentAdapter; + /** The edit bridge (preview/validate/apply through the single author API). */ + readonly bridge: EditorBridge; + /** Existing story-lines offered by the guided picker. */ + readonly storylines?: readonly StorylineOption[]; + /** Known card ids, offered as link suggestions and as incoming-link sources. */ + readonly knownCardIds?: readonly string[]; + /** Called after an accepted (written) add. */ + readonly onApplied?: (outcome: EditApplyOutcome) => void; + /** Called after the draft is discarded. */ + readonly onDiscarded?: () => void; +} + +/** The mounted add form's handle. */ +export interface AddCardFormHandle { + /** The form's root element. */ + readonly root: HTMLElement; + /** The fields rendered, in order. */ + readonly descriptors: readonly CardFieldDescriptor[]; + /** The preview/validation panel below the form. */ + readonly previewPanel: PreviewPanelHandle; + /** The current draft (field key → string value). */ + draft(): Readonly<Record<string, string>>; + /** The predecessor card id selected for the incoming link ('' = none). */ + linkFrom(): string; + /** True when any field or guided choice differs from its starting value. */ + hasPendingChanges(): boolean; + /** Sets a field's value (programmatic equivalent of typing). */ + setValue(key: string, value: string): void; + /** Selects the story-line group (fills the storyline id/title fields). */ + setStoryline(storylineId: string): void; + /** Selects the predecessor that gains a link to the new card ('' = none). */ + setLinkFrom(cardId: string): void; + /** Sets the incoming link's option label (defaults to the card's name). */ + setLinkLabel(label: string): void; + /** Previews the pending add; returns `null` when blocked. */ + preview(): Promise<EditPreview | null>; + /** Previews (always) then applies; returns `null` when blocked. */ + apply(): Promise<EditApplyOutcome | null>; + /** Discards the draft — nothing is written. */ + discard(): void; + /** Removes the form and its listeners from the document. */ + dispose(): void; +} + +/** Creates a plain element with an optional class and text. */ +function el<K extends keyof HTMLElementTagNameMap>( + tag: K, + className?: string, + text?: string, +): HTMLElementTagNameMap[K] { + const element = document.createElement(tag); + if (className) element.className = className; + if (text !== undefined) element.textContent = text; + return element; +} + +/** Monotonic id source for the guided controls (deterministic DOM ids). */ +let addFormIdCounter = 0; + +/** Builds the editable edit for the current draft (add vs add-and-link). */ +function buildEdit( + descriptors: readonly CardFieldDescriptor[], + draft: Readonly<Record<string, string>>, + linkFrom: string, + linkLabel: string, +): EditorEdit { + const card = buildAddSpec(descriptors, draft); + if (linkFrom.trim() === '') return { kind: 'add-card', spec: card }; + return { + kind: 'add-linked-card', + spec: { + card, + linkFrom: { id: linkFrom.trim(), label: linkLabel.trim() || card.name }, + }, + }; +} + +/** + * Mounts the guided add-card form. + * + * @param options The adapter, the bridge, the guided choices and callbacks. + * @returns The form handle. + */ +export function createAddCardForm(options: AddCardFormOptions): AddCardFormHandle { + const { adapter, bridge } = options; + const storylines = options.storylines ?? []; + const knownCardIds = options.knownCardIds ?? []; + + const descriptors = buildNewCardFieldDescriptors(adapter, { + ...(knownCardIds.length > 0 ? { knownCardIds } : {}), + }); + const baseline = draftFromDescriptors(descriptors); + let draftRecord: Record<string, string> = { ...baseline }; + let selectedStoryline = ''; + let linkFromId = ''; + let linkLabel = ''; + let disposed = false; + + const root = el('div', 'editor-add-card'); + root.dataset.role = 'add-card-form'; + const formId = ++addFormIdCounter; + + const heading = el('h3', 'editor-form-title', 'Add a card'); + heading.dataset.role = 'add-card-title'; + root.append(heading); + + const form = el('form', 'editor-form'); + form.dataset.role = 'add-form'; + form.addEventListener('submit', (event) => event.preventDefault()); + + const controls = new Map<string, FieldControl>(); + const grid = el('div', 'editor-form-fields'); + const storylineKeys = new Set(['storylineId', 'storylineTitle']); + for (const descriptor of descriptors) { + if (storylineKeys.has(descriptor.key)) continue; + const control = createFieldControl(descriptor, (key, value) => setValue(key, value, false)); + controls.set(descriptor.key, control); + grid.append(control.wrapper); + } + form.append(grid); + + // ── Guided story-line membership ─────────────────────────── + const storylineGroup = el('fieldset', 'editor-form-group'); + storylineGroup.dataset.role = 'storyline-group'; + storylineGroup.append(el('legend', 'editor-form-legend', 'Story-line membership')); + + const storylineSelectLabel = el('label', 'editor-field-label', 'Story-line'); + const storylineSelectId = `editor-storyline-${formId}`; + storylineSelectLabel.htmlFor = storylineSelectId; + const storylineSelect = el('select', 'editor-field-input'); + storylineSelect.id = storylineSelectId; + storylineSelect.dataset.role = 'storyline-select'; + const noStoryline = el('option', undefined, '— New story-line —'); + noStoryline.value = ''; + storylineSelect.append(noStoryline); + for (const storyline of storylines) { + const option = el('option', undefined, `${storyline.title} (${storyline.id})`); + option.value = storyline.id; + storylineSelect.append(option); + } + storylineGroup.append(storylineSelectLabel, storylineSelect); + + const storylineHint = el( + 'p', + 'editor-field-hint', + 'Choosing an existing story-line fills its id and title. A new story-line is named below.', + ); + storylineGroup.append(storylineHint); + + for (const key of storylineKeys) { + const descriptor = descriptors.find((candidate) => candidate.key === key); + if (!descriptor) continue; + const control = createFieldControl(descriptor, (changed, value) => + setValue(changed, value, false), + ); + controls.set(descriptor.key, control); + storylineGroup.append(control.wrapper); + } + // A game without a story-line id mapping has no membership to guide. + if (adapter.fields.storylineId !== undefined) form.append(storylineGroup); + + // ── Guided incoming link ─────────────────────────────────── + const linkGroup = el('fieldset', 'editor-form-group'); + linkGroup.dataset.role = 'link-group'; + linkGroup.append(el('legend', 'editor-form-legend', 'Incoming link (optional)')); + + const linkSelectLabel = el('label', 'editor-field-label', 'Linked from card'); + const linkSelectId = `editor-link-from-${formId}`; + linkSelectLabel.htmlFor = linkSelectId; + const linkSelect = el('select', 'editor-field-input'); + linkSelect.id = linkSelectId; + linkSelect.dataset.role = 'link-from'; + const noLink = el('option', undefined, '— No incoming link —'); + noLink.value = ''; + linkSelect.append(noLink); + for (const cardId of knownCardIds) { + const option = el('option', undefined, cardId); + option.value = cardId; + linkSelect.append(option); + } + + const linkLabelField = el('div', 'editor-field'); + linkLabelField.dataset.field = 'linkLabel'; + linkLabelField.dataset.kind = 'text'; + const linkLabelLabel = el('label', 'editor-field-label', 'Option label'); + linkLabelLabel.htmlFor = linkSelectId + '-label'; + const linkLabelInput = el('input', 'editor-field-input'); + linkLabelInput.type = 'text'; + linkLabelInput.id = linkSelectId + '-label'; + linkLabelInput.dataset.role = 'link-label'; + linkLabelInput.placeholder = 'Defaults to the card name'; + const linkLabelError = el('p', 'editor-field-error'); + linkLabelError.dataset.role = 'field-error'; + linkLabelError.hidden = true; + linkLabelField.append(linkLabelLabel, linkLabelInput, linkLabelError); + + linkGroup.append( + linkSelectLabel, + linkSelect, + linkLabelField, + el( + 'p', + 'editor-field-hint', + 'Adds an option leading from the chosen card to this new card, in the same validated write.', + ), + ); + // Only offer an incoming link when the adapter has a column to carry it. + const canLink = + adapter.fields.options !== undefined || + adapter.fields.acceptNextCardId !== undefined || + adapter.fields.rejectNextCardId !== undefined; + if (canLink) form.append(linkGroup); + + const status = el('p', 'editor-form-status', 'Fill in the card fields, then preview.'); + status.dataset.role = 'add-status'; + + const actions = el('div', 'editor-form-actions'); + const previewButton = el('button', 'editor-button', 'Preview card'); + previewButton.type = 'button'; + previewButton.dataset.action = 'preview-add'; + const applyButton = el('button', 'editor-button editor-button--primary', 'Create card'); + applyButton.type = 'button'; + applyButton.dataset.action = 'apply-add'; + const discardButton = el('button', 'editor-button', 'Discard changes'); + discardButton.type = 'button'; + discardButton.dataset.action = 'discard-add'; + actions.append(previewButton, applyButton, discardButton); + form.append(actions, status); + + root.append(form); + const previewPanel = createPreviewPanel(); + root.append(previewPanel.root); + + function setBusy(value: boolean): void { + previewButton.disabled = value; + applyButton.disabled = value; + discardButton.disabled = value; + } + + function hasPendingChanges(): boolean { + return ( + Object.keys(baseline).some((key) => draftRecord[key] !== baseline[key]) || + selectedStoryline !== '' || + linkFromId !== '' || + linkLabel !== '' + ); + } + + function clearInlineErrors(): void { + for (const control of controls.values()) { + control.error.textContent = ''; + control.error.hidden = true; + control.wrapper.dataset.invalid = 'false'; + } + linkLabelError.textContent = ''; + linkLabelError.hidden = true; + } + + function renderInlineErrors(errors: readonly CardFormError[]): void { + clearInlineErrors(); + for (const error of errors) { + if (!error.field) continue; + if (error.field === 'linkLabel') { + linkLabelError.textContent = error.message; + linkLabelError.hidden = false; + continue; + } + const control = controls.get(error.field); + if (!control) continue; + control.error.textContent = error.message; + control.error.hidden = false; + control.wrapper.dataset.invalid = 'true'; + } + } + + function setValue(key: string, value: string, syncControl = true): void { + if (disposed) return; + const descriptor = descriptors.find((candidate) => candidate.key === key); + if (!descriptor) return; + draftRecord = { ...draftRecord, [key]: value }; + if (syncControl) controls.get(key)?.set(value); + clearInlineErrors(); + status.textContent = 'Pending changes — preview before creating the card.'; + status.dataset.tone = 'info'; + previewPanel.renderMessage('Pending changes — preview to see the diff.', 'info'); + } + + function setStoryline(storylineId: string): void { + selectedStoryline = storylineId; + storylineSelect.value = storylineId; + if (storylineId === '') { + setValue('storylineId', '', true); + setValue('storylineTitle', '', true); + return; + } + const matched = storylines.find((storyline) => storyline.id === storylineId); + setValue('storylineId', storylineId, true); + setValue('storylineTitle', matched?.title ?? '', true); + } + + function setLinkFrom(cardId: string): void { + linkFromId = cardId; + linkSelect.value = cardId; + if (cardId !== '') { + status.textContent = 'Pending changes — preview before creating the card.'; + status.dataset.tone = 'info'; + } + } + + function setLinkLabel(label: string): void { + linkLabel = label; + linkLabelInput.value = label; + } + + async function preview(): Promise<EditPreview | null> { + const edit = buildEdit(descriptors, draftRecord, linkFromId, linkLabel); + setBusy(true); + try { + const result = await bridge.preview(edit); + previewPanel.render(result); + const errors = attributeEditErrors({ + descriptors, + draft: draftRecord, + cardId: null, + errors: result.errors, + issues: result.validation.issues, + }); + renderInlineErrors(errors); + status.textContent = result.ok + ? 'Preview is valid — create to write it.' + : 'Preview is invalid — the write is blocked.'; + status.dataset.tone = result.ok ? 'success' : 'error'; + return result; + } catch (error) { + status.textContent = `Preview failed: ${(error as Error).message}`; + status.dataset.tone = 'error'; + previewPanel.renderMessage(`Preview failed: ${(error as Error).message}`, 'error'); + return null; + } finally { + setBusy(false); + } + } + + async function apply(): Promise<EditApplyOutcome | null> { + const edit = buildEdit(descriptors, draftRecord, linkFromId, linkLabel); + // Structural guarantee: always preview (and validate) before writing. + const previewed = await preview(); + if (!previewed || !previewed.ok) return null; + + setBusy(true); + try { + const outcome = await bridge.apply(edit); + previewPanel.render(previewed, outcome); + if (outcome.ok) { + status.textContent = `Created — ${outcome.message}`; + status.dataset.tone = 'success'; + options.onApplied?.(outcome); + } else { + status.textContent = `Refused — ${outcome.message}`; + status.dataset.tone = 'error'; + renderInlineErrors( + attributeEditErrors({ + descriptors, + draft: draftRecord, + cardId: null, + errors: outcome.errors, + issues: outcome.preview.validation.issues, + }), + ); + } + return outcome; + } catch (error) { + status.textContent = `Create failed: ${(error as Error).message}`; + status.dataset.tone = 'error'; + previewPanel.renderMessage(`Create failed: ${(error as Error).message}`, 'error'); + return null; + } finally { + setBusy(false); + } + } + + function discard(): void { + draftRecord = { ...baseline }; + for (const [key, control] of controls) control.set(draftRecord[key] ?? ''); + selectedStoryline = ''; + storylineSelect.value = ''; + linkFromId = ''; + linkSelect.value = ''; + linkLabel = ''; + linkLabelInput.value = ''; + clearInlineErrors(); + status.textContent = 'Changes discarded — nothing was written.'; + status.dataset.tone = 'info'; + previewPanel.renderMessage('Changes discarded — the content file is untouched.', 'info'); + options.onDiscarded?.(); + } + + const onPreviewClick = (): void => { + void preview(); + }; + const onApplyClick = (): void => { + void apply(); + }; + const onDiscardClick = (): void => discard(); + const onStorylineChange = (): void => setStoryline(storylineSelect.value); + const onLinkFromChange = (): void => setLinkFrom(linkSelect.value); + const onLinkLabelInput = (): void => setLinkLabel(linkLabelInput.value); + + previewButton.addEventListener('click', onPreviewClick); + applyButton.addEventListener('click', onApplyClick); + discardButton.addEventListener('click', onDiscardClick); + storylineSelect.addEventListener('change', onStorylineChange); + linkSelect.addEventListener('change', onLinkFromChange); + linkLabelInput.addEventListener('input', onLinkLabelInput); + + return { + root, + descriptors, + previewPanel, + draft: () => ({ ...draftRecord }), + linkFrom: () => linkFromId, + hasPendingChanges, + setValue: (key, value) => setValue(key, value, true), + setStoryline, + setLinkFrom, + setLinkLabel, + preview, + apply, + discard, + dispose: () => { + if (disposed) return; + disposed = true; + previewButton.removeEventListener('click', onPreviewClick); + applyButton.removeEventListener('click', onApplyClick); + discardButton.removeEventListener('click', onDiscardClick); + storylineSelect.removeEventListener('change', onStorylineChange); + linkSelect.removeEventListener('change', onLinkFromChange); + linkLabelInput.removeEventListener('input', onLinkLabelInput); + root.remove(); + }, + }; +} diff --git a/tools/storyline-editor/app-shell.ts b/tools/storyline-editor/app-shell.ts new file mode 100644 index 000000000..80e67c091 --- /dev/null +++ b/tools/storyline-editor/app-shell.ts @@ -0,0 +1,519 @@ +/** + * Story-line editor page shell. + * + * Builds the editor's layout around the SVG graph renderer: a toolbar with + * zoom controls, the graph itself, a story-line legend and a details sidebar + * that shows the selected card's links and metadata (name, effect, routing, + * successor targets, story-line id/title and the adapter-declared columns). + * + * The shell is a plain-DOM module with no *host* dependency: it is handed one + * generated manifest and renders everything from that single in-memory model, + * which is why the browser tests can mount it directly and assert the rendered + * graph and selection behaviour without a dev server. + * + * When it is also handed the game's content adapter, the raw content cards and + * the edit bridge (the host serves all three), the shell additionally exposes + * the curated editing workflow: an **Edit card** action for the selected node, + * an **Add card** action, and a **Manage story-lines** action, each mounting + * the corresponding form (see `edit-forms.ts` / `add-card-form.ts` / + * `storyline-manager.ts`) whose preview, validation and apply all run through + * the F2 bridge — the shell itself never writes content. + * + * @module + */ + +import type { StorylineManifest } from '../../src/story-line/content/manifest'; +import { baseIdOf, type ContentAdapter, type ContentCard } from '../../src/story-line/content/types'; +import { + buildEditorViewModel, + type EditorViewModel, +} from '../../src/story-line/editor/view-model'; +import { + createGraphRenderer, + type EditorGraphRenderer, +} from './graph-renderer'; +import type { EditApplyOutcome, EditorBridge } from './bridge-client'; +import { createCardEditForm } from './edit-forms'; +import { createAddCardForm } from './add-card-form'; +import { + createStorylineManager, + type StorylineManagerHandle, +} from './storyline-manager'; + +/** Anything the editing host can mount (a card form or the story-line manager). */ +interface FormHandle { + readonly root: HTMLElement; + dispose(): void; +} + +/** Optional editing wiring (the host supplies all three together). */ +export interface EditorShellOptions { + /** The game content adapter (drives the curated form fields). */ + readonly adapter?: ContentAdapter; + /** The raw content cards behind the manifest nodes (editable values). */ + readonly cards?: readonly ContentCard[]; + /** The edit bridge (preview/validate/apply through the single author API). */ + readonly bridge?: EditorBridge; + /** Called after an accepted edit (the page may remount with fresh content). */ + readonly onApplied?: (outcome: EditApplyOutcome) => void; +} + +/** The mounted shell's handle. */ +export interface EditorShell { + /** The mounted root element. */ + readonly root: HTMLElement; + /** The view model the shell rendered. */ + readonly viewModel: EditorViewModel; + /** The graph renderer inside the shell. */ + readonly renderer: EditorGraphRenderer; + /** Selects a card (or clears the selection with `null`). */ + selectNode(nodeId: string | null): void; + /** Opens the edit form for a card (no-op unless editing is wired). */ + openEditForm(nodeId: string): void; + /** Opens the guided add-card form (no-op unless editing is wired). */ + openAddForm(): void; + /** + * Opens the story-line management panel (no-op unless editing is wired). + * + * @returns The mounted manager, so callers/tests can drive it directly. + */ + openStorylineManager(): StorylineManagerHandle | null; + /** Closes any open edit/add/management form. */ + closeEditor(): void; + /** Removes the shell's DOM and listeners from the container. */ + dispose(): void; +} + +/** Creates a plain HTML element with an optional class and text. */ +function el<K extends keyof HTMLElementTagNameMap>( + tag: K, + className?: string, + text?: string, +): HTMLElementTagNameMap[K] { + const element = document.createElement(tag); + if (className) element.className = className; + if (text !== undefined) element.textContent = text; + return element; +} + +/** + * Mounts the editor shell into a container. + * + * @param container The element to render into (its content is replaced). + * @param manifest The generated story-line manifest to display. + * @param options Optional editing wiring (adapter + content cards + bridge). + * @returns The shell handle. + */ +export function mountEditorShell( + container: HTMLElement, + manifest: StorylineManifest, + options: EditorShellOptions = {}, +): EditorShell { + const viewModel = buildEditorViewModel(manifest); + container.replaceChildren(); + const editing = + options.adapter && options.cards && options.bridge + ? { adapter: options.adapter, cards: options.cards, bridge: options.bridge } + : null; + const knownCardIds = viewModel.nodes.map((node) => node.id); + let openForm: FormHandle | null = null; + let selectedNodeId: string | null = null; + + const root = el('div', 'editor-shell'); + root.dataset.role = 'editor-shell'; + + // ── Toolbar ──────────────────────────────────────────────── + const toolbar = el('header', 'editor-toolbar'); + toolbar.dataset.role = 'toolbar'; + + const titleBlock = el('div', 'editor-toolbar-title'); + titleBlock.append(el('h1', 'editor-title', 'Story-line editor')); + const summary = el('p', 'editor-summary'); + summary.dataset.role = 'graph-summary'; + summary.textContent = [ + `${viewModel.nodes.length} card${viewModel.nodes.length === 1 ? '' : 's'}`, + `${viewModel.edges.length} link${viewModel.edges.length === 1 ? '' : 's'}`, + `${viewModel.storylines.length} story-line${viewModel.storylines.length === 1 ? '' : 's'}`, + `${viewModel.cycles.length} cycle${viewModel.cycles.length === 1 ? '' : 's'}`, + ].join(' · '); + titleBlock.append(summary); + toolbar.append(titleBlock); + + const actions = el('div', 'editor-toolbar-actions'); + const zoomIn = el('button', 'editor-button', 'Zoom in'); + zoomIn.type = 'button'; + zoomIn.dataset.action = 'zoom-in'; + const zoomOut = el('button', 'editor-button', 'Zoom out'); + zoomOut.type = 'button'; + zoomOut.dataset.action = 'zoom-out'; + const resetView = el('button', 'editor-button', 'Reset view'); + resetView.type = 'button'; + resetView.dataset.action = 'reset-view'; + const fitView = el('button', 'editor-button', 'Fit view'); + fitView.type = 'button'; + fitView.dataset.action = 'fit-view'; + const addCardButton = el('button', 'editor-button editor-button--primary', 'Add card'); + addCardButton.type = 'button'; + addCardButton.dataset.action = 'add-card'; + const manageStorylinesButton = el( + 'button', + 'editor-button', + 'Manage story-lines', + ); + manageStorylinesButton.type = 'button'; + manageStorylinesButton.dataset.action = 'manage-storylines'; + actions.append( + zoomIn, + zoomOut, + resetView, + fitView, + ...(editing ? [manageStorylinesButton, addCardButton] : []), + ); + toolbar.append(actions); + root.append(toolbar); + + const readOnlyNote = el( + 'p', + 'editor-readonly-note', + editing + ? 'Editing is enabled — every edit is previewed and validated before it is written through the author API.' + : 'Read-only preview — rendered from the generated manifest. Launch the editor with "npm run storylines:editor" to edit.', + ); + readOnlyNote.dataset.role = 'readonly-note'; + root.append(readOnlyNote); + + // ── Main region: graph + details sidebar ─────────────────── + const main = el('div', 'editor-main'); + const graphRegion = el('div', 'editor-graph'); + graphRegion.dataset.region = 'graph'; + const sidebar = el('aside', 'editor-sidebar'); + sidebar.dataset.region = 'sidebar'; + + sidebar.append(el('h2', 'editor-sidebar-title', 'Card details')); + + const empty = el( + 'p', + 'editor-sidebar-empty', + 'Select a card in the graph to inspect its links and metadata.', + ); + empty.dataset.role = 'details-empty'; + + const details = el('div', 'editor-details'); + details.dataset.role = 'details'; + details.hidden = true; + + const detailsName = el('h3', 'editor-details-name'); + detailsName.dataset.role = 'details-name'; + const detailsId = el('p', 'editor-details-id'); + detailsId.dataset.role = 'details-id'; + const detailsKind = el('p', 'editor-details-kind'); + detailsKind.dataset.role = 'details-kind'; + const detailsStoryline = el('p', 'editor-details-storyline'); + detailsStoryline.dataset.role = 'details-storyline'; + + const meta = el('dl', 'editor-details-meta'); + const metaPieces: Array<[label: string, role: string]> = [ + ['Effect', 'details-effect'], + ['Impact', 'details-impact'], + ['Routing', 'details-routing'], + ]; + const metaValues = new Map<string, HTMLElement>(); + for (const [label, role] of metaPieces) { + meta.append(el('dt', undefined, label)); + const value = el('dd'); + value.dataset.role = role; + metaValues.set(role, value); + meta.append(value); + } + + details.append(detailsName, detailsId, detailsKind, detailsStoryline, meta); + + // ── Editing affordance (present only when the host wired the bridge) ── + const editCardButton = el('button', 'editor-button editor-button--primary', 'Edit card'); + editCardButton.type = 'button'; + editCardButton.dataset.role = 'edit-card'; + const editActions = el('div', 'editor-details-actions'); + editActions.append(editCardButton); + if (editing) details.append(editActions); + + details.append(el('h4', 'editor-details-section', 'Links')); + const links = el('ul', 'editor-links'); + links.dataset.role = 'details-links'; + const noLinks = el('p', 'editor-details-note', 'No outgoing links (the chain ends here).'); + noLinks.dataset.role = 'details-no-links'; + details.append(links, noLinks); + + details.append(el('h4', 'editor-details-section', 'Reached from')); + const incoming = el('ul', 'editor-links'); + incoming.dataset.role = 'details-incoming'; + const noIncoming = el('p', 'editor-details-note', 'No incoming links (this is an entry point).'); + noIncoming.dataset.role = 'details-no-incoming'; + details.append(incoming, noIncoming); + + details.append(el('h4', 'editor-details-section', 'Adapter columns')); + const fields = el('dl', 'editor-fields'); + fields.dataset.role = 'details-fields'; + const noFields = el('p', 'editor-details-note', 'This card has no adapter columns.'); + noFields.dataset.role = 'details-no-fields'; + details.append(fields, noFields); + + sidebar.append(empty, details); + + // ── Editing host: the curated edit/add form mounts here ── + const editorHost = el('section', 'editor-editor-host'); + editorHost.dataset.region = 'editor'; + editorHost.hidden = true; + const editorHostHeading = el('div', 'editor-editor-host-header'); + const editorHostTitle = el('h3', 'editor-editor-host-title', 'Editing'); + editorHostTitle.dataset.role = 'editor-host-title'; + const closeEditorButton = el('button', 'editor-button', 'Close'); + closeEditorButton.type = 'button'; + closeEditorButton.dataset.role = 'close-editor'; + editorHostHeading.append(editorHostTitle, closeEditorButton); + editorHost.append(editorHostHeading); + const editorFormHost = el('div', 'editor-editor-form-host'); + editorFormHost.dataset.role = 'editor-form-host'; + editorHost.append(editorFormHost); + sidebar.append(editorHost); + + main.append(graphRegion, sidebar); + root.append(main); + + // ── Story-line legend ────────────────────────────────────── + const legend = el('footer', 'editor-legend'); + legend.append(el('h2', 'editor-legend-title', 'Story-lines')); + const legendList = el('ul', 'editor-legend-items'); + legendList.dataset.role = 'storyline-legend'; + legend.append(legendList); + root.append(legend); + + container.append(root); + + // ── Graph ────────────────────────────────────────────────── + const renderer = createGraphRenderer(graphRegion, viewModel, { + onSelect: (nodeId) => { + selectedNodeId = nodeId; + renderDetails(nodeId); + }, + }); + + const nodeById = new Map(viewModel.nodes.map((node) => [node.id, node])); + + function renderDetails(nodeId: string | null): void { + const node = nodeId === null ? undefined : nodeById.get(nodeId); + empty.hidden = node !== undefined; + details.hidden = node === undefined; + if (!node) return; + + detailsName.textContent = node.name; + detailsId.textContent = node.id; + detailsKind.textContent = node.hasChoices + ? `Choice card · ${node.optionCount} option${node.optionCount === 1 ? '' : 's'}` + : 'Plain incident'; + detailsStoryline.textContent = node.storylineId + ? `Story-line: ${node.storylineTitle ?? node.storylineId} (${node.storylineId})` + : 'Story-line: none'; + metaValues.get('details-effect')!.textContent = node.effect; + metaValues.get('details-impact')!.textContent = node.impact || '—'; + metaValues.get('details-routing')!.textContent = node.labels.routing; + + links.replaceChildren(); + for (const link of node.successors) { + const item = el('li', 'editor-link-item'); + item.dataset.linkTo = link.targetId ?? ''; + item.dataset.linkLabel = link.label; + item.dataset.linkPolicy = link.effectPolicy; + item.dataset.linkRandom = String(link.random); + item.dataset.linkCycle = String(link.cycle); + const policy = link.effectPolicy === 'apply' ? 'effect applies' : 'effect skipped'; + const random = link.random ? ` · random ${link.candidateIndex + 1}` : ''; + const cycle = link.cycle ? ' · cycle' : ''; + item.textContent = `${link.label} → ${link.targetName} (${policy}${random}${cycle})`; + links.append(item); + } + links.hidden = node.successors.length === 0; + noLinks.hidden = node.successors.length > 0; + + incoming.replaceChildren(); + for (const link of node.predecessors) { + const item = el('li', 'editor-link-item'); + item.dataset.linkFrom = link.fromId; + item.dataset.linkLabel = link.label; + item.dataset.linkCycle = String(link.cycle); + item.textContent = `${link.fromName} · ${link.label}${link.cycle ? ' · cycle' : ''}`; + incoming.append(item); + } + incoming.hidden = node.predecessors.length === 0; + noIncoming.hidden = node.predecessors.length > 0; + + fields.replaceChildren(); + const columns = Object.keys(node.fields).sort(); + for (const column of columns) { + const row = el('div', 'editor-field-row'); + row.dataset.field = column; + row.append(el('dt', undefined, column)); + row.append(el('dd', undefined, node.fields[column] ?? '')); + fields.append(row); + } + fields.hidden = columns.length === 0; + noFields.hidden = columns.length > 0; + } + + // ── Legend ───────────────────────────────────────────────── + for (const group of viewModel.storylines) { + const key = group.storylineId ?? ''; + const item = el('li', 'editor-legend-item'); + item.dataset.storylineId = key; + item.dataset.role = 'storyline-legend-item'; + item.append(el('span', 'editor-legend-swatch')); + item.append( + el( + 'span', + 'editor-legend-label', + `${group.title} · ${group.nodeIds.length} card${group.nodeIds.length === 1 ? '' : 's'}`, + ), + ); + item.hidden = group.storylineId === null; + if (group.storylineId !== null) { + item.addEventListener('click', () => { + const first = group.nodeIds[0]; + if (first) renderer.selectNode(first); + }); + } + legendList.append(item); + } + + // ── Toolbar wiring ───────────────────────────────────────── + const onZoomIn = (): void => renderer.zoomBy(1.25); + const onZoomOut = (): void => renderer.zoomBy(1 / 1.25); + const onReset = (): void => renderer.resetView(); + const onFit = (): void => renderer.fitToView(); + zoomIn.addEventListener('click', onZoomIn); + zoomOut.addEventListener('click', onZoomOut); + resetView.addEventListener('click', onReset); + fitView.addEventListener('click', onFit); + + // ── Editing wiring ───────────────────────────────────────── + function closeEditor(): void { + openForm?.dispose(); + openForm = null; + editorFormHost.replaceChildren(); + editorHost.hidden = true; + } + + function openEditingForm(form: FormHandle, title: string): void { + closeEditor(); + openForm = form; + editorHostTitle.textContent = title; + editorFormHost.append(form.root); + editorHost.hidden = false; + } + + function openEditForm(nodeId: string): void { + if (!editing) return; + const card = editing.cards.find( + (candidate) => baseIdOf(editing.adapter, candidate.id) === nodeId, + ); + if (!card) { + openEditingFormContents( + el('p', 'editor-details-note', `Card "${nodeId}" is not in the loaded content.`), + 'Editing', + ); + return; + } + const form = createCardEditForm({ + adapter: editing.adapter, + card, + bridge: editing.bridge, + knownCardIds, + onApplied: (outcome) => options.onApplied?.(outcome), + }); + openEditingForm(form, `Edit ${card.name}`); + } + + function openAddForm(): void { + if (!editing) return; + const storylines = viewModel.storylines + .filter((group) => group.storylineId !== null) + .map((group) => ({ id: group.storylineId as string, title: group.title })); + const form = createAddCardForm({ + adapter: editing.adapter, + bridge: editing.bridge, + storylines, + knownCardIds, + onApplied: (outcome) => options.onApplied?.(outcome), + }); + openEditingForm(form, 'Add a card'); + } + + /** + * Opens the story-line management panel: the manifest's story-line groups and + * member cards, backed by the same bridge as the card forms. Creating, + * renaming and reassigning all write one validated batch through it. + */ + function openStorylineManager(): StorylineManagerHandle | null { + if (!editing) return null; + const groups = viewModel.storylines + .filter((group) => group.storylineId !== null) + .map((group) => ({ storylineId: group.storylineId as string, title: group.title })); + const members = viewModel.nodes.map((node) => ({ + id: node.id, + name: node.name, + storylineId: node.storylineId, + storylineTitle: node.storylineTitle, + })); + const manager = createStorylineManager({ + groups, + members, + bridge: editing.bridge, + ...(options.onApplied ? { onApplied: options.onApplied } : {}), + }); + openEditingForm(manager, 'Manage story-lines'); + return manager; + } + + /** Replaces the editor host's content with a plain message panel. */ + function openEditingFormContents(content: HTMLElement, title: string): void { + closeEditor(); + editorHostTitle.textContent = title; + editorFormHost.append(content); + editorHost.hidden = false; + } + + const onCloseEditor = (): void => closeEditor(); + closeEditorButton.addEventListener('click', onCloseEditor); + const onEditCard = (): void => { + if (selectedNodeId !== null) openEditForm(selectedNodeId); + }; + editCardButton.addEventListener('click', onEditCard); + const onAddCard = (): void => openAddForm(); + addCardButton.addEventListener('click', onAddCard); + const onManageStorylines = (): void => { + openStorylineManager(); + }; + manageStorylinesButton.addEventListener('click', onManageStorylines); + + return { + root, + viewModel, + renderer, + selectNode: (nodeId) => renderer.selectNode(nodeId), + openEditForm, + openAddForm, + openStorylineManager, + closeEditor, + dispose: () => { + zoomIn.removeEventListener('click', onZoomIn); + zoomOut.removeEventListener('click', onZoomOut); + resetView.removeEventListener('click', onReset); + fitView.removeEventListener('click', onFit); + closeEditorButton.removeEventListener('click', onCloseEditor); + editCardButton.removeEventListener('click', onEditCard); + addCardButton.removeEventListener('click', onAddCard); + manageStorylinesButton.removeEventListener('click', onManageStorylines); + closeEditor(); + renderer.dispose(); + container.replaceChildren(); + }, + }; +} diff --git a/tools/storyline-editor/app.ts b/tools/storyline-editor/app.ts new file mode 100644 index 000000000..cb8abd9a3 --- /dev/null +++ b/tools/storyline-editor/app.ts @@ -0,0 +1,143 @@ +/** + * Story-line editor page bootstrap. + * + * The page is served by the editor host (`npm run storylines:editor`), which + * answers: + * + * - `GET ./manifest.json` — the deterministic graph manifest (the view model); + * - `GET ./content.json` — the adapter's declarative field mapping/defaults + * plus the raw content cards (what the curated edit/add forms prefill from); + * - `POST ./api/preview|validate|apply` — the F2 write bridge. + * + * The bootstrap fetches the two reads once, then mounts the shell. The content + * response is **optional**: without it the editor still renders the read-only + * graph (for example when Vite is run directly on `vite.editor.config.ts` + * without the host's API middleware), with an actionable note instead of an + * error. + * + * @module + */ + +import type { StorylineManifest } from '../../src/story-line/content/manifest'; +import type { ContentAdapter, ContentCard } from '../../src/story-line/content/types'; +import { mountEditorShell, type EditorShell } from './app-shell'; +import { createFetchEditorBridge } from './bridge-client'; +import { EDITOR_APP_ID, EDITOR_CONTENT_URL, EDITOR_MANIFEST_URL } from './config'; + +/** The editable content the host serves for the curated forms. */ +export interface EditorContentPayload { + /** The adapter's game id. */ + readonly gameId: string; + /** Checksum of the loaded content (the concurrency token). */ + readonly checksum: string; + /** The adapter's declarative field mapping/defaults. */ + readonly adapter: ContentAdapter; + /** The raw content cards, in file order. */ + readonly cards: readonly ContentCard[]; +} + +/** Renders a message panel instead of the editor. */ +function renderMessage(container: HTMLElement, title: string, detail: string): void { + container.replaceChildren(); + const panel = document.createElement('section'); + panel.className = 'editor-message'; + panel.dataset.role = 'editor-message'; + + const heading = document.createElement('h1'); + heading.textContent = title; + panel.append(heading); + + const paragraph = document.createElement('p'); + paragraph.textContent = detail; + panel.append(paragraph); + + container.append(panel); +} + +/** Fetches a JSON document and validates it is an object. */ +async function fetchJson(url: string): Promise<Record<string, unknown>> { + const response = await fetch(url, { headers: { accept: 'application/json' } }); + if (!response.ok) { + throw new Error(`the editor host did not serve ${url} (HTTP ${response.status})`); + } + const parsed: unknown = await response.json(); + if (typeof parsed !== 'object' || parsed === null) { + throw new Error(`${url} is not a JSON object`); + } + return parsed as Record<string, unknown>; +} + +/** Fetches the generated manifest from the editor host. */ +async function fetchManifest(): Promise<StorylineManifest> { + const parsed = await fetchJson(EDITOR_MANIFEST_URL); + if (!Array.isArray(parsed.nodes) || !Array.isArray(parsed.edges)) { + throw new Error(`${EDITOR_MANIFEST_URL} is not a story-line manifest`); + } + return parsed as unknown as StorylineManifest; +} + +/** + * Fetches the editable content (adapter schema + raw cards). + * + * @returns The payload, or `null` when the host does not serve it (the editor + * then stays read-only without failing the page). + */ +async function fetchContent(): Promise<EditorContentPayload | null> { + try { + const parsed = await fetchJson(EDITOR_CONTENT_URL); + const adapter = parsed.adapter as ContentAdapter | undefined; + if (!adapter || !Array.isArray(parsed.cards)) return null; + return { + gameId: String(parsed.gameId ?? adapter.gameId ?? ''), + checksum: String(parsed.checksum ?? ''), + adapter, + cards: parsed.cards as readonly ContentCard[], + }; + } catch { + return null; + } +} + +/** Fetches both reads and mounts the shell (remounting after an accepted edit). */ +async function mountEditor(container: HTMLElement): Promise<EditorShell> { + const manifest = await fetchManifest(); + const content = await fetchContent(); + const bridge = createFetchEditorBridge(); + + const shell: EditorShell = mountEditorShell(container, manifest, { + ...(content + ? { adapter: content.adapter, cards: content.cards, bridge } + : {}), + onApplied: () => { + // The host reloaded its session; re-fetch so the graph and the form + // prefill reflect the written content. + shell.dispose(); + void mountEditor(container); + }, + }); + return shell; +} + +/** Boots the editor page. */ +export async function bootEditorPage(container: HTMLElement): Promise<EditorShell | null> { + try { + return await mountEditor(container); + } catch (error) { + renderMessage( + container, + 'Story-line editor', + `Could not load the story-line manifest: ${(error as Error).message}. ` + + 'Launch the editor with "npm run storylines:editor" from the repository root ' + + 'so the host can serve the manifest generated from your content CSV.', + ); + return null; + } +} + +// Guarded so the module stays importable outside a page (tests, tooling). +if (typeof document !== 'undefined') { + const mountPoint = document.getElementById(EDITOR_APP_ID); + if (mountPoint) { + void bootEditorPage(mountPoint); + } +} diff --git a/tools/storyline-editor/bridge-client.ts b/tools/storyline-editor/bridge-client.ts new file mode 100644 index 000000000..92c602ed9 --- /dev/null +++ b/tools/storyline-editor/bridge-client.ts @@ -0,0 +1,128 @@ +/** + * Browser-safe client for the story-line editor's write bridge. + * + * The editor page never computes an edit, a diff or a validation verdict + * itself: it posts an {@link EditorEdit} to the host's F2 endpoints + * (`/api/preview`, `/api/validate`, `/api/apply`), which call the core bridge + * — `previewEdit`, `validateEdit`, `applyEdit`. This module is the page's side + * of that contract: one small typed interface the curated forms depend on, and + * a `fetch` implementation of it. + * + * Keeping the interface here (rather than reaching for `fetch` directly in the + * forms) is what makes the forms testable in the browser without a dev server: + * a test can hand the workflow an in-memory bridge that runs the **real** pure + * preview/validation seam (`src/story-line/editor/edits.ts`) and only stubs the + * file write. + * + * Only **types** are imported from the Node-side bridge module (`api.ts`); + * `import type` is erased at build time, so this module stays browser-safe. + * + * @module + */ + +import type { + EditApplyResult, + EditPreview, + EditValidation, + EditorEdit, +} from '../../src/story-line/editor/api'; +import type { StorylineManifest } from '../../src/story-line/content/manifest'; +import { EDITOR_API_PATHS } from './config'; + +/** + * What the forms need back from an accepted edit. + * + * Deliberately a structural subset of {@link EditApplyResult} so an in-memory + * test bridge only has to supply the fields the UI reads, while the real + * bridge's full result is assignable unchanged. + */ +export interface EditApplyOutcome { + /** True when the edit was accepted (applied, or valid on a dry run). */ + readonly ok: boolean; + /** Human-readable outcome summary (rendered verbatim). */ + readonly message: string; + /** The preview/diff and validation report for the attempted edit. */ + readonly preview: EditPreview; + /** Actionable errors (empty when `ok`). */ + readonly errors: readonly string[]; + /** True only when the content file was actually written. */ + readonly written?: boolean; + /** True when the caller must reload before retrying (stale content). */ + readonly reloadRequired?: boolean; + /** The content checksum after the attempt. */ + readonly checksum?: string; + /** The regenerated manifest, when the host includes it (page refresh). */ + readonly manifest?: StorylineManifest; +} + +/** + * The editor's edit bridge: preview → validate → apply, in that order. + * + * Every curated form routes through this seam, so no form can write content + * except through the host's single author-API path. + */ +export interface EditorBridge { + /** Applies an edit in memory and returns its diff + validation report. */ + preview(edit: EditorEdit): Promise<EditPreview>; + /** Runs the validator only (no diff), nothing written. */ + validate(edit: EditorEdit): Promise<EditValidation>; + /** Applies an edit through the transactional author API (the only write). */ + apply( + edit: EditorEdit, + options?: { readonly expectedChecksum?: string; readonly dryRun?: boolean }, + ): Promise<EditApplyOutcome>; +} + +/** The host response for an apply request (the bridge result plus a manifest). */ +export type EditApplyResultWithManifest = EditApplyResult & { + readonly manifest?: StorylineManifest; +}; + +/** Posts a JSON body and returns the parsed response body. */ +async function postJson(url: string, body: unknown): Promise<unknown> { + const response = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json', accept: 'application/json' }, + body: JSON.stringify(body), + }); + const text = await response.text(); + let parsed: unknown; + try { + parsed = text === '' ? {} : (JSON.parse(text) as unknown); + } catch { + throw new Error(`${url} returned a non-JSON response (HTTP ${response.status})`); + } + if (!response.ok) { + // The apply endpoint returns the structured refusal in the body; surface it + // rather than throwing so the form can render the validator's verdict. + const looked = parsed as { errors?: unknown; message?: unknown }; + if (Array.isArray(looked.errors) || typeof looked.message === 'string') { + return parsed; + } + throw new Error(`${url} failed (HTTP ${response.status}): ${text}`); + } + return parsed; +} + +/** + * Creates the `fetch`-backed bridge the editor page uses. + * + * @param baseUrl Optional base URL prefixed to the host API paths (defaults to + * the page's own origin, i.e. the editor dev server). + * @returns The bridge. + */ +export function createFetchEditorBridge(baseUrl = ''): EditorBridge { + const url = (path: string): string => `${baseUrl}${path}`; + return { + async preview(edit) { + return (await postJson(url(EDITOR_API_PATHS.preview), { edit })) as EditPreview; + }, + async validate(edit) { + return (await postJson(url(EDITOR_API_PATHS.validate), { edit })) as EditValidation; + }, + async apply(edit, options = {}) { + const body = { edit, ...options }; + return (await postJson(url(EDITOR_API_PATHS.apply), body)) as EditApplyResultWithManifest; + }, + }; +} diff --git a/tools/storyline-editor/config.ts b/tools/storyline-editor/config.ts new file mode 100644 index 000000000..a38b1fa01 --- /dev/null +++ b/tools/storyline-editor/config.ts @@ -0,0 +1,59 @@ +/** + * Shared, browser-safe constants for the story-line editor page. + * + * The editor host (a Node-side Vite dev server) and the page must agree on the + * manifest URL and the mount element without importing each other: the host + * imports `vite` and Node builtins, so the page cannot import it. Keeping the + * two ends' constants here — a module with no DOM, Node or `vite` dependency — + * makes the agreement explicit and unit-testable. + * + * @module + */ + +/** Id of the page's mount element (see `index.html`). */ +export const EDITOR_APP_ID = 'storyline-editor-app'; + +/** Browser-relative URL the page fetches the generated manifest from. */ +export const EDITOR_MANIFEST_URL = './manifest.json'; + +/** + * Browser-relative URL the page fetches the editable content from. + * + * The manifest is the graph view model; the content response adds what the + * curated forms need to prefill themselves — the content adapter's field + * mapping/defaults (declarative data only) and the raw cards (including the + * legacy successor-link columns the manifest deliberately does not expose). + */ +export const EDITOR_CONTENT_URL = './content.json'; + +/** + * Host-relative path the editor host serves the generated manifest on. + * + * Kept in sync with {@link EDITOR_MANIFEST_URL} by + * `tests/story-line/editor/cli.test.ts`. + */ +export const EDITOR_MANIFEST_PATH = '/manifest.json'; + +/** + * Host-relative path the editor host serves the editable content on (read-only + * `GET`): the adapter's declarative field mapping/defaults plus the raw cards. + * Kept in sync with {@link EDITOR_CONTENT_URL} by + * `tests/story-line/editor/cli.test.ts`. + */ +export const EDITOR_CONTENT_PATH = '/content.json'; + +/** Host-relative path of the preview endpoint (`POST`, returns the diff). */ +export const EDITOR_PREVIEW_PATH = '/api/preview'; + +/** Host-relative path of the validate endpoint (`POST`, validator report only). */ +export const EDITOR_VALIDATE_PATH = '/api/validate'; + +/** Host-relative path of the apply endpoint (`POST`, the only write). */ +export const EDITOR_APPLY_PATH = '/api/apply'; + +/** Every host API path the editor page may call, with its operation. */ +export const EDITOR_API_PATHS = { + preview: EDITOR_PREVIEW_PATH, + validate: EDITOR_VALIDATE_PATH, + apply: EDITOR_APPLY_PATH, +} as const; diff --git a/tools/storyline-editor/edit-forms.ts b/tools/storyline-editor/edit-forms.ts new file mode 100644 index 000000000..d9e17e5cf --- /dev/null +++ b/tools/storyline-editor/edit-forms.ts @@ -0,0 +1,425 @@ +/** + * Curated, adapter-driven card edit form. + * + * The form is built from the adapter's **field descriptors** + * ({@link buildCardFieldDescriptors}) rather than hard-coded columns: core + * edits the generic story-line fields (id read-only, name, effect, choice + * flag, options encoding, legacy accept/reject links, storyline id/title) and + * every adapter-declared column renders as an additional text field without + * the editor knowing its semantics. + * + * The workflow is deliberately conservative — the same order the epic + * requires: + * + * 1. the author changes fields (a **pending** draft; nothing is written); + * 2. **Preview** posts the derived edit to the bridge, which applies it in + * memory, validates and diffs it, and the panel renders the diff plus the + * validator's report; + * 3. **Apply** previews again first (structurally: an apply can never happen + * without a fresh, valid preview) and only then writes through the single + * author API; + * 4. **Discard** throws the draft away, leaving the content file untouched. + * + * Inline validation errors are attributed to the field that caused them + * ({@link attributeEditErrors}) and block the write — the offending control is + * marked so the author fixes exactly that field, while the panel keeps the + * full validator report. + * + * Plain DOM, no Node, no Phaser: the form is mounted directly into the page or + * a browser test document and talks to the F2 bridge through the injected + * {@link EditorBridge}. + * + * @module + */ + +import type { ContentAdapter, ContentCard } from '../../src/story-line/content/types'; +import type { EditPreview } from '../../src/story-line/editor/api'; +import { + attributeEditErrors, + buildCardFieldDescriptors, + buildUpdateSpec, + draftFromDescriptors, + type CardFieldDescriptor, + type CardFormError, +} from '../../src/story-line/editor/field-descriptors'; +import type { EditApplyOutcome, EditorBridge } from './bridge-client'; +import { createPreviewPanel, type PreviewPanelHandle } from './preview-panel'; + +// ── Shared field controls ─────────────────────────────────── + +/** A rendered field control and its inline error slot. */ +export interface FieldControl { + /** The field wrapper (`[data-field]`), containing label, control and error. */ + readonly wrapper: HTMLElement; + /** The input/textarea/checkbox element. */ + readonly control: HTMLInputElement | HTMLTextAreaElement; + /** The inline error slot (`[data-role="field-error"]`). */ + readonly error: HTMLElement; + /** Reads the control's current value in descriptor string form. */ + get(): string; + /** Writes a value into the control. */ + set(value: string): void; +} + +let fieldIdCounter = 0; + +/** Creates a plain element with an optional class and text. */ +function el<K extends keyof HTMLElementTagNameMap>( + tag: K, + className?: string, + text?: string, +): HTMLElementTagNameMap[K] { + const element = document.createElement(tag); + if (className) element.className = className; + if (text !== undefined) element.textContent = text; + return element; +} + +/** + * Renders one descriptor as a labelled control plus an inline error slot. + * + * @param descriptor The field descriptor. + * @param onInput Called with the field key and the new string value. + * @returns The control handle. + */ +export function createFieldControl( + descriptor: CardFieldDescriptor, + onInput: (key: string, value: string) => void, +): FieldControl { + const id = `editor-field-${++fieldIdCounter}`; + const wrapper = el('div', 'editor-field'); + wrapper.dataset.field = descriptor.key; + wrapper.dataset.kind = descriptor.kind; + wrapper.dataset.group = descriptor.group; + + const label = el('label', 'editor-field-label', descriptor.label); + label.htmlFor = id; + + let control: HTMLInputElement | HTMLTextAreaElement; + if (descriptor.kind === 'long-text') { + control = el('textarea', 'editor-field-input'); + control.rows = 3; + } else { + const input = el('input', 'editor-field-input'); + input.type = descriptor.kind === 'boolean' ? 'checkbox' : 'text'; + if (descriptor.kind === 'read-only') input.readOnly = true; + if (descriptor.suggestions && descriptor.suggestions.length > 0) { + const listId = `${id}-suggestions`; + const list = el('datalist'); + list.id = listId; + for (const suggestion of descriptor.suggestions) { + const option = el('option'); + option.value = suggestion; + list.append(option); + } + input.setAttribute('list', listId); + wrapper.append(list); + } + control = input; + } + control.id = id; + control.dataset.role = 'field-input'; + + const get = (): string => { + if (descriptor.kind === 'boolean' && control instanceof HTMLInputElement) { + return control.checked ? 'true' : 'false'; + } + return control.value; + }; + const set = (value: string): void => { + if (descriptor.kind === 'boolean' && control instanceof HTMLInputElement) { + control.checked = value === 'true'; + return; + } + control.value = value; + }; + set(descriptor.value); + + const onControlInput = (): void => onInput(descriptor.key, get()); + control.addEventListener('input', onControlInput); + control.addEventListener('change', onControlInput); + + const error = el('p', 'editor-field-error'); + error.dataset.role = 'field-error'; + error.hidden = true; + + wrapper.append(label, control); + if (descriptor.hint) wrapper.append(el('p', 'editor-field-hint', descriptor.hint)); + wrapper.append(error); + + return { wrapper, control, error, get, set }; +} + +// ── Card edit form ────────────────────────────────────────── + +/** Options for {@link createCardEditForm}. */ +export interface CardEditFormOptions { + /** The game content adapter (drives the fields). */ + readonly adapter: ContentAdapter; + /** The card being edited (its loaded values are the baseline). */ + readonly card: ContentCard; + /** The edit bridge (preview/validate/apply through the single author API). */ + readonly bridge: EditorBridge; + /** Known card ids, offered as suggestions on link fields. */ + readonly knownCardIds?: readonly string[]; + /** Called after an accepted (written) edit. */ + readonly onApplied?: (outcome: EditApplyOutcome) => void; + /** Called after the draft is discarded. */ + readonly onDiscarded?: () => void; +} + +/** The mounted edit form's handle. */ +export interface CardEditFormHandle { + /** The form's root element. */ + readonly root: HTMLElement; + /** The fields rendered, in order. */ + readonly descriptors: readonly CardFieldDescriptor[]; + /** The preview/validation panel below the form. */ + readonly previewPanel: PreviewPanelHandle; + /** The current draft (field key → string value). */ + draft(): Readonly<Record<string, string>>; + /** True when at least one field differs from the last accepted baseline. */ + hasPendingChanges(): boolean; + /** Sets a field's value (programmatic equivalent of typing). */ + setValue(key: string, value: string): void; + /** Previews the pending edit; returns `null` when there is nothing to preview. */ + preview(): Promise<EditPreview | null>; + /** Previews (always) then applies; returns `null` when blocked or unchanged. */ + apply(): Promise<EditApplyOutcome | null>; + /** Discards the pending draft — nothing is written. */ + discard(): void; + /** Removes the form and its listeners from the document. */ + dispose(): void; +} + +/** + * Mounts the curated card edit form. + * + * @param options The adapter, the card, the bridge and lifecycle callbacks. + * @returns The form handle. + */ +export function createCardEditForm(options: CardEditFormOptions): CardEditFormHandle { + const { adapter, card, bridge } = options; + const descriptors = buildCardFieldDescriptors(adapter, card, { + ...(options.knownCardIds ? { knownCardIds: options.knownCardIds } : {}), + }); + const baseline = draftFromDescriptors(descriptors); + let draftRecord: Record<string, string> = { ...baseline }; + let disposed = false; + + const root = el('div', 'editor-card-edit'); + root.dataset.role = 'card-edit-form'; + root.dataset.cardId = card.id; + + const heading = el('h3', 'editor-form-title', `Edit card — ${card.name}`); + heading.dataset.role = 'edit-card-title'; + root.append(heading); + + const form = el('form', 'editor-form'); + form.dataset.role = 'edit-form'; + form.addEventListener('submit', (event) => event.preventDefault()); + + const controls = new Map<string, FieldControl>(); + const grid = el('div', 'editor-form-fields'); + for (const descriptor of descriptors) { + const control = createFieldControl(descriptor, (key, value) => setValue(key, value, false)); + controls.set(descriptor.key, control); + grid.append(control.wrapper); + } + form.append(grid); + + const status = el('p', 'editor-form-status', 'No pending changes.'); + status.dataset.role = 'edit-status'; + + const actions = el('div', 'editor-form-actions'); + const previewButton = el('button', 'editor-button', 'Preview changes'); + previewButton.type = 'button'; + previewButton.dataset.action = 'preview-edit'; + const applyButton = el('button', 'editor-button editor-button--primary', 'Apply edit'); + applyButton.type = 'button'; + applyButton.dataset.action = 'apply-edit'; + const discardButton = el('button', 'editor-button', 'Discard changes'); + discardButton.type = 'button'; + discardButton.dataset.action = 'discard-edit'; + actions.append(previewButton, applyButton, discardButton); + form.append(actions, status); + + root.append(form); + const previewPanel = createPreviewPanel(); + root.append(previewPanel.root); + + function setBusy(value: boolean): void { + previewButton.disabled = value; + applyButton.disabled = value; + discardButton.disabled = value; + } + + function hasPendingChanges(): boolean { + return Object.keys(baseline).some((key) => draftRecord[key] !== baseline[key]); + } + + function clearInlineErrors(): void { + for (const control of controls.values()) { + control.error.textContent = ''; + control.error.hidden = true; + control.wrapper.dataset.invalid = 'false'; + } + } + + function renderInlineErrors(errors: readonly CardFormError[]): void { + clearInlineErrors(); + for (const error of errors) { + if (!error.field) continue; + const control = controls.get(error.field); + if (!control) continue; + const existing = control.error.hidden ? '' : `${control.error.textContent ?? ''}\n`; + control.error.textContent = `${existing}${error.message}`; + control.error.hidden = false; + control.wrapper.dataset.invalid = 'true'; + } + } + + function setValue(key: string, value: string, syncControl = true): void { + if (disposed) return; + const descriptor = descriptors.find((candidate) => candidate.key === key); + if (!descriptor) return; + draftRecord = { ...draftRecord, [key]: value }; + if (syncControl) controls.get(key)?.set(value); + clearInlineErrors(); + if (hasPendingChanges()) { + status.textContent = 'Pending changes — preview before applying.'; + status.dataset.tone = 'info'; + previewPanel.renderMessage('Pending changes — preview to see the diff.', 'info'); + } else { + status.textContent = 'No pending changes.'; + delete status.dataset.tone; + previewPanel.clear(); + } + } + + async function preview(): Promise<EditPreview | null> { + const spec = buildUpdateSpec(descriptors, draftRecord); + if (!spec) { + status.textContent = 'No pending changes to preview.'; + status.dataset.tone = 'info'; + previewPanel.renderMessage('No pending changes to preview.', 'info'); + return null; + } + setBusy(true); + try { + const result = await bridge.preview({ kind: 'update-card', spec }); + previewPanel.render(result); + renderInlineErrors( + attributeEditErrors({ + descriptors, + draft: draftRecord, + cardId: card.id, + errors: result.errors, + issues: result.validation.issues, + }), + ); + status.textContent = result.ok + ? 'Preview is valid — apply to write it.' + : 'Preview is invalid — the write is blocked.'; + status.dataset.tone = result.ok ? 'success' : 'error'; + return result; + } catch (error) { + status.textContent = `Preview failed: ${(error as Error).message}`; + status.dataset.tone = 'error'; + previewPanel.renderMessage(`Preview failed: ${(error as Error).message}`, 'error'); + return null; + } finally { + setBusy(false); + } + } + + async function apply(): Promise<EditApplyOutcome | null> { + if (!hasPendingChanges()) { + status.textContent = 'No pending changes to apply.'; + status.dataset.tone = 'info'; + previewPanel.renderMessage('No pending changes to apply.', 'info'); + return null; + } + const spec = buildUpdateSpec(descriptors, draftRecord); + if (!spec) return null; + + // Structural guarantee: an apply always runs a fresh preview first, so an + // invalid edit can never reach the author API. + const previewed = await preview(); + if (!previewed || !previewed.ok) return null; + + setBusy(true); + try { + const outcome = await bridge.apply({ kind: 'update-card', spec }); + previewPanel.render(previewed, outcome); + if (outcome.ok) { + draftRecord = { ...draftRecord }; + for (const key of Object.keys(baseline)) baseline[key] = draftRecord[key] ?? baseline[key]; + status.textContent = `Applied — ${outcome.message}`; + status.dataset.tone = 'success'; + options.onApplied?.(outcome); + } else { + status.textContent = `Refused — ${outcome.message}`; + status.dataset.tone = 'error'; + renderInlineErrors( + attributeEditErrors({ + descriptors, + draft: draftRecord, + cardId: card.id, + errors: outcome.errors, + issues: outcome.preview.validation.issues, + }), + ); + } + return outcome; + } catch (error) { + status.textContent = `Apply failed: ${(error as Error).message}`; + status.dataset.tone = 'error'; + previewPanel.renderMessage(`Apply failed: ${(error as Error).message}`, 'error'); + return null; + } finally { + setBusy(false); + } + } + + function discard(): void { + draftRecord = { ...baseline }; + for (const [key, control] of controls) control.set(draftRecord[key] ?? ''); + clearInlineErrors(); + status.textContent = 'Changes discarded — nothing was written.'; + status.dataset.tone = 'info'; + previewPanel.renderMessage('Changes discarded — the content file is untouched.', 'info'); + options.onDiscarded?.(); + } + + const onPreviewClick = (): void => { + void preview(); + }; + const onApplyClick = (): void => { + void apply(); + }; + const onDiscardClick = (): void => discard(); + previewButton.addEventListener('click', onPreviewClick); + applyButton.addEventListener('click', onApplyClick); + discardButton.addEventListener('click', onDiscardClick); + + return { + root, + descriptors, + previewPanel, + draft: () => ({ ...draftRecord }), + hasPendingChanges, + setValue: (key, value) => setValue(key, value, true), + preview, + apply, + discard, + dispose: () => { + if (disposed) return; + disposed = true; + previewButton.removeEventListener('click', onPreviewClick); + applyButton.removeEventListener('click', onApplyClick); + discardButton.removeEventListener('click', onDiscardClick); + root.remove(); + }, + }; +} diff --git a/tools/storyline-editor/graph-renderer.ts b/tools/storyline-editor/graph-renderer.ts new file mode 100644 index 000000000..a399792b4 --- /dev/null +++ b/tools/storyline-editor/graph-renderer.ts @@ -0,0 +1,548 @@ +/** + * Lightweight SVG graph renderer for the story-line editor. + * + * A plain-DOM module: no Phaser, no framework and no new runtime dependency. + * It takes the render-ready {@link EditorViewModel} and produces an SVG picture + * of the manifest — one column per story-line group, choice cards drawn as + * diamonds and plain incidents as rounded rectangles, cycle edges and cycle + * members highlighted, option labels on the links — with pan/zoom and node + * selection. + * + * The renderer is intentionally reload-free: the whole graph is built from the + * one view model it is given (the page fetches the manifest once), so + * interacting with it never round-trips to the host. + * + * Everything the browser tests assert is exposed deliberately: `data-node-id` + * and `data-edge` markers, `data-cycle` / `data-has-choices` flags, a + * `getTransform()` accessor and the `zoomBy`/`panBy`/`resetView`/`fitToView` + * controls that back the toolbar buttons. + * + * @module + */ + +import type { + EditorNode, + EditorViewModel, +} from '../../src/story-line/editor/view-model'; + +const SVG_NS = 'http://www.w3.org/2000/svg'; + +/** Rendered node width in graph units. */ +export const NODE_WIDTH = 190; +/** Rendered node height in graph units. */ +export const NODE_HEIGHT = 72; +/** Horizontal gap between story-line columns. */ +export const COLUMN_GAP = 120; +/** Vertical gap between rows within a column. */ +export const ROW_GAP = 38; +/** Outer padding around the graph. */ +export const GRAPH_PADDING = 56; +/** Length of the stub drawn for an option whose chain ends. */ +export const TERMINAL_STUB = 72; +/** Smallest zoom factor the renderer allows. */ +export const MIN_SCALE = 0.2; +/** Largest zoom factor the renderer allows. */ +export const MAX_SCALE = 4; + +/** A laid-out node: the view-model node plus its graph position. */ +export interface EditorGraphNodePosition { + readonly node: EditorNode; + readonly x: number; + readonly y: number; +} + +/** One story-line column of the layout. */ +export interface EditorGraphColumn { + readonly storylineId: string | null; + readonly title: string; + readonly x: number; + readonly width: number; + readonly nodeIds: readonly string[]; +} + +/** The deterministic layout of a view model. */ +export interface EditorGraphLayout { + readonly positions: ReadonlyMap<string, EditorGraphNodePosition>; + readonly columns: readonly EditorGraphColumn[]; + readonly width: number; + readonly height: number; +} + +/** A translate/scale view transform. */ +export interface EditorGraphTransform { + readonly scale: number; + readonly x: number; + readonly y: number; +} + +/** Options for {@link createGraphRenderer}. */ +export interface EditorGraphRendererOptions { + /** Called whenever the selected node changes (`null` when cleared). */ + readonly onSelect?: (nodeId: string | null) => void; +} + +/** The mounted renderer's handle. */ +export interface EditorGraphRenderer { + /** The root SVG element. */ + readonly svg: SVGSVGElement; + /** The transformed viewport group holding every node and edge. */ + readonly viewport: SVGGElement; + /** Id of the currently selected node, or null. */ + readonly selectedNodeId: string | null; + /** The layout used to place the nodes. */ + readonly layout: EditorGraphLayout; + /** Selects a node (or clears the selection with `null`). */ + selectNode(nodeId: string | null): void; + /** Zooms around the viewport centre. */ + zoomBy(factor: number): void; + /** Translates the viewport by graph units. */ + panBy(dx: number, dy: number): void; + /** Restores the identity transform. */ + resetView(): void; + /** Fits the whole graph into the viewport width. */ + fitToView(): void; + /** Returns the current translate/scale transform. */ + getTransform(): EditorGraphTransform; + /** Removes listeners, the style block and the SVG from the container. */ + dispose(): void; +} + +/** Rounds a transform component for a stable, readable attribute string. */ +function round(value: number): number { + return Math.round(value * 1000) / 1000; +} + +/** Creates an SVG element in the SVG namespace. */ +function svgEl<K extends keyof SVGElementTagNameMap>(tag: K): SVGElementTagNameMap[K] { + return document.createElementNS(SVG_NS, tag) as SVGElementTagNameMap[K]; +} + +/** + * Lays the view model out deterministically: one column per story-line group + * (group order preserved), one row per card within the group. + * + * @param viewModel The editor view model. + * @returns Node positions, column metadata and the graph's bounding size. + */ +export function layoutEditorGraph(viewModel: EditorViewModel): EditorGraphLayout { + const positions = new Map<string, EditorGraphNodePosition>(); + const columns: EditorGraphColumn[] = []; + let maxRows = 0; + + viewModel.storylines.forEach((group, columnIndex) => { + const x = GRAPH_PADDING + columnIndex * (NODE_WIDTH + COLUMN_GAP); + maxRows = Math.max(maxRows, group.nodes.length); + group.nodes.forEach((node, rowIndex) => { + positions.set(node.id, { node, x, y: GRAPH_PADDING + rowIndex * (NODE_HEIGHT + ROW_GAP) }); + }); + columns.push({ + storylineId: group.storylineId, + title: group.title, + x, + width: NODE_WIDTH, + nodeIds: group.nodeIds, + }); + }); + + const columnCount = Math.max(1, columns.length); + const rowCount = Math.max(1, maxRows); + const width = + GRAPH_PADDING * 2 + columnCount * NODE_WIDTH + (columnCount - 1) * COLUMN_GAP + TERMINAL_STUB; + const height = GRAPH_PADDING * 2 + rowCount * NODE_HEIGHT + (rowCount - 1) * ROW_GAP; + + return { positions, columns, width, height }; +} + +/** Builds the cubic path from a node's right edge to its target (or stub). */ +function edgePath(from: EditorGraphNodePosition, to: EditorGraphNodePosition | null): string { + const startX = from.x + NODE_WIDTH; + const startY = from.y + NODE_HEIGHT / 2; + + if (!to) { + const endX = startX + TERMINAL_STUB; + return `M ${startX} ${startY} C ${startX + 32} ${startY}, ${endX - 32} ${startY}, ${endX} ${startY}`; + } + + if (to.x > from.x) { + const endX = to.x; + const endY = to.y + NODE_HEIGHT / 2; + const bend = Math.max(40, (endX - startX) / 2); + return `M ${startX} ${startY} C ${startX + bend} ${startY}, ${endX - bend} ${endY}, ${endX} ${endY}`; + } + + // Same column or backwards (a cycle): bow out to the right so the link is + // visible instead of hiding behind the cards. + const endX = to.x + NODE_WIDTH; + const endY = to.y + NODE_HEIGHT / 2; + const bend = Math.max(NODE_WIDTH, Math.abs(endY - startY) / 2) + 70; + return `M ${startX} ${startY} C ${startX + bend} ${startY}, ${endX + bend} ${endY}, ${endX} ${endY}`; +} + +/** Midpoint used to place an edge's option label. */ +function edgeLabelPoint( + from: EditorGraphNodePosition, + to: EditorGraphNodePosition | null, +): { x: number; y: number } { + const startY = from.y + NODE_HEIGHT / 2; + if (!to) { + return { x: from.x + NODE_WIDTH + TERMINAL_STUB / 2, y: startY - 8 }; + } + if (to.x > from.x) { + return { x: (from.x + NODE_WIDTH + to.x) / 2, y: (startY + to.y + NODE_HEIGHT / 2) / 2 - 8 }; + } + const bend = Math.max(NODE_WIDTH, Math.abs(to.y - from.y) / 2) + 70; + return { + x: Math.max(from.x, to.x) + NODE_WIDTH + bend * 0.72, + y: (startY + to.y + NODE_HEIGHT / 2) / 2, + }; +} + +/** The renderer's own stylesheet (scoped inside the SVG element). */ +const RENDERER_STYLES = ` +.editor-graph-svg { display: block; width: 100%; height: 100%; background: #12121c; cursor: grab; touch-action: none; } +.editor-graph-svg.is-panning { cursor: grabbing; } +.graph-column-bg { fill: #1a1a28; stroke: #2b2b44; stroke-width: 1; rx: 14px; } +.column-title { fill: #9aa0c0; font: 600 12px system-ui, -apple-system, sans-serif; letter-spacing: 0.06em; } +.column-count { fill: #6b7294; font: 11px system-ui, -apple-system, sans-serif; } +.graph-edge { fill: none; stroke: #5b6180; stroke-width: 1.6; } +.graph-edge.edge--random { stroke-dasharray: 4 4; } +.graph-edge.edge--cycle { stroke: #e05a5a; stroke-width: 2.4; stroke-dasharray: 7 4; } +.edge-label { fill: #b9c0dd; font: 11px system-ui, -apple-system, sans-serif; paint-order: stroke; stroke: #12121c; stroke-width: 3px; } +.edge-terminal { fill: #5b6180; } +.graph-node { cursor: pointer; } +.node-shape { fill: #24243a; stroke: #6f78a8; stroke-width: 1.5; } +.node--choice .node-shape { fill: #2c2440; stroke: #b98cff; stroke-width: 2; } +.node--cycle .node-shape { stroke: #e05a5a; } +.graph-node.is-selected .node-shape { stroke: #ffd166; stroke-width: 3; } +.node-name { fill: #f2f3fa; font: 600 13px system-ui, -apple-system, sans-serif; } +.node-id { fill: #9aa0c0; font: 10px ui-monospace, SFMono-Regular, monospace; } +.node-kind { fill: #b98cff; font: 9px system-ui, -apple-system, sans-serif; letter-spacing: 0.08em; } +`; + +/** + * Mounts the SVG graph renderer into a container. + * + * @param container The element to render into (its content is replaced). + * @param viewModel The render-ready view model to draw. + * @param options Optional selection callback. + * @returns The renderer handle. + */ +export function createGraphRenderer( + container: HTMLElement, + viewModel: EditorViewModel, + options: EditorGraphRendererOptions = {}, +): EditorGraphRenderer { + const layout = layoutEditorGraph(viewModel); + container.replaceChildren(); + + const svg = svgEl('svg'); + svg.setAttribute('class', 'editor-graph-svg'); + svg.setAttribute('data-role', 'graph-svg'); + svg.setAttribute('viewBox', `0 0 ${layout.width} ${layout.height}`); + svg.setAttribute('preserveAspectRatio', 'xMidYMid meet'); + svg.setAttribute('role', 'img'); + svg.setAttribute('aria-label', `Story-line graph for ${viewModel.gameId}`); + + const style = svgEl('style'); + style.textContent = RENDERER_STYLES; + svg.append(style); + + const viewport = svgEl('g'); + viewport.setAttribute('class', 'graph-viewport'); + viewport.setAttribute('data-role', 'graph-viewport'); + svg.append(viewport); + + let transform: EditorGraphTransform = { scale: 1, x: 0, y: 0 }; + const applyTransform = (): void => { + viewport.setAttribute( + 'transform', + `translate(${round(transform.x)} ${round(transform.y)}) scale(${round(transform.scale)})`, + ); + }; + applyTransform(); + + // ── Story-line columns (grouping) ────────────────────────── + const columnHeight = Math.max(NODE_HEIGHT, layout.height - GRAPH_PADDING * 2 + 40); + for (const column of layout.columns) { + const group = svgEl('g'); + group.setAttribute('class', 'graph-column'); + group.setAttribute('data-storyline-id', column.storylineId ?? ''); + group.setAttribute('data-role', 'graph-column'); + + const background = svgEl('rect'); + background.setAttribute('class', 'graph-column-bg'); + background.setAttribute('x', String(column.x - 16)); + background.setAttribute('y', String(GRAPH_PADDING - 38)); + background.setAttribute('width', String(column.width + 32)); + background.setAttribute('height', String(columnHeight + 20)); + group.append(background); + + const title = svgEl('text'); + title.setAttribute('class', 'column-title'); + title.setAttribute('x', String(column.x)); + title.setAttribute('y', String(GRAPH_PADDING - 20)); + title.textContent = column.title; + group.append(title); + + const count = svgEl('text'); + count.setAttribute('class', 'column-count'); + count.setAttribute('x', String(column.x)); + count.setAttribute('y', String(GRAPH_PADDING - 6)); + count.textContent = `${column.nodeIds.length} card${column.nodeIds.length === 1 ? '' : 's'}`; + group.append(count); + + viewport.append(group); + } + + // ── Edges (drawn under the nodes) ────────────────────────── + const edgeLayer = svgEl('g'); + edgeLayer.setAttribute('class', 'graph-edge-layer'); + edgeLayer.setAttribute('data-role', 'graph-edges'); + viewport.append(edgeLayer); + + for (const edge of viewModel.edges) { + const from = layout.positions.get(edge.from); + if (!from) continue; + const to = edge.to === null ? null : (layout.positions.get(edge.to) ?? null); + if (edge.to !== null && to === null) continue; + + const classes = ['graph-edge']; + if (edge.random) classes.push('edge--random'); + if (edge.cycle) classes.push('edge--cycle'); + if (edge.to === null) classes.push('edge--terminal'); + + const path = svgEl('path'); + path.setAttribute('class', classes.join(' ')); + path.setAttribute('d', edgePath(from, to)); + path.setAttribute('data-edge', 'true'); + path.setAttribute('data-edge-from', edge.from); + path.setAttribute('data-edge-to', edge.to ?? ''); + path.setAttribute('data-edge-label', edge.label); + path.setAttribute('data-cycle', String(edge.cycle)); + path.setAttribute('data-random', String(edge.random)); + path.setAttribute('data-candidate-index', String(edge.candidateIndex)); + edgeLayer.append(path); + + if (edge.to === null) { + const terminal = svgEl('circle'); + terminal.setAttribute('class', 'edge-terminal'); + terminal.setAttribute('cx', String(from.x + NODE_WIDTH + TERMINAL_STUB)); + terminal.setAttribute('cy', String(from.y + NODE_HEIGHT / 2)); + terminal.setAttribute('r', '4'); + edgeLayer.append(terminal); + } + + const labelPoint = edgeLabelPoint(from, to); + const label = svgEl('text'); + label.setAttribute('class', 'edge-label'); + label.setAttribute('x', String(labelPoint.x)); + label.setAttribute('y', String(labelPoint.y)); + label.setAttribute('text-anchor', 'middle'); + label.setAttribute('data-role', 'edge-label'); + label.textContent = edge.random + ? `${edge.label} (${edge.candidateIndex + 1})` + : edge.label; + edgeLayer.append(label); + } + + // ── Nodes ────────────────────────────────────────────────── + const nodeLayer = svgEl('g'); + nodeLayer.setAttribute('class', 'graph-node-layer'); + nodeLayer.setAttribute('data-role', 'graph-nodes'); + viewport.append(nodeLayer); + + const nodeElements = new Map<string, SVGGElement>(); + for (const position of layout.positions.values()) { + const { node, x, y } = position; + const classes = ['graph-node', node.hasChoices ? 'node--choice' : 'node--incident']; + if (node.inCycle) classes.push('node--cycle'); + + const group = svgEl('g'); + group.setAttribute('class', classes.join(' ')); + group.setAttribute('transform', `translate(${x} ${y})`); + group.setAttribute('data-node-id', node.id); + group.setAttribute('data-role', 'graph-node'); + group.setAttribute('data-has-choices', String(node.hasChoices)); + group.setAttribute('data-in-cycle', String(node.inCycle)); + group.setAttribute('data-storyline-id', node.storylineId ?? ''); + + if (node.hasChoices) { + const shape = svgEl('polygon'); + shape.setAttribute('class', 'node-shape'); + shape.setAttribute( + 'points', + `${NODE_WIDTH / 2},0 ${NODE_WIDTH},${NODE_HEIGHT / 2} ${NODE_WIDTH / 2},${NODE_HEIGHT} 0,${NODE_HEIGHT / 2}`, + ); + group.append(shape); + } else { + const shape = svgEl('rect'); + shape.setAttribute('class', 'node-shape'); + shape.setAttribute('width', String(NODE_WIDTH)); + shape.setAttribute('height', String(NODE_HEIGHT)); + shape.setAttribute('rx', '12'); + group.append(shape); + } + + const name = svgEl('text'); + name.setAttribute('class', 'node-name'); + name.setAttribute('x', String(NODE_WIDTH / 2)); + name.setAttribute('y', String(NODE_HEIGHT / 2 - 4)); + name.setAttribute('text-anchor', 'middle'); + name.textContent = node.name; + group.append(name); + + const id = svgEl('text'); + id.setAttribute('class', 'node-id'); + id.setAttribute('x', String(NODE_WIDTH / 2)); + id.setAttribute('y', String(NODE_HEIGHT / 2 + 12)); + id.setAttribute('text-anchor', 'middle'); + id.textContent = node.id; + group.append(id); + + const kind = svgEl('text'); + kind.setAttribute('class', 'node-kind'); + kind.setAttribute('x', String(NODE_WIDTH / 2)); + kind.setAttribute('y', String(node.hasChoices ? NODE_HEIGHT - 16 : NODE_HEIGHT - 10)); + kind.setAttribute('text-anchor', 'middle'); + kind.textContent = node.hasChoices ? `CHOICE · ${node.optionCount}` : 'INCIDENT'; + group.append(kind); + + group.addEventListener('click', () => { + selectNode(node.id); + }); + + nodeLayer.append(group); + nodeElements.set(node.id, group); + } + + container.append(svg); + + // ── Selection ────────────────────────────────────────────── + let selectedNodeId: string | null = null; + function selectNode(nodeId: string | null): void { + if (selectedNodeId !== null) { + nodeElements.get(selectedNodeId)?.classList.remove('is-selected'); + } + selectedNodeId = nodeId; + if (selectedNodeId !== null) { + nodeElements.get(selectedNodeId)?.classList.add('is-selected'); + } + options.onSelect?.(selectedNodeId); + } + + // ── Pan / zoom ───────────────────────────────────────────── + function zoomAt(factor: number, originX: number, originY: number): void { + const nextScale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, transform.scale * factor)); + const ratio = nextScale / transform.scale; + transform = { + scale: nextScale, + x: originX - (originX - transform.x) * ratio, + y: originY - (originY - transform.y) * ratio, + }; + applyTransform(); + } + + function viewportCentre(): { x: number; y: number } { + return { x: svg.clientWidth / 2 || layout.width / 2, y: svg.clientHeight / 2 || layout.height / 2 }; + } + + function zoomBy(factor: number): void { + const centre = viewportCentre(); + zoomAt(factor, centre.x, centre.y); + } + + function panBy(dx: number, dy: number): void { + transform = { ...transform, x: transform.x + dx, y: transform.y + dy }; + applyTransform(); + } + + function resetView(): void { + transform = { scale: 1, x: 0, y: 0 }; + applyTransform(); + } + + function fitToView(): void { + const width = svg.clientWidth || layout.width; + const height = svg.clientHeight || layout.height; + const scale = Math.min( + MAX_SCALE, + Math.max(MIN_SCALE, Math.min(width / layout.width, height / layout.height) * 0.94), + ); + transform = { + scale, + x: (width - layout.width * scale) / 2, + y: (height - layout.height * scale) / 2, + }; + applyTransform(); + } + + const onWheel = (event: WheelEvent): void => { + event.preventDefault(); + const rect = svg.getBoundingClientRect(); + const factor = event.deltaY < 0 ? 1.12 : 1 / 1.12; + zoomAt(factor, event.clientX - rect.left, event.clientY - rect.top); + }; + + let drag: { pointerId: number; x: number; y: number } | null = null; + + const onPointerDown = (event: PointerEvent): void => { + const target = event.target as Element | null; + if (target && typeof target.closest === 'function' && target.closest('[data-node-id]')) { + return; + } + drag = { pointerId: event.pointerId, x: event.clientX, y: event.clientY }; + svg.classList.add('is-panning'); + try { + svg.setPointerCapture(event.pointerId); + } catch { + // Synthetic pointer events (tests) have no active pointer to capture. + } + }; + + const onPointerMove = (event: PointerEvent): void => { + if (!drag || drag.pointerId !== event.pointerId) return; + panBy(event.clientX - drag.x, event.clientY - drag.y); + drag = { pointerId: drag.pointerId, x: event.clientX, y: event.clientY }; + }; + + const onPointerUp = (event: PointerEvent): void => { + if (!drag || drag.pointerId !== event.pointerId) return; + drag = null; + svg.classList.remove('is-panning'); + try { + svg.releasePointerCapture(event.pointerId); + } catch { + // Nothing captured (see onPointerDown). + } + }; + + svg.addEventListener('wheel', onWheel); + svg.addEventListener('pointerdown', onPointerDown); + svg.addEventListener('pointermove', onPointerMove); + svg.addEventListener('pointerup', onPointerUp); + svg.addEventListener('pointercancel', onPointerUp); + + return { + svg, + viewport, + layout, + get selectedNodeId() { + return selectedNodeId; + }, + selectNode, + zoomBy, + panBy, + resetView, + fitToView, + getTransform: () => ({ ...transform }), + dispose: () => { + svg.removeEventListener('wheel', onWheel); + svg.removeEventListener('pointerdown', onPointerDown); + svg.removeEventListener('pointermove', onPointerMove); + svg.removeEventListener('pointerup', onPointerUp); + svg.removeEventListener('pointercancel', onPointerUp); + nodeElements.clear(); + svg.remove(); + }, + }; +} diff --git a/tools/storyline-editor/index.html b/tools/storyline-editor/index.html new file mode 100644 index 000000000..e029176da --- /dev/null +++ b/tools/storyline-editor/index.html @@ -0,0 +1,511 @@ +<!DOCTYPE html> +<html lang="en-GB"> + <head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title>Story-line editor · Tableau Card Engine + + + + +
+ + + diff --git a/tools/storyline-editor/preview-panel.ts b/tools/storyline-editor/preview-panel.ts new file mode 100644 index 000000000..2faaf36e1 --- /dev/null +++ b/tools/storyline-editor/preview-panel.ts @@ -0,0 +1,187 @@ +/** + * Preview/diff and validation panel for the story-line editor's curated forms. + * + * Every accepted edit is previewed first: the panel renders the pending + * **diff** (only the affected rows, column by column, before → after) and the + * validator's own report — the same text/JSON the CLI renders — so the author + * sees exactly what would be written and why it is (or is not) allowed. A + * refused edit renders its validation errors instead of a diff; the form stays + * pending and nothing is written. + * + * Plain DOM, no Node, no Phaser: the panel is mounted straight into the page + * (or into a browser test document) and driven by a {@link EditPreview}. + * + * @module + */ + +import type { EditPreview, EditRowDiff } from '../../src/story-line/editor/api'; +import type { CardFormError } from '../../src/story-line/editor/field-descriptors'; +import type { EditApplyOutcome } from './bridge-client'; + +/** The mounted panel's handle. */ +export interface PreviewPanelHandle { + /** The panel's root element. */ + readonly root: HTMLElement; + /** Renders a preview (diff + validation report), optionally with an outcome. */ + render(preview: EditPreview, outcome?: EditApplyOutcome): void; + /** Renders form-level errors (those that could not be attributed to a field). */ + renderErrors(errors: readonly CardFormError[]): void; + /** Replaces the panel with a one-line status message. */ + renderMessage(message: string, tone: 'info' | 'success' | 'error'): void; + /** Empties the panel (no pending preview). */ + clear(): void; +} + +/** Creates a plain element with an optional class and text. */ +function el( + tag: K, + className?: string, + text?: string, +): HTMLElementTagNameMap[K] { + const element = document.createElement(tag); + if (className) element.className = className; + if (text !== undefined) element.textContent = text; + return element; +} + +/** Renders one affected row's diff entry. */ +function renderDiffRow( + parent: HTMLElement, + row: EditRowDiff, + order: readonly string[], +): void { + const article = el('article', 'editor-diff-row'); + article.dataset.role = 'diff-row'; + article.dataset.cardId = row.id; + article.dataset.status = row.status; + + const heading = el('h3', 'editor-diff-title'); + heading.append(el('span', 'editor-diff-card-id', row.id)); + heading.append(el('span', 'editor-diff-status', row.status)); + article.append(heading); + + const list = el('dl', 'editor-diff-list'); + const columns = [...row.changedColumns].sort((a, b) => { + const ai = order.indexOf(a); + const bi = order.indexOf(b); + return (ai < 0 ? order.length : ai) - (bi < 0 ? order.length : bi); + }); + for (const column of columns) { + const entry = el('div', 'editor-diff-change'); + entry.dataset.role = 'diff-change'; + entry.dataset.column = column; + entry.dataset.changed = 'true'; + entry.append(el('dt', 'editor-diff-column', column)); + const value = el('dd', 'editor-diff-values'); + const before = el( + 'span', + 'editor-diff-before', + row.before === null ? '(new row)' : (row.before[column] ?? ''), + ); + before.dataset.role = 'diff-before'; + const after = el( + 'span', + 'editor-diff-after', + row.after === null ? '(row removed)' : (row.after[column] ?? ''), + ); + after.dataset.role = 'diff-after'; + value.append(before, el('span', 'editor-diff-arrow', ' → '), after); + entry.append(value); + list.append(entry); + } + article.append(list); + parent.append(article); +} + +/** + * Creates the preview/diff and validation panel. + * + * @returns The panel handle. + */ +export function createPreviewPanel(): PreviewPanelHandle { + const root = el('section', 'editor-preview'); + root.dataset.role = 'preview-panel'; + + const status = el('p', 'editor-preview-status'); + status.dataset.role = 'preview-status'; + const summary = el('p', 'editor-preview-summary'); + summary.dataset.role = 'preview-summary'; + const outcomeMessage = el('p', 'editor-preview-outcome'); + outcomeMessage.dataset.role = 'apply-outcome'; + outcomeMessage.hidden = true; + + const diff = el('div', 'editor-preview-diff'); + diff.dataset.role = 'preview-diff'; + const errors = el('div', 'editor-preview-errors'); + errors.dataset.role = 'preview-errors'; + const report = el('pre', 'editor-preview-report'); + report.dataset.role = 'preview-report'; + report.hidden = true; + + root.append(outcomeMessage, status, summary, diff, errors, report); + + function clear(): void { + status.textContent = ''; + summary.textContent = ''; + outcomeMessage.textContent = ''; + outcomeMessage.hidden = true; + diff.replaceChildren(); + errors.replaceChildren(); + report.textContent = ''; + report.hidden = true; + } + + function renderErrors(entries: readonly CardFormError[]): void { + errors.replaceChildren(); + for (const entry of entries) { + const item = el('p', 'editor-preview-error', entry.message); + item.dataset.role = 'preview-error'; + item.dataset.code = entry.code; + if (entry.field) item.dataset.field = entry.field; + errors.append(item); + } + } + + function renderMessage(message: string, tone: 'info' | 'success' | 'error'): void { + clear(); + status.dataset.tone = tone; + status.textContent = message; + } + + function render(preview: EditPreview, outcome?: EditApplyOutcome): void { + clear(); + + if (outcome) { + outcomeMessage.hidden = false; + outcomeMessage.dataset.tone = outcome.ok ? 'success' : 'error'; + outcomeMessage.textContent = outcome.message; + status.textContent = outcome.ok ? 'Accepted' : 'Refused'; + status.dataset.tone = outcome.ok ? 'success' : 'error'; + } else { + status.textContent = preview.ok ? 'Valid — ready to apply' : 'Invalid — the write is blocked'; + status.dataset.tone = preview.ok ? 'success' : 'error'; + } + + const rowCount = preview.rows.length; + summary.textContent = preview.ok + ? `${rowCount} affected row${rowCount === 1 ? '' : 's'}: ${preview.affectedIds.join(', ') || '(none)'}` + : `${preview.validation.issues.length} validation error${preview.validation.issues.length === 1 ? '' : 's'}`; + + // Only a valid candidate has a diff; an invalid one shows its errors. + if (preview.ok) { + const columnOrder = preview.rows.flatMap((row) => + row.after ? Object.keys(row.after) : [], + ); + for (const row of preview.rows) renderDiffRow(diff, row, columnOrder); + } + + renderErrors( + preview.errors.map((message) => ({ field: null, code: 'validation', message })), + ); + + report.hidden = false; + report.textContent = preview.report; + } + + return { root, render, renderErrors, renderMessage, clear }; +} diff --git a/tools/storyline-editor/storyline-manager.ts b/tools/storyline-editor/storyline-manager.ts new file mode 100644 index 000000000..1c797222b --- /dev/null +++ b/tools/storyline-editor/storyline-manager.ts @@ -0,0 +1,899 @@ +/** + * Story-line management UI (F4). + * + * A story-line has no declaration of its own: it exists implicitly through the + * member cards that declare its id and title. So "managing" a story-line is + * always a **batch edit of its member cards' storyline fields**, and this panel + * is the story-line-level counterpart of the curated card forms: + * + * - **List** — every group the generated manifest reports (its `storylineIds` + * plus the members' `storylineId`/`storylineTitle`), followed by any group + * created in this session that the manifest cannot show yet because it has no + * member cards. + * - **Create** — register a new id/title group. With member cards selected the + * grouping is written immediately as one validated batch; without members it + * stays in-session until cards are moved into it (nothing to write). + * - **Rename** — retitle (and optionally re-id) a group by applying the new + * story-line fields to **every** member card in one batch. + * - **Reassign** — move the selected member cards into another group. + * + * Every operation that changes content is one {@link EditorEdit} of kind + * `set-storyline-batch`: applied in memory and validated **once** for the whole + * batch before the first write, then written **once** through the F2 bridge and + * the single transactional author API. No per-card write loop exists here, so + * renaming a four-member group can never become four author-API calls. + * + * The pure model (group derivation, draft validation, edit construction) is + * exported separately from the DOM so the operations are unit-testable without + * a browser, and the same builders drive the panel. + * + * **Session-only state.** A pending group lives in this panel's memory. The + * page remounts the shell after every accepted write (so the graph and the + * groups come from the regenerated manifest), which drops any *other* pending + * group created earlier in the session — only unwritten groups are affected, + * and writing one is what makes it durable. + * + * Plain DOM, no Node, no Phaser: the panel is mounted into the page or a + * browser-test document and talks to the F2 bridge through the injected + * {@link EditorBridge}. + * + * @module + */ + +import type { EditorEdit } from '../../src/story-line/editor/api'; +import type { EditApplyOutcome, EditorBridge } from './bridge-client'; +import { createPreviewPanel, type PreviewPanelHandle } from './preview-panel'; + +// ── Pure model ────────────────────────────────────────────── + +/** A story-line group the generated manifest reports. */ +export interface StorylineGroupSource { + /** The story-line id its member cards declare. */ + readonly storylineId: string; + /** Display title (the manifest falls back to the id when untitled). */ + readonly title: string; +} + +/** One card that can be a story-line member. */ +export interface StorylineMemberSource { + /** Base card id (chain serials stripped). */ + readonly id: string; + /** Display name. */ + readonly name: string; + /** The story-line the card declares, or null. */ + readonly storylineId: string | null; + /** The story-line title the card declares, or null. */ + readonly storylineTitle: string | null; +} + +/** A group created in the editor session (it has no manifest presence yet). */ +export interface CreatedStorylineGroup extends StorylineGroupSource { + /** True once the group's membership has been written through the bridge. */ + readonly written: boolean; +} + +/** One render-ready story-line group with its member cards. */ +export interface StorylineGroupView { + /** The story-line id. */ + readonly storylineId: string; + /** Display title. */ + readonly title: string; + /** Member cards, in manifest node order. */ + readonly members: readonly StorylineMemberSource[]; + /** + * True when the group exists only in this session: it has no member cards yet, + * so there is no content row through which it could be written. + */ + readonly pending: boolean; +} + +/** A story-line id/title draft (create or rename). */ +export interface StorylineDraft { + /** The story-line id. */ + readonly id: string; + /** The display title. */ + readonly title: string; +} + +/** Why a story-line draft is not acceptable (checked before any write). */ +export type StorylineDraftErrorCode = 'missing-id' | 'missing-title' | 'duplicate-id'; + +/** One draft validation error. */ +export interface StorylineDraftError { + /** Stable machine-readable code. */ + readonly code: StorylineDraftErrorCode; + /** Actionable message (rendered verbatim). */ + readonly message: string; +} + +/** + * Derives the rendered story-line list from the manifest's groups and members, + * plus any group created in this session. + * + * A manifest group always has members (a story-line id exists only because a + * card declares it), so a declared group that has lost every member — for + * example because a rename re-id'd all of them in this session — is dropped. + * A session-created group is kept while it is pending (nothing written) even + * with no members, so the author can create a group first and populate it next. + * + * @param groups Groups reported by the manifest. + * @param members Every card that may be a member, in manifest order. + * @param created Groups created in this editor session. + * @returns The groups to render, declared groups first. + */ +export function buildStorylineGroups( + groups: readonly StorylineGroupSource[], + members: readonly StorylineMemberSource[], + created: readonly CreatedStorylineGroup[] = [], +): StorylineGroupView[] { + const views: StorylineGroupView[] = []; + const seen = new Set(); + + const add = (group: StorylineGroupSource, pending: boolean): void => { + if (seen.has(group.storylineId)) return; + const groupMembers = members.filter( + (member) => member.storylineId === group.storylineId, + ); + // A written group exists only through its member cards; a pending group + // (created in-session, not yet written) is the one exception. + if (!pending && groupMembers.length === 0) return; + seen.add(group.storylineId); + views.push({ + storylineId: group.storylineId, + title: group.title, + members: groupMembers, + pending, + }); + }; + + for (const group of groups) add(group, false); + for (const group of created) { + add({ storylineId: group.storylineId, title: group.title }, !group.written); + } + return views; +} + +/** + * Validates a create/rename draft before it reaches the bridge. + * + * These are the authoring errors the content validator cannot report (a + * story-line has no row of its own), so they are caught here and rendered + * inline instead of being posted as an edit: + * + * - the id is required (it is how member cards declare membership); + * - the title is required (a group is always authored and displayed with one); + * - the id must be unique across the groups the editor knows about. + * + * @param draft The id/title being authored. + * @param existingIds Ids already used by other groups. + * @param options `allowId` exempts the group being renamed from the check. + * @returns The errors (empty when the draft is acceptable). + */ +export function validateStorylineDraft( + draft: StorylineDraft, + existingIds: readonly string[], + options: { readonly allowId?: string } = {}, +): StorylineDraftError[] { + const errors: StorylineDraftError[] = []; + const id = draft.id.trim(); + const title = draft.title.trim(); + + if (id === '') { + errors.push({ code: 'missing-id', message: 'A story-line id is required.' }); + } + if (title === '') { + errors.push({ code: 'missing-title', message: 'A story-line title is required.' }); + } + + const taken = existingIds + .map((existing) => existing.trim()) + .filter((existing) => existing !== options.allowId); + if (id !== '' && taken.includes(id)) { + errors.push({ + code: 'duplicate-id', + message: `Story-line id "${id}" is already used by another group.`, + }); + } + return errors; +} + +/** + * Builds the single batch edit a management operation writes. + * + * One `set-storyline-batch` edit carries every affected member card, so the + * author API validates the combined result once and writes the file once. + * + * @param memberIds The member cards to (re)assign. + * @param storylineId The target story-line id. + * @param storylineTitle The target story-line title. + * @returns The batch edit for the bridge. + */ +export function setStorylineMembersEdit( + memberIds: readonly string[], + storylineId: string, + storylineTitle: string, +): EditorEdit { + return { + kind: 'set-storyline-batch', + spec: { ids: [...memberIds], storylineId, storylineTitle }, + }; +} + +// ── DOM ───────────────────────────────────────────────────── + +/** Options for {@link createStorylineManager}. */ +export interface StorylineManagerOptions { + /** Groups reported by the generated manifest. */ + readonly groups: readonly StorylineGroupSource[]; + /** Every card that may be a story-line member (manifest nodes). */ + readonly members: readonly StorylineMemberSource[]; + /** The edit bridge (preview/validate/apply through the single author API). */ + readonly bridge: EditorBridge; + /** Called after an accepted (written) operation. */ + readonly onApplied?: (outcome: EditApplyOutcome) => void; +} + +/** The mounted manager's handle. */ +export interface StorylineManagerHandle { + /** The panel's root element. */ + readonly root: HTMLElement; + /** The shared preview/diff and validation panel. */ + readonly previewPanel: PreviewPanelHandle; + /** The rendered groups (declared first, then session-created). */ + groups(): readonly StorylineGroupView[]; + /** The member card ids currently selected. */ + selection(): readonly string[]; + /** Replaces the member selection (programmatic equivalent of ticking boxes). */ + setSelected(memberIds: readonly string[]): void; + /** Creates a group, assigning the given members in one validated write. */ + createGroup( + input: StorylineDraft & { readonly memberIds?: readonly string[] }, + ): Promise; + /** Renames/re-ids a group, applying the fields to every member in one write. */ + renameGroup( + groupId: string, + update: { readonly storylineId?: string; readonly storylineTitle?: string }, + ): Promise; + /** Moves the given member cards into an existing group in one write. */ + reassignMembers( + memberIds: readonly string[], + targetGroupId: string, + ): Promise; + /** Removes the panel and its listeners from the document. */ + dispose(): void; +} + +/** Creates a plain element with an optional class and text. */ +function el( + tag: K, + className?: string, + text?: string, +): HTMLElementTagNameMap[K] { + const element = document.createElement(tag); + if (className) element.className = className; + if (text !== undefined) element.textContent = text; + return element; +} + +/** Creates a labelled text control with a stable data role. */ +function labelledInput( + labelText: string, + role: string, + value = '', +): { readonly field: HTMLElement; readonly input: HTMLInputElement } { + const field = el('div', 'editor-field'); + const label = el('label', 'editor-field-label', labelText); + const input = el('input', 'editor-field-input'); + input.type = 'text'; + input.value = value; + input.dataset.role = role; + label.append(input); + field.append(label); + return { field, input }; +} + +/** A locally tracked group entry (the manager mutates its title/id after a write). */ +interface MutableGroup { + storylineId: string; + title: string; +} + +/** A locally tracked session-created group (mutable: it may be renamed/written). */ +interface MutableCreatedGroup extends MutableGroup { + written: boolean; +} + +/** + * Mounts the story-line management panel. + * + * @param options The manifest-derived groups/members and the edit bridge. + * @returns The manager handle. + */ +export function createStorylineManager( + options: StorylineManagerOptions, +): StorylineManagerHandle { + let declared: MutableGroup[] = options.groups.map((group) => ({ ...group })); + let members: StorylineMemberSource[] = options.members.map((member) => ({ ...member })); + let created: MutableCreatedGroup[] = []; + const selected = new Set(); + let openRenameFor: string | null = null; + let busy = false; + let disposed = false; + + const root = el('section', 'editor-storyline-manager'); + root.dataset.role = 'storyline-manager'; + + const heading = el('h3', 'editor-form-title', 'Manage story-lines'); + heading.dataset.role = 'manager-title'; + const intro = el( + 'p', + 'editor-field-hint', + 'A story-line exists through its member cards: creating, renaming and reassigning a group is one validated batch write over those cards.', + ); + + const status = el('p', 'editor-form-status', 'No story-line changes yet.'); + status.dataset.role = 'manager-status'; + const selectionSummary = el('p', 'editor-field-hint', 'No member cards selected.'); + selectionSummary.dataset.role = 'selection-summary'; + + // ── Create group ────────────────────────────────────────── + const createPanel = el('section', 'editor-storyline-create'); + createPanel.dataset.role = 'create-group-panel'; + createPanel.append(el('h4', 'editor-form-title', 'Create a group')); + const createId = labelledInput('Story-line id', 'new-storyline-id'); + const createTitle = labelledInput('Story-line title', 'new-storyline-title'); + const createFields = el('div', 'editor-storyline-inline-fields'); + createFields.append(createId.field, createTitle.field); + const createButton = el('button', 'editor-button editor-button--primary', 'Create group'); + createButton.type = 'button'; + createButton.dataset.action = 'create-group'; + const createActions = el('div', 'editor-form-actions'); + createActions.append(createButton); + createPanel.append( + createFields, + el( + 'p', + 'editor-field-hint', + 'Select member cards below to write the new group immediately; with none selected the group stays pending until cards are moved into it.', + ), + createActions, + ); + + // ── Group list ──────────────────────────────────────────── + const groupsHost = el('div', 'editor-storyline-groups'); + groupsHost.dataset.role = 'storyline-groups'; + + // ── Rename panel (static: inputs must survive a list refresh) ── + const renamePanel = el('section', 'editor-storyline-create'); + renamePanel.dataset.role = 'rename-panel'; + renamePanel.hidden = true; + const renameHeading = el('h4', 'editor-form-title', 'Rename a group'); + renameHeading.dataset.role = 'rename-heading'; + const renameId = labelledInput('Story-line id', 'rename-storyline-id'); + const renameTitle = labelledInput('Story-line title', 'rename-storyline-title'); + const renameFields = el('div', 'editor-storyline-inline-fields'); + renameFields.append(renameId.field, renameTitle.field); + const previewRenameButton = el('button', 'editor-button', 'Preview rename'); + previewRenameButton.type = 'button'; + previewRenameButton.dataset.action = 'preview-rename'; + const applyRenameButton = el('button', 'editor-button editor-button--primary', 'Rename group'); + applyRenameButton.type = 'button'; + applyRenameButton.dataset.action = 'apply-rename'; + const cancelRenameButton = el('button', 'editor-button', 'Cancel'); + cancelRenameButton.type = 'button'; + cancelRenameButton.dataset.action = 'cancel-rename'; + const renameActions = el('div', 'editor-form-actions'); + renameActions.append(previewRenameButton, applyRenameButton, cancelRenameButton); + renamePanel.append( + renameHeading, + renameFields, + el( + 'p', + 'editor-field-hint', + 'Renaming applies the new story-line id/title to every member card in one validated write.', + ), + renameActions, + ); + + const previewPanel = createPreviewPanel(); + + root.append( + heading, + intro, + status, + selectionSummary, + createPanel, + groupsHost, + renamePanel, + previewPanel.root, + ); + + // ── State helpers ───────────────────────────────────────── + + function allGroupIds(): string[] { + return [...declared.map((group) => group.storylineId), ...created.map((group) => group.storylineId)]; + } + + function currentGroups(): StorylineGroupView[] { + return buildStorylineGroups(declared, members, created); + } + + function groupView(groupId: string): StorylineGroupView | undefined { + return currentGroups().find((group) => group.storylineId === groupId); + } + + function setStatus(message: string, tone: 'info' | 'success' | 'error'): void { + status.textContent = message; + status.dataset.tone = tone; + } + + function updateSelectionSummary(): void { + const count = selected.size; + selectionSummary.textContent = + count === 0 + ? 'No member cards selected.' + : `${count} member card${count === 1 ? '' : 's'} selected.`; + } + + function applyBusy(): void { + for (const button of root.querySelectorAll('button')) button.disabled = busy; + } + + /** Records an accepted write locally, so the list matches the written content. */ + function recordAccepted( + memberIds: readonly string[], + storylineId: string, + storylineTitle: string, + ): void { + const ids = new Set(memberIds); + members = members.map((member) => + ids.has(member.id) ? { ...member, storylineId, storylineTitle } : member, + ); + + const declaredEntry = declared.find((group) => group.storylineId === storylineId); + if (declaredEntry) { + declaredEntry.title = storylineTitle; + return; + } + const createdEntry = created.find((group) => group.storylineId === storylineId); + if (createdEntry) { + createdEntry.title = storylineTitle; + createdEntry.written = true; + return; + } + created.push({ storylineId, title: storylineTitle, written: true }); + } + + // ── Write path (preview → validate → one batch apply) ───── + + async function applyBatch( + memberIds: readonly string[], + storylineId: string, + storylineTitle: string, + ): Promise { + const ids = [...new Set(memberIds)]; + if (ids.length === 0) { + setStatus('Select at least one member card first.', 'error'); + return null; + } + + const edit = setStorylineMembersEdit(ids, storylineId, storylineTitle); + busy = true; + renderGroups(); + applyBusy(); + try { + // One preview for the whole batch: the combined result is validated once + // and an invalid candidate never reaches the author API. + const preview = await options.bridge.preview(edit); + previewPanel.render(preview); + if (!preview.ok) { + setStatus( + 'Preview is invalid — the write is blocked. See the validator report for the offending card or link.', + 'error', + ); + return null; + } + + const outcome = await options.bridge.apply(edit); + previewPanel.render(preview, outcome); + if (outcome.ok) { + recordAccepted(ids, storylineId, storylineTitle); + setStatus(`Applied — ${outcome.message}`, 'success'); + options.onApplied?.(outcome); + } else { + setStatus(`Refused — ${outcome.message}`, 'error'); + } + return outcome; + } catch (error) { + setStatus(`Edit failed: ${(error as Error).message}`, 'error'); + previewPanel.renderMessage(`Edit failed: ${(error as Error).message}`, 'error'); + return null; + } finally { + busy = false; + renderGroups(); + updateSelectionSummary(); + applyBusy(); + } + } + + // ── Operations ──────────────────────────────────────────── + + async function createGroup( + input: StorylineDraft & { readonly memberIds?: readonly string[] }, + ): Promise { + if (disposed) return null; + const id = input.id.trim(); + const title = input.title.trim(); + const errors = validateStorylineDraft({ id, title }, allGroupIds()); + if (errors.length > 0) { + setStatus(errors.map((error) => error.message).join(' '), 'error'); + return null; + } + + // The group is visible immediately, even before it has members. + created.push({ storylineId: id, title, written: false }); + renderGroups(); + applyBusy(); + + const memberIds = [...new Set(input.memberIds ?? [])]; + createId.input.value = ''; + createTitle.input.value = ''; + selected.clear(); + updateSelectionSummary(); + + if (memberIds.length === 0) { + setStatus( + `Created "${title}" (${id}) in this session — select member cards and move them here to write it.`, + 'info', + ); + previewPanel.renderMessage( + 'Nothing written yet: select member cards below, then move them into this group.', + 'info', + ); + return groupView(id) ?? null; + } + + const outcome = await applyBatch(memberIds, id, title); + if (outcome?.ok) { + setStatus(`Created "${title}" (${id}) — ${outcome.message}`, 'success'); + } + return groupView(id) ?? null; + } + + async function renameGroup( + groupId: string, + update: { readonly storylineId?: string; readonly storylineTitle?: string }, + ): Promise { + if (disposed) return null; + const group = groupView(groupId); + if (!group) { + setStatus(`Unknown story-line "${groupId}".`, 'error'); + return null; + } + + const id = (update.storylineId ?? group.storylineId).trim(); + const title = (update.storylineTitle ?? group.title).trim(); + const errors = validateStorylineDraft({ id, title }, allGroupIds(), { + allowId: group.storylineId, + }); + if (errors.length > 0) { + setStatus(errors.map((error) => error.message).join(' '), 'error'); + return null; + } + if (id === group.storylineId && title === group.title) { + setStatus('No changes — the group already has that id and title.', 'info'); + return null; + } + + // A pending group has no content rows, so a rename stays in-session. + if (group.members.length === 0) { + renameLocalGroup(group.storylineId, id, title); + renderGroups(); + applyBusy(); + setStatus( + `Renamed the pending group to "${title}" (${id}) in this session — nothing to write yet.`, + 'info', + ); + previewPanel.renderMessage( + 'Nothing written yet: the group has no member cards.', + 'info', + ); + return null; + } + + const outcome = await applyBatch( + group.members.map((member) => member.id), + id, + title, + ); + if (outcome?.ok && id !== group.storylineId) { + // The old group no longer exists in the content: drop its local entry so + // the list matches the regenerated manifest. + declared = declared.filter((entry) => entry.storylineId !== group.storylineId); + created = created.filter((entry) => entry.storylineId !== group.storylineId); + renderGroups(); + applyBusy(); + } + return outcome; + } + + /** Renames a pending (unwritten) group in the session registry only. */ + function renameLocalGroup(groupId: string, id: string, title: string): void { + const createdEntry = created.find((group) => group.storylineId === groupId); + if (createdEntry) { + createdEntry.storylineId = id; + createdEntry.title = title; + return; + } + const declaredEntry = declared.find((group) => group.storylineId === groupId); + if (declaredEntry) { + declaredEntry.storylineId = id; + declaredEntry.title = title; + } + } + + async function reassignMembers( + memberIds: readonly string[], + targetGroupId: string, + ): Promise { + if (disposed) return null; + const target = groupView(targetGroupId); + if (!target) { + setStatus(`Unknown story-line "${targetGroupId}".`, 'error'); + return null; + } + + const moves = [...new Set(memberIds)].filter((memberId) => { + const member = members.find((candidate) => candidate.id === memberId); + return member !== undefined && member.storylineId !== targetGroupId; + }); + if (moves.length === 0) { + setStatus( + 'Those cards already belong to that story-line — nothing to write.', + 'info', + ); + return null; + } + + selected.clear(); + updateSelectionSummary(); + const outcome = await applyBatch(moves, target.storylineId, target.title); + if (outcome?.ok) setStatus(`Reassigned — ${outcome.message}`, 'success'); + return outcome; + } + + // ── Rendering ───────────────────────────────────────────── + + function renderGroups(): void { + groupsHost.replaceChildren(); + const groups = currentGroups(); + if (groups.length === 0) { + groupsHost.append( + el( + 'p', + 'editor-details-note', + 'No story-line groups yet — create one above, or assign a card a story-line from the card form.', + ), + ); + return; + } + for (const group of groups) groupsHost.append(renderGroup(group)); + } + + function renderGroup(group: StorylineGroupView): HTMLElement { + const article = el('article', 'editor-storyline-group'); + article.dataset.role = 'storyline-group'; + article.dataset.storylineId = group.storylineId; + article.dataset.pending = String(group.pending); + + const header = el('div', 'editor-storyline-group-header'); + const title = el('h4', 'editor-storyline-group-title', group.title); + title.dataset.role = 'storyline-group-title'; + const count = group.members.length; + const meta = el( + 'span', + 'editor-storyline-group-meta', + `${group.storylineId} · ${count} card${count === 1 ? '' : 's'}`, + ); + meta.dataset.role = 'storyline-group-meta'; + header.append(title, meta); + if (group.pending) { + const pending = el( + 'span', + 'editor-storyline-pending', + 'new — not yet written', + ); + pending.dataset.role = 'storyline-group-pending'; + header.append(pending); + } + article.append(header); + + if (count === 0) { + const note = el( + 'p', + 'editor-details-note', + 'No member cards yet — select cards from another group and move them here.', + ); + note.dataset.role = 'storyline-group-empty'; + article.append(note); + } else { + const list = el('ul', 'editor-storyline-members'); + list.dataset.role = 'storyline-members'; + for (const member of group.members) { + const item = el('li', 'editor-storyline-member'); + const label = el('label'); + const checkbox = el('input'); + checkbox.type = 'checkbox'; + checkbox.dataset.role = 'storyline-member-toggle'; + checkbox.dataset.cardId = member.id; + checkbox.checked = selected.has(member.id); + checkbox.addEventListener('change', () => { + if (checkbox.checked) selected.add(member.id); + else selected.delete(member.id); + updateSelectionSummary(); + }); + const id = el('span', 'editor-storyline-member-id', member.id); + label.append(checkbox, document.createTextNode(` ${member.name} `), id); + item.append(label); + list.append(item); + } + article.append(list); + } + + const actions = el('div', 'editor-form-actions'); + const renameButton = el('button', 'editor-button', 'Rename…'); + renameButton.type = 'button'; + renameButton.dataset.action = 'rename-group'; + renameButton.dataset.storylineId = group.storylineId; + renameButton.addEventListener('click', () => openRename(group.storylineId)); + const moveButton = el('button', 'editor-button', 'Move selected here'); + moveButton.type = 'button'; + moveButton.dataset.action = 'reassign-selected'; + moveButton.dataset.storylineId = group.storylineId; + moveButton.addEventListener('click', () => { + void reassignMembers([...selected], group.storylineId); + }); + actions.append(renameButton, moveButton); + article.append(actions); + return article; + } + + function openRename(groupId: string): void { + const group = groupView(groupId); + if (!group) return; + openRenameFor = groupId; + renameId.input.value = group.storylineId; + renameTitle.input.value = group.title; + renderRenamePanel(); + previewPanel.clear(); + } + + function renderRenamePanel(): void { + const group = openRenameFor === null ? undefined : groupView(openRenameFor); + renamePanel.hidden = group === undefined; + if (!group) return; + renameHeading.textContent = `Rename "${group.title}" (${group.storylineId})`; + } + + async function previewRename(): Promise { + if (openRenameFor === null) return; + const group = groupView(openRenameFor); + if (!group) return; + + const id = renameId.input.value.trim(); + const title = renameTitle.input.value.trim(); + const errors = validateStorylineDraft({ id, title }, allGroupIds(), { + allowId: group.storylineId, + }); + if (errors.length > 0) { + setStatus(errors.map((error) => error.message).join(' '), 'error'); + return; + } + if (group.members.length === 0) { + setStatus( + 'The group has no member cards, so there is nothing to write — rename it to keep it pending.', + 'info', + ); + previewPanel.renderMessage( + 'Nothing to write: a story-line exists through its member cards.', + 'info', + ); + return; + } + + const edit = setStorylineMembersEdit( + group.members.map((member) => member.id), + id, + title, + ); + busy = true; + applyBusy(); + try { + const preview = await options.bridge.preview(edit); + previewPanel.render(preview); + // A valid preview is readiness, not a completed write: `success` is + // reserved for an applied edit, so a caller waiting on the status tone + // can tell the two apart. + setStatus( + preview.ok + ? 'Preview is valid — rename to write it.' + : 'Preview is invalid — the write is blocked.', + preview.ok ? 'info' : 'error', + ); + } catch (error) { + setStatus(`Preview failed: ${(error as Error).message}`, 'error'); + previewPanel.renderMessage(`Preview failed: ${(error as Error).message}`, 'error'); + } finally { + busy = false; + applyBusy(); + } + } + + async function applyRename(): Promise { + if (openRenameFor === null) return null; + const groupId = openRenameFor; + const outcome = await renameGroup(groupId, { + storylineId: renameId.input.value, + storylineTitle: renameTitle.input.value, + }); + if (outcome?.ok) { + openRenameFor = null; + renderRenamePanel(); + } + return outcome; + } + + // ── Wiring ──────────────────────────────────────────────── + + const onCreate = (): void => { + void createGroup({ + id: createId.input.value, + title: createTitle.input.value, + memberIds: [...selected], + }); + }; + const onPreviewRename = (): void => { + void previewRename(); + }; + const onApplyRename = (): void => { + void applyRename(); + }; + const onCancelRename = (): void => { + openRenameFor = null; + renderRenamePanel(); + previewPanel.clear(); + }; + createButton.addEventListener('click', onCreate); + previewRenameButton.addEventListener('click', onPreviewRename); + applyRenameButton.addEventListener('click', onApplyRename); + cancelRenameButton.addEventListener('click', onCancelRename); + + renderGroups(); + updateSelectionSummary(); + + return { + root, + previewPanel, + groups: () => currentGroups(), + selection: () => [...selected], + setSelected: (memberIds) => { + selected.clear(); + for (const memberId of memberIds) selected.add(memberId); + renderGroups(); + updateSelectionSummary(); + applyBusy(); + }, + createGroup, + renameGroup, + reassignMembers, + dispose: () => { + if (disposed) return; + disposed = true; + createButton.removeEventListener('click', onCreate); + previewRenameButton.removeEventListener('click', onPreviewRename); + applyRenameButton.removeEventListener('click', onApplyRename); + cancelRenameButton.removeEventListener('click', onCancelRename); + root.remove(); + }, + }; +} diff --git a/tsconfig.json b/tsconfig.json index 48429fc44..e845c6941 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"], @@ -42,7 +44,9 @@ "example-games/**/*.ts", "tests/**/*.ts", "scripts/**/*.ts", - "vite.config.ts" + "tools/**/*.ts", + "vite.config.ts", + "vite.editor.config.ts" ], "exclude": [ "node_modules", diff --git a/vite.config.ts b/vite.config.ts index 4fbb9f8f8..cd49b34de 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -33,6 +33,9 @@ const CORE_TEST_GROUPS = new Set([ 'rule-engine', 'ai', 'core', + // Story-line engine + content/authoring toolchain + editor harness; always + // present in a core checkout and never game-owned. + 'story-line', ]); /** @@ -246,6 +249,15 @@ export default defineConfig(({ mode, command }) => ({ 'tests/golf/HelpPanel.browser.test.ts', 'tests/ui/TooltipManager.browser.test.ts', 'tests/ui/SettingsPanelTooltips.browser.test.ts', + // Story-line editor: the non-Phaser dev-page browser path + // (plain-DOM renderer modules mounted directly; see + // tests/story-line/editor/harness.browser.test.ts and the real + // viewer coverage in viewer.browser.test.ts). + 'tests/story-line/editor/harness.browser.test.ts', + 'tests/story-line/editor/viewer.browser.test.ts', + // Curated card edit forms: the real edit/add round-trip on the Gym + // fixture through the plain-DOM forms and the preview panel. + 'tests/story-line/editor/edit-roundtrip.browser.test.ts', // Main Street key E2E 'tests/main-street/MainStreetScene.browser.test.ts', 'tests/main-street/drag.browser.test.ts', diff --git a/vite.editor.config.ts b/vite.editor.config.ts new file mode 100644 index 000000000..b781bea9a --- /dev/null +++ b/vite.editor.config.ts @@ -0,0 +1,70 @@ +/** + * Dedicated Vite config for the story-line editor dev page. + * + * The editor is a **development tool**, not a game: it is a plain-DOM page + * (no Phaser, no game scenes) driven by the story-line content/author seams. + * It therefore gets its own config instead of reusing the game/build config + * (`vite.config.ts`), so: + * + * - the editor page is never pulled into the production game bundle or the + * packaged Electron app (separate `root` and `build.outDir`); + * - the game's build plugins (`virtual:game-registry` discovery, game assets, + * runtime shared chunks) are not applied to the editor; + * - the core aliases still resolve so the editor imports the engine modules + * directly. + * + * The page itself lands in the editor-host slice; this config exists from the + * harness slice onwards so the browser-test path and the dev-server wiring are + * decided up front rather than retrofitted. + * + * ```bash + * npx vite --config vite.editor.config.ts + * ``` + * + * @module + */ + +import { dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { defineConfig } from 'vite'; + +import { resolveCoreAliases } from './scripts/vite-game-discovery-plugin'; + +/** + * Absolute path of this config file's directory (the core checkout root). + * + * Derived from `import.meta.url` rather than `__dirname` so the config loads + * identically under Vite, Vitest and `tsx` (the editor host boots through + * `tsx`; ESM has no `__dirname`). + */ +const CONFIG_DIR = dirname(fileURLToPath(import.meta.url)); + +/** Directory the editor dev page is served from (repository-relative). */ +export const EDITOR_PAGE_ROOT = 'tools/storyline-editor'; + +/** Dedicated dev-server port (distinct from the game dev server's 3000). */ +export const EDITOR_SERVER_PORT = 5174; + +/** Build output for the editor page — never the game's `dist/`. */ +export const EDITOR_BUILD_OUT_DIR = 'dist/editor'; + +export default defineConfig({ + root: EDITOR_PAGE_ROOT, + base: '/', + resolve: { + // The editor talks to the same core modules as the game; the aliases are + // resolved from this checkout (CORE_ROOT is honoured for a game repo that + // composes core as a submodule). + alias: resolveCoreAliases(process.env.CORE_ROOT || CONFIG_DIR), + }, + server: { + port: EDITOR_SERVER_PORT, + open: false, + }, + build: { + outDir: EDITOR_BUILD_OUT_DIR, + emptyOutDir: true, + sourcemap: true, + }, +});