Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<Record<LeaderId, number>>): Award[]`
- `export function humanDemiseLine(game: GameState, initialPopulations: Partial<Record<LeaderId, number>>, 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 `<Panel title="Honours (Dishonours)">` above Death Toll listing `awards`: each row = title, `<Portrait leaderId size={36} flag=…>` + 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.
Original file line number Diff line number Diff line change
@@ -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 `<Panel title="Awards">` (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.
44 changes: 44 additions & 0 deletions src/ui/screens/Winners.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -87,13 +87,57 @@
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;
gap: 12px;
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 {
Expand Down
37 changes: 35 additions & 2 deletions src/ui/screens/Winners.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()}`;
}
}

Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -91,21 +95,50 @@ 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'}
</Stamp>
</div>
</div>
)}
<div className={styles.heroText}>
<h1 className={styles.headline}>{headline}</h1>
<p className={styles.subline}>"{subLine}"</p>
<p className={styles.epitaph}>{epitaph}</p>
<div className={styles.buttonRow}>
<Btn variant="primary" size="lg" onClick={newGame}>New Game</Btn>
<Btn size="lg" onClick={sameCast}>Same Cast, Again</Btn>
</div>
</div>
</div>

{awards.length > 0 && (
<Panel title="Honours (Dishonours)" style={{ marginTop: 32, background: 'var(--paper)', color: 'var(--ink)' }}>
<div className={styles.tableScroll}>
<div className={styles.awardsList}>
{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 (
<div
key={award.title}
className={`${styles.awardRow} ${mine ? styles.awardMine : ''}`}
>
<span className={styles.awardTitle}>{award.title}</span>
<span className={`${styles.cellLeader} ${styles.leaderCell}`}>
<Portrait leaderId={award.leaderId} size={36} flag={flagFor(award.leaderId)} />
<strong>{leader.name}{mine ? ' (you)' : ''}</strong>
</span>
<span className={styles.awardDetail}>{award.detail}</span>
</div>
);
})}
</div>
</div>
</Panel>
)}

<Panel title="Death Toll" style={{ marginTop: 32, background: 'var(--paper)', color: 'var(--ink)' }}>
<div className={styles.tableScroll}>
<div className={styles.deathTable}>
Expand Down
Loading
Loading