From c5c879fd2e726ec8c5457ffeb69e44496eadc9be Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Wed, 23 Sep 2026 16:54:25 +0000 Subject: [PATCH] feat: the cast on the site, and a first-run onboarding flow in the game MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Asked for: more character development on nightcell7.com, and a gameplay onboarding flow. THE CAST The story bible (PRD §7.5 to §7.7) has a supporting cast, four factions and MIRAGE, and the site showed none of them: two dossiers and nothing else. - packages/game-core/src/cast.ts holds them as data: Jonas Vale, Director Mara Vey, Colonel Arman Daryan, Silas Kade; the Nightcell Program, the Security Directorate, Orison Strategic and the International Verification Mission; MIRAGE. The site and the game both read it, so no surface can describe a person differently. - /characters: the protagonists as a matched pair, the supporting cast with faction, role and which campaign you meet them in, the factions, MIRAGE, and the fiction notice. - Each dossier gains "Who they answer to" (Vey / Daryan) and "Who they cross paths with" (Vale, Kade), the same shape for both sides. - Linked from the home page's Two perspectives section and the footer. Public copy, under the home-page timeline's rule: who these people are and what the player sees early, never what the campaigns reveal. Vale's death is already public on that timeline; who authorised the cleanup and what Nightcell 7 designates are not. cast.test.ts refuses those reveals, any Arabic-script text while Farsi awaits native review (docs/content-and-culture.md), and a Daryan written as a villain, and holds both protagonists' circles to the same size (§14.3). ONBOARDING A first-time player got every option at once and was then dropped into Team Deathmatch against four bots with no guidance. - A briefing on the deploy gate: which institution the chosen side belongs to and whose story it is, from the same cast data; what the yard is; that the two sides can never share a colour. Re-renders on a side change. - A coach in the yard that teaches one control at a time, in the order PRD §10.1 introduces systems (move, look, sprint, crouch, jump, fire, reload, switch, frag), and advances only when the player actually does the thing, never on a timer. It measures each step against a baseline, so "switch" means changing slot and "frag" means spending one; it waits while the player is dead, because a respawn resets both and would complete steps by itself; it counts turns across the +-pi wrap correctly. - Skippable from the gate. The cursor is locked in play, so a skip button in the yard could not be clicked; Esc brings the gate up and the button is there. A live region announces each step. Remembered in nc7.onboarded; a blocked storage counts as onboarded rather than nagging forever. - Logic is pure in apps/game/src/onboarding.ts, 12 tests. Also fixed: the side blurbs on the gate still said "Cyan band" and "Orange band", which #62 made wrong; the player now chooses the colour. Verified: a real build under the gameplay capture tool shows the briefing on the gate, the coach on step 1 ("W A S D · Move"), and the coach advancing to "Mouse · Look around" once the scripted player walks. /characters and both dossiers render with no horizontal overflow at 1280 or 390 px. pnpm check green, 369 tests. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/game/src/hud.ts | 77 ++++++++ apps/game/src/loadout.ts | 4 +- apps/game/src/main.ts | 39 ++++ apps/game/src/onboarding.test.ts | 183 +++++++++++++++++++ apps/game/src/onboarding.ts | 215 +++++++++++++++++++++++ apps/game/src/style.css | 133 ++++++++++++++ apps/site/app/characters/[side]/page.tsx | 37 +++- apps/site/app/characters/page.tsx | 150 ++++++++++++++++ apps/site/app/globals.css | 6 + apps/site/app/layout.tsx | 3 + apps/site/app/page.tsx | 5 + docs/prd.md | 20 +++ packages/game-core/src/cast.test.ts | 79 +++++++++ packages/game-core/src/cast.ts | 140 +++++++++++++++ packages/game-core/src/index.ts | 1 + 15 files changed, 1089 insertions(+), 3 deletions(-) create mode 100644 apps/game/src/onboarding.test.ts create mode 100644 apps/game/src/onboarding.ts create mode 100644 apps/site/app/characters/page.tsx create mode 100644 packages/game-core/src/cast.test.ts create mode 100644 packages/game-core/src/cast.ts diff --git a/apps/game/src/hud.ts b/apps/game/src/hud.ts index 476ff49..8a55092 100644 --- a/apps/game/src/hud.ts +++ b/apps/game/src/hud.ts @@ -20,7 +20,9 @@ import { armorClassInfo, type ArmoryItemId, type Loadout, + type SideId, } from "./loadout"; +import type { Briefing, CoachState } from "./onboarding"; /** * HUD and start gate. @@ -44,6 +46,14 @@ export interface HudOptions { /** Credits on hand, and the purchase sink. Returns whether the sale went through. */ credits?: number; onBuy?: (item: ArmoryItemId) => boolean; + /** + * First-run briefing for the chosen side, shown at the top of the gate until + * dismissed. Absent once the player has been onboarded. + */ + briefing?: (side: SideId) => Briefing; + onBriefingDismiss?: () => void; + /** Skip the in-yard coach. Offered on the gate while the coach is running. */ + onSkipCoach?: () => void; readonly renderer: string; readonly mapName: string; readonly mapChecksum: string; @@ -64,6 +74,8 @@ export interface Hud { setLocked(locked: boolean): void; /** Credits on hand; re-enables and disables the armory's buttons. */ setCredits(credits: number): void; + /** The coach's current step, or null to hide it. */ + setCoach(state: CoachState | null): void; dispose(): void; } @@ -196,6 +208,20 @@ export function createHud(root: HTMLElement, options: HudOptions): Hud { const notices = el("div", "notices"); hud.append(notices); + // First-run coach: one control at a time, above the reticle. A live region, + // so a screen reader announces each new step (accessibility is P0). + const coach = el("div", "coach"); + coach.hidden = true; + coach.setAttribute("role", "status"); + coach.setAttribute("aria-live", "polite"); + const coachCount = el("p", "coach__count"); + const coachLine = el("p", "coach__line"); + const coachKeys = el("kbd", "coach__keys"); + const coachAction = el("span", "coach__action"); + coachLine.append(coachKeys, coachAction); + coach.append(coachCount, coachLine, el("p", "coach__skip", "Esc, then Skip training")); + hud.append(coach); + // Blood and hit-direction arcs are created per hit and remove themselves. const spatter = el("div", "spatter"); hud.append(spatter); @@ -239,6 +265,32 @@ export function createHud(root: HTMLElement, options: HudOptions): Hud { ), ); + // First-run briefing. Who you are and what this place is, before the options. + const briefing = el("section", "briefing"); + briefing.setAttribute("aria-labelledby", "nc7-briefing-title"); + const renderBriefing = (side: SideId) => { + if (!options.briefing) { + briefing.hidden = true; + return; + } + const content = options.briefing(side); + const title = el("h2", "briefing__title", content.title); + title.id = "nc7-briefing-title"; + const dismiss = el("button", "briefing__dismiss", "Got it"); + dismiss.type = "button"; + dismiss.addEventListener("click", () => { + briefing.hidden = true; + options.onBriefingDismiss?.(); + }); + briefing.replaceChildren( + title, + ...content.lines.map((line) => el("p", "briefing__line", line)), + dismiss, + ); + }; + renderBriefing((options.loadout ?? DEFAULT_LOADOUT).side); + gate.append(briefing); + // Mode picker. // // Radios rather than buttons, because this is a choice that persists into the @@ -314,6 +366,7 @@ export function createHud(root: HTMLElement, options: HudOptions): Hud { let loadout: Loadout = options.loadout ?? DEFAULT_LOADOUT; const changeLoadout = (next: Partial) => { loadout = { ...loadout, ...next }; + if (next.side && !briefing.hidden) renderBriefing(next.side); options.onLoadoutChange?.(loadout); }; const operator = el("div", "operator"); @@ -446,6 +499,18 @@ export function createHud(root: HTMLElement, options: HudOptions): Hud { button.addEventListener("click", () => options.onStart()); gate.append(button); + // Offered only while the coach is running. The cursor is locked during play, + // so a skip control in the yard could not be clicked; Esc brings the player + // here, and here it can. + const skipTraining = el("button", "gate__skip", "Skip training"); + skipTraining.type = "button"; + skipTraining.hidden = true; + skipTraining.addEventListener("click", () => { + skipTraining.hidden = true; + options.onSkipCoach?.(); + }); + gate.append(skipTraining); + const keys = el("ul", "keys"); for (const [combo, meaning] of KEYS) { const li = el("li"); @@ -712,6 +777,18 @@ export function createHud(root: HTMLElement, options: HudOptions): Hud { refreshArmory(); }, + setCoach(state: CoachState | null): void { + const step = state?.step ?? null; + coach.hidden = step === null; + skipTraining.hidden = step === null; + if (!step || !state) return; + // Written only on change: this is called every frame. + const count = `TRAINING ${state.index + 1} / ${state.total}`; + if (coachCount.textContent !== count) coachCount.textContent = count; + if (coachKeys.textContent !== step.keys) coachKeys.textContent = step.keys; + if (coachAction.textContent !== step.action) coachAction.textContent = step.action; + }, + dispose(): void { for (const timer of noticeTimers) window.clearTimeout(timer); noticeTimers.clear(); diff --git a/apps/game/src/loadout.ts b/apps/game/src/loadout.ts index 98bafdd..7991f80 100644 --- a/apps/game/src/loadout.ts +++ b/apps/game/src/loadout.ts @@ -35,13 +35,13 @@ export const SIDES: readonly SideInfo[] = [ { id: SIDE.NIGHTCELL, name: "Nightcell", - blurb: "Irregulars holding the south of the yard. Cyan band.", + blurb: "Irregulars holding the south of the yard.", team: TEAM_IDS.NIGHTCELL, }, { id: SIDE.DIRECTORATE, name: "Directorate", - blurb: "Regulars holding the north. Orange band.", + blurb: "Regulars holding the north.", team: TEAM_IDS.DIRECTORATE, }, ]; diff --git a/apps/game/src/main.ts b/apps/game/src/main.ts index 6814721..039e4be 100644 --- a/apps/game/src/main.ts +++ b/apps/game/src/main.ts @@ -25,6 +25,7 @@ import { WeaponEffects } from "./vfx"; import { Opponents } from "./opponents"; import { createRenderer, DynamicResolution } from "./renderer"; import { buildWorld } from "./world"; +import { Coach, briefingFor, hasOnboarded, markOnboarded } from "./onboarding"; import "./style.css"; /** @@ -198,7 +199,23 @@ async function boot(): Promise { const player = new PlayerController(scene, camera, canvas, ARDAVAN_YARD, spawn); + // First run: a briefing on the gate, then a coach in the yard until the + // player has used every control once or skips it. Remembered, so a returning + // player sees neither. + const firstRun = !hasOnboarded(safeStorage()); + let coach: Coach | null = firstRun ? new Coach() : null; + const finishCoaching = () => { + coach = null; + markOnboarded(safeStorage()); + hud.setCoach(null); + }; + const hud = createHud(ui, { + ...(firstRun ? { briefing: briefingFor } : {}), + onSkipCoach: () => { + coach?.skip(); + finishCoaching(); + }, renderer: kind, mapName: ARDAVAN_YARD.displayName, mapChecksum: checksum, @@ -434,6 +451,28 @@ async function boot(): Promise { if (packs > 0) earn(packs * CREDITS_PER_PACK, "health pack"); hud.update(status, engine.getFps(), local); + + // The coach only watches while the player is actually in the yard. + if (coach && status.locked) { + const step = coach.observe({ + speed: status.speed, + grounded: status.grounded, + crouching: status.crouching, + sprinting: status.sprinting, + firing: status.firing, + yaw: camera.rotation.y, + reloading: local.reloading, + slot: local.slot, + grenades: local.grenades, + alive: local.alive, + }); + hud.setCoach(step); + if (step.finished) { + finishCoaching(); + hud.notify("Training complete · the yard is yours"); + } + } + scene.render(); }); diff --git a/apps/game/src/onboarding.test.ts b/apps/game/src/onboarding.test.ts new file mode 100644 index 0000000..4a61e03 --- /dev/null +++ b/apps/game/src/onboarding.test.ts @@ -0,0 +1,183 @@ +import { describe, expect, it } from "vitest"; +import { FACTIONS } from "@nightcell7/game-core"; +import { SIDE } from "./loadout"; +import { + COACH_STEPS, + Coach, + briefingFor, + hasOnboarded, + markOnboarded, + type CoachSnapshot, +} from "./onboarding"; + +const IDLE: CoachSnapshot = { + speed: 0, + grounded: true, + crouching: false, + sprinting: false, + firing: false, + yaw: 0, + reloading: false, + slot: 0, + grenades: 2, + alive: true, +}; + +const at = (over: Partial): CoachSnapshot => ({ ...IDLE, ...over }); + +/** Drive a coach through a list of snapshots, returning the last state. */ +function drive(coach: Coach, frames: CoachSnapshot[]) { + let state = coach.observe(IDLE); + for (const f of frames) state = coach.observe(f); + return state; +} + +describe("coach", () => { + it("teaches in the PRD's order: move first, frag last", () => { + expect(COACH_STEPS.map((s) => s.id)).toEqual([ + "move", + "look", + "sprint", + "crouch", + "jump", + "fire", + "reload", + "switch", + "frag", + ]); + }); + + it("starts on the first step and does not advance on its own", () => { + const coach = new Coach(); + for (let i = 0; i < 200; i += 1) { + const state = coach.observe(IDLE); + expect(state.step?.id).toBe("move"); + } + }); + + it("advances only when the player does the thing", () => { + const coach = new Coach(); + expect(drive(coach, [at({ speed: 3 })]).step?.id).toBe("look"); + }); + + it("counts a real turn, not a wrap past pi", () => { + const coach = new Coach(); + // Start facing just under +pi and finish "move" there, so the turn count + // begins from 3.1 rather than from a jump off yaw 0. + coach.observe(at({ yaw: 3.1 })); + expect(coach.observe(at({ yaw: 3.1, speed: 3 })).step?.id).toBe("look"); + // Crossing from just under +pi to just over -pi is a 0.08 rad turn, not 6.2. + expect(coach.observe(at({ yaw: -3.1 })).step?.id).toBe("look"); + // A real 0.9 rad turn completes it. + expect(coach.observe(at({ yaw: -2.2 })).step?.id).toBe("sprint"); + }); + + it("needs a change of weapon, not merely being on a slot", () => { + const coach = new Coach(); + drive(coach, [ + at({ speed: 3 }), + at({ yaw: 1 }), + at({ sprinting: true }), + at({ crouching: true }), + at({ grounded: false }), + at({ firing: true }), + at({ reloading: true }), + ]); + expect(coach.observe(at({ slot: 0 })).step?.id).toBe("switch"); + expect(coach.observe(at({ slot: 1 })).step?.id).toBe("frag"); + }); + + it("finishes on spending a frag, and says so exactly once", () => { + const coach = new Coach(); + drive(coach, [ + at({ speed: 3 }), + at({ yaw: 1 }), + at({ sprinting: true }), + at({ crouching: true }), + at({ grounded: false }), + at({ firing: true }), + at({ reloading: true }), + at({ slot: 1 }), + ]); + const done = coach.observe(at({ slot: 1, grenades: 1 })); + expect(done.finished).toBe(true); + expect(done.step).toBeNull(); + const after = coach.observe(at({ slot: 1, grenades: 1 })); + expect(after.finished).toBe(false); + expect(coach.complete).toBe(true); + }); + + it("waits while the player is dead instead of scoring the respawn", () => { + const coach = new Coach(); + drive(coach, [ + at({ speed: 3 }), + at({ yaw: 1 }), + at({ sprinting: true }), + at({ crouching: true }), + at({ grounded: false }), + at({ firing: true }), + at({ reloading: true }), + at({ slot: 1 }), + ]); + // Died holding slot 1 with 2 frags; respawn resets to slot 0 with 2 frags. + coach.observe(at({ alive: false, slot: 1 })); + const back = coach.observe(at({ slot: 0, grenades: 2 })); + expect(back.step?.id).toBe("frag"); + }); + + it("stops when skipped", () => { + const coach = new Coach(); + coach.skip(); + expect(coach.observe(at({ speed: 3 })).step).toBeNull(); + expect(coach.complete).toBe(true); + }); +}); + +describe("briefing", () => { + it("names the chosen side's institution from the shared cast", () => { + const nightcell = FACTIONS.find((f) => f.id === "nightcell")!; + const directorate = FACTIONS.find((f) => f.id === "directorate")!; + expect(briefingFor(SIDE.NIGHTCELL).lines[0]).toContain(nightcell.name); + expect(briefingFor(SIDE.NIGHTCELL).lines[0]).toContain("Rook"); + expect(briefingFor(SIDE.DIRECTORATE).lines[0]).toContain(directorate.name); + expect(briefingFor(SIDE.DIRECTORATE).lines[0]).toContain("Leila"); + }); + + it("gives both sides the same number of lines", () => { + expect(briefingFor(SIDE.NIGHTCELL).lines.length).toBe( + briefingFor(SIDE.DIRECTORATE).lines.length, + ); + }); +}); + +describe("persistence", () => { + function memoryStorage(): Storage { + const data = new Map(); + return { + get length() { + return data.size; + }, + clear: () => data.clear(), + getItem: (k) => data.get(k) ?? null, + key: (i) => [...data.keys()][i] ?? null, + removeItem: (k) => void data.delete(k), + setItem: (k, v) => void data.set(k, String(v)), + }; + } + + it("is not onboarded until marked", () => { + const storage = memoryStorage(); + expect(hasOnboarded(storage)).toBe(false); + markOnboarded(storage); + expect(hasOnboarded(storage)).toBe(true); + }); + + it("treats blocked storage as onboarded rather than nagging forever", () => { + const blocked = { + getItem: () => { + throw new Error("blocked"); + }, + } as unknown as Storage; + expect(hasOnboarded(blocked)).toBe(true); + }); +}); diff --git a/apps/game/src/onboarding.ts b/apps/game/src/onboarding.ts new file mode 100644 index 0000000..062649a --- /dev/null +++ b/apps/game/src/onboarding.ts @@ -0,0 +1,215 @@ +import { FACTION, faction, type FactionId } from "@nightcell7/game-core"; +import { SIDE, type SideId } from "./loadout"; + +/** + * First-run onboarding: a briefing on the deploy gate, then a coach in the yard. + * + * Pure on purpose. No DOM and no Babylon, so the whole flow is testable and + * the HUD only has to draw what this module decides. + * + * **The briefing** answers the question a first-time player cannot: who am I + * and what is this place? The sandbox is squad play in the multiplayer map, and + * the campaigns are one-person stories, so the briefing says which institution + * the chosen side belongs to and which protagonist's story it is, using the same + * cast data the website's cast page is built from. + * + * **The coach** teaches one control at a time, in the order the PRD's first + * mission introduces systems (§10.1: movement, then sprint, crouch and jump, + * then the weapon, reload, weapon switching, the frag). A step completes only + * when the player actually does the thing, never on a timer: a hint that + * disappears before the player has tried it teaches nothing. + */ + +// ------------------------------------------------------------------- storage + +const ONBOARDED_KEY = "nc7.onboarded"; + +/** True once the player has finished or skipped onboarding. */ +export function hasOnboarded(storage?: Storage): boolean { + try { + return storage?.getItem(ONBOARDED_KEY) === "1"; + } catch { + // Blocked storage: treat as onboarded rather than nagging on every visit. + return true; + } +} + +export function markOnboarded(storage?: Storage): void { + try { + storage?.setItem(ONBOARDED_KEY, "1"); + } catch { + // Nothing to do; the worst case is seeing the briefing again. + } +} + +// ------------------------------------------------------------------ briefing + +export interface Briefing { + readonly title: string; + readonly lines: readonly string[]; +} + +/** The institution each sandbox side belongs to in the story. */ +const SIDE_FACTION: Readonly> = { + [SIDE.NIGHTCELL]: FACTION.NIGHTCELL, + [SIDE.DIRECTORATE]: FACTION.DIRECTORATE, +}; + +const SIDE_STORY: Readonly> = { + [SIDE.NIGHTCELL]: "Rook's program", + [SIDE.DIRECTORATE]: "the service Leila serves", +}; + +export function briefingFor(side: SideId): Briefing { + const own = faction(SIDE_FACTION[side]); + return { + title: "First deployment", + lines: [ + `You are deploying with ${own.name}, ${SIDE_STORY[side]}. ${own.summary}`, + "This is Ardavan Yard, the multiplayer map, played as squad Team Deathmatch against bots. The campaigns are one-person stories; the yard is where you learn to fight.", + "Your squad wears the colour you choose below, and the other side is always given a colour you cannot confuse with it. Once you deploy, a short coach walks you through the controls one at a time.", + ], + }; +} + +// --------------------------------------------------------------------- coach + +/** What the coach reads each frame. Built from the controller and the simulation. */ +export interface CoachSnapshot { + readonly speed: number; + readonly grounded: boolean; + readonly crouching: boolean; + readonly sprinting: boolean; + readonly firing: boolean; + /** Camera yaw in radians. */ + readonly yaw: number; + readonly reloading: boolean; + readonly slot: number; + readonly grenades: number; + readonly alive: boolean; +} + +export interface CoachStep { + readonly id: string; + readonly keys: string; + readonly action: string; +} + +/** How far the player must turn before "look" counts, in radians. */ +const LOOK_RADIANS = 0.8; +/** Movement below this is standing still, m/s. */ +const MOVE_SPEED = 0.8; + +interface StepRule extends CoachStep { + done(now: CoachSnapshot, start: CoachSnapshot, turned: number): boolean; +} + +const STEPS: readonly StepRule[] = [ + { id: "move", keys: "W A S D", action: "Move", done: (n) => n.speed > MOVE_SPEED }, + { + id: "look", + keys: "Mouse", + action: "Look around", + done: (_n, _s, turned) => turned >= LOOK_RADIANS, + }, + { id: "sprint", keys: "Shift", action: "Sprint while moving", done: (n) => n.sprinting }, + { id: "crouch", keys: "Ctrl / C", action: "Crouch", done: (n) => n.crouching }, + { id: "jump", keys: "Space", action: "Jump", done: (n) => !n.grounded }, + { id: "fire", keys: "Left click", action: "Fire", done: (n) => n.firing }, + { id: "reload", keys: "R", action: "Reload", done: (n) => n.reloading }, + { + id: "switch", + keys: "1 2 3 · Wheel", + action: "Switch weapons", + done: (n, s) => n.slot !== s.slot, + }, + { id: "frag", keys: "G", action: "Throw a frag", done: (n, s) => n.grenades < s.grenades }, +]; + +export const COACH_STEPS: readonly CoachStep[] = STEPS.map(({ id, keys, action }) => ({ + id, + keys, + action, +})); + +export interface CoachState { + /** The step to show, or null once every step is done. */ + readonly step: CoachStep | null; + readonly index: number; + readonly total: number; + /** True on the one frame the last step completes. */ + readonly finished: boolean; +} + +/** + * Feed it a snapshot every frame; it says which step to show. + * + * Each step measures against a baseline taken when the step began, so + * "switch weapons" means *change* the slot you were in, and "throw a frag" + * means spend one of the frags you had, however the snapshot started. While + * the player is dead the coach waits instead of advancing, because a + * respawn resets ammunition and grenades and would otherwise complete steps on + * its own. + */ +export class Coach { + private index = 0; + private baseline: CoachSnapshot | null = null; + private lastYaw: number | null = null; + private turned = 0; + private done = false; + + observe(now: CoachSnapshot): CoachState { + if (this.done) return { step: null, index: STEPS.length, total: STEPS.length, finished: false }; + + if (!now.alive) { + // Re-baseline after the respawn rather than scoring it. + this.baseline = null; + this.lastYaw = null; + return this.state(false); + } + + if (!this.baseline) this.baseline = now; + + if (this.lastYaw !== null) this.turned += Math.abs(angleDelta(now.yaw, this.lastYaw)); + this.lastYaw = now.yaw; + + const rule = STEPS[this.index]!; + if (rule.done(now, this.baseline, this.turned)) { + this.index += 1; + this.baseline = now; + this.turned = 0; + if (this.index >= STEPS.length) { + this.done = true; + return { step: null, index: STEPS.length, total: STEPS.length, finished: true }; + } + } + return this.state(false); + } + + /** Stop coaching; used by the Skip control. */ + skip(): void { + this.done = true; + } + + get complete(): boolean { + return this.done; + } + + private state(finished: boolean): CoachState { + const rule = STEPS[this.index]!; + return { + step: { id: rule.id, keys: rule.keys, action: rule.action }, + index: this.index, + total: STEPS.length, + finished, + }; + } +} + +/** Shortest signed difference between two angles, so a wrap past ±π is not a full turn. */ +function angleDelta(a: number, b: number): number { + let d = a - b; + while (d > Math.PI) d -= Math.PI * 2; + while (d < -Math.PI) d += Math.PI * 2; + return d; +} diff --git a/apps/game/src/style.css b/apps/game/src/style.css index ba8c92d..7e76192 100644 --- a/apps/game/src/style.css +++ b/apps/game/src/style.css @@ -1219,3 +1219,136 @@ body { display: none; } } + +/* ------------------------------------------------------------ onboarding */ + +/* First-run briefing on the gate. Same column as the pickers below it, with a + gold rule rather than a tinted box: it is a dispatch, not an alert. */ +.briefing { + max-width: 500px; + padding: 1rem 1.2rem; + border-left: 3px solid var(--dust-gold); + background: color-mix(in srgb, var(--ink-950) 55%, transparent); + text-align: left; +} + +.briefing[hidden] { + display: none; +} + +.briefing__title { + margin: 0 0 0.5rem; + font-family: var(--font-display); + font-size: 1.05rem; + letter-spacing: 0.2em; + text-transform: uppercase; + color: var(--dust-gold); +} + +.briefing__line { + margin: 0 0 0.6rem; + font-family: system-ui, sans-serif; + font-size: 0.86rem; + line-height: 1.55; + color: #c9d6dc; +} + +.briefing__dismiss, +.gate__skip { + padding: 0.45rem 1rem; + border: 1px solid color-mix(in srgb, var(--bone-300) 45%, transparent); + border-radius: 2px; + background: transparent; + color: var(--bone-100); + font-family: var(--font-mono); + font-size: 0.68rem; + letter-spacing: 0.18em; + text-transform: uppercase; + cursor: pointer; +} + +.briefing__dismiss:hover, +.gate__skip:hover { + border-color: var(--bone-100); +} + +.briefing__dismiss:focus-visible, +.gate__skip:focus-visible { + outline: 2px solid var(--signal-cyan); + outline-offset: 3px; +} + +.gate__skip[hidden] { + display: none; +} + +/* The in-yard coach: one step at a time, above the reticle and clear of the + notices so a pickup message never covers the instruction. */ +.coach { + position: absolute; + left: 50%; + top: 22%; + transform: translateX(-50%); + display: grid; + gap: 0.35rem; + justify-items: center; + padding: 0.7rem 1.2rem 0.6rem; + border-top: 2px solid var(--dust-gold); + background: color-mix(in srgb, var(--ink-950) 72%, transparent); + text-align: center; + pointer-events: none; +} + +.coach[hidden] { + display: none; +} + +.coach__count { + margin: 0; + font-family: var(--font-mono); + font-size: 0.62rem; + letter-spacing: 0.24em; + color: var(--dust-gold); +} + +.coach__line { + display: flex; + align-items: center; + gap: 0.6rem; + margin: 0; + font-family: var(--font-display); + font-size: 1.25rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--bone-100); +} + +.coach__keys { + padding: 0.2rem 0.5rem; + border: 1px solid color-mix(in srgb, var(--bone-300) 45%, transparent); + border-radius: 2px; + font-family: var(--font-mono); + font-size: 0.78rem; + letter-spacing: 0.06em; +} + +.coach__skip { + margin: 0; + font-family: var(--font-mono); + font-size: 0.55rem; + letter-spacing: 0.16em; + color: var(--hud-dim); +} + +@media (prefers-reduced-motion: no-preference) { + .coach { + animation: coach-in 220ms cubic-bezier(0.2, 0, 0, 1); + } +} + +@keyframes coach-in { + from { + opacity: 0; + transform: translate(-50%, -6px); + } +} diff --git a/apps/site/app/characters/[side]/page.tsx b/apps/site/app/characters/[side]/page.tsx index 4e06429..e541f6a 100644 --- a/apps/site/app/characters/[side]/page.tsx +++ b/apps/site/app/characters/[side]/page.tsx @@ -1,6 +1,13 @@ import type { Metadata } from "next"; import { notFound } from "next/navigation"; -import { SIDE, missionsForSide, type SideId } from "@nightcell7/game-core"; +import { + CIRCLES, + SIDE, + castMember, + faction, + missionsForSide, + type SideId, +} from "@nightcell7/game-core"; import { PageShell } from "../../_components/page-shell"; import { CapturePlate } from "../../gallery"; @@ -111,6 +118,11 @@ export default async function CharacterPage({ params }: { params: Promise<{ side if (!dossier) notFound(); const missions = missionsForSide(dossier.side); + // From the shared cast data, so this page and the cast page cannot disagree. + // Both sides have a circle of the same shape (a test enforces it). + const circle = CIRCLES[dossier.side]; + const commander = castMember(circle.answersTo); + const crossings = circle.crosses.map(castMember); return ( @@ -124,6 +136,29 @@ export default async function CharacterPage({ params }: { params: Promise<{ side

