feat: port all six scenarios' NPC cast rosters into the data model

Populate cast[] (kind:'npc') for every Continuum scenario, transcribed
from the gm-utility NPC sources — normalising the two schemas (console
{id,name,ess,sec} and module {name,tag,secret}) into
{ id, name, kind, note, secret? }. 57 NPCs total. Pregens/PCs excluded
(separate character-sheet system).

No cast UI consumer yet — the design reserves scenario.cast for a future
cast dashboard, so this is a data-model port guarded by
tests/scenarios/cast.test.js (shape + unique ids). 131 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
slaguru666
2026-07-20 22:18:52 +01:00
co-authored by Claude Opus 4.8
parent ce91b4ce98
commit 4096291b1d
9 changed files with 104 additions and 8 deletions
+1 -1
View File
@@ -97,7 +97,7 @@ compress if you reach a beat behind schedule.
3. **Slice 3** — dice rule-packs (CoC d100, VANITY d6-pool, Panic & Glory, Dee Sanction) + tray pack selector. ✅
4. **Slice 4** — the clue **safety-net** (essential-clue gap tracker + fallbacks). ✅
5. **Slice 5** — the convention **hub** (all slots, live "live now / up next / done", deep-links into each scenario). ✅ All **six** Continuum 2026 slots ported and scheduled with real times.
6. **Later** — remaining tray tools (break timer, parking-lot note, wake-lock); online art "Generate"; porting the scenarios' cast into the data model (clue trails done); a dedicated BRP dice pack (Day One currently borrows the d100 pack); markdown → scenario-data generator; native iPad wrapper.
6. **Later** — remaining tray tools (break timer, parking-lot note, wake-lock); online art "Generate"; a cast-dashboard UI to surface the ported `scenario.cast` rosters; a dedicated BRP dice pack (Day One currently borrows the d100 pack); markdown → scenario-data generator; native iPad wrapper.
## Docs
@@ -59,7 +59,13 @@ jsdom. No new dependencies. Fully offline.
clue `essential: true` (their source is a single curated spine); day-one and princes-bride carry
the source's own essential/optional split.
- **All six scenarios' cast rosters ported** (NPCs, `kind: 'npc'`) via `extract-cast.mjs`, normalising
the two source schemas (`{id,name,ess,sec}` consoles and `{name,tag,secret}` modules) to
`{ id, name, kind, note, secret? }`. `tests/scenarios/cast.test.js` guards shape + unique ids.
Note: the `cast` field has no UI consumer yet (the design reserves it for a future cast dashboard) —
this is a data-model port. Pregens/PCs are intentionally excluded (separate character-sheet/handout system).
## Still deferred (backlog)
- Porting each scenario's **cast** into the data model (still `cast: []`).
- A **cast dashboard** UI to surface `scenario.cast` (parallels `<clue-net>`).
- A dedicated **BRP** dice pack — Day One currently borrows `coc-d100` (d100 roll-under).
+11 -1
View File
@@ -16,6 +16,16 @@ export default {
{ id: 'c5', label: 'Pier + dawn deadline known before 2:45', essential: true, fallback: 'both Act Two tracks carry it' },
{ id: 'c6', label: 'Whisper-9 is stolen Wallace property — why Corvin has legal cover', essential: true },
],
cast: [],
cast: [
{ id: 'cmdr-bryant', name: 'Cmdr. Bryant', kind: 'npc', note: 'RDU commander — ally, guarded', secret: 'If asked to expedite the IDENT CONFLICT desk review: “Can’t. Wallace liaison put a hold on all RDU record reviews tonight. Funny, that.”' },
{ id: 'dr-avis', name: 'Dr. Avis', kind: 'npc', note: 'Coroner — friendly' },
{ id: 'wren-unit-a-1', name: 'Wren (unit A-1)', kind: 'npc', note: 'The runner — flight, then fragile trust', secret: 'If she escapes: origami sparrow under the wiper — YOU WANT THE TRUTH. THE PIER. DAWN.' },
{ id: 'lark-unit-a-2', name: 'Lark (unit A-2)', kind: 'npc', note: 'The still one — cooperative, composed', secret: 'Under the grief: relief at being found out, without knowing for what.' },
{ id: 'aris-kade', name: 'Aris Kade', kind: 'npc', note: 'The original — 38, exhausted beyond fear', secret: 'Truth-ending lever: “Say something. One of you. They’re your witnesses too.”' },
{ id: 'corvin', name: 'Corvin', kind: 'npc', note: 'Wallace Asset Protection — a deadline in a good coat', secret: 'His warrant covers units and media ONLY — no paper on a human. A human homicide collapses his pretext. He will trade. Even his defeats are tidy.' },
{ id: 'ap-officers-2', name: 'AP Officers ×2', kind: 'npc', note: 'Follow the warrant, not the man' },
{ id: 'street-extras-improv', name: 'Street extras (improv)', kind: 'npc', note: 'Off-script encounters', secret: 'Fortune-teller line, aimed at a replicant PC: “Two birds with one song. The cage is open, officer. Whose hand closed it?”' },
{ id: 'auntie-bo', name: 'Auntie Bo', kind: 'npc', note: 'Night-market broth queen — stall 44', secret: 'Will never sell Wren out — a phone call ahead only means Wren is already spooked.' },
],
props: [],
};
+10 -1
View File
@@ -22,6 +22,15 @@ export default {
{ id: 'c6', label: 'THE BIRDS STOP — the gear change, marked plainly (SAN 0/1D2)', essential: true },
{ id: 'c7', label: 'While 3+ singers hold the note, the console alone cannot stop it', essential: true },
],
cast: [],
cast: [
{ id: 'esc', name: 'COMANDANTE ESCORPIÓN', kind: 'npc', note: 'Strongman, half-transformed; mirror shades over nictitating membranes' },
{ id: 'volkova', name: 'COL. KATYA VOLKOVA', kind: 'npc', note: 'Soviet handler — the hinge, not the villain. Carries the launch keys', secret: 'From the turn she counts as a pulp ally and shoots like one. Superb chopper-door face for the final frame.' },
{ id: 'padre', name: 'PADRE MARISCO', kind: 'npc', note: 'Salt-cured priest of the Trench; conducts rather than fights' },
{ id: 'singers', name: 'STONE-SINGERS', kind: 'npc', note: 'The note does not stop for breath — or bullets, entirely' },
{ id: 'willis', name: 'AGENT B. WILLIS', kind: 'npc', note: 'Ally from the cells — barefoot, vest, mouth running' },
{ id: 'segal', name: 'AGENT S. SEGAL', kind: 'npc', note: 'Silk robe, herbal tea, whale-song — hypno-conditioned, not a traitor', secret: 'Never fixed? He wanders toward the volcano "for the ceremony" at the worst moment. Comedy becomes stakes.' },
{ id: 'mooks', name: 'RED TIDE MOOKS', kind: 'npc', note: 'They miss, gloriously. They drop from one hit. That is their whole job' },
{ id: 'novia', name: 'LA NOVIA — THE BRIDE', kind: 'npc', note: 'Never statted. Some things are endings, not encounters' },
],
props: [],
};
+9 -1
View File
@@ -28,6 +28,14 @@ export default {
{ id: 'c11', label: 'Pete’s radio confirms the north-bank cordon — closes the loop.', essential: false, act: 'Act Three' },
{ id: 'c12', label: 'The RIB’s fuel gauge — check it before trusting the escape route.', essential: false, act: 'Act Three' },
],
cast: [],
cast: [
{ id: 'fatima', name: 'FATIMA BALOGUN', kind: 'npc', note: 'Market stall owner — first competent adult voice' },
{ id: 'callum', name: 'PC CALLUM FRASER', kind: 'npc', note: 'Met constable, 28 — institution failing around him', secret: 'Heard "first reports… Elephant… 07:14" before comms went patchy. Hasn’t told anyone. When the cordon fragment lands, play his face first.' },
{ id: 'sharif', name: 'DR YASMIN SHARIF', kind: 'npc', note: 'Off-duty A&E consultant — will name what this is', secret: 'She wants to reach St Thomas’ — 2.5 km west. She is wrong. The state of Guy’s (visible from the car park) is the counter-argument. Losing her costs the group its best medic.' },
{ id: 'marcus', name: 'MARCUS', kind: 'npc', note: '19, livestreaming to 41,000 — walking liability', secret: 'Phone at 31%. When it dies, he’s just a scared 19-year-old — that’s a beat, not a punishment. Chat reports military massing at London Bridge north approach.' },
{ id: 'helen', name: 'HELEN', kind: 'npc', note: '"I’m sure it’s a gas leak." The bravest person here', secret: 'Thirty years Cabinet Office. "There’s a protocol for this. If they haven’t activated it, something went wrong before the alert." Names Meridian if trusted. THE MOMENT: once, she steps into danger for someone else. Play it straight. Recommended reanimation victim.' },
{ id: 'pete', name: 'PETE', kind: 'npc', note: 'Bridge Group foreman — triage with no good options', secret: 'Knows the cordon is real (someone in his group has a radio). Hasn’t told his people. Has already turned away two infected and dealt with the consequences — he is broken, not cruel.' },
{ id: 'minors', name: 'MINORS — Gordon · Danny · Declan & Ryan · Bridge Group', kind: 'npc', note: 'Stat as minors', secret: 'DANNY: bitten 40 min before the pub; jacket over the arm; turns if the group stays ~30 min — Sharif spots him in 5. RYAN is also bitten (blood on sleeve) — post-alert bites already symptomatic.' },
],
props: [],
};
+14 -1
View File
@@ -29,6 +29,19 @@ export default {
{ id: 'c13', label: 'The Bright Lady’s claim & terms-space (the stones at dusk; else she attends the trial).', essential: true, act: 'Act Two — The Deeper Dark' },
{ id: 'c14', label: 'Garratt’s price: “Give me evidence I can cite, and I’ll happily be the man who saved an innocent.”', essential: false, act: 'Act Two — The Deeper Dark' },
],
cast: [],
cast: [
{ id: 'margaret-patterson', name: 'Margaret Patterson', kind: 'npc', note: 'the accused — innocent of the charge; woven with the uncanny', secret: 'Meadowsweet follows her. The Bright Lady calls her “little candle”.' },
{ id: 'father-murphy-mcshay', name: 'Father Murphy McShay', kind: 'npc', note: 'a wound preaching' },
{ id: 'witch-finder-garratt', name: 'Witch Finder Garratt', kind: 'npc', note: 'a professional waiting to see what pays' },
{ id: 'mary-fletcher', name: 'Mary Fletcher', kind: 'npc', note: 'grief stitched into spite, waiting to be caught', secret: 'Fallback: she breaks down publicly at dusk prayers.' },
{ id: 'count-drago-karnkowski', name: 'Count Drago Karnkowski', kind: 'npc', note: 'brave, loyal, out of his depth' },
{ id: 'thomas-patterson', name: 'Thomas Patterson', kind: 'npc', note: 'a father bargaining with fate' },
{ id: 'eleanor-patterson', name: 'Eleanor Patterson', kind: 'npc', note: 'a mother afraid of her own daughter' },
{ id: 'alderman-vane', name: 'Alderman Vane', kind: 'npc', note: 'order first, mercy where affordable' },
{ id: 'william-mcshay', name: 'William McShay', kind: 'npc', note: 'a boy drowning in his uncle’s certainty' },
{ id: 'roger-dallam', name: 'Roger Dallam', kind: 'npc', note: 'knows everything, risks nothing' },
{ id: 'the-bright-lady', name: 'The Bright Lady', kind: 'npc', note: 'not evil — alien, proprietary, patient', secret: 'First sight: Unravelling roll, every witness. No roll wins the parley. If no bargain: she curtseys, withdraws, and the claim stays OPEN.' },
{ id: 'agatha-thorne-d-2-yrs', name: 'Agatha Thorne (d. 2 yrs)', kind: 'npc', note: 'the last keeper of the courtesies' },
],
props: [],
};
+14 -1
View File
@@ -23,6 +23,19 @@ export default {
{ id: 'c7', label: 'THE ALMANAC — new moon on the 21st; no October eclipse; Nov 4’s real one invisible in America', essential: true },
{ id: 'c8', label: 'The False Moon’s first rise — a full moon in a sky that has none (SAN 0/1)', essential: true },
],
cast: [],
cast: [
{ id: 'mary', name: 'MARY FLETCHER', kind: 'npc', note: 'Innkeeper; saw the watchers; knows the sheriff’s ring', secret: 'She sewed nothing — but she saw Crowe sew: the pendant in the mattress is findable because Mary remembers him doing it.' },
{ id: 'braddock', name: 'SHERIFF BRADDOCK', kind: 'npc', note: 'The law, in the cult’s ring — coerced, ashamed', secret: 'HE WIRES CHICAGO TONIGHT regardless. If the line is cut — the wire already went, or a rider goes. The countdown never depends on the players failing.' },
{ id: 'danny', name: 'DANNY TATE', kind: 'npc', note: 'Stable boy, 14; saw the midnight taking' },
{ id: 'tom', name: 'WHISKEY TOM GRAVES', kind: 'npc', note: 'Last old miner; the 1861 collapse, once removed' },
{ id: 'shambler', name: 'THE MINE GUARDIAN', kind: 'npc', note: 'Dimensional Shambler — abducts, not kills' },
{ id: 'enforcers', name: 'CULT ENFORCERS', kind: 'npc', note: 'Dark coats, silver rings, capture-first' },
{ id: 'thrall', name: 'THE CAPTURED THRALL', kind: 'npc', note: 'Branded, broken, grateful — confirms everything' },
{ id: 'byakhee', name: 'BYAKHEE', kind: 'npc', note: 'Answers the broken lake-rite out of the fog' },
{ id: 'dole', name: 'EPHRAIM DOLE', kind: 'npc', note: 'Survivor’s son; dynamite, the back adit, will not enter' },
{ id: 'dunworthy', name: 'SILAS DUNWORTHY', kind: 'npc', note: 'Industrialist, believer, intended vessel — occupied at the last', secret: 'THE FINAL FORM (art exists — show it): at the apex the Aspect arrives AS him. His eternity, delivered as occupancy. SAN 1/1D8. Cap: no investigator dies without a warned, failed roll.' },
{ id: 'crowe', name: 'DR. EZEKIEL CROWE', kind: 'npc', note: 'The client — alive, on the altar, the best-informed man in the room' },
{ id: 'aspect', name: 'THE FALSE MOON / LUNAR ASPECT', kind: 'npc', note: 'Nyarlathotep as the light that watches', secret: 'When the rite breaks: the crossing shadow COMPLETES, the light goes out like a struck lamp, and the true moonless sky returns. Give it silence.' },
],
props: [],
};
+11 -1
View File
@@ -21,6 +21,16 @@ export default {
{ id: 'c5', label: 'The crown’s bargain: +1 Charm forever · Vice: Pride · a standing Bane no humility clears', essential: true },
{ id: 'c6', label: 'Cassian’s plea lands: “vain, not evil — it matters” (shapes the reburial ending)', essential: true },
],
cast: [],
cast: [
{ id: 'reeve', name: 'REEVE PENNYWHISTLE', kind: 'npc', note: 'Velvet cap he can’t afford; wants it done quietly', secret: 'He hopes they bring the crown TO HIM. If anyone wears it before him — he kneels. First. Play it horrible.' },
{ id: 'marrow', name: 'GOODWIFE MARROW', kind: 'npc', note: 'The Drowned Duck; knows every rumour in three parishes', secret: 'She is the fallback for every missed Act One fact — she saw the dawn ride from her window.' },
{ id: 'cassian', name: 'BROTHER CASSIAN', kind: 'npc', note: 'Old, kind, quietly terrified — the conscience' },
{ id: 'stranger', name: 'THE PALE STRANGER', kind: 'npc', note: 'Vesper’s watcher in pilgrim’s clothes — too clean, too calm', secret: 'HARD TRIGGER: at 1:00 he bolts and blows the whistle whatever is happening. The act break never depends on the players.' },
{ id: 'robbers', name: 'GRAVE-ROBBERS ×4 (+wolf-handler)', kind: 'npc', note: 'Hired blades, owed money, scared of the pale one' },
{ id: 'wolves', name: 'DIRE WOLVES', kind: 'npc', note: 'Vesper’s sentry-beasts — one on the green, three in the wood' },
{ id: 'crypt', name: 'THE CRYPT — 6 SKELETONS + 2 GHOULS (+1 wanderer)', kind: 'npc', note: 'The king’s servants, still serving' },
{ id: 'vesper', name: 'VESPER KOUND', kind: 'npc', note: 'Scholar-turned-grave-robber; wants to be rich, then admired for it', secret: 'If he lives, he sends a polite, infuriating letter of thanks (Props tab). Read it in the epilogue.' },
{ id: 'aldric', name: 'KING ALDRIC — THE VAIN CORPSE', kind: 'npc', note: 'Nemesis. Wants only to be witnessed', secret: 'PHASE 2 AT GRIT 7 (telegraph a round ahead — "the crown begins to blaze"): Aura −1 die to all who ever failed it; he stops defending and attacks twice.' },
],
props: [],
};
+27
View File
@@ -0,0 +1,27 @@
import { describe, it, expect } from 'vitest';
import continuum from '../../src/con/continuum-2026.js';
import { getScenario } from '../../src/scenarios/index.js';
const scenarios = continuum.slots.map((s) => getScenario(s.scenarioId));
describe('ported cast rosters', () => {
it('every Continuum scenario has a non-empty cast', () => {
for (const sc of scenarios) {
expect(sc.cast.length, sc.meta.id).toBeGreaterThan(0);
}
});
it('cast entries are well-formed with unique ids and a valid kind', () => {
for (const sc of scenarios) {
const ids = sc.cast.map((c) => c.id);
expect(new Set(ids).size, sc.meta.id + ' unique ids').toBe(ids.length);
for (const c of sc.cast) {
expect(typeof c.id, sc.meta.id).toBe('string');
expect(c.id.length, sc.meta.id).toBeGreaterThan(0);
expect(typeof c.name, sc.meta.id).toBe('string');
expect(c.name.length, sc.meta.id).toBeGreaterThan(0);
expect(['pc', 'npc'], sc.meta.id + ' ' + c.id).toContain(c.kind);
}
}
});
});