diff --git a/docs/superpowers/plans/2026-07-26-fun-while-dying-slice3-demise-scorecard.md b/docs/superpowers/plans/2026-07-26-fun-while-dying-slice3-demise-scorecard.md new file mode 100644 index 0000000..7b16b87 --- /dev/null +++ b/docs/superpowers/plans/2026-07-26-fun-while-dying-slice3-demise-scorecard.md @@ -0,0 +1,101 @@ +# Slice 3 — Demise scorecard Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development. Steps use checkbox (`- [ ]`) syntax. + +**Goal:** Make the Winners screen honest and turn death into the reward — reframe the pyrrhic "X WINS" lie to an honest-deadpan headline, and add comedic "how you died" awards derived from the game log. UI + one pure module; no engine change. + +**Architecture:** New pure `src/ui/util/demise.ts` derives awards + a human epitaph from `game.log` + final `leaders` + `outcome`. `Winners.tsx` consumes it: honest pyrrhic headline, an Awards panel, a human epitaph line. `checkOutcome`/`WinOutcome`/`scoreState` untouched. + +**Tech Stack:** React 18 + TS + CSS modules, vitest. + +**Spec:** `docs/superpowers/specs/2026-07-26-fun-while-dying-slice3-demise-scorecard-design.md` + +## Global Constraints + +- Every commit: `npm run typecheck` clean AND `npm run test:run` green. Unconditional test assertions. +- No engine win-condition/scoring change. Deterministic derivation (no RNG/Date). +- Product emojis kept; award copy in the satirical voice, no religious markers. + +## Guardrails (better-memory) + +- Derive elimination ORDER from the sequence of `LeaderEliminated` events in the chronological cumulative `game.log`; do NOT invent round numbers (the engine doesn't expose per-round data at match end). +- Superlatives need deterministic tie-breaks (cast order) or tests flake. +- Winners.test.tsx uses a `makeState(outcome)` helper — the awards panel must render gracefully when `game.log` is sparse/empty (outcome-based awards only); confirm the helper's log shape before asserting award rows. + +## File Structure + +``` +src/ui/util/demise.ts (new: Award type, deriveAwards, humanDemiseLine) +src/ui/screens/Winners.tsx (modify: pyrrhic headline; Awards panel; human epitaph) +src/ui/screens/Winners.module.css(modify: minimal award-row + highlight styles) +tests/ui/demise.test.ts (new: unit tests, synthetic logs) +tests/ui/Winners.test.tsx (modify: pyrrhic headline honest; awards panel renders) +``` + +--- + +### Task 1: `demise.ts` derivation module + +**Files:** Create `src/ui/util/demise.ts`; Test `tests/ui/demise.test.ts`. + +**Interfaces produced:** +- `export interface Award { title: string; leaderId: LeaderId; detail: string }` +- `export function deriveAwards(game: GameState, initialPopulations: Partial>): Award[]` +- `export function humanDemiseLine(game: GameState, initialPopulations: Partial>, humanId: LeaderId): string` + +- [ ] **Step 1: Write failing tests** — `tests/ui/demise.test.ts`. Build minimal `GameState` objects (reuse the shape from `initialState` or a local factory) with a crafted `log: ResolutionEvent[]`, `leaders`, `cast`, `outcome`. Cover: + - DEADLIEST → leader with highest summed `ImpactPeople.deaths` as `from`. + - BIGGEST BANG → leader who landed the largest-yield impact (large > medium > small). + - TRIGGER HAPPY → most `MissileLaunched` as `from`. + - DIED FIRST → `id` of the first `LeaderEliminated` in log order. + - LAST TO FALL → `outcome.winner` for a pyrrhic outcome; LAST ONE STANDING for survivor; neither present for apocalypse. + - Tie-break: two leaders equal on a metric → award goes to the earlier one in `game.cast`. + - Omission: no `MissileLaunched` in log → no TRIGGER HAPPY award; empty log + apocalypse → no log-derived awards (array may be empty or outcome-only). + - `humanDemiseLine`: returns a string containing the human's fate for (a) survived, (b) eliminated, (c) pyrrhic last-to-fall, referencing pop lost + hits. + Assertions unconditional (assert the exact `leaderId` per award; guard "award present" with a find + `toBeDefined()` before reading it). + +- [ ] **Step 2: Run — expect FAIL** (`npx vitest run tests/ui/demise.test.ts`). + +- [ ] **Step 3: Implement `demise.ts`.** Single pass over `game.log` accumulating per-leader: `deathsCaused` (Σ ImpactPeople.deaths from=leader), `biggestYield` (max yield of any impact from=leader, rank large=3/medium=2/small=1), `launches` (MissileLaunched from=leader), `hitsLanded`, `hitsTaken` (impacts with target=leader); and `eliminationOrder` = LeaderEliminated ids in encounter order. Build awards with a small helper `superlative(metricMap, {title, detailFn})` that picks the max with cast-order tie-break and returns undefined if the max is 0/none; push only defined awards. Add the outcome-based LAST TO FALL / LAST ONE STANDING. `humanDemiseLine` composes fate from `leaders[humanId].alive`, `outcome`, initial vs final pop, hitsLanded/Taken. Keep copy deadpan-satirical. + +- [ ] **Step 4: Run — expect PASS.** Then `npm run typecheck`. + +--- + +### Task 2: Winners.tsx — honest headline + awards + epitaph + +**Files:** Modify `src/ui/screens/Winners.tsx`, `Winners.module.css`; Test `tests/ui/Winners.test.tsx`. + +- [ ] **Step 1: Update tests first** — in `tests/ui/Winners.test.tsx`: add a pyrrhic case asserting the headline is the honest-deadpan form and is NOT `"… WINS"` (e.g. `getByText(/LAST TO FALL/i)`, and `queryByText(/WINS/)` for the pyrrhic winner is absent from the headline); assert an Awards panel/section renders. Keep the survivor "CARNAGE WINS", apocalypse, and death-toll tests. If `makeState` produces an empty `game.log`, ensure the pyrrhic case still shows LAST TO FALL (outcome-based) and the test doesn't depend on log-derived awards. Unconditional asserts. + +- [ ] **Step 2: Run — expect FAIL.** + +- [ ] **Step 3: Implement Winners.tsx:** + - `pickHeadline`: `case 'pyrrhic': return 'LAST TO FALL: ' + leaders[outcome.winner].name.toUpperCase();` (survivor + apocalypse unchanged). + - Optionally set the pyrrhic hero Stamp text to "LAST TO FALL" (keep magenta). + - Compute `const awards = deriveAwards(game, state.initialPopulations);` and `const epitaph = humanDemiseLine(game, state.initialPopulations, 'player1');`. + - Render `epitaph` under the hero subline (a `.epitaph` line). + - Add an `` above Death Toll listing `awards`: each row = title, `` + name (+ " (you)" for human), detail; add `styles.awardRow` and a `styles.awardMine` highlight when `isHuman(award.leaderId)`. If `awards` is empty, omit the panel. + - Imports: `deriveAwards`, `humanDemiseLine` from `../util/demise`. + +- [ ] **Step 4: Minimal CSS** in `Winners.module.css` — `.awardRow` (flex, ink-on-paper like deathRow), `.awardTitle`, `.awardMine` (magenta accent), `.epitaph` (italic subline). Match existing panel/table styling. + +- [ ] **Step 5: Full verify** — `npm run typecheck` clean; `npm run test:run` green. + +- [ ] **Step 6: Visual check** — `npm run dev`, reach a pyrrhic and a survivor ending (dev-nav or play); confirm honest headline + awards + human epitaph read well. (Sanity, not a gate.) + +- [ ] **Step 7: Commit** (single commit, branch `feat/demise-scorecard`): + +```bash +git add src/ui/util/demise.ts src/ui/screens/Winners.tsx src/ui/screens/Winners.module.css tests/ui/demise.test.ts tests/ui/Winners.test.tsx +git commit -m "feat(ui): honest demise scorecard — deadpan pyrrhic headline + comedic awards" +``` + +--- + +## Self-Review Notes + +- Spec coverage: §2 module → Task 1; §3 Winners → Task 2; §4 tests → both. +- No engine touch — `checkOutcome`/`WinOutcome`/`scoreState` untouched; hard-mode lookahead unaffected; survivor + apocalypse framing preserved; only the pyrrhic lie is corrected. +- Determinism (cast-order tie-break) is the flake risk — pinned in Task 1 tests. +- The only cross-file dependency is Winners → demise; the module ships first (Task 1) so Task 2 compiles against real exports. diff --git a/docs/superpowers/specs/2026-07-26-fun-while-dying-slice3-demise-scorecard-design.md b/docs/superpowers/specs/2026-07-26-fun-while-dying-slice3-demise-scorecard-design.md new file mode 100644 index 0000000..2df578c --- /dev/null +++ b/docs/superpowers/specs/2026-07-26-fun-while-dying-slice3-demise-scorecard-design.md @@ -0,0 +1,56 @@ +# nuke — "fun while dying" slice 3: score how you died + +**Date:** 2026-07-26 +**Status:** approved in brainstorming; pending spec review +**Design frame:** The playability assessment's core legibility failure (C1): a pyrrhic "winner" is just the last leader to die, so the Winners screen crowns a corpse with "X WINS." This slice makes the endgame *honest* and turns the death into the reward — the "fun while dying" payoff at match end. Slice 3 of 3 (slices 1 & 2 shipped, PRs #16/#17). + +## 0. Decisions (locked in brainstorming) + +- **Comedic awards, no fake number.** Assign tongue-in-cheek superlative titles to leaders, derived from the game log. No composite score, no phony leaderboard — celebrating the death, not inventing a winner. +- **Honest-deadpan pyrrhic headline.** Replace the pyrrhic "X WINS" with a flat statement of the truth (e.g. "LAST TO FALL: X"); the joke is that *this* is the win. +- **UI + a pure derived module only. No engine change** — `checkOutcome` / `WinOutcome` / `scoreState` untouched, so hard-mode lookahead stays stable. + +## 1. What's already honest vs the lie + +`Winners.tsx` already shows an honest Death Toll table (START / END / % LOST / SURVIVED-or-ELIMINATED) and an honest apocalypse headline ("WINNER: NOBODY"). The **survivor** outcome (one leader alive, rest dead) is a legitimate win — "X WINS" stays. The single lie is the **pyrrhic** headline `${winner} WINS` when everyone, including the "winner", is dead. That's the one reframe; the rest of the slice adds the awards layer. + +## 2. Derived module — `src/ui/util/demise.ts` (pure, tested) + +`export function deriveAwards(game: GameState, initialPopulations): Award[]` where `Award = { title: string; leaderId: LeaderId; detail: string }`. All awards derive from the cumulative `game.log` (all `ResolutionEvent`s of the match, chronological) + final `game.leaders` + `game.outcome`. Superlative = one winner per category; ties broken by `game.cast` order (deterministic). Omit an award if no leader qualifies (e.g. nobody launched). + +Award set (all robustly derivable — no round-boundary data needed): +- **LAST ONE STANDING** (survivor outcome) / **LAST TO FALL** (pyrrhic outcome) → `outcome.winner`; detail notes they outlasted the rest (by a round, for pyrrhic). Omitted for apocalypse. +- **DIED FIRST** → the first `LeaderEliminated` event in log order. Detail: deadpan (e.g. "Set the tone. The tone was 'dead'."). +- **DEADLIEST** → max total people killed = Σ `ImpactPeople.deaths` where `from = leader`. Detail: "XM on their conscience (conscience sold separately)." +- **BIGGEST BANG** → largest single warhead landed (`ImpactPeople`/`ImpactInfrastructure` with `from = leader`, max yield large>medium>small). Detail names the yield. +- **TRIGGER HAPPY** → most `MissileLaunched` with `from = leader`. Detail: "N launches. Subtlety: none." +- **COLD FEET** → among leaders who ended with launch capacity but fired the fewest/zero launches (a nuke leader who barely fired). Detail: deadpan. (Only if it reads meaningfully; else omit.) + +Helper `export function humanDemiseLine(game, initialPopulations, humanId): string` — a one-line honest epitaph for the human slot regardless of awards: fate (survived / eliminated / last to fall), pop lost, hits landed vs taken. Used to guarantee the human always gets a personal "how you died" beat. + +(Elimination *order* comes from the sequence of `LeaderEliminated` events in the chronological log; exact round numbers are NOT needed and NOT used — keeps this decoupled from any per-round state the engine doesn't expose at match end.) + +## 3. Winners.tsx changes + +- **Pyrrhic headline** (`pickHeadline`): pyrrhic no longer returns `"${winner} WINS"`. Return honest-deadpan, e.g. `LAST TO FALL: ${winner.toUpperCase()}`. Survivor keeps `"${winner} WINS"`; apocalypse keeps `"WINNER: NOBODY"`. The pyrrhic hero Stamp can read "LAST TO FALL" instead of "PYRRHIC" (or keep PYRRHIC — minor; align with headline). +- **Awards panel:** new `` (or "Honours (Dishonours)") between the hero and the Death Toll, rendering `deriveAwards(...)`. Each row: award title, the leader (Portrait + name, "(you)" if human), and the detail line. Awards whose `leaderId` is the human slot get a highlight class. Reuse existing comic primitives + Winners.module.css patterns; add minimal CSS. +- **Human epitaph:** show `humanDemiseLine(...)` prominently (e.g. under the hero subline or atop the awards) so the human always gets an honest personal readout even in an all-AI-award game. (If the cast has multiple humans, show player1's; a fuller multi-human treatment is out of scope.) +- Death Toll table, apocalypse handling, New Game / Same Cast buttons, the closing "EVERYBODY PLAYS. NOBODY WINS." — unchanged. + +## 4. Testing + +- `tests/ui/demise.test.ts` (unit, pure): build small synthetic `GameState`s with crafted `log` + `leaders` + `outcome`, assert each award goes to the right leader (deadliest = highest summed deaths; biggest bang = large-warhead lander; died first = first LeaderEliminated; last to fall = pyrrhic winner; trigger happy = most launches); tie-break by cast order; award omitted when no qualifier (e.g. no launches → no TRIGGER HAPPY, or empty log → only outcome-based/none). `humanDemiseLine` returns the right fate string for survived / eliminated / last-to-fall. All assertions unconditional. +- `tests/ui/Winners.test.tsx`: update the pyrrhic case — assert the headline is the honest-deadpan form (NOT "WINS"); assert an Awards panel renders and the human's award/epitaph appears. Keep survivor/apocalypse assertions. +- `npm run typecheck` clean; `npm run test:run` green. + +## 5. Out of scope +- Engine outcome/scoring/win-condition changes (NONE — pure UI + derivation). +- RoundSummary (slice 2, shipped). Character/AI (slice 1, shipped). +- Multi-human per-player scorecards beyond player1's epitaph. +- Persisting scores / cross-game records. + +## 6. Constraints +- Every commit typechecks and passes the suite; unconditional test assertions. +- Product emojis (☢ flags) are design language — keep. +- Comedic-award copy in the cast's satirical voice; punch up; no religious markers. +- Awards derive deterministically from game state — no RNG, no Date. diff --git a/src/ui/screens/Winners.module.css b/src/ui/screens/Winners.module.css index 1ae5e37..d8939de 100644 --- a/src/ui/screens/Winners.module.css +++ b/src/ui/screens/Winners.module.css @@ -87,6 +87,14 @@ line-height: 1.35; } +.epitaph { + font-family: var(--font-hand); + font-style: italic; + font-size: 16px; + margin: 8px 0 0; + color: var(--paper-edge); +} + .buttonRow { margin-top: 24px; display: flex; @@ -94,6 +102,42 @@ flex-wrap: wrap; } +/* ---- Awards ---- */ + +.awardsList { + display: flex; + flex-direction: column; + min-width: 560px; +} + +.awardRow { + display: flex; + gap: 10px; + align-items: center; + padding: 8px 10px; + border-bottom: 1px dashed rgba(20, 18, 20, 0.3); +} + +.awardRow:last-child { border-bottom: 0; } + +.awardTitle { + flex: 0 0 160px; + font-family: var(--font-display); + font-size: 12px; + letter-spacing: 0.08em; +} + +.awardDetail { + flex: 2; + font-family: var(--font-hand); + font-size: 14px; +} + +.awardMine { + background: rgba(214, 33, 122, 0.08); + border-left: 3px solid var(--magenta); +} + /* ---- Death toll ---- */ .tableScroll { diff --git a/src/ui/screens/Winners.tsx b/src/ui/screens/Winners.tsx index 2414c7b..f174b92 100644 --- a/src/ui/screens/Winners.tsx +++ b/src/ui/screens/Winners.tsx @@ -4,14 +4,16 @@ import { isHuman } from '../../engine/state'; import { extractFlag } from '../portraits'; import Portrait from '../components/Portrait'; import { Btn, Halftone, Panel, RelBadge, Stamp, Tag } from '../components/comic'; +import { deriveAwards, humanDemiseLine } from '../util/demise'; import styles from './Winners.module.css'; function pickHeadline(outcome: WinOutcome, leaders: GameState['leaders']): string { switch (outcome.type) { case 'apocalypse': return 'WINNER: NOBODY'; case 'survivor': - case 'pyrrhic': return `${leaders[outcome.winner].name.toUpperCase()} WINS`; + case 'pyrrhic': + return `LAST TO FALL: ${leaders[outcome.winner].name.toUpperCase()}`; } } @@ -40,6 +42,8 @@ export default function Winners({ state, dispatch }: ScreenProps) { const outcome = game.outcome!; const headline = pickHeadline(outcome, game.leaders); const subLine = pickSubLine(outcome, game.leaders, state.initialPopulations); + const awards = deriveAwards(game, state.initialPopulations); + const epitaph = humanDemiseLine(game, state.initialPopulations, 'player1'); const tollRows = game.cast.map((id) => { const leader = game.leaders[id]; @@ -91,7 +95,7 @@ export default function Winners({ state, dispatch }: ScreenProps) { rotate={14} style={{ fontSize: 14, padding: '5px 12px' }} > - {outcome.type === 'survivor' ? 'SURVIVOR' : 'PYRRHIC'} + {outcome.type === 'survivor' ? 'SURVIVOR' : 'LAST TO FALL'} @@ -99,6 +103,7 @@ export default function Winners({ state, dispatch }: ScreenProps) {