What they find out

{dossier.discovers}

+

Who they answer to

+

+ + {commander.name} + {" "} + — {commander.role}, {faction(commander.faction).name} +

+ +

Who they cross paths with

+
    + {crossings.map((member) => ( +
  • + + {member.name} + {" "} + — {member.role}, {faction(member.faction).name} +
  • + ))} +
+

+ The full cast and the four factions +

+

How they operate

    {dossier.strengths.map((item) => ( diff --git a/apps/site/app/characters/page.tsx b/apps/site/app/characters/page.tsx new file mode 100644 index 0000000..d90d575 --- /dev/null +++ b/apps/site/app/characters/page.tsx @@ -0,0 +1,150 @@ +import type { Metadata } from "next"; +import { + FACTIONS, + MIRAGE, + SIDE, + SUPPORTING_CAST, + faction, + type SideId, +} from "@nightcell7/game-core"; +import { LeilaSigil, RookSigil } from "../art"; + +export const metadata: Metadata = { + title: "Cast", + description: + "The people and institutions of NIGHTCELL 7: FALSE DAWN. Two protagonists, the people around them, and the four factions caught in one manufactured night.", +}; + +/** + * The cast. + * + * Everything on this page comes from `@nightcell7/game-core`'s cast module, the + * same data the game's briefing reads, so the site and the game cannot describe + * a person differently. It is spoiler-safe by the same rule the home page's + * timeline follows: who these people are and what the player sees of them + * early, never what the campaigns reveal. + * + * The protagonists are shown as a matched pair, same card, same length of + * copy, in the same order the home page uses: PRD §14.3 requires that neither + * side look richer or more heroic than the other. + */ + +const PROTAGONISTS: ReadonlyArray<{ + side: SideId; + name: string; + route: string; + line: string; + dossier: string; + Sigil: typeof RookSigil; +}> = [ + { + side: SIDE.ROOK, + name: "Rook", + route: "Nightcell", + line: "An American deep-cover operative, eighteen months inside Orison's logistics network, working for a program that officially does not exist. Speaks sparingly.", + dossier: "Rook dossier", + Sigil: RookSigil, + }, + { + side: SIDE.LEILA, + name: "Leila Farzan", + route: "Countersignal", + line: "An Iranian counterintelligence officer who noticed that a set of classified signatures are impossible. Skeptical, exact, and hunting the one operative who is trying to stop the same attack.", + dossier: "Leila dossier", + Sigil: LeilaSigil, + }, +]; + +function seenInLabel(seenIn: SideId | "both"): string { + if (seenIn === "both") return "Both campaigns"; + return seenIn === SIDE.ROOK ? "Rook's campaign" : "Leila's campaign"; +} + +export default function CastPage() { + return ( + <> +
    +
    +

    Cast

    +

    Two people, one manufactured night.

    +

    + Each campaign is one person’s night, told from inside their institution. The + people below are who they answer to, who they meet, and who is working against both of + them. +

    + +
    + {PROTAGONISTS.map(({ side, name, route, line, dossier, Sigil }) => ( + + ))} +
    +
    +
    + +
    +
    +

    Around them

    +

    The people in the way.

    +

    + Nobody here is decoration. Each of them changes what one of the protagonists believes, + and none of them is who either side assumes at the start. +

    + +
    + {SUPPORTING_CAST.map((member) => ( +
    +

    {faction(member.faction).name}

    +

    {member.name}

    +

    {member.summary}

    +
    +
    Role
    +
    {member.role}
    +
    Met in
    +
    {seenInLabel(member.seenIn)}
    +
    +
    + ))} +
    +
    +
    + +
    +
    +

    Institutions

    +

    Four factions. One of them wants the war.

    + +
    + {FACTIONS.map((entry) => ( +
    +

    Faction

    +

    {entry.name}

    +

    {entry.summary}

    +
    + ))} +
    +
    +
    + +
    +
    +

    The system

    +

    {MIRAGE.name}

    +

    {MIRAGE.summary}

    +

    + NIGHTCELL 7 is fiction. Its organisations, facilities, operations and characters are + invented, and it does not depict any real government, military operation or current + event. MIRAGE is deliberately fictional and describes no real technique. +

    +
    +
    + + ); +} diff --git a/apps/site/app/globals.css b/apps/site/app/globals.css index b7970f4..61213a4 100644 --- a/apps/site/app/globals.css +++ b/apps/site/app/globals.css @@ -452,6 +452,12 @@ a { color: var(--signal-cyan); } +/* Everyone who is not a protagonist: the neutral gold of the site's + containment brackets, so no supporting card borrows either side's colour. */ +.side--cast .side__route { + color: var(--dust-gold); +} + .side h3 { font-size: clamp(1.6rem, 3.5vw, 2.4rem); } diff --git a/apps/site/app/layout.tsx b/apps/site/app/layout.tsx index bb40385..da256b6 100644 --- a/apps/site/app/layout.tsx +++ b/apps/site/app/layout.tsx @@ -132,6 +132,9 @@ export default function RootLayout({ children }: { children: React.ReactNode })
  • Leila
  • +
  • + Full cast +
  • Play in browser
  • diff --git a/apps/site/app/page.tsx b/apps/site/app/page.tsx index 8f15288..364608a 100644 --- a/apps/site/app/page.tsx +++ b/apps/site/app/page.tsx @@ -157,6 +157,11 @@ export default function HomePage() { +

    + + Meet the full cast + +

    diff --git a/docs/prd.md b/docs/prd.md index 991199c..deaf1bc 100644 --- a/docs/prd.md +++ b/docs/prd.md @@ -155,6 +155,26 @@ It stays inside the locked rules: If a sixth is ever proposed, it has to clear the same bar: a new verb, not a new stat line. +## Cast and first-run onboarding, 2026-09-23 + +**Cast.** The supporting cast (Jonas Vale, Director Mara Vey, Colonel Arman +Daryan, Silas Kade), the four factions and MIRAGE now live as data in +`packages/game-core/src/cast.ts`, taken from the story bible (PRD §7.5 to +§7.7). The site's `/characters` page and both dossiers render it, and so does +the game's first-run briefing, so no surface can describe a person differently. +It is public copy under the home-page timeline's rule: who these people are and +what the player sees of them early, never what the campaigns reveal. Tests in +`cast.test.ts` refuse the reveals (who authorised the cleanup, what Nightcell 7 +designates), any Arabic-script text while Farsi awaits native review, and a +Daryan written as a villain. Both protagonists get a circle of the same size +(§14.3). + +**Onboarding.** A first-time player gets a briefing on the deploy gate (their +side's institution and whose story it is, from the same cast data) and then a +coach in the yard that teaches one control at a time in §10.1's order and +advances only when the player does the thing. Skippable from the gate (Esc); +remembered in `nc7.onboarded`. Logic is pure in `apps/game/src/onboarding.ts`. + ## Budgets held in code Changing any of these is a product decision, not a tuning tweak. diff --git a/packages/game-core/src/cast.test.ts b/packages/game-core/src/cast.test.ts new file mode 100644 index 0000000..01d3fef --- /dev/null +++ b/packages/game-core/src/cast.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; +import { + CIRCLES, + FACTIONS, + MIRAGE, + SUPPORTING_CAST, + castMember, + faction, + type FactionId, +} from "./cast"; +import { SIDE } from "./ids"; + +/** + * The cast is public copy shared by the site and the game, so the tests guard + * the two rules that are easy to break by editing a sentence: no spoilers, and + * the content standards in docs/content-and-culture.md. + */ +const ALL_TEXT = [ + ...SUPPORTING_CAST.flatMap((m) => [m.name, m.role, m.summary]), + ...FACTIONS.flatMap((f) => [f.name, f.summary]), + MIRAGE.summary, +].join("\n"); + +describe("cast canon", () => { + it("names the four supporting characters from the story bible", () => { + expect(SUPPORTING_CAST.map((m) => m.name)).toEqual([ + "Jonas Vale", + "Director Mara Vey", + "Colonel Arman Daryan", + "Silas Kade", + ]); + }); + + it("gives every cast member a real faction", () => { + for (const member of SUPPORTING_CAST) { + expect(() => faction(member.faction as FactionId)).not.toThrow(); + } + }); + + it("resolves every name in both protagonists' circles", () => { + for (const side of [SIDE.ROOK, SIDE.LEILA]) { + const circle = CIRCLES[side]; + expect(() => castMember(circle.answersTo)).not.toThrow(); + for (const id of circle.crosses) expect(() => castMember(id)).not.toThrow(); + } + }); + + it("gives both protagonists a circle of the same size", () => { + // PRD §14.3: neither side may be presented as the richer one. + expect(CIRCLES[SIDE.ROOK].crosses.length).toBe(CIRCLES[SIDE.LEILA].crosses.length); + }); +}); + +describe("public copy rules", () => { + it("does not reveal who authorised the cleanup", () => { + expect(ALL_TEXT).not.toMatch(/approved the burn|authori[sz]ed the (burn|cleanup)/i); + expect(ALL_TEXT).not.toMatch(/ties to MIRAGE/i); + }); + + it("does not reveal what Nightcell 7 designates", () => { + expect(ALL_TEXT).not.toMatch(/Nightcell 7 (means|designates|stands for)/i); + }); + + it("ships no Farsi until native review completes", () => { + // Arabic-script block, which Persian uses. + expect(ALL_TEXT).not.toMatch(/[؀-ۿ]/); + }); + + it("never calls Daryan a villain without negating it", () => { + const daryan = castMember("daryan").summary; + expect(daryan).toMatch(/not a villain/); + }); + + it("describes MIRAGE without operational method", () => { + expect(MIRAGE.summary).not.toMatch( + /\b(exploit|payload|protocol|frequency|spoof(?:ing)? GPS)\b/i, + ); + }); +}); diff --git a/packages/game-core/src/cast.ts b/packages/game-core/src/cast.ts new file mode 100644 index 0000000..15102c5 --- /dev/null +++ b/packages/game-core/src/cast.ts @@ -0,0 +1,140 @@ +import { SIDE, type SideId } from "./ids"; + +/** + * The people and institutions of Episode 1, as canon. + * + * Shared by the marketing site (the cast page and the dossiers) and the game + * (the first-run briefing), so the two can never describe the same person + * differently. Everything here comes from the Episode 1 story bible (PRD §7). + * + * **Spoiler rule.** This is public copy. It says who each person is and what + * the player sees of them early, never what the campaigns reveal about them. + * The site already publishes the shared timeline's opening hour, so Vale's + * death at 01:16 is public; who authorised the cleanup, and what "Nightcell 7" + * designates, are not, and nothing here gives them away. + * + * **Content rule** (docs/content-and-culture.md). No Farsi strings: that + * content is pending native review. Nationality never equals enemy; the + * Directorate is written as an institution with competent, sympathetic people + * in it, and Daryan is not a villain. + */ + +export const FACTION = { + NIGHTCELL: "nightcell", + DIRECTORATE: "directorate", + ORISON: "orison", + VERIFICATION: "verification", +} as const; +export type FactionId = (typeof FACTION)[keyof typeof FACTION]; + +export interface Faction { + readonly id: FactionId; + readonly name: string; + readonly summary: string; +} + +export const FACTIONS: readonly Faction[] = [ + { + id: FACTION.NIGHTCELL, + name: "Nightcell Program", + summary: + "A compartmented, American-led multinational special-activities structure. Officially it does not exist, which is what makes it useful to the people who run it, and dangerous to the people inside it.", + }, + { + id: FACTION.DIRECTORATE, + name: "Security Directorate", + summary: + "A fictional Iranian security service, and not a monolith. Some of its officers act on orders that have been poisoned, some hunt the protagonists, and some quietly work to stop the escalation.", + }, + { + id: FACTION.ORISON, + name: "Orison Strategic", + summary: + "A multinational defense, intelligence, logistics and data contractor, and the operational center of the conspiracy. Orison profits from emergency contracts, destroyed evidence and a wider war.", + }, + { + id: FACTION.VERIFICATION, + name: "International Verification Mission", + summary: + "The small multinational mission monitoring a fragile de-escalation agreement. Most of its people know nothing about the plot. They are its intended victims.", + }, +]; + +export interface CastMember { + readonly id: string; + readonly name: string; + readonly role: string; + readonly faction: FactionId; + /** Which campaign the player mostly meets them in; both when they cross. */ + readonly seenIn: SideId | "both"; + readonly summary: string; +} + +/** The supporting cast. The two protagonists have their own dossiers. */ +export const SUPPORTING_CAST: readonly CastMember[] = [ + { + id: "vale", + name: "Jonas Vale", + role: "Rook's handler", + faction: FACTION.NIGHTCELL, + seenIn: "both", + summary: + "The only person in the theater who knows who Rook really is. Vale carries the first physical piece of MIRAGE to the meeting at Kaviran, and Orison reaches him before he can hand it over. His last words are a warning about the signal. Leila hears a fragment of it on an intercepted channel, without knowing whose voice it is.", + }, + { + id: "vey", + name: "Director Mara Vey", + role: "Nightcell authority", + faction: FACTION.NIGHTCELL, + seenIn: SIDE.ROOK, + summary: + "Rook's command voice. Vey's orders arrive over the radio, and they are always calm. She is certain that Leila is part of the plot. Whether she is right about that, or about anything else, is one of the questions the campaigns exist to answer.", + }, + { + id: "daryan", + name: "Colonel Arman Daryan", + role: "Leila's commanding officer", + faction: FACTION.DIRECTORATE, + seenIn: SIDE.LEILA, + summary: + "Leila's superior in the Directorate. He orders Rook's capture on evidence that looks conclusive, and as the night goes on his orders begin to contradict each other. Daryan is a professional being handed a lie, not a villain, and the player is never told what to make of him too early.", + }, + { + id: "kade", + name: "Silas Kade", + role: "Orison tactical director", + faction: FACTION.ORISON, + seenIn: "both", + summary: + "Runs Orison's field teams and the cleanup that follows MIRAGE. To Kade a war is a supply chain with casualties in it, and he manages it the same way. Both protagonists end up in his way, and each campaign sees him from a different side.", + }, +]; + +export const MIRAGE = { + name: "MIRAGE", + summary: + "A fictional system that manufactures battlefield attribution: forged transponder identities, altered command logs, manipulated sensor footage and synthetic voice orders. MIRAGE does not need to win a fight. It only needs each side to believe the other fired first.", +} as const; + +/** The people around each protagonist, for their dossier. Same shape per side. */ +export interface Circle { + readonly answersTo: string; + readonly crosses: readonly string[]; +} + +export const CIRCLES: Readonly> = { + [SIDE.ROOK]: { answersTo: "vey", crosses: ["vale", "kade"] }, + [SIDE.LEILA]: { answersTo: "daryan", crosses: ["vale", "kade"] }, +}; + +export function castMember(id: string): CastMember { + const member = SUPPORTING_CAST.find((m) => m.id === id); + if (!member) throw new Error(`unknown cast member: ${id}`); + return member; +} + +export function faction(id: FactionId): Faction { + const found = FACTIONS.find((f) => f.id === id); + if (!found) throw new Error(`unknown faction: ${id}`); + return found; +} diff --git a/packages/game-core/src/index.ts b/packages/game-core/src/index.ts index 186c5ba..9dbc611 100644 --- a/packages/game-core/src/index.ts +++ b/packages/game-core/src/index.ts @@ -12,5 +12,6 @@ export * from "./grenades"; export * from "./damage"; export * from "./difficulty"; export * from "./campaign"; +export * from "./cast"; export * from "./progress"; export * from "./match-rules";