{headline}

"{subLine}"

+

{epitaph}

New Game Same Cast, Again @@ -106,6 +111,34 @@ export default function Winners({ state, dispatch }: ScreenProps) {
+ {awards.length > 0 && ( + +
+
+ {awards.map((award) => { + const leader = game.leaders[award.leaderId]; + // Viewer = player1, consistent with the epitaph and death-toll + // "(you)" convention; multi-human per-player scorecards are out of scope. + const mine = award.leaderId === 'player1'; + return ( +
+ {award.title} + + + {leader.name}{mine ? ' (you)' : ''} + + {award.detail} +
+ ); + })} +
+
+
+ )} +
diff --git a/src/ui/util/demise.ts b/src/ui/util/demise.ts new file mode 100644 index 0000000..270209c --- /dev/null +++ b/src/ui/util/demise.ts @@ -0,0 +1,174 @@ +import type { GameState, LeaderId, Yield } from '../../engine/types'; + +export interface Award { + title: string; + leaderId: LeaderId; + detail: string; +} + +const YIELD_RANK: Record = { small: 1, medium: 2, large: 3 }; +const RANK_YIELD: Record = { 1: 'small', 2: 'medium', 3: 'large' }; + +interface LeaderStats { + deathsCaused: number; + bestYieldRank: number; + launches: number; +} + +/** Single pass over the cumulative game.log accumulating per-leader award metrics. */ +function collectStats(game: GameState): { stats: Record; eliminationOrder: LeaderId[] } { + const stats = {} as Record; + for (const id of game.cast) { + stats[id] = { deathsCaused: 0, bestYieldRank: 0, launches: 0 }; + } + const eliminationOrder: LeaderId[] = []; + for (const event of game.log) { + switch (event.kind) { + case 'MissileLaunched': + stats[event.from].launches += 1; + break; + case 'ImpactPeople': + stats[event.from].deathsCaused += event.deaths; + stats[event.from].bestYieldRank = Math.max(stats[event.from].bestYieldRank, YIELD_RANK[event.warhead]); + break; + case 'ImpactInfrastructure': + stats[event.from].bestYieldRank = Math.max(stats[event.from].bestYieldRank, YIELD_RANK[event.warhead]); + break; + case 'LeaderEliminated': + eliminationOrder.push(event.id); + break; + default: + break; + } + } + return { stats, eliminationOrder }; +} + +/** Picks the max of `metric` over `cast` in cast order — ties go to the earlier entry. Returns undefined if nobody clears 0. */ +function superlative( + cast: LeaderId[], + metric: (id: LeaderId) => number, + build: (id: LeaderId, value: number) => Award, +): Award | undefined { + let bestId: LeaderId | undefined; + let bestValue = 0; + for (const id of cast) { + const value = metric(id); + if (value > bestValue) { + bestValue = value; + bestId = id; + } + } + return bestId === undefined ? undefined : build(bestId, bestValue); +} + +/** + * Comedic superlative awards derived from the cumulative game.log + final + * leaders/outcome. One winner per category, cast-order tie-break, omitted + * when nobody qualifies. No engine data beyond GameState is consulted. + */ +export function deriveAwards( + game: GameState, + _initialPopulations: Partial>, +): Award[] { + const { stats, eliminationOrder } = collectStats(game); + const awards: Award[] = []; + + const deadliest = superlative( + game.cast, + (id) => stats[id].deathsCaused, + (id, deaths) => ({ + title: 'DEADLIEST', + leaderId: id, + detail: `${deaths}M on their conscience (conscience sold separately).`, + }), + ); + if (deadliest) awards.push(deadliest); + + const biggestBang = superlative( + game.cast, + (id) => stats[id].bestYieldRank, + (id, rank) => ({ + title: 'BIGGEST BANG', + leaderId: id, + detail: `Dropped a ${RANK_YIELD[rank].toUpperCase()} warhead on somebody. Go big or go extinct.`, + }), + ); + if (biggestBang) awards.push(biggestBang); + + const triggerHappy = superlative( + game.cast, + (id) => stats[id].launches, + (id, launches) => ({ + title: 'TRIGGER HAPPY', + leaderId: id, + detail: `${launches} launch${launches === 1 ? '' : 'es'}. Subtlety: none.`, + }), + ); + if (triggerHappy) awards.push(triggerHappy); + + if (eliminationOrder.length > 0) { + awards.push({ + title: 'DIED FIRST', + leaderId: eliminationOrder[0], + detail: 'Set the tone. The tone was "dead."', + }); + } + + if (game.outcome?.type === 'survivor') { + awards.push({ + title: 'LAST ONE STANDING', + leaderId: game.outcome.winner, + detail: "Outlasted everyone. That's the whole prize.", + }); + } else if (game.outcome?.type === 'pyrrhic') { + awards.push({ + title: 'LAST TO FALL', + leaderId: game.outcome.winner, + detail: 'Outlasted everyone by about one round, then joined them anyway.', + }); + } + + return awards; +} + +/** Counts ImpactPeople/ImpactInfrastructure events with `side` (from/target) equal to `id`. */ +function countImpacts(game: GameState, side: 'from' | 'target', id: LeaderId): number { + let count = 0; + for (const event of game.log) { + if (event.kind === 'ImpactPeople' || event.kind === 'ImpactInfrastructure') { + if (event[side] === id) count += 1; + } + } + return count; +} + +/** + * A one-line honest epitaph for the human slot: fate (survived / eliminated / + * last to fall) plus population lost and hits landed vs taken. Guarantees a + * personal "how you died" beat even when the human wins no award. + */ +export function humanDemiseLine( + game: GameState, + initialPopulations: Partial>, + humanId: LeaderId, +): string { + const leader = game.leaders[humanId]; + const initial = initialPopulations[humanId] ?? leader.population; + const final = leader.population; + const popLost = Math.max(0, initial - final); + const hitsLanded = countImpacts(game, 'from', humanId); + const hitsTaken = countImpacts(game, 'target', humanId); + const hitWord = (n: number) => `${n} hit${n === 1 ? '' : 's'}`; + + let fate: string; + if (leader.alive) { + fate = `${leader.name} survived`; + } else if (game.outcome?.type === 'pyrrhic' && game.outcome.winner === humanId) { + fate = `${leader.name} was the last to fall`; + } else { + fate = `${leader.name} was eliminated`; + } + + return `${fate}. Lost ${popLost}M of ${initial}M. Landed ${hitWord(hitsLanded)}, took ${hitWord(hitsTaken)}.`; +} diff --git a/tests/ui/Winners.test.tsx b/tests/ui/Winners.test.tsx index 9bfb173..66d3bc6 100644 --- a/tests/ui/Winners.test.tsx +++ b/tests/ui/Winners.test.tsx @@ -62,10 +62,32 @@ describe('', () => { expect(screen.getByText('☢')).toBeInTheDocument(); }); - it('marks a pyrrhic winner with the PYRRHIC stamp', () => { + it('gives the pyrrhic outcome an honest headline instead of a "WINS" lie', () => { render(); - expect(screen.getByText('CHUMP WINS')).toBeInTheDocument(); - expect(screen.getByText('PYRRHIC')).toBeInTheDocument(); + expect(screen.getByText('LAST TO FALL: CHUMP')).toBeInTheDocument(); + expect(screen.queryByText('CHUMP WINS')).not.toBeInTheDocument(); + expect(screen.getAllByText('LAST TO FALL').length).toBeGreaterThan(0); + }); + + it('renders an Awards panel for the pyrrhic outcome from outcome data alone (sparse log)', () => { + render(); + expect(screen.getByText('Honours (Dishonours)')).toBeInTheDocument(); + }); + + it('renders an Awards panel with LAST ONE STANDING for the survivor outcome', () => { + render(); + expect(screen.getByText('Honours (Dishonours)')).toBeInTheDocument(); + expect(screen.getByText('LAST ONE STANDING')).toBeInTheDocument(); + }); + + it('omits the Awards panel when there are no awards (apocalypse, empty log)', () => { + render(); + expect(screen.queryByText('Honours (Dishonours)')).not.toBeInTheDocument(); + }); + + it('shows the human epitaph line', () => { + render(); + expect(screen.getByText(/Rufus T\. Firefly was eliminated/)).toBeInTheDocument(); }); it('New Game dispatches BACK_TO_SETUP', () => { diff --git a/tests/ui/demise.test.ts b/tests/ui/demise.test.ts new file mode 100644 index 0000000..ca863b6 --- /dev/null +++ b/tests/ui/demise.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it } from 'vitest'; +import { initialState } from '../../src/engine/state'; +import type { GameState, ResolutionEvent } from '../../src/engine/types'; +import { deriveAwards, humanDemiseLine } from '../../src/ui/util/demise'; + +const CAST = ['player1', 'chump', 'carnage'] as const; + +function makeGame(log: ResolutionEvent[], overrides: Partial = {}): GameState { + const game = initialState({ cast: [...CAST], difficulty: 'normal', seed: 'demise-test' }); + game.log = log; + return { ...game, ...overrides }; +} + +function findAward(awards: ReturnType, title: string) { + return awards.find((a) => a.title === title); +} + +describe('deriveAwards', () => { + it('awards DEADLIEST to the leader with the highest summed ImpactPeople deaths', () => { + const game = makeGame([ + { kind: 'ImpactPeople', from: 'chump', target: 'carnage', warhead: 'small', deaths: 5 }, + { kind: 'ImpactPeople', from: 'carnage', target: 'chump', warhead: 'medium', deaths: 10 }, + ]); + const awards = deriveAwards(game, {}); + const deadliest = findAward(awards, 'DEADLIEST'); + expect(deadliest).toBeDefined(); + expect(deadliest!.leaderId).toBe('carnage'); + }); + + it('awards BIGGEST BANG to the leader who landed the largest-yield impact', () => { + const game = makeGame([ + { kind: 'ImpactPeople', from: 'chump', target: 'carnage', warhead: 'medium', deaths: 1 }, + { kind: 'ImpactInfrastructure', from: 'carnage', target: 'chump', warhead: 'large', factoriesDestroyed: 2 }, + ]); + const awards = deriveAwards(game, {}); + const biggestBang = findAward(awards, 'BIGGEST BANG'); + expect(biggestBang).toBeDefined(); + expect(biggestBang!.leaderId).toBe('carnage'); + }); + + it('awards TRIGGER HAPPY to the leader with the most MissileLaunched events', () => { + const game = makeGame([ + { kind: 'MissileLaunched', from: 'chump', to: 'carnage', delivery: 'missile', warhead: 'small', targetType: 'people' }, + { kind: 'MissileLaunched', from: 'chump', to: 'carnage', delivery: 'missile', warhead: 'small', targetType: 'people' }, + { kind: 'MissileLaunched', from: 'carnage', to: 'chump', delivery: 'missile', warhead: 'small', targetType: 'people' }, + ]); + const awards = deriveAwards(game, {}); + const triggerHappy = findAward(awards, 'TRIGGER HAPPY'); + expect(triggerHappy).toBeDefined(); + expect(triggerHappy!.leaderId).toBe('chump'); + }); + + it('awards DIED FIRST to the id of the first LeaderEliminated event in log order', () => { + const game = makeGame([ + { kind: 'LeaderEliminated', id: 'chump' }, + { kind: 'LeaderEliminated', id: 'carnage' }, + ]); + // chump is not first in cast order (player1 is) — this proves log order, not cast order, drives the award. + const awards = deriveAwards(game, {}); + const diedFirst = findAward(awards, 'DIED FIRST'); + expect(diedFirst).toBeDefined(); + expect(diedFirst!.leaderId).toBe('chump'); + }); + + it('awards LAST TO FALL to outcome.winner for a pyrrhic outcome', () => { + const game = makeGame([], { outcome: { type: 'pyrrhic', winner: 'chump' } }); + const awards = deriveAwards(game, {}); + const lastToFall = findAward(awards, 'LAST TO FALL'); + expect(lastToFall).toBeDefined(); + expect(lastToFall!.leaderId).toBe('chump'); + expect(findAward(awards, 'LAST ONE STANDING')).toBeUndefined(); + }); + + it('awards LAST ONE STANDING to outcome.winner for a survivor outcome', () => { + const game = makeGame([], { outcome: { type: 'survivor', winner: 'carnage' } }); + const awards = deriveAwards(game, {}); + const lastOneStanding = findAward(awards, 'LAST ONE STANDING'); + expect(lastOneStanding).toBeDefined(); + expect(lastOneStanding!.leaderId).toBe('carnage'); + expect(findAward(awards, 'LAST TO FALL')).toBeUndefined(); + }); + + it('awards neither LAST TO FALL nor LAST ONE STANDING for an apocalypse outcome', () => { + const game = makeGame([], { outcome: { type: 'apocalypse' } }); + const awards = deriveAwards(game, {}); + expect(findAward(awards, 'LAST TO FALL')).toBeUndefined(); + expect(findAward(awards, 'LAST ONE STANDING')).toBeUndefined(); + }); + + it('breaks ties by cast order (player1, chump, carnage)', () => { + const game = makeGame([ + { kind: 'ImpactPeople', from: 'chump', target: 'player1', warhead: 'small', deaths: 5 }, + { kind: 'ImpactPeople', from: 'carnage', target: 'player1', warhead: 'small', deaths: 5 }, + ]); + const awards = deriveAwards(game, {}); + const deadliest = findAward(awards, 'DEADLIEST'); + expect(deadliest).toBeDefined(); + expect(deadliest!.leaderId).toBe('chump'); + }); + + it('omits TRIGGER HAPPY when the log has no MissileLaunched events', () => { + const game = makeGame([ + { kind: 'ImpactPeople', from: 'chump', target: 'carnage', warhead: 'small', deaths: 1 }, + ]); + const awards = deriveAwards(game, {}); + expect(findAward(awards, 'TRIGGER HAPPY')).toBeUndefined(); + }); + + it('returns no log-derived awards for an empty log and apocalypse outcome', () => { + const game = makeGame([], { outcome: { type: 'apocalypse' } }); + const awards = deriveAwards(game, {}); + expect(awards).toEqual([]); + }); +}); + +describe('humanDemiseLine', () => { + it('describes a survived fate', () => { + const game = makeGame([ + { kind: 'ImpactPeople', from: 'player1', target: 'chump', warhead: 'small', deaths: 2 }, + ], { outcome: { type: 'survivor', winner: 'player1' } }); + game.leaders.player1.alive = true; + game.leaders.player1.population = 20; + const line = humanDemiseLine(game, { player1: 25 }, 'player1'); + expect(line).toContain('survived'); + expect(line).toContain('5M'); + expect(line).toContain('25M'); + }); + + it('describes an eliminated fate', () => { + const game = makeGame([ + { kind: 'ImpactPeople', from: 'chump', target: 'player1', warhead: 'small', deaths: 25 }, + ], { outcome: { type: 'survivor', winner: 'chump' } }); + game.leaders.player1.alive = false; + game.leaders.player1.population = 0; + const line = humanDemiseLine(game, { player1: 25 }, 'player1'); + expect(line).toContain('eliminated'); + expect(line).toContain('25M'); + }); + + it('describes a pyrrhic last-to-fall fate', () => { + const game = makeGame([ + { kind: 'ImpactPeople', from: 'player1', target: 'chump', warhead: 'small', deaths: 3 }, + { kind: 'ImpactPeople', from: 'chump', target: 'player1', warhead: 'small', deaths: 25 }, + ], { outcome: { type: 'pyrrhic', winner: 'player1' } }); + game.leaders.player1.alive = false; + game.leaders.player1.population = 0; + const line = humanDemiseLine(game, { player1: 25 }, 'player1'); + expect(line).toContain('last to fall'); + expect(line).toContain('25M'); + }); +});