From 6d5314f53b65df8494a0fd57d18f99a1bdeee985 Mon Sep 17 00:00:00 2001 From: gethi Date: Wed, 20 May 2026 19:45:31 +0100 Subject: [PATCH 01/43] docs: visual-design portrait-system spec Captures the three top-level look-and-feel decisions from the 2026-05-19 brainstorm (polish = elevate past mockups; framing = responsive desktop; identity = custom portrait art) plus the full portrait system: master 800x1000 head-and-shoulders assets, a five-step size scale, always-on flag-badge overlay, AI-generated pipeline locked to the Chump reference, and the flag-fill variant for player-slot occupants. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...20-visual-design-portrait-system-design.md | 188 ++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-20-visual-design-portrait-system-design.md diff --git a/docs/superpowers/specs/2026-05-20-visual-design-portrait-system-design.md b/docs/superpowers/specs/2026-05-20-visual-design-portrait-system-design.md new file mode 100644 index 0000000..e9610c0 --- /dev/null +++ b/docs/superpowers/specs/2026-05-20-visual-design-portrait-system-design.md @@ -0,0 +1,188 @@ +# nuke — visual design direction & portrait system + +**Date:** 2026-05-20 +**Status:** drafted from brainstorming session; pending user review + +This spec captures three top-level look-and-feel decisions and the full portrait system that sits on top of them. It is the first concrete elaboration of the 2026-05-08 base design (`docs/superpowers/specs/2026-05-08-nuke-design.md`) and the four parchment-phone mockups in `docs/superpowers/mockups/`. + +Tagline driving tone: **"everybody plays. Nobody wins."** + +--- + +## 1. Direction decisions + +### 1.1 Polish — mockups as floor, then elevate + +The 2026-05-08 parchment-phone mockups are the *floor*, not the ceiling. We adopt their design language (warm parchment palette, dark wrapper, single-column phone shape) and go past them: a real spacing scale, a real type scale, shared design tokens, more depth and texture, tighter visual hierarchy. No animation in this phase. + +The token system itself (spacing, type, colour, shadow) is **out of scope** for this spec — it follows in a separate design-tokens spec. This spec only nails the portrait-system tokens (§3.1). + +### 1.2 Desktop framing — responsive + +Mobile-first single column is the source of truth. On wider viewports the layout opens up into a multi-column desktop arrangement (e.g. the Table beside the orders column on Planning, instead of stacked). No phone-bezel frame, no fixed-width floating panel — the parchment surface re-flows. + +Per-screen multi-column breakpoints and exact arrangements are out of scope for this spec; the portrait-size scale (§3) accommodates both column widths via slot-size swaps at desktop breakpoint. + +### 1.3 Leader identity — custom portrait art + +Every leader appears as a custom illustrated head-and-shoulders portrait, replacing the current bare flag-emoji rendering across every screen. A flag badge sits on the portrait's corner so country reads at a glance even with a strong caricature (§3.3). + +--- + +## 2. Cast covered + +All six playable leaders plus the Disparage cameo receive the same portrait treatment. Same artist hand, same framing, same head size, same eye-line height — so the cast reads as one coherent set, never a collage. + +| Slug | In-game name | Flag | +|---|---|---| +| `chump` | Chump | 🇺🇸 | +| `netanyahoo` | Netanyahoo | 🇮🇱 | +| `khameneverhere` | Khameneverhere | 🇮🇷 | +| `starmless` | Starmless | 🇬🇧 | +| `carnage` | Carnage | 🇨🇦 | +| `mileigh-hem` | Mileigh-hem | 🇦🇷 | +| `disparage` | Nigel Disparage (cameo) | 🇬🇧 | + +The Chump caricature already produced in brainstorming is the **style reference** — every other portrait matches its line weight, palette, exaggeration intensity, and lighting. + +Khameneverhere's running visual gag — never actually shown — is preserved by his portrait being a stylised empty-chair / silhouette frame rather than a face. He still occupies the slot; the slot just shows absence. + +--- + +## 3. Portrait system + +### 3.1 Size scale (tokens) + +Five discrete sizes. Every slot in the UI picks one of these — no bespoke sizes. + +| Token | Pixel size | Where it's used | +|---|---|---| +| `xs` | 32 px | History strip, inline meta, dense lists | +| `sm` | 56 px | RoundSummary story rows | +| `md` | 80 px | Reactions row, Action actor/receiver (mobile) | +| `lg` | 128 px | Planning Table card (desktop), Action portraits (desktop) | +| `xl` | 200 px | Setup picker tile, HotseatHandoff hero, Winners hero | + +These elevate above the mockup defaults (mockup used 42 px on RoundSummary and 56 px on Reactions; we bump to 56 and 80 respectively). The master asset (§3.2) is sized so even `xl` is a 4× downsample, well within sharp-render range. + +Implementation as CSS custom properties: + +```css +--portrait-xs: 32px; +--portrait-sm: 56px; +--portrait-md: 80px; +--portrait-lg: 128px; +--portrait-xl: 200px; +``` + +### 3.2 Master asset spec + +Every portrait is produced once at master resolution and downscaled at render time: + +- **Dimensions:** 800 × 1000 px (4:5 portrait ratio) +- **Format:** PNG-24 with alpha channel +- **Background:** transparent +- **Composition rules** (rigorously uniform across the cast): + - Head horizontally centred in the frame. + - Entire face/head fits inside the **top 800 × 800 square**, so any slot can circle-crop to a square avatar without losing the face. + - ~10% breathing room around the head on every side inside that square. + - Shoulders crop naturally at the bottom of the frame. + - Same head size, same eye-line height, same shoulder line across all seven assets. +- **Framing:** uniform head-and-shoulders. No signature poses or characteristic gestures — every leader is composed identically. Personality reads through facial expression and caricature exaggeration, not body language. + +### 3.3 Flag badge overlay + +Every portrait, at every size, carries a small circular flag badge at its **bottom-right corner**. Badge proportions (% of portrait diameter, not fixed pixels — they scale with the slot): + +| Spec | Value | +|---|---| +| Badge diameter | 28% of portrait short edge | +| Border ring | 2 px white (parchment) ring around the badge | +| Position | Bottom-right, centre of badge sitting on the portrait's bottom-right edge (overhanging slightly) | +| Content | The leader's flag emoji, rendered at badge size | +| At `xs` (32 px) | Badge ≈ 9 px — small but still rendered so the rule is uniform. The `xs` slot is always paired with the leader name inline, so country also reads from text | + +### 3.4 Art pipeline + +AI-generated, single locked style. One image-generation model + one master prompt template, with the approved Chump portrait as the in-context style reference for every subsequent leader. Output is post-processed (alpha cleanup, exact 800 × 1000 crop, head-position normalisation) before commit. + +Regeneration policy: portraits are not sacred — if a leader's prompt fails to hit the style or framing, regenerate. Treat the seven portraits as a single batched asset job that we may rerun end-to-end at a higher fidelity model later. The system is designed so that swap-in is a file replacement, nothing more. + +The exact prompt template, model choice, and the post-processing pipeline are out of scope for this spec — they belong in the implementation plan. + +### 3.5 Player slots (hotseat) — flag fill, no portrait + +The engine distinguishes the six AI character ids (with fixed personality) from the five player slot ids (`player1`..`player5`, claimed by humans at Setup who pick their own name and country). Only the AI cast receives caricature portraits. Player slots render as a **flag-fill** variant: + +- The slot is filled by the player's chosen country flag glyph, sized to fill the circle (≈ 70% of slot diameter). +- Same parchment background, same circle crop, same shadow as a portrait slot. +- The corner flag badge is **omitted** in the flag-fill variant — the slot already is the flag. +- All five size tokens (xs–xl) apply; the glyph scales proportionally. + +This is a deliberate asymmetry: caricature carries personality, and players are whoever the user said they are. Forcing a generic silhouette or monogram would dilute the cast or pretend the player is part of it. + +### 3.6 File layout + +``` +src/assets/portraits/ + chump.png + netanyahoo.png + khameneverhere.png + starmless.png + carnage.png + mileigh-hem.png + disparage.png +``` + +Imported via Vite's static-asset handling. A single `getPortrait(leaderId): string` helper in `src/ui/assets/portraits.ts` maps leader ids to asset URLs. Components consume that helper, never raw paths. + +A single `` component (`src/ui/components/Portrait.tsx`) is the only direct consumer — it renders the `` (AI cast) or the flag-fill variant (player slots), the corner flag badge for AI portraits, and handles circle vs. square crop. Every screen calls ``, never raw ``s. Internally it dispatches on `isHuman(leaderId)`: human → flag fill, otherwise → portrait + badge. + +--- + +## 4. Slot mapping (per screen) + +The table below lists every visible portrait slot today and the chosen size token. Mobile-first sizes given; desktop overrides noted where they differ. + +| Screen / component | Slot | Mobile token | Desktop token | Crop | +|---|---|---|---|---| +| `Setup.tsx` | Cast picker tile (one per leader) | `xl` | `xl` | Square (with rounded corners, not circle) | +| `Planning.tsx` (TargetRow) | Per-target row header | `sm` | `md` | Circle | +| `Planning.tsx` (Table cards) | Per-leader Table card | `md` | `lg` | Circle | +| `Action.tsx` | Actor portrait (world-map style) | `md` | `lg` | Circle | +| `Action.tsx` | Receiver portrait | `md` | `lg` | Circle | +| `EventCard.tsx` | Inline event-feed leader pip | `xs` | `xs` | Circle | +| `RoundSummary.tsx` | Story row portrait | `sm` | `sm` | Circle | +| `RoundSummary.tsx` (Reactions sub-block) | Reaction row portrait | `md` | `md` | Circle | +| `HotseatHandoff.tsx` | Curtain hero | `xl` | `xl` | Circle | +| `Winners.tsx` | Winner hero | `xl` | `xl` | Circle | +| `AiConferring.tsx` | Conferring leader (rotating) | `lg` | `lg` | Circle | +| `DisparageCard.tsx` / `DisparageColumn.tsx` | Cameo sidebar | `md` | `md` | Circle | + +The cast-picker Setup tile is the only **square** crop; everywhere else, the slot is a circle that picks up the top 800 × 800 of the master. Player-slot occupants use the flag-fill variant (§3.5) at the same size token. + +--- + +## 5. Out of scope + +Captured here so they don't get rolled into the implementation plan for this spec: + +- The full design-token system (spacing scale, type scale, colour palette, shadow tokens) — separate spec. +- Per-screen multi-column desktop arrangements — separate spec, after tokens. +- Animation and motion design — explicitly deferred. +- The image-generation prompt template, model, and post-processing pipeline — belongs in this spec's implementation plan, not the design spec itself. +- Replacing the AI-generated portraits with commissioned art — a future regeneration pass; no code or spec change required. + +--- + +## 6. Open questions + +None. Every decision above was made interactively in the 2026-05-19 and 2026-05-20 brainstorm sessions. + +--- + +## 7. Implementation references + +- `docs/superpowers/specs/2026-05-08-nuke-design.md` — base game design. +- `docs/superpowers/mockups/{planning,action,reactions,round-summary}-screen.html` — the parchment-phone visual floor. +- `docs/superpowers/flavour-bank.md` — leader cast and personality references that the portraits must read against. From 93adfac3bccd0938fb8a01cbecd85000741991ff Mon Sep 17 00:00:00 2001 From: gethi Date: Wed, 20 May 2026 20:17:45 +0100 Subject: [PATCH 02/43] =?UTF-8?q?docs:=20portrait-system=20spec=20?= =?UTF-8?q?=E2=80=94=20disambiguate=20animation=20status?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first draft read as if animation was cancelled. It isn't: Framer Motion plus the Action/Reactions pacing were already settled in the 2026-05-08 design and the mockups. This commit clarifies that this spec adds no NEW animation work but the component must be animation-friendly (motion-wrappable, addressable inner layers) so the existing Phase-5 plan can attach without refactor. Adds a §6 "Animation context (already decided)" reference section. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...20-visual-design-portrait-system-design.md | 38 +++++++++++++++---- 1 file changed, 31 insertions(+), 7 deletions(-) diff --git a/docs/superpowers/specs/2026-05-20-visual-design-portrait-system-design.md b/docs/superpowers/specs/2026-05-20-visual-design-portrait-system-design.md index e9610c0..fc770bf 100644 --- a/docs/superpowers/specs/2026-05-20-visual-design-portrait-system-design.md +++ b/docs/superpowers/specs/2026-05-20-visual-design-portrait-system-design.md @@ -13,7 +13,9 @@ Tagline driving tone: **"everybody plays. Nobody wins."** ### 1.1 Polish — mockups as floor, then elevate -The 2026-05-08 parchment-phone mockups are the *floor*, not the ceiling. We adopt their design language (warm parchment palette, dark wrapper, single-column phone shape) and go past them: a real spacing scale, a real type scale, shared design tokens, more depth and texture, tighter visual hierarchy. No animation in this phase. +The 2026-05-08 parchment-phone mockups are the *floor*, not the ceiling. We adopt their design language (warm parchment palette, dark wrapper, single-column phone shape) and go past them: a real spacing scale, a real type scale, shared design tokens, more depth and texture, tighter visual hierarchy. + +This spec adds no *new* animation work — the existing animation plan (Framer Motion, missile arcs, impact rings, sequential beat timing) is already settled in the May 8 design spec and the Action/Reactions mockups (see §6). The portrait component this spec defines must be **animation-friendly** so that plan can attach to it without refactor (§3.7). The token system itself (spacing, type, colour, shadow) is **out of scope** for this spec — it follows in a separate design-tokens spec. This spec only nails the portrait-system tokens (§3.1). @@ -138,6 +140,14 @@ Imported via Vite's static-asset handling. A single `getPortrait(leaderId): stri A single `` component (`src/ui/components/Portrait.tsx`) is the only direct consumer — it renders the `` (AI cast) or the flag-fill variant (player slots), the corner flag badge for AI portraits, and handles circle vs. square crop. Every screen calls ``, never raw ``s. Internally it dispatches on `isHuman(leaderId)`: human → flag fill, otherwise → portrait + badge. +### 3.7 Animation-readiness + +The Action and Reactions phases run a Framer Motion sequence (missile arcs, country pulses, impact rings, portrait pop-in, speech-bubble fades — see §6). The portrait system has to play well with that: + +- `` accepts an optional `as={motion.div}` (or equivalent) prop, so callers can wrap it as a `motion` component when an animation needs to drive the slot. Default render is a plain `
` to avoid loading Framer Motion on screens that don't animate. +- The inner image and badge are addressable as separate animatable layers — Framer Motion variants can target the portrait's translate/scale and the badge's pop-in independently. +- No animation is implemented in this spec — only the structural seams that let the existing animation plan attach later without refactor. + --- ## 4. Slot mapping (per screen) @@ -169,20 +179,34 @@ Captured here so they don't get rolled into the implementation plan for this spe - The full design-token system (spacing scale, type scale, colour palette, shadow tokens) — separate spec. - Per-screen multi-column desktop arrangements — separate spec, after tokens. -- Animation and motion design — explicitly deferred. +- **Implementing** Action/Reactions animations (Framer Motion variants, missile arcs, impact rings, pacing) — those are a separately-planned phase, already specified in the May 8 design and mockups (see §6). This spec only ensures the portrait component is animation-ready; it does not write the animation code or variants. - The image-generation prompt template, model, and post-processing pipeline — belongs in this spec's implementation plan, not the design spec itself. - Replacing the AI-generated portraits with commissioned art — a future regeneration pass; no code or spec change required. --- -## 6. Open questions +## 6. Animation context (already decided, reference only) + +Recorded here so the portrait system isn't designed in ignorance of the animation plan it has to feed. + +- **Library:** Framer Motion. Lives at `src/ui/animation/` (per `2026-05-08-nuke-design.md` §10). +- **Action phase:** `docs/superpowers/mockups/action-screen.html` specifies ~1.8 s per event: green firing pulse on the actor's country, SVG missile arc across the world-map background (rocket emoji mid-flight), red dashed glow ring + damage badge on the receiver, portraits and speech bubbles fade in, ~0.6 s dwell, fade. Fast Resolve toggle = 3× speed. +- **Reactions phase:** `docs/superpowers/mockups/reactions-screen.html` specifies ~1.3 s per beat, six beats ≈ 8 s total: portrait shifts in, quote types out, state-change badge slides in. `prefers-reduced-motion` shows all six at once after 4 s with no animation. +- **Audio:** paired with the animation work; same Phase-5 bucket. +- **Build phase:** scheduled as P5 in `2026-05-11-phase-3-ui-design.md` deferred-items list and `2026-05-12-phase-4a-satire-hotseat-design.md` deferred-items list. + +The portrait component (§3.7) is the only seam this spec adds for that work. + +--- + +## 7. Open questions -None. Every decision above was made interactively in the 2026-05-19 and 2026-05-20 brainstorm sessions. +None. Every decision in this spec was made interactively in the 2026-05-19 and 2026-05-20 brainstorm sessions; the animation context in §6 was already settled in the May 8 design. --- -## 7. Implementation references +## 8. Implementation references -- `docs/superpowers/specs/2026-05-08-nuke-design.md` — base game design. -- `docs/superpowers/mockups/{planning,action,reactions,round-summary}-screen.html` — the parchment-phone visual floor. +- `docs/superpowers/specs/2026-05-08-nuke-design.md` — base game design, animation library choice. +- `docs/superpowers/mockups/{planning,action,reactions,round-summary}-screen.html` — the parchment-phone visual floor + animation pacing. - `docs/superpowers/flavour-bank.md` — leader cast and personality references that the portraits must read against. From 80fce5109a0bc9b2ebbf2c9f9ed12f23dd6c7e52 Mon Sep 17 00:00:00 2001 From: gethi Date: Sun, 19 Jul 2026 13:46:27 +0100 Subject: [PATCH 03/43] docs: slice-1 comic-retheme spec; supersede parchment portrait spec Design handoff at design_handoff_nuke_game/ becomes the visual source of truth. Slice 1 covers tokens, fonts, SVG portrait system, shared comic primitives, and the Setup screen re-theme. Co-Authored-By: Claude Fable 5 --- ...20-visual-design-portrait-system-design.md | 2 +- .../2026-07-19-comic-retheme-slice1-design.md | 95 +++++++++++++++++++ 2 files changed, 96 insertions(+), 1 deletion(-) create mode 100644 docs/superpowers/specs/2026-07-19-comic-retheme-slice1-design.md diff --git a/docs/superpowers/specs/2026-05-20-visual-design-portrait-system-design.md b/docs/superpowers/specs/2026-05-20-visual-design-portrait-system-design.md index fc770bf..15c07b0 100644 --- a/docs/superpowers/specs/2026-05-20-visual-design-portrait-system-design.md +++ b/docs/superpowers/specs/2026-05-20-visual-design-portrait-system-design.md @@ -1,7 +1,7 @@ # nuke — visual design direction & portrait system **Date:** 2026-05-20 -**Status:** drafted from brainstorming session; pending user review +**Status:** SUPERSEDED by `2026-07-19-comic-retheme-slice1-design.md` — visual direction replaced by the comic-tabloid design handoff (`design_handoff_nuke_game/`); geometric SVG portraits replace the PNG pipeline and flag-fill human variant described here. This spec captures three top-level look-and-feel decisions and the full portrait system that sits on top of them. It is the first concrete elaboration of the 2026-05-08 base design (`docs/superpowers/specs/2026-05-08-nuke-design.md`) and the four parchment-phone mockups in `docs/superpowers/mockups/`. diff --git a/docs/superpowers/specs/2026-07-19-comic-retheme-slice1-design.md b/docs/superpowers/specs/2026-07-19-comic-retheme-slice1-design.md new file mode 100644 index 0000000..436f91a --- /dev/null +++ b/docs/superpowers/specs/2026-07-19-comic-retheme-slice1-design.md @@ -0,0 +1,95 @@ +# nuke — comic-tabloid re-theme, slice 1: foundation + Setup + +**Date:** 2026-07-19 +**Status:** approved in brainstorming session +**Supersedes:** `2026-05-20-visual-design-portrait-system-design.md` (parchment direction, AI-generated PNG portraits, flag-fill human slots) + +## 0. Context + +A complete hi-fi design handoff lives at `design_handoff_nuke_game/` — a working HTML/JSX prototype of all seven screens in a political-cartoon comic-book / four-color tabloid aesthetic, with design tokens, shared primitives, geometric SVG caricature portraits, and final copy. See its `readme.md` for the full screen-by-screen description. + +The handoff is the new visual source of truth. It is a design reference, not production code (CDN React + in-browser Babel); we recreate it in this codebase's stack: Vite + React 18 + TypeScript + CSS modules, driven by live engine state from `src/ui/store.ts`. + +The work is decomposed into four slices, each its own spec → plan → PR cycle: + +1. **This slice:** design tokens, fonts, portrait system, shared comic primitives, Setup screen re-theme. +2. Planning + HotseatHandoff + AiConferring re-themes. +3. Action screen (world map, event playback overlays). +4. RoundSummary (fake newspaper) + Winners re-themes. + +During the waves the app will be visually mixed (new Setup, old rest). Accepted. + +## 1. Design tokens (`src/ui/tokens.css`) + +Port `design_handoff_nuke_game/tokens.css` near-verbatim to `src/ui/tokens.css`, imported first in `src/ui/main.tsx`. It replaces any existing global parchment styling. Contents: + +- **Custom properties:** ink/paper palette, CMYK-aged accents (`--cyan #1d7fb0`, `--magenta #c61f44`, `--yellow #f3c318`, `--green #2e7a3c`), leader signature colors (`--c-chump`…`--c-disparage`), font stacks, spacing scale `--s-1`…`--s-8` (4/8/12/16/24/32/48/64px), borders 2/3/4px, radius 0, hard ink-offset shadows (`--offset-sm/md/lg`, no blur). +- **Global base classes:** `.paper` (newsprint texture), `.panel` (+ `tight/flat/dark/tilt-*` variants), `.panel-title`, `.tag`, `.stamp`, `.bubble`, `.burst`, `.halftone-cyan/magenta/yellow/ink`, button base styles. +- **Keyframe animations:** `fadein`, `pop`, `pulsedot`, `wobble`, `scan`. + +Base classes are intentionally global — they are the design language shared by every screen. Screen-specific layout stays in per-screen CSS modules that reference the custom properties. + +## 2. Fonts + +Self-hosted via `@fontsource` packages, imported in `main.tsx`: + +| Family | Package | Weights | +|---|---|---| +| Anton | `@fontsource/anton` | 400 | +| Playfair Display | `@fontsource/playfair-display` | 400, 700, 900 + italics | +| Work Sans | `@fontsource/work-sans` | 400–800 | +| JetBrains Mono | `@fontsource/jetbrains-mono` | 400, 700 | +| Bowlby One | `@fontsource/bowlby-one` | 400 | +| Patrick Hand | `@fontsource/patrick-hand` | 400 | +| Bangers | `@fontsource/bangers` | 400 | + +No runtime external fetch; deterministic builds. + +## 3. Portrait system + +Supersedes the old PNG/flag-fill portrait spec. Geometric caricature SVGs from `design_handoff_nuke_game/portraits.jsx`, ported to typed components. + +- `src/ui/portraits/` — one file per leader (`chump.tsx`, `netanyahoo.tsx`, `khameneverhere.tsx`, `starmless.tsx`, `carnage.tsx`, `mileighhem.tsx`, `disparage.tsx`, `groucho.tsx`), each exporting an SVG face component; `index.ts` exports a `FACES` record keyed by leader id. +- `src/ui/components/Portrait.tsx` — the single consumer-facing component: ``. Sizes are free pixel values per call site (handoff convention: 48–200px), not a fixed token scale. + - **AI leader:** signature-color background, the leader's `FACES` SVG, halftone multiply overlay. + - **Human slot (`player1`–`player5`):** Groucho-disguise SVG (glasses + moustache + cigar), differentiated by per-slot accent color (fixed per slot: player1 `--cyan`, player2 `--green`, player3 `--yellow-soft`, player4 `--magenta-deep`, player5 `--ink-soft`) plus the player's chosen-country flag badge. + - Dispatches on `isHuman(leaderId)` from `src/engine/state.ts`. +- SVGs are deliberate placeholders: `` API stays stable so commissioned art can replace internals with no call-site change. +- Khameneverhere's portrait is the cardboard-cutout "NOT HERE" gag — absence preserved. + +## 4. Shared comic primitives (`src/ui/components/comic/`) + +Port `design_handoff_nuke_game/ui.jsx` to one typed TSX file per component: + +`Panel`, `Halftone`, `Tag`, `Stamp`, `Bubble`, `Burst`, `Btn`, `Stat`, `ApMeter`, `PhaseTrack`, `DamageBadge`, `RelBadge`, `HoldButton`, `Ribbon`. + +- Props typed; behavior matches the prototype (e.g. `Panel` tilt variants, `Stamp` rotation, `Burst` color/fg pairing). +- `HoldButton`: requestAnimationFrame progress fill over `duration` ms (default 600), fires `onComplete` at 100%, cancels on mouse/touch leave/up. +- Components lean on the global base classes from `tokens.css`; anything component-specific goes in a co-located CSS module. + +## 5. Setup screen re-theme + +Rebuild `src/ui/screens/Setup.tsx` + `Setup.module.css` to the handoff layout (`screens-1.jsx`): + +- Max-width 1180px on `.paper`. Two-column grid (`minmax(0,1.6fr) minmax(0,1fr)`) ≥920px; single column below. +- **Left:** "The Table" cast picker — 2-col grid of rotated cast tiles (3px ink border, 4px offset shadow, ±1.4° rotation; 64px ``, name in Anton, country/profile in mono, 2-line clamped mood quote in Patrick Hand, POP/⚙/AP stat tags, magenta "PICKED" stamp when selected; hover lifts to rotation 0 + translateY(−2px)). +- **Right:** stacked panels — human roster, difficulty, begin panel with live opponent count. +- **Header copy:** kicker "A PARODY IN POOR TASTE", title "NUKE!" (magenta "!"), tagline "Everybody plays. **Nobody** wins.", yellow ribbon "SELECT YOUR ENEMIES". +- **Difficulty labels** (mapped to existing engine values): easy = "Fine, Probably", normal = "Not Great", hard = "We're Cooked". Selected option: ink bg, paper text, magenta check square. +- **Pick behavior:** toggle on click; if already 4 opponents picked, drop the oldest pick and add the new one (adopted from handoff). Begin disabled until ≥2 opponents and all human rows complete. +- **Behavior kept from existing code, diverging from handoff:** multi-human hotseat roster (1–5 players, add/remove, per-player name + country inputs, player1 defaults to Rufus T. Firefly / Freedonia and cannot be removed) — styled as roster rows in the same panel language. Seed input stays. All `dispatch({type:'START_GAME'…})` wiring unchanged. +- Begin button: "Begin the End ↯". + +## 6. Testing & verification + +- Existing vitest + testing-library suite stays green; Setup behavior tests updated for new markup (queries by role/label, not old class names). +- New render tests: `Portrait` AI vs human variant; `HoldButton` fires `onComplete` after hold, cancels on early release. +- Visual verification: `npm run dev` side-by-side with the prototype (`design_handoff_nuke_game/index.html`). + +## 7. Out of scope (later slices / never) + +- Re-theming Planning, HotseatHandoff, AiConferring (slice 2), Action (slice 3), RoundSummary + Winners (slice 4). +- Framer Motion animation work (separate, already-specified phase). +- Handoff's DEV NAV bar — not ported. +- Commissioned portrait art — future swap behind the `` API. +- The prototype's mock data (`data.jsx`) — never imported; live engine state only. Its copy (mood quotes, difficulty labels, headlines) is lifted as content where a screen needs it. From 19ae92e60799af8be58104cc31fa62118ffe6922 Mon Sep 17 00:00:00 2001 From: gethi Date: Sun, 19 Jul 2026 14:00:35 +0100 Subject: [PATCH 04/43] docs: implementation plan for comic re-theme slice 1 Co-Authored-By: Claude Fable 5 --- .../plans/2026-07-19-comic-retheme-slice1.md | 874 ++++++++++++++++++ 1 file changed, 874 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-19-comic-retheme-slice1.md diff --git a/docs/superpowers/plans/2026-07-19-comic-retheme-slice1.md b/docs/superpowers/plans/2026-07-19-comic-retheme-slice1.md new file mode 100644 index 0000000..c827a40 --- /dev/null +++ b/docs/superpowers/plans/2026-07-19-comic-retheme-slice1.md @@ -0,0 +1,874 @@ +# Comic Re-theme Slice 1 (Foundation + Setup) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Land the comic-tabloid design foundation (tokens, fonts, SVG portrait system, shared comic primitives) and re-theme the Setup screen to the design handoff. + +**Architecture:** Global `tokens.css` carries the design language (custom properties + base classes); typed React components in `src/ui/components/comic/` and `src/ui/components/Portrait.tsx` port the handoff prototype (`design_handoff_nuke_game/`); `Setup.tsx` is rebuilt on top of them keeping all existing behavior (multi-human roster, seed, START_GAME dispatch). + +**Tech Stack:** Vite 5, React 18, TypeScript 5, CSS modules, vitest + @testing-library/react, @fontsource self-hosted fonts. + +**Spec:** `docs/superpowers/specs/2026-07-19-comic-retheme-slice1-design.md` + +## Global Constraints + +- Design source of truth: `design_handoff_nuke_game/` (in-repo). Lift exact values; never import its files at runtime, never use its mock `data.jsx` as state. +- Difficulty labels verbatim: easy = "Fine, Probably", normal = "Not Great", hard = "We're Cooked". +- Header copy verbatim: kicker "A PARODY IN POOR TASTE", title "NUKE!" (magenta "!"), tagline `"Everybody plays. Nobody wins."` (Nobody bold), ribbon "SELECT YOUR ENEMIES", begin button "Begin the End ↯". +- Human portrait accents fixed per slot: player1 `--cyan`, player2 `--green`, player3 `--yellow-soft`, player4 `--magenta-deep`, player5 `--ink-soft`. +- Stat values on cast tiles come from `LEADER_PROFILES` (engine truth), NOT the handoff's sample numbers. +- The handoff DEV NAV is not ported. + +## Guardrails (from better-memory, surfaced per planning reflection be7ad6bf, conf 0.9) + +- **[[aa0375da]] Product emojis are design language, not whimsy** (conf 0.7): the global "no emojis" user preference applies to assistant text only. The ↯ ⚙ 🦆 flag emojis in this plan's UI code are spec'd — do NOT strip them. +- **[[326f4462]] Every commit must independently typecheck** (conf 0.55, 2 evid): `noUnusedLocals` fails on imports added "for a later task". Add each import only in the commit that uses it. +- **[[063500c1]] No guarded assertions** (conf 0.7, 3 evid, vitest): never `if (cond) expect(...)`; assert preconditions unconditionally. +- **[[9f01589a]] Subagents may stop before the final commit** (conf 0.6): controller verifies `git status` after each task; if work is on disk uncommitted, verify tests/typecheck and commit directly. +- **[[0c83e25d]] Locators match DOM textContent, not CSS-rendered text** (conf 0.8): `text-transform: uppercase` in CSS keeps testing-library queries on the source string working — keep semantic case in JSX, uppercase in CSS. + +Dismissed as not applicable here: AI-planner/resource-projection reflections (31c145bf, 125145ea, 0409087f, ef7d086e — no engine changes in this slice), python tempfile (85e7ec84), freeze logging (228dfa6d), clone-and-mutate orchestrator (977f97cc), consume-split (f091be56), scaffold-defaults (a6230fe0 — project already has sources/tests), website/README sync (98056ebc — no better-memory code touched; repo has no website). + +## File Structure + +``` +src/ui/tokens.css (new — global tokens + base classes) +src/ui/main.tsx (modify — import fonts + tokens) +src/ui/portraits/faces.tsx (new — 8 face SVG components) +src/ui/portraits/index.ts (new — FACES, PORTRAIT_META, HUMAN_ACCENTS, extractFlag) +src/ui/components/Portrait.tsx (new) +src/ui/components/comic/*.tsx (new — 14 primitives + index.ts barrel) +src/ui/screens/Setup.tsx (rewrite) +src/ui/screens/Setup.module.css (rewrite) +src/ui/content/cast.ts (new — profile/mood copy per AI leader) +tests/ui/Portrait.test.tsx (new) +tests/ui/HoldButton.test.tsx (new) +tests/ui/Setup.multihuman.test.tsx (modify — begin-button assertions) +package.json (modify — @fontsource deps) +``` + +--- + +### Task 1: Design tokens + fonts + +**Files:** +- Create: `src/ui/tokens.css` +- Modify: `src/ui/main.tsx` +- Modify: `package.json` (via npm install) + +**Interfaces:** +- Produces: global classes `.paper .panel .panel-title .display .tabloid .pop .hand .bang .mono .upper .italic .stamp .tag .btn .bubble .burst .halftone-* .row .col .grow .center .between .gap-* .wrap .tc .fadein .popin .wobble .screen .app .app-stage` and all `--*` custom properties — every later task relies on these exact names. + +- [ ] **Step 1: Install fonts** + +Run: +```bash +npm install -D @fontsource/anton @fontsource/playfair-display @fontsource/work-sans @fontsource/jetbrains-mono @fontsource/bowlby-one @fontsource/patrick-hand @fontsource/bangers +``` +Expected: 7 packages added to devDependencies. + +- [ ] **Step 2: Create `src/ui/tokens.css`** + +Copy `design_handoff_nuke_game/tokens.css` verbatim with exactly two changes: +1. Delete the dev-nav block — everything from the comment `/* Bottom debug nav (toggleable via tweaks) */` through the `.devnav button:hover { ... }` rule (source lines 314–340). +2. Keep everything else byte-identical (palette, type utilities, `.panel`, `.stamp`, `.tag`, `.btn`, `.bubble`, `.burst`, scrollbar, `.app`/`.screen`, utility classes, keyframes). + +- [ ] **Step 3: Wire imports in `src/ui/main.tsx`** + +```tsx +import '@fontsource/anton'; +import '@fontsource/playfair-display/400.css'; +import '@fontsource/playfair-display/700.css'; +import '@fontsource/playfair-display/900.css'; +import '@fontsource/playfair-display/400-italic.css'; +import '@fontsource/playfair-display/700-italic.css'; +import '@fontsource/work-sans/400.css'; +import '@fontsource/work-sans/500.css'; +import '@fontsource/work-sans/600.css'; +import '@fontsource/work-sans/700.css'; +import '@fontsource/work-sans/800.css'; +import '@fontsource/jetbrains-mono/400.css'; +import '@fontsource/jetbrains-mono/700.css'; +import '@fontsource/bowlby-one'; +import '@fontsource/patrick-hand'; +import '@fontsource/bangers'; +import './tokens.css'; +import { createRoot } from 'react-dom/client'; +import App from './App'; + +const rootElement = document.getElementById('root'); +if (!rootElement) throw new Error('Root element #root not found in index.html'); +createRoot(rootElement).render(); +``` + +- [ ] **Step 4: Verify** + +Run: `npm run typecheck` — Expected: clean. +Run: `npm run test:run` — Expected: all existing tests pass (tokens are additive; no screen imports them by class yet). +Run: `npm run build` — Expected: builds; font files emitted to dist assets. + +- [ ] **Step 5: Commit** + +```bash +git add src/ui/tokens.css src/ui/main.tsx package.json package-lock.json +git commit -m "feat(ui): comic-tabloid design tokens + self-hosted fonts" +``` + +--- + +### Task 2: Portrait system + +**Files:** +- Create: `src/ui/portraits/faces.tsx` +- Create: `src/ui/portraits/index.ts` +- Create: `src/ui/components/Portrait.tsx` +- Test: `tests/ui/Portrait.test.tsx` + +**Interfaces:** +- Consumes: `isHuman(id: LeaderId): boolean` from `src/engine/state.ts`; `LeaderId` from `src/engine/types.ts`; CSS vars from Task 1. +- Produces: + - `` (default size 80, shape circle, showBadge true) + - `extractFlag(country: string): string` + - `PORTRAIT_META: Record` (AI cast + disparage) + - `HUMAN_ACCENTS: Record<'player1'|'player2'|'player3'|'player4'|'player5', string>` + +- [ ] **Step 1: Write the failing test** — `tests/ui/Portrait.test.tsx` + +```tsx +import { describe, expect, it } from 'vitest'; +import { render } from '@testing-library/react'; +import Portrait from '../../src/ui/components/Portrait'; +import { extractFlag } from '../../src/ui/portraits'; + +describe('', () => { + it('renders an AI leader face SVG with its flag badge', () => { + const { container, getByText } = render(); + expect(container.querySelector('svg')).not.toBeNull(); + expect(getByText('🇺🇸')).toBeInTheDocument(); + }); + + it('renders the Groucho disguise for human slots with the supplied flag', () => { + const { container, getByText } = render( + , + ); + // Groucho face is identifiable by its data marker + expect(container.querySelector('[data-face="groucho"]')).not.toBeNull(); + expect(getByText('🦆')).toBeInTheDocument(); + }); + + it('hides the badge when showBadge is false', () => { + const { queryByText } = render( + , + ); + expect(queryByText('🇺🇸')).toBeNull(); + }); +}); + +describe('extractFlag', () => { + it('extracts a regional-indicator flag', () => { + expect(extractFlag('🇺🇸 US')).toBe('🇺🇸'); + }); + it('extracts a pictographic emoji', () => { + expect(extractFlag('🦆 Freedonia')).toBe('🦆'); + }); + it('falls back to the globe for plain text', () => { + expect(extractFlag('Freedonia')).toBe('🌐'); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run tests/ui/Portrait.test.tsx` +Expected: FAIL — cannot resolve `../../src/ui/components/Portrait`. + +- [ ] **Step 3: Create `src/ui/portraits/faces.tsx`** + +Port the eight face functions from `design_handoff_nuke_game/portraits.jsx` (lines 9–249) verbatim as named exports typed `(): JSX.Element` — `ChumpFace`, `NetanyahooFace`, `KhameneverhereFace`, `StarmlessFace`, `CarnageFace`, `MileighFace`, `DisparageFace`, `GrouchoFace` (rename of `PlayerFace`). The JSX is already camelCase-attribute React code; copy bodies unchanged, with one addition: `GrouchoFace`'s root `` gets `data-face="groucho"` (test hook + future art-swap marker). + +- [ ] **Step 4: Create `src/ui/portraits/index.ts`** + +```ts +import type { LeaderId } from '../../engine/types'; +import { + CarnageFace, ChumpFace, DisparageFace, GrouchoFace, + KhameneverhereFace, MileighFace, NetanyahooFace, StarmlessFace, +} from './faces'; + +/** Face component per AI leader; every human slot maps to the Groucho disguise. */ +export const FACES: Record JSX.Element> = { + chump: ChumpFace, + netanyahoo: NetanyahooFace, + khameneverhere: KhameneverhereFace, + starmless: StarmlessFace, + carnage: CarnageFace, + 'mileigh-hem': MileighFace, + disparage: DisparageFace, + player1: GrouchoFace, + player2: GrouchoFace, + player3: GrouchoFace, + player4: GrouchoFace, + player5: GrouchoFace, +}; + +/** Signature disc color + badge flag for the fixed cast. */ +export const PORTRAIT_META: Record = { + chump: { color: 'var(--c-chump)', flag: '🇺🇸' }, + netanyahoo: { color: 'var(--c-netan)', flag: '🇮🇱' }, + khameneverhere: { color: 'var(--c-khamen)', flag: '🇮🇷' }, + starmless: { color: 'var(--c-starm)', flag: '🇬🇧' }, + carnage: { color: 'var(--c-carnage)', flag: '🇨🇦' }, + 'mileigh-hem': { color: 'var(--c-mileigh)', flag: '🇦🇷' }, + disparage: { color: 'var(--c-disparage)', flag: '🇬🇧' }, +}; + +/** Fixed per-slot accent for human (Groucho) portraits — spec §3. */ +export const HUMAN_ACCENTS: Record = { + player1: 'var(--cyan)', + player2: 'var(--green)', + player3: 'var(--yellow-soft)', + player4: 'var(--magenta-deep)', + player5: 'var(--ink-soft)', +}; + +/** Leading flag/emoji of a country string ("🇺🇸 US" → "🇺🇸"), 🌐 fallback. */ +export function extractFlag(country: string): string { + const m = country.trim().match(/^(\p{RI}\p{RI}|\p{Extended_Pictographic})/u); + return m ? m[0] : '🌐'; +} + +export type { LeaderId }; +``` + +- [ ] **Step 5: Create `src/ui/components/Portrait.tsx`** + +```tsx +import { isHuman } from '../../engine/state'; +import type { LeaderId } from '../../engine/types'; +import { FACES, HUMAN_ACCENTS, PORTRAIT_META } from '../portraits'; + +export interface PortraitProps { + leaderId: LeaderId; + /** Pixel diameter (circle) or edge (square). */ + size?: number; + shape?: 'circle' | 'square'; + showBadge?: boolean; + /** Badge flag override — humans pass their chosen country's flag. */ + flag?: string; + className?: string; +} + +export default function Portrait({ + leaderId, size = 80, shape = 'circle', showBadge = true, flag, className, +}: PortraitProps) { + const human = isHuman(leaderId); + const Face = FACES[leaderId]; + if (!Face) return null; + const color = human ? HUMAN_ACCENTS[leaderId] : PORTRAIT_META[leaderId].color; + const badgeFlag = flag ?? (human ? '🌐' : PORTRAIT_META[leaderId].flag); + const badgeSize = Math.max(16, Math.round(size * 0.28)); + + return ( + + + + + + + {shape !== 'square' && ( + + )} + + {showBadge && ( + + {badgeFlag} + + )} + + ); +} +``` + +- [ ] **Step 6: Run test to verify it passes** + +Run: `npx vitest run tests/ui/Portrait.test.tsx` +Expected: PASS (6 tests). + +- [ ] **Step 7: Typecheck + full test run + commit** + +Run: `npm run typecheck` then `npm run test:run` — Expected: clean / all pass. + +```bash +git add src/ui/portraits src/ui/components/Portrait.tsx tests/ui/Portrait.test.tsx +git commit -m "feat(ui): geometric SVG portrait system with Groucho human variant" +``` + +--- + +### Task 3: Comic primitives + +**Files:** +- Create: `src/ui/components/comic/Halftone.tsx`, `Panel.tsx`, `Tag.tsx`, `Stamp.tsx`, `Bubble.tsx`, `Burst.tsx`, `Btn.tsx`, `Stat.tsx`, `ApMeter.tsx`, `PhaseTrack.tsx`, `DamageBadge.tsx`, `RelBadge.tsx`, `HoldButton.tsx`, `Ribbon.tsx`, `index.ts` +- Test: `tests/ui/HoldButton.test.tsx` + +**Interfaces:** +- Consumes: global classes/vars from Task 1. +- Produces (barrel `src/ui/components/comic/index.ts` re-exports all): + - `Halftone({ color?, size?, opacity?, style? })` + - `Panel({ title?, tilt?: -2|-1|0|1|2, dark?, halftone?, halftoneColor?, padding?, className?, style?, children })` + - `Tag({ children, color?: 'ink'|'cyan'|'magenta'|'yellow'|'green'|'outline', style? })` + - `Stamp({ children, color?: 'magenta'|'ink'|'cyan'|'yellow'|'green', rotate?, style? })` + - `Bubble({ children, tail?: 'bl'|'tl', thought?, style? })` + - `Burst({ children, color?, rotate?, size?, style? })` + - `Btn({ children, variant?: 'default'|'primary'|'danger'|'cyan'|'ghost', size?: 'md'|'lg'|'xl', onClick?, disabled?, type?, style? })` + - `Stat({ label, value, sub?, accent?, style? })` + - `ApMeter({ used: number, max: number })` + - `PhaseTrack({ phases: string[], current: string, done: string[] })` + - `DamageBadge({ value: number|string, label?, rotate?, style? })` + - `RelBadge({ kind: 'hit-you'|'wooing'|'you-woo'|'grudge'|'mood'|'gain'|'neutral'|'harm'|'cyan'|'yellow', children })` + - `HoldButton({ onComplete: () => void, duration?: number, children, disabled?, style? })` + - `Ribbon({ children, color?, rotate?, style? })` + +Port each from `design_handoff_nuke_game/ui.jsx` (`RoundBadge` is NOT ported — slice 2). Mechanical translation rules: add a typed props interface per component; replace `React.useState/useRef` with named imports; keep inline styles and class names byte-equivalent; `Btn` renders the same class list (`btn` + variant + size). No behavioral changes except `HoldButton` below. + +- [ ] **Step 1: Write the failing test** — `tests/ui/HoldButton.test.tsx` + +```tsx +import { describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, waitFor } from '@testing-library/react'; +import { HoldButton } from '../../src/ui/components/comic'; + +describe('', () => { + it('fires onComplete after the hold duration', async () => { + const onComplete = vi.fn(); + const { getByRole } = render( + SEAL, + ); + fireEvent.mouseDown(getByRole('button')); + await waitFor(() => expect(onComplete).toHaveBeenCalledTimes(1), { timeout: 2000 }); + }); + + it('does not fire when released early', async () => { + const onComplete = vi.fn(); + const { getByRole } = render( + SEAL, + ); + const btn = getByRole('button'); + fireEvent.mouseDown(btn); + fireEvent.mouseUp(btn); + await new Promise((r) => setTimeout(r, 300)); + expect(onComplete).not.toHaveBeenCalled(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run tests/ui/HoldButton.test.tsx` +Expected: FAIL — cannot resolve `../../src/ui/components/comic`. + +- [ ] **Step 3: Create the 14 components + barrel** + +`HoldButton.tsx` (the only one with logic changes — rAF cleanup on unmount): + +```tsx +import { useEffect, useRef, useState } from 'react'; +import type { CSSProperties, ReactNode } from 'react'; + +export interface HoldButtonProps { + onComplete: () => void; + duration?: number; + children: ReactNode; + disabled?: boolean; + style?: CSSProperties; +} + +export default function HoldButton({ + onComplete, duration = 600, children, disabled, style, +}: HoldButtonProps) { + const [progress, setProgress] = useState(0); + const frameRef = useRef(null); + const startRef = useRef(0); + + const cancelHold = () => { + if (frameRef.current !== null) cancelAnimationFrame(frameRef.current); + frameRef.current = null; + setProgress(0); + }; + + const startHold = () => { + if (disabled) return; + startRef.current = Date.now(); + const tick = () => { + const p = Math.min(1, (Date.now() - startRef.current) / duration); + setProgress(p); + if (p >= 1) { + cancelHold(); + onComplete(); + } else { + frameRef.current = requestAnimationFrame(tick); + } + }; + frameRef.current = requestAnimationFrame(tick); + }; + + useEffect(() => cancelHold, []); + + return ( + + ); +} +``` + +The other 13: translate from `design_handoff_nuke_game/ui.jsx` per the mechanical rules above (typed props interface, named React imports, byte-equivalent styles/classes). Source line ranges: Halftone 7–22, Panel 25–39, Tag 42–44, Stamp 47–53, Bubble 56–65, Burst 68–83, Btn 86–99, Stat 102–110, ApMeter 113–153, PhaseTrack 175–206, DamageBadge 209–231, RelBadge 234–263, Ribbon 318–338. + +`index.ts` barrel: + +```ts +export { default as Halftone } from './Halftone'; +export { default as Panel } from './Panel'; +export { default as Tag } from './Tag'; +export { default as Stamp } from './Stamp'; +export { default as Bubble } from './Bubble'; +export { default as Burst } from './Burst'; +export { default as Btn } from './Btn'; +export { default as Stat } from './Stat'; +export { default as ApMeter } from './ApMeter'; +export { default as PhaseTrack } from './PhaseTrack'; +export { default as DamageBadge } from './DamageBadge'; +export { default as RelBadge } from './RelBadge'; +export { default as HoldButton } from './HoldButton'; +export { default as Ribbon } from './Ribbon'; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run tests/ui/HoldButton.test.tsx` +Expected: PASS (2 tests). Note: jsdom 24 implements requestAnimationFrame on real timers; no mocking needed. + +- [ ] **Step 5: Typecheck + full test run + commit** + +Run: `npm run typecheck` then `npm run test:run` — Expected: clean / all pass. + +```bash +git add src/ui/components/comic tests/ui/HoldButton.test.tsx +git commit -m "feat(ui): shared comic primitives ported from design handoff" +``` + +--- + +### Task 4: Setup screen re-theme + +**Files:** +- Create: `src/ui/content/cast.ts` +- Rewrite: `src/ui/screens/Setup.tsx`, `src/ui/screens/Setup.module.css` +- Modify: `tests/ui/Setup.multihuman.test.tsx` + +**Interfaces:** +- Consumes: `Panel, Tag, Stamp, Btn, Ribbon` from `../components/comic`; `Portrait` from `../components/Portrait`; `extractFlag` from `../portraits`; `LEADER_PROFILES` from `../../engine/balance`; existing `ScreenProps`/dispatch contract (unchanged). +- Produces: `CAST_COPY: Record` in `src/ui/content/cast.ts`. + +Behavior kept from the current `Setup.tsx` verbatim: `humans` state array + `addHuman`/`removeHuman`/`updateHuman` (P1 fixed, re-key on remove), `canStart` (≥2 AI picked AND ≤4 AND all human rows complete), `generateSeed`, the `START_GAME` dispatch payload, aria-labels `Name for P{n}` / `Country for P{n}` / `Remove P{n}` / add-button text "+ Add another human". Behavior adopted from the handoff: cast-pick toggle drops the OLDEST pick when a 5th is clicked (replaces the current no-op at 4). + +- [ ] **Step 1: Update the test first** — in `tests/ui/Setup.multihuman.test.tsx` replace the last test: + +```tsx + it('Begin button gates on 2+ opponents and shows the live count', () => { + const dispatch = vi.fn(); + render(); + const begin = screen.getByRole('button', { name: /begin the end/i }); + expect(begin).toBeDisabled(); + fireEvent.click(screen.getByRole('button', { name: /chump/i })); + fireEvent.click(screen.getByRole('button', { name: /carnage/i })); + expect(begin).toBeEnabled(); + expect(screen.getByText(/2/)).toBeInTheDocument(); + fireEvent.click(begin); + expect(dispatch).toHaveBeenCalledWith( + expect.objectContaining({ type: 'START_GAME' }), + ); + }); +``` + +Add one new test for the oldest-drop rule: + +```tsx + it('picking a 5th opponent drops the oldest pick', () => { + render(); + for (const name of [/chump/i, /carnage/i, /starmless/i, /netanyahoo/i]) { + fireEvent.click(screen.getByRole('button', { name })); + } + fireEvent.click(screen.getByRole('button', { name: /khameneverhere/i })); + // chump (oldest) dropped; the four later picks remain + expect(screen.getByRole('button', { name: /chump/i })).toHaveAttribute('aria-pressed', 'false'); + for (const name of [/carnage/i, /starmless/i, /netanyahoo/i, /khameneverhere/i]) { + expect(screen.getByRole('button', { name })).toHaveAttribute('aria-pressed', 'true'); + } + }); +``` + +- [ ] **Step 2: Run tests to verify state** + +Run: `npx vitest run tests/ui/Setup.multihuman.test.tsx` +Expected: the two new/changed tests FAIL (old markup); the three roster tests still PASS. + +- [ ] **Step 3: Create `src/ui/content/cast.ts`** (copy lifted from handoff `data.jsx` LEADERS) + +```ts +/** Per-leader satirical copy for the Setup cast tiles (from the design handoff). */ +export const CAST_COPY: Record = { + chump: { profile: 'Coward', mood: "Many people are saying we should hit somebody. We won't, but we should." }, + khameneverhere: { profile: 'Grudge', mood: 'Orders filed. Orders unsigned.' }, + starmless: { profile: 'Cautious', mood: 'So, let me be very clear…' }, + carnage: { profile: 'Rational', mood: 'Nostalgia is not a strategy.' }, + 'mileigh-hem': { profile: 'Glass cannon', mood: 'Banking. Cooking. ¡Viva la libertad, carajo!' }, + netanyahoo: { profile: 'Warmonger', mood: 'Drawing a fresh red line on the bomb diagram.' }, +}; +``` + +- [ ] **Step 4: Rewrite `Setup.tsx`** + +Keep the entire state/handler block of the current file (lines 1–74) except `toggleAi` becomes: + +```tsx + function toggleAi(id: LeaderId) { + setSelectedAi((current) => { + if (current.includes(id)) return current.filter((x) => x !== id); + if (current.length >= 4) return [...current.slice(1), id]; + return [...current, id]; + }); + } +``` + +Render (full replacement of the return block): + +```tsx + return ( +
+
+
+
+
A Parody In Poor Taste
+

+ NUKE! +

+
+ "Everybody plays. Nobody wins." +
+
+ Select Your Enemies +
+ +
+ +
+ {AI_IDS.map((id, i) => { + const profile = LEADER_PROFILES[id]; + const copy = CAST_COPY[id]; + const picked = selectedAi.includes(id); + const tilts = [-1.4, 0.8, -0.5, 1.2, -0.9, 0.4]; + return ( + + ); + })} +
+
Pick 2–4 opponents. You're already at the table.
+
+ +
+ + {humans.map((h, idx) => ( +
+ +
+ updateHuman(idx, 'name', e.target.value)} + placeholder="Name" + /> + updateHuman(idx, 'country', e.target.value)} + placeholder="🌐 Country" + /> +
+ {idx > 0 && ( + + )} +
+ ))} + +
+ Default: Rufus T. Firefly of Freedonia (Duck Soup, 1933). Change if + you'd prefer to die under a different name. +
+
+ + +
+ {( + [ + { id: 'easy', label: 'Fine, Probably', sub: "30% random AI · what's the worst that could happen" }, + { id: 'normal', label: 'Not Great', sub: '10% random · things are getting spicy' }, + { id: 'hard', label: "We're Cooked", sub: '0% random + lookahead · kiss the kids' }, + ] as const + ).map((d) => ( + + ))} +
+ +
+ + +
+
+
Opponents Picked
+
+ {selectedAi.length} + /4 +
+
+ + Begin the End ↯ + +
+
+
+
+
+
+ ); +``` + +Imports to add at top: `Panel, Tag, Stamp, Btn, Ribbon` from `../components/comic`; `Portrait` from `../components/Portrait`; `extractFlag` from `../portraits`; `CAST_COPY` from `../content/cast`. + +- [ ] **Step 5: Rewrite `Setup.module.css`** + +Full replacement (uppercase via CSS per guardrail 0c83e25d — JSX keeps semantic case): + +```css +.setup { padding: 32px 20px 96px; min-height: 100vh; } +.wrap { max-width: 1180px; margin: 0 auto; } + +.header { display: flex; justify-content: space-between; align-items: flex-end; flex-wrap: wrap; gap: 16px; margin-bottom: 24px; } +.kicker { font-size: 12px; letter-spacing: 0.3em; color: var(--magenta); text-transform: uppercase; } +.title { font-size: clamp(64px, 10vw, 140px); margin: 4px 0 0; letter-spacing: -0.02em; color: var(--ink); } +.bang { color: var(--magenta); } +.tagline { font-size: 18px; margin-top: 4px; } + +.grid { display: grid; grid-template-columns: 1fr; gap: 24px; } +@media (min-width: 920px) { .grid { grid-template-columns: minmax(0, 1.6fr) minmax(0, 1fr); } } + +.castGrid { display: grid; grid-template-columns: 1fr; gap: 14px; } +@media (min-width: 640px) { .castGrid { grid-template-columns: 1fr 1fr; } } + +.castTile { + position: relative; text-align: left; padding: 14px; + border: 3px solid rgba(20, 18, 20, 0.5); background: var(--paper-warm); + cursor: pointer; box-shadow: 4px 4px 0 var(--ink); + font-family: inherit; transition: transform 100ms ease; +} +.castTile:hover { transform: rotate(0deg) translateY(-2px) !important; } +.picked { background: var(--paper-bright); border-color: var(--ink); } +.tileRow { display: flex; gap: 10px; align-items: flex-start; } +.tileBody { flex: 1; min-width: 0; } +.tileName { font-size: 18px; line-height: 1; margin-top: 2px; } +.picked .tileName { padding-right: 70px; } +.tileMeta { font-size: 9px; opacity: 0.65; letter-spacing: 0.1em; margin-top: 2px; text-transform: uppercase; } +.tileMood { font-size: 13px; margin-top: 4px; line-height: 1.2; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; } +.tileTags { display: flex; gap: 3px; margin-top: 6px; flex-wrap: wrap; } +.castHint { margin-top: 14px; font-size: 12px; font-style: italic; color: var(--ink-soft); } + +.humanCard { display: flex; gap: 12px; align-items: center; padding: 8px 0; border-bottom: 2px dashed var(--paper-edge); position: relative; } +.humanFields { flex: 1; min-width: 0; } +.nameInput { width: 100%; border: 0; border-bottom: 3px solid var(--ink); background: transparent; font-size: 20px; padding: 2px 0; } +.countryInput { margin-top: 6px; border: 2px solid var(--ink); background: var(--paper); padding: 2px 8px; font-family: var(--font-display); font-size: 13px; letter-spacing: 0.1em; width: 160px; } +.removeBtn { background: var(--ink); color: var(--paper); border: 2px solid var(--ink); width: 24px; height: 24px; cursor: pointer; font-size: 14px; line-height: 1; } +.addBtn { margin-top: 10px; font-size: 12px; } +.humanHint { font-size: 12px; font-style: italic; margin-top: 12px; color: var(--ink-soft); } + +.diffList { display: grid; gap: 6px; } +.diffOption { display: flex; align-items: center; gap: 12px; padding: 10px 12px; cursor: pointer; text-align: left; background: transparent; color: var(--ink); border: 2.5px solid var(--ink); font-family: inherit; } +.diffSelected { background: var(--ink); color: var(--paper); } +.diffCheck { width: 14px; height: 14px; border: 2.5px solid currentColor; flex: 0 0 auto; } +.diffSelected .diffCheck { background: var(--magenta); } +.diffText { flex: 1; display: flex; flex-direction: column; } +.diffLabel { font-size: 16px; letter-spacing: 0.06em; } +.diffSub { font-size: 11px; opacity: 0.7; } +.seedBlock { display: block; margin-top: 12px; } +.seedLabel { font-size: 10px; letter-spacing: 0.18em; display: block; margin-bottom: 4px; text-transform: uppercase; } +.seedInput { width: 100%; padding: 6px 8px; border: 2px solid var(--ink); background: var(--paper); font-size: 13px; } + +.beginRow { display: flex; justify-content: space-between; align-items: center; gap: 16px; flex-wrap: wrap; } +.countLabel { font-size: 11px; letter-spacing: 0.18em; text-transform: uppercase; } +.countValue { font-size: 36px; color: var(--ink); } +.countLow { color: var(--magenta); } +.countMax { opacity: 0.4; font-size: 24px; } +``` + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `npx vitest run tests/ui/Setup.multihuman.test.tsx` +Expected: PASS (6 tests: 3 roster + begin-gate + oldest-drop + original add/remove). + +- [ ] **Step 7: Typecheck + full test run + commit** + +Run: `npm run typecheck` then `npm run test:run` — Expected: clean / all pass. + +```bash +git add src/ui/screens/Setup.tsx src/ui/screens/Setup.module.css src/ui/content/cast.ts tests/ui/Setup.multihuman.test.tsx +git commit -m "feat(ui): re-theme Setup screen to comic-tabloid design" +``` + +--- + +### Task 5: Verification sweep + +**Files:** none created; verification only. + +- [ ] **Step 1: Full suite + typecheck + build** + +Run: `npm run test:run && npm run typecheck && npm run build` +Expected: all pass, clean, build succeeds. + +- [ ] **Step 2: Visual check against the prototype** + +Run `npm run dev`; open the app and `design_handoff_nuke_game/index.html` side by side. Verify on the Setup screen: paper texture + ink frame, NUKE! masthead with magenta bang, yellow ribbon, rotated cast tiles with SVG portraits + PICKED stamps, difficulty gag labels, magenta Begin button, human roster rows with Groucho portraits. Check responsive collapse below 920px. + +- [ ] **Step 3: Update knowledge graph** + +Run: `graphify update .` +Expected: graph refreshed, no API cost. + +- [ ] **Step 4: Commit any stragglers** + +Run `git status` — if verification produced fixes, commit them with a descriptive message. + +--- + +## Self-Review Notes + +- Spec §1 tokens → Task 1; §2 fonts → Task 1; §3 portraits → Task 2; §4 primitives → Task 3; §5 Setup → Task 4; §6 testing → Tasks 2–5. §7 exclusions respected (no RoundBadge/dev-nav/animations). +- Verbatim-port steps (faces, 13 primitives, tokens.css) reference exact in-repo source line ranges instead of duplicating hundreds of lines — the source bundle is checked in at `design_handoff_nuke_game/` and is the design source of truth. +- Type consistency: `Portrait` prop `flag` (Task 2) is what Task 4's human roster passes; `extractFlag` exported from `../portraits` matches Task 4's import; `HoldButton` barrel export name matches its test import. Nothing in slice 1 consumes `ApMeter`/`PhaseTrack`/etc. yet — they exist for slices 2–4 per spec §4. From 96535889574b6cef1f9e59b0563dbf0198f48af3 Mon Sep 17 00:00:00 2001 From: gethi Date: Sun, 19 Jul 2026 14:07:34 +0100 Subject: [PATCH 05/43] docs: p3 re-theme spec Co-Authored-By: Claude Fable 5 --- .../2026-07-19-comic-retheme-slice3-design.md | 218 ++++++++++++++++++ 1 file changed, 218 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-19-comic-retheme-slice3-design.md diff --git a/docs/superpowers/specs/2026-07-19-comic-retheme-slice3-design.md b/docs/superpowers/specs/2026-07-19-comic-retheme-slice3-design.md new file mode 100644 index 0000000..54ba954 --- /dev/null +++ b/docs/superpowers/specs/2026-07-19-comic-retheme-slice3-design.md @@ -0,0 +1,218 @@ +# nuke — comic-tabloid re-theme, slice 3: Action screen (world map) + +**Date:** 2026-07-19 +**Status:** spec for phase 3 of the comic re-theme +**Depends on:** slice 1 (`2026-07-19-comic-retheme-slice1-design.md`) — tokens.css, fonts, ``, and the shared comic primitives in `src/ui/components/comic/` must exist before this slice starts. + +## 0. Context + +Slice 3 of the four-slice comic-tabloid re-theme (see slice-1 spec §0). This slice rebuilds the Action screen from the static grouped-event list into the handoff's sequential resolution playback on a stylised world map (`design_handoff_nuke_game/screens-3.jsx` `ActionScreen`, readme §5 "Action"). + +The handoff prototype plays back `SAMPLE_EVENTS` mock data; we drive the identical presentation from the real `ResolutionEvent[]` in `state.events` (`src/ui/store.ts`, populated by `AI_RESOLVE` → `resolveRound`). Nothing in the engine or store changes. + +**Adopted handoff behavior (explicit change to existing UX):** the Action screen stops showing all events at once as phase-sectioned cards and instead plays them back one step at a time with auto-advance, pause, fast, skip, and prev/next. This is the point of the slice, per the handoff. + +**Not adopted:** the handoff's `Math.random() < 0.5` Disparage cameo. Cameo appearance is already an engine decision (`DisparageCameo` events from `src/engine/cameo.ts`); the UI renders cameos when and only when the engine emitted one. + +## 1. Target files + +| File | Action | +|---|---| +| `src/ui/screens/Action.tsx` | Rewrite: playback state machine + screen layout | +| `src/ui/screens/Action.module.css` | Rewrite: ink layout, map frame, overlay positioning, breakpoints | +| `src/ui/components/WorldMap.tsx` + `WorldMap.module.css` | New: pure presentational SVG map (continents, country highlights, arcs/trails) | +| `src/ui/util/eventText.ts` | New: `formatEventText(event, game, count)` — the existing `formatEventCard` switch moved verbatim (minus `className`), returning `{ icon, body, quote? } | null` | +| `src/ui/components/DisparageCard.tsx` + `.module.css` | Re-theme: becomes the map's bottom-right cameo overlay card | +| `src/ui/components/comic/RoundBadge.tsx` | New primitive: port `RoundBadge` from `design_handoff_nuke_game/ui.jsx` (3px paper border, ink bg, small letter-spaced label over Anton number). It was not in the slice-1 primitive list | +| `src/ui/components/EventCard.tsx` + `.module.css` | Delete (its only consumer is Action; the formatter logic survives in `eventText.ts`) | +| `src/ui/components/PhaseTracker.tsx` + `.module.css` | Delete (replaced by the slice-1 `PhaseTrack` comic primitive) | +| `src/ui/util/eventGrouping.ts` | Unchanged — still the grouping source of truth | +| `src/ui/store.ts`, `src/engine/**` | Unchanged | + +## 2. Playback model (state wiring) + +### 2.1 Building the step list + +Pure function inside `Action.tsx` (exported for tests): `buildPlaybackSteps(events: ResolutionEvent[]): PlaybackStep[]`. + +```ts +interface PlaybackStep { + phase: Phase; // 'DEFENCES' | 'BUILDS' | 'PROPAGANDA' | 'WOOING' | 'LAUNCHES' | 'FINAL_RETALIATIONS' + event: ResolutionEvent; // the grouped representative event + count: number; // from groupPhaseEvents + cameo?: Extract; +} +``` + +- Reuse the existing phase-cursor logic from today's `Action.tsx` unchanged: `phaseAdvanceFor` assigns each event to a phase bucket; `isRenderable` filters out `OrdersSealed`, `OutcomeReached`, `DefenceConsumed`, `PreRoundMood`, `PostRoundReaction`, `DisparageColumn`. +- Per phase, run `groupPhaseEvents` (unchanged), then flatten buckets in `PHASE_ORDER` into one flat step list. +- **`DisparageCameo` events do not get their own step.** Remove them from the flat list and attach each one as `cameo` on the immediately preceding step (which, given engine ordering, is the impact it follows). A cameo with no preceding step is dropped. +- `LeaderEliminated` remains a renderable step (it is today), in whatever phase the cursor was in. + +### 2.2 Component state and timers + +Local `useState` only (no store changes): `idx` (current step), `paused` (bool), `fast` (bool). + +- Auto-advance effect keyed on `[idx, paused, fast]`: `setTimeout` of **2700 ms** (fast: **1100 ms**); on fire, `idx + 1` unless already at the last step. Cleanup clears the timeout. Paused: no timer. +- `← PREV` → `max(0, idx − 1)`, disabled at `idx === 0`. +- `NEXT →` → `idx + 1`; on the last step the button instead dispatches `ACTION_DONE`. +- `SKIP →` (header) → dispatches `ACTION_DONE` from anywhere. +- **Empty round guard:** if `buildPlaybackSteps` returns `[]`, render header + PhaseTrack + map (no overlays) + banner text `An eerily quiet round. Nobody did anything.` and the continue button only. + +### 2.3 Data sources per visual element + +| Visual | Source | +|---|---| +| Round badge number | `game.round − 1` (round has already incremented during resolution — existing behavior kept) | +| PhaseTrack current/done | phase of `steps[idx]`; done = phases of steps before `idx` excluding current | +| Event banner text | `formatEventText(step.event, game, step.count).body`, icon prefixed | +| Map highlights, arcs, portraits | actor/receiver derived per event kind (§5.3) | +| Speech bubble | `formatEventText(...).quote` (already merges attacker/sender/receiver/target quote fields) | +| Detail strip hand-font line | same `quote`, or empty when the event has none | +| DamageBadge | `ImpactPeople.deaths` (label `M`) / `ImpactInfrastructure.factoriesDestroyed` (label `FACT`) | +| Cameo card | `step.cameo.quote` | +| Leader names / flags / countries | `game.leaders[id]` (engine `LEADER_PROFILES` + player overrides) — never handoff sample data | +| Continue | `dispatch({ type: 'ACTION_DONE' })` (unchanged) | + +## 3. Layout + +Full-viewport ink screen (`background: var(--ink)`, `color: var(--paper)`, `min-height: 100vh`, padding 12, flex column) — matches handoff exactly. + +Top to bottom: + +1. **Header** — flex row, `border-bottom: 2px solid var(--paper)`, wraps (`flex-wrap: wrap; gap: 8px`). Left: `` + `ACTION` in Anton 22px, letter-spacing 0.04em. Right: three `.ctrl-btn` controls (Anton 11px, 0.12em tracking, transparent bg, 2px paper border, hover inverts to paper/ink, disabled opacity 0.3). +2. **PhaseTrack** (slice-1 primitive) — six segments (engine truth; the handoff's five-segment track is extended with WOOING): + `DEFENCE · BUILDS · PROPAGANDA · WOOING · LAUNCHES · FINAL RETAL.` + Current phase magenta with the yellow underline bar; done phases ink; pending paper. +3. **Event banner** — magenta strip (`padding 8px 12px`, 2px ink borders top/bottom, paper text, Work Sans 13px). Contents: `NOW {PHASE LABEL}` followed by the banner text. +4. **The map** — `flex: 1`, `position: relative`, `min-height: 360px`, `border: 3px solid var(--paper)`, `overflow: hidden`. Background: cyan sea with dot-grid + vertical gradient, verbatim from handoff: + `radial-gradient(rgba(0,0,0,0.3) 1px, transparent 1.4px), linear-gradient(180deg, #2a8eb4 0%, #1d7fb0 50%, #16527a 100%)`, `background-size: 6px 6px, 100% 100%`. Overlays per §5. +5. **Detail strip** — `background: var(--ink-soft)`, 2px paper border (no top), padding `10px 14px`. Row of progress dots (8px circles: past = `--paper-edge`, current = `--magenta`, future = `--ink-soft`, 1px paper border). Below: hand-font (Patrick Hand 16px) quote line. Below: `← PREV` / `NEXT →` ctrl-btns + right-aligned mono counter. + +**Breakpoint ≤600px** (verbatim from handoff): actor/receiver portrait wrappers `transform: scale(0.7)`; actor repositioned `top: 60%; left: 4%`; receiver `top: 12%; right: 4%`; impact badge `right: 8%; top: 8%; scale(0.85)`; bubble `left: 4%; top: 92%; max-width: 60%`. + +## 4. Copy (verbatim) + +| Element | Copy | +|---|---| +| Round badge label | `ROUND` | +| Screen title | `ACTION` | +| Pause toggle | `⏸ PAUSE` / `▶ RESUME` | +| Fast toggle | `⏭ FAST 3×` / `1×` | +| Skip | `SKIP →` | +| Banner tag | `NOW DEFENCE` / `NOW BUILDS` / `NOW PROPAGANDA` / `NOW WOOING` / `NOW LAUNCHES` / `NOW FINAL RETAL.` | +| Prev / Next | `← PREV` / `NEXT →` | +| Last-step next, no outcome | `TO THE NEWS →` | +| Last-step next, `game.outcome` set | `TO THE VERDICT →` | +| Counter | `Event {idx+1} of {steps.length}` | +| Cameo tag | `🍺 FROM CLACTON` (existing 🍺 kept; tag styling from handoff) | +| Empty-round banner | `An eerily quiet round. Nobody did anything.` | +| Damage badge labels | `M` (deaths) / `FACT` (factories) | +| Bursts | `BOOM!` (ImpactPeople) / `WHAM!` (ImpactInfrastructure) / `INTERCEPTED!` (MissileIntercepted) / `EVERYTHING!` (FinalRetaliationTriggered) | +| Elimination stamp | `ELIMINATED` | + +Banner/quote body text is generated by `formatEventText` from engine data — its existing strings (e.g. `"🇺🇸 Chump builds 2 factories"`) are kept as-is; product icons (⚙ 🚀 🛩 ☢ 🛡 📰 🤝 ☠️ 🏭 💥) are design language and stay. + +## 5. The world map (`WorldMap.tsx`) + +### 5.1 SVG frame + +- `viewBox="0 0 100 85"`, `preserveAspectRatio="none"`, absolutely filling the map div. The handoff uses `0 0 100 60`, which clips the South-American continent path and the ARG marker (content extends to y≈80) — extending the viewBox is a deliberate fix, paths themselves are **copied verbatim** from `screens-3.jsx` (all seven continent ``s including the separate UK island, `fill var(--paper)`, `stroke var(--ink)` 0.4, plus the faint `UK` Anton label). + +### 5.2 Country positions + +`COUNTRY_POS: Record` — AI positions and labels verbatim from the handoff; human slots 2–5 are new fixed ocean positions (invented "Freedonia archipelago"; no landmass drawn, matching how the handoff treats player1): + +| id | cx, cy | label | +|---|---|---| +| `chump` | 23, 36 | `USA` | +| `carnage` | 24, 24 | `CANADA` | +| `starmless` | 44, 13 | `UK` | +| `netanyahoo` | 58, 40 | `ISR` | +| `khameneverhere` | 64, 38 | `IRAN` | +| `mileigh-hem` | 33, 78 | `ARG` | +| `player1` | 22, 50 | derived | +| `player2` | 10, 62 | derived | +| `player3` | 84, 18 | derived | +| `player4` | 90, 68 | derived | +| `player5` | 44, 66 | derived | + +Human labels are derived from live state, not hardcoded: `game.leaders[id].country` with the leading flag/emoji token stripped, uppercased (default player1 → `FREEDONIA`). Only leaders in `game.cast` are rendered. + +### 5.3 Highlights, actor/receiver derivation + +Each cast member gets an ellipse (`rx 6 / ry 3.4`; UK special-cases `rx 3 / ry 2`) + Anton label beneath, exactly as the handoff. Fill: + +- actor → `var(--green)`, opacity 0.85 +- receiver → `var(--magenta)`, opacity 0.85 +- otherwise → the leader's signature color `var(--c-)` for AI, the per-slot human accent (player1 `--cyan`, player2 `--green`, player3 `--yellow-soft`, player4 `--magenta-deep`, player5 `--ink-soft`), opacity 0.5 + +Actor/receiver per event kind: + +| Kind | Actor | Receiver(s) | +|---|---|---| +| FactoryBuilt / DeliveryBuilt / WarheadBuilt / DefenceBuilt / DefenceDeployed | `by` | — | +| PropagandaTransfer / WooApplied / MissileLaunched / MissileIntercepted | `from` | `to` | +| ImpactPeople / ImpactInfrastructure | `from` | `target` | +| LeaderEliminated | — | `id` | +| FinalRetaliationTriggered | `by` | every id in `targets` (all highlighted magenta) | + +### 5.4 Per-kind map overlays + +- **MissileLaunched / ImpactPeople / ImpactInfrastructure:** quadratic arc from actor to receiver (control point: midX, `min(cy) − 18`) — yellow 0.6 dashed `1.5 1` over magenta 0.3, 🚀 (or 🛩 when `delivery === 'bomber'`; impacts always 🚀) fontSize 5 at the apex; concentric magenta dashed impact ellipses on the receiver (`9×5` @0.85, `11.5×7` @0.5); green firing pulse circle r5 on the actor. Verbatim geometry from the handoff. +- **MissileIntercepted:** same arc, but 🛡 at the apex instead of the rocket, no impact rings; green pulse on the *receiver* (the defence worked). +- **PropagandaTransfer:** cyan 0.4 dashed `0.8 0.6` quadratic trail (control point midY − 6) with 📃 fontSize 3.6 at midpoint — verbatim. +- **WooApplied:** the propaganda trail re-stroked `var(--green)` with 🤝 at midpoint. +- **Builds/defence (`by`-only kinds):** no trail; the event's icon (⚙ 🚀 🛩 ☢ 🛡) rendered fontSize 4 just above the actor's country ellipse with the `pop` keyframe. +- **FinalRetaliationTriggered:** one launch arc from `by` to each target + impact rings on every target. +- **LeaderEliminated:** no arc; receiver highlight + eliminated portrait treatment (§6). + +## 6. Overlay chrome (HTML, absolutely positioned over the map) + +All keyed on `idx` so entrances re-run per step (`pop`/`fadein` keyframes from tokens.css — no Framer Motion). + +| Slot | Component / size | Position | +|---|---|---| +| Actor portrait | `` + name pill (`game.leaders[id].name.toUpperCase()`, Anton 11px, ink bg, 2px paper border) | top-left 14px | +| Receiver portrait | `` + name pill; for `LeaderEliminated`, add a rotated magenta `ELIMINATED` across it | top-right 14px | +| Speech bubble | `` with the step quote (Patrick Hand); omitted when no quote | bottom-left 14px, max-width 60% | +| Burst + damage badge | `` over `` — only on ImpactPeople / ImpactInfrastructure (badge) and the burst kinds in §4; `INTERCEPTED!` burst uses `color="cyan"`, `EVERYTHING!` uses `color="magenta"` | right 14%, top 6% | +| Disparage cameo | re-themed `DisparageCard`: paper bg, 3px ink border, `box-shadow: 3px 3px 0 var(--magenta)`, max-width 280px; inside: `` + yellow `🍺 FROM CLACTON` tag + hand-font quote from the event. Shown for the whole dwell of the step it is attached to | bottom-right 12px | + +Portrait sizes on this screen: actor/receiver **110px**, cameo **56px**. Human portraits are the Groucho variant with slot accent + flag badge, exactly as slice 1 defines — no per-screen variation. + +## 7. Behaviors preserved vs adopted + +**Preserved from the existing implementation:** +- Event grouping (`groupPhaseEvents`) and phase-cursor assignment, including WOOING as a first-class phase. +- Renderability rules: `OrdersSealed`, `OutcomeReached`, `DefenceConsumed`, `PreRoundMood`, `PostRoundReaction`, `DisparageColumn` never appear on Action. +- Round display = `game.round − 1`. +- Outcome-aware exit: existing "final verdict vs round summary" distinction survives as the `TO THE VERDICT →` / `TO THE NEWS →` label split; either way `ACTION_DONE` is the only dispatch. +- Engine-driven Disparage cameos and their quotes. +- Multi-human cast (player1–player5) fully supported on the map. + +**Adopted from the handoff:** +- Sequential single-event playback replacing the static list. +- Auto-advance timings (2700/1100 ms), pause, fast, skip, prev/next, progress dots, event counter. +- Ink screen, PhaseTrack, magenta banner, cyan halftone map, arcs/trails/rings, portrait pins with name pills, burst + damage badge, cameo overlay card, ≤600px repositioning. + +## 8. Testing & verification + +- Vitest + testing-library, `vi.useFakeTimers()` for playback. No guarded assertions (`if (x) expect(...)` forbidden). +- **`buildPlaybackSteps` (pure):** phase assignment matches the old bucketing; non-renderables excluded; builds grouped with counts; `DisparageCameo` attached to the preceding step, never a step itself; leading cameo dropped. +- **`formatEventText` (pure):** existing per-kind strings unchanged; exhaustive switch still compile-checked (`noFallthroughCasesInSwitch`). +- **Action playback:** renders step 0 banner from a real resolved game state; advances after 2700 ms, 1100 ms in fast, halts when paused; `← PREV` disabled at 0; `NEXT →` steps; last step shows `TO THE NEWS →` and clicking it dispatches `ACTION_DONE`; `SKIP →` dispatches `ACTION_DONE` mid-playback; outcome games show `TO THE VERDICT →`; empty step list shows the quiet-round copy. +- **WorldMap:** cast-only markers; actor green / receiver magenta; human label derived from live country string. +- **DisparageCard:** renders tag + engine quote + 56px Disparage portrait. +- Every commit: `tsc --noEmit` (noUnusedLocals) + `npm run test:run` green. Existing suites (`eventGrouping.test.ts`, store/round-summary tests) must stay green untouched. +- Visual check: `npm run dev` side-by-side with `design_handoff_nuke_game/index.html` on the Action screen. + +## 9. Out of scope + +- RoundSummary (fake newspaper) and Winners re-themes — slice 4. +- Framer Motion / any animation library — separate future phase; tokens.css keyframes only. +- The handoff DEV NAV — never ported. +- Engine or store changes of any kind (no new events, no playback state in the store). +- Planning-screen components (`BuildGrid`, `DefenceGrid`, `LaunchCell`, `TargetRow`, `ApBudget`, `SoftWarnPanel`) and `DisparageColumn` (RoundSummary's) — untouched. +- Replay scrubber / per-round history UI (P4a note in `types.ts`) — not this slice. +- Sound effects, real geography, commissioned map art. From 2a133ca00c715123aae46b8e4ad9d07323d1dd7f Mon Sep 17 00:00:00 2001 From: gethi Date: Sun, 19 Jul 2026 14:07:35 +0100 Subject: [PATCH 06/43] docs: p2 re-theme spec Co-Authored-By: Claude Fable 5 --- .../2026-07-19-comic-retheme-slice4-design.md | 239 ++++++++++++++++++ 1 file changed, 239 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-19-comic-retheme-slice4-design.md diff --git a/docs/superpowers/specs/2026-07-19-comic-retheme-slice4-design.md b/docs/superpowers/specs/2026-07-19-comic-retheme-slice4-design.md new file mode 100644 index 0000000..56b96fc --- /dev/null +++ b/docs/superpowers/specs/2026-07-19-comic-retheme-slice4-design.md @@ -0,0 +1,239 @@ +# nuke — comic-tabloid re-theme, slice 4: RoundSummary newspaper + Winners + +**Date:** 2026-07-19 +**Status:** approved for implementation +**Depends on:** slice 1 (`2026-07-19-comic-retheme-slice1-design.md` — tokens.css, fonts, ``, comic primitives), slices 2–3 (Planning/Handoff/Conferring, Action). This slice assumes those deliverables are merged: `src/ui/tokens.css`, `src/ui/components/Portrait.tsx`, `src/ui/components/comic/*` (`Panel`, `Halftone`, `Tag`, `Stamp`, `Btn`, `RelBadge`, `Burst`, …) all exist. + +## 0. Context + +Final slice of the four-slice comic re-theme. Re-themes the two post-resolution screens to the handoff's most detailed designs (`design_handoff_nuke_game/screens-3.jsx` — `SummaryScreen`, `WinnersScreen`; `readme.md` §§6–7): + +- **RoundSummary** becomes the fake tabloid front page: masthead, giant headline, mushroom-cloud "photo", casualty strip, full news stories, data-driven sidebar sections, corrections footer. +- **Winners** becomes the ironic final page: hero with 200px winner portrait + stamp, death-toll table, closing tagline. + +The handoff's `NEWS_*` arrays and `SAMPLE_REACTIONS` in `data.jsx` are **shape and copy reference only** — every visual element on both screens is driven from live game state (`src/ui/store.ts` `UiState`: `game`, `events`, `prevPopulations`, `initialPopulations`) and engine data. `data.jsx` is never imported. + +## 1. Target files + +| File | Change | +|---|---| +| `src/ui/screens/RoundSummary.tsx` | Rewrite render to tabloid layout. **Keep** `pickHeadline` (private) and `pickSubhead` (exported, tested) logic unchanged. | +| `src/ui/screens/RoundSummary.module.css` | Rewrite (tabloid layout, from `screens-3.jsx` inline styles). | +| `src/ui/util/newspaper.ts` | **New.** Pure derivation module: typed section models (stories, forecast, market, box score, obits, corrections index, photo caption) computed from `GameState` + `ResolutionEvent[]` + `prevPopulations`. No React. | +| `src/ui/components/MushroomCloudPhoto.tsx` | **New.** The stylised mushroom-cloud SVG "photo" frame (SVG lifted from `screens-3.jsx`), props: `stampText?: string`, `caption: string`. | +| `src/ui/components/DisparageColumn.tsx` + `.module.css` | Restyle as a `.side-sec` sidebar section; add ``. Props/event wiring unchanged. | +| `src/ui/screens/Winners.tsx` + `Winners.module.css` | Rewrite render to hero + death-toll layout. **Keep** `pickHeadline`, `pickSubLine`, `tollRows` computation/sort, `newGame`/`sameCast` handlers unchanged. | +| `tests/ui/newspaper.test.ts` | **New** — derivation tests (§7). | +| `tests/ui/roundSummary.test.ts` | Unchanged; must stay green. | + +No engine changes. `src/engine/masthead.ts` is consumed as-is. + +## 2. RoundSummary — layout + +Ink desk background (`--ink`), padding `20px 12px 96px`. The paper sheet: `.paper` class, max-width 1100px centered, padding `28px 28px 16px`, `4px solid var(--ink)` border, `8px 8px 0 var(--magenta)` offset shadow. + +Inside the sheet, top to bottom: + +1. **Masthead** (centered): magenta kicker `EXTRA · EXTRA · EXTRA` (Anton 10px, letter-spacing 0.28em); masthead title (Playfair 900, `clamp(48px, 9vw, 96px)`, line-height 0.9, uppercase via CSS `text-transform`); rule line (1.5px ink top+bottom borders, Anton 11px, space-between, wraps) with three spans: `VOL. IV · ROUND {reportedRound}` / `MORNING EDITION` / `FREE WHILE STOCKS LAST`. +2. **Summary grid**: single column; ≥920px `grid-template-columns: minmax(0, 2fr) minmax(0, 1fr)`, gap 28px. Left = main column, right = sidebar (`align-self: start`). +3. **Corrections box** (full width below the grid). +4. **Continue bar** (full width, ink background, `border-top: 4px double var(--magenta)`, centered). + +`reportedRound = game.round - 1` (the engine increments `round` at the end of `resolveRound`; the paper reports the round just resolved). This matches the existing `pickMasthead(game.mastheadOrder, game.round - 1, game.outcome)` call, which is kept verbatim (including the apocalypse `THE END TIMES — FINAL EDITION` override). + +Breakpoints: grid collapses <920px; story flow drops to 1 column <600px (§3.4); casualty strip stays 4-across at all widths (cells shrink). + +## 3. RoundSummary — main column + +### 3.1 Headline + standfirst + +- **Headline**: existing `pickHeadline` output, Anton `clamp(56px, 10vw, 108px)`, line-height 0.88, ink. Behavior preserved exactly: apocalypse → `THE END.`; elimination → `{NAME} ELIMINATED`; worst delta ≤ −10 → `{NAME} CLOBBERED`; ≤ −3 → `{NAME} STRUCK`; else `ROUND {round − 1} SETTLES`. The existing `styles.theEnd` apocalypse treatment is re-expressed in the new CSS (magenta headline). +- **Stamp**: when `thisRoundLost > 0`, a magenta `` reading `−{thisRoundLost}M`, absolutely positioned top-right of the headline block, rotated 8°. +- **Standfirst**: existing `pickSubhead` output (exported function, logic and tests untouched), Playfair italic 17px, line-height 1.35. + +### 3.2 Photo frame (`MushroomCloudPhoto`) + +Frame: `position: relative`, `3px solid var(--ink)` border, ink background, height 220px, `overflow: hidden`, margin `16px 0`. Contents: + +- The SVG from `screens-3.jsx` lines 373–405 lifted verbatim (viewBox `0 0 400 200`, `preserveAspectRatio="xMidYMid slice"`, radial gradient `#f3c318 → #e6a517 → #c61f44 → #3a1010 → #141214`, 5×5 halftone dot pattern at 0.18 opacity, stacked ellipses + stem rect, 18 ink burst rays at 0.3 opacity). `aria-hidden="true"`. Gradient/pattern ids must be unique-per-instance (e.g. `useId`) so the component is safe to reuse. +- **Stamp** (top-right, magenta, rotate −7°): rendered only when `stampText` prop is set; RoundSummary passes `−{thisRoundLost}M` when `thisRoundLost > 0`, else omits it. +- **Caption bar** (absolute bottom, `rgba(20,18,20,0.85)` on paper text, Playfair italic 11px), text from `newspaper.ts`: + - casualties this round: `Eyewitness sketch of the impact zone over {country}. Artist's impression.` — `{country}` is the country (flag emoji stripped) of the target in the round's largest summed attacker→target people-loss pairing (same pairing logic as `pickSubhead`). + - no casualties: `File photo of a previous detonation. Nothing exploded tonight. Cherish it.` + +The photo frame renders every round (the paper always runs a picture). + +### 3.3 Casualty strip + +4-column grid, `padding: 12px 14px`, ink background, paper text, `margin-bottom: 14px`. Each cell: Anton 10px label (letter-spacing 0.16em, opacity 0.6) over Anton 22px value. + +| Label | Value | Color | Source | +|---|---|---|---| +| `THIS ROUND` | `−{thisRoundLost}M` | `--magenta` | existing `thisRoundLost` computation (sum of `max(0, prev − current)` over `prevPopulations`) — preserved | +| `WAR TO DATE` | `−{warTotalLost}M` | paper | existing `warTotalLost` computation (vs `initialPopulations`) — preserved | +| `SURVIVORS` | `{Σ population of cast}M` | `--green` | sum of `game.leaders[id].population` over `game.cast` | +| `LEADERS LEFT` | `{aliveCount}/{game.cast.length}` | paper | existing `survivors` count — preserved | + +### 3.4 News stories — "FROM AROUND THE RUBBLE" + +Section head: `★ FROM AROUND THE RUBBLE ★` — Anton 16px, centered, `3px double` ink rules top+bottom. (Replaces the current "World Reactions" heading and row list.) + +Story flow: CSS `column-count: 2`, `column-gap: 22px`, `column-rule: 1px solid rgba(20,18,20,0.3)`; `column-count: 1` under 600px. Each `
` is `break-inside: avoid` with dashed bottom rule. The **lead story** (`.lead`) spans both columns (`column-span: all`, `3px double` bottom rule, headline 30px, body itself set in 2 columns). + +One story per **living** cast member (same iteration + `alive` filter as today's world-reactions loop). Story anatomy (fonts per handoff: kicker Anton 9px magenta; headline Anton 18px / 30px lead; byline Playfair 10px; body Playfair 12.5px justified/hyphens; pull-quote Playfair italic 14px with 3px magenta left rule): + +- **Kicker (dateline)** — static map for AI leaders, adopted from handoff copy: chump `MAR-A-LAGO`, carnage `PARLIAMENT HILL`, khameneverhere `LOCATION UNDISCLOSED`, netanyahoo `THE KIRYA, TEL AVIV`, starmless `WESTMINSTER`, mileigh-hem `CASA ROSADA`. Human slots (`player1`–`player5`): the leader's country name, flag emoji stripped, uppercased (e.g. `FREEDONIA`). +- **Headline** — template chosen by the leader's dominant round condition, first match wins: + + | Precedence | Condition (from `events` + deltas) | Headline (verbatim) | + |---|---|---| + | 1 | popDelta ≤ −10 | `{NAME} DIGS OUT, BLAMES EVERYONE` | + | 2 | −10 < popDelta < 0 | `{NAME} SHRUGS OFF LIGHT VAPORISATION` | + | 3 | any `MissileLaunched` with `from === id` | `{NAME} PRESSES BUTTON, FEELS BETTER` | + | 4 | any `MissileIntercepted` with `to === id` | `DEFENCES HOLD; {NAME} INSUFFERABLE` | + | 5 | any `FactoryBuilt` with `by === id` | `GROUNDBREAKING CEREMONY HELD` | + | 6 | any `PropagandaTransfer` with `from === id` | `LEAFLETS AWAY; NEIGHBOURS FURIOUS` | + | 7 | any `WooApplied` with `from === id` | `{NAME} SPOTTED BEING NICE; MOTIVE UNKNOWN` | + | 8 | otherwise | `{NAME} DOES NOTHING, CALLS IT STRATEGY` | + + `{NAME}` = `leader.name.toUpperCase()`. popDelta = `population − prevPopulations[id]` (0 when prev undefined). +- **Byline**: `by our {country} correspondent` (flag stripped), plus the population delta on the right in mono: `{prev}M → {cur}M` when changed (magenta if down, green if up), else `{cur}M` in `--ink-soft`. +- **Portrait**: `` for the lead story, `size={46}` otherwise; floated left (`float: left; margin: 2px 10px 4px 0; shape-outside: margin-box`). +- **Body**: 1–3 sentences concatenated from these templates, in this order, only for categories present in the leader's round events (numbers summed per category): + - people lost: `A strike removed {n} million citizens from the census.` (when a single attacker: `A strike from {attacker} removed {n} million citizens from the census.`) + - factories lost: `{n} factory(ies) now qualify as open-plan.` + - intercept as defender: `Air defences held; one incoming warhead retired over open water.` + - launched: `{n} launch order(s) were filed and, regrettably, executed.` + - propaganda received: `{n} million citizens defected after reading a leaflet.` + - propaganda sent: `Leaflet drops persuaded {n} million foreigners to relocate.` + - factory built: `Ground was broken on a new factory.` + - woo sent: `Diplomatic flowers were dispatched to {target name}.` + - none of the above: `No orders of consequence were filed. Analysts describe the restraint as "suspicious."` + + Cap the body at the first three applicable sentences (same precedence order as listed). +- **Pull-quote**: the leader's `PostRoundReaction` quote (existing lookup, preserved), rendered `"{quote}"`; omitted when absent. +- **Badges** (`` row, `clear: both`): derived, in order — `−{n}M citizens` (harm, pop loss), `+{n}M citizens` (gain, pop gain), `−{n} factories` (harm), `+1 factory` (gain, `FactoryBuilt`), `banked {n} AP` (neutral, when `leader.apBanked > 0`). Omit any zero entries; row omitted if empty. + +**Lead story selection**: the living leader with the largest population loss this round; ties broken by `game.cast` order; if no one lost population, the first living leader in cast order. + +The old compact reaction rows (`👥 {pop}M · 🏭 {factories}` line) are replaced by this section; the data they showed survives in byline deltas and badges. + +### 3.5 Existing behaviors preserved (main column) + +- `pickHeadline` / `pickSubhead` logic, `pickSubhead` export and its tests. +- `thisRoundLost` / `warTotalLost` / `survivors` computations. +- Masthead selection via `pickMasthead` incl. apocalypse final edition. +- The `[ mushroom-cloud SVG — P4a ]` placeholder is finally replaced by the real SVG. + +## 4. RoundSummary — sidebar + +Each section is a `.side-sec` (paper-warm bg, `2px solid var(--ink)`, `3px 3px 0 var(--ink)` shadow, 12px padding, 16px stacked gap) with a `.side-head` (Anton 12px, centered, `3px double` ink rules). Order top→bottom: + +### 4.1 THE FORECAST + +Driven by `thisRoundLost` tiers: + +| `thisRoundLost` | outlook (magenta, Anton 20px) | temp / tempLabel (mono 11px) | UV (☢ × n) | Fallout row | +|---|---|---|---|---| +| 0 | `FALLOUT: NONE` | `20°` / `seasonal, suspicious` | 1 | `None reported` | +| 1–5 | `FALLOUT: LIGHT` | `400°` / `localised high` | 2 | `Light, drifting east` | +| 6–14 | `FALLOUT: HEAVY` | `1,200°` / `ground zero high` | 4 | `Heavy, drifting east` | +| ≥15 | `FALLOUT: BIBLICAL` | `5,800°` / `surface of the sun, briefly` | 5 | `Total, drifting everywhere` | + +Conditions table (label Anton 10px / value Playfair italic 12px right-aligned, dotted row rules): +- `Fallout` — tier row above. +- `Visibility` — `Nil to 200 yards` when lost > 0, else `Unlimited. For now.` +- `Wind` — `Mushroom-shaped` when lost > 0, else `Light breeze`. +- `Outlook` — `Worse. Always worse.` (always). + +UV block right-aligned: label `UV INDEX`, value `☢` repeated n times. + +### 4.2 MARKET REPORT + +Sub-line: `Population exchange · close of round` (Playfair italic 10px, ink-soft). One ticker row per cast member **including the dead** (grid `42px 64px 1fr`, dotted rules): + +- **sym** (mono 12px bold): AI static map — chump `USA`, khameneverhere `IRN`, starmless `UK`, carnage `CAN`, mileigh-hem `ARG`, netanyahoo `ISR`. Humans: country name with flag stripped, uppercased, first 3 characters (Freedonia → `FRE`). +- **change**: `prev > 0 ? Math.round((cur − prev) / prev × 100) : 0` (prev from `prevPopulations`, 0 fallback when undefined). Rendered `▲ {n}%` green / `▬ 0%` ink-soft / `▼ {n}%` magenta (absolute value shown). +- **note** (Playfair italic 10px): dead → `delisted`; change ≤ −20 → `clobbered`; −20 < change < 0 → `down`; 0 → `holds`; > 0 → `up`. + +### 4.3 TONIGHT'S EXCHANGES (box score) + +One row per qualifying event, in event order (mono attacker `›` target on the left, Anton score right-aligned): + +| Event | Score text | Color | +|---|---|---| +| `ImpactPeople` | `−{deaths}M` | `--magenta` | +| `ImpactInfrastructure` | `−{factoriesDestroyed} fac` | `--magenta` | +| `MissileIntercepted` | `INTERCEPT` | `--green` | + +Names use `leader.name` (first 9 chars + `.` when longer). Empty state (no qualifying events): single Playfair-italic line `No exchanges. The censors are baffled.` + +### 4.4 OBITUARIES + +Detection preserved from current code: eliminated this round = `!leader.alive && prevPopulations[id] > 0`; quote from the `LeaderEliminated` event. Entry: name (Anton 13px) + line (Playfair italic 11px) = the event quote, or `Gone, and swiftly forgotten.` when the event carries no quote. When nobody died this round the section still renders with placeholder: `None yet. Everyone's still here. Give it a round.` (verbatim from handoff). + +### 4.5 THE DISPARAGE COLUMN + +`DisparageColumn` component restyled as a `.side-sec`. Rendering condition preserved: only when a `DisparageColumn` event exists in `state.events`. Content: head `THE DISPARAGE COLUMN`; sub-line `From his Clacton office (allegedly)` (kept, moves from header into a Playfair-italic line); flex row of `` + `event.quote` (Playfair 13px); footer `event.footer` (Playfair italic 10px, top border). Engine-driven quote/footer wiring unchanged. + +### 4.6 CLASSIFIEDS + +Static filler pool, all four shown, lifted verbatim from `data.jsx` `NEWS_CLASSIFIEDS` (tag in Anton 9px, body Playfair 11px justified, dotted rules): + +- `FOR SALE` — `One (1) missile shield, barely used. Buyer collects from crater.` +- `WANTED` — `Delivery system for Large warhead. Will not fly itself, apparently.` +- `LOST` — `Iran's signed orders. Last seen never. Reward: plausible deniability.` +- `PERSONAL` — `Lonely glass cannon seeks 100% aggression. ¡Viva la libertad, carajo!` + +### 4.7 Advertisement + +Yellow block (`3px solid var(--ink)`, centered): `ADVERTISEMENT` (Anton 11px) / `NUCLEAR DUCKS` (Bangers 28px, two lines) / `If it walks, talks, and quacks — it's covered. Limited supply.` (Patrick Hand 14px). + +## 5. RoundSummary — footer + +### 5.1 Corrections & Clarifications + +Full-width box (paper-warm, 2px ink border): label `CORRECTIONS & CLARIFICATIONS` (Anton 10px) followed by one rotating line (Playfair italic), pool verbatim from `data.jsx`, index `(reportedRound − 1) % 3`: + +1. `CORRECTION: Yesterday we reported 14M dead. It was 15M. We regret the optimism.` +2. `CORRECTION: Mr Chump was described as 'a stable genius.' This was his description.` +3. `CORRECTION: The duck was, in fact, nuclear. We apologise to the duck.` + +### 5.2 Continue bar + +`` dispatching `{ type: 'NEXT_ROUND' }` — dispatch preserved. Label strings preserved from current code (`Final Verdict` when `game.outcome`, else `` `Round ${game.round} → Plan` ``); uppercase presentation via CSS `text-transform: uppercase` so text queries keep matching the DOM string. + +## 6. Winners + +Full-viewport ink background, paper text, `padding: 40px 16px 96px`, `` overlay, content max-width 1100px centered. Top to bottom: + +1. **Tag** (centered): yellow `` `FINAL SCORE · ROUND {game.round − 1}`. +2. **Hero** — flex row (gap 32px, wraps; column layout <720px), `--ink-soft` bg, `3px solid var(--paper)` border, `8px 8px 0 var(--magenta)` shadow, 28px padding: + - **Portrait block** (survivor/pyrrhic only): `` with rotated (14°) `` at top-right — yellow `SURVIVOR` for `survivor`, magenta `PYRRHIC` for `pyrrhic`. **Apocalypse**: no portrait; a 200px-square ink panel containing a single ☢ glyph (~120px, `--magenta`) with magenta `NO SURVIVORS` stamp. + - **Text block**: headline from existing `pickHeadline` (Anton `clamp(48px, 8vw, 110px)`, line-height 0.9) — logic preserved (`WINNER: NOBODY` / `{NAME} WINS`); subline from existing `pickSubLine` (Patrick Hand 20px, `--paper-edge`), still wrapped in quotes as today; button row — `New Game` and `Same Cast, Again`, wired to the existing `newGame` / `sameCast` handlers (BACK_TO_SETUP; START_GAME with `lastNewGameOpts` + fresh seed — both preserved verbatim). +3. **Death Toll** — `` on paper (ink text), containing the table. Data preserved: existing `tollRows` computation from `initialPopulations` vs live `leader.population`, sorted ascending by `pctLost`. Columns (header row: ink bg, Anton 11px): `LEADER` (flex 2: `` + bold name + existing `(you)` marker on `player1` — preserved) / `START` (`{start}M`, mono) / `END` (`{end}M`, mono — magenta when 0, green otherwise) / `% LOST` (`{pct}%`, mono bold — **adopted from handoff:** integer `Math.round(pctLost)` replaces the current `toFixed(1)`) / `STATE` (flex 2: `SURVIVED` when `end > 0`, else `ELIMINATED`). Rows dashed-ruled. On narrow screens the table sits in an `overflow-x: auto` wrapper. +4. **Final word** (centered): `"EVERYBODY PLAYS. NOBODY WINS."` (Anton 28px, `--magenta`) over `— the original 1989 box, more or less.` (Patrick Hand 16px, `--paper-edge`). + +## 7. Testing & verification + +- **Existing tests stay green**: `tests/ui/roundSummary.test.ts` (`pickSubhead`) unchanged; full `npm run test:run` and `tsc --noEmit` (noUnusedLocals) pass on every commit. No guarded assertions anywhere (`if (x) expect(...)` forbidden — assert presence first or use non-null narrowing). +- **New `tests/ui/newspaper.test.ts`** (pure derivation, no rendering): + - forecast tier boundaries: lost = 0 / 1 / 5 / 6 / 14 / 15 map to NONE / LIGHT / LIGHT / HEAVY / HEAVY / BIBLICAL with matching UV counts; + - market rows: percent math, `▬ 0%` when prev undefined, `delisted` note for dead leaders, human sym derivation (`FRE` from `🦆 Freedonia`); + - box score mapping for ImpactPeople / ImpactInfrastructure / MissileIntercepted and the empty-state row; + - story headline precedence (a leader who both lost ≥10M and launched gets the DIGS OUT headline; an idle leader gets DOES NOTHING); + - lead-story selection (largest loss wins; cast-order fallback when no losses); + - photo caption: impact-zone country for the biggest pairing vs. the file-photo line when no casualties. +- **Render tests** (testing-library, query by role/text): RoundSummary shows the masthead from `game.mastheadOrder`, casualty-strip values, corrections line rotating with round; DisparageColumn section renders only when the event is present; Winners renders sorted death-toll rows, ELIMINATED badge for `end === 0`, and both buttons dispatch their existing actions. +- Visual verification: `npm run dev` side-by-side with `design_handoff_nuke_game/index.html` (Summary + Winners via its nav). + +## 8. Out of scope + +- Action screen re-theme (slice 3), and any Planning/Setup/Handoff/Conferring work (slices 1–2). +- Framer Motion — separate future phase; only `tokens.css` keyframes (`fadein`, `pop`) may be used here. +- The handoff DEV NAV — not ported. +- Engine changes of any kind (masthead pool, event kinds, outcome logic all consumed as-is). +- Importing `design_handoff_nuke_game/data.jsx` — copy is lifted into this codebase's source/derivation module as content, never imported. +- Commissioned portrait art (future swap behind ``). +- Print stylesheet / share-image export of the newspaper. +- Multi-human hotseat flow changes — roster, `(you)` marker semantics, and all `dispatch` wiring stay exactly as they are. From ba57b566d1be86d218ac02c82c86d3c8b7942392 Mon Sep 17 00:00:00 2001 From: gethi Date: Sun, 19 Jul 2026 14:07:49 +0100 Subject: [PATCH 07/43] =?UTF-8?q?docs:=20p1=20re-theme=20spec=20=E2=80=94?= =?UTF-8?q?=20address=20review=20feedback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .../specs/2026-07-19-comic-retheme-slice1-design.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/specs/2026-07-19-comic-retheme-slice1-design.md b/docs/superpowers/specs/2026-07-19-comic-retheme-slice1-design.md index 436f91a..210e9a8 100644 --- a/docs/superpowers/specs/2026-07-19-comic-retheme-slice1-design.md +++ b/docs/superpowers/specs/2026-07-19-comic-retheme-slice1-design.md @@ -36,7 +36,7 @@ Self-hosted via `@fontsource` packages, imported in `main.tsx`: | Family | Package | Weights | |---|---|---| | Anton | `@fontsource/anton` | 400 | -| Playfair Display | `@fontsource/playfair-display` | 400, 700, 900 + italics | +| Playfair Display | `@fontsource/playfair-display` | 400, 700, 900 upright; 400, 700 italic (900-italic is not used by the handoff and is not imported) | | Work Sans | `@fontsource/work-sans` | 400–800 | | JetBrains Mono | `@fontsource/jetbrains-mono` | 400, 700 | | Bowlby One | `@fontsource/bowlby-one` | 400 | @@ -49,8 +49,10 @@ No runtime external fetch; deterministic builds. Supersedes the old PNG/flag-fill portrait spec. Geometric caricature SVGs from `design_handoff_nuke_game/portraits.jsx`, ported to typed components. -- `src/ui/portraits/` — one file per leader (`chump.tsx`, `netanyahoo.tsx`, `khameneverhere.tsx`, `starmless.tsx`, `carnage.tsx`, `mileighhem.tsx`, `disparage.tsx`, `groucho.tsx`), each exporting an SVG face component; `index.ts` exports a `FACES` record keyed by leader id. +- `src/ui/portraits/faces.tsx` — a single file containing all eight face SVG components as named exports (`ChumpFace`, `NetanyahooFace`, `KhameneverhereFace`, `StarmlessFace`, `CarnageFace`, `MileighFace`, `DisparageFace`, `GrouchoFace`), ported from `portraits.jsx`. One file, not one-per-leader: the faces are small sibling functions that always change together, matching the handoff's own layout. +- `src/ui/portraits/index.ts` — exports `FACES` (face component per portrait id), `PORTRAIT_META` (signature color + flag per AI/cameo id), `HUMAN_ACCENTS` (per-slot accent), and the `extractFlag`/`stripFlag` helpers (see §5). - `src/ui/components/Portrait.tsx` — the single consumer-facing component: ``. Sizes are free pixel values per call site (handoff convention: 48–200px), not a fixed token scale. + - **Prop type:** `leaderId: LeaderId | 'disparage'`. `'disparage'` is a non-playable cameo, deliberately absent from the engine's `LeaderId` union; the widened prop makes `DisparageFace`/`PORTRAIT_META.disparage` reachable now so slices 3–4 (Disparage cameo/column) need no API change. Slice 1 never renders it. `isHuman` (which takes `LeaderId`) is only consulted for non-`'disparage'` ids; `'disparage'` always takes the AI branch. - **AI leader:** signature-color background, the leader's `FACES` SVG, halftone multiply overlay. - **Human slot (`player1`–`player5`):** Groucho-disguise SVG (glasses + moustache + cigar), differentiated by per-slot accent color (fixed per slot: player1 `--cyan`, player2 `--green`, player3 `--yellow-soft`, player4 `--magenta-deep`, player5 `--ink-soft`) plus the player's chosen-country flag badge. - Dispatches on `isHuman(leaderId)` from `src/engine/state.ts`. @@ -73,6 +75,8 @@ Rebuild `src/ui/screens/Setup.tsx` + `Setup.module.css` to the handoff layout (` - Max-width 1180px on `.paper`. Two-column grid (`minmax(0,1.6fr) minmax(0,1fr)`) ≥920px; single column below. - **Left:** "The Table" cast picker — 2-col grid of rotated cast tiles (3px ink border, 4px offset shadow, ±1.4° rotation; 64px ``, name in Anton, country/profile in mono, 2-line clamped mood quote in Patrick Hand, POP/⚙/AP stat tags, magenta "PICKED" stamp when selected; hover lifts to rotation 0 + translateY(−2px)). + - **Stat tags:** each tile `Tag` carries the handoff's per-tile overrides — `fontSize: 9`, `padding: '2px 5px'` (screens-1.jsx lines 70–72), not the base 10px / 3px 7px from `tokens.css`. Applied via `Tag`'s `style` prop or an equivalent `styles.tileTag` class. + - **Meta line:** the handoff shows plain `USA · COWARD`; engine `country` strings embed the flag (`"🇺🇸 US"`), and the `` badge already shows that flag. The meta line therefore renders `stripFlag(profile.country) · profile-copy` — `stripFlag` (exported from `src/ui/portraits` alongside `extractFlag`) removes a leading flag/emoji plus following whitespace, returning the string unchanged when no flag is present. No doubled flag on the tile. - **Right:** stacked panels — human roster, difficulty, begin panel with live opponent count. - **Header copy:** kicker "A PARODY IN POOR TASTE", title "NUKE!" (magenta "!"), tagline "Everybody plays. **Nobody** wins.", yellow ribbon "SELECT YOUR ENEMIES". - **Difficulty labels** (mapped to existing engine values): easy = "Fine, Probably", normal = "Not Great", hard = "We're Cooked". Selected option: ink bg, paper text, magenta check square. @@ -83,6 +87,7 @@ Rebuild `src/ui/screens/Setup.tsx` + `Setup.module.css` to the handoff layout (` ## 6. Testing & verification - Existing vitest + testing-library suite stays green; Setup behavior tests updated for new markup (queries by role/label, not old class names). +- **Opponent-count assertions must be scoped.** In the new markup a bare `screen.getByText(/2/)` matches multiple elements (the cast hint "Pick 2–4 opponents. You're already at the table.", the `POP {n}M` stat tags from `LEADER_PROFILES`, and the count element itself) and throws. The begin-gate test asserts the live count via a scoped query — e.g. `within()` on the begin panel, a function matcher for the exact `2/4` composite text, or an accessible name/testid on the count element. Unscoped digit regexes against the whole screen are forbidden. - New render tests: `Portrait` AI vs human variant; `HoldButton` fires `onComplete` after hold, cancels on early release. - Visual verification: `npm run dev` side-by-side with the prototype (`design_handoff_nuke_game/index.html`). From 4bcc672275ff37874faa6a4464fa7aa31e6dad54 Mon Sep 17 00:00:00 2001 From: gethi Date: Sun, 19 Jul 2026 14:08:18 +0100 Subject: [PATCH 08/43] docs: p2 re-theme spec (slice-2 file) Prior commit 2a133ca carried the same message but swept in the parallel slice-4 spec during an index.lock race; this commit adds the actual slice-2 spec. Co-Authored-By: Claude Fable 5 --- .../2026-07-19-comic-retheme-slice2-design.md | 183 ++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-19-comic-retheme-slice2-design.md diff --git a/docs/superpowers/specs/2026-07-19-comic-retheme-slice2-design.md b/docs/superpowers/specs/2026-07-19-comic-retheme-slice2-design.md new file mode 100644 index 0000000..25c5eb2 --- /dev/null +++ b/docs/superpowers/specs/2026-07-19-comic-retheme-slice2-design.md @@ -0,0 +1,183 @@ +# nuke — comic-tabloid re-theme, slice 2: Planning + HotseatHandoff + AiConferring + +**Date:** 2026-07-19 +**Status:** draft for review +**Depends on:** `2026-07-19-comic-retheme-slice1-design.md` (tokens.css, fonts, ``, comic primitives incl. `Panel`, `Tag`, `Stamp`, `Btn`, `Stat`, `ApMeter`, `RelBadge`, `HoldButton`, `Ribbon`) — slice 1 must land first. + +## 0. Context + +Slice 2 re-themes the three mid-loop screens to the handoff design: + +- `src/ui/screens/Planning.tsx` (+ `TargetRow`, `BuildGrid`, `DefenceGrid`, `LaunchCell`, `ApBudget`, `SoftWarnPanel`) +- `src/ui/screens/HotseatHandoff.tsx` +- `src/ui/screens/AiConferring.tsx` + +Handoff references: `design_handoff_nuke_game/screens-2.jsx` (PlanningScreen), `screens-1.jsx` (HandoffScreen, ConferringScreen), `readme.md` §§3–4 (Planning, AI Conferring) and §2 (Hotseat Handoff), `ui.jsx` (`RoundBadge`). + +**Governing rule:** the handoff supplies layout, styling, and copy; **all order/AP/validation behavior and store wiring stays exactly as implemented today** unless a handoff behavior is explicitly adopted below. All numbers shown come from the engine (`ACTION_COSTS`, `AP_BANK_CAP`, live `Leader` state), never from handoff sample data — the handoff's AP costs (shield 2, AA 2, "cap +2") disagree with the engine (`buildDefence` 4, `deployDefence` 4, `AP_BANK_CAP` 4); the engine wins. + +## 1. New comic primitive: `RoundBadge` + +`src/ui/components/comic/RoundBadge.tsx` — port of `ui.jsx` lines 156–172. + +- Props: `{ round: number; label?: string }` (`label` defaults to `"Round"`). +- Ink plaque: inline-flex column, 3px `var(--paper)` border, `var(--ink)` background, `var(--paper)` text, padding 4px 12px, line-height 1. Label row: display font 9px, letter-spacing 0.2em, opacity 0.7. Number row: display font 22px. +- Exported alongside the other slice-1 primitives (same barrel/import convention as slice 1 chose). + +## 2. Planning screen + +### 2.1 Target files + +| File | Change | +|---|---| +| `src/ui/screens/Planning.tsx` + `Planning.module.css` | Rewrite markup/layout to handoff structure; keep all state, hooks, projections, and dispatch wiring | +| `src/ui/components/TargetRow.tsx` + `.module.css` | Restyle as "The Table" opponent card | +| `src/ui/components/BuildGrid.tsx` + `.module.css` | Restyle as Build Yard tiles | +| `src/ui/components/DefenceGrid.tsx` + `.module.css` | Restyle as Build Yard-style tiles (own panel) | +| `src/ui/components/LaunchCell.tsx` + `.module.css` | Restyle as mini comic tile | +| `src/ui/components/ApBudget.tsx` + `.module.css` | Currently orphaned (rendered nowhere); repurpose as the AP block inside "Your Country" (wraps `ApMeter`) and mount it in Planning | +| `src/ui/components/SoftWarnPanel.tsx` + `.module.css` | Restyle as "SOFT WARNINGS" panel; always rendered (adopted, see 2.6) | + +### 2.2 Layout & breakpoints (from `screens-2.jsx`) + +- Screen root: `.paper` background, padding `20px 16px 96px`, content wrapper max-width **1280px** centered. +- **Header bar**: flex, space-between, wrap, gap 14px, `margin-bottom 18px`, `padding-bottom 14px`, bottom border **4px solid var(--ink)**. Left cluster: `` + block with the active player's name in display font (`clamp(18px, 2.6vw, 26px)`, uppercase) followed by a magenta `·` separator, country name uppercased, and flag emoji. Full `player.name` is shown (not the handoff's `split(' ').pop()` — multi-human names must stay unambiguous). +- **Main grid** (`.planning-grid`): + - `<800px`: single column, stacked in order left-col → centre-col → orders-col; gap 22px. + - `800–1179px`: `grid-template-columns: minmax(0,1fr) minmax(0,1.4fr)`; orders column spans full width (`grid-column: 1 / -1`). + - `≥1180px`: `grid-template-columns: minmax(260px,1.1fr) minmax(420px,1.7fr) minmax(280px,1.1fr)`. + +### 2.3 Left column — "Your Country" + +`Panel` title "Your Country", tilt −1, cyan halftone (`rgba(29,127,176,0.08)`): + +- `` (human variant: Groucho disguise + per-slot accent + flag badge, per slice 1). +- 2×2 `Stat` grid (gap `12px 18px`), all live from `game.leaders[activeId]`: + - `POP` → `${population}M` + - `FACTORIES` → `factories` + - `ARSENAL` → `${stockpile.missiles} / ${stockpile.bombers}` with sub `miss / bomb · ${warheadsSmall+warheadsMedium+warheadsLarge}W` + - `DEFENCE` → `${stockpile.shields} / ${stockpile.aa}` with sub `shield / AA` +- `ApBudget` (repurposed): renders `` plus the note line "Banked AP carries over (cap {AP_BANK_CAP})." with, when `player.apBanked > 0`, " Of which banked: {apBanked}." appended. Props become `{ used: number; max: number; banked: number }`. `ApMeter` itself supplies the `{used} / {max} AP` readout and the "OVER BUDGET" / "{n} will bank" right-hand label (slice-1 primitive). +- Below the panel: restyled `SoftWarnPanel` (see 2.6). + +Not adopted from the handoff left column: the "Last 3 Rounds" history strip — the engine keeps no cross-round history the UI can query; deferred (see Out of scope). + +### 2.4 Centre column — "The Table" + Build Yard + Defence + +Heading: display-font `The Table` (24px) with hand-font subtitle "Queue launches, woos, and propaganda per leader." (adapted from handoff — our interaction is in-card steppers, not click-to-target). + +**Opponent cards** (`.table-grid`: 1 col <640px, 2 cols ≥640px, gap 14px). Each `TargetRow` becomes a `Panel` (padding 12, alternating tilt −1/+1 by index) keeping every existing control and handler: + +- `` (replaces the bare flag emoji). +- Header row: name in display font 17px; country string in mono 10px right-aligned; the existing **people / infra** segmented toggle restyled as a mono two-segment control (selected segment: ink bg, paper text). `onTargetTypeChange` retarget-all-queued-launches behavior unchanged. +- Live stat line (mono 10px): `POP {population}M · ⚙ {factories}` from `game.leaders[id]` — no arsenal/defence intel (the handoff's `sample` arsenal/defence/rel strings are mock data; not adopted). +- Mood quote: hand font, 2-line clamp, quotes — from `PreRoundMood` events in `state.events` (existing `moodByLeader` wiring; absent on round 1, render nothing). +- Diplomacy row: existing Woo / Propaganda **toggle** buttons restyled as `.mini-btn` (display font, 2px ink border; toggled-on state: ink bg, paper text): `🤝 WOO · {ACTION_COSTS.woo} AP`, `📃 PROP · {ACTION_COSTS.propaganda} AP`. Toggle semantics and AP gating unchanged. (Handoff's `↯ HIT` append-button is **not** adopted — it can't express delivery/yield/targetType.) +- Launch rows unchanged in behavior: `🚀 missiles · {projection.missiles} left` and `✈️ bombers · {projection.bombers} left` labels, each followed by the three-`LaunchCell` grid (small/med/big). `LaunchCell` restyled as a mini tile: 2px ink border, `var(--paper-bright)` bg, 💥 icon, display-font size label, mono `{warheadsLeft} left`, stepper `− {count} +`. States: `on` (count>0): yellow-soft bg; `off` (no warheads, count 0): 40% opacity. + +**Build Yard**: `Panel` title "Build Yard", tilt −1, containing `BuildGrid` restyled: `repeat(auto-fit, minmax(110px,1fr))` grid, gap 8px; each cell a `.build-tile` (2.5px ink border, `paper-bright` bg, `2px 2px 0` ink shadow; hover `translate(-1px,-1px)` + `3px 3px 0 var(--magenta)` shadow): emoji 26px, display-font label, mono magenta `{cost} AP` from `ACTION_COSTS`, plus the existing `− {count} +` stepper (steppers are kept — handoff tiles were append-only; decrement must survive). Cells/order kinds unchanged (Factory, Missile, Bomber, Sm/Md/Lg Warhead). + +**Defence**: `Panel` title "Civil Defence", tilt +1, containing `DefenceGrid` restyled in the identical tile language. All four cells (Build Shield 🛡️, Deploy Shield 🛡️↑, Build AA 📡, Deploy AA 📡↑), costs from `ACTION_COSTS.buildDefence`/`deployDefence`, `· {n} owned` hint and `canAddMore` stockpile gating unchanged. (The handoff has no deploy mechanic; the engine's is kept as-is.) + +### 2.5 Right column — "Your Orders" + masthead teaser + +`Panel` title "Your Orders", tilt +1, min-height 320px. **Adopted handoff behavior:** an explicit order-queue list is added (pure view over the existing `orders` array; steppers and the queue always agree because both derive from it). + +- Empty state (hand font, centered): "No orders yet. Banking is fine. (Cap {AP_BANK_CAP}.)" +- One `.order-row` per element of `orders` (2px ink border, `2px 2px 0` ink shadow): icon, label, mono magenta `{ap} AP` (per-order cost from `ACTION_COSTS`), and an `×` remove button (`aria-label="remove"`) that removes **that array index** — equivalent to the stepper decrement, AP totals recompute as today. Icon/label mapping (labels use live leader names for targeted orders): + +| Order kind | Icon | Label | +|---|---|---| +| `build-factory` | 🏭 | `Build factory` | +| `build-missile` | 🚀 | `Build missile` | +| `build-bomber` | ✈️ | `Build bomber` | +| `build-warhead` | 💥 | `Build {small/medium/large} warhead` | +| `build-defence` | 🛡️ / 📡 | `Build {shield/AA}` | +| `deploy-defence` | 🛡️↑ / 📡↑ | `Deploy {shield/AA}` | +| `launch` | ↯ | `Launch on {name} · {missile/bomber} · {small/med/big} · {people/infra}` | +| `propaganda` | 📃 | `Propaganda → {name}` | +| `woo` | 🤝 | `Woo {name}` | + +- Subtotal block (2px dashed ink top border): display-font "SUBTOTAL" left; mono `{apUsed} / {player.ap} AP` right, magenta when `overBudget`. When over: hand-font magenta-deep line "Over budget by {apUsed − player.ap}. Drop something. Or don't, and panic." +- **Seal control — adopted handoff interaction:** the plain `sealBtn` is replaced by slice-1's `` labelled "SEAL ORDERS — HOLD ↯" (full width, 22px), caption below "Hold 0.6s. Irreversible." `onComplete` dispatches the existing `{ type: 'PLAYER_SUBMIT', leaderId: activeId, orders }`. **Preserved existing validation:** the button is `disabled` while `overBudget` (hold cannot start); handoff had no such guard. Sealing with zero orders (banking) stays legal. +- Below: masthead teaser — `Panel` tilt −1, ink bg / paper text: display-font kicker "TONIGHT'S MASTHEAD", tabloid 900 title from `pickMasthead(game.mastheadOrder, game.round, null)` (the edition that will report this round), hand-font subline `"Vol. IV · Round {game.round} · Morning Edition"` (Vol. IV is static gag copy; round is live). + +Not adopted: the header's `SAVE` / `FAST RESOLVE` tags (no such features exist) and the `"Hail Freedonia…"` header subline (hardcoded to one country; wrong for custom rosters). + +### 2.6 SoftWarnPanel + +Restyled as the handoff's warnings panel and **adopts the always-visible variant**: `Panel` tilt 0, display-font header "SOFT WARNINGS"; when `warnings.length === 0` it renders the hand-font fallback "Nothing obviously stupid. Yet." instead of returning `null`. Warning lines keep the existing `describe()` messages verbatim (they are engine-accurate, unlike the handoff's single sample warning), rendered in hand font, `var(--magenta-deep)`, with a leading ⚠. + +## 3. HotseatHandoff screen + +Rewrite `HotseatHandoff.tsx` + module to the handoff curtain (`screens-1.jsx` HandoffScreen). Wiring unchanged: reads `state.activeHumanTurn`, button dispatches `{ type: 'BEGIN_PLANNING', leaderId: id }`. + +- Full-viewport `var(--ink)` background, `var(--paper)` text, centered column max-width 640px, padding `32px 24px 96px`. +- Dot-grid overlay (`aria-hidden`): `radial-gradient(rgba(255,255,255,0.08) 1px, transparent 1.4px)`, `background-size: 8px 8px`, opacity 0.4. +- Content, top to bottom: + - Yellow `Tag` "HOTSEAT HANDOFF" (12px, padding 5px 12px). + - Giant flag emoji `clamp(72px, 16vw, 180px)` — first whitespace-separated token of `leader.country` (existing split logic kept; the flag is the chosen country's, per the human-portrait rule). + - Display-font kicker "PASS TO" (18px, letter-spacing 0.3em, opacity 0.7). + - Leader name headline: display font, `clamp(40px, 6.5vw, 72px)`, `var(--yellow)`, uppercase, `text-wrap: balance`. + - Hand-font line "Previous orders sealed. Don't peek." (18px, opacity 0.8). + - `BEGIN MY TURN`. +- Adopted handoff simplification: the separate country-name text line is dropped (flag carries it). Everything else about the flow (when the screen appears, who it names) is unchanged. + +## 4. AiConferring screen + +Rewrite `AiConferring.tsx` + module to the handoff intermission (`screens-1.jsx` ConferringScreen). Still dispatches `{ type: 'AI_RESOLVE' }` exactly once when the beat ends. + +- **Adopted handoff timing** (replaces the current flat 1500ms): total stagger window `TOTAL = 2400`ms; AI row *i* (0-based, over the living AI leaders) flips to "filed" at `(i + 1) * TOTAL / n`; `AI_RESOLVE` dispatched at `TOTAL + 500`ms. Dots cycle 1→2→3 every 320ms. All timers cleared on unmount. +- Layout: `.paper` screen, centered column max-width 520px. + - Centered header: ink `Tag` "INTERMISSION"; display-font headline "AI IS FILING ORDERS" (52px) with 1–3 trailing dots; hand-font subline "Don't look. They can sense it." + - One `Panel` (tilt 0, padding 12) per **living AI leader in `game.cast`** (`!isHuman(id) && leaders[id].alive` — same filter Planning uses): ``, display-font `leader.name` (16px, live name from state), hand-font flavour line `"{name} {line}"`, right-hand status: three pulsing dots (`pulsedot`, 150ms stagger) until filed, then green `Tag` "FILED ✓". +- Flavour lines, lifted verbatim from the handoff, keyed by `LeaderId` (module-level const; fallback `"is conferring."`): + +| id | line | +|---|---| +| `chump` | is on the phone yelling at his generals. | +| `khameneverhere` | has filed orders. The orders are unsigned. | +| `starmless` | is consulting his shadow cabinet, slowly. | +| `carnage` | polishes his glasses. Files orders calmly. | +| `mileigh-hem` | is revving something offscreen. | +| `netanyahoo` | is adding a fresh red line to the bomb diagram. | + +## 5. State wiring summary + +| Visual element | Data source | +|---|---| +| Round badge, masthead teaser round | `game.round` | +| Header name/country/flag; handoff curtain name/flag | `game.leaders[activeId].name` / `.country` (respects `playerProfiles` overrides) | +| Your Country stats | `game.leaders[activeId]` (`population`, `factories`, `stockpile.*`) | +| AP meter used/max, subtotal, over-budget | `totalApCost(orders)` vs `player.ap` (existing) | +| Banked note | `player.apBanked`, `AP_BANK_CAP` | +| All displayed AP costs | `ACTION_COSTS` | +| Opponent card roster; conferring rows | `game.cast` filtered `!isHuman && alive` | +| Opponent stats | `game.leaders[id].population` / `.factories` | +| Mood quotes | `PreRoundMood` events in `state.events` | +| Launch-cell / deploy availability | `projectInventory(player, orders)` (existing) | +| Soft warnings | `analyseOrderSequence(game, activeId, orders)` (existing) | +| Masthead title | `pickMasthead(game.mastheadOrder, game.round, null)` | +| Seal / begin-turn / conferring-end | existing dispatches: `PLAYER_SUBMIT`, `BEGIN_PLANNING`, `AI_RESOLVE` — unchanged | + +`src/ui/store.ts` is not modified in this slice. + +## 6. Testing & verification + +- Every commit: `npx tsc --noEmit` clean (noUnusedLocals) and `npm run test:run` green. No guarded assertions anywhere (`if (x) expect(...)` forbidden). +- **Updated** (markup changed; queries move to role/label/text, never class names): `tests/ui/Planning.actionGrid.test.tsx`, `Planning.targetRow.test.tsx`, `Planning.softwarn.test.tsx` (now asserts the always-rendered panel + "Nothing obviously stupid. Yet." empty state), `HotseatHandoff.test.tsx` (asserts "PASS TO", leader name, "BEGIN MY TURN" dispatch), `ApBudget.test.tsx` (new props: used/max/banked). +- **New**: + - Orders queue: queuing via stepper shows a row with correct label + AP; clicking `×` removes exactly that order and updates the subtotal; empty state shows the banking copy. + - Seal: holding the seal button (fake timers / rAF mock per slice-1's HoldButton tests) dispatches `PLAYER_SUBMIT` with the queued orders; while over budget the button is disabled and cannot complete. + - AiConferring: with fake timers, rows flip to "FILED ✓" in cast order at the staggered times and `AI_RESOLVE` is dispatched exactly once at 2900ms; unmount clears timers (no dispatch after unmount). + - RoundBadge: renders label + round number. +- `tests/ui/store.multihuman.test.ts` must pass untouched — proof the store wiring didn't move. +- Visual verification: `npm run dev` side-by-side with `design_handoff_nuke_game/index.html` (Planning, Handoff, Conferring via its DEV NAV). + +## 7. Out of scope + +- **"Last 3 Rounds" history strip** — engine exposes no per-round history; needs an engine/store feature first (candidate for slice 4 alongside newspaper data). +- **Relationship badges on opponent cards** (`RelBadge` "hit you R3" / "wooing you" etc.) — deriving live badges from `favourability`/`grudge`/`recentAggressionFrom` is new product logic, not a re-theme. `RelBadge` (slice 1) stays available for the Action/Summary slices. +- Handoff's click-to-append `↯ HIT / 📃 PROP / 🤝 WOO` order model — existing steppers/toggles keep full delivery/yield/targetType expressiveness. +- Header `SAVE` / `FAST RESOLVE` tags; `"Hail Freedonia…"` header subline. +- Action screen, RoundSummary, Winners (slices 3–4); Framer Motion (separate phase); DEV NAV (never); `data.jsx` imports (never — copy lifted verbatim only where stated above). From 01346adc286bc4b077049be061b68bdce91cbc84 Mon Sep 17 00:00:00 2001 From: gethi Date: Sun, 19 Jul 2026 14:12:01 +0100 Subject: [PATCH 09/43] docs: p1 re-theme spec Co-Authored-By: Claude Fable 5 --- .../specs/2026-07-19-comic-retheme-slice1-design.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/specs/2026-07-19-comic-retheme-slice1-design.md b/docs/superpowers/specs/2026-07-19-comic-retheme-slice1-design.md index 210e9a8..417ef0d 100644 --- a/docs/superpowers/specs/2026-07-19-comic-retheme-slice1-design.md +++ b/docs/superpowers/specs/2026-07-19-comic-retheme-slice1-design.md @@ -76,9 +76,9 @@ Rebuild `src/ui/screens/Setup.tsx` + `Setup.module.css` to the handoff layout (` - Max-width 1180px on `.paper`. Two-column grid (`minmax(0,1.6fr) minmax(0,1fr)`) ≥920px; single column below. - **Left:** "The Table" cast picker — 2-col grid of rotated cast tiles (3px ink border, 4px offset shadow, ±1.4° rotation; 64px ``, name in Anton, country/profile in mono, 2-line clamped mood quote in Patrick Hand, POP/⚙/AP stat tags, magenta "PICKED" stamp when selected; hover lifts to rotation 0 + translateY(−2px)). - **Stat tags:** each tile `Tag` carries the handoff's per-tile overrides — `fontSize: 9`, `padding: '2px 5px'` (screens-1.jsx lines 70–72), not the base 10px / 3px 7px from `tokens.css`. Applied via `Tag`'s `style` prop or an equivalent `styles.tileTag` class. - - **Meta line:** the handoff shows plain `USA · COWARD`; engine `country` strings embed the flag (`"🇺🇸 US"`), and the `` badge already shows that flag. The meta line therefore renders `stripFlag(profile.country) · profile-copy` — `stripFlag` (exported from `src/ui/portraits` alongside `extractFlag`) removes a leading flag/emoji plus following whitespace, returning the string unchanged when no flag is present. No doubled flag on the tile. + - **Meta line:** the handoff shows plain `USA · COWARD`; engine `country` strings embed the flag (`"🇺🇸 US"`), and the `` badge already shows that flag. The meta line therefore renders `stripFlag(profile.country) · profile-copy` — never raw `profile.country`, which would double the flag already shown in the `` badge. `stripFlag` (exported from `src/ui/portraits` alongside `extractFlag`) removes a leading flag/emoji plus following whitespace, returning the string unchanged when no flag is present. No doubled flag on the tile. - **Right:** stacked panels — human roster, difficulty, begin panel with live opponent count. -- **Header copy:** kicker "A PARODY IN POOR TASTE", title "NUKE!" (magenta "!"), tagline "Everybody plays. **Nobody** wins.", yellow ribbon "SELECT YOUR ENEMIES". +- **Header copy:** kicker "A PARODY IN POOR TASTE", title "NUKE!" (magenta "!"), tagline "Everybody plays. **Nobody** wins.", yellow ribbon "SELECT YOUR ENEMIES". The ribbon carries the handoff's `whiteSpace: 'nowrap'` override (screens-1.jsx line 38) so the text never wraps at narrow widths. - **Difficulty labels** (mapped to existing engine values): easy = "Fine, Probably", normal = "Not Great", hard = "We're Cooked". Selected option: ink bg, paper text, magenta check square. - **Pick behavior:** toggle on click; if already 4 opponents picked, drop the oldest pick and add the new one (adopted from handoff). Begin disabled until ≥2 opponents and all human rows complete. - **Behavior kept from existing code, diverging from handoff:** multi-human hotseat roster (1–5 players, add/remove, per-player name + country inputs, player1 defaults to Rufus T. Firefly / Freedonia and cannot be removed) — styled as roster rows in the same panel language. Seed input stays. All `dispatch({type:'START_GAME'…})` wiring unchanged. @@ -89,6 +89,7 @@ Rebuild `src/ui/screens/Setup.tsx` + `Setup.module.css` to the handoff layout (` - Existing vitest + testing-library suite stays green; Setup behavior tests updated for new markup (queries by role/label, not old class names). - **Opponent-count assertions must be scoped.** In the new markup a bare `screen.getByText(/2/)` matches multiple elements (the cast hint "Pick 2–4 opponents. You're already at the table.", the `POP {n}M` stat tags from `LEADER_PROFILES`, and the count element itself) and throws. The begin-gate test asserts the live count via a scoped query — e.g. `within()` on the begin panel, a function matcher for the exact `2/4` composite text, or an accessible name/testid on the count element. Unscoped digit regexes against the whole screen are forbidden. - New render tests: `Portrait` AI vs human variant; `HoldButton` fires `onComplete` after hold, cancels on early release. +- **Flag-helper unit tests** (in `tests/ui/Portrait.test.tsx`, alongside the `Portrait` tests): `extractFlag` and `stripFlag` are both covered — `stripFlag("🇺🇸 US")` → `"US"` (leading flag emoji plus following whitespace removed), and `stripFlag` is the identity on strings with no leading flag (e.g. `"Freedonia"`). Shipping `stripFlag` without tests is not acceptable; the Setup tile meta line depends on it. - Visual verification: `npm run dev` side-by-side with the prototype (`design_handoff_nuke_game/index.html`). ## 7. Out of scope (later slices / never) From c0cd50f62872c61f1fbc40f2b9d75d38b5d439cf Mon Sep 17 00:00:00 2001 From: gethi Date: Sun, 19 Jul 2026 14:13:22 +0100 Subject: [PATCH 10/43] docs: p4 re-theme spec Co-Authored-By: Claude Fable 5 --- .../specs/2026-07-19-comic-retheme-slice4-design.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/specs/2026-07-19-comic-retheme-slice4-design.md b/docs/superpowers/specs/2026-07-19-comic-retheme-slice4-design.md index 56b96fc..7a2bf31 100644 --- a/docs/superpowers/specs/2026-07-19-comic-retheme-slice4-design.md +++ b/docs/superpowers/specs/2026-07-19-comic-retheme-slice4-design.md @@ -116,6 +116,8 @@ One story per **living** cast member (same iteration + `alive` filter as today's **Lead story selection**: the living leader with the largest population loss this round; ties broken by `game.cast` order; if no one lost population, the first living leader in cast order. +**DOM order**: the lead story renders **first** in the story flow, followed by the remaining living leaders in `game.cast` order. Because `.lead` is `column-span: all`, placing it anywhere but first would split the 2-column flow mid-stream; the handoff always renders the lead at the top (`screens-3.jsx` `big = i === 0`) with its `3px double` rule separating it from the columned stories below. Do not iterate the cast in order and tag the matching story in place — reorder so the selected lead comes first. + The old compact reaction rows (`👥 {pop}M · 🏭 {factories}` line) are replaced by this section; the data they showed survives in byline deltas and badges. ### 3.5 Existing behaviors preserved (main column) @@ -148,6 +150,8 @@ Conditions table (label Anton 10px / value Playfair italic 12px right-aligned, d UV block right-aligned: label `UV INDEX`, value `☢` repeated n times. +> **Deliberate divergence from the mock:** `data.jsx` `NEWS_WEATHER` pairs `FALLOUT: HEAVY` with `uv: 5` ("out of 5"). This spec's 1/2/4/5 ladder reserves UV 5 for the BIBLICAL tier so the scale has headroom; HEAVY = 4 is intentional. Do not "fix" it back to match the sample datum. + ### 4.2 MARKET REPORT Sub-line: `Population exchange · close of round` (Playfair italic 10px, ink-soft). One ticker row per cast member **including the dead** (grid `42px 64px 1fr`, dotted rules): @@ -156,6 +160,8 @@ Sub-line: `Population exchange · close of round` (Playfair italic 10px, ink-sof - **change**: `prev > 0 ? Math.round((cur − prev) / prev × 100) : 0` (prev from `prevPopulations`, 0 fallback when undefined). Rendered `▲ {n}%` green / `▬ 0%` ink-soft / `▼ {n}%` magenta (absolute value shown). - **note** (Playfair italic 10px): dead → `delisted`; change ≤ −20 → `clobbered`; −20 < change < 0 → `down`; 0 → `holds`; > 0 → `up`. +> **Deliberate divergence from the mock:** the deterministic first-3-chars rule yields `FRE` for Freedonia, while `data.jsx` `NEWS_MARKET` hand-picks `FRD`. Humans can enter any country name, so a hand-tuned abbreviation table is impossible; the rule wins and the tests assert `FRE`. A side-by-side check against the prototype showing `FRD` is not a defect. + ### 4.3 TONIGHT'S EXCHANGES (box score) One row per qualifying event, in event order (mono attacker `›` target on the left, Anton score right-aligned): @@ -166,7 +172,9 @@ One row per qualifying event, in event order (mono attacker `›` target on the | `ImpactInfrastructure` | `−{factoriesDestroyed} fac` | `--magenta` | | `MissileIntercepted` | `INTERCEPT` | `--green` | -Names use `leader.name` (first 9 chars + `.` when longer). Empty state (no qualifying events): single Playfair-italic line `No exchanges. The censors are baffled.` +Names use `leader.name` (first 9 chars + `.` when longer). Intercept rows render `{attacker} › {defender}` (from the event's `from`/`to`), with `INTERCEPT` in green. Empty state (no qualifying events): single Playfair-italic line `No exchanges. The censors are baffled.` + +> **Deliberate divergence from the mock:** `data.jsx` `NEWS_BOXSCORE` renders its intercept row as `{defender} › —`. The `{attacker} › {defender}` form is unambiguous (names who fired the intercepted shot) and keeps one row shape for all three event kinds; the mock's `—` form is not adopted. A side-by-side visual check will differ here by design. ### 4.4 OBITUARIES @@ -222,7 +230,7 @@ Full-viewport ink background, paper text, `padding: 40px 16px 96px`, ` Date: Sun, 19 Jul 2026 14:14:31 +0100 Subject: [PATCH 11/43] docs: p3 re-theme spec Co-Authored-By: Claude Fable 5 --- .../2026-07-19-comic-retheme-slice3-design.md | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/docs/superpowers/specs/2026-07-19-comic-retheme-slice3-design.md b/docs/superpowers/specs/2026-07-19-comic-retheme-slice3-design.md index 54ba954..2658226 100644 --- a/docs/superpowers/specs/2026-07-19-comic-retheme-slice3-design.md +++ b/docs/superpowers/specs/2026-07-19-comic-retheme-slice3-design.md @@ -46,7 +46,7 @@ interface PlaybackStep { - Reuse the existing phase-cursor logic from today's `Action.tsx` unchanged: `phaseAdvanceFor` assigns each event to a phase bucket; `isRenderable` filters out `OrdersSealed`, `OutcomeReached`, `DefenceConsumed`, `PreRoundMood`, `PostRoundReaction`, `DisparageColumn`. - Per phase, run `groupPhaseEvents` (unchanged), then flatten buckets in `PHASE_ORDER` into one flat step list. -- **`DisparageCameo` events do not get their own step.** Remove them from the flat list and attach each one as `cameo` on the immediately preceding step (which, given engine ordering, is the impact it follows). A cameo with no preceding step is dropped. +- **`DisparageCameo` events do not get their own step.** Remove them from the flat list and attach each one as `cameo` on the impact step it identifies: the first step (in flat order) whose grouped event is `ImpactPeople` or `ImpactInfrastructure` with `event.from === cameo.afterImpact.from && event.target === cameo.afterImpact.to`. Matching by `afterImpact` (not by "the immediately preceding event") is deliberate: `groupPhaseEvents` collapses duplicate `(target, attacker)` impacts into the first occurrence, so the raw event preceding the cameo may no longer exist as its own step — the `afterImpact` pair still resolves to the surviving collapsed step. A cameo with no matching impact step is dropped. The engine emits at most one cameo per round (`src/engine/resolution.ts`, P4c.3 cap), so no step ever carries two. - `LeaderEliminated` remains a renderable step (it is today), in whatever phase the cursor was in. ### 2.2 Component state and timers @@ -69,7 +69,7 @@ Local `useState` only (no store changes): `idx` (current step), `paused` (bool), | Map highlights, arcs, portraits | actor/receiver derived per event kind (§5.3) | | Speech bubble | `formatEventText(...).quote` (already merges attacker/sender/receiver/target quote fields) | | Detail strip hand-font line | same `quote`, or empty when the event has none | -| DamageBadge | `ImpactPeople.deaths` (label `M`) / `ImpactInfrastructure.factoriesDestroyed` (label `FACT`) | +| DamageBadge | `ImpactPeople.deaths` (label `M`) / `ImpactInfrastructure.factoriesDestroyed` (label `FACT`) — rendered only when the value is `> 0` (§6) | | Cameo card | `step.cameo.quote` | | Leader names / flags / countries | `game.leaders[id]` (engine `LEADER_PROFILES` + player overrides) — never handoff sample data | | Continue | `dispatch({ type: 'ACTION_DONE' })` (unchanged) | @@ -121,7 +121,7 @@ Banner/quote body text is generated by `formatEventText` from engine data — it ### 5.2 Country positions -`COUNTRY_POS: Record` — AI positions and labels verbatim from the handoff; human slots 2–5 are new fixed ocean positions (invented "Freedonia archipelago"; no landmass drawn, matching how the handoff treats player1): +`COUNTRY_POS: Record` — AI positions and labels verbatim from the handoff; human slots 2–5 are new fixed ocean positions (invented "Freedonia archipelago"; no landmass drawn, matching how the handoff treats player1). The handoff's `tilt` field is **dropped**: no rendering rule in this spec consumes it, and the handoff's own render code never reads it either — carrying an unused field into the declared interface would leave an implementer unable to complete it (the handoff has no tilt values for player2–5). | id | cx, cy | label | |---|---|---| @@ -137,7 +137,7 @@ Banner/quote body text is generated by `formatEventText` from engine data — it | `player4` | 90, 68 | derived | | `player5` | 44, 66 | derived | -Human labels are derived from live state, not hardcoded: `game.leaders[id].country` with the leading flag/emoji token stripped, uppercased (default player1 → `FREEDONIA`). Only leaders in `game.cast` are rendered. +AI ids carry the `label` shown above; human slots omit `label` — their labels are derived from live state at render, not hardcoded: `game.leaders[id].country` with the leading flag/emoji token stripped (slice-1 `stripFlag`), uppercased (default player1 → `FREEDONIA`). Only leaders in `game.cast` are rendered. ### 5.3 Highlights, actor/receiver derivation @@ -145,7 +145,8 @@ Each cast member gets an ellipse (`rx 6 / ry 3.4`; UK special-cases `rx 3 / ry 2 - actor → `var(--green)`, opacity 0.85 - receiver → `var(--magenta)`, opacity 0.85 -- otherwise → the leader's signature color `var(--c-)` for AI, the per-slot human accent (player1 `--cyan`, player2 `--green`, player3 `--yellow-soft`, player4 `--magenta-deep`, player5 `--ink-soft`), opacity 0.5 +- otherwise, AI leader → the leader's signature color from slice-1's `PORTRAIT_META[id]` (the hex value, the codebase's equivalent of the handoff's `LEADER_MAP[id].color`), opacity 0.5. **Do not interpolate `var(--c-)`:** tokens.css defines only abbreviated tokens (`--c-khamen`, `--c-netan`, `--c-starm`, `--c-mileigh` — not `--c-khameneverhere` / `--c-netanyahoo` / `--c-starmless` / `--c-mileigh-hem`), so a literal id interpolation yields undefined custom properties and black ellipses for 4 of the 6 AI leaders. `PORTRAIT_META` is the single id→color source of truth; no new mapping table. +- otherwise, human slot → the per-slot accent CSS var, all of which exist in tokens.css (player1 `var(--cyan)`, player2 `var(--green)`, player3 `var(--yellow-soft)`, player4 `var(--magenta-deep)`, player5 `var(--ink-soft)`), opacity 0.5 Actor/receiver per event kind: @@ -176,7 +177,7 @@ All keyed on `idx` so entrances re-run per step (`pop`/`fadein` keyframes from t | Actor portrait | `` + name pill (`game.leaders[id].name.toUpperCase()`, Anton 11px, ink bg, 2px paper border) | top-left 14px | | Receiver portrait | `` + name pill; for `LeaderEliminated`, add a rotated magenta `ELIMINATED` across it | top-right 14px | | Speech bubble | `` with the step quote (Patrick Hand); omitted when no quote | bottom-left 14px, max-width 60% | -| Burst + damage badge | `` over `` — only on ImpactPeople / ImpactInfrastructure (badge) and the burst kinds in §4; `INTERCEPTED!` burst uses `color="cyan"`, `EVERYTHING!` uses `color="magenta"` | right 14%, top 6% | +| Burst + damage badge | `` over `` — only on ImpactPeople / ImpactInfrastructure (badge) and the burst kinds in §4; `INTERCEPTED!` burst uses `color="cyan"`, `EVERYTHING!` uses `color="magenta"`. **Zero-damage guard (adopted from the handoff):** on impact kinds, the badge renders only when its value is `> 0` — the engine can emit `ImpactInfrastructure` with `factoriesDestroyed: 0` (clamped by `receiver.factories` in `src/engine/launches.ts`), and the handoff suppresses the badge in that case. The impact `Burst` is suppressed together with the badge (in the handoff both live inside the same `deaths > 0 \|\| factories > 0` conditional), so a zero-damage impact shows neither burst nor badge; the arc, impact rings, and portraits still render. The guard applies per grouped step, after `groupPhaseEvents` sums duplicate pairs. Non-impact bursts (`INTERCEPTED!`, `EVERYTHING!`) are unaffected | right 14%, top 6% | | Disparage cameo | re-themed `DisparageCard`: paper bg, 3px ink border, `box-shadow: 3px 3px 0 var(--magenta)`, max-width 280px; inside: `` + yellow `🍺 FROM CLACTON` tag + hand-font quote from the event. Shown for the whole dwell of the step it is attached to | bottom-right 12px | Portrait sizes on this screen: actor/receiver **110px**, cameo **56px**. Human portraits are the Groucho variant with slot accent + flag badge, exactly as slice 1 defines — no per-screen variation. @@ -199,10 +200,11 @@ Portrait sizes on this screen: actor/receiver **110px**, cameo **56px**. Human p ## 8. Testing & verification - Vitest + testing-library, `vi.useFakeTimers()` for playback. No guarded assertions (`if (x) expect(...)` forbidden). -- **`buildPlaybackSteps` (pure):** phase assignment matches the old bucketing; non-renderables excluded; builds grouped with counts; `DisparageCameo` attached to the preceding step, never a step itself; leading cameo dropped. +- **`buildPlaybackSteps` (pure):** phase assignment matches the old bucketing; non-renderables excluded; builds grouped with counts; `DisparageCameo` attached to the impact step matching its `afterImpact` pair, never a step itself — including the collapse case, where the cameo followed a later duplicate `(target, attacker)` impact that `groupPhaseEvents` folded into the first occurrence; a cameo whose `afterImpact` pair matches no step is dropped. - **`formatEventText` (pure):** existing per-kind strings unchanged; exhaustive switch still compile-checked (`noFallthroughCasesInSwitch`). - **Action playback:** renders step 0 banner from a real resolved game state; advances after 2700 ms, 1100 ms in fast, halts when paused; `← PREV` disabled at 0; `NEXT →` steps; last step shows `TO THE NEWS →` and clicking it dispatches `ACTION_DONE`; `SKIP →` dispatches `ACTION_DONE` mid-playback; outcome games show `TO THE VERDICT →`; empty step list shows the quiet-round copy. -- **WorldMap:** cast-only markers; actor green / receiver magenta; human label derived from live country string. +- **WorldMap:** cast-only markers; actor green / receiver magenta; non-actor AI ellipse fill equals the `PORTRAIT_META` hex (assert the rendered `fill` attribute is a concrete hex, not an undefined `var(--c-…)`); human label derived from live country string. +- **Zero-damage guard:** a step whose grouped `ImpactInfrastructure` has `factoriesDestroyed: 0` renders neither `DamageBadge` nor `Burst` (query by absence, unconditionally — no guarded assertions); a `deaths > 0` step renders both. - **DisparageCard:** renders tag + engine quote + 56px Disparage portrait. - Every commit: `tsc --noEmit` (noUnusedLocals) + `npm run test:run` green. Existing suites (`eventGrouping.test.ts`, store/round-summary tests) must stay green untouched. - Visual check: `npm run dev` side-by-side with `design_handoff_nuke_game/index.html` on the Action screen. From de434f802363f3844d6ada87b8a30e12860f606a Mon Sep 17 00:00:00 2001 From: gethi Date: Sun, 19 Jul 2026 14:21:21 +0100 Subject: [PATCH 12/43] feat(ui): comic-tabloid design tokens + self-hosted fonts Port design_handoff_nuke_game/tokens.css to src/ui/tokens.css (devnav block dropped per spec) and wire @fontsource imports in main.tsx. Co-Authored-By: Claude Fable 5 --- package-lock.json | 77 +++++++++++ package.json | 7 + src/ui/main.tsx | 17 +++ src/ui/tokens.css | 336 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 437 insertions(+) create mode 100644 src/ui/tokens.css diff --git a/package-lock.json b/package-lock.json index 6c8ea90..99d6007 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,13 @@ "name": "nuke", "version": "0.1.0", "devDependencies": { + "@fontsource/anton": "^5.3.0", + "@fontsource/bangers": "^5.3.0", + "@fontsource/bowlby-one": "^5.3.0", + "@fontsource/jetbrains-mono": "^5.3.0", + "@fontsource/patrick-hand": "^5.3.0", + "@fontsource/playfair-display": "^5.3.0", + "@fontsource/work-sans": "^5.3.0", "@testing-library/jest-dom": "^6.4.0", "@testing-library/react": "^14.2.0", "@types/node": "^20.12.7", @@ -858,6 +865,76 @@ "node": ">=12" } }, + "node_modules/@fontsource/anton": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/anton/-/anton-5.3.0.tgz", + "integrity": "sha512-OR1D5n124n2XRs66SbX7IjcOSptljEJE8FcDNnlni2y1/M6KpTx7cU2PIWzTSsBj4WFkHPw24On8qw3qkQZuTw==", + "dev": true, + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/bangers": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/bangers/-/bangers-5.3.0.tgz", + "integrity": "sha512-tSRpmWwkqrO9PCqo9wjA/99/5sLOIWm5cf51G3ooEHd8qZus8sVSSJoWp/7EvM5rIgZMjKRG6XtY9yeisKxE7g==", + "dev": true, + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/bowlby-one": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/bowlby-one/-/bowlby-one-5.3.0.tgz", + "integrity": "sha512-mjHkM64RbN4I2xFpk+6sQt+aF/k46IHEWP6ul4/RwAYJF5ETDjUmDp1zgU2Tj/w2yYIjMGVyGVifCNNR5ZbyZQ==", + "dev": true, + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/jetbrains-mono": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/jetbrains-mono/-/jetbrains-mono-5.3.0.tgz", + "integrity": "sha512-fqDfB5I9f1p1TV486aUgB9t8zP84P0O1FtQR5Ol9vjwPy+S+EIGlVYm1cvj2W5shcZMTg2nZFdVMoH5wFu8a1A==", + "dev": true, + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/patrick-hand": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/patrick-hand/-/patrick-hand-5.3.0.tgz", + "integrity": "sha512-V8C38IlIFfdWg0Xtri3F7sLCVpm069rUwn6w4n+oyn38gt7H4arOJmuReg3tAw1Q6jHqZp89wTnAAJagq4Atxw==", + "dev": true, + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/playfair-display": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/playfair-display/-/playfair-display-5.3.0.tgz", + "integrity": "sha512-uJSABalvdWc10fEPppQjQocamT21LEyRfzzWfRfqbkPz+sRv+kKpYDKyluBUskvJREkJTos9epj1WjQROGu99Q==", + "dev": true, + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/work-sans": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/work-sans/-/work-sans-5.3.0.tgz", + "integrity": "sha512-3cKV28+bEiLGdyOl0VgEGCFWE1XdZJu/K+nH4Jt76XC3T//XrRtm2EF7jA61GSMIy8sYp6ZFTE01nD3lLaBV9A==", + "dev": true, + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@jest/schemas": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", diff --git a/package.json b/package.json index 25ed8c5..75db3ad 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,13 @@ "typecheck": "tsc --noEmit" }, "devDependencies": { + "@fontsource/anton": "^5.3.0", + "@fontsource/bangers": "^5.3.0", + "@fontsource/bowlby-one": "^5.3.0", + "@fontsource/jetbrains-mono": "^5.3.0", + "@fontsource/patrick-hand": "^5.3.0", + "@fontsource/playfair-display": "^5.3.0", + "@fontsource/work-sans": "^5.3.0", "@testing-library/jest-dom": "^6.4.0", "@testing-library/react": "^14.2.0", "@types/node": "^20.12.7", diff --git a/src/ui/main.tsx b/src/ui/main.tsx index 7c12802..cfe455a 100644 --- a/src/ui/main.tsx +++ b/src/ui/main.tsx @@ -1,3 +1,20 @@ +import '@fontsource/anton'; +import '@fontsource/playfair-display/400.css'; +import '@fontsource/playfair-display/700.css'; +import '@fontsource/playfair-display/900.css'; +import '@fontsource/playfair-display/400-italic.css'; +import '@fontsource/playfair-display/700-italic.css'; +import '@fontsource/work-sans/400.css'; +import '@fontsource/work-sans/500.css'; +import '@fontsource/work-sans/600.css'; +import '@fontsource/work-sans/700.css'; +import '@fontsource/work-sans/800.css'; +import '@fontsource/jetbrains-mono/400.css'; +import '@fontsource/jetbrains-mono/700.css'; +import '@fontsource/bowlby-one'; +import '@fontsource/patrick-hand'; +import '@fontsource/bangers'; +import './tokens.css'; import { createRoot } from 'react-dom/client'; import App from './App'; diff --git a/src/ui/tokens.css b/src/ui/tokens.css new file mode 100644 index 0000000..9ff9b88 --- /dev/null +++ b/src/ui/tokens.css @@ -0,0 +1,336 @@ +/* nuke — design tokens + base layer + * Political-cartoon comic-book / four-color tabloid. + * Heavy ink, cream newsprint, halftone, CMYK accents. + */ + +:root { + /* Four-color comic palette */ + --ink: #141214; + --ink-soft: #2a2528; + --paper: #f1e6cc; + --paper-bright: #f8eed4; + --paper-warm: #ece0bf; + --paper-edge: #d6c79e; + --paper-shadow: #b9a978; + + /* CMYK-ish accents (slightly print-aged) */ + --cyan: #1d7fb0; + --cyan-soft: #58a5cf; + --magenta: #c61f44; + --magenta-deep: #951129; + --yellow: #f3c318; + --yellow-soft: #e9b81e; + --green: #2e7a3c; + + /* Leader signature colors */ + --c-chump: #d6293f; + --c-chump-2: #f3c318; + --c-khamen: #1f5e3a; + --c-netan: #1a4d8c; + --c-starm: #8a1729; + --c-carnage: #b22535; + --c-mileigh: #e6a517; + --c-disparage: #5a3e8a; + + /* Type */ + --font-display: "Anton", "Arial Narrow", sans-serif; + --font-tabloid: "Playfair Display", Georgia, serif; + --font-ui: "Work Sans", -apple-system, BlinkMacSystemFont, sans-serif; + --font-mono: "JetBrains Mono", "Courier New", monospace; + --font-pop: "Bowlby One", "Anton", sans-serif; + --font-hand: "Patrick Hand", "Comic Sans MS", cursive; + --font-bang: "Bangers", "Anton", sans-serif; + + /* Spacing */ + --s-1: 4px; + --s-2: 8px; + --s-3: 12px; + --s-4: 16px; + --s-5: 24px; + --s-6: 32px; + --s-7: 48px; + --s-8: 64px; + + /* Borders / panels */ + --border-1: 2px; + --border-2: 3px; + --border-3: 4px; + --radius: 0px; /* comic panels are mostly square */ + --radius-soft: 6px; + + /* Shadows — hard "ink offset" rather than soft drop */ + --offset-sm: 3px 3px 0 var(--ink); + --offset-md: 5px 5px 0 var(--ink); + --offset-lg: 8px 8px 0 var(--ink); +} + +* { box-sizing: border-box; } + +html, body, #root { + margin: 0; + padding: 0; + min-height: 100vh; + background: var(--ink); +} + +body { + font-family: var(--font-ui); + color: var(--ink); + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; +} + +/* ============================================================ + * Paper / halftone / grain + * ============================================================ */ + +.paper { + background-color: var(--paper); + background-image: + radial-gradient(circle at 12% 18%, rgba(255,255,255,0.45) 0%, transparent 50%), + radial-gradient(circle at 88% 82%, rgba(0,0,0,0.05) 0%, transparent 60%), + repeating-linear-gradient(43deg, transparent 0 5px, rgba(0,0,0,0.012) 5px 6px), + repeating-linear-gradient(-47deg, transparent 0 4px, rgba(120,80,40,0.018) 4px 5px); +} + +.halftone-cyan { background-image: radial-gradient(var(--cyan) 1px, transparent 1.4px); background-size: 6px 6px; } +.halftone-magenta { background-image: radial-gradient(var(--magenta) 1px, transparent 1.4px); background-size: 6px 6px; } +.halftone-yellow { background-image: radial-gradient(var(--yellow) 1.2px, transparent 1.6px); background-size: 5px 5px; } +.halftone-ink { background-image: radial-gradient(var(--ink) 0.8px, transparent 1.2px); background-size: 5px 5px; } + +/* ============================================================ + * Comic panel + * ============================================================ */ + +.panel { + position: relative; + background: var(--paper-bright); + border: var(--border-3) solid var(--ink); + box-shadow: var(--offset-md); + padding: var(--s-4); +} +.panel.tight { padding: var(--s-3); } +.panel.flat { box-shadow: none; } +.panel.dark { background: var(--ink); color: var(--paper); } +.panel.tilt-l { transform: rotate(-0.6deg); } +.panel.tilt-r { transform: rotate(0.6deg); } +.panel.tilt-r-1 { transform: rotate(1.2deg); } +.panel.tilt-l-1 { transform: rotate(-1.2deg); } + +.panel-title { + font-family: var(--font-display); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.14em; + background: var(--ink); + color: var(--paper); + padding: 4px 10px; + display: inline-block; + margin: -22px 0 12px -22px; + transform: rotate(-1.5deg); +} + +/* ============================================================ + * Type utilities + * ============================================================ */ + +.display { font-family: var(--font-display); text-transform: uppercase; letter-spacing: 0.01em; line-height: 0.95; } +.tabloid { font-family: var(--font-tabloid); } +.pop { font-family: var(--font-pop); } +.hand { font-family: var(--font-hand); } +.bang { font-family: var(--font-bang); letter-spacing: 0.03em; } +.mono { font-family: var(--font-mono); } +.upper { text-transform: uppercase; } +.italic { font-style: italic; } + +/* ============================================================ + * Stamps / badges + * ============================================================ */ + +.stamp { + display: inline-block; + font-family: var(--font-display); + font-size: 13px; + letter-spacing: 0.06em; + background: var(--magenta); + color: var(--paper); + padding: 4px 10px; + border: 2px solid var(--paper); + outline: 2px solid var(--ink); + text-transform: uppercase; + transform: rotate(-6deg); + box-shadow: 2px 2px 0 var(--ink); +} +.stamp.ink { background: var(--ink); } +.stamp.cyan { background: var(--cyan); } +.stamp.yellow { background: var(--yellow); color: var(--ink); } +.stamp.green { background: var(--green); } + +.tag { + display: inline-block; + font-family: var(--font-display); + font-size: 10px; + letter-spacing: 0.12em; + text-transform: uppercase; + padding: 3px 7px; + background: var(--ink); + color: var(--paper); +} +.tag.cyan { background: var(--cyan); } +.tag.magenta { background: var(--magenta); } +.tag.yellow { background: var(--yellow); color: var(--ink); } +.tag.green { background: var(--green); } +.tag.outline { background: transparent; color: var(--ink); border: 2px solid var(--ink); } + +/* ============================================================ + * Buttons + * ============================================================ */ + +.btn { + font-family: var(--font-display); + font-size: 16px; + letter-spacing: 0.08em; + text-transform: uppercase; + padding: 10px 20px; + background: var(--paper); + color: var(--ink); + border: var(--border-2) solid var(--ink); + box-shadow: var(--offset-sm); + cursor: pointer; + transition: transform 80ms ease, box-shadow 80ms ease; +} +.btn:hover { transform: translate(-1px, -1px); box-shadow: 4px 4px 0 var(--ink); } +.btn:active { transform: translate(2px, 2px); box-shadow: 1px 1px 0 var(--ink); } +.btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; box-shadow: var(--offset-sm); } +.btn.lg { font-size: 22px; padding: 14px 28px; box-shadow: var(--offset-md); } +.btn.xl { font-size: 32px; padding: 18px 38px; box-shadow: var(--offset-lg); } +.btn.primary { background: var(--magenta); color: var(--paper); } +.btn.danger { background: var(--ink); color: var(--paper); } +.btn.cyan { background: var(--cyan); color: var(--paper); } +.btn.ghost { background: transparent; box-shadow: none; border: 2px dashed var(--ink); } + +/* ============================================================ + * Speech bubble + * ============================================================ */ + +.bubble { + position: relative; + display: inline-block; + background: var(--paper-bright); + color: var(--ink); + border: var(--border-2) solid var(--ink); + padding: 10px 14px; + font-family: var(--font-ui); + font-size: 15px; + font-weight: 600; + line-height: 1.3; + max-width: 280px; + box-shadow: 3px 3px 0 var(--ink); +} +.bubble::after, .bubble::before { + content: ""; + position: absolute; + width: 0; height: 0; +} +.bubble.tail-bl::before { + bottom: -14px; left: 18px; + border-left: 12px solid transparent; + border-right: 6px solid transparent; + border-top: 14px solid var(--ink); +} +.bubble.tail-bl::after { + bottom: -10px; left: 21px; + border-left: 9px solid transparent; + border-right: 4px solid transparent; + border-top: 11px solid var(--paper-bright); +} +.bubble.tail-tl::before { + top: -14px; left: 18px; + border-left: 6px solid transparent; + border-right: 12px solid transparent; + border-bottom: 14px solid var(--ink); +} +.bubble.tail-tl::after { + top: -10px; left: 21px; + border-left: 4px solid transparent; + border-right: 9px solid transparent; + border-bottom: 11px solid var(--paper-bright); +} +.bubble.thought { + border-radius: 26px; + border-style: dashed; +} + +/* ============================================================ + * Burst / sound effects + * ============================================================ */ + +.burst { + display: inline-block; + font-family: var(--font-bang); + font-size: 32px; + color: var(--paper); + background: var(--magenta); + padding: 12px 16px; + border: 3px solid var(--ink); + text-transform: uppercase; + letter-spacing: 0.04em; + transform: rotate(-7deg); + clip-path: polygon( + 0 12%, 12% 0%, 24% 14%, 36% 0%, 50% 16%, 64% 0%, 76% 14%, 88% 0%, 100% 14%, + 96% 36%, 100% 52%, 90% 70%, 100% 88%, 84% 100%, 70% 88%, 56% 100%, 42% 90%, 28% 100%, 14% 88%, 0 100%, + 8% 76%, 0 60%, 10% 44%, 0 28% + ); +} + +/* ============================================================ + * Scrollbar (light) + * ============================================================ */ +*::-webkit-scrollbar { width: 8px; height: 8px; } +*::-webkit-scrollbar-thumb { background: var(--ink); border: 1px solid var(--paper); } +*::-webkit-scrollbar-track { background: var(--paper-edge); } + +/* ============================================================ + * App-level layout + * ============================================================ */ + +.app { + min-height: 100vh; + background: var(--ink); + display: flex; + flex-direction: column; + align-items: stretch; +} +.app-stage { + flex: 1; + position: relative; + overflow: hidden; +} +.screen { + min-height: 100vh; + width: 100%; +} + + +/* Utility */ +.row { display: flex; gap: var(--s-3); } +.col { display: flex; flex-direction: column; gap: var(--s-3); } +.grow { flex: 1; } +.center { display: flex; align-items: center; justify-content: center; } +.between { display: flex; justify-content: space-between; align-items: center; } +.gap-2 { gap: var(--s-2); } +.gap-3 { gap: var(--s-3); } +.gap-4 { gap: var(--s-4); } +.gap-5 { gap: var(--s-5); } +.wrap { flex-wrap: wrap; } +.tc { text-align: center; } + +/* Animations (subtle) */ +@keyframes fadein { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } } +@keyframes pop { 0% { transform: scale(0.85); opacity: 0; } 60% { transform: scale(1.06); opacity: 1; } 100% { transform: scale(1); opacity: 1; } } +@keyframes pulsedot { 0%, 100% { transform: scale(1); opacity: 0.4; } 50% { transform: scale(1.4); opacity: 1; } } +@keyframes wobble { 0%, 100% { transform: rotate(-1deg); } 50% { transform: rotate(1deg); } } +@keyframes scan { 0% { transform: translateY(-100%); } 100% { transform: translateY(100%); } } +.fadein { animation: fadein 240ms ease-out both; } +.popin { animation: pop 260ms cubic-bezier(0.4, 0.0, 0.2, 1) both; } +.wobble { animation: wobble 4s ease-in-out infinite; } From 6cddd3f2be42b5a333ebf26c5f1586b0141e145d Mon Sep 17 00:00:00 2001 From: gethi Date: Sun, 19 Jul 2026 14:24:37 +0100 Subject: [PATCH 13/43] feat(ui): geometric SVG portrait system with Groucho human variant Eight face SVGs ported from the design handoff; Portrait component dispatches AI signature colors vs per-slot human accents, with flag badge and extractFlag/stripFlag helpers. Co-Authored-By: Claude Fable 5 --- src/ui/components/Portrait.tsx | 76 ++++++++++ src/ui/portraits/faces.tsx | 248 +++++++++++++++++++++++++++++++++ src/ui/portraits/index.ts | 56 ++++++++ tests/ui/Portrait.test.tsx | 52 +++++++ 4 files changed, 432 insertions(+) create mode 100644 src/ui/components/Portrait.tsx create mode 100644 src/ui/portraits/faces.tsx create mode 100644 src/ui/portraits/index.ts create mode 100644 tests/ui/Portrait.test.tsx diff --git a/src/ui/components/Portrait.tsx b/src/ui/components/Portrait.tsx new file mode 100644 index 0000000..4b04ec7 --- /dev/null +++ b/src/ui/components/Portrait.tsx @@ -0,0 +1,76 @@ +import { isHuman } from '../../engine/state'; +import type { LeaderId } from '../../engine/types'; +import { FACES, HUMAN_ACCENTS, PORTRAIT_META } from '../portraits'; + +export interface PortraitProps { + /** 'disparage' is a non-playable cameo, absent from the engine's LeaderId union. */ + leaderId: LeaderId | 'disparage'; + /** Pixel diameter (circle) or edge (square). */ + size?: number; + shape?: 'circle' | 'square'; + showBadge?: boolean; + /** Badge flag override — humans pass their chosen country's flag. */ + flag?: string; + className?: string; +} + +export default function Portrait({ + leaderId, size = 80, shape = 'circle', showBadge = true, flag, className, +}: PortraitProps) { + const human = leaderId !== 'disparage' && isHuman(leaderId); + const Face = FACES[leaderId]; + if (!Face) return null; + const color = human ? HUMAN_ACCENTS[leaderId] : PORTRAIT_META[leaderId].color; + const badgeFlag = flag ?? (human ? '🌐' : PORTRAIT_META[leaderId].flag); + const badgeSize = Math.max(16, Math.round(size * 0.28)); + + return ( + + + + + + + {shape !== 'square' && ( + + )} + + {showBadge && ( + + {badgeFlag} + + )} + + ); +} diff --git a/src/ui/portraits/faces.tsx b/src/ui/portraits/faces.tsx new file mode 100644 index 0000000..71e3636 --- /dev/null +++ b/src/ui/portraits/faces.tsx @@ -0,0 +1,248 @@ +/** + * nuke — geometric caricature portraits (from the design handoff). + * Each leader rendered as a flat geometric SVG composition. + * Recognizable through silhouette + signature color + key prop, never literal likeness. + * Deliberate placeholders: commissioned art can replace bodies with no API change. + */ + +export function ChumpFace(): JSX.Element { + return ( + + ); +} + +export function NetanyahooFace(): JSX.Element { + return ( + + ); +} + +export function KhameneverhereFace(): JSX.Element { + return ( + + ); +} + +export function StarmlessFace(): JSX.Element { + return ( + + ); +} + +export function CarnageFace(): JSX.Element { + return ( + + ); +} + +export function MileighFace(): JSX.Element { + return ( + + ); +} + +export function DisparageFace(): JSX.Element { + return ( + + ); +} + +export function GrouchoFace(): JSX.Element { + return ( + + ); +} diff --git a/src/ui/portraits/index.ts b/src/ui/portraits/index.ts new file mode 100644 index 0000000..fc24944 --- /dev/null +++ b/src/ui/portraits/index.ts @@ -0,0 +1,56 @@ +import type { LeaderId } from '../../engine/types'; +import { + CarnageFace, ChumpFace, DisparageFace, GrouchoFace, + KhameneverhereFace, MileighFace, NetanyahooFace, StarmlessFace, +} from './faces'; + +/** Face component per AI leader; every human slot maps to the Groucho disguise. */ +export const FACES: Record JSX.Element> = { + chump: ChumpFace, + netanyahoo: NetanyahooFace, + khameneverhere: KhameneverhereFace, + starmless: StarmlessFace, + carnage: CarnageFace, + 'mileigh-hem': MileighFace, + disparage: DisparageFace, + player1: GrouchoFace, + player2: GrouchoFace, + player3: GrouchoFace, + player4: GrouchoFace, + player5: GrouchoFace, +}; + +/** Signature disc color + badge flag for the fixed cast. */ +export const PORTRAIT_META: Record = { + chump: { color: 'var(--c-chump)', flag: '🇺🇸' }, + netanyahoo: { color: 'var(--c-netan)', flag: '🇮🇱' }, + khameneverhere: { color: 'var(--c-khamen)', flag: '🇮🇷' }, + starmless: { color: 'var(--c-starm)', flag: '🇬🇧' }, + carnage: { color: 'var(--c-carnage)', flag: '🇨🇦' }, + 'mileigh-hem': { color: 'var(--c-mileigh)', flag: '🇦🇷' }, + disparage: { color: 'var(--c-disparage)', flag: '🇬🇧' }, +}; + +/** Fixed per-slot accent for human (Groucho) portraits — spec §3. */ +export const HUMAN_ACCENTS: Record = { + player1: 'var(--cyan)', + player2: 'var(--green)', + player3: 'var(--yellow-soft)', + player4: 'var(--magenta-deep)', + player5: 'var(--ink-soft)', +}; + +const LEADING_FLAG = /^(\p{RI}\p{RI}|\p{Extended_Pictographic})/u; + +/** Leading flag/emoji of a country string ("🇺🇸 US" → "🇺🇸"), 🌐 fallback. */ +export function extractFlag(country: string): string { + const m = country.trim().match(LEADING_FLAG); + return m ? m[0] : '🌐'; +} + +/** Country string without its leading flag/emoji ("🇺🇸 US" → "US"); identity when no flag. */ +export function stripFlag(country: string): string { + return country.trim().replace(LEADING_FLAG, '').trimStart(); +} + +export type { LeaderId }; diff --git a/tests/ui/Portrait.test.tsx b/tests/ui/Portrait.test.tsx new file mode 100644 index 0000000..3805bdf --- /dev/null +++ b/tests/ui/Portrait.test.tsx @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; +import { render } from '@testing-library/react'; +import Portrait from '../../src/ui/components/Portrait'; +import { extractFlag, stripFlag } from '../../src/ui/portraits'; + +describe('', () => { + it('renders an AI leader face SVG with its flag badge', () => { + const { container, getByText } = render(); + expect(container.querySelector('svg')).not.toBeNull(); + expect(getByText('🇺🇸')).toBeInTheDocument(); + }); + + it('renders the Groucho disguise for human slots with the supplied flag', () => { + const { container, getByText } = render( + , + ); + // Groucho face is identifiable by its data marker + expect(container.querySelector('[data-face="groucho"]')).not.toBeNull(); + expect(getByText('🦆')).toBeInTheDocument(); + }); + + it('hides the badge when showBadge is false', () => { + const { queryByText } = render( + , + ); + expect(queryByText('🇺🇸')).toBeNull(); + }); +}); + +describe('extractFlag', () => { + it('extracts a regional-indicator flag', () => { + expect(extractFlag('🇺🇸 US')).toBe('🇺🇸'); + }); + it('extracts a pictographic emoji', () => { + expect(extractFlag('🦆 Freedonia')).toBe('🦆'); + }); + it('falls back to the globe for plain text', () => { + expect(extractFlag('Freedonia')).toBe('🌐'); + }); +}); + +describe('stripFlag', () => { + it('removes a leading regional-indicator flag plus whitespace', () => { + expect(stripFlag('🇺🇸 US')).toBe('US'); + }); + it('removes a leading pictographic emoji plus whitespace', () => { + expect(stripFlag('🦆 Freedonia')).toBe('Freedonia'); + }); + it('is the identity on strings with no leading flag', () => { + expect(stripFlag('Freedonia')).toBe('Freedonia'); + }); +}); From a7a9133e04fa947ee89d58cd162ebbf94f4c1e4b Mon Sep 17 00:00:00 2001 From: gethi Date: Sun, 19 Jul 2026 14:27:17 +0100 Subject: [PATCH 14/43] feat(ui): shared comic primitives ported from design handoff 14 typed components (Panel, Halftone, Tag, Stamp, Bubble, Burst, Btn, Stat, ApMeter, PhaseTrack, DamageBadge, RelBadge, HoldButton, Ribbon) with a barrel export. HoldButton adds rAF cleanup on unmount. Co-Authored-By: Claude Fable 5 --- src/ui/components/comic/ApMeter.tsx | 54 +++++++++++++++++++ src/ui/components/comic/Btn.tsx | 29 +++++++++++ src/ui/components/comic/Bubble.tsx | 17 ++++++ src/ui/components/comic/Burst.tsx | 29 +++++++++++ src/ui/components/comic/DamageBadge.tsx | 33 ++++++++++++ src/ui/components/comic/Halftone.tsx | 28 ++++++++++ src/ui/components/comic/HoldButton.tsx | 69 +++++++++++++++++++++++++ src/ui/components/comic/Panel.tsx | 33 ++++++++++++ src/ui/components/comic/PhaseTrack.tsx | 43 +++++++++++++++ src/ui/components/comic/RelBadge.tsx | 45 ++++++++++++++++ src/ui/components/comic/Ribbon.tsx | 33 ++++++++++++ src/ui/components/comic/Stamp.tsx | 17 ++++++ src/ui/components/comic/Stat.tsx | 20 +++++++ src/ui/components/comic/Tag.tsx | 12 +++++ src/ui/components/comic/index.ts | 14 +++++ tests/ui/HoldButton.test.tsx | 26 ++++++++++ 16 files changed, 502 insertions(+) create mode 100644 src/ui/components/comic/ApMeter.tsx create mode 100644 src/ui/components/comic/Btn.tsx create mode 100644 src/ui/components/comic/Bubble.tsx create mode 100644 src/ui/components/comic/Burst.tsx create mode 100644 src/ui/components/comic/DamageBadge.tsx create mode 100644 src/ui/components/comic/Halftone.tsx create mode 100644 src/ui/components/comic/HoldButton.tsx create mode 100644 src/ui/components/comic/Panel.tsx create mode 100644 src/ui/components/comic/PhaseTrack.tsx create mode 100644 src/ui/components/comic/RelBadge.tsx create mode 100644 src/ui/components/comic/Ribbon.tsx create mode 100644 src/ui/components/comic/Stamp.tsx create mode 100644 src/ui/components/comic/Stat.tsx create mode 100644 src/ui/components/comic/Tag.tsx create mode 100644 src/ui/components/comic/index.ts create mode 100644 tests/ui/HoldButton.test.tsx diff --git a/src/ui/components/comic/ApMeter.tsx b/src/ui/components/comic/ApMeter.tsx new file mode 100644 index 0000000..cd7a88f --- /dev/null +++ b/src/ui/components/comic/ApMeter.tsx @@ -0,0 +1,54 @@ +export interface ApMeterProps { + used: number; + max: number; +} + +/** AP meter */ +export default function ApMeter({ used, max }: ApMeterProps) { + const pct = Math.min(100, (used / Math.max(1, max)) * 100); + const over = used > max; + return ( +
+
+
+ {/* tick marks */} + {Array.from({ length: Math.max(0, max - 1) }).map((_, i) => ( + + ))} +
+
+ {used} / {max} AP + + {over ? 'OVER BUDGET' : `${Math.max(0, max - used)} will bank`} + +
+
+ ); +} diff --git a/src/ui/components/comic/Btn.tsx b/src/ui/components/comic/Btn.tsx new file mode 100644 index 0000000..8275dde --- /dev/null +++ b/src/ui/components/comic/Btn.tsx @@ -0,0 +1,29 @@ +import type { CSSProperties, MouseEventHandler, ReactNode } from 'react'; + +export interface BtnProps { + children: ReactNode; + variant?: 'default' | 'primary' | 'danger' | 'cyan' | 'ghost'; + size?: 'md' | 'lg' | 'xl'; + onClick?: MouseEventHandler; + disabled?: boolean; + type?: 'button' | 'submit' | 'reset'; + style?: CSSProperties; +} + +/** Button */ +export default function Btn({ + children, variant = 'default', size = 'md', onClick, disabled, style = {}, type = 'button', +}: BtnProps) { + const cls = ['btn']; + if (variant === 'primary') cls.push('primary'); + if (variant === 'danger') cls.push('danger'); + if (variant === 'cyan') cls.push('cyan'); + if (variant === 'ghost') cls.push('ghost'); + if (size === 'lg') cls.push('lg'); + if (size === 'xl') cls.push('xl'); + return ( + + ); +} diff --git a/src/ui/components/comic/Bubble.tsx b/src/ui/components/comic/Bubble.tsx new file mode 100644 index 0000000..f06ffb4 --- /dev/null +++ b/src/ui/components/comic/Bubble.tsx @@ -0,0 +1,17 @@ +import type { CSSProperties, ReactNode } from 'react'; + +export interface BubbleProps { + children: ReactNode; + tail?: 'bl' | 'tl'; + thought?: boolean; + style?: CSSProperties; +} + +/** Speech bubble */ +export default function Bubble({ children, tail = 'bl', thought = false, style = {} }: BubbleProps) { + return ( + + {children} + + ); +} diff --git a/src/ui/components/comic/Burst.tsx b/src/ui/components/comic/Burst.tsx new file mode 100644 index 0000000..bc865b0 --- /dev/null +++ b/src/ui/components/comic/Burst.tsx @@ -0,0 +1,29 @@ +import type { CSSProperties, ReactNode } from 'react'; + +export interface BurstProps { + children: ReactNode; + color?: string; + rotate?: number; + size?: number; + style?: CSSProperties; +} + +/** Comic burst (sound effect) */ +export default function Burst({ + children, color = 'magenta', rotate = -8, size = 32, style = {}, +}: BurstProps) { + const bg = `var(--${color})`; + const fg = color === 'yellow' ? 'var(--ink)' : 'var(--paper)'; + return ( + + {children} + + ); +} diff --git a/src/ui/components/comic/DamageBadge.tsx b/src/ui/components/comic/DamageBadge.tsx new file mode 100644 index 0000000..a46090a --- /dev/null +++ b/src/ui/components/comic/DamageBadge.tsx @@ -0,0 +1,33 @@ +import type { CSSProperties, ReactNode } from 'react'; + +export interface DamageBadgeProps { + value: number | string; + label?: ReactNode; + rotate?: number; + style?: CSSProperties; +} + +/** Damage badge (comic-tilted red stamp with -XM number) */ +export default function DamageBadge({ value, label = 'M', rotate = -8, style = {} }: DamageBadgeProps) { + return ( + + −{value} + {label} + + ); +} diff --git a/src/ui/components/comic/Halftone.tsx b/src/ui/components/comic/Halftone.tsx new file mode 100644 index 0000000..8038e37 --- /dev/null +++ b/src/ui/components/comic/Halftone.tsx @@ -0,0 +1,28 @@ +import type { CSSProperties } from 'react'; + +export interface HalftoneProps { + color?: string; + size?: number; + opacity?: number; + style?: CSSProperties; +} + +/** Halftone dot overlay layer */ +export default function Halftone({ + color = 'rgba(20,18,20,0.16)', size = 6, opacity = 1, style = {}, +}: HalftoneProps) { + return ( + + ); +} diff --git a/src/ui/components/comic/HoldButton.tsx b/src/ui/components/comic/HoldButton.tsx new file mode 100644 index 0000000..32ab17f --- /dev/null +++ b/src/ui/components/comic/HoldButton.tsx @@ -0,0 +1,69 @@ +import { useEffect, useRef, useState } from 'react'; +import type { CSSProperties, ReactNode } from 'react'; + +export interface HoldButtonProps { + onComplete: () => void; + duration?: number; + children: ReactNode; + disabled?: boolean; + style?: CSSProperties; +} + +/** Tap-and-hold button — rAF progress fill, fires onComplete at 100%. */ +export default function HoldButton({ + onComplete, duration = 600, children, disabled, style, +}: HoldButtonProps) { + const [progress, setProgress] = useState(0); + const frameRef = useRef(null); + const startRef = useRef(0); + + const cancelHold = () => { + if (frameRef.current !== null) cancelAnimationFrame(frameRef.current); + frameRef.current = null; + setProgress(0); + }; + + const startHold = () => { + if (disabled) return; + startRef.current = Date.now(); + const tick = () => { + const p = Math.min(1, (Date.now() - startRef.current) / duration); + setProgress(p); + if (p >= 1) { + cancelHold(); + onComplete(); + } else { + frameRef.current = requestAnimationFrame(tick); + } + }; + frameRef.current = requestAnimationFrame(tick); + }; + + useEffect(() => cancelHold, []); + + return ( + + ); +} diff --git a/src/ui/components/comic/Panel.tsx b/src/ui/components/comic/Panel.tsx new file mode 100644 index 0000000..cf3a179 --- /dev/null +++ b/src/ui/components/comic/Panel.tsx @@ -0,0 +1,33 @@ +import type { CSSProperties, ReactNode } from 'react'; +import Halftone from './Halftone'; + +export interface PanelProps { + title?: ReactNode; + tilt?: -2 | -1 | 0 | 1 | 2; + dark?: boolean; + halftone?: boolean; + halftoneColor?: string; + padding?: number | string; + className?: string; + style?: CSSProperties; + children?: ReactNode; +} + +/** Comic panel with optional title strip */ +export default function Panel({ + title, tilt = 0, dark = false, halftone = false, halftoneColor, + padding, className = '', style = {}, children, +}: PanelProps) { + const tiltClass = + tilt === -1 ? 'tilt-l' : tilt === 1 ? 'tilt-r' : tilt === -2 ? 'tilt-l-1' : tilt === 2 ? 'tilt-r-1' : ''; + return ( +
+ {halftone && } + {title &&
{title}
} +
{children}
+
+ ); +} diff --git a/src/ui/components/comic/PhaseTrack.tsx b/src/ui/components/comic/PhaseTrack.tsx new file mode 100644 index 0000000..2174e83 --- /dev/null +++ b/src/ui/components/comic/PhaseTrack.tsx @@ -0,0 +1,43 @@ +export interface PhaseTrackProps { + phases: string[]; + current: string; + done: string[]; +} + +/** Phase track for Action screen */ +export default function PhaseTrack({ phases, current, done }: PhaseTrackProps) { + return ( +
+ {phases.map((p, i) => { + const isDone = done.includes(p); + const isCurrent = current === p; + const bg = isCurrent ? 'var(--magenta)' : isDone ? 'var(--ink)' : 'var(--paper)'; + const color = isCurrent ? 'var(--paper)' : isDone ? 'var(--paper)' : 'var(--ink-soft)'; + return ( +
+ {p} + {isCurrent && ( +
+ )} +
+ ); + })} +
+ ); +} diff --git a/src/ui/components/comic/RelBadge.tsx b/src/ui/components/comic/RelBadge.tsx new file mode 100644 index 0000000..31ecb06 --- /dev/null +++ b/src/ui/components/comic/RelBadge.tsx @@ -0,0 +1,45 @@ +import type { ReactNode } from 'react'; + +export type RelBadgeKind = + | 'hit-you' | 'wooing' | 'you-woo' | 'grudge' | 'mood' + | 'gain' | 'neutral' | 'harm' | 'cyan' | 'yellow'; + +export interface RelBadgeProps { + kind: RelBadgeKind; + children: ReactNode; +} + +const KIND_STYLES: Record = { + 'hit-you': { bg: 'var(--magenta)', fg: 'var(--paper)' }, + wooing: { bg: 'var(--green)', fg: 'var(--paper)' }, + 'you-woo': { bg: '#6b8a2b', fg: 'var(--paper)' }, + grudge: { bg: '#5a3a1a', fg: 'var(--paper)' }, + mood: { bg: 'var(--paper-warm)', fg: 'var(--ink)' }, + gain: { bg: 'var(--green)', fg: 'var(--paper)' }, + neutral: { bg: '#5a4a3a', fg: 'var(--paper)' }, + harm: { bg: 'var(--magenta-deep)', fg: 'var(--paper)' }, + cyan: { bg: 'var(--cyan)', fg: 'var(--paper)' }, + yellow: { bg: 'var(--yellow)', fg: 'var(--ink)' }, +}; + +/** Relationship badge */ +export default function RelBadge({ kind, children }: RelBadgeProps) { + const s = KIND_STYLES[kind] ?? KIND_STYLES.neutral; + return ( + + {children} + + ); +} diff --git a/src/ui/components/comic/Ribbon.tsx b/src/ui/components/comic/Ribbon.tsx new file mode 100644 index 0000000..bfc5104 --- /dev/null +++ b/src/ui/components/comic/Ribbon.tsx @@ -0,0 +1,33 @@ +import type { CSSProperties, ReactNode } from 'react'; + +export interface RibbonProps { + children: ReactNode; + color?: string; + rotate?: number; + style?: CSSProperties; +} + +/** Tilted ribbon banner */ +export default function Ribbon({ children, color = 'magenta', rotate = -2, style = {} }: RibbonProps) { + const bg = `var(--${color})`; + return ( + + {children} + + ); +} diff --git a/src/ui/components/comic/Stamp.tsx b/src/ui/components/comic/Stamp.tsx new file mode 100644 index 0000000..0166429 --- /dev/null +++ b/src/ui/components/comic/Stamp.tsx @@ -0,0 +1,17 @@ +import type { CSSProperties, ReactNode } from 'react'; + +export interface StampProps { + children: ReactNode; + color?: 'magenta' | 'ink' | 'cyan' | 'yellow' | 'green'; + rotate?: number; + style?: CSSProperties; +} + +/** Stamp — slanted "rubber stamp" */ +export default function Stamp({ children, color = 'magenta', rotate = -6, style = {} }: StampProps) { + return ( + + {children} + + ); +} diff --git a/src/ui/components/comic/Stat.tsx b/src/ui/components/comic/Stat.tsx new file mode 100644 index 0000000..d963786 --- /dev/null +++ b/src/ui/components/comic/Stat.tsx @@ -0,0 +1,20 @@ +import type { CSSProperties, ReactNode } from 'react'; + +export interface StatProps { + label: ReactNode; + value: ReactNode; + sub?: ReactNode; + accent?: string; + style?: CSSProperties; +} + +/** Stat block — label + value, comic style */ +export default function Stat({ label, value, sub, accent = 'ink', style = {} }: StatProps) { + return ( +
+ {label} + {value} + {sub && {sub}} +
+ ); +} diff --git a/src/ui/components/comic/Tag.tsx b/src/ui/components/comic/Tag.tsx new file mode 100644 index 0000000..f351f57 --- /dev/null +++ b/src/ui/components/comic/Tag.tsx @@ -0,0 +1,12 @@ +import type { CSSProperties, ReactNode } from 'react'; + +export interface TagProps { + children: ReactNode; + color?: 'ink' | 'cyan' | 'magenta' | 'yellow' | 'green' | 'outline'; + style?: CSSProperties; +} + +/** Tag — small pill label */ +export default function Tag({ children, color = 'ink', style = {} }: TagProps) { + return {children}; +} diff --git a/src/ui/components/comic/index.ts b/src/ui/components/comic/index.ts new file mode 100644 index 0000000..8524de6 --- /dev/null +++ b/src/ui/components/comic/index.ts @@ -0,0 +1,14 @@ +export { default as Halftone } from './Halftone'; +export { default as Panel } from './Panel'; +export { default as Tag } from './Tag'; +export { default as Stamp } from './Stamp'; +export { default as Bubble } from './Bubble'; +export { default as Burst } from './Burst'; +export { default as Btn } from './Btn'; +export { default as Stat } from './Stat'; +export { default as ApMeter } from './ApMeter'; +export { default as PhaseTrack } from './PhaseTrack'; +export { default as DamageBadge } from './DamageBadge'; +export { default as RelBadge } from './RelBadge'; +export { default as HoldButton } from './HoldButton'; +export { default as Ribbon } from './Ribbon'; diff --git a/tests/ui/HoldButton.test.tsx b/tests/ui/HoldButton.test.tsx new file mode 100644 index 0000000..1899b4a --- /dev/null +++ b/tests/ui/HoldButton.test.tsx @@ -0,0 +1,26 @@ +import { describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, waitFor } from '@testing-library/react'; +import { HoldButton } from '../../src/ui/components/comic'; + +describe('', () => { + it('fires onComplete after the hold duration', async () => { + const onComplete = vi.fn(); + const { getByRole } = render( + SEAL, + ); + fireEvent.mouseDown(getByRole('button')); + await waitFor(() => expect(onComplete).toHaveBeenCalledTimes(1), { timeout: 2000 }); + }); + + it('does not fire when released early', async () => { + const onComplete = vi.fn(); + const { getByRole } = render( + SEAL, + ); + const btn = getByRole('button'); + fireEvent.mouseDown(btn); + fireEvent.mouseUp(btn); + await new Promise((r) => setTimeout(r, 300)); + expect(onComplete).not.toHaveBeenCalled(); + }); +}); From c3337c72e2b93716e38dfc767a4d41664a81a2eb Mon Sep 17 00:00:00 2001 From: gethi Date: Sun, 19 Jul 2026 14:31:06 +0100 Subject: [PATCH 15/43] feat(ui): re-theme Setup screen to comic-tabloid design Handoff layout: NUKE! masthead, rotated cast tiles with SVG portraits and PICKED stamps, gag difficulty labels, live opponent count, Begin the End button. Keeps multi-human roster, seed input, and START_GAME wiring; adopts handoff oldest-pick-drop at 4 opponents. Co-Authored-By: Claude Fable 5 --- src/ui/content/cast.ts | 9 + src/ui/screens/Setup.module.css | 225 ++++++------------------ src/ui/screens/Setup.tsx | 272 ++++++++++++++++++----------- tests/ui/Setup.multihuman.test.tsx | 24 ++- 4 files changed, 261 insertions(+), 269 deletions(-) create mode 100644 src/ui/content/cast.ts diff --git a/src/ui/content/cast.ts b/src/ui/content/cast.ts new file mode 100644 index 0000000..74cf002 --- /dev/null +++ b/src/ui/content/cast.ts @@ -0,0 +1,9 @@ +/** Per-leader satirical copy for the Setup cast tiles (from the design handoff). */ +export const CAST_COPY: Record = { + chump: { profile: 'Coward', mood: "Many people are saying we should hit somebody. We won't, but we should." }, + khameneverhere: { profile: 'Grudge', mood: 'Orders filed. Orders unsigned.' }, + starmless: { profile: 'Cautious', mood: 'So, let me be very clear…' }, + carnage: { profile: 'Rational', mood: 'Nostalgia is not a strategy.' }, + 'mileigh-hem': { profile: 'Glass cannon', mood: 'Banking. Cooking. ¡Viva la libertad, carajo!' }, + netanyahoo: { profile: 'Warmonger', mood: 'Drawing a fresh red line on the bomb diagram.' }, +}; diff --git a/src/ui/screens/Setup.module.css b/src/ui/screens/Setup.module.css index 14c3121..25f96fb 100644 --- a/src/ui/screens/Setup.module.css +++ b/src/ui/screens/Setup.module.css @@ -1,168 +1,57 @@ -.setup { - max-width: 480px; - margin: 0 auto; - padding: 24px 16px; - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; - background: #f4ede2; - color: #1a1a1a; - min-height: 100vh; - box-sizing: border-box; -} - -.title { - text-align: center; - font-size: 28px; - font-weight: 700; - color: #6b1d1d; - margin: 0 0 24px; -} - -.sectionTitle { - font-size: 11px; - text-transform: uppercase; - letter-spacing: 0.1em; - color: #8a7a6b; - margin: 16px 0 8px; - font-weight: 600; -} - -.playerPanel label, -.seed label { - display: block; - font-size: 13px; - color: #5a4a3a; - margin: 8px 0; -} - -.playerPanel input[type="text"], -.seed input[type="text"] { - display: block; - width: 100%; - margin-top: 4px; - padding: 8px 10px; - font-size: 14px; - border: 1px solid #d0c8b8; - border-radius: 6px; - background: #fff8eb; - box-sizing: border-box; -} - -.castPicker { - margin: 16px 0; -} - -.castCard { - display: grid; - grid-template-columns: auto 1fr auto; - align-items: center; - gap: 12px; - width: 100%; - padding: 10px 14px; - margin: 6px 0; - background: #fff8eb; - border: 1px solid #d0c8b8; - border-radius: 8px; - cursor: pointer; - font-size: 14px; - color: inherit; -} - -.castCard.selected { - background: #ffe9c8; - border-color: #6b1d1d; - box-shadow: 0 0 0 2px rgba(107, 29, 29, 0.15); -} - -.castFlag { font-size: 18px; } -.castName { text-align: left; font-weight: 600; } -.castPop { font-size: 12px; color: #5a4a3a; } - -.difficulty { - margin: 16px 0; -} - -.diffRadio { - display: inline-flex; - align-items: center; - margin-right: 16px; - font-size: 14px; - gap: 6px; - cursor: pointer; -} - -.newGameButton { - display: block; - width: 100%; - margin-top: 24px; - padding: 14px; - font-size: 16px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.1em; - background: #6b1d1d; - color: #fff8eb; - border: none; - border-radius: 8px; - cursor: pointer; -} - -.newGameButton:disabled { - background: #c9b89a; - cursor: not-allowed; -} - -.humanRoster { margin-bottom: 20px; } - -.humanCard { - position: relative; - background: #fff8e1; - border-left: 3px solid #f59f00; - padding: 8px 12px 8px 36px; - border-radius: 6px; - margin-bottom: 6px; -} - -.humanLabel { - position: absolute; - left: 10px; - top: 12px; - font-weight: 700; - color: #856404; - font-size: 10px; - letter-spacing: 0.05em; -} - -.humanCard input[type='text'] { - display: block; - width: 100%; - padding: 4px 6px; - margin-bottom: 4px; - border: 1px solid #d0c8b8; - border-radius: 4px; - font-size: 13px; -} - -.removeBtn { - position: absolute; - top: 4px; - right: 6px; - background: transparent; - border: none; - color: #b02a37; - font-size: 18px; - font-weight: 700; - cursor: pointer; -} - -.addBtn { - display: block; - width: 100%; - background: transparent; - color: #198754; - border: 1px dashed #198754; - padding: 8px; - font-size: 12px; - border-radius: 4px; - cursor: pointer; -} -.addBtn:disabled { color: #adb5bd; border-color: #dee2e6; cursor: not-allowed; } +.setup { padding: 32px 20px 96px; min-height: 100vh; } +.wrap { max-width: 1180px; margin: 0 auto; } + +.header { display: flex; justify-content: space-between; align-items: flex-end; flex-wrap: wrap; gap: 16px; margin-bottom: 24px; } +.kicker { font-size: 12px; letter-spacing: 0.3em; color: var(--magenta); text-transform: uppercase; } +.title { font-size: clamp(64px, 10vw, 140px); margin: 4px 0 0; letter-spacing: -0.02em; color: var(--ink); } +.bang { color: var(--magenta); } +.tagline { font-size: 18px; margin-top: 4px; } + +.grid { display: grid; grid-template-columns: 1fr; gap: 24px; } +@media (min-width: 920px) { .grid { grid-template-columns: minmax(0, 1.6fr) minmax(0, 1fr); } } + +.castGrid { display: grid; grid-template-columns: 1fr; gap: 14px; } +@media (min-width: 640px) { .castGrid { grid-template-columns: 1fr 1fr; } } + +.castTile { + position: relative; text-align: left; padding: 14px; + border: 3px solid rgba(20, 18, 20, 0.5); background: var(--paper-warm); + cursor: pointer; box-shadow: 4px 4px 0 var(--ink); + font-family: inherit; transition: transform 100ms ease; +} +.castTile:hover { transform: rotate(0deg) translateY(-2px) !important; } +.picked { background: var(--paper-bright); border-color: var(--ink); } +.tileRow { display: flex; gap: 10px; align-items: flex-start; } +.tileBody { flex: 1; min-width: 0; } +.tileName { font-size: 18px; line-height: 1; margin-top: 2px; } +.picked .tileName { padding-right: 70px; } +.tileMeta { font-size: 9px; opacity: 0.65; letter-spacing: 0.1em; margin-top: 2px; text-transform: uppercase; } +.tileMood { font-size: 13px; margin-top: 4px; line-height: 1.2; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; } +.tileTags { display: flex; gap: 3px; margin-top: 6px; flex-wrap: wrap; } +.castHint { margin-top: 14px; font-size: 12px; font-style: italic; color: var(--ink-soft); } + +.humanCard { display: flex; gap: 12px; align-items: center; padding: 8px 0; border-bottom: 2px dashed var(--paper-edge); position: relative; } +.humanFields { flex: 1; min-width: 0; } +.nameInput { width: 100%; border: 0; border-bottom: 3px solid var(--ink); background: transparent; font-size: 20px; padding: 2px 0; } +.countryInput { margin-top: 6px; border: 2px solid var(--ink); background: var(--paper); padding: 2px 8px; font-family: var(--font-display); font-size: 13px; letter-spacing: 0.1em; width: 160px; } +.removeBtn { background: var(--ink); color: var(--paper); border: 2px solid var(--ink); width: 24px; height: 24px; cursor: pointer; font-size: 14px; line-height: 1; } +.addBtn { margin-top: 10px; font-size: 12px; } +.humanHint { font-size: 12px; font-style: italic; margin-top: 12px; color: var(--ink-soft); } + +.diffList { display: grid; gap: 6px; } +.diffOption { display: flex; align-items: center; gap: 12px; padding: 10px 12px; cursor: pointer; text-align: left; background: transparent; color: var(--ink); border: 2.5px solid var(--ink); font-family: inherit; } +.diffSelected { background: var(--ink); color: var(--paper); } +.diffCheck { width: 14px; height: 14px; border: 2.5px solid currentColor; flex: 0 0 auto; } +.diffSelected .diffCheck { background: var(--magenta); } +.diffText { flex: 1; display: flex; flex-direction: column; } +.diffLabel { font-size: 16px; letter-spacing: 0.06em; } +.diffSub { font-size: 11px; opacity: 0.7; } +.seedBlock { display: block; margin-top: 12px; } +.seedLabel { font-size: 10px; letter-spacing: 0.18em; display: block; margin-bottom: 4px; text-transform: uppercase; } +.seedInput { width: 100%; padding: 6px 8px; border: 2px solid var(--ink); background: var(--paper); font-size: 13px; } + +.beginRow { display: flex; justify-content: space-between; align-items: center; gap: 16px; flex-wrap: wrap; } +.countLabel { font-size: 11px; letter-spacing: 0.18em; text-transform: uppercase; } +.countValue { font-size: 36px; color: var(--ink); } +.countLow { color: var(--magenta); } +.countMax { opacity: 0.4; font-size: 24px; } diff --git a/src/ui/screens/Setup.tsx b/src/ui/screens/Setup.tsx index e6801ce..b63a136 100644 --- a/src/ui/screens/Setup.tsx +++ b/src/ui/screens/Setup.tsx @@ -3,6 +3,10 @@ import type { ScreenProps } from '../App'; import type { Difficulty, LeaderId } from '../../engine/types'; import { LEADER_PROFILES } from '../../engine/balance'; import { isHuman } from '../../engine/state'; +import { Btn, Panel, Ribbon, Stamp, Tag } from '../components/comic'; +import Portrait from '../components/Portrait'; +import { extractFlag, stripFlag } from '../portraits'; +import { CAST_COPY } from '../content/cast'; import styles from './Setup.module.css'; const AI_IDS: LeaderId[] = (Object.keys(LEADER_PROFILES) as LeaderId[]).filter( @@ -10,6 +14,15 @@ const AI_IDS: LeaderId[] = (Object.keys(LEADER_PROFILES) as LeaderId[]).filter( ); const HUMAN_IDS: LeaderId[] = ['player1', 'player2', 'player3', 'player4', 'player5']; +/** Handoff per-tile stat-tag overrides (screens-1.jsx lines 70–72). */ +const TILE_TAG_STYLE = { fontSize: 9, padding: '2px 5px' } as const; + +const DIFFICULTIES: ReadonlyArray<{ id: Difficulty; label: string; sub: string }> = [ + { id: 'easy', label: 'Fine, Probably', sub: "30% random AI · what's the worst that could happen" }, + { id: 'normal', label: 'Not Great', sub: '10% random · things are getting spicy' }, + { id: 'hard', label: "We're Cooked", sub: '0% random + lookahead · kiss the kids' }, +]; + interface HumanRow { id: LeaderId; name: string; @@ -51,9 +64,11 @@ export default function Setup({ dispatch }: ScreenProps) { } function toggleAi(id: LeaderId) { - setSelectedAi((current) => - current.includes(id) ? current.filter((x) => x !== id) : [...current, id], - ); + setSelectedAi((current) => { + if (current.includes(id)) return current.filter((x) => x !== id); + if (current.length >= 4) return [...current.slice(1), id]; + return [...current, id]; + }); } function start() { @@ -74,107 +89,166 @@ export default function Setup({ dispatch }: ScreenProps) { } return ( -
-

New Game

- -
-

Humans (1–5)

- {humans.map((h, idx) => ( -
- P{idx + 1} - updateHuman(idx, 'name', e.target.value)} - placeholder="Name" - /> - updateHuman(idx, 'country', e.target.value)} - placeholder="🌐 Country" - /> - {idx > 0 && ( +
+
+
+
+
A Parody In Poor Taste
+

+ NUKE! +

+
+ "Everybody plays. Nobody wins." +
+
+ + Select Your Enemies + +
+ +
+ +
+ {AI_IDS.map((id, i) => { + const profile = LEADER_PROFILES[id]; + const copy = CAST_COPY[id]; + const picked = selectedAi.includes(id); + const tilts = [-1.4, 0.8, -0.5, 1.2, -0.9, 0.4]; + return ( + + ); + })} +
+
Pick 2–4 opponents. You're already at the table.
+
+ +
+ + {humans.map((h, idx) => ( +
+ +
+ updateHuman(idx, 'name', e.target.value)} + placeholder="Name" + /> + updateHuman(idx, 'country', e.target.value)} + placeholder="🌐 Country" + /> +
+ {idx > 0 && ( + + )} +
+ ))} - )} +
+ Default: Rufus T. Firefly of Freedonia (Duck Soup, 1933). Change if + you'd prefer to die under a different name. +
+
+ + +
+ {DIFFICULTIES.map((d) => ( + + ))} +
+ +
+ + +
+
+
Opponents Picked
+
+ {selectedAi.length} + /4 +
+
+ + Begin the End ↯ + +
+
- ))} - -
- -
-

AI cast (pick 2–4)

- {AI_IDS.map((id) => { - const profile = LEADER_PROFILES[id]; - const selected = selectedAi.includes(id); - return ( - - ); - })} -
- -
-

Difficulty

- {(['easy', 'normal', 'hard'] as const).map((d) => ( - - ))} -
- -
- -
- - +
+
); } diff --git a/tests/ui/Setup.multihuman.test.tsx b/tests/ui/Setup.multihuman.test.tsx index 5e4b33d..d7f3e2d 100644 --- a/tests/ui/Setup.multihuman.test.tsx +++ b/tests/ui/Setup.multihuman.test.tsx @@ -27,11 +27,31 @@ describe(' multi-human roster', () => { expect(screen.getAllByRole('textbox', { name: /name/i })).toHaveLength(1); }); - it('Start button label reflects roster size', () => { + it('Begin button gates on 2+ opponents and shows the live count', () => { const dispatch = vi.fn(); render(); + const begin = screen.getByRole('button', { name: /begin the end/i }); + expect(begin).toBeDisabled(); fireEvent.click(screen.getByRole('button', { name: /chump/i })); fireEvent.click(screen.getByRole('button', { name: /carnage/i })); - expect(screen.getByRole('button', { name: /start.*1 human.*2 ai/i })).toBeInTheDocument(); + expect(begin).toBeEnabled(); + expect(screen.getByTestId('opponent-count')).toHaveTextContent('2/4'); + fireEvent.click(begin); + expect(dispatch).toHaveBeenCalledWith( + expect.objectContaining({ type: 'START_GAME' }), + ); + }); + + it('picking a 5th opponent drops the oldest pick', () => { + render(); + for (const name of [/chump/i, /carnage/i, /starmless/i, /netanyahoo/i]) { + fireEvent.click(screen.getByRole('button', { name })); + } + fireEvent.click(screen.getByRole('button', { name: /khameneverhere/i })); + // chump (oldest) dropped; the four later picks remain + expect(screen.getByRole('button', { name: /chump/i })).toHaveAttribute('aria-pressed', 'false'); + for (const name of [/carnage/i, /starmless/i, /netanyahoo/i, /khameneverhere/i]) { + expect(screen.getByRole('button', { name })).toHaveAttribute('aria-pressed', 'true'); + } }); }); From 0829c831ce6708441c7a8893428158f6bc530957 Mon Sep 17 00:00:00 2001 From: gethi Date: Sun, 19 Jul 2026 14:42:13 +0100 Subject: [PATCH 16/43] ui: add RoundBadge comic primitive (slice 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port of handoff ui.jsx RoundBadge — ink plaque with label + round number, exported from the comic barrel. Co-Authored-By: Claude Fable 5 --- src/ui/components/comic/RoundBadge.tsx | 25 +++++++++++++++++++++++++ src/ui/components/comic/index.ts | 1 + tests/ui/RoundBadge.test.tsx | 17 +++++++++++++++++ 3 files changed, 43 insertions(+) create mode 100644 src/ui/components/comic/RoundBadge.tsx create mode 100644 tests/ui/RoundBadge.test.tsx diff --git a/src/ui/components/comic/RoundBadge.tsx b/src/ui/components/comic/RoundBadge.tsx new file mode 100644 index 0000000..93a4288 --- /dev/null +++ b/src/ui/components/comic/RoundBadge.tsx @@ -0,0 +1,25 @@ +export interface RoundBadgeProps { + round: number; + label?: string; +} + +/** Round number ink plaque (handoff ui.jsx lines 156–172). */ +export default function RoundBadge({ round, label = 'Round' }: RoundBadgeProps) { + return ( +
+ {label} + {round} +
+ ); +} diff --git a/src/ui/components/comic/index.ts b/src/ui/components/comic/index.ts index 8524de6..f2958ce 100644 --- a/src/ui/components/comic/index.ts +++ b/src/ui/components/comic/index.ts @@ -11,4 +11,5 @@ export { default as PhaseTrack } from './PhaseTrack'; export { default as DamageBadge } from './DamageBadge'; export { default as RelBadge } from './RelBadge'; export { default as HoldButton } from './HoldButton'; +export { default as RoundBadge } from './RoundBadge'; export { default as Ribbon } from './Ribbon'; diff --git a/tests/ui/RoundBadge.test.tsx b/tests/ui/RoundBadge.test.tsx new file mode 100644 index 0000000..112df6e --- /dev/null +++ b/tests/ui/RoundBadge.test.tsx @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { RoundBadge } from '../../src/ui/components/comic'; + +describe('', () => { + it('renders the default label and round number', () => { + render(); + expect(screen.getByText('Round')).toBeInTheDocument(); + expect(screen.getByText('7')).toBeInTheDocument(); + }); + + it('renders a custom label', () => { + render(); + expect(screen.getByText('Turn')).toBeInTheDocument(); + expect(screen.getByText('2')).toBeInTheDocument(); + }); +}); From cb2f2cbcbe967669ed450817f233a2eeb18e0b8f Mon Sep 17 00:00:00 2001 From: gethi Date: Sun, 19 Jul 2026 14:44:00 +0100 Subject: [PATCH 17/43] ui: re-theme HotseatHandoff to the handoff curtain Ink full-viewport curtain with dot-grid overlay, yellow HOTSEAT HANDOFF tag, giant country flag, PASS TO kicker, yellow display-font leader name, and a primary xl BEGIN MY TURN button. Wiring unchanged: reads state.activeHumanTurn and dispatches BEGIN_PLANNING. The separate country-name text line is dropped per the slice-2 spec (the flag carries it). Co-Authored-By: Claude Fable 5 --- src/ui/screens/HotseatHandoff.module.css | 71 +++++++++++++----------- src/ui/screens/HotseatHandoff.tsx | 26 +++++---- tests/ui/HotseatHandoff.test.tsx | 11 ++-- 3 files changed, 60 insertions(+), 48 deletions(-) diff --git a/src/ui/screens/HotseatHandoff.module.css b/src/ui/screens/HotseatHandoff.module.css index 8ebebec..c65fb67 100644 --- a/src/ui/screens/HotseatHandoff.module.css +++ b/src/ui/screens/HotseatHandoff.module.css @@ -1,46 +1,53 @@ .curtain { min-height: 100vh; - background: linear-gradient(180deg, #1a1a2e 0%, #16213e 100%); - color: #fff; + background: var(--ink); + color: var(--paper); display: flex; - flex-direction: column; align-items: center; justify-content: center; - padding: 32px; - text-align: center; - max-width: 480px; - margin: 0 auto; + padding: 32px 24px 96px; + position: relative; + overflow: hidden; +} + +.dotGrid { + position: absolute; + inset: 0; + background-image: radial-gradient(rgba(255, 255, 255, 0.08) 1px, transparent 1.4px); + background-size: 8px 8px; + opacity: 0.4; } -.passLabel { - font-size: 11px; - letter-spacing: 0.2em; - opacity: 0.5; - text-transform: uppercase; - margin-bottom: 32px; +.content { + position: relative; + text-align: center; + max-width: 640px; + width: 100%; } -.flag { font-size: 96px; line-height: 1; margin-bottom: 16px; } -.country { font-size: 28px; font-weight: 700; margin-bottom: 8px; } -.leaderName { font-size: 18px; opacity: 0.8; margin-bottom: 32px; } +.flag { + font-size: clamp(72px, 16vw, 180px); + line-height: 1; + margin-top: 12px; +} -.divider { - width: 50%; - height: 1px; - background: rgba(255,255,255,0.2); - margin: 0 auto 32px; +.kicker { + font-size: 18px; + letter-spacing: 0.3em; + margin-top: 18px; + opacity: 0.7; } -.sealed { font-size: 12px; opacity: 0.5; margin-bottom: 24px; } +.leaderName { + font-size: clamp(40px, 6.5vw, 72px); + line-height: 1.05; + margin: 6px 0 0; + color: var(--yellow); + text-wrap: balance; +} -.tapBtn { - background: transparent; - color: #fff; - border: 1px solid rgba(255,255,255,0.4); - border-radius: 32px; - padding: 14px 28px; - font-size: 14px; - cursor: pointer; - letter-spacing: 0.04em; +.sealed { + font-size: 18px; + opacity: 0.8; + margin-top: 22px; } -.tapBtn:hover { background: rgba(255,255,255,0.05); } diff --git a/src/ui/screens/HotseatHandoff.tsx b/src/ui/screens/HotseatHandoff.tsx index 80c5f08..4ec769b 100644 --- a/src/ui/screens/HotseatHandoff.tsx +++ b/src/ui/screens/HotseatHandoff.tsx @@ -1,4 +1,5 @@ import type { ScreenProps } from '../App'; +import { Btn, Tag } from '../components/comic'; import styles from './HotseatHandoff.module.css'; export default function HotseatHandoff({ state, dispatch }: ScreenProps) { @@ -6,23 +7,26 @@ export default function HotseatHandoff({ state, dispatch }: ScreenProps) { const id = state.activeHumanTurn!; const leader = game.leaders[id]; - // Country string is e.g. "🦆 Freedonia"; split into flag (first segment) and name (rest). - const [flag, ...rest] = leader.country.split(' '); - const countryName = rest.join(' '); + // Country string is e.g. "🦆 Freedonia"; the first token is the flag. + const flag = leader.country.split(' ')[0]; function begin() { dispatch({ type: 'BEGIN_PLANNING', leaderId: id }); } return ( -
-
Pass the device
-
{flag}
-
{countryName}
-
{leader.name}
-
-
Previous orders sealed.
- +
+
+
+ HOTSEAT HANDOFF +
{flag}
+
PASS TO
+

{leader.name}

+
Previous orders sealed. Don't peek.
+ + BEGIN MY TURN + +
); } diff --git a/tests/ui/HotseatHandoff.test.tsx b/tests/ui/HotseatHandoff.test.tsx index 3e44940..0c380c5 100644 --- a/tests/ui/HotseatHandoff.test.tsx +++ b/tests/ui/HotseatHandoff.test.tsx @@ -28,17 +28,18 @@ function makeState(): UiState { } describe('', () => { - it('renders the next leader\'s country and name', () => { + it('renders PASS TO, the next leader\'s name, and their country flag', () => { render(); - expect(screen.getByText(/Sylvania/i)).toBeInTheDocument(); - expect(screen.getByText(/Bob/i)).toBeInTheDocument(); + expect(screen.getByText('PASS TO')).toBeInTheDocument(); + expect(screen.getByText('Bob')).toBeInTheDocument(); + expect(screen.getByText('🐢')).toBeInTheDocument(); expect(screen.getByText(/previous orders sealed/i)).toBeInTheDocument(); }); - it('tap dispatches BEGIN_PLANNING with the active human', () => { + it('BEGIN MY TURN dispatches BEGIN_PLANNING with the active human', () => { const dispatch = vi.fn(); render(); - fireEvent.click(screen.getByRole('button', { name: /tap to begin/i })); + fireEvent.click(screen.getByRole('button', { name: /begin my turn/i })); expect(dispatch).toHaveBeenCalledWith({ type: 'BEGIN_PLANNING', leaderId: 'player2' }); }); }); From c6f1c90dfbf263ec3b83436fac6773253ee4a8f0 Mon Sep 17 00:00:00 2001 From: gethi Date: Sun, 19 Jul 2026 14:46:07 +0100 Subject: [PATCH 18/43] ui: re-theme AiConferring to the handoff intermission Per-AI-leader panels with portraits, live names, verbatim handoff flavour lines, pulsing dots that flip to a green FILED tag on a 2400ms stagger (row i files at (i+1)*2400/n), headline dots cycling every 320ms, and AI_RESOLVE dispatched once at 2900ms. All timers cleared on unmount. Roster comes from game.cast filtered to living AI leaders, same as Planning. Co-Authored-By: Claude Fable 5 --- src/ui/screens/AiConferring.module.css | 65 +++++++++++++---- src/ui/screens/AiConferring.tsx | 98 +++++++++++++++++++++++--- tests/ui/AiConferring.test.tsx | 81 +++++++++++++++++++++ 3 files changed, 220 insertions(+), 24 deletions(-) create mode 100644 tests/ui/AiConferring.test.tsx diff --git a/src/ui/screens/AiConferring.module.css b/src/ui/screens/AiConferring.module.css index adc30d3..65516c5 100644 --- a/src/ui/screens/AiConferring.module.css +++ b/src/ui/screens/AiConferring.module.css @@ -1,28 +1,63 @@ .beat { + min-height: 100vh; + padding: 32px; display: flex; align-items: center; justify-content: center; - min-height: 100vh; - background: #f4ede2; - color: #5a4a3a; - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } -.text { +.wrap { + width: 100%; + max-width: 520px; +} + +.head { + text-align: center; + margin-bottom: 24px; +} + +.headline { + font-size: 52px; + margin-top: 8px; + letter-spacing: 0.01em; +} + +.subline { font-size: 18px; - font-weight: 500; - font-style: italic; + margin-top: 4px; + color: var(--ink-soft); } -.dots span { - display: inline-block; - animation: blink 1s infinite; +.rowLeft { + display: flex; + gap: 12px; + align-items: center; } -.dots span:nth-child(2) { animation-delay: 0.2s; } -.dots span:nth-child(3) { animation-delay: 0.4s; } +.rowName { font-size: 16px; } + +.rowLine { + font-size: 14px; + opacity: 0.8; +} + +.rowStatus { + display: flex; + gap: 6px; + align-items: center; + min-width: 76px; + justify-content: flex-end; +} + +.dotRow { + display: flex; + gap: 3px; +} -@keyframes blink { - 0%, 60%, 100% { opacity: 0.2; } - 30% { opacity: 1; } +.pulseDot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--ink); + animation: pulsedot 900ms ease-in-out infinite; } diff --git a/src/ui/screens/AiConferring.tsx b/src/ui/screens/AiConferring.tsx index 1ea5482..c70d14e 100644 --- a/src/ui/screens/AiConferring.tsx +++ b/src/ui/screens/AiConferring.tsx @@ -1,18 +1,98 @@ -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import type { ScreenProps } from '../App'; +import type { LeaderId } from '../../engine/types'; +import { isHuman } from '../../engine/state'; +import { Panel, Tag } from '../components/comic'; +import Portrait from '../components/Portrait'; import styles from './AiConferring.module.css'; -export default function AiConferring({ dispatch }: ScreenProps) { +/** Flavour lines lifted verbatim from the handoff (screens-1.jsx ConferringScreen). */ +const LINES: Partial> = { + chump: 'is on the phone yelling at his generals.', + khameneverhere: 'has filed orders. The orders are unsigned.', + starmless: 'is consulting his shadow cabinet, slowly.', + carnage: 'polishes his glasses. Files orders calmly.', + 'mileigh-hem': 'is revving something offscreen.', + netanyahoo: 'is adding a fresh red line to the bomb diagram.', +}; + +/** Total stagger window: AI row i (0-based) flips to "filed" at (i+1) * TOTAL / n. */ +const TOTAL_MS = 2400; +/** AI_RESOLVE fires at TOTAL_MS + RESOLVE_DELAY_MS. */ +const RESOLVE_DELAY_MS = 500; +const DOT_INTERVAL_MS = 320; + +export default function AiConferring({ state, dispatch }: ScreenProps) { + const game = state.game!; + const aiIds = game.cast.filter((id) => !isHuman(id) && game.leaders[id].alive); + + const [dots, setDots] = useState(1); + const [filedCount, setFiledCount] = useState(0); + useEffect(() => { - const timer = setTimeout(() => dispatch({ type: 'AI_RESOLVE' }), 1500); - return () => clearTimeout(timer); - }, [dispatch]); + const dotsInterval = setInterval(() => setDots((d) => (d % 3) + 1), DOT_INTERVAL_MS); + const step = TOTAL_MS / Math.max(1, aiIds.length); + const rowTimers = aiIds.map((_, i) => + setTimeout(() => setFiledCount((c) => Math.max(c, i + 1)), (i + 1) * step), + ); + const finish = setTimeout(() => dispatch({ type: 'AI_RESOLVE' }), TOTAL_MS + RESOLVE_DELAY_MS); + return () => { + clearInterval(dotsInterval); + for (const t of rowTimers) clearTimeout(t); + clearTimeout(finish); + }; + // The beat's timeline is fixed at mount; run once. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); return ( -
-

- AI players are filing orders... -

+
+
+
+ INTERMISSION +
+ AI IS FILING ORDERS{'.'.repeat(dots)} +
+
Don't look. They can sense it.
+
+ +
+ {aiIds.map((id, i) => { + const leader = game.leaders[id]; + const filed = i < filedCount; + return ( + +
+
+ +
+
{leader.name}
+
+ {leader.name} {LINES[id] ?? 'is conferring.'} +
+
+
+
+ {filed ? ( + FILED ✓ + ) : ( +
+ {[0, 1, 2].map((d) => ( + + ))} +
+ )} +
+
+
+ ); + })} +
+
); } diff --git a/tests/ui/AiConferring.test.tsx b/tests/ui/AiConferring.test.tsx new file mode 100644 index 0000000..6360457 --- /dev/null +++ b/tests/ui/AiConferring.test.tsx @@ -0,0 +1,81 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, render, screen } from '@testing-library/react'; +import AiConferring from '../../src/ui/screens/AiConferring'; +import { initialState } from '../../src/engine/state'; +import type { UiState } from '../../src/ui/store'; + +function makeState(): UiState { + const game = initialState({ + cast: ['player1', 'chump', 'carnage'], + difficulty: 'normal', + seed: 'conferring-test', + }); + return { + screen: 'aiConferring', + game, + events: [], + prevPopulations: {}, + initialPopulations: {}, + lastNewGameOpts: null, + pendingHumanOrders: {}, + }; +} + +describe('', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('renders one row per living AI leader with the live name and flavour line', () => { + render(); + expect(screen.getByText(/is on the phone yelling at his generals/)).toBeInTheDocument(); + expect(screen.getByText(/polishes his glasses/)).toBeInTheDocument(); + // Humans get no row (player1's flavour text never appears). + expect(screen.queryByText(/Rufus/)).not.toBeInTheDocument(); + }); + + it('flips rows to FILED in cast order at staggered times', () => { + render(); + // Two AI leaders → flips at 1200ms and 2400ms. + expect(screen.queryAllByText('FILED ✓')).toHaveLength(0); + act(() => { + vi.advanceTimersByTime(1200); + }); + expect(screen.getAllByText('FILED ✓')).toHaveLength(1); + act(() => { + vi.advanceTimersByTime(1200); + }); + expect(screen.getAllByText('FILED ✓')).toHaveLength(2); + }); + + it('dispatches AI_RESOLVE exactly once at 2900ms', () => { + const dispatch = vi.fn(); + render(); + act(() => { + vi.advanceTimersByTime(2899); + }); + expect(dispatch).not.toHaveBeenCalled(); + act(() => { + vi.advanceTimersByTime(1); + }); + expect(dispatch).toHaveBeenCalledTimes(1); + expect(dispatch).toHaveBeenCalledWith({ type: 'AI_RESOLVE' }); + act(() => { + vi.advanceTimersByTime(5000); + }); + expect(dispatch).toHaveBeenCalledTimes(1); + }); + + it('unmount clears timers — no dispatch after unmount', () => { + const dispatch = vi.fn(); + const { unmount } = render(); + unmount(); + act(() => { + vi.advanceTimersByTime(10000); + }); + expect(dispatch).not.toHaveBeenCalled(); + }); +}); From ec92dc219ea60fd7bc7fff225cb4aa9d5c91c401 Mon Sep 17 00:00:00 2001 From: gethi Date: Sun, 19 Jul 2026 14:47:47 +0100 Subject: [PATCH 19/43] ui: repurpose ApBudget as the Your Country AP block Previously orphaned; now wraps the slice-1 ApMeter and renders the banking note (Banked AP carries over, cap 4, plus the banked amount when > 0). Props change from {ap, apBanked} to {used, max, banked}. Mounted in Planning in the slice-2 rewrite. Co-Authored-By: Claude Fable 5 --- src/ui/components/ApBudget.module.css | 25 +++++------------------- src/ui/components/ApBudget.tsx | 26 +++++++++++++------------ tests/ui/ApBudget.test.tsx | 28 ++++++++++++++++----------- 3 files changed, 36 insertions(+), 43 deletions(-) diff --git a/src/ui/components/ApBudget.module.css b/src/ui/components/ApBudget.module.css index 2cb5c55..7668405 100644 --- a/src/ui/components/ApBudget.module.css +++ b/src/ui/components/ApBudget.module.css @@ -1,24 +1,9 @@ .apBudget { - background: #fff8eb; - border: 1px solid #d0c8b8; - border-radius: 8px; - padding: 8px 12px; - margin: 8px 0; + margin-top: 14px; } -.row { - display: flex; - justify-content: space-between; - font-size: 12px; - margin: 2px 0; -} - -.label { color: #5a4a3a; } -.value { font-weight: 600; color: #6b1d1d; } - -.row.total { - border-top: 1px dashed #d0c8b8; - padding-top: 4px; - margin-top: 4px; - font-size: 13px; +.note { + margin-top: 6px; + font-size: 11px; + color: var(--ink-soft); } diff --git a/src/ui/components/ApBudget.tsx b/src/ui/components/ApBudget.tsx index a548b68..a909d3c 100644 --- a/src/ui/components/ApBudget.tsx +++ b/src/ui/components/ApBudget.tsx @@ -1,23 +1,25 @@ +import { AP_BANK_CAP } from '../../engine/balance'; +import { ApMeter } from './comic'; import styles from './ApBudget.module.css'; export interface ApBudgetProps { - ap: number; // total AP available this round (engine bakes factoryAp + banked + bonus into this) - apBanked: number; // informational: how much of `ap` carried over from last round + /** AP queued so far this round. */ + used: number; + /** Total AP available this round (engine bakes factoryAp + banked + bonus into this). */ + max: number; + /** Informational: how much of `max` carried over from last round. */ + banked: number; } -export default function ApBudget({ ap, apBanked }: ApBudgetProps) { +/** AP block inside "Your Country" — ApMeter plus the banking note. */ +export default function ApBudget({ used, max, banked }: ApBudgetProps) { return (
-
- AP available - {ap} + +
+ Banked AP carries over (cap {AP_BANK_CAP}). + {banked > 0 ? ` Of which banked: ${banked}.` : ''}
- {apBanked > 0 && ( -
- Of which banked - {apBanked} -
- )}
); } diff --git a/tests/ui/ApBudget.test.tsx b/tests/ui/ApBudget.test.tsx index 4b810b3..4928eda 100644 --- a/tests/ui/ApBudget.test.tsx +++ b/tests/ui/ApBudget.test.tsx @@ -1,21 +1,27 @@ +import { describe, expect, it } from 'vitest'; import { render, screen } from '@testing-library/react'; import ApBudget from '../../src/ui/components/ApBudget'; describe('', () => { - it('renders AP available', () => { - render(); - expect(screen.getByText(/AP available/i)).toBeInTheDocument(); - expect(screen.getByText('5')).toBeInTheDocument(); + it('renders the ApMeter readout and bank label', () => { + render(); + expect(screen.getByText(/\/ 6 AP/)).toBeInTheDocument(); + expect(screen.getByText('4 will bank')).toBeInTheDocument(); }); - it('shows banked row when apBanked > 0', () => { - render(); - expect(screen.getByText(/banked/i)).toBeInTheDocument(); - expect(screen.getByText('2')).toBeInTheDocument(); + it('shows OVER BUDGET when used exceeds max', () => { + render(); + expect(screen.getByText('OVER BUDGET')).toBeInTheDocument(); }); - it('omits banked row when apBanked is 0', () => { - render(); - expect(screen.queryByText(/banked/i)).not.toBeInTheDocument(); + it('renders the banking note with the cap', () => { + render(); + expect(screen.getByText(/Banked AP carries over \(cap 4\)\./)).toBeInTheDocument(); + expect(screen.queryByText(/Of which banked/)).not.toBeInTheDocument(); + }); + + it('appends the banked amount when banked > 0', () => { + render(); + expect(screen.getByText(/Of which banked: 2\./)).toBeInTheDocument(); }); }); From 77d986de06686de2cb85642d34d5516af3564173 Mon Sep 17 00:00:00 2001 From: gethi Date: Sun, 19 Jul 2026 14:50:14 +0100 Subject: [PATCH 20/43] ui: comic tiles for Build/Defence/LaunchCell; always-on SoftWarnPanel SoftWarnPanel becomes the handoff SOFT WARNINGS panel: always rendered, hand-font magenta warnings with leading warning sign, and the Nothing obviously stupid. Yet. empty state (adopted behavior). describe() messages unchanged. BuildGrid/DefenceGrid cells become Build Yard tiles (2.5px ink border, paper-bright, hard ink shadow, magenta hover offset, emoji + display label + mono magenta AP cost) with the existing +/- steppers kept; stepper buttons gain add/remove aria-labels. LaunchCell becomes a mini comic tile (yellow-soft when queued, 40% opacity when no warheads). All order/AP gating logic untouched. Co-Authored-By: Claude Fable 5 --- src/ui/components/BuildGrid.module.css | 75 ++++++++++++++++------ src/ui/components/BuildGrid.tsx | 8 ++- src/ui/components/DefenceGrid.module.css | 75 ++++++++++++++++------ src/ui/components/DefenceGrid.tsx | 8 ++- src/ui/components/LaunchCell.module.css | 46 +++++++++---- src/ui/components/LaunchCell.tsx | 5 +- src/ui/components/SoftWarnPanel.module.css | 34 +++++----- src/ui/components/SoftWarnPanel.tsx | 23 ++++--- tests/ui/Planning.softwarn.test.tsx | 17 ++--- 9 files changed, 195 insertions(+), 96 deletions(-) diff --git a/src/ui/components/BuildGrid.module.css b/src/ui/components/BuildGrid.module.css index f6b6c83..0e2384d 100644 --- a/src/ui/components/BuildGrid.module.css +++ b/src/ui/components/BuildGrid.module.css @@ -1,25 +1,62 @@ -.grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 4px; } +.grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); + gap: 8px; +} -.cell { - background: white; - border: 1px solid #d0c8b8; - border-radius: 4px; - padding: 6px; - display: flex; - flex-direction: column; - align-items: center; - min-height: 56px; +.tile { + background: var(--paper-bright); + border: 2.5px solid var(--ink); + padding: 10px 8px; + text-align: center; + box-shadow: 2px 2px 0 var(--ink); + transition: all 100ms ease; +} +.tile:hover { + transform: translate(-1px, -1px); + box-shadow: 3px 3px 0 var(--magenta); } -.cell.on { background: #d4edda; border-color: #198754; } +.tile.on { background: var(--yellow-soft); } -.emoji { font-size: 18px; line-height: 1; } -.label { font-size: 9px; margin: 2px 0; text-align: center; } -.cost { font-size: 8px; color: #6c757d; } +.emoji { font-size: 26px; line-height: 1; } -.stepper { display: flex; align-items: center; gap: 6px; margin-top: 2px; } +.label { + font-size: 11px; + margin-top: 6px; + letter-spacing: 0.08em; +} + +.cost { + font-size: 11px; + color: var(--magenta); + font-weight: 700; + margin-top: 2px; +} + +.stepper { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + margin-top: 6px; +} .stepper button { - background: #1a1a1a; color: white; border: 0; border-radius: 50%; - width: 16px; height: 16px; line-height: 14px; font-size: 11px; padding: 0; cursor: pointer; + width: 24px; + height: 24px; + border: 2px solid var(--ink); + background: var(--paper); + color: var(--ink); + font-family: var(--font-mono); + font-size: 14px; + line-height: 1; + padding: 0; + cursor: pointer; +} +.stepper button:disabled { opacity: 0.35; cursor: not-allowed; } + +.num { + font-family: var(--font-mono); + font-weight: 700; + min-width: 16px; + text-align: center; } -.stepper button:disabled { background: #adb5bd; cursor: not-allowed; } -.num { font-weight: 700; font-size: 12px; min-width: 16px; text-align: center; } diff --git a/src/ui/components/BuildGrid.tsx b/src/ui/components/BuildGrid.tsx index a76a1a3..1e0ee5a 100644 --- a/src/ui/components/BuildGrid.tsx +++ b/src/ui/components/BuildGrid.tsx @@ -34,13 +34,14 @@ export default function BuildGrid({ orders, setOrders, apRemaining }: Props) { const count = orders.filter(cell.matches).length; const canAdd = apRemaining >= cell.cost; return ( -
0 ? styles.on : ''}`}> +
0 ? styles.on : ''}`}>
{cell.emoji}
-
{cell.label}
-
{cell.cost} AP
+
{cell.label}
+
{cell.cost} AP
diff --git a/src/ui/components/DefenceGrid.module.css b/src/ui/components/DefenceGrid.module.css index f6b6c83..0e2384d 100644 --- a/src/ui/components/DefenceGrid.module.css +++ b/src/ui/components/DefenceGrid.module.css @@ -1,25 +1,62 @@ -.grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 4px; } +.grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); + gap: 8px; +} -.cell { - background: white; - border: 1px solid #d0c8b8; - border-radius: 4px; - padding: 6px; - display: flex; - flex-direction: column; - align-items: center; - min-height: 56px; +.tile { + background: var(--paper-bright); + border: 2.5px solid var(--ink); + padding: 10px 8px; + text-align: center; + box-shadow: 2px 2px 0 var(--ink); + transition: all 100ms ease; +} +.tile:hover { + transform: translate(-1px, -1px); + box-shadow: 3px 3px 0 var(--magenta); } -.cell.on { background: #d4edda; border-color: #198754; } +.tile.on { background: var(--yellow-soft); } -.emoji { font-size: 18px; line-height: 1; } -.label { font-size: 9px; margin: 2px 0; text-align: center; } -.cost { font-size: 8px; color: #6c757d; } +.emoji { font-size: 26px; line-height: 1; } -.stepper { display: flex; align-items: center; gap: 6px; margin-top: 2px; } +.label { + font-size: 11px; + margin-top: 6px; + letter-spacing: 0.08em; +} + +.cost { + font-size: 11px; + color: var(--magenta); + font-weight: 700; + margin-top: 2px; +} + +.stepper { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + margin-top: 6px; +} .stepper button { - background: #1a1a1a; color: white; border: 0; border-radius: 50%; - width: 16px; height: 16px; line-height: 14px; font-size: 11px; padding: 0; cursor: pointer; + width: 24px; + height: 24px; + border: 2px solid var(--ink); + background: var(--paper); + color: var(--ink); + font-family: var(--font-mono); + font-size: 14px; + line-height: 1; + padding: 0; + cursor: pointer; +} +.stepper button:disabled { opacity: 0.35; cursor: not-allowed; } + +.num { + font-family: var(--font-mono); + font-weight: 700; + min-width: 16px; + text-align: center; } -.stepper button:disabled { background: #adb5bd; cursor: not-allowed; } -.num { font-weight: 700; font-size: 12px; min-width: 16px; text-align: center; } diff --git a/src/ui/components/DefenceGrid.tsx b/src/ui/components/DefenceGrid.tsx index 4e22292..3fc0d41 100644 --- a/src/ui/components/DefenceGrid.tsx +++ b/src/ui/components/DefenceGrid.tsx @@ -60,16 +60,17 @@ export default function DefenceGrid({ const count = orders.filter(cell.matches).length; const canAdd = cell.canAddMore && apRemaining >= cell.cost; return ( -
0 ? styles.on : ''}`}> +
0 ? styles.on : ''}`}>
{cell.emoji}
-
{cell.label}
-
+
{cell.label}
+
{cell.cost} AP {cell.ownedHint !== undefined ? ` · ${cell.ownedHint} owned` : ''}
diff --git a/src/ui/components/LaunchCell.module.css b/src/ui/components/LaunchCell.module.css index 0d63b46..00baf4b 100644 --- a/src/ui/components/LaunchCell.module.css +++ b/src/ui/components/LaunchCell.module.css @@ -1,7 +1,6 @@ .cell { - background: white; - border: 1px solid #d0c8b8; - border-radius: 4px; + background: var(--paper-bright); + border: 2px solid var(--ink); padding: 6px 4px; text-align: center; display: flex; @@ -9,18 +8,39 @@ align-items: center; gap: 2px; } -.cell.on { background: #f8d7da; border-color: #b02a37; } -.cell.off { background: #f8f9fa; color: #adb5bd; opacity: 0.55; } +.cell.on { background: var(--yellow-soft); } +.cell.off { opacity: 0.4; } .emoji { font-size: 20px; line-height: 1; } -.label { font-size: 10px; font-weight: 600; } -.inv { font-size: 9px; color: #6c757d; font-style: italic; margin-top: -1px; } -.cell.on .inv { color: #b02a37; font-weight: 600; font-style: normal; } -.stepper { display: flex; align-items: center; gap: 4px; margin-top: 2px; } +.label { font-size: 11px; letter-spacing: 0.08em; } + +.inv { font-size: 10px; opacity: 0.7; } +.cell.on .inv { opacity: 1; font-weight: 700; } + +.stepper { + display: flex; + align-items: center; + gap: 4px; + margin-top: 2px; +} .stepper button { - background: #1a1a1a; color: white; border: 0; border-radius: 50%; - width: 16px; height: 16px; line-height: 14px; font-size: 11px; padding: 0; cursor: pointer; + width: 20px; + height: 20px; + border: 2px solid var(--ink); + background: var(--paper); + color: var(--ink); + font-family: var(--font-mono); + font-size: 12px; + line-height: 1; + padding: 0; + cursor: pointer; +} +.stepper button:disabled { opacity: 0.35; cursor: not-allowed; } + +.num { + font-family: var(--font-mono); + font-weight: 700; + min-width: 14px; + text-align: center; } -.stepper button:disabled { background: #adb5bd; cursor: not-allowed; } -.num { font-weight: 700; font-size: 13px; min-width: 14px; text-align: center; } diff --git a/src/ui/components/LaunchCell.tsx b/src/ui/components/LaunchCell.tsx index cf95691..b9fc825 100644 --- a/src/ui/components/LaunchCell.tsx +++ b/src/ui/components/LaunchCell.tsx @@ -9,13 +9,14 @@ interface Props { onDec: () => void; } +/** Mini comic tile for one yield of one delivery type. */ export default function LaunchCell({ sizeLabel, warheadsLeft, count, canAdd, onInc, onDec }: Props) { const stateClass = count > 0 ? styles.on : warheadsLeft === 0 && count === 0 ? styles.off : ''; return (
💥
-
{sizeLabel}
-
{warheadsLeft} left
+
{sizeLabel}
+
{warheadsLeft} left
{count} diff --git a/src/ui/components/SoftWarnPanel.module.css b/src/ui/components/SoftWarnPanel.module.css index d34bf37..a59515b 100644 --- a/src/ui/components/SoftWarnPanel.module.css +++ b/src/ui/components/SoftWarnPanel.module.css @@ -1,24 +1,24 @@ -.panel { - background: #fff8e1; - border-left: 3px solid #f59f00; - padding: 10px 12px; - border-radius: 0 4px 4px 0; - margin: 8px 0; -} - .title { - font-size: 10px; - letter-spacing: 0.1em; - text-transform: uppercase; - color: #856404; - font-weight: 600; + font-size: 11px; + letter-spacing: 0.14em; margin-bottom: 6px; } +.empty { + font-size: 14px; + color: var(--ink-soft); +} + .list { + list-style: none; margin: 0; - padding-left: 18px; - font-size: 12px; - color: #5a4400; + padding: 0; + display: flex; + flex-direction: column; + gap: 4px; +} + +.warning { + font-size: 14px; + color: var(--magenta-deep); } -.list li { margin-bottom: 2px; } diff --git a/src/ui/components/SoftWarnPanel.tsx b/src/ui/components/SoftWarnPanel.tsx index 7c1d005..7ae38e7 100644 --- a/src/ui/components/SoftWarnPanel.tsx +++ b/src/ui/components/SoftWarnPanel.tsx @@ -1,4 +1,5 @@ import type { GameState, SoftWarning } from '../../engine/types'; +import { Panel } from './comic'; import styles from './SoftWarnPanel.module.css'; interface Props { @@ -6,17 +7,21 @@ interface Props { game: GameState; } +/** Always-visible warnings panel (handoff screens-2.jsx warnings-panel). */ export default function SoftWarnPanel({ warnings, game }: Props) { - if (warnings.length === 0) return null; return ( -
-
⚠ Suggestions
-
    - {warnings.map((w, i) => ( -
  • {describe(w, game)}
  • - ))} -
-
+ +
SOFT WARNINGS
+ {warnings.length === 0 ? ( +
Nothing obviously stupid. Yet.
+ ) : ( +
    + {warnings.map((w, i) => ( +
  • ⚠ {describe(w, game)}
  • + ))} +
+ )} +
); } diff --git a/tests/ui/Planning.softwarn.test.tsx b/tests/ui/Planning.softwarn.test.tsx index d4ddde7..1c7c9e0 100644 --- a/tests/ui/Planning.softwarn.test.tsx +++ b/tests/ui/Planning.softwarn.test.tsx @@ -23,23 +23,18 @@ function makeState(): UiState { } describe(' soft-warn panel', () => { - it('hides the panel when no warnings', () => { + it('always renders the panel; empty state shows the fallback copy', () => { render(); - expect(screen.queryByText(/suggestions/i)).not.toBeInTheDocument(); + expect(screen.getByText('SOFT WARNINGS')).toBeInTheDocument(); + expect(screen.getByText(/nothing obviously stupid\. yet\./i)).toBeInTheDocument(); }); - it('shows the panel after adding a warhead with no delivery owned', () => { + it('shows the warning after adding a warhead with no delivery owned', () => { render(); - // Click the + button on the Sm Warhead cell in BuildGrid. // player1 starts with 0 missiles + 0 bombers, so queuing a warhead // triggers the warhead-no-delivery soft warning. - const smWarheadLabel = screen.getByText(/Sm Warhead/i); - const cell = smWarheadLabel.closest('[class*="cell"]') as HTMLElement; - const buttons = cell.querySelectorAll('button'); - // stepper layout: [−] count [+]; + is the last button - const plusBtn = buttons[buttons.length - 1] as HTMLButtonElement; - fireEvent.click(plusBtn); - expect(screen.getByText(/suggestions/i)).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'add Sm Warhead' })); expect(screen.getByText(/no delivery/i)).toBeInTheDocument(); + expect(screen.queryByText(/nothing obviously stupid/i)).not.toBeInTheDocument(); }); }); From 92067ef924b16ef8bd70b5cc42f20dfb556d9a6f Mon Sep 17 00:00:00 2001 From: gethi Date: Sun, 19 Jul 2026 14:53:51 +0100 Subject: [PATCH 21/43] ui: re-theme Planning to the handoff three-column layout Header: RoundBadge + full player name / country / flag over a 4px ink rule. Left column: Your Country panel (portrait with chosen- country flag badge, 2x2 live Stat grid, ApBudget wrapping ApMeter) plus the always-on SoftWarnPanel. Centre: The Table opponent cards (TargetRow as alternating-tilt Panels with portraits, mono stat line, segmented people/infra toggle, WOO/PROP mini-buttons, launch tile grids), Build Yard and Civil Defence panels. Right: Your Orders queue (adopted handoff behavior) as a pure view over the orders array with per-row x removal, dashed subtotal with over-budget line, HoldButton seal (SEAL ORDERS, hold 0.6s, disabled while over budget - existing validation preserved), and the ink masthead teaser via pickMasthead. All order/AP/validation behavior, projections, retarget-on-toggle and store dispatches unchanged; every displayed number comes from the engine. Tests: queries moved to role/label/text; new orders-queue and seal-hold suites. Co-Authored-By: Claude Fable 5 --- src/ui/components/TargetRow.module.css | 130 ++++++++++--- src/ui/components/TargetRow.tsx | 144 +++++++------- src/ui/screens/Planning.module.css | 205 ++++++++++++++++++-- src/ui/screens/Planning.tsx | 251 +++++++++++++++++++------ tests/ui/Planning.actionGrid.test.tsx | 11 +- tests/ui/Planning.ordersQueue.test.tsx | 65 +++++++ tests/ui/Planning.seal.test.tsx | 74 ++++++++ tests/ui/Planning.targetRow.test.tsx | 18 +- 8 files changed, 728 insertions(+), 170 deletions(-) create mode 100644 tests/ui/Planning.ordersQueue.test.tsx create mode 100644 tests/ui/Planning.seal.test.tsx diff --git a/src/ui/components/TargetRow.module.css b/src/ui/components/TargetRow.module.css index 6af769f..5291ec1 100644 --- a/src/ui/components/TargetRow.module.css +++ b/src/ui/components/TargetRow.module.css @@ -1,36 +1,112 @@ -.row { - background: white; - border: 1px solid #d0c8b8; - border-radius: 6px; - padding: 8px 10px; - margin-bottom: 6px; +.top { + display: flex; + gap: 10px; } -.head { display: flex; align-items: center; gap: 6px; font-size: 12px; margin-bottom: 6px; } -.flag { font-size: 18px; } -.name { flex: 1; font-weight: 700; } -.tt { font-size: 10px; background: #f0f0f0; padding: 2px; border-radius: 12px; display: flex; gap: 0; } -.ttSeg { padding: 2px 8px; cursor: pointer; border-radius: 10px; font-weight: 500; } -.ttOn { background: #b02a37; color: white; } +.body { + flex: 1; + min-width: 0; +} + +.headRow { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 8px; +} -.mood { font-size: 11px; font-style: italic; color: #5a4a3a; margin-bottom: 6px; } +.name { + font-size: 17px; + line-height: 1; +} + +.country { + font-size: 10px; + opacity: 0.6; + text-align: right; + white-space: nowrap; +} + +.ttRow { + display: inline-flex; + border: 2px solid var(--ink); + margin-top: 6px; +} + +.ttSeg { + font-size: 10px; + padding: 3px 10px; + background: var(--paper); + color: var(--ink); + border: 0; + cursor: pointer; +} +.ttSeg + .ttSeg { border-left: 2px solid var(--ink); } +.ttOn { + background: var(--ink); + color: var(--paper); +} -.diplo { display: grid; grid-template-columns: 1fr 1fr; gap: 4px; margin-bottom: 6px; } -.diploBtn { - background: white; border: 1px solid #d0c8b8; border-radius: 4px; - padding: 6px 8px; font-size: 11px; cursor: pointer; - display: flex; align-items: center; gap: 6px; justify-content: center; +.stats { + font-size: 10px; + opacity: 0.7; + margin-top: 6px; +} + +.mood { + font-size: 13px; + margin-top: 6px; + line-height: 1.2; + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} + +.diplo { + display: flex; + gap: 3px; + flex-wrap: wrap; + margin-top: 8px; +} + +.miniBtn { + font-family: var(--font-display); + font-size: 10px; + letter-spacing: 0.1em; + padding: 5px 8px; + background: var(--paper); + color: var(--ink); + border: 2px solid var(--ink); + cursor: pointer; + transition: all 80ms ease; +} +.miniBtn:hover { background: var(--ink); color: var(--paper); } +.miniOn { + background: var(--ink); + color: var(--paper); } -.diploOn { background: #d4edda; border-color: #198754; } -.cost { font-size: 9px; color: #6c757d; } -.diploOn .cost { color: #155724; } .rowLabel { - font-size: 9px; letter-spacing: 0.1em; text-transform: uppercase; - color: #6c757d; margin: 4px 0 2px; - display: flex; align-items: baseline; gap: 6px; + font-family: var(--font-display); + font-size: 10px; + letter-spacing: 0.1em; + text-transform: uppercase; + margin: 10px 0 4px; + display: flex; + align-items: baseline; + gap: 6px; } -.inv { letter-spacing: 0; text-transform: none; font-weight: 600; color: #495057; } -.invEmpty { color: #adb5bd; font-style: italic; font-weight: 400; } -.lcGrid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 4px; } +.inv { + letter-spacing: 0; + text-transform: none; + font-size: 10px; + opacity: 0.7; +} + +.lcGrid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 4px; +} diff --git a/src/ui/components/TargetRow.tsx b/src/ui/components/TargetRow.tsx index b6f95fa..a60c4ec 100644 --- a/src/ui/components/TargetRow.tsx +++ b/src/ui/components/TargetRow.tsx @@ -2,6 +2,8 @@ import type { Dispatch, SetStateAction } from 'react'; import type { Leader, Order, TargetType, Yield } from '../../engine/types'; import type { ProjectedInventory } from '../util/projection'; import { ACTION_COSTS } from '../../engine/balance'; +import { Panel } from './comic'; +import Portrait from './Portrait'; import LaunchCell from './LaunchCell'; import styles from './TargetRow.module.css'; import { findLastIndexMatching } from '../util/arrays'; @@ -15,6 +17,8 @@ interface Props { setOrders: Dispatch>; apRemaining: number; projection: ProjectedInventory; + /** Panel tilt — Planning alternates -1/+1 by card index. */ + tilt?: -1 | 1; } const YIELDS: Array<{ label: 'small' | 'med' | 'big'; yield: Yield }> = [ @@ -25,9 +29,8 @@ const YIELDS: Array<{ label: 'small' | 'med' | 'big'; yield: Yield }> = [ export default function TargetRow({ target, mood, targetType, onTargetTypeChange, orders, setOrders, apRemaining, projection, + tilt = -1, }: Props) { - const flag = target.country.split(' ')[0]; - const wooed = orders.some((o) => o.kind === 'woo' && o.target === target.id); const propd = orders.some((o) => o.kind === 'propaganda' && o.target === target.id); @@ -84,70 +87,85 @@ export default function TargetRow({ }; return ( -
-
- {flag} - {target.name} - - onTargetTypeChange('people')} - >people - onTargetTypeChange('infra')} - >infra - -
- - {mood &&
{mood}
} +
+ +
+ +
+
+ {target.name} + {target.country} +
+
+ + +
+
+ POP {target.population}M · ⚙ {target.factories} +
+ {mood &&
"{mood}"
} +
+
-
- - -
+
+ + +
-
- 🚀 missiles · {projection.missiles} left -
-
- {YIELDS.map((Y) => ( - addLaunch('missile', Y.yield)} - onDec={() => removeLaunch('missile', Y.yield)} - /> - ))} -
+
+ 🚀 missiles · {projection.missiles} left +
+
+ {YIELDS.map((Y) => ( + addLaunch('missile', Y.yield)} + onDec={() => removeLaunch('missile', Y.yield)} + /> + ))} +
-
- ✈️ bombers · {projection.bombers} left -
-
- {YIELDS.map((Y) => ( - addLaunch('bomber', Y.yield)} - onDec={() => removeLaunch('bomber', Y.yield)} - /> - ))} -
+
+ ✈️ bombers · {projection.bombers} left +
+
+ {YIELDS.map((Y) => ( + addLaunch('bomber', Y.yield)} + onDec={() => removeLaunch('bomber', Y.yield)} + /> + ))} +
+
); } diff --git a/src/ui/screens/Planning.module.css b/src/ui/screens/Planning.module.css index 27282fb..8a2aafe 100644 --- a/src/ui/screens/Planning.module.css +++ b/src/ui/screens/Planning.module.css @@ -1,25 +1,202 @@ .planning { - max-width: 480px; + padding: 20px 16px 96px; +} + +.wrap { + max-width: 1280px; margin: 0 auto; - padding: 12px; - font-size: 12px; } -.header { font-size: 14px; font-weight: 700; margin-bottom: 8px; } +.header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 14px; + flex-wrap: wrap; + margin-bottom: 18px; + padding-bottom: 14px; + border-bottom: 4px solid var(--ink); +} + +.headerLeft { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; + flex: 1; + flex-wrap: wrap; +} + +.headerName { + min-width: 0; + flex: 1 1 220px; +} + +.leaderLine { + font-size: clamp(18px, 2.6vw, 26px); + line-height: 1; +} + +.headerDot { color: var(--magenta); } + +.grid { + display: grid; + grid-template-columns: 1fr; + gap: 22px; +} +@media (min-width: 800px) { + .grid { + grid-template-columns: minmax(0, 1fr) minmax(0, 1.4fr); + } + .colOrders { grid-column: 1 / -1; } +} +@media (min-width: 1180px) { + .grid { + grid-template-columns: minmax(260px, 1.1fr) minmax(420px, 1.7fr) minmax(280px, 1.1fr); + } + .colOrders { grid-column: auto; } +} -.apBanner { - background: #198754; color: white; padding: 6px 12px; border-radius: 4px; - margin-bottom: 12px; font-size: 12px; display: flex; justify-content: space-between; +.ownRow { + display: flex; + gap: 14px; + align-items: flex-start; + flex-wrap: wrap; } -.sectionTitle { - font-size: 9px; letter-spacing: 0.12em; text-transform: uppercase; - color: #856404; font-weight: 700; margin: 10px 0 4px; +.statGrid { + flex: 1 1 180px; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px 18px; + min-width: 0; } -.sealBtn { - background: #b02a37; color: white; border: none; border-radius: 24px; - padding: 14px 28px; font-size: 14px; font-weight: 600; width: 100%; margin-top: 16px; +.tableHead { margin-bottom: -4px; } + +.tableTitle { + font-size: 24px; + margin: 0; + line-height: 1; +} + +.tableSub { + font-size: 13px; + opacity: 0.65; +} + +.tableGrid { + display: grid; + grid-template-columns: 1fr; + gap: 14px; +} +@media (min-width: 640px) { + .tableGrid { grid-template-columns: 1fr 1fr; } +} + +.emptyOrders { + font-size: 16px; + opacity: 0.6; + padding: 30px 0; + text-align: center; +} + +.orderList { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 4px; +} + +.orderRow { + display: flex; + gap: 8px; + align-items: center; + padding: 6px 10px; + background: var(--paper); + border: 2px solid var(--ink); + box-shadow: 2px 2px 0 var(--ink); +} + +.orderIcon { + font-size: 18px; + width: 24px; + text-align: center; + flex-shrink: 0; +} + +.orderLabel { + flex: 1; + font-size: 13px; + min-width: 0; +} + +.orderAp { + color: var(--magenta); + font-weight: 700; + white-space: nowrap; +} + +.xBtn { + background: transparent; + border: 0; cursor: pointer; + font-size: 18px; + padding: 0 4px; + color: var(--ink-soft); +} +.xBtn:hover { color: var(--magenta); } + +.subtotal { + border-top: 2px dashed var(--ink); + margin-top: 12px; + padding-top: 10px; +} + +.subtotalLabel { + font-size: 14px; + letter-spacing: 0.08em; +} + +.subtotalValue { + font-size: 22px; + font-weight: 800; +} + +.subtotalOver { color: var(--magenta); } + +.subtotalMax { opacity: 0.5; } + +.overLine { + font-size: 14px; + margin-top: 6px; + color: var(--magenta-deep); +} + +.sealWrap { margin-top: 16px; } + +.sealHint { + font-size: 11px; + margin-top: 6px; + text-align: center; + color: var(--ink-soft); +} + +.mastKicker { + font-size: 11px; + letter-spacing: 0.18em; + opacity: 0.6; +} + +.mastTitle { + font-weight: 900; + font-size: 22px; + margin-top: 4px; + line-height: 1; +} + +.mastSub { + font-size: 13px; + margin-top: 6px; + opacity: 0.7; } -.sealBtn:disabled { background: #adb5bd; cursor: not-allowed; } diff --git a/src/ui/screens/Planning.tsx b/src/ui/screens/Planning.tsx index 1fb8552..6bd69ef 100644 --- a/src/ui/screens/Planning.tsx +++ b/src/ui/screens/Planning.tsx @@ -1,8 +1,14 @@ import { useState } from 'react'; import type { ScreenProps } from '../App'; -import type { LeaderId, Order, TargetType } from '../../engine/types'; +import type { GameState, LeaderId, Order, TargetType, Yield } from '../../engine/types'; import { isHuman } from '../../engine/state'; -import { totalApCost, analyseOrderSequence } from '../../engine/orders'; +import { totalApCost, apCostOf, analyseOrderSequence } from '../../engine/orders'; +import { AP_BANK_CAP } from '../../engine/balance'; +import { pickMasthead } from '../../engine/masthead'; +import { HoldButton, Panel, RoundBadge, Stat } from '../components/comic'; +import Portrait from '../components/Portrait'; +import { extractFlag, stripFlag } from '../portraits'; +import ApBudget from '../components/ApBudget'; import BuildGrid from '../components/BuildGrid'; import DefenceGrid from '../components/DefenceGrid'; import TargetRow from '../components/TargetRow'; @@ -10,6 +16,37 @@ import SoftWarnPanel from '../components/SoftWarnPanel'; import { projectInventory } from '../util/projection'; import styles from './Planning.module.css'; +const YIELD_SHORT: Record = { small: 'small', medium: 'med', large: 'big' }; + +function orderIcon(o: Order): string { + switch (o.kind) { + case 'build-factory': return '🏭'; + case 'build-missile': return '🚀'; + case 'build-bomber': return '✈️'; + case 'build-warhead': return '💥'; + case 'build-defence': return o.type === 'shield' ? '🛡️' : '📡'; + case 'deploy-defence': return o.type === 'shield' ? '🛡️↑' : '📡↑'; + case 'launch': return '↯'; + case 'propaganda': return '📃'; + case 'woo': return '🤝'; + } +} + +function orderLabel(o: Order, game: GameState): string { + switch (o.kind) { + case 'build-factory': return 'Build factory'; + case 'build-missile': return 'Build missile'; + case 'build-bomber': return 'Build bomber'; + case 'build-warhead': return `Build ${o.yield} warhead`; + case 'build-defence': return `Build ${o.type === 'shield' ? 'shield' : 'AA'}`; + case 'deploy-defence': return `Deploy ${o.type === 'shield' ? 'shield' : 'AA'}`; + case 'launch': + return `Launch on ${game.leaders[o.target].name} · ${o.delivery} · ${YIELD_SHORT[o.warhead]} · ${o.targetType}`; + case 'propaganda': return `Propaganda → ${game.leaders[o.target].name}`; + case 'woo': return `Woo ${game.leaders[o.target].name}`; + } +} + export default function Planning({ state, dispatch }: ScreenProps) { const game = state.game!; const activeId = state.activeHumanTurn ?? 'player1'; @@ -33,59 +70,167 @@ export default function Planning({ state, dispatch }: ScreenProps) { if (e.kind === 'PreRoundMood') moodByLeader[e.leaderId] = e.quote; } + const totalWarheads = + player.stockpile.warheadsSmall + player.stockpile.warheadsMedium + player.stockpile.warheadsLarge; + + function removeOrderAt(index: number) { + setOrders((prev) => prev.filter((_, i) => i !== index)); + } + return ( -
-
- Round {game.round}{state.activeHumanTurn ? ` · ${player.name}` : ''} -
- -
- AP used: {apUsed} / {apTotal} - {apRemaining} left -
+
+
+
+
+ +
+
+ {player.name} ·{' '} + {stripFlag(player.country)} {extractFlag(player.country)} +
+
+
+
+ +
+ {/* Left column — Your Country */} +
+ +
+ +
+ + + + +
+
+ +
+ +
+ + {/* Centre column — The Table + Build Yard + Civil Defence */} +
+
+

The Table

+ + Queue launches, woos, and propaganda per leader. + +
+
+ {aiLeaders.map((id, i) => ( + { + setTargetTypes((prev) => ({ ...prev, [id]: next })); + // Retarget every launch already queued at this leader so the + // toggle and the orders never disagree. + setOrders((prev) => prev.map((o) => + o.kind === 'launch' && o.target === id ? { ...o, targetType: next } : o, + )); + }} + orders={orders} + setOrders={setOrders} + apRemaining={apRemaining} + projection={projection} + /> + ))} +
+ + + + + + + + +
- - -
Build
- - -
Defence
- - -
Actions by target
- {aiLeaders.map((id) => ( - { - setTargetTypes((prev) => ({ ...prev, [id]: next })); - // Retarget every launch already queued at this leader so the - // toggle and the orders never disagree. - setOrders((prev) => prev.map((o) => - o.kind === 'launch' && o.target === id ? { ...o, targetType: next } : o, - )); - }} - orders={orders} - setOrders={setOrders} - apRemaining={apRemaining} - projection={projection} - /> - ))} - - + {/* Right column — Your Orders + masthead teaser */} +
+ + {orders.length === 0 ? ( +
+ No orders yet. Banking is fine. (Cap {AP_BANK_CAP}.) +
+ ) : ( +
+ {orders.map((o, i) => ( +
+ {orderIcon(o)} + {orderLabel(o, game)} + {apCostOf(o)} AP + +
+ ))} +
+ )} + +
+
+ SUBTOTAL + + {apUsed} / {apTotal} AP + +
+ {overBudget && ( +
+ Over budget by {apUsed - apTotal}. Drop something. Or don't, and panic. +
+ )} +
+ +
+ dispatch({ type: 'PLAYER_SUBMIT', leaderId: activeId, orders })} + style={{ width: '100%', fontSize: 22, padding: '16px 20px' }} + > + SEAL ORDERS — HOLD ↯ + +
Hold 0.6s. Irreversible.
+
+
+ + +
TONIGHT'S MASTHEAD
+
+ {pickMasthead(game.mastheadOrder, game.round, null)} +
+
+ "Vol. IV · Round {game.round} · Morning Edition" +
+
+
+
+
); } diff --git a/tests/ui/Planning.actionGrid.test.tsx b/tests/ui/Planning.actionGrid.test.tsx index 42a12bb..ef11e57 100644 --- a/tests/ui/Planning.actionGrid.test.tsx +++ b/tests/ui/Planning.actionGrid.test.tsx @@ -25,12 +25,12 @@ function makeState(): UiState { describe(' action-card grid', () => { it('renders build grid with Factory cell', () => { render(); - expect(screen.getByText(/Factory/i)).toBeInTheDocument(); + expect(screen.getByText('Factory')).toBeInTheDocument(); }); it('renders defence grid with Build Shield cell', () => { render(); - expect(screen.getByText(/Build Shield/i)).toBeInTheDocument(); + expect(screen.getByText('Build Shield')).toBeInTheDocument(); }); it('renders target rows for each opponent', () => { @@ -39,14 +39,15 @@ describe(' action-card grid', () => { expect(screen.getByLabelText(/Target row for Carnage/i)).toBeInTheDocument(); }); - it('renders AP banner', () => { + it('renders the AP meter and orders subtotal', () => { render(); - expect(screen.getByText(/AP used/i)).toBeInTheDocument(); + expect(screen.getByText('SUBTOTAL')).toBeInTheDocument(); + expect(screen.getByText(/will bank/)).toBeInTheDocument(); }); it('seal button exists', () => { const dispatch = vi.fn(); render(); - expect(screen.getByRole('button', { name: /seal/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /seal orders/i })).toBeInTheDocument(); }); }); diff --git a/tests/ui/Planning.ordersQueue.test.tsx b/tests/ui/Planning.ordersQueue.test.tsx new file mode 100644 index 0000000..41e11a0 --- /dev/null +++ b/tests/ui/Planning.ordersQueue.test.tsx @@ -0,0 +1,65 @@ +import { describe, expect, it, vi } from 'vitest'; +import { render, screen, fireEvent, within } from '@testing-library/react'; +import Planning from '../../src/ui/screens/Planning'; +import { initialState } from '../../src/engine/state'; +import type { UiState } from '../../src/ui/store'; + +function makeState(): UiState { + const game = initialState({ + cast: ['player1', 'chump', 'carnage'], + difficulty: 'normal', + seed: 'orders-queue-test', + }); + return { + screen: 'planning', + game, + events: [], + prevPopulations: {}, + initialPopulations: {}, + lastNewGameOpts: null, + activeHumanTurn: 'player1', + pendingHumanOrders: {}, + }; +} + +describe(' orders queue', () => { + it('shows the banking empty state when no orders are queued', () => { + render(); + expect(screen.getByText(/No orders yet\. Banking is fine\. \(Cap 4\.\)/)).toBeInTheDocument(); + }); + + it('queuing via a stepper shows a row with the label and AP cost', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'add Factory' })); + + const label = screen.getByText('Build factory'); + expect(label).toBeInTheDocument(); + const row = label.closest('div') as HTMLElement; + expect(within(row).getByText('3 AP')).toBeInTheDocument(); + expect(screen.queryByText(/No orders yet/)).not.toBeInTheDocument(); + }); + + it('× removes exactly that order and the subtotal updates', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'add Factory' })); // 3 AP + fireEvent.click(screen.getByRole('button', { name: 'add Missile' })); // 1 AP + + const subtotal = screen.getByText('SUBTOTAL').parentElement as HTMLElement; + expect(subtotal).toHaveTextContent('4 / 6 AP'); + + const factoryRow = screen.getByText('Build factory').closest('div') as HTMLElement; + fireEvent.click(within(factoryRow).getByRole('button', { name: 'remove' })); + + expect(screen.queryByText('Build factory')).not.toBeInTheDocument(); + expect(screen.getByText('Build missile')).toBeInTheDocument(); + expect(subtotal).toHaveTextContent('1 / 6 AP'); + }); + + it('removing the last order restores the empty state', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'add Missile' })); + const row = screen.getByText('Build missile').closest('div') as HTMLElement; + fireEvent.click(within(row).getByRole('button', { name: 'remove' })); + expect(screen.getByText(/No orders yet/)).toBeInTheDocument(); + }); +}); diff --git a/tests/ui/Planning.seal.test.tsx b/tests/ui/Planning.seal.test.tsx new file mode 100644 index 0000000..0be07a8 --- /dev/null +++ b/tests/ui/Planning.seal.test.tsx @@ -0,0 +1,74 @@ +import { describe, expect, it, vi } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import Planning from '../../src/ui/screens/Planning'; +import { initialState } from '../../src/engine/state'; +import type { UiState } from '../../src/ui/store'; + +function makeState(): UiState { + const game = initialState({ + cast: ['player1', 'chump', 'carnage'], + difficulty: 'normal', + seed: 'seal-test', + }); + return { + screen: 'planning', + game, + events: [], + prevPopulations: {}, + initialPopulations: {}, + lastNewGameOpts: null, + activeHumanTurn: 'player1', + pendingHumanOrders: {}, + }; +} + +describe(' seal orders (hold)', () => { + it('holding the seal button dispatches PLAYER_SUBMIT with the queued orders', async () => { + const dispatch = vi.fn(); + render(); + fireEvent.click(screen.getByRole('button', { name: 'add Missile' })); + + fireEvent.mouseDown(screen.getByRole('button', { name: /seal orders/i })); + await waitFor(() => expect(dispatch).toHaveBeenCalledTimes(1), { timeout: 3000 }); + + expect(dispatch).toHaveBeenCalledWith({ + type: 'PLAYER_SUBMIT', + leaderId: 'player1', + orders: [{ kind: 'build-missile' }], + }); + }); + + it('sealing with zero orders stays legal (banking)', async () => { + const dispatch = vi.fn(); + render(); + + fireEvent.mouseDown(screen.getByRole('button', { name: /seal orders/i })); + await waitFor(() => expect(dispatch).toHaveBeenCalledTimes(1), { timeout: 3000 }); + + expect(dispatch).toHaveBeenCalledWith({ + type: 'PLAYER_SUBMIT', + leaderId: 'player1', + orders: [], + }); + }); + + it('while over budget the seal button is disabled and the hold cannot complete', async () => { + const dispatch = vi.fn(); + const state = makeState(); + const { rerender } = render(); + + // Queue a 3 AP factory, then collapse the AP budget under it and re-render: + // the queued orders (component state) now exceed player.ap. + fireEvent.click(screen.getByRole('button', { name: 'add Factory' })); + state.game!.leaders.player1.ap = 1; + rerender(); + + expect(screen.getByText(/Over budget by 2/)).toBeInTheDocument(); + const seal = screen.getByRole('button', { name: /seal orders/i }); + expect(seal).toBeDisabled(); + + fireEvent.mouseDown(seal); + await new Promise((r) => setTimeout(r, 900)); + expect(dispatch).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/ui/Planning.targetRow.test.tsx b/tests/ui/Planning.targetRow.test.tsx index aa32bd1..ac082fc 100644 --- a/tests/ui/Planning.targetRow.test.tsx +++ b/tests/ui/Planning.targetRow.test.tsx @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import { render, screen, fireEvent, within } from '@testing-library/react'; +import { render, screen, fireEvent, waitFor, within } from '@testing-library/react'; import Planning from '../../src/ui/screens/Planning'; import { initialState } from '../../src/engine/state'; import type { UiState } from '../../src/ui/store'; @@ -34,13 +34,15 @@ describe('', () => { it('woo button toggles when clicked', () => { render(); - const wooBtn = screen.getByRole('button', { name: /Woo/i }); + const wooBtn = screen.getByRole('button', { name: /woo/i }); + expect(wooBtn).toHaveAttribute('aria-pressed', 'false'); fireEvent.click(wooBtn); - // After click, the button should have the diploOn class - expect(wooBtn.className).toMatch(/diploOn|on/i); + expect(wooBtn).toHaveAttribute('aria-pressed', 'true'); + fireEvent.click(wooBtn); + expect(wooBtn).toHaveAttribute('aria-pressed', 'false'); }); - it('retargets queued launches when the target type is toggled to infra', () => { + it('retargets queued launches when the target type is toggled to infra', async () => { const game = initialState({ cast: ['player1', 'chump'], difficulty: 'normal', seed: 'tt-toggle' }); game.leaders.player1.ap = 10; game.leaders.player1.stockpile.missiles = 2; @@ -63,10 +65,10 @@ describe('', () => { fireEvent.click(within(row).getAllByText('+')[0]); // Toggle the target to infra. fireEvent.click(within(row).getByText('infra')); - // Seal orders. - fireEvent.click(screen.getByRole('button', { name: /Seal Orders/i })); + // Seal orders (hold-to-confirm). + fireEvent.mouseDown(screen.getByRole('button', { name: /seal orders/i })); + await waitFor(() => expect(dispatch).toHaveBeenCalledTimes(1), { timeout: 3000 }); - expect(dispatch).toHaveBeenCalledTimes(1); const orders = dispatch.mock.calls[0][0].orders as Order[]; const launches = orders.filter((o) => o.kind === 'launch'); expect(launches).toHaveLength(1); From 5784d943c72906a75fb0e0bd8a89ce486591dc08 Mon Sep 17 00:00:00 2001 From: gethi Date: Sun, 19 Jul 2026 15:01:26 +0100 Subject: [PATCH 22/43] fix: define pulsedot keyframes locally in AiConferring.module.css CSS modules localize animation names in the shorthand, so referencing the global @keyframes pulsedot from tokens.css never resolved and the conferring dots did not animate. Defining the keyframes inside the module makes the localized names match (verified in emitted build CSS). Co-Authored-By: Claude Fable 5 --- src/ui/screens/AiConferring.module.css | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/ui/screens/AiConferring.module.css b/src/ui/screens/AiConferring.module.css index 65516c5..21cd592 100644 --- a/src/ui/screens/AiConferring.module.css +++ b/src/ui/screens/AiConferring.module.css @@ -54,6 +54,13 @@ gap: 3px; } +/* Defined locally: CSS modules localize animation names in the shorthand, + so referencing the global @keyframes in tokens.css would not resolve. */ +@keyframes pulsedot { + 0%, 100% { transform: scale(1); opacity: 0.4; } + 50% { transform: scale(1.4); opacity: 1; } +} + .pulseDot { width: 6px; height: 6px; From 8e6b1ea522d391161395a154a5576c3c04298a7c Mon Sep 17 00:00:00 2001 From: gethi Date: Sun, 19 Jul 2026 15:13:58 +0100 Subject: [PATCH 23/43] ui: extract formatEventText into util/eventText Pure event->display-text formatter for the slice-3 Action re-theme: formatEventCard's switch moved verbatim minus the className field. EventCard still carries its own copy until the Action rewrite deletes it. Co-Authored-By: Claude Fable 5 --- src/ui/util/eventText.ts | 120 ++++++++++++++++++++++++++++++++++++ tests/ui/eventText.test.ts | 123 +++++++++++++++++++++++++++++++++++++ 2 files changed, 243 insertions(+) create mode 100644 src/ui/util/eventText.ts create mode 100644 tests/ui/eventText.test.ts diff --git a/src/ui/util/eventText.ts b/src/ui/util/eventText.ts new file mode 100644 index 0000000..c8d3ece --- /dev/null +++ b/src/ui/util/eventText.ts @@ -0,0 +1,120 @@ +import type { GameState, ResolutionEvent } from '../../engine/types'; + +export interface EventText { + icon: string; + body: string; + quote?: string; +} + +function flag(game: GameState, id: keyof GameState['leaders']): string { + return game.leaders[id]?.country.split(' ')[0] ?? id; +} +function name(game: GameState, id: keyof GameState['leaders']): string { + return game.leaders[id]?.name ?? id; +} + +/** + * Per-kind display text for a resolution event (icon + body + merged quote). + * Moved verbatim from EventCard's formatEventCard (slice-3 re-theme) minus the + * className field. Returns null for kinds never rendered on the Action screen. + */ +export function formatEventText( + event: ResolutionEvent, + game: GameState, + count = 1, +): EventText | null { + switch (event.kind) { + case 'OrdersSealed': + return null; // not rendered + case 'FactoryBuilt': + return { + icon: '⚙', + body: `${flag(game, event.by)} ${name(game, event.by)} builds ${count} ${count === 1 ? 'factory' : 'factories'}`, + quote: event.quote, + }; + case 'DeliveryBuilt': + return { + icon: event.type === 'missile' ? '🚀' : '🛩', + body: `${flag(game, event.by)} ${name(game, event.by)} builds ${count} ${event.type}${count === 1 ? '' : 's'}`, + }; + case 'WarheadBuilt': + return { + icon: '☢', + body: `${flag(game, event.by)} ${name(game, event.by)} builds ${count} ${event.yield} warhead${count === 1 ? '' : 's'}`, + }; + case 'DefenceBuilt': + return { + icon: '🛡', + body: `${flag(game, event.by)} ${name(game, event.by)} builds ${count} ${event.type === 'shield' ? (count === 1 ? 'shield' : 'shields') : 'AA'}`, + quote: event.quote, + }; + case 'PropagandaTransfer': + return { + icon: '📰', + body: `${flag(game, event.from)} → ${flag(game, event.to)} · ${event.amount}M transferred`, + quote: [event.senderQuote, event.receiverQuote].filter(Boolean).join(' — ') || undefined, + }; + case 'WooApplied': + return { + icon: '🤝', + body: `${flag(game, event.from)} woos ${flag(game, event.to)} · ${event.points} points`, + quote: [event.senderQuote, event.receiverQuote].filter(Boolean).join(' — ') || undefined, + }; + case 'MissileLaunched': + return { + icon: event.delivery === 'missile' ? '🚀' : '🛩', + body: `${flag(game, event.from)} → ${flag(game, event.to)} (${event.warhead} · ${event.targetType})`, + quote: event.attackerQuote, + }; + case 'MissileIntercepted': + return { + icon: '🛡✗', + body: `${name(game, event.from)}'s ${event.delivery} to ${name(game, event.to)} intercepted`, + }; + case 'ImpactPeople': + return { + icon: '☠️', + body: `${flag(game, event.target)} ${name(game, event.target)} ─ ${event.deaths}M deaths (from ${flag(game, event.from)})`, + quote: event.targetQuote, + }; + case 'ImpactInfrastructure': + return { + icon: '🏭✗', + body: `${flag(game, event.target)} ${name(game, event.target)} ─ ${event.factoriesDestroyed} factories destroyed`, + quote: event.targetQuote, + }; + case 'LeaderEliminated': + return { + icon: '⬛', + body: `${name(game, event.id)} eliminated`, + quote: event.quote, + }; + case 'DefenceDeployed': + return { + icon: '🛡', + body: `${flag(game, event.by)} ${name(game, event.by)} deploys ${event.type === 'shield' ? 'a shield' : 'AA'}`, + quote: event.quote, + }; + case 'DefenceConsumed': + return null; // round-end housekeeping, not rendered on Action + case 'FinalRetaliationTriggered': + return { + icon: '💥', + body: `${flag(game, event.by)} launches Final Retaliation at ${event.targets.map((t) => flag(game, t)).join(', ')}`, + quote: event.quote, + }; + case 'OutcomeReached': + return null; // not rendered + // Not rendered on Action screen (only on Planning / RoundSummary): + case 'PreRoundMood': + return null; + case 'PostRoundReaction': + return null; + // DisparageCameo renders as the cameo overlay card, not banner text. + // DisparageColumn is not rendered on Action. + case 'DisparageCameo': + return null; + case 'DisparageColumn': + return null; + } +} diff --git a/tests/ui/eventText.test.ts b/tests/ui/eventText.test.ts new file mode 100644 index 0000000..7ff0723 --- /dev/null +++ b/tests/ui/eventText.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from 'vitest'; +import { formatEventText } from '../../src/ui/util/eventText'; +import { initialState } from '../../src/engine/state'; + +const game = initialState({ + cast: ['player1', 'chump', 'carnage'], + difficulty: 'normal', + seed: 'event-text-test', +}); + +describe('formatEventText', () => { + it('formats FactoryBuilt with count pluralisation and quote', () => { + const one = formatEventText({ kind: 'FactoryBuilt', by: 'chump', quote: 'Tremendous.' }, game, 1); + expect(one).toEqual({ + icon: '⚙', + body: '🇺🇸 Chump builds 1 factory', + quote: 'Tremendous.', + }); + const three = formatEventText({ kind: 'FactoryBuilt', by: 'chump' }, game, 3); + expect(three?.body).toBe('🇺🇸 Chump builds 3 factories'); + }); + + it('formats DeliveryBuilt with missile vs bomber icons', () => { + expect(formatEventText({ kind: 'DeliveryBuilt', by: 'chump', type: 'missile' }, game, 2)).toEqual({ + icon: '🚀', + body: '🇺🇸 Chump builds 2 missiles', + }); + expect(formatEventText({ kind: 'DeliveryBuilt', by: 'chump', type: 'bomber' }, game, 1)?.icon).toBe('🛩'); + }); + + it('formats WarheadBuilt with yield', () => { + expect(formatEventText({ kind: 'WarheadBuilt', by: 'carnage', yield: 'medium' }, game, 2)).toEqual({ + icon: '☢', + body: '🇨🇦 Carnage builds 2 medium warheads', + }); + }); + + it('formats DefenceBuilt for shields and AA', () => { + expect(formatEventText({ kind: 'DefenceBuilt', by: 'chump', type: 'shield' }, game, 2)?.body) + .toBe('🇺🇸 Chump builds 2 shields'); + expect(formatEventText({ kind: 'DefenceBuilt', by: 'chump', type: 'aa' }, game, 1)?.body) + .toBe('🇺🇸 Chump builds 1 AA'); + }); + + it('formats PropagandaTransfer and merges sender/receiver quotes', () => { + const r = formatEventText({ + kind: 'PropagandaTransfer', from: 'chump', to: 'carnage', amount: 2, + senderQuote: 'Read it.', receiverQuote: 'No.', + }, game); + expect(r).toEqual({ + icon: '📰', + body: '🇺🇸 → 🇨🇦 · 2M transferred', + quote: 'Read it. — No.', + }); + const noQuotes = formatEventText({ kind: 'PropagandaTransfer', from: 'chump', to: 'carnage', amount: 1 }, game); + expect(noQuotes?.quote).toBeUndefined(); + }); + + it('formats WooApplied', () => { + expect(formatEventText({ kind: 'WooApplied', from: 'carnage', to: 'chump', points: 1 }, game)?.body) + .toBe('🇨🇦 woos 🇺🇸 · 1 points'); + }); + + it('formats MissileLaunched with the attacker quote', () => { + const r = formatEventText({ + kind: 'MissileLaunched', from: 'chump', to: 'carnage', + delivery: 'missile', warhead: 'large', targetType: 'people', attackerQuote: 'Bye.', + }, game); + expect(r).toEqual({ + icon: '🚀', + body: '🇺🇸 → 🇨🇦 (large · people)', + quote: 'Bye.', + }); + }); + + it('formats MissileIntercepted', () => { + expect(formatEventText({ + kind: 'MissileIntercepted', from: 'chump', to: 'carnage', delivery: 'bomber', warhead: 'small', + }, game)?.body).toBe("Chump's bomber to Carnage intercepted"); + }); + + it('formats ImpactPeople and ImpactInfrastructure', () => { + expect(formatEventText({ + kind: 'ImpactPeople', from: 'chump', target: 'carnage', warhead: 'large', deaths: 5, targetQuote: 'Ouch.', + }, game)).toEqual({ + icon: '☠️', + body: '🇨🇦 Carnage ─ 5M deaths (from 🇺🇸)', + quote: 'Ouch.', + }); + expect(formatEventText({ + kind: 'ImpactInfrastructure', from: 'chump', target: 'carnage', warhead: 'small', factoriesDestroyed: 2, + }, game)?.body).toBe('🇨🇦 Carnage ─ 2 factories destroyed'); + }); + + it('formats LeaderEliminated without a className field', () => { + const r = formatEventText({ kind: 'LeaderEliminated', id: 'carnage', quote: 'Elbows down.' }, game); + expect(r).toEqual({ + icon: '⬛', + body: 'Carnage eliminated', + quote: 'Elbows down.', + }); + }); + + it('formats DefenceDeployed and FinalRetaliationTriggered', () => { + expect(formatEventText({ kind: 'DefenceDeployed', by: 'chump', type: 'shield' }, game)?.body) + .toBe('🇺🇸 Chump deploys a shield'); + expect(formatEventText({ + kind: 'FinalRetaliationTriggered', by: 'chump', targets: ['carnage', 'player1'], quote: 'EVERYTHING.', + }, game)?.body).toBe('🇺🇸 launches Final Retaliation at 🇨🇦, 🦆'); + }); + + it('returns null for every kind not rendered on Action', () => { + expect(formatEventText({ kind: 'OrdersSealed', leaderId: 'chump', orderCount: 3 }, game)).toBeNull(); + expect(formatEventText({ kind: 'DefenceConsumed', by: 'chump', type: 'shield' }, game)).toBeNull(); + expect(formatEventText({ kind: 'OutcomeReached', outcome: { type: 'apocalypse' } }, game)).toBeNull(); + expect(formatEventText({ kind: 'PreRoundMood', leaderId: 'chump', quote: 'Hm.', snapBack: false }, game)).toBeNull(); + expect(formatEventText({ kind: 'PostRoundReaction', leaderId: 'chump', quote: 'Hm.' }, game)).toBeNull(); + expect(formatEventText({ + kind: 'DisparageCameo', afterImpact: { from: 'chump', to: 'carnage' }, quote: 'Pint?', + }, game)).toBeNull(); + expect(formatEventText({ kind: 'DisparageColumn', quote: 'Pint.', footer: 'Allegedly.' }, game)).toBeNull(); + }); +}); From 5b5af4d6127e43a4156b2e78db369cf6d1a2d4e8 Mon Sep 17 00:00:00 2001 From: gethi Date: Sun, 19 Jul 2026 15:16:53 +0100 Subject: [PATCH 24/43] ui: WorldMap SVG component; PORTRAIT_META colors to concrete hexes Stylised political-cartoon map (slice-3 spec section 5): continent paths verbatim from the handoff on an extended 0 0 100 85 viewBox, cast-only country ellipses (actor green / receiver magenta / AI signature color / human slot accent), launch arcs with impact rings and firing pulse, intercept shield, propaganda and woo trails, build icons, final-retal multi-arc. PORTRAIT_META colors become concrete hexes (mirroring tokens.css --c-*) so SVG fill attributes never interpolate undefined var(--c-) custom properties. Co-Authored-By: Claude Fable 5 --- src/ui/components/WorldMap.module.css | 20 +++ src/ui/components/WorldMap.tsx | 248 ++++++++++++++++++++++++++ src/ui/portraits/index.ts | 20 ++- tests/ui/WorldMap.test.tsx | 167 +++++++++++++++++ 4 files changed, 447 insertions(+), 8 deletions(-) create mode 100644 src/ui/components/WorldMap.module.css create mode 100644 src/ui/components/WorldMap.tsx create mode 100644 tests/ui/WorldMap.test.tsx diff --git a/src/ui/components/WorldMap.module.css b/src/ui/components/WorldMap.module.css new file mode 100644 index 0000000..1698c09 --- /dev/null +++ b/src/ui/components/WorldMap.module.css @@ -0,0 +1,20 @@ +.svg { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +/* Build-event icon entrance above the actor's country ellipse. + * Local keyframes: tokens.css keyframe names are not visible from a CSS module. */ +.buildIcon { + transform-box: fill-box; + transform-origin: center; + animation: build-pop 260ms cubic-bezier(0.4, 0, 0.2, 1) both; +} + +@keyframes build-pop { + 0% { transform: scale(0.85); opacity: 0; } + 60% { transform: scale(1.06); opacity: 1; } + 100% { transform: scale(1); opacity: 1; } +} diff --git a/src/ui/components/WorldMap.tsx b/src/ui/components/WorldMap.tsx new file mode 100644 index 0000000..c492b78 --- /dev/null +++ b/src/ui/components/WorldMap.tsx @@ -0,0 +1,248 @@ +import type { GameState, LeaderId, ResolutionEvent } from '../../engine/types'; +import { isHuman } from '../../engine/state'; +import { HUMAN_ACCENTS, PORTRAIT_META, stripFlag } from '../portraits'; +import { formatEventText } from '../util/eventText'; +import styles from './WorldMap.module.css'; + +export interface CountryPos { + cx: number; + cy: number; + /** Fixed label for AI countries; human labels derive from live state. */ + label?: string; +} + +/** Map positions — AI values verbatim from the handoff (screens-3.jsx); + * human slots 2–5 are fixed ocean positions (slice-3 spec §5.2). */ +export const COUNTRY_POS: Record = { + chump: { cx: 23, cy: 36, label: 'USA' }, + carnage: { cx: 24, cy: 24, label: 'CANADA' }, + starmless: { cx: 44, cy: 13, label: 'UK' }, + netanyahoo: { cx: 58, cy: 40, label: 'ISR' }, + khameneverhere: { cx: 64, cy: 38, label: 'IRAN' }, + 'mileigh-hem': { cx: 33, cy: 78, label: 'ARG' }, + player1: { cx: 22, cy: 50 }, + player2: { cx: 10, cy: 62 }, + player3: { cx: 84, cy: 18 }, + player4: { cx: 90, cy: 68 }, + player5: { cx: 44, cy: 66 }, +}; + +/** The event's actor (green highlight), per slice-3 spec §5.3. */ +export function actorOf(event: ResolutionEvent): LeaderId | undefined { + switch (event.kind) { + case 'FactoryBuilt': + case 'DeliveryBuilt': + case 'WarheadBuilt': + case 'DefenceBuilt': + case 'DefenceDeployed': + case 'FinalRetaliationTriggered': + return event.by; + case 'PropagandaTransfer': + case 'WooApplied': + case 'MissileLaunched': + case 'MissileIntercepted': + case 'ImpactPeople': + case 'ImpactInfrastructure': + return event.from; + default: + return undefined; + } +} + +/** The event's receivers (magenta highlights), per slice-3 spec §5.3. */ +export function receiversOf(event: ResolutionEvent): LeaderId[] { + switch (event.kind) { + case 'PropagandaTransfer': + case 'WooApplied': + case 'MissileLaunched': + case 'MissileIntercepted': + return [event.to]; + case 'ImpactPeople': + case 'ImpactInfrastructure': + return [event.target]; + case 'LeaderEliminated': + return [event.id]; + case 'FinalRetaliationTriggered': + return [...event.targets]; + default: + return []; + } +} + +function launchArcPath(a: CountryPos, b: CountryPos): { d: string; midX: number; midY: number } { + const midX = (a.cx + b.cx) / 2; + const midY = Math.min(a.cy, b.cy) - 18; + return { d: `M ${a.cx} ${a.cy} Q ${midX} ${midY} ${b.cx} ${b.cy}`, midX, midY }; +} + +/** Yellow-over-magenta launch arc with the flight icon at the apex. */ +function LaunchArc({ a, b, icon }: { a: CountryPos; b: CountryPos; icon: string }) { + const { d, midX, midY } = launchArcPath(a, b); + return ( + + + + {icon} + + ); +} + +/** Concentric magenta dashed impact rings on the receiver. */ +function ImpactRings({ p }: { p: CountryPos }) { + return ( + + + + + ); +} + +/** Green firing pulse circle. */ +function Pulse({ p }: { p: CountryPos }) { + return ; +} + +/** Cyan/green dashed diplomacy trail with an icon at the midpoint. */ +function Trail({ a, b, stroke, icon }: { a: CountryPos; b: CountryPos; stroke: string; icon: string }) { + const midX = (a.cx + b.cx) / 2; + const midY = (a.cy + b.cy) / 2 - 6; + return ( + + + {icon} + + ); +} + +function Overlay({ event, game }: { event: ResolutionEvent; game: GameState }) { + switch (event.kind) { + case 'MissileLaunched': + case 'ImpactPeople': + case 'ImpactInfrastructure': { + const a = COUNTRY_POS[event.from]; + const b = COUNTRY_POS[event.kind === 'MissileLaunched' ? event.to : event.target]; + const icon = event.kind === 'MissileLaunched' && event.delivery === 'bomber' ? '🛩' : '🚀'; + return ( + + + + + + ); + } + case 'MissileIntercepted': { + const a = COUNTRY_POS[event.from]; + const b = COUNTRY_POS[event.to]; + return ( + + + + + ); + } + case 'PropagandaTransfer': + return ; + case 'WooApplied': + return ; + case 'FactoryBuilt': + case 'DeliveryBuilt': + case 'WarheadBuilt': + case 'DefenceBuilt': + case 'DefenceDeployed': { + const p = COUNTRY_POS[event.by]; + const icon = formatEventText(event, game, 1)?.icon ?? '⚙'; + return ( + + {icon} + + ); + } + case 'FinalRetaliationTriggered': { + const a = COUNTRY_POS[event.by]; + return ( + + {event.targets.map((t) => ( + + + + + ))} + + + ); + } + default: + return null; + } +} + +export interface WorldMapProps { + game: GameState; + /** The step's event; omit for an empty round (markers only, no overlays). */ + event?: ResolutionEvent; +} + +/** Stylised political-cartoon world map (slice-3 spec §5). + * Pure presentational: continents, cast country highlights, per-event overlays. */ +export default function WorldMap({ game, event }: WorldMapProps) { + const actor = event ? actorOf(event) : undefined; + const receivers = event ? receiversOf(event) : []; + + return ( + + {/* continents — abstract chunky political-cartoon shapes (verbatim from the handoff) */} + + + + + + + + {/* UK — separate island northwest of Europe */} + + UK + + + {/* country highlights — cast members only */} + {game.cast.map((id) => { + const p = COUNTRY_POS[id]; + const isActor = actor === id; + const isReceiver = receivers.includes(id); + const fill = isActor + ? 'var(--green)' + : isReceiver + ? 'var(--magenta)' + : isHuman(id) + ? HUMAN_ACCENTS[id] + : PORTRAIT_META[id].color; + const label = p.label ?? stripFlag(game.leaders[id].country).toUpperCase(); + const isUK = id === 'starmless'; + return ( + + + + {label} + + + ); + })} + + {event && } + + ); +} diff --git a/src/ui/portraits/index.ts b/src/ui/portraits/index.ts index fc24944..cc0855c 100644 --- a/src/ui/portraits/index.ts +++ b/src/ui/portraits/index.ts @@ -20,15 +20,19 @@ export const FACES: Record JSX.Element> = { player5: GrouchoFace, }; -/** Signature disc color + badge flag for the fixed cast. */ +/** Signature disc color + badge flag for the fixed cast. + * Colors are concrete hexes (mirroring tokens.css `--c-*`) so they work both as + * CSS backgrounds and as SVG `fill` attributes (WorldMap country ellipses, + * slice-3 spec §5.3 — interpolating `var(--c-)` would yield undefined + * custom properties for the long ids). Keep in sync with tokens.css. */ export const PORTRAIT_META: Record = { - chump: { color: 'var(--c-chump)', flag: '🇺🇸' }, - netanyahoo: { color: 'var(--c-netan)', flag: '🇮🇱' }, - khameneverhere: { color: 'var(--c-khamen)', flag: '🇮🇷' }, - starmless: { color: 'var(--c-starm)', flag: '🇬🇧' }, - carnage: { color: 'var(--c-carnage)', flag: '🇨🇦' }, - 'mileigh-hem': { color: 'var(--c-mileigh)', flag: '🇦🇷' }, - disparage: { color: 'var(--c-disparage)', flag: '🇬🇧' }, + chump: { color: '#d6293f', flag: '🇺🇸' }, + netanyahoo: { color: '#1a4d8c', flag: '🇮🇱' }, + khameneverhere: { color: '#1f5e3a', flag: '🇮🇷' }, + starmless: { color: '#8a1729', flag: '🇬🇧' }, + carnage: { color: '#b22535', flag: '🇨🇦' }, + 'mileigh-hem': { color: '#e6a517', flag: '🇦🇷' }, + disparage: { color: '#5a3e8a', flag: '🇬🇧' }, }; /** Fixed per-slot accent for human (Groucho) portraits — spec §3. */ diff --git a/tests/ui/WorldMap.test.tsx b/tests/ui/WorldMap.test.tsx new file mode 100644 index 0000000..ed1c20f --- /dev/null +++ b/tests/ui/WorldMap.test.tsx @@ -0,0 +1,167 @@ +import { describe, expect, it } from 'vitest'; +import { render } from '@testing-library/react'; +import WorldMap, { actorOf, receiversOf } from '../../src/ui/components/WorldMap'; +import { initialState } from '../../src/engine/state'; +import type { GameState, LeaderId, ResolutionEvent } from '../../src/engine/types'; + +function makeGame(cast: LeaderId[] = ['player1', 'chump', 'carnage']): GameState { + return initialState({ + cast, + difficulty: 'normal', + seed: 'world-map-test', + config: { + playerProfiles: { player1: { name: 'Rufus T. Firefly', country: '🦆 Duckland' } }, + }, + }); +} + +function markerEllipse(container: HTMLElement, id: string): SVGEllipseElement { + const el = container.querySelector(`[data-leader="${id}"] ellipse`); + expect(el).not.toBeNull(); + return el as SVGEllipseElement; +} + +describe('', () => { + it('renders markers for cast members only', () => { + const { container } = render(); + expect(container.querySelector('[data-leader="chump"]')).not.toBeNull(); + expect(container.querySelector('[data-leader="carnage"]')).not.toBeNull(); + expect(container.querySelector('[data-leader="player1"]')).not.toBeNull(); + // Not in the cast: + expect(container.querySelector('[data-leader="starmless"]')).toBeNull(); + expect(container.querySelector('[data-leader="netanyahoo"]')).toBeNull(); + }); + + it('fills non-actor AI ellipses with a concrete PORTRAIT_META hex, never an undefined var()', () => { + const { container } = render(); + const chumpFill = markerEllipse(container, 'chump').getAttribute('fill'); + const carnageFill = markerEllipse(container, 'carnage').getAttribute('fill'); + expect(chumpFill).toBe('#d6293f'); + expect(carnageFill).toBe('#b22535'); + expect(chumpFill).toMatch(/^#[0-9a-f]{6}$/i); + expect(carnageFill).toMatch(/^#[0-9a-f]{6}$/i); + }); + + it('fills human slots with the per-slot accent var at half opacity', () => { + const { container } = render(); + const el = markerEllipse(container, 'player1'); + expect(el.getAttribute('fill')).toBe('var(--cyan)'); + expect(el.getAttribute('opacity')).toBe('0.5'); + }); + + it('highlights the actor green and the receiver magenta at 0.85 opacity', () => { + const { container } = render( + , + ); + const actor = markerEllipse(container, 'chump'); + const receiver = markerEllipse(container, 'carnage'); + expect(actor.getAttribute('fill')).toBe('var(--green)'); + expect(actor.getAttribute('opacity')).toBe('0.85'); + expect(receiver.getAttribute('fill')).toBe('var(--magenta)'); + expect(receiver.getAttribute('opacity')).toBe('0.85'); + // Rocket at the arc apex. + expect(container.textContent).toContain('🚀'); + }); + + it('derives human labels from the live country string, AI labels from COUNTRY_POS', () => { + const { getByText } = render(); + expect(getByText('DUCKLAND')).toBeInTheDocument(); + expect(getByText('USA')).toBeInTheDocument(); + expect(getByText('CANADA')).toBeInTheDocument(); + }); + + it('renders the intercept shield at the apex and no impact rings', () => { + const { container } = render( + , + ); + expect(container.textContent).toContain('🛡'); + // Impact rings are dashed magenta ellipses rx=9/11.5 — absent on intercepts. + expect(container.querySelector('ellipse[rx="9"]')).toBeNull(); + expect(container.querySelector('ellipse[rx="11.5"]')).toBeNull(); + }); + + it('renders the propaganda trail with the paper icon', () => { + const { container } = render( + , + ); + expect(container.textContent).toContain('📃'); + expect(container.querySelector('path[stroke="var(--cyan)"]')).not.toBeNull(); + }); + + it('renders the woo trail re-stroked green with the handshake icon', () => { + const { container } = render( + , + ); + expect(container.textContent).toContain('🤝'); + expect(container.querySelector('path[stroke="var(--green)"][stroke-dasharray="0.8 0.6"]')).not.toBeNull(); + }); + + it('renders the build icon above the actor for build events', () => { + const { container } = render( + , + ); + expect(container.textContent).toContain('☢'); + }); + + it('renders one arc + rings per target for FinalRetaliationTriggered', () => { + const { container } = render( + , + ); + // Two targets → two sets of impact rings. + expect(container.querySelectorAll('ellipse[rx="9"]')).toHaveLength(2); + // Both targets highlighted magenta. + expect(markerEllipse(container, 'carnage').getAttribute('fill')).toBe('var(--magenta)'); + expect(markerEllipse(container, 'player1').getAttribute('fill')).toBe('var(--magenta)'); + }); +}); + +describe('actorOf / receiversOf', () => { + it('maps by-only kinds to actor with no receivers', () => { + expect(actorOf({ kind: 'FactoryBuilt', by: 'chump' })).toBe('chump'); + expect(receiversOf({ kind: 'FactoryBuilt', by: 'chump' })).toEqual([]); + expect(actorOf({ kind: 'DefenceDeployed', by: 'carnage', type: 'shield' })).toBe('carnage'); + }); + + it('maps from/to and from/target kinds', () => { + const launch: ResolutionEvent = { + kind: 'MissileLaunched', from: 'chump', to: 'carnage', + delivery: 'missile', warhead: 'small', targetType: 'people', + }; + expect(actorOf(launch)).toBe('chump'); + expect(receiversOf(launch)).toEqual(['carnage']); + const impact: ResolutionEvent = { + kind: 'ImpactPeople', from: 'chump', target: 'carnage', warhead: 'small', deaths: 3, + }; + expect(actorOf(impact)).toBe('chump'); + expect(receiversOf(impact)).toEqual(['carnage']); + }); + + it('maps LeaderEliminated to a receiver-only highlight', () => { + expect(actorOf({ kind: 'LeaderEliminated', id: 'carnage' })).toBeUndefined(); + expect(receiversOf({ kind: 'LeaderEliminated', id: 'carnage' })).toEqual(['carnage']); + }); + + it('maps FinalRetaliationTriggered to every target', () => { + const e: ResolutionEvent = { kind: 'FinalRetaliationTriggered', by: 'chump', targets: ['carnage', 'player1'] }; + expect(actorOf(e)).toBe('chump'); + expect(receiversOf(e)).toEqual(['carnage', 'player1']); + }); +}); From 2f9975d68a38f1e7b8032201b85e48a66fabfaa8 Mon Sep 17 00:00:00 2001 From: gethi Date: Sun, 19 Jul 2026 15:18:31 +0100 Subject: [PATCH 25/43] ui: re-theme DisparageCard as the map cameo overlay card Paper card with 3px ink border and magenta offset shadow: 56px Disparage portrait, yellow beer-tag, hand-font engine quote (slice-3 spec section 6). Co-Authored-By: Claude Fable 5 --- src/ui/components/DisparageCard.module.css | 27 +++++++++------------ src/ui/components/DisparageCard.tsx | 12 ++++++++-- tests/ui/DisparageCard.test.tsx | 28 ++++++++++++++++++++++ 3 files changed, 49 insertions(+), 18 deletions(-) create mode 100644 tests/ui/DisparageCard.test.tsx diff --git a/src/ui/components/DisparageCard.module.css b/src/ui/components/DisparageCard.module.css index 7e04cee..0710f56 100644 --- a/src/ui/components/DisparageCard.module.css +++ b/src/ui/components/DisparageCard.module.css @@ -1,24 +1,19 @@ .card { - background: #fff8e1; - border: 1px solid #f59f00; - border-left: 4px solid #f59f00; - border-radius: 4px; - padding: 10px 12px; - margin-bottom: 6px; + background: var(--paper); + border: 3px solid var(--ink); + padding: 8px 10px; + max-width: 280px; + box-shadow: 3px 3px 0 var(--magenta); } -.header { - font-size: 10px; - letter-spacing: 0.15em; - text-transform: uppercase; - color: #856404; - font-weight: 700; - margin-bottom: 6px; +.row { + display: flex; + gap: 8px; + align-items: flex-start; } .quote { font-size: 13px; - font-style: italic; - color: #5a4400; - line-height: 1.4; + margin-top: 4px; + color: var(--ink); } diff --git a/src/ui/components/DisparageCard.tsx b/src/ui/components/DisparageCard.tsx index 1b2e8f9..9ed2e1c 100644 --- a/src/ui/components/DisparageCard.tsx +++ b/src/ui/components/DisparageCard.tsx @@ -1,15 +1,23 @@ import type { ResolutionEvent } from '../../engine/types'; +import Portrait from './Portrait'; +import { Tag } from './comic'; import styles from './DisparageCard.module.css'; interface Props { event: Extract; } +/** Disparage cameo overlay card (map bottom-right, slice-3 spec §6). */ export default function DisparageCard({ event }: Props) { return (
-
🍺 FROM CLACTON
-
{event.quote}
+
+ +
+ 🍺 FROM CLACTON +
{event.quote}
+
+
); } diff --git a/tests/ui/DisparageCard.test.tsx b/tests/ui/DisparageCard.test.tsx new file mode 100644 index 0000000..6a1b008 --- /dev/null +++ b/tests/ui/DisparageCard.test.tsx @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { render } from '@testing-library/react'; +import DisparageCard from '../../src/ui/components/DisparageCard'; + +const event = { + kind: 'DisparageCameo', + afterImpact: { from: 'chump', to: 'carnage' }, + quote: 'Disgraceful. Anyway, off to America again.', +} as const; + +describe('', () => { + it('renders the FROM CLACTON tag with the beer glass', () => { + const { getByText } = render(); + expect(getByText('🍺 FROM CLACTON')).toBeInTheDocument(); + }); + + it('renders the engine-provided quote', () => { + const { getByText } = render(); + expect(getByText('Disgraceful. Anyway, off to America again.')).toBeInTheDocument(); + }); + + it('renders the 56px Disparage portrait', () => { + const { container } = render(); + const disc = container.querySelector('span[style*="width: 56px"]'); + expect(disc).not.toBeNull(); + expect(container.querySelector('svg')).not.toBeNull(); + }); +}); From 7aeed6a322b6506a2fdfd845dbb9fc46e448a49c Mon Sep 17 00:00:00 2001 From: gethi Date: Sun, 19 Jul 2026 15:22:29 +0100 Subject: [PATCH 26/43] ui: rewrite Action as sequential world-map playback Slice-3 re-theme: the static phase-sectioned event list becomes the handoff's one-step-at-a-time resolution playback driven by real ResolutionEvents. buildPlaybackSteps (exported, pure) keeps the existing phase-cursor + groupPhaseEvents bucketing and attaches engine-emitted DisparageCameo events to the impact step matching their afterImpact pair (cameos never become steps; unmatched cameos drop). Screen: ink background, RoundBadge + ACTION header with pause / fast 3x / skip ctrl-buttons, six-segment PhaseTrack, magenta NOW-phase banner, WorldMap with actor/receiver portrait pins + name pills, speech bubble, burst + damage badge (zero-damage impacts suppress both), ELIMINATED stamp, cameo overlay card, and the detail strip (progress dots, hand-font quote, prev/next, event counter). Auto-advance 2700ms / 1100ms fast; last-step next label is TO THE NEWS -> / TO THE VERDICT -> and dispatches ACTION_DONE. Empty rounds show the quiet-round banner and continue only. EventCard and PhaseTracker are deleted: the formatter lives on as util/eventText, the tracker is replaced by the comic PhaseTrack primitive. Co-Authored-By: Claude Fable 5 --- src/ui/components/EventCard.module.css | 38 --- src/ui/components/EventCard.tsx | 135 ---------- src/ui/components/PhaseTracker.module.css | 18 -- src/ui/components/PhaseTracker.tsx | 13 - src/ui/screens/Action.module.css | 257 ++++++++++++++++--- src/ui/screens/Action.tsx | 297 +++++++++++++++++++--- tests/ui/Action.playback.test.tsx | 214 ++++++++++++++++ tests/ui/Action.playbackSteps.test.ts | 96 +++++++ 8 files changed, 794 insertions(+), 274 deletions(-) delete mode 100644 src/ui/components/EventCard.module.css delete mode 100644 src/ui/components/EventCard.tsx delete mode 100644 src/ui/components/PhaseTracker.module.css delete mode 100644 src/ui/components/PhaseTracker.tsx create mode 100644 tests/ui/Action.playback.test.tsx create mode 100644 tests/ui/Action.playbackSteps.test.ts diff --git a/src/ui/components/EventCard.module.css b/src/ui/components/EventCard.module.css deleted file mode 100644 index f4f8475..0000000 --- a/src/ui/components/EventCard.module.css +++ /dev/null @@ -1,38 +0,0 @@ -.card { - display: flex; - align-items: center; - gap: 10px; - background: #fff8eb; - border: 1px solid #d0c8b8; - border-radius: 6px; - padding: 8px 12px; - margin: 4px 0; - font-size: 13px; -} - -.icon { - font-size: 18px; - min-width: 24px; - text-align: center; -} - -.body { flex: 1; } - -.bodyColumn { - flex: 1; - display: flex; - flex-direction: column; -} - -.quote { - font-size: 11px; - font-style: italic; - color: #5a4a3a; - margin-top: 4px; -} - -.obituary { - background: #2a2a2a; - color: #d8d0c0; - border-color: #000; -} diff --git a/src/ui/components/EventCard.tsx b/src/ui/components/EventCard.tsx deleted file mode 100644 index 5090a2e..0000000 --- a/src/ui/components/EventCard.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import type { GameState, ResolutionEvent } from '../../engine/types'; -import DisparageCard from './DisparageCard'; -import styles from './EventCard.module.css'; - -export interface EventCardProps { - event: ResolutionEvent; - game: GameState; - /** How many identical build events this card stands for (default 1). */ - count?: number; -} - -export default function EventCard({ event, game, count = 1 }: EventCardProps) { - if (event.kind === 'DisparageCameo') return ; - const result = formatEventCard(event, game, count); - if (!result) return null; - const { icon, body, className, quote } = result; - return ( -
- {icon} -
- {body} - {quote && {quote}} -
-
- ); -} - -function flag(game: GameState, id: keyof GameState['leaders']): string { - return game.leaders[id]?.country.split(' ')[0] ?? id; -} -function name(game: GameState, id: keyof GameState['leaders']): string { - return game.leaders[id]?.name ?? id; -} - -export function formatEventCard( - event: ResolutionEvent, - game: GameState, - count = 1, -): { icon: string; body: string; className?: string; quote?: string } | null { - switch (event.kind) { - case 'OrdersSealed': - return null; // not rendered - case 'FactoryBuilt': - return { - icon: '⚙', - body: `${flag(game, event.by)} ${name(game, event.by)} builds ${count} ${count === 1 ? 'factory' : 'factories'}`, - quote: event.quote, - }; - case 'DeliveryBuilt': - return { - icon: event.type === 'missile' ? '🚀' : '🛩', - body: `${flag(game, event.by)} ${name(game, event.by)} builds ${count} ${event.type}${count === 1 ? '' : 's'}`, - }; - case 'WarheadBuilt': - return { - icon: '☢', - body: `${flag(game, event.by)} ${name(game, event.by)} builds ${count} ${event.yield} warhead${count === 1 ? '' : 's'}`, - }; - case 'DefenceBuilt': - return { - icon: '🛡', - body: `${flag(game, event.by)} ${name(game, event.by)} builds ${count} ${event.type === 'shield' ? (count === 1 ? 'shield' : 'shields') : 'AA'}`, - quote: event.quote, - }; - case 'PropagandaTransfer': - return { - icon: '📰', - body: `${flag(game, event.from)} → ${flag(game, event.to)} · ${event.amount}M transferred`, - quote: [event.senderQuote, event.receiverQuote].filter(Boolean).join(' — ') || undefined, - }; - case 'WooApplied': - return { - icon: '🤝', - body: `${flag(game, event.from)} woos ${flag(game, event.to)} · ${event.points} points`, - quote: [event.senderQuote, event.receiverQuote].filter(Boolean).join(' — ') || undefined, - }; - case 'MissileLaunched': - return { - icon: event.delivery === 'missile' ? '🚀' : '🛩', - body: `${flag(game, event.from)} → ${flag(game, event.to)} (${event.warhead} · ${event.targetType})`, - quote: event.attackerQuote, - }; - case 'MissileIntercepted': - return { - icon: '🛡✗', - body: `${name(game, event.from)}'s ${event.delivery} to ${name(game, event.to)} intercepted`, - }; - case 'ImpactPeople': - return { - icon: '☠️', - body: `${flag(game, event.target)} ${name(game, event.target)} ─ ${event.deaths}M deaths (from ${flag(game, event.from)})`, - quote: event.targetQuote, - }; - case 'ImpactInfrastructure': - return { - icon: '🏭✗', - body: `${flag(game, event.target)} ${name(game, event.target)} ─ ${event.factoriesDestroyed} factories destroyed`, - quote: event.targetQuote, - }; - case 'LeaderEliminated': - return { - icon: '⬛', - body: `${name(game, event.id)} eliminated`, - className: styles.obituary, - quote: event.quote, - }; - case 'DefenceDeployed': - return { - icon: '🛡', - body: `${flag(game, event.by)} ${name(game, event.by)} deploys ${event.type === 'shield' ? 'a shield' : 'AA'}`, - quote: event.quote, - }; - case 'DefenceConsumed': - return null; // round-end housekeeping, not rendered on Action - case 'FinalRetaliationTriggered': - return { - icon: '💥', - body: `${flag(game, event.by)} launches Final Retaliation at ${event.targets.map((t) => flag(game, t)).join(', ')}`, - quote: event.quote, - }; - case 'OutcomeReached': - return null; // not rendered - // Not rendered on Action screen (only on Planning / RoundSummary): - case 'PreRoundMood': - return null; - case 'PostRoundReaction': - return null; - // DisparageCameo is handled before formatEventCard is called (routes to DisparageCard). - // DisparageColumn is not rendered on Action. - case 'DisparageCameo': - return null; - case 'DisparageColumn': - return null; - } -} diff --git a/src/ui/components/PhaseTracker.module.css b/src/ui/components/PhaseTracker.module.css deleted file mode 100644 index 97b3ee9..0000000 --- a/src/ui/components/PhaseTracker.module.css +++ /dev/null @@ -1,18 +0,0 @@ -.tracker { - display: flex; - justify-content: space-between; - background: #e8d8c0; - padding: 8px 12px; - border-radius: 8px; - margin-bottom: 12px; - font-size: 10px; - text-transform: uppercase; - letter-spacing: 0.05em; - color: #5a4a3a; -} - -.phase { - flex: 1; - text-align: center; - font-weight: 600; -} diff --git a/src/ui/components/PhaseTracker.tsx b/src/ui/components/PhaseTracker.tsx deleted file mode 100644 index b4afd65..0000000 --- a/src/ui/components/PhaseTracker.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import styles from './PhaseTracker.module.css'; - -const PHASES = ['Defences', 'Builds', 'Propaganda', 'Wooing', 'Launches', 'Final Retal.']; - -export default function PhaseTracker() { - return ( -
- {PHASES.map((p) => ( - {p} - ))} -
- ); -} diff --git a/src/ui/screens/Action.module.css b/src/ui/screens/Action.module.css index 65b283b..d8d6aeb 100644 --- a/src/ui/screens/Action.module.css +++ b/src/ui/screens/Action.module.css @@ -1,47 +1,238 @@ -.action { - max-width: 480px; - margin: 0 auto; - padding: 16px; - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; - background: #f4ede2; - color: #1a1a1a; +/* Action — sequential resolution playback on the world map (slice-3 spec §3). */ + +.screen { + background: var(--ink); + color: var(--paper); min-height: 100vh; - box-sizing: border-box; + padding: 12px; + display: flex; + flex-direction: column; } +/* ── Header ─────────────────────────────────────────────────────────── */ + .header { - text-align: center; - font-size: 16px; - font-weight: 700; - color: #6b1d1d; - margin-bottom: 12px; + display: flex; + justify-content: space-between; + align-items: center; + padding: 6px 8px 10px; + border-bottom: 2px solid var(--paper); + margin-bottom: 8px; + flex-wrap: wrap; + gap: 8px; +} + +.headerLeft { + display: flex; + align-items: center; + gap: 10px; +} + +.headerRight { + display: flex; + gap: 8px; } -.phaseSection { - margin: 12px 0; +.title { + font-size: 22px; + letter-spacing: 0.04em; } -.phaseHeader { +.ctrlBtn { + font-family: var(--font-display); font-size: 11px; + letter-spacing: 0.12em; + background: transparent; + color: var(--paper); + border: 2px solid var(--paper); + padding: 4px 10px; + cursor: pointer; text-transform: uppercase; - letter-spacing: 0.1em; - color: #8a7a6b; - margin: 0 0 6px; - font-weight: 600; +} +.ctrlBtn:hover { + background: var(--paper); + color: var(--ink); +} +.ctrlBtn:disabled { + opacity: 0.3; + cursor: not-allowed; +} + +.trackWrap { + padding: 0 4px; +} + +/* ── Event banner ───────────────────────────────────────────────────── */ + +.banner { + background: var(--magenta); + padding: 8px 12px; + border-top: 2px solid var(--ink); + border-bottom: 2px solid var(--ink); + color: var(--paper); + font-size: 13px; + font-family: var(--font-ui); + margin-top: 6px; +} + +/* ── The map ────────────────────────────────────────────────────────── */ + +.map { + flex: 1; + position: relative; + min-height: 360px; + background: var(--cyan); + background-image: + radial-gradient(rgba(0, 0, 0, 0.3) 1px, transparent 1.4px), + linear-gradient(180deg, #2a8eb4 0%, #1d7fb0 50%, #16527a 100%); + background-size: 6px 6px, 100% 100%; + border: 3px solid var(--paper); + overflow: hidden; + margin-top: 8px; +} + +.actorPortrait, +.receiverPortrait { + position: absolute; + z-index: 4; +} +.actorPortrait { + left: 14px; + top: 14px; +} +.receiverPortrait { + right: 14px; + top: 14px; } -.continueBtn { +.receiverInner { + position: relative; + display: inline-block; +} + +.eliminatedStamp { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + z-index: 5; +} + +.namePill { display: block; - width: 100%; - margin-top: 24px; - padding: 14px; - font-size: 14px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.1em; - background: #6b1d1d; - color: #fff8eb; - border: none; - border-radius: 8px; - cursor: pointer; + margin-top: 4px; + background: var(--ink); + color: var(--paper); + padding: 3px 8px; + font-family: var(--font-display); + font-size: 11px; + letter-spacing: 0.12em; + border: 2px solid var(--paper); + text-align: center; +} + +.bubbleWrap { + position: absolute; + left: 14px; + bottom: 14px; + z-index: 5; + max-width: 60%; +} + +.impactBadge { + position: absolute; + right: 14%; + top: 6%; + z-index: 6; +} + +.burstWrap { + margin-bottom: 6px; +} + +.cameoWrap { + position: absolute; + right: 12px; + bottom: 12px; + z-index: 7; +} + +/* ── Detail strip ───────────────────────────────────────────────────── */ + +.detailStrip { + background: var(--ink-soft); + color: var(--paper); + border: 2px solid var(--paper); + border-top: 0; + padding: 10px 14px; + margin-top: 6px; +} + +.dots { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; +} + +.dot { + width: 8px; + height: 8px; + border-radius: 50%; + border: 1px solid var(--paper); + background: var(--ink-soft); +} +.dot[data-state='past'] { + background: var(--paper-edge); +} +.dot[data-state='current'] { + background: var(--magenta); +} + +.detailQuote { + font-size: 16px; + color: var(--paper); + margin-top: 6px; + min-height: 22px; +} + +.stripControls { + margin-top: 14px; + display: flex; + gap: 10px; + align-items: center; +} + +.counter { + margin-left: auto; + font-size: 11px; + opacity: 0.6; +} + +/* ── Breakpoint (≤600px, verbatim from the handoff) ─────────────────── */ + +@media (max-width: 600px) { + .actorPortrait, + .receiverPortrait { + transform: scale(0.7); + } + .actorPortrait { + top: 60%; + left: 4%; + } + .receiverPortrait { + top: 12%; + right: 4%; + } + .impactBadge { + right: 8%; + top: 8%; + transform: scale(0.85); + } + .bubbleWrap { + left: 4%; + top: 92%; + max-width: 60%; + } } diff --git a/src/ui/screens/Action.tsx b/src/ui/screens/Action.tsx index 1ada479..db0a5f7 100644 --- a/src/ui/screens/Action.tsx +++ b/src/ui/screens/Action.tsx @@ -1,22 +1,32 @@ +import { useEffect, useMemo, useState } from 'react'; import type { ScreenProps } from '../App'; -import type { ResolutionEvent } from '../../engine/types'; -import EventCard from '../components/EventCard'; -import PhaseTracker from '../components/PhaseTracker'; +import type { LeaderId, ResolutionEvent } from '../../engine/types'; +import { isHuman } from '../../engine/state'; +import { Bubble, Burst, DamageBadge, PhaseTrack, RoundBadge, Stamp, Tag } from '../components/comic'; +import Portrait from '../components/Portrait'; +import WorldMap, { actorOf, receiversOf } from '../components/WorldMap'; +import DisparageCard from '../components/DisparageCard'; import { groupPhaseEvents } from '../util/eventGrouping'; +import { formatEventText } from '../util/eventText'; +import { extractFlag } from '../portraits'; import styles from './Action.module.css'; type Phase = 'DEFENCES' | 'BUILDS' | 'PROPAGANDA' | 'WOOING' | 'LAUNCHES' | 'FINAL_RETALIATIONS'; const PHASE_ORDER: Phase[] = ['DEFENCES', 'BUILDS', 'PROPAGANDA', 'WOOING', 'LAUNCHES', 'FINAL_RETALIATIONS']; const PHASE_LABELS: Record = { - DEFENCES: 'Defences', - BUILDS: 'Builds', - PROPAGANDA: 'Propaganda', - WOOING: 'Wooing', - LAUNCHES: 'Launches', - FINAL_RETALIATIONS: 'Final Retaliations', + DEFENCES: 'DEFENCE', + BUILDS: 'BUILDS', + PROPAGANDA: 'PROPAGANDA', + WOOING: 'WOOING', + LAUNCHES: 'LAUNCHES', + FINAL_RETALIATIONS: 'FINAL RETAL.', }; +/** Auto-advance dwell per step (slice-3 spec §2.2). */ +const DWELL_MS = 2700; +const DWELL_FAST_MS = 1100; + /** Returns the phase a "phase-advancing" event belongs to, or null for * events that should NOT advance the cursor (LeaderEliminated, OrdersSealed, * OutcomeReached — those inherit the current phase or aren't rendered). */ @@ -57,45 +67,258 @@ function isRenderable(kind: ResolutionEvent['kind']): boolean { ); } -export default function Action({ state, dispatch }: ScreenProps) { - const game = state.game!; +type Cameo = Extract; + +export interface PlaybackStep { + phase: Phase; + /** The grouped representative event. */ + event: ResolutionEvent; + /** How many identical build events this step stands for (from groupPhaseEvents). */ + count: number; + /** Disparage cameo attached to this impact step, if the engine emitted one. */ + cameo?: Cameo; +} + +/** + * Flatten a round's ResolutionEvents into the sequential playback step list + * (slice-3 spec §2.1): phase-cursor bucketing, groupPhaseEvents per phase, + * phases flattened in PHASE_ORDER. DisparageCameo events never become steps — + * each attaches to the impact step matching its `afterImpact` pair (the pair + * survives groupPhaseEvents' duplicate-impact collapse); unmatched cameos drop. + */ +export function buildPlaybackSteps(events: ResolutionEvent[]): PlaybackStep[] { const byPhase: Record = { DEFENCES: [], BUILDS: [], PROPAGANDA: [], WOOING: [], LAUNCHES: [], FINAL_RETALIATIONS: [], }; + const cameos: Cameo[] = []; let cursor: Phase = 'DEFENCES'; // first phase the engine emits into - for (const e of state.events) { + for (const e of events) { const advance = phaseAdvanceFor(e.kind); if (advance !== null) cursor = advance; + if (e.kind === 'DisparageCameo') { + cameos.push(e); + continue; + } if (isRenderable(e.kind)) byPhase[cursor].push(e); } - const continueLabel = game.outcome ? 'View Final Verdict' : 'Continue → Round Summary'; + const steps: PlaybackStep[] = []; + for (const phase of PHASE_ORDER) { + for (const g of groupPhaseEvents(byPhase[phase])) { + steps.push({ phase, event: g.event, count: g.count }); + } + } + + for (const cameo of cameos) { + const step = steps.find( + (s) => + (s.event.kind === 'ImpactPeople' || s.event.kind === 'ImpactInfrastructure') && + s.event.from === cameo.afterImpact.from && + s.event.target === cameo.afterImpact.to, + ); + // The engine caps cameos at one per round, so no step ever carries two. + if (step && !step.cameo) step.cameo = cameo; + } + + return steps; +} + +function burstFor(event: ResolutionEvent): { text: string; color: string } | null { + switch (event.kind) { + // Zero-damage guard (spec §6): burst and badge suppressed together. + case 'ImpactPeople': + return event.deaths > 0 ? { text: 'BOOM!', color: 'yellow' } : null; + case 'ImpactInfrastructure': + return event.factoriesDestroyed > 0 ? { text: 'WHAM!', color: 'yellow' } : null; + case 'MissileIntercepted': + return { text: 'INTERCEPTED!', color: 'cyan' }; + case 'FinalRetaliationTriggered': + return { text: 'EVERYTHING!', color: 'magenta' }; + default: + return null; + } +} + +function badgeFor(event: ResolutionEvent): { value: number; label: string } | null { + if (event.kind === 'ImpactPeople' && event.deaths > 0) { + return { value: event.deaths, label: 'M' }; + } + if (event.kind === 'ImpactInfrastructure' && event.factoriesDestroyed > 0) { + return { value: event.factoriesDestroyed, label: 'FACT' }; + } + return null; +} + +export default function Action({ state, dispatch }: ScreenProps) { + const game = state.game!; + const steps = useMemo(() => buildPlaybackSteps(state.events), [state.events]); + + const [idx, setIdx] = useState(0); + const [paused, setPaused] = useState(false); + const [fast, setFast] = useState(false); + + useEffect(() => { + if (paused) return; + const t = setTimeout(() => { + setIdx((i) => (i < steps.length - 1 ? i + 1 : i)); + }, fast ? DWELL_FAST_MS : DWELL_MS); + return () => clearTimeout(t); + }, [idx, paused, fast, steps.length]); + + const step: PlaybackStep | undefined = steps[idx]; + const isLast = idx === steps.length - 1; + const doneLabel = game.outcome ? 'TO THE VERDICT →' : 'TO THE NEWS →'; + + const text = step ? formatEventText(step.event, game, step.count) : null; + const actor = step ? actorOf(step.event) : undefined; + const receiver = step ? receiversOf(step.event)[0] : undefined; + const burst = step ? burstFor(step.event) : null; + const badge = step ? badgeFor(step.event) : null; + + const donePhases = step + ? PHASE_ORDER.filter((p, i) => i < PHASE_ORDER.indexOf(step.phase) && steps.some((s) => s.phase === p)) + .map((p) => PHASE_LABELS[p]) + : []; + + const flagFor = (id: LeaderId) => + isHuman(id) ? extractFlag(game.leaders[id].country) : undefined; return ( -
-
Round {game.round - 1} — events
- - - {PHASE_ORDER.map((phase) => { - const events = byPhase[phase]; - if (events.length === 0) return null; - return ( -
-

{PHASE_LABELS[phase]}

- {groupPhaseEvents(events).map((g, i) => ( - - ))} -
- ); - })} - - +
+
+
+ + ACTION +
+
+ + + +
+
+ +
+ PHASE_LABELS[p])} + current={step ? PHASE_LABELS[step.phase] : ''} + done={donePhases} + /> +
+ +
+ {step ? ( + <> + NOW {PHASE_LABELS[step.phase]} + {text ? `${text.icon} ${text.body}` : ''} + + ) : ( + An eerily quiet round. Nobody did anything. + )} +
+ +
+ + + {actor !== undefined && ( +
+ {/* entrance animation on an inner div so its fill-mode transform + never overrides the wrapper's ≤600px scale(0.7) */} +
+ +
{game.leaders[actor].name.toUpperCase()}
+
+
+ )} + {receiver !== undefined && ( +
+
+
+ + {step !== undefined && step.event.kind === 'LeaderEliminated' && ( +
+ ELIMINATED +
+ )} +
+
{game.leaders[receiver].name.toUpperCase()}
+
+
+ )} + + {text?.quote && ( +
+ {text.quote} +
+ )} + + {(burst !== null || badge !== null) && ( +
+
+ {burst !== null && ( +
+ {burst.text} +
+ )} + {badge !== null && } +
+
+ )} + + {step?.cameo && ( +
+ +
+ )} +
+ +
+ {steps.length === 0 ? ( +
+ +
+ ) : ( + <> +
+ {steps.map((_, i) => ( + + ))} +
+
{text?.quote ?? ''}
+
+ + + + Event {idx + 1} of {steps.length} + +
+ + )} +
); } diff --git a/tests/ui/Action.playback.test.tsx b/tests/ui/Action.playback.test.tsx new file mode 100644 index 0000000..b3ee79d --- /dev/null +++ b/tests/ui/Action.playback.test.tsx @@ -0,0 +1,214 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, fireEvent, render, screen } from '@testing-library/react'; +import Action, { buildPlaybackSteps } from '../../src/ui/screens/Action'; +import { formatEventText } from '../../src/ui/util/eventText'; +import { initialState } from '../../src/engine/state'; +import { initialUiState, uiReducer } from '../../src/ui/store'; +import type { UiState } from '../../src/ui/store'; +import type { ResolutionEvent } from '../../src/engine/types'; + +const EVENTS: ResolutionEvent[] = [ + { kind: 'DefenceDeployed', by: 'chump', type: 'shield' }, + { kind: 'FactoryBuilt', by: 'carnage' }, + { kind: 'MissileLaunched', from: 'chump', to: 'carnage', delivery: 'missile', warhead: 'large', targetType: 'people', attackerQuote: 'Bye bye.' }, + { kind: 'ImpactPeople', from: 'chump', target: 'carnage', warhead: 'large', deaths: 5, targetQuote: 'Rude.' }, +]; + +function makeState(events: ResolutionEvent[] = EVENTS, outcome = false): UiState { + const game = initialState({ + cast: ['player1', 'chump', 'carnage'], + difficulty: 'normal', + seed: 'action-playback-test', + }); + game.round = 2; // resolution has already incremented the round + if (outcome) game.outcome = { type: 'survivor', winner: 'chump' }; + return { + screen: 'action', + game, + events, + prevPopulations: {}, + initialPopulations: {}, + lastNewGameOpts: null, + pendingHumanOrders: {}, + }; +} + +describe(' playback', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('renders the step-0 banner, phase tag, and counter', () => { + const { container } = render(); + expect(screen.getByText('NOW DEFENCE')).toBeInTheDocument(); + expect(container.textContent).toContain('🇺🇸 Chump deploys a shield'); + expect(screen.getByText('Event 1 of 4')).toBeInTheDocument(); + expect(screen.getByText('ACTION')).toBeInTheDocument(); + }); + + it('renders the banner from a real resolved game state', () => { + const before: UiState = { + ...initialUiState, + screen: 'aiConferring', + game: initialState({ cast: ['chump', 'carnage'], difficulty: 'normal', seed: 'real-resolve' }), + }; + const ui = uiReducer(before, { type: 'AI_RESOLVE' }); + const steps = buildPlaybackSteps(ui.events); + expect(steps.length).toBeGreaterThan(0); + const first = formatEventText(steps[0].event, ui.game!, steps[0].count); + expect(first).not.toBeNull(); + const { container } = render(); + expect(container.textContent).toContain(first!.body); + }); + + it('auto-advances after 2700ms, and after 1100ms in fast mode', () => { + const { container } = render(); + act(() => { + vi.advanceTimersByTime(2699); + }); + expect(container.textContent).toContain('deploys a shield'); + act(() => { + vi.advanceTimersByTime(1); + }); + expect(container.textContent).toContain('builds 1 factory'); + // Fast mode: next dwell is 1100ms. + fireEvent.click(screen.getByText('⏭ FAST 3×')); + act(() => { + vi.advanceTimersByTime(1100); + }); + expect(screen.getByText('NOW LAUNCHES')).toBeInTheDocument(); + expect(container.textContent).toContain('🇺🇸 → 🇨🇦 (large · people)'); + }); + + it('halts while paused and resumes on RESUME', () => { + const { container } = render(); + fireEvent.click(screen.getByText('⏸ PAUSE')); + act(() => { + vi.advanceTimersByTime(20000); + }); + expect(container.textContent).toContain('deploys a shield'); + fireEvent.click(screen.getByText('▶ RESUME')); + act(() => { + vi.advanceTimersByTime(2700); + }); + expect(container.textContent).toContain('builds 1 factory'); + }); + + it('disables PREV at step 0; NEXT steps forward and PREV back', () => { + const { container } = render(); + const prev = screen.getByText('← PREV'); + expect(prev).toBeDisabled(); + fireEvent.click(screen.getByText('NEXT →')); + expect(container.textContent).toContain('builds 1 factory'); + expect(screen.getByText('Event 2 of 4')).toBeInTheDocument(); + expect(prev).not.toBeDisabled(); + fireEvent.click(prev); + expect(container.textContent).toContain('deploys a shield'); + }); + + it('shows TO THE NEWS → on the last step and dispatches ACTION_DONE on click', () => { + const dispatch = vi.fn(); + render(); + fireEvent.click(screen.getByText('NEXT →')); + fireEvent.click(screen.getByText('NEXT →')); + fireEvent.click(screen.getByText('NEXT →')); + const done = screen.getByText('TO THE NEWS →'); + expect(dispatch).not.toHaveBeenCalled(); + fireEvent.click(done); + expect(dispatch).toHaveBeenCalledTimes(1); + expect(dispatch).toHaveBeenCalledWith({ type: 'ACTION_DONE' }); + }); + + it('SKIP → dispatches ACTION_DONE from mid-playback', () => { + const dispatch = vi.fn(); + render(); + fireEvent.click(screen.getByText('SKIP →')); + expect(dispatch).toHaveBeenCalledWith({ type: 'ACTION_DONE' }); + }); + + it('labels the last-step exit TO THE VERDICT → when the game has an outcome', () => { + render(); + fireEvent.click(screen.getByText('NEXT →')); + fireEvent.click(screen.getByText('NEXT →')); + fireEvent.click(screen.getByText('NEXT →')); + expect(screen.getByText('TO THE VERDICT →')).toBeInTheDocument(); + }); + + it('shows the quiet-round copy and only a continue button when there are no steps', () => { + const dispatch = vi.fn(); + render(); + expect(screen.getByText('An eerily quiet round. Nobody did anything.')).toBeInTheDocument(); + expect(screen.queryByText('← PREV')).not.toBeInTheDocument(); + expect(screen.queryByText(/Event \d+ of/)).not.toBeInTheDocument(); + fireEvent.click(screen.getByText('TO THE NEWS →')); + expect(dispatch).toHaveBeenCalledWith({ type: 'ACTION_DONE' }); + }); + + it('renders actor and receiver name pills over the map', () => { + render(); + // Step 0 (DefenceDeployed): actor only. + expect(screen.getByText('CHUMP')).toBeInTheDocument(); + expect(screen.queryByText('CARNAGE')).not.toBeInTheDocument(); + fireEvent.click(screen.getByText('NEXT →')); + fireEvent.click(screen.getByText('NEXT →')); + // Step 2 (MissileLaunched): actor + receiver + attacker quote bubble. + expect(screen.getByText('CHUMP')).toBeInTheDocument(); + expect(screen.getByText('CARNAGE')).toBeInTheDocument(); + expect(screen.getAllByText('Bye bye.').length).toBeGreaterThan(0); + }); + + it('shows burst + damage badge on a damaging impact', () => { + render(); + fireEvent.click(screen.getByText('NEXT →')); + fireEvent.click(screen.getByText('NEXT →')); + fireEvent.click(screen.getByText('NEXT →')); + expect(screen.getByText('BOOM!')).toBeInTheDocument(); + expect(screen.getByText('−5')).toBeInTheDocument(); + expect(screen.getByText('M')).toBeInTheDocument(); + }); + + it('suppresses both burst and badge on a zero-damage impact', () => { + const events: ResolutionEvent[] = [ + { kind: 'ImpactInfrastructure', from: 'chump', target: 'carnage', warhead: 'small', factoriesDestroyed: 0 }, + ]; + render(); + expect(screen.queryByText('WHAM!')).not.toBeInTheDocument(); + expect(screen.queryByText('FACT')).not.toBeInTheDocument(); + expect(screen.queryByText('−0')).not.toBeInTheDocument(); + // The step itself still plays (banner + receiver highlight). + expect(screen.getByText('NOW LAUNCHES')).toBeInTheDocument(); + }); + + it('renders the FACT badge for infrastructure damage', () => { + const events: ResolutionEvent[] = [ + { kind: 'ImpactInfrastructure', from: 'chump', target: 'carnage', warhead: 'small', factoriesDestroyed: 2 }, + ]; + render(); + expect(screen.getByText('WHAM!')).toBeInTheDocument(); + expect(screen.getByText('−2')).toBeInTheDocument(); + expect(screen.getByText('FACT')).toBeInTheDocument(); + }); + + it('renders the Disparage cameo card on the step its impact matches', () => { + const events: ResolutionEvent[] = [ + { kind: 'ImpactPeople', from: 'chump', target: 'carnage', warhead: 'large', deaths: 5 }, + { kind: 'DisparageCameo', afterImpact: { from: 'chump', to: 'carnage' }, quote: 'Pint?' }, + ]; + render(); + expect(screen.getByText('Event 1 of 1')).toBeInTheDocument(); + expect(screen.getByText('🍺 FROM CLACTON')).toBeInTheDocument(); + expect(screen.getByText('Pint?')).toBeInTheDocument(); + }); + + it('stamps ELIMINATED across the receiver portrait on LeaderEliminated', () => { + const events: ResolutionEvent[] = [ + { kind: 'LeaderEliminated', id: 'carnage', quote: 'Elbows down.' }, + ]; + render(); + expect(screen.getByText('ELIMINATED')).toBeInTheDocument(); + expect(screen.getByText('CARNAGE')).toBeInTheDocument(); + }); +}); diff --git a/tests/ui/Action.playbackSteps.test.ts b/tests/ui/Action.playbackSteps.test.ts new file mode 100644 index 0000000..7826f1b --- /dev/null +++ b/tests/ui/Action.playbackSteps.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest'; +import { buildPlaybackSteps } from '../../src/ui/screens/Action'; +import type { ResolutionEvent } from '../../src/engine/types'; + +describe('buildPlaybackSteps', () => { + it('assigns phases via the cursor and flattens in PHASE_ORDER', () => { + const events: ResolutionEvent[] = [ + { kind: 'DefenceDeployed', by: 'chump', type: 'shield' }, + { kind: 'FactoryBuilt', by: 'carnage' }, + { kind: 'FactoryBuilt', by: 'carnage' }, + { kind: 'PropagandaTransfer', from: 'chump', to: 'carnage', amount: 1 }, + { kind: 'WooApplied', from: 'carnage', to: 'chump', points: 1 }, + { kind: 'MissileLaunched', from: 'chump', to: 'carnage', delivery: 'missile', warhead: 'large', targetType: 'people' }, + { kind: 'ImpactPeople', from: 'chump', target: 'carnage', warhead: 'large', deaths: 5 }, + { kind: 'LeaderEliminated', id: 'carnage' }, + { kind: 'FinalRetaliationTriggered', by: 'carnage', targets: ['chump'] }, + ]; + const steps = buildPlaybackSteps(events); + expect(steps.map((s) => [s.phase, s.event.kind, s.count])).toEqual([ + ['DEFENCES', 'DefenceDeployed', 1], + ['BUILDS', 'FactoryBuilt', 2], + ['PROPAGANDA', 'PropagandaTransfer', 1], + ['WOOING', 'WooApplied', 1], + ['LAUNCHES', 'MissileLaunched', 1], + ['LAUNCHES', 'ImpactPeople', 1], + ['LAUNCHES', 'LeaderEliminated', 1], // inherits the cursor's phase + ['FINAL_RETALIATIONS', 'FinalRetaliationTriggered', 1], + ]); + }); + + it('excludes every non-renderable kind', () => { + const events: ResolutionEvent[] = [ + { kind: 'OrdersSealed', leaderId: 'chump', orderCount: 2 }, + { kind: 'PreRoundMood', leaderId: 'chump', quote: 'Hm.', snapBack: false }, + { kind: 'DefenceConsumed', by: 'chump', type: 'shield' }, + { kind: 'PostRoundReaction', leaderId: 'chump', quote: 'Hm.' }, + { kind: 'OutcomeReached', outcome: { type: 'apocalypse' } }, + { kind: 'DisparageColumn', quote: 'Pint.', footer: 'Allegedly.' }, + ]; + expect(buildPlaybackSteps(events)).toEqual([]); + }); + + it('never emits a DisparageCameo step; the cameo rides on its matching impact step', () => { + const events: ResolutionEvent[] = [ + { kind: 'ImpactPeople', from: 'chump', target: 'carnage', warhead: 'large', deaths: 5 }, + { kind: 'DisparageCameo', afterImpact: { from: 'chump', to: 'carnage' }, quote: 'Pint?' }, + ]; + const steps = buildPlaybackSteps(events); + expect(steps).toHaveLength(1); + expect(steps[0].event.kind).toBe('ImpactPeople'); + expect(steps[0].cameo).toEqual({ + kind: 'DisparageCameo', + afterImpact: { from: 'chump', to: 'carnage' }, + quote: 'Pint?', + }); + }); + + it('attaches a cameo that followed a duplicate impact to the surviving collapsed step', () => { + const events: ResolutionEvent[] = [ + { kind: 'ImpactPeople', from: 'chump', target: 'carnage', warhead: 'small', deaths: 3 }, + { kind: 'ImpactPeople', from: 'starmless', target: 'carnage', warhead: 'small', deaths: 4 }, + // Duplicate (target, attacker) pair — groupPhaseEvents folds it into the first. + { kind: 'ImpactPeople', from: 'chump', target: 'carnage', warhead: 'small', deaths: 2 }, + { kind: 'DisparageCameo', afterImpact: { from: 'chump', to: 'carnage' }, quote: 'Shambles.' }, + ]; + const steps = buildPlaybackSteps(events); + expect(steps).toHaveLength(2); + // Summed deaths on the collapsed step, cameo attached there. + expect(steps[0].event).toMatchObject({ kind: 'ImpactPeople', from: 'chump', target: 'carnage', deaths: 5 }); + expect(steps[0].cameo?.quote).toBe('Shambles.'); + expect(steps[1].cameo).toBeUndefined(); + }); + + it('drops a cameo whose afterImpact pair matches no step', () => { + const events: ResolutionEvent[] = [ + { kind: 'ImpactPeople', from: 'chump', target: 'carnage', warhead: 'small', deaths: 3 }, + { kind: 'DisparageCameo', afterImpact: { from: 'starmless', to: 'chump' }, quote: 'Nobody asked.' }, + ]; + const steps = buildPlaybackSteps(events); + expect(steps).toHaveLength(1); + expect(steps.map((s) => s.cameo)).toEqual([undefined]); + }); + + it('groups non-adjacent builds per (leader, item) within a phase', () => { + const events: ResolutionEvent[] = [ + { kind: 'WarheadBuilt', by: 'chump', yield: 'small' }, + { kind: 'DeliveryBuilt', by: 'chump', type: 'missile' }, + { kind: 'WarheadBuilt', by: 'chump', yield: 'small' }, + ]; + const steps = buildPlaybackSteps(events); + expect(steps.map((s) => [s.event.kind, s.count])).toEqual([ + ['WarheadBuilt', 2], + ['DeliveryBuilt', 1], + ]); + }); +}); From 971f69db2cc722b276f01fd7e5e4bb25b1ef7523 Mon Sep 17 00:00:00 2001 From: gethi Date: Sun, 19 Jul 2026 15:34:56 +0100 Subject: [PATCH 27/43] feat: newspaper.ts derivation module for the tabloid RoundSummary Pure section models (forecast tiers, market ticker, box score, news stories with lead selection + DOM ordering, photo caption, corrections/ classifieds pools) computed from live GameState + ResolutionEvents. Copy lifted from the handoff data.jsx as content; nothing imported. Co-Authored-By: Claude Fable 5 --- src/ui/util/newspaper.ts | 435 +++++++++++++++++++++++++++++++++++++ tests/ui/newspaper.test.ts | 291 +++++++++++++++++++++++++ 2 files changed, 726 insertions(+) create mode 100644 src/ui/util/newspaper.ts create mode 100644 tests/ui/newspaper.test.ts diff --git a/src/ui/util/newspaper.ts b/src/ui/util/newspaper.ts new file mode 100644 index 0000000..0727b72 --- /dev/null +++ b/src/ui/util/newspaper.ts @@ -0,0 +1,435 @@ +/** + * newspaper.ts — pure derivation for the RoundSummary tabloid front page. + * + * Typed section models (forecast, market, box score, news stories, photo + * caption, corrections) computed from live game state + the round's + * ResolutionEvents. No React. The handoff's NEWS_* arrays in + * design_handoff_nuke_game/data.jsx are shape+copy reference only — nothing + * is imported from the prototype. + */ +import type { GameState, LeaderId, ResolutionEvent } from '../../engine/types'; +import { stripFlag } from '../portraits'; + +export type BadgeKind = 'harm' | 'gain' | 'neutral'; + +/* ============================================================ + * THE FORECAST + * ============================================================ */ + +export interface ForecastRow { + label: string; + value: string; +} + +export interface Forecast { + outlook: string; + temp: string; + tempLabel: string; + /** Number of ☢ glyphs to render for the UV index. */ + uv: number; + rows: ForecastRow[]; +} + +/** + * Fallout tiers by `thisRoundLost`. Deliberate divergence from the mock: + * data.jsx pairs HEAVY with uv 5; this ladder reserves UV 5 for BIBLICAL so + * the scale has headroom (spec §4.1). Do not "fix" it back. + */ +export function deriveForecast(thisRoundLost: number): Forecast { + const tier = + thisRoundLost === 0 ? { outlook: 'FALLOUT: NONE', temp: '20°', tempLabel: 'seasonal, suspicious', uv: 1, fallout: 'None reported' } : + thisRoundLost <= 5 ? { outlook: 'FALLOUT: LIGHT', temp: '400°', tempLabel: 'localised high', uv: 2, fallout: 'Light, drifting east' } : + thisRoundLost <= 14 ? { outlook: 'FALLOUT: HEAVY', temp: '1,200°', tempLabel: 'ground zero high', uv: 4, fallout: 'Heavy, drifting east' } : + { outlook: 'FALLOUT: BIBLICAL', temp: '5,800°', tempLabel: 'surface of the sun, briefly', uv: 5, fallout: 'Total, drifting everywhere' }; + + const lost = thisRoundLost > 0; + return { + outlook: tier.outlook, + temp: tier.temp, + tempLabel: tier.tempLabel, + uv: tier.uv, + rows: [ + { label: 'Fallout', value: tier.fallout }, + { label: 'Visibility', value: lost ? 'Nil to 200 yards' : 'Unlimited. For now.' }, + { label: 'Wind', value: lost ? 'Mushroom-shaped' : 'Light breeze' }, + { label: 'Outlook', value: 'Worse. Always worse.' }, + ], + }; +} + +/* ============================================================ + * MARKET REPORT + * ============================================================ */ + +export interface MarketRow { + id: LeaderId; + sym: string; + arrow: '▲' | '▬' | '▼'; + /** Signed rounded percent; render with Math.abs. */ + change: number; + note: string; +} + +const AI_SYMS: Partial> = { + chump: 'USA', + khameneverhere: 'IRN', + starmless: 'UK', + carnage: 'CAN', + 'mileigh-hem': 'ARG', + netanyahoo: 'ISR', +}; + +function tickerSym(id: LeaderId, country: string): string { + // Humans can enter any country name, so the deterministic first-3-chars + // rule wins over hand-tuned abbreviations ('🦆 Freedonia' → FRE, not the + // mock's FRD — spec §4.2 deliberate divergence). + return AI_SYMS[id] ?? stripFlag(country).toUpperCase().slice(0, 3); +} + +/** One ticker row per cast member, including the dead. */ +export function deriveMarket( + game: GameState, + prevPopulations: Partial>, +): MarketRow[] { + return game.cast.map((id) => { + const leader = game.leaders[id]; + const prev = prevPopulations[id] ?? 0; + const change = prev > 0 ? Math.round(((leader.population - prev) / prev) * 100) : 0; + const note = + !leader.alive ? 'delisted' : + change <= -20 ? 'clobbered' : + change < 0 ? 'down' : + change === 0 ? 'holds' : 'up'; + return { + id, + sym: tickerSym(id, leader.country), + arrow: change > 0 ? '▲' : change < 0 ? '▼' : '▬', + change, + note, + }; + }); +} + +/* ============================================================ + * TONIGHT'S EXCHANGES (box score) + * ============================================================ */ + +export interface BoxScoreRow { + a: string; + b: string; + score: string; + tone: 'harm' | 'good'; +} + +export const BOX_SCORE_EMPTY = 'No exchanges. The censors are baffled.'; + +function shortName(name: string): string { + return name.length > 9 ? `${name.slice(0, 9)}.` : name; +} + +/** + * One row per qualifying event, in event order. Intercept rows render + * `{attacker} › {defender}` (spec §4.3 deliberate divergence from the mock's + * `{defender} › —` form). + */ +export function deriveBoxScore( + events: ResolutionEvent[], + leaders: GameState['leaders'], +): BoxScoreRow[] { + const rows: BoxScoreRow[] = []; + for (const e of events) { + if (e.kind === 'ImpactPeople') { + rows.push({ + a: shortName(leaders[e.from].name), + b: shortName(leaders[e.target].name), + score: `−${e.deaths}M`, + tone: 'harm', + }); + } else if (e.kind === 'ImpactInfrastructure') { + rows.push({ + a: shortName(leaders[e.from].name), + b: shortName(leaders[e.target].name), + score: `−${e.factoriesDestroyed} fac`, + tone: 'harm', + }); + } else if (e.kind === 'MissileIntercepted') { + rows.push({ + a: shortName(leaders[e.from].name), + b: shortName(leaders[e.to].name), + score: 'INTERCEPT', + tone: 'good', + }); + } + } + return rows; +} + +/* ============================================================ + * NEWS STORIES — "FROM AROUND THE RUBBLE" + * ============================================================ */ + +export interface StoryBadge { + kind: BadgeKind; + text: string; +} + +export interface NewsStory { + id: LeaderId; + lead: boolean; + kicker: string; + headline: string; + /** Flag-stripped country for "by our {country} correspondent". */ + bylineCountry: string; + delta: { text: string; kind: BadgeKind }; + body: string; + pullquote?: string; + badges: StoryBadge[]; +} + +const AI_KICKERS: Partial> = { + chump: 'MAR-A-LAGO', + carnage: 'PARLIAMENT HILL', + khameneverhere: 'LOCATION UNDISCLOSED', + netanyahoo: 'THE KIRYA, TEL AVIV', + starmless: 'WESTMINSTER', + 'mileigh-hem': 'CASA ROSADA', +}; + +interface RoundFacts { + popDelta: number; + peopleLost: number; + attackers: LeaderId[]; + factoriesLost: number; + interceptedAsDefender: boolean; + launches: number; + propagandaReceived: number; + propagandaSent: number; + factoryBuilt: boolean; + wooTarget: LeaderId | null; +} + +function collectFacts( + id: LeaderId, + events: ResolutionEvent[], + cur: number, + prev: number | undefined, +): RoundFacts { + const facts: RoundFacts = { + popDelta: prev === undefined ? 0 : cur - prev, + peopleLost: 0, + attackers: [], + factoriesLost: 0, + interceptedAsDefender: false, + launches: 0, + propagandaReceived: 0, + propagandaSent: 0, + factoryBuilt: false, + wooTarget: null, + }; + for (const e of events) { + switch (e.kind) { + case 'ImpactPeople': + if (e.target === id) { + facts.peopleLost += e.deaths; + if (!facts.attackers.includes(e.from)) facts.attackers.push(e.from); + } + break; + case 'ImpactInfrastructure': + if (e.target === id) facts.factoriesLost += e.factoriesDestroyed; + break; + case 'MissileIntercepted': + if (e.to === id) facts.interceptedAsDefender = true; + break; + case 'MissileLaunched': + if (e.from === id) facts.launches += 1; + break; + case 'PropagandaTransfer': + if (e.to === id) facts.propagandaReceived += e.amount; + if (e.from === id) facts.propagandaSent += e.amount; + break; + case 'FactoryBuilt': + if (e.by === id) facts.factoryBuilt = true; + break; + case 'WooApplied': + if (e.from === id && facts.wooTarget === null) facts.wooTarget = e.to; + break; + } + } + return facts; +} + +/** Headline template by the leader's dominant round condition — first match wins (spec §3.4). */ +function storyHeadline(name: string, f: RoundFacts): string { + const NAME = name.toUpperCase(); + if (f.popDelta <= -10) return `${NAME} DIGS OUT, BLAMES EVERYONE`; + if (f.popDelta < 0) return `${NAME} SHRUGS OFF LIGHT VAPORISATION`; + if (f.launches > 0) return `${NAME} PRESSES BUTTON, FEELS BETTER`; + if (f.interceptedAsDefender) return `DEFENCES HOLD; ${NAME} INSUFFERABLE`; + if (f.factoryBuilt) return 'GROUNDBREAKING CEREMONY HELD'; + if (f.propagandaSent > 0) return 'LEAFLETS AWAY; NEIGHBOURS FURIOUS'; + if (f.wooTarget !== null) return `${NAME} SPOTTED BEING NICE; MOTIVE UNKNOWN`; + return `${NAME} DOES NOTHING, CALLS IT STRATEGY`; +} + +/** 1–3 sentences, in fixed precedence order, from categories present this round. */ +function storyBody(f: RoundFacts, leaders: GameState['leaders']): string { + const sentences: string[] = []; + if (f.peopleLost > 0) { + sentences.push( + f.attackers.length === 1 + ? `A strike from ${leaders[f.attackers[0]].name} removed ${f.peopleLost} million citizens from the census.` + : `A strike removed ${f.peopleLost} million citizens from the census.`, + ); + } + if (f.factoriesLost > 0) { + sentences.push( + f.factoriesLost === 1 + ? '1 factory now qualifies as open-plan.' + : `${f.factoriesLost} factories now qualify as open-plan.`, + ); + } + if (f.interceptedAsDefender) { + sentences.push('Air defences held; one incoming warhead retired over open water.'); + } + if (f.launches > 0) { + sentences.push( + f.launches === 1 + ? '1 launch order was filed and, regrettably, executed.' + : `${f.launches} launch orders were filed and, regrettably, executed.`, + ); + } + if (f.propagandaReceived > 0) { + sentences.push(`${f.propagandaReceived} million citizens defected after reading a leaflet.`); + } + if (f.propagandaSent > 0) { + sentences.push(`Leaflet drops persuaded ${f.propagandaSent} million foreigners to relocate.`); + } + if (f.factoryBuilt) { + sentences.push('Ground was broken on a new factory.'); + } + if (f.wooTarget !== null) { + sentences.push(`Diplomatic flowers were dispatched to ${leaders[f.wooTarget].name}.`); + } + if (sentences.length === 0) { + return 'No orders of consequence were filed. Analysts describe the restraint as "suspicious."'; + } + return sentences.slice(0, 3).join(' '); +} + +/** + * One story per living cast member. The lead (largest population loss this + * round; cast-order tiebreak; first living leader when nobody lost) is + * ordered FIRST so its `column-span: all` block doesn't split the 2-column + * flow mid-stream (spec §3.4 DOM order). + */ +export function deriveStories( + game: GameState, + events: ResolutionEvent[], + prevPopulations: Partial>, +): NewsStory[] { + const living = game.cast.filter((id) => game.leaders[id].alive); + if (living.length === 0) return []; + + let leadId = living[0]; + let leadLoss = 0; + for (const id of living) { + const prev = prevPopulations[id]; + const loss = prev === undefined ? 0 : Math.max(0, prev - game.leaders[id].population); + if (loss > leadLoss) { + leadLoss = loss; + leadId = id; + } + } + + const ordered = [leadId, ...living.filter((id) => id !== leadId)]; + return ordered.map((id) => { + const leader = game.leaders[id]; + const prev = prevPopulations[id]; + const cur = leader.population; + const facts = collectFacts(id, events, cur, prev); + const reaction = events.find( + (e): e is Extract => + e.kind === 'PostRoundReaction' && e.leaderId === id, + ); + + const delta: NewsStory['delta'] = + prev !== undefined && prev !== cur + ? { text: `${prev}M → ${cur}M`, kind: cur < prev ? 'harm' : 'gain' } + : { text: `${cur}M`, kind: 'neutral' }; + + const badges: StoryBadge[] = []; + if (facts.popDelta < 0) badges.push({ kind: 'harm', text: `−${-facts.popDelta}M citizens` }); + if (facts.popDelta > 0) badges.push({ kind: 'gain', text: `+${facts.popDelta}M citizens` }); + if (facts.factoriesLost > 0) badges.push({ kind: 'harm', text: `−${facts.factoriesLost} factories` }); + if (facts.factoryBuilt) badges.push({ kind: 'gain', text: '+1 factory' }); + if (leader.apBanked > 0) badges.push({ kind: 'neutral', text: `banked ${leader.apBanked} AP` }); + + return { + id, + lead: id === leadId, + kicker: AI_KICKERS[id] ?? stripFlag(leader.country).toUpperCase(), + headline: storyHeadline(leader.name, facts), + bylineCountry: stripFlag(leader.country), + delta, + body: storyBody(facts, game.leaders), + pullquote: reaction?.quote, + badges, + }; + }); +} + +/* ============================================================ + * PHOTO CAPTION + * ============================================================ */ + +/** + * Caption for the mushroom-cloud "photo". Uses the same summed + * attacker→target pairing logic as RoundSummary's pickSubhead. + */ +export function derivePhotoCaption( + events: ResolutionEvent[], + leaders: GameState['leaders'], +): string { + const pairs = new Map(); + for (const e of events) { + if (e.kind !== 'ImpactPeople') continue; + const k = `${e.from}|${e.target}`; + const cur = pairs.get(k); + if (cur) cur.deaths += e.deaths; + else pairs.set(k, { target: e.target, deaths: e.deaths }); + } + const all = [...pairs.values()]; + if (all.length === 0) { + return 'File photo of a previous detonation. Nothing exploded tonight. Cherish it.'; + } + let biggest = all[0]; + for (const p of all) if (p.deaths > biggest.deaths) biggest = p; + const country = stripFlag(leaders[biggest.target].country); + return `Eyewitness sketch of the impact zone over ${country}. Artist's impression.`; +} + +/* ============================================================ + * CORRECTIONS & CLASSIFIEDS (static pools, copy lifted from data.jsx) + * ============================================================ */ + +export const CORRECTIONS: readonly string[] = [ + 'CORRECTION: Yesterday we reported 14M dead. It was 15M. We regret the optimism.', + "CORRECTION: Mr Chump was described as 'a stable genius.' This was his description.", + 'CORRECTION: The duck was, in fact, nuclear. We apologise to the duck.', +]; + +export function pickCorrection(reportedRound: number): string { + return CORRECTIONS[(reportedRound - 1) % CORRECTIONS.length]; +} + +export interface Classified { + tag: string; + text: string; +} + +export const CLASSIFIEDS: readonly Classified[] = [ + { tag: 'FOR SALE', text: 'One (1) missile shield, barely used. Buyer collects from crater.' }, + { tag: 'WANTED', text: 'Delivery system for Large warhead. Will not fly itself, apparently.' }, + { tag: 'LOST', text: "Iran's signed orders. Last seen never. Reward: plausible deniability." }, + { tag: 'PERSONAL', text: 'Lonely glass cannon seeks 100% aggression. ¡Viva la libertad, carajo!' }, +]; diff --git a/tests/ui/newspaper.test.ts b/tests/ui/newspaper.test.ts new file mode 100644 index 0000000..f6f9fec --- /dev/null +++ b/tests/ui/newspaper.test.ts @@ -0,0 +1,291 @@ +import { describe, it, expect } from 'vitest'; +import { + deriveForecast, + deriveMarket, + deriveBoxScore, + deriveStories, + derivePhotoCaption, + pickCorrection, + CORRECTIONS, +} from '../../src/ui/util/newspaper'; +import { initialState } from '../../src/engine/state'; +import type { LeaderId, ResolutionEvent } from '../../src/engine/types'; + +function makeGame(cast: LeaderId[]) { + return initialState({ cast, difficulty: 'normal', seed: 'newspaper-test' }); +} + +describe('deriveForecast', () => { + it('maps casualty tiers to outlook and UV count at the boundaries', () => { + const cases: Array<[number, string, number]> = [ + [0, 'FALLOUT: NONE', 1], + [1, 'FALLOUT: LIGHT', 2], + [5, 'FALLOUT: LIGHT', 2], + [6, 'FALLOUT: HEAVY', 4], + [14, 'FALLOUT: HEAVY', 4], + [15, 'FALLOUT: BIBLICAL', 5], + ]; + for (const [lost, outlook, uv] of cases) { + const f = deriveForecast(lost); + expect(f.outlook).toBe(outlook); + expect(f.uv).toBe(uv); + } + }); + + it('reports quiet conditions when nothing was lost', () => { + const f = deriveForecast(0); + expect(f.temp).toBe('20°'); + expect(f.rows).toEqual([ + { label: 'Fallout', value: 'None reported' }, + { label: 'Visibility', value: 'Unlimited. For now.' }, + { label: 'Wind', value: 'Light breeze' }, + { label: 'Outlook', value: 'Worse. Always worse.' }, + ]); + }); + + it('reports fallout conditions when people were lost', () => { + const f = deriveForecast(15); + expect(f.temp).toBe('5,800°'); + expect(f.tempLabel).toBe('surface of the sun, briefly'); + expect(f.rows).toEqual([ + { label: 'Fallout', value: 'Total, drifting everywhere' }, + { label: 'Visibility', value: 'Nil to 200 yards' }, + { label: 'Wind', value: 'Mushroom-shaped' }, + { label: 'Outlook', value: 'Worse. Always worse.' }, + ]); + }); +}); + +describe('deriveMarket', () => { + it('computes rounded percent change from prevPopulations', () => { + const game = makeGame(['player1', 'chump', 'starmless']); + game.leaders.chump.population = 18; + const rows = deriveMarket(game, { chump: 33, starmless: 25 }); + const chump = rows.find((r) => r.id === 'chump'); + expect(chump).toBeDefined(); + // (18 - 33) / 33 * 100 = -45.45… → -45 + expect(chump!.change).toBe(-45); + expect(chump!.arrow).toBe('▼'); + expect(chump!.note).toBe('clobbered'); + }); + + it('flat-lines with ▬ 0% when prev is undefined', () => { + const game = makeGame(['player1', 'chump']); + const rows = deriveMarket(game, {}); + for (const row of rows) { + expect(row.change).toBe(0); + expect(row.arrow).toBe('▬'); + expect(row.note).toBe('holds'); + } + }); + + it('marks dead leaders as delisted and includes them in the listing', () => { + const game = makeGame(['player1', 'chump', 'carnage']); + game.leaders.carnage.alive = false; + game.leaders.carnage.population = 0; + const rows = deriveMarket(game, { carnage: 25 }); + expect(rows).toHaveLength(3); + const carnage = rows.find((r) => r.id === 'carnage'); + expect(carnage).toBeDefined(); + expect(carnage!.note).toBe('delisted'); + }); + + it('grades down / holds / up notes by change sign', () => { + const game = makeGame(['chump', 'starmless', 'mileigh-hem']); + game.leaders.starmless.population = 22; // up from 20 + game.leaders['mileigh-hem'].population = 21; // down from 22 + const rows = deriveMarket(game, { + chump: game.leaders.chump.population, + starmless: 20, + 'mileigh-hem': 22, + }); + expect(rows.find((r) => r.id === 'chump')!.note).toBe('holds'); + const up = rows.find((r) => r.id === 'starmless')!; + expect(up.arrow).toBe('▲'); + expect(up.note).toBe('up'); + const down = rows.find((r) => r.id === 'mileigh-hem')!; + expect(down.arrow).toBe('▼'); + expect(down.note).toBe('down'); + }); + + it('derives human ticker symbols from the flag-stripped country (first 3 chars)', () => { + const game = makeGame(['player1', 'chump', 'starmless']); + const rows = deriveMarket(game, {}); + // player1's default country is '🦆 Freedonia' → FRE (rule, not the mock's FRD) + expect(rows.find((r) => r.id === 'player1')!.sym).toBe('FRE'); + expect(rows.find((r) => r.id === 'chump')!.sym).toBe('USA'); + expect(rows.find((r) => r.id === 'starmless')!.sym).toBe('UK'); + }); +}); + +describe('deriveBoxScore', () => { + const game = makeGame(['player1', 'chump', 'carnage', 'khameneverhere']); + + it('maps ImpactPeople, ImpactInfrastructure and MissileIntercepted rows in event order', () => { + const events: ResolutionEvent[] = [ + { kind: 'ImpactPeople', from: 'carnage', target: 'chump', warhead: 'large', deaths: 6 }, + { kind: 'ImpactInfrastructure', from: 'chump', target: 'carnage', warhead: 'small', factoriesDestroyed: 2 }, + { kind: 'MissileIntercepted', from: 'khameneverhere', to: 'chump', delivery: 'missile', warhead: 'medium' }, + ]; + const rows = deriveBoxScore(events, game.leaders); + expect(rows).toEqual([ + { a: 'Carnage', b: 'Chump', score: '−6M', tone: 'harm' }, + { a: 'Chump', b: 'Carnage', score: '−2 fac', tone: 'harm' }, + { a: 'Khameneve.', b: 'Chump', score: 'INTERCEPT', tone: 'good' }, + ]); + }); + + it('truncates names longer than 9 characters to the first 9 chars plus a dot', () => { + const events: ResolutionEvent[] = [ + { kind: 'ImpactPeople', from: 'khameneverhere', target: 'chump', warhead: 'small', deaths: 2 }, + ]; + const rows = deriveBoxScore(events, game.leaders); + // 'Khameneverhere' → first 9 chars 'Khameneve' + '.' + expect(rows[0].a).toBe('Khameneve.'); + }); + + it('returns no rows when nothing qualifies (empty state)', () => { + const events: ResolutionEvent[] = [ + { kind: 'FactoryBuilt', by: 'chump' }, + { kind: 'PostRoundReaction', leaderId: 'chump', quote: 'fine.' }, + ]; + expect(deriveBoxScore(events, game.leaders)).toEqual([]); + }); +}); + +describe('deriveStories', () => { + it('gives DIGS OUT priority over launch headlines when a leader lost ≥10M and launched', () => { + const game = makeGame(['player1', 'chump', 'carnage']); + game.leaders.chump.population = 18; + const events: ResolutionEvent[] = [ + { kind: 'MissileLaunched', from: 'chump', to: 'carnage', delivery: 'missile', warhead: 'large', targetType: 'people' }, + { kind: 'ImpactPeople', from: 'carnage', target: 'chump', warhead: 'large', deaths: 15 }, + ]; + const stories = deriveStories(game, events, { player1: game.leaders.player1.population, chump: 33, carnage: game.leaders.carnage.population }); + const chump = stories.find((s) => s.id === 'chump'); + expect(chump).toBeDefined(); + expect(chump!.headline).toBe('CHUMP DIGS OUT, BLAMES EVERYONE'); + }); + + it('gives an idle leader the DOES NOTHING headline and the restraint body line', () => { + const game = makeGame(['player1', 'carnage']); + const stories = deriveStories(game, [], { + player1: game.leaders.player1.population, + carnage: game.leaders.carnage.population, + }); + const carnage = stories.find((s) => s.id === 'carnage'); + expect(carnage).toBeDefined(); + expect(carnage!.headline).toBe('CARNAGE DOES NOTHING, CALLS IT STRATEGY'); + expect(carnage!.body).toBe('No orders of consequence were filed. Analysts describe the restraint as "suspicious."'); + }); + + it('selects the largest loser as lead and orders it first, rest in cast order', () => { + const game = makeGame(['player1', 'chump', 'carnage']); + game.leaders.chump.population = 23; // lost 10 + game.leaders.carnage.population = 21; // lost 4 + const stories = deriveStories(game, [], { player1: game.leaders.player1.population, chump: 33, carnage: 25 }); + expect(stories.map((s) => s.id)).toEqual(['chump', 'player1', 'carnage']); + expect(stories[0].lead).toBe(true); + expect(stories[1].lead).toBe(false); + expect(stories[2].lead).toBe(false); + }); + + it('falls back to the first living cast member as lead when nobody lost population', () => { + const game = makeGame(['player1', 'chump', 'carnage']); + const prev = { + player1: game.leaders.player1.population, + chump: game.leaders.chump.population, + carnage: game.leaders.carnage.population, + }; + const stories = deriveStories(game, [], prev); + expect(stories.map((s) => s.id)).toEqual(['player1', 'chump', 'carnage']); + expect(stories[0].lead).toBe(true); + }); + + it('excludes dead leaders from the story flow', () => { + const game = makeGame(['player1', 'chump', 'carnage']); + game.leaders.chump.alive = false; + game.leaders.chump.population = 0; + const stories = deriveStories(game, [], {}); + expect(stories.map((s) => s.id)).toEqual(['player1', 'carnage']); + }); + + it('uses static AI datelines and flag-stripped uppercase country for humans', () => { + const game = makeGame(['player1', 'chump', 'khameneverhere']); + const stories = deriveStories(game, [], {}); + expect(stories.find((s) => s.id === 'chump')!.kicker).toBe('MAR-A-LAGO'); + expect(stories.find((s) => s.id === 'khameneverhere')!.kicker).toBe('LOCATION UNDISCLOSED'); + expect(stories.find((s) => s.id === 'player1')!.kicker).toBe('FREEDONIA'); + }); + + it('names a single attacker in the strike body line and caps the body at three sentences', () => { + const game = makeGame(['player1', 'chump', 'carnage']); + game.leaders.chump.population = 27; + game.leaders.chump.factories = 1; + const events: ResolutionEvent[] = [ + { kind: 'ImpactPeople', from: 'carnage', target: 'chump', warhead: 'medium', deaths: 6 }, + { kind: 'ImpactInfrastructure', from: 'carnage', target: 'chump', warhead: 'small', factoriesDestroyed: 1 }, + { kind: 'MissileIntercepted', from: 'player1', to: 'chump', delivery: 'missile', warhead: 'small' }, + { kind: 'MissileLaunched', from: 'chump', to: 'carnage', delivery: 'missile', warhead: 'small', targetType: 'people' }, + ]; + const story = deriveStories(game, events, { chump: 33 }).find((s) => s.id === 'chump'); + expect(story).toBeDefined(); + expect(story!.body).toBe( + 'A strike from Carnage removed 6 million citizens from the census. ' + + '1 factory now qualifies as open-plan. ' + + 'Air defences held; one incoming warhead retired over open water.', + ); + }); + + it('reports byline deltas and badges from population change and events', () => { + const game = makeGame(['player1', 'chump']); + game.leaders.chump.population = 27; + game.leaders.chump.apBanked = 2; + const events: ResolutionEvent[] = [ + { kind: 'ImpactPeople', from: 'player1', target: 'chump', warhead: 'medium', deaths: 6 }, + { kind: 'FactoryBuilt', by: 'chump' }, + { kind: 'PostRoundReaction', leaderId: 'chump', quote: 'Tremendous.' }, + ]; + const story = deriveStories(game, events, { chump: 33 }).find((s) => s.id === 'chump'); + expect(story).toBeDefined(); + expect(story!.delta).toEqual({ text: '33M → 27M', kind: 'harm' }); + expect(story!.pullquote).toBe('Tremendous.'); + expect(story!.badges).toEqual([ + { kind: 'harm', text: '−6M citizens' }, + { kind: 'gain', text: '+1 factory' }, + { kind: 'neutral', text: 'banked 2 AP' }, + ]); + }); +}); + +describe('derivePhotoCaption', () => { + const game = makeGame(['player1', 'chump', 'carnage']); + + it('names the target country of the biggest summed attacker→target pairing', () => { + const events: ResolutionEvent[] = [ + { kind: 'ImpactPeople', from: 'carnage', target: 'chump', warhead: 'large', deaths: 9 }, + { kind: 'ImpactPeople', from: 'carnage', target: 'player1', warhead: 'medium', deaths: 4 }, + { kind: 'ImpactPeople', from: 'carnage', target: 'player1', warhead: 'medium', deaths: 6 }, + ]; + // carnage→player1 sums to 10, beating carnage→chump's 9. + expect(derivePhotoCaption(events, game.leaders)).toBe( + "Eyewitness sketch of the impact zone over Freedonia. Artist's impression.", + ); + }); + + it('falls back to the file-photo line when nobody died', () => { + expect(derivePhotoCaption([], game.leaders)).toBe( + 'File photo of a previous detonation. Nothing exploded tonight. Cherish it.', + ); + }); +}); + +describe('pickCorrection', () => { + it('rotates through the three corrections by reported round', () => { + expect(CORRECTIONS).toHaveLength(3); + expect(pickCorrection(1)).toBe(CORRECTIONS[0]); + expect(pickCorrection(2)).toBe(CORRECTIONS[1]); + expect(pickCorrection(3)).toBe(CORRECTIONS[2]); + expect(pickCorrection(4)).toBe(CORRECTIONS[0]); + }); +}); From 68df9e079647256a613a14e389fc0fd463ff867f Mon Sep 17 00:00:00 2001 From: gethi Date: Sun, 19 Jul 2026 15:36:47 +0100 Subject: [PATCH 28/43] feat: MushroomCloudPhoto SVG photo frame Stylised mushroom-cloud photo lifted from the handoff screens-3.jsx, with per-instance gradient/pattern ids (useId), optional magenta stamp and caption bar. Replaces the P4a placeholder in the next commit. Co-Authored-By: Claude Fable 5 --- src/ui/components/MushroomCloudPhoto.tsx | 98 ++++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 src/ui/components/MushroomCloudPhoto.tsx diff --git a/src/ui/components/MushroomCloudPhoto.tsx b/src/ui/components/MushroomCloudPhoto.tsx new file mode 100644 index 0000000..9b85931 --- /dev/null +++ b/src/ui/components/MushroomCloudPhoto.tsx @@ -0,0 +1,98 @@ +/** + * MushroomCloudPhoto — the RoundSummary's stylised mushroom-cloud "photo" + * frame. SVG lifted from design_handoff_nuke_game/screens-3.jsx; gradient and + * pattern ids are unique per instance (useId) so the component is safe to + * reuse on one page. + */ +import { useId } from 'react'; +import Stamp from './comic/Stamp'; + +export interface MushroomCloudPhotoProps { + /** Rendered as a magenta stamp top-right when set (e.g. '−15M'). */ + stampText?: string; + caption: string; +} + +const RAYS = Array.from({ length: 18 }, (_, i) => { + const a = (i / 18) * Math.PI * 2; + return { + x1: 200 + Math.cos(a) * 50, + y1: 100 + Math.sin(a) * 30, + x2: 200 + Math.cos(a) * 200, + y2: 100 + Math.sin(a) * 130, + }; +}); + +export default function MushroomCloudPhoto({ stampText, caption }: MushroomCloudPhotoProps) { + const uid = useId(); + const boomId = `boom-${uid}`; + const halftoneId = `halftone-${uid}`; + + return ( +
+ + {stampText !== undefined && ( +
+ {stampText} +
+ )} +
+ {caption} +
+
+ ); +} From 10a44a87f4f06be96169237fe5976a893d6199ff Mon Sep 17 00:00:00 2001 From: gethi Date: Sun, 19 Jul 2026 15:42:21 +0100 Subject: [PATCH 29/43] feat: RoundSummary re-themed as the tabloid front page Masthead + rule line, giant headline with casualty stamp, mushroom-cloud photo with derived caption, 4-across casualty strip, FROM AROUND THE RUBBLE story flow (lead first), data-driven sidebar (forecast, market, box score, obituaries, classifieds, ad), rotating corrections box and ink continue bar. DisparageColumn restyled as a .side-sec with the disparage portrait; pickHeadline/pickSubhead and all dispatch wiring preserved. Render tests cover masthead, strip values, corrections rotation, Disparage conditional and the NEXT_ROUND button. Co-Authored-By: Claude Fable 5 --- src/ui/components/DisparageColumn.module.css | 64 ++- src/ui/components/DisparageColumn.tsx | 17 +- src/ui/screens/RoundSummary.module.css | 570 ++++++++++++++++--- src/ui/screens/RoundSummary.tsx | 317 ++++++++--- tests/ui/RoundSummary.render.test.tsx | 96 ++++ 5 files changed, 890 insertions(+), 174 deletions(-) create mode 100644 tests/ui/RoundSummary.render.test.tsx diff --git a/src/ui/components/DisparageColumn.module.css b/src/ui/components/DisparageColumn.module.css index 3de3f26..fe82675 100644 --- a/src/ui/components/DisparageColumn.module.css +++ b/src/ui/components/DisparageColumn.module.css @@ -1,42 +1,52 @@ -.column { - background: #fff; - border: 1px solid #d0c8b8; - border-top: 4px solid #b02a37; - padding: 12px 14px; - margin: 16px 0; +/* Disparage Column — a RoundSummary sidebar .side-sec (slice 4). */ + +.sideSec { + padding: 12px; + background: var(--paper-warm); + border: 2px solid var(--ink); + box-shadow: 3px 3px 0 var(--ink); + margin-bottom: 16px; + color: var(--ink); } -.header { - font-family: 'Times New Roman', serif; - font-size: 14px; - font-weight: 700; +.head { + font-family: var(--font-display); + font-size: 12px; + letter-spacing: 0.16em; + border-top: 3px double var(--ink); + border-bottom: 3px double var(--ink); + padding: 5px 0; + text-align: center; + margin-bottom: 10px; text-transform: uppercase; - letter-spacing: 0.02em; - color: #1a1a1a; - margin-bottom: 8px; } -.subhead { - display: block; - font-size: 10px; - font-weight: 400; - text-transform: none; - color: #6c757d; +.subLine { + font-family: var(--font-tabloid); font-style: italic; - margin-top: 2px; + font-size: 11px; + margin-bottom: 8px; + color: var(--ink-soft); } -.body { +.row { + display: flex; + gap: 10px; + align-items: flex-start; +} + +.quote { + font-family: var(--font-tabloid); font-size: 13px; - line-height: 1.5; - color: #1a1a1a; - margin: 0 0 8px; + line-height: 1.4; } .footer { - font-size: 10px; - color: #6c757d; + font-family: var(--font-tabloid); font-style: italic; - border-top: 1px dotted #d0c8b8; + font-size: 10px; + margin-top: 10px; + color: var(--ink-soft); + border-top: 1px solid rgba(20, 18, 20, 0.3); padding-top: 6px; } diff --git a/src/ui/components/DisparageColumn.tsx b/src/ui/components/DisparageColumn.tsx index 96d0ee7..43342f4 100644 --- a/src/ui/components/DisparageColumn.tsx +++ b/src/ui/components/DisparageColumn.tsx @@ -1,19 +1,22 @@ import type { ResolutionEvent } from '../../engine/types'; +import Portrait from './Portrait'; import styles from './DisparageColumn.module.css'; interface Props { event: Extract; } +/** Sidebar `.side-sec` for the RoundSummary tabloid. Engine-driven quote/footer wiring unchanged. */ export default function DisparageColumn({ event }: Props) { return ( - + ); } diff --git a/src/ui/screens/RoundSummary.module.css b/src/ui/screens/RoundSummary.module.css index 9e1f900..5f4d05f 100644 --- a/src/ui/screens/RoundSummary.module.css +++ b/src/ui/screens/RoundSummary.module.css @@ -1,115 +1,539 @@ +/* RoundSummary — the fake tabloid front page (comic re-theme slice 4). + * Layout lifted from design_handoff_nuke_game/screens-3.jsx SummaryScreen. */ + .summary { - max-width: 480px; - margin: 0 auto; - padding: 16px; - font-family: Georgia, "Times New Roman", serif; - background: #efe5d2; - color: #1a1a1a; min-height: 100vh; - box-sizing: border-box; + background: var(--ink); + padding: 20px 12px 96px; } +.sheet { + max-width: 1100px; + margin: 0 auto; + padding: 28px 28px 16px; + border: 4px solid var(--ink); + box-shadow: 8px 8px 0 var(--magenta); + color: var(--ink); +} + +/* ---- Masthead ---- */ + .masthead { text-align: center; - font-size: 11px; - letter-spacing: 0.2em; +} + +.mastheadKicker { + font-family: var(--font-display); + font-size: 10px; + letter-spacing: 0.28em; + color: var(--magenta); + text-transform: uppercase; +} + +.mastheadTitle { + font-family: var(--font-tabloid); + font-weight: 900; + font-size: clamp(48px, 9vw, 96px); + margin: 4px 0; + line-height: 0.9; + letter-spacing: 0.005em; text-transform: uppercase; - color: #5a4a3a; - border-top: 2px solid #1a1a1a; - border-bottom: 2px solid #1a1a1a; +} + +.ruleLine { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 8px; + border-top: 1.5px solid var(--ink); + border-bottom: 1.5px solid var(--ink); padding: 4px 0; - margin-bottom: 12px; - font-family: Georgia, serif; + font-size: 11px; + font-family: var(--font-display); + letter-spacing: 0.16em; + text-transform: uppercase; +} + +/* ---- Grid ---- */ + +.summaryGrid { + display: grid; + gap: 28px; + grid-template-columns: 1fr; + margin-top: 12px; +} + +@media (min-width: 920px) { + .summaryGrid { grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); } +} + +.sidebar { + align-self: start; +} + +/* ---- Headline + standfirst ---- */ + +.headlineWrap { + margin-top: 14px; + position: relative; } .headline { - font-size: 32px; - font-weight: 900; - text-align: center; - margin: 16px 0 6px; - letter-spacing: -0.02em; + font-family: var(--font-display); + text-transform: uppercase; + font-size: clamp(56px, 10vw, 108px); + line-height: 0.88; + margin: 0; + letter-spacing: -0.005em; + color: var(--ink); } .headline.theEnd { - font-size: 48px; - letter-spacing: 0; + color: var(--magenta); } -.subhead { - font-size: 14px; +.headlineStamp { + position: absolute; + top: -10px; + right: 0; +} + +.standfirst { + font-family: var(--font-tabloid); font-style: italic; - text-align: center; - color: #5a4a3a; - margin: 0 0 16px; + font-size: 17px; + margin: 10px 0 0; + line-height: 1.35; } +/* ---- Casualty strip (4-across at all widths; cells shrink) ---- */ + .casualtyStrip { - background: #fff8eb; - border: 1px solid #d0c8b8; - border-radius: 8px; + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 4px; + padding: 12px 14px; + background: var(--ink); + color: var(--paper); + margin-bottom: 14px; +} + +.casualtyLabel { + font-family: var(--font-display); + font-size: 10px; + letter-spacing: 0.16em; + opacity: 0.6; + text-transform: uppercase; +} + +.casualtyValue { + font-family: var(--font-display); + font-size: 22px; +} + +.casualtyMagenta { color: var(--magenta); } +.casualtyGreen { color: var(--green); } + +/* ---- News stories ---- */ + +.sectionHead { + font-family: var(--font-display); + font-size: 16px; + letter-spacing: 0.12em; + border-top: 3px double var(--ink); + border-bottom: 3px double var(--ink); + padding: 6px 0; + text-align: center; + margin: 18px 0 14px; + text-transform: uppercase; +} + +.storyFlow { + column-count: 2; + column-gap: 22px; + column-rule: 1px solid rgba(20, 18, 20, 0.3); + margin-top: 4px; +} + +@media (max-width: 600px) { + .storyFlow { column-count: 1; } +} + +.newsStory { + break-inside: avoid; + padding: 10px 0 12px; + border-bottom: 1px dashed rgba(20, 18, 20, 0.35); + margin-bottom: 12px; +} + +.newsStory:last-child { border-bottom: 0; } + +.newsStory.lead { + column-span: all; + border-bottom: 3px double rgba(20, 18, 20, 0.55); + padding-bottom: 14px; + margin-bottom: 18px; +} + +.newsStory.lead .storyHeadline { font-size: 30px; line-height: 0.98; } +.newsStory.lead .storyBody { font-size: 14px; column-count: 2; column-gap: 18px; } + +.storyKicker { + font-family: var(--font-display); + font-size: 9px; + letter-spacing: 0.16em; + color: var(--magenta); + margin-bottom: 2px; + text-transform: uppercase; +} + +.storyHeadline { + font-family: var(--font-display); + font-size: 18px; + line-height: 1; + letter-spacing: 0.005em; + color: var(--ink); + margin: 0 0 5px; + text-transform: uppercase; +} + +.storyByline { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 8px; + font-family: var(--font-tabloid); + font-size: 10px; + color: var(--ink-soft); + border-bottom: 1px solid rgba(20, 18, 20, 0.25); + padding-bottom: 5px; + margin-bottom: 7px; +} + +.bylineWho { font-style: italic; } + +.bylineDelta { + font-family: var(--font-mono); + font-weight: 700; +} + +.deltaHarm { color: var(--magenta); } +.deltaGain { color: var(--green); } +.deltaNeutral { color: var(--ink-soft); } + +.storyPortrait { + float: left; + margin: 2px 10px 4px 0; + shape-outside: margin-box; +} + +.storyBody { + margin: 0; + font-family: var(--font-tabloid); + font-size: 12.5px; + line-height: 1.42; + color: var(--ink); + text-align: justify; + hyphens: auto; +} + +.storyPull { + font-family: var(--font-tabloid); + font-size: 14px; + font-style: italic; + color: var(--ink); + margin: 8px 0 0; + padding-left: 10px; + border-left: 3px solid var(--magenta); + clear: both; +} + +.storyTags { + display: flex; + gap: 4px; + flex-wrap: wrap; + margin-top: 8px; + clear: both; +} + +/* ---- Sidebar sections ---- */ + +.sideSec { padding: 12px; - margin: 12px 0; + background: var(--paper-warm); + border: 2px solid var(--ink); + box-shadow: 3px 3px 0 var(--ink); + margin-bottom: 16px; } -.casualtyPlaceholder { +.sideHead { + font-family: var(--font-display); + font-size: 12px; + letter-spacing: 0.16em; + border-top: 3px double var(--ink); + border-bottom: 3px double var(--ink); + padding: 5px 0; text-align: center; - color: #8a7a6b; + margin-bottom: 10px; + text-transform: uppercase; +} + +.sideTable { + width: 100%; + border-collapse: collapse; +} + +.sideTable td { + padding: 3px 0; + vertical-align: baseline; + border-bottom: 1px dotted rgba(20, 18, 20, 0.3); +} + +.sideTable tr:last-child td { border-bottom: 0; } + +.sideTable.boxscore td { padding: 4px 0; } + +.sideTableLabel { + font-family: var(--font-display); + font-size: 10px; + letter-spacing: 0.1em; + opacity: 0.7; + text-transform: uppercase; +} + +.sideTableValue { + font-family: var(--font-tabloid); + font-size: 12px; font-style: italic; + text-align: right; +} + +/* Forecast */ + +.forecastTop { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 10px; + margin-bottom: 10px; + padding-bottom: 8px; + border-bottom: 1px solid rgba(20, 18, 20, 0.3); +} + +.forecastOutlook { + font-family: var(--font-display); + font-size: 20px; + color: var(--magenta); + line-height: 1; +} + +.forecastTemp { + font-family: var(--font-mono); font-size: 11px; - margin-bottom: 8px; - padding: 16px 8px; - background: #e8d8c0; - border-radius: 6px; + margin-top: 4px; } -.casualtyRow { - font-family: ui-monospace, monospace; +.forecastTempLabel { opacity: 0.6; } + +.uvBlock { text-align: right; } + +.uvLabel { + font-family: var(--font-display); + font-size: 9px; + letter-spacing: 0.14em; + opacity: 0.6; +} + +.uvValue { font-size: 13px; - margin: 2px 0; + letter-spacing: 1px; } -.sectionTitle { +/* Market report */ + +.marketSub { + font-family: var(--font-tabloid); + font-style: italic; + font-size: 10px; + color: var(--ink-soft); + margin-bottom: 6px; +} + +.ticker { + display: flex; + flex-direction: column; + gap: 3px; +} + +.tickerRow { + display: grid; + grid-template-columns: 42px 64px 1fr; + align-items: baseline; + gap: 6px; + padding: 3px 0; + border-bottom: 1px dotted rgba(20, 18, 20, 0.3); +} + +.tickerRow:last-child { border-bottom: 0; } + +.tickerSym { + font-family: var(--font-mono); + font-weight: 700; + font-size: 12px; +} + +.tickerChange { + font-family: var(--font-mono); font-size: 12px; - text-transform: uppercase; - letter-spacing: 0.1em; - color: #5a4a3a; - margin: 16px 0 8px; font-weight: 700; - font-family: Georgia, serif; } -.worldReactions { margin: 16px 0; } -.reactionRow { - display: flex; - align-items: center; - gap: 8px; - padding: 6px 8px; - border-bottom: 1px dotted #d0c8b8; +.tickerNote { + font-family: var(--font-tabloid); + font-size: 10px; + font-style: italic; + opacity: 0.7; +} + +/* Box score */ + +.boxScoreNames { + font-family: var(--font-mono); + font-size: 11px; +} + +.boxScoreArrow { opacity: 0.5; } + +.boxScoreScore { + font-family: var(--font-display); + font-size: 11px; + text-align: right; +} + +.boxScoreGood { color: var(--green); } +.boxScoreHarm { color: var(--magenta); } + +.boxScoreEmpty { + font-family: var(--font-tabloid); + font-style: italic; font-size: 12px; + color: var(--ink-soft); } -.reactionFlag { font-size: 16px; } -.reactionName { font-weight: 600; min-width: 90px; } -.reactionStats { font-family: 'Consolas', monospace; font-size: 11px; color: #3a3a3a; min-width: 116px; white-space: nowrap; } -.reactionDelta { font-family: 'Consolas', monospace; color: #b02a37; min-width: 40px; } -.reactionQuote { font-style: italic; color: #5a4a3a; font-size: 11px; } -.obituaries { margin: 16px 0; } -.obit { background: #f4f4f4; border-left: 4px solid #1a1a1a; padding: 10px 12px; margin-bottom: 6px; } -.obitHeader { font-weight: 700; font-size: 13px; text-transform: uppercase; letter-spacing: 0.05em; color: #1a1a1a; } -.obitQuote { font-style: italic; font-size: 12px; color: #5a4a3a; margin-top: 4px; } +/* Obituaries */ -.continueBtn { - display: block; - width: 100%; - margin-top: 24px; - padding: 16px; - font-size: 16px; - font-weight: 700; +.obit { margin-bottom: 8px; } + +.obitName { + font-family: var(--font-display); + font-size: 13px; text-transform: uppercase; +} + +.obitLine { + font-family: var(--font-tabloid); + font-style: italic; + font-size: 11px; + color: var(--ink-soft); +} + +.obitEmpty { + font-family: var(--font-tabloid); + font-style: italic; + font-size: 12px; + color: var(--ink-soft); + padding: 4px 0; +} + +/* Classifieds */ + +.classified { + font-family: var(--font-tabloid); + font-size: 11px; + line-height: 1.35; + margin: 0 0 7px; + padding-bottom: 7px; + border-bottom: 1px dotted rgba(20, 18, 20, 0.3); + color: var(--ink); + text-align: justify; +} + +.classified:last-child { + border-bottom: 0; + margin-bottom: 0; +} + +.classifiedTag { + font-family: var(--font-display); + font-size: 9px; letter-spacing: 0.1em; - background: #8a1a1a; - color: #fff8eb; - border: none; - border-radius: 8px; - cursor: pointer; + margin-right: 4px; + text-transform: uppercase; +} + +/* Advertisement */ + +.adBlock { + margin-top: 22px; + padding: 14px; + border: 3px solid var(--ink); + background: var(--yellow); + text-align: center; +} + +.adLabel { + font-family: var(--font-display); + font-size: 11px; + letter-spacing: 0.16em; + text-transform: uppercase; +} + +.adTitle { + font-family: var(--font-bang); + font-size: 28px; + letter-spacing: 0.04em; + margin-top: 6px; + line-height: 0.95; +} + +.adCopy { + font-family: var(--font-hand); + font-size: 14px; + margin-top: 4px; +} + +/* ---- Corrections + continue ---- */ + +.correctionsBox { + margin-top: 20px; + padding: 8px 14px; + background: var(--paper-warm); + border: 2px solid var(--ink); + font-size: 12px; + color: var(--ink); +} + +.correctionsLabel { + font-family: var(--font-display); + font-size: 10px; + letter-spacing: 0.16em; + margin-right: 8px; + text-transform: uppercase; +} + +.correctionsLine { + font-family: var(--font-tabloid); + font-style: italic; +} + +/* The continue button keeps its mixed-case label strings ('Final Verdict', + * 'Round N → Plan') in the DOM; the global .btn text-transform: uppercase + * provides the all-caps presentation, so text queries keep matching. */ +.continueBar { + margin-top: 24px; + padding: 18px; + background: var(--ink); + text-align: center; + border-top: 4px double var(--magenta); } diff --git a/src/ui/screens/RoundSummary.tsx b/src/ui/screens/RoundSummary.tsx index f9ed70a..45f0577 100644 --- a/src/ui/screens/RoundSummary.tsx +++ b/src/ui/screens/RoundSummary.tsx @@ -1,7 +1,22 @@ import type { ScreenProps } from '../App'; import type { GameState, LeaderId, ResolutionEvent } from '../../engine/types'; import { pickMasthead } from '../../engine/masthead'; +import { isHuman } from '../../engine/state'; +import { extractFlag } from '../portraits'; +import Portrait from '../components/Portrait'; +import MushroomCloudPhoto from '../components/MushroomCloudPhoto'; import DisparageColumn from '../components/DisparageColumn'; +import { Btn, RelBadge, Stamp } from '../components/comic'; +import { + BOX_SCORE_EMPTY, + CLASSIFIEDS, + deriveBoxScore, + deriveForecast, + deriveMarket, + derivePhotoCaption, + deriveStories, + pickCorrection, +} from '../util/newspaper'; import styles from './RoundSummary.module.css'; function pickHeadline( @@ -52,6 +67,7 @@ export function pickSubhead(events: ResolutionEvent[], leaders: GameState['leade export default function RoundSummary({ state, dispatch }: ScreenProps) { const game = state.game!; + const reportedRound = game.round - 1; const headline = pickHeadline(state.events, game.leaders, state.prevPopulations, game.round, game.outcome); const subhead = pickSubhead(state.events, game.leaders); @@ -66,81 +82,248 @@ export default function RoundSummary({ state, dispatch }: ScreenProps) { return acc + Math.max(0, init - game.leaders[id].population); }, 0); const survivors = game.cast.filter((id) => game.leaders[id].alive).length; + const survivorsPop = game.cast.reduce((acc, id) => acc + game.leaders[id].population, 0); + + const forecast = deriveForecast(thisRoundLost); + const market = deriveMarket(game, state.prevPopulations); + const boxScore = deriveBoxScore(state.events, game.leaders); + const stories = deriveStories(game, state.events, state.prevPopulations); + const photoCaption = derivePhotoCaption(state.events, game.leaders); + const lostStamp = thisRoundLost > 0 ? `−${thisRoundLost}M` : undefined; + + // Eliminated this round = alive=false AND prev > 0 (detection preserved). + const obits = game.cast.flatMap((id) => { + const leader = game.leaders[id]; + const prev = state.prevPopulations[id]; + if (leader.alive || prev === undefined || prev <= 0) return []; + const death = state.events.find( + (e): e is Extract => + e.kind === 'LeaderEliminated' && e.id === id, + ); + return [{ id, name: leader.name, line: death?.quote ?? 'Gone, and swiftly forgotten.' }]; + }); + + const disparage = state.events.find( + (e): e is Extract => e.kind === 'DisparageColumn', + ); + + const flagFor = (id: LeaderId) => + isHuman(id) ? extractFlag(game.leaders[id].country) : undefined; const continueLabel = game.outcome ? 'Final Verdict' : `Round ${game.round} → Plan`; return (
-
- {pickMasthead(game.mastheadOrder, game.round - 1, game.outcome)} -
-

- {headline} -

-

{subhead}

- -
-
[ mushroom-cloud SVG — P4a ]
-
This round: {thisRoundLost}M lost
-
War total: {warTotalLost}M lost
-
Survivors: {survivors} of {game.cast.length}
-
+
+ {/* Masthead */} +
+
EXTRA · EXTRA · EXTRA
+

+ {pickMasthead(game.mastheadOrder, game.round - 1, game.outcome)} +

+
+ VOL. IV · ROUND {reportedRound} + MORNING EDITION + FREE WHILE STOCKS LAST +
+
-
-

World Reactions

- {game.cast.map((id) => { - const leader = game.leaders[id]; - if (!leader.alive) return null; - const prev = state.prevPopulations[id] ?? leader.population; - const delta = leader.population - prev; - const reaction = state.events.find( - (e) => e.kind === 'PostRoundReaction' && e.leaderId === id, - ); - const quote = reaction?.kind === 'PostRoundReaction' ? reaction.quote : undefined; - return ( -
- {leader.country.split(' ')[0]} - {leader.name} - 👥 {leader.population}M · 🏭 {leader.factories} - {delta >= 0 ? `+${delta}` : delta}M - {quote && "{quote}"} +
+ {/* Main column */} +
+
+

+ {headline} +

+ {lostStamp !== undefined && ( +
+ {lostStamp} +
+ )}
- ); - })} -
- -
- {game.cast.map((id) => { - const leader = game.leaders[id]; - const prev = state.prevPopulations[id]; - // Eliminated this round = alive=false AND prev > 0 - if (leader.alive || prev === undefined || prev <= 0) return null; - const death = state.events.find( - (e) => e.kind === 'LeaderEliminated' && e.id === id, - ); - const quote = death?.kind === 'LeaderEliminated' ? death.quote : undefined; - return ( -
-
OBITUARY: {leader.name}
- {quote &&
"{quote}"
} +

{subhead}

+ + + +
+
+
THIS ROUND
+
−{thisRoundLost}M
+
+
+
WAR TO DATE
+
−{warTotalLost}M
+
+
+
SURVIVORS
+
{survivorsPop}M
+
+
+
LEADERS LEFT
+
{survivors}/{game.cast.length}
+
- ); - })} -
- - {(() => { - const col = state.events.find((e) => e.kind === 'DisparageColumn'); - if (col?.kind === 'DisparageColumn') return ; - return null; - })()} - - + +
★ FROM AROUND THE RUBBLE ★
+
+ {stories.map((s) => ( +
+
{s.kicker}
+

{s.headline}

+
+ by our {s.bylineCountry} correspondent + + {s.delta.text} + +
+
+ +
+

{s.body}

+ {s.pullquote !== undefined && ( +

"{s.pullquote}"

+ )} + {s.badges.length > 0 && ( +
+ {s.badges.map((b, j) => ( + {b.text} + ))} +
+ )} +
+ ))} +
+
+ + {/* Sidebar */} + +
+ + {/* Corrections & Clarifications */} +
+ CORRECTIONS & CLARIFICATIONS + {pickCorrection(reportedRound)} +
+ + {/* Continue bar */} +
+ dispatch({ type: 'NEXT_ROUND' })}> + {continueLabel} + +
+
); } diff --git a/tests/ui/RoundSummary.render.test.tsx b/tests/ui/RoundSummary.render.test.tsx new file mode 100644 index 0000000..de1e692 --- /dev/null +++ b/tests/ui/RoundSummary.render.test.tsx @@ -0,0 +1,96 @@ +import { describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/react'; +import RoundSummary from '../../src/ui/screens/RoundSummary'; +import { CORRECTIONS } from '../../src/ui/util/newspaper'; +import { initialState } from '../../src/engine/state'; +import type { ResolutionEvent } from '../../src/engine/types'; +import type { UiState } from '../../src/ui/store'; + +/** Post-resolution state: round already incremented to 2, paper reports round 1. */ +function makeState(events: ResolutionEvent[] = [], round = 2): UiState { + const game = initialState({ + cast: ['player1', 'chump'], + difficulty: 'normal', + seed: 'summary-render', + }); + game.round = round; + game.leaders.player1.population = 30; + game.leaders.chump.population = 27; + return { + screen: 'roundSummary', + game, + events, + prevPopulations: { player1: 30, chump: 33 }, + initialPopulations: { player1: 30, chump: 40 }, + lastNewGameOpts: null, + pendingHumanOrders: {}, + }; +} + +describe(' tabloid', () => { + it('renders the masthead from game.mastheadOrder for the reported round', () => { + const state = makeState(); + // reportedRound = 1 → mastheadOrder[0] + render(); + expect(screen.getByText(state.game!.mastheadOrder[0])).toBeInTheDocument(); + expect(screen.getByText('EXTRA · EXTRA · EXTRA')).toBeInTheDocument(); + expect(screen.getByText('VOL. IV · ROUND 1')).toBeInTheDocument(); + }); + + it('shows casualty-strip values from live populations', () => { + render(); + expect(screen.getByText('THIS ROUND')).toBeInTheDocument(); + // thisRoundLost = 33 − 27 = 6 → appears in the strip plus the headline + // and photo stamps (both render −6M as well). + expect(screen.getAllByText('−6M')).toHaveLength(3); + // warTotalLost = 40 − 27 = 13 + expect(screen.getByText('−13M')).toBeInTheDocument(); + // survivors population = 30 + 27 + expect(screen.getByText('57M')).toBeInTheDocument(); + expect(screen.getByText('2/2')).toBeInTheDocument(); + }); + + it('rotates the corrections line with the reported round', () => { + const first = render(); + expect(screen.getByText(CORRECTIONS[0])).toBeInTheDocument(); + first.unmount(); + render(); + expect(screen.getByText(CORRECTIONS[1])).toBeInTheDocument(); + }); + + it('renders the story section head and one story per living leader', () => { + render(); + expect(screen.getByText('★ FROM AROUND THE RUBBLE ★')).toBeInTheDocument(); + // chump lost 6M → shrugs-off headline; player1 idle → does-nothing headline. + expect(screen.getByText('CHUMP SHRUGS OFF LIGHT VAPORISATION')).toBeInTheDocument(); + expect(screen.getByText('RUFUS T. FIREFLY DOES NOTHING, CALLS IT STRATEGY')).toBeInTheDocument(); + }); + + it('omits the Disparage column when no DisparageColumn event exists', () => { + render(); + expect(screen.queryByText('THE DISPARAGE COLUMN')).not.toBeInTheDocument(); + }); + + it('renders the Disparage column from the event when present', () => { + const events: ResolutionEvent[] = [ + { kind: 'DisparageColumn', quote: 'Pint?', footer: 'Unavailable for follow-up.' }, + ]; + render(); + expect(screen.getByText('THE DISPARAGE COLUMN')).toBeInTheDocument(); + expect(screen.getByText('Pint?')).toBeInTheDocument(); + expect(screen.getByText('Unavailable for follow-up.')).toBeInTheDocument(); + }); + + it('renders the box-score empty state when nothing qualifies', () => { + render(); + expect(screen.getByText('No exchanges. The censors are baffled.')).toBeInTheDocument(); + }); + + it('dispatches NEXT_ROUND from the continue button with the preserved label', () => { + const dispatch = vi.fn(); + render(); + const btn = screen.getByRole('button', { name: 'Round 2 → Plan' }); + fireEvent.click(btn); + expect(dispatch).toHaveBeenCalledWith({ type: 'NEXT_ROUND' }); + }); +}); From 551bff19cb83365c96e862d80867074d5b58c89a Mon Sep 17 00:00:00 2001 From: gethi Date: Sun, 19 Jul 2026 15:46:21 +0100 Subject: [PATCH 30/43] feat: Winners re-themed as the ironic final page Yellow FINAL SCORE tag, ink-soft hero with 200px winner portrait and SURVIVOR/PYRRHIC stamp (apocalypse: radiation panel + NO SURVIVORS), Death Toll panel with portraits, integer % LOST (adopted from handoff) and SURVIVED/ELIMINATED badges, closing 1989-box tagline. pickHeadline, pickSubLine, tollRows sort and newGame/sameCast handlers preserved. Render tests cover sorting, badges, stamps and both button dispatches. Co-Authored-By: Claude Fable 5 --- src/ui/screens/Winners.module.css | 211 ++++++++++++++++++++---------- src/ui/screens/Winners.tsx | 115 ++++++++++++---- tests/ui/Winners.test.tsx | 90 +++++++++++++ 3 files changed, 321 insertions(+), 95 deletions(-) create mode 100644 tests/ui/Winners.test.tsx diff --git a/src/ui/screens/Winners.module.css b/src/ui/screens/Winners.module.css index 1704651..fc24efd 100644 --- a/src/ui/screens/Winners.module.css +++ b/src/ui/screens/Winners.module.css @@ -1,94 +1,171 @@ +/* Winners — ironic final page (comic re-theme slice 4). + * Layout lifted from design_handoff_nuke_game/screens-3.jsx WinnersScreen. */ + .winners { - max-width: 520px; - margin: 0 auto; - padding: 24px 16px; - font-family: Georgia, serif; - background: #1a1a1a; - color: #f4ede2; min-height: 100vh; - box-sizing: border-box; + padding: 40px 16px 96px; + background: var(--ink); + color: var(--paper); + position: relative; + overflow: hidden; +} + +.content { + max-width: 1100px; + margin: 0 auto; + position: relative; +} + +.finalTag { text-align: center; + margin-bottom: 12px; } -.headline { - font-size: 36px; - font-weight: 900; - letter-spacing: -0.02em; - margin: 16px 0; - color: #ffe9c8; +/* ---- Hero ---- */ + +.hero { + display: flex; + gap: 32px; + align-items: center; + flex-wrap: wrap; + background: var(--ink-soft); + padding: 28px; + border: 3px solid var(--paper); + box-shadow: 8px 8px 0 var(--magenta); } -.subline { - font-size: 16px; - font-style: italic; - color: #c9b89a; - margin: 8px 0 32px; +@media (max-width: 720px) { + .hero { + flex-direction: column; + align-items: flex-start; + } } -.sectionTitle { - font-size: 12px; - text-transform: uppercase; - letter-spacing: 0.15em; - color: #c9b89a; - margin: 24px 0 8px; - font-weight: 700; +.portraitBlock { + position: relative; + flex: 0 0 auto; } -.tollTable { - width: 100%; - border-collapse: collapse; - background: #2a2a2a; - border-radius: 6px; - overflow: hidden; - margin: 8px 0 24px; +.heroStamp { + position: absolute; + top: -14px; + right: -22px; } -.tollTable th, -.tollTable td { - padding: 8px 12px; - font-size: 13px; - text-align: left; - border-bottom: 1px solid #3a3a3a; +.apocalypsePanel { + width: 200px; + height: 200px; + background: var(--ink); + border: 3px solid var(--paper); + display: flex; + align-items: center; + justify-content: center; + font-size: 120px; + line-height: 1; + color: var(--magenta); } -.tollTable th { - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.05em; - font-size: 11px; - color: #c9b89a; - background: #1a1a1a; +.heroText { + flex: 1 1 320px; + min-width: 0; } -.tollTable .numCell { - font-family: ui-monospace, monospace; - text-align: right; +.headline { + font-family: var(--font-display); + text-transform: uppercase; + font-size: clamp(48px, 8vw, 110px); + letter-spacing: -0.005em; + line-height: 0.9; + margin: 0; + word-break: break-word; } -.playerRow { background: #3a2a1a; } +.subline { + font-family: var(--font-hand); + font-size: 20px; + margin: 14px 0 0; + color: var(--paper-edge); + line-height: 1.35; +} .buttonRow { + margin-top: 24px; + display: flex; + gap: 12px; + flex-wrap: wrap; +} + +/* ---- Death toll ---- */ + +.tableScroll { + overflow-x: auto; +} + +.deathTable { + display: flex; + flex-direction: column; + min-width: 560px; +} + +.deathHead, +.deathRow { display: flex; gap: 8px; - margin-top: 24px; + align-items: center; + padding: 8px 10px; } -.button { - flex: 1; - padding: 14px; - font-size: 14px; - font-weight: 700; +.deathHead { + background: var(--ink); + color: var(--paper); + font-family: var(--font-display); + font-size: 11px; + letter-spacing: 0.12em; text-transform: uppercase; - letter-spacing: 0.1em; - background: #8a1a1a; - color: #fff8eb; - border: none; - border-radius: 8px; - cursor: pointer; -} - -.button.secondary { - background: transparent; - border: 1px solid #c9b89a; - color: #c9b89a; +} + +.deathRow { + border-bottom: 1px dashed rgba(20, 18, 20, 0.3); +} + +.deathRow:last-child { border-bottom: 0; } + +.cellLeader { flex: 2; } + +.leaderCell { + display: flex; + gap: 10px; + align-items: center; +} + +.cellNum { flex: 1; } + +.cellState { flex: 2; } + +.numCell { font-family: var(--font-mono); } + +.endDead { color: var(--magenta); } +.endAlive { color: var(--green); } + +.pctCell { font-weight: 700; } + +/* ---- Final word ---- */ + +.finalWord { + margin-top: 32px; + text-align: center; +} + +.finalQuote { + font-family: var(--font-display); + font-size: 28px; + color: var(--magenta); + letter-spacing: 0.04em; +} + +.finalAttribution { + font-family: var(--font-hand); + font-size: 16px; + margin-top: 6px; + color: var(--paper-edge); } diff --git a/src/ui/screens/Winners.tsx b/src/ui/screens/Winners.tsx index dd0b6a9..2414c7b 100644 --- a/src/ui/screens/Winners.tsx +++ b/src/ui/screens/Winners.tsx @@ -1,5 +1,9 @@ import type { ScreenProps } from '../App'; import type { GameState, LeaderId, WinOutcome } from '../../engine/types'; +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 styles from './Winners.module.css'; function pickHeadline(outcome: WinOutcome, leaders: GameState['leaders']): string { @@ -55,38 +59,93 @@ export default function Winners({ state, dispatch }: ScreenProps) { }); } + const flagFor = (id: LeaderId) => + isHuman(id) ? extractFlag(game.leaders[id].country) : undefined; + return (
-

{headline}

-

"{subLine}"

+ +
+
+ + FINAL SCORE · ROUND {game.round - 1} + +
+ +
+ {outcome.type === 'apocalypse' ? ( +
+ +
+ + NO SURVIVORS + +
+
+ ) : ( +
+ +
+ + {outcome.type === 'survivor' ? 'SURVIVOR' : 'PYRRHIC'} + +
+
+ )} +
+

{headline}

+

"{subLine}"

+
+ New Game + Same Cast, Again +
+
+
-

Death Toll

- - - - - - - - - - - {tollRows.map((row) => ( - - - - - - - ))} - -
LeaderStartEnd% lost
{row.country.split(' ')[0]} {row.name}{row.id === 'player1' ? ' (you)' : ''}{row.start}M{row.end}M{row.pctLost.toFixed(1)}%
+ +
+
+
+ LEADER + START + END + % LOST + STATE +
+ {tollRows.map((row) => ( +
+ + + {row.name}{row.id === 'player1' ? ' (you)' : ''} + + {row.start}M + + {row.end}M + + + {Math.round(row.pctLost)}% + + + {row.end > 0 + ? SURVIVED + : ELIMINATED} + +
+ ))} +
+
+
-
- - +
+
"EVERYBODY PLAYS. NOBODY WINS."
+
— the original 1989 box, more or less.
+
); diff --git a/tests/ui/Winners.test.tsx b/tests/ui/Winners.test.tsx new file mode 100644 index 0000000..9bfb173 --- /dev/null +++ b/tests/ui/Winners.test.tsx @@ -0,0 +1,90 @@ +import { describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/react'; +import Winners from '../../src/ui/screens/Winners'; +import { initialState } from '../../src/engine/state'; +import type { NewGameOpts } from '../../src/engine/state'; +import type { WinOutcome } from '../../src/engine/types'; +import type { UiState } from '../../src/ui/store'; + +const OPTS: NewGameOpts = { + cast: ['player1', 'chump', 'carnage'], + difficulty: 'normal', + seed: 'winners-render', +}; + +function makeState(outcome: WinOutcome): UiState { + const game = initialState(OPTS); + game.round = 8; + game.outcome = outcome; + // player1 wiped out (100%), chump battered (55%), carnage lightly grazed (20%). + game.leaders.player1.population = 0; + game.leaders.player1.alive = false; + game.leaders.chump.population = 15; + game.leaders.carnage.population = 20; + return { + screen: 'winners', + game, + events: [], + prevPopulations: {}, + initialPopulations: { player1: 30, chump: 33, carnage: 25 }, + lastNewGameOpts: OPTS, + pendingHumanOrders: {}, + }; +} + +describe('', () => { + it('renders the winner headline, final-score tag and survivor stamp', () => { + render(); + expect(screen.getByText('CARNAGE WINS')).toBeInTheDocument(); + expect(screen.getByText('FINAL SCORE · ROUND 7')).toBeInTheDocument(); + expect(screen.getByText('SURVIVOR')).toBeInTheDocument(); + expect(screen.getByText('"EVERYBODY PLAYS. NOBODY WINS."')).toBeInTheDocument(); + }); + + it('sorts death-toll rows ascending by percent lost, rounded to integers', () => { + render(); + const pcts = screen.getAllByText(/^\d+%$/).map((el) => el.textContent); + // carnage (25→20) 20%, chump (33→15) 54.5→55%, player1 (30→0) 100%. + expect(pcts).toEqual(['20%', '55%', '100%']); + expect(screen.getByText('Rufus T. Firefly (you)')).toBeInTheDocument(); + }); + + it('badges end-zero rows ELIMINATED and the rest SURVIVED', () => { + render(); + expect(screen.getAllByText('SURVIVED')).toHaveLength(2); + expect(screen.getAllByText('ELIMINATED')).toHaveLength(1); + }); + + it('shows the NO SURVIVORS apocalypse panel instead of a portrait', () => { + render(); + expect(screen.getByText('WINNER: NOBODY')).toBeInTheDocument(); + expect(screen.getByText('NO SURVIVORS')).toBeInTheDocument(); + expect(screen.getByText('☢')).toBeInTheDocument(); + }); + + it('marks a pyrrhic winner with the PYRRHIC stamp', () => { + render(); + expect(screen.getByText('CHUMP WINS')).toBeInTheDocument(); + expect(screen.getByText('PYRRHIC')).toBeInTheDocument(); + }); + + it('New Game dispatches BACK_TO_SETUP', () => { + const dispatch = vi.fn(); + render(); + fireEvent.click(screen.getByRole('button', { name: 'New Game' })); + expect(dispatch).toHaveBeenCalledWith({ type: 'BACK_TO_SETUP' }); + }); + + it('Same Cast, Again dispatches START_GAME with the last opts and a fresh seed', () => { + const dispatch = vi.fn(); + render(); + fireEvent.click(screen.getByRole('button', { name: 'Same Cast, Again' })); + expect(dispatch).toHaveBeenCalledTimes(1); + const action = dispatch.mock.calls[0][0]; + expect(action.type).toBe('START_GAME'); + expect(action.opts.cast).toEqual(OPTS.cast); + expect(action.opts.difficulty).toBe(OPTS.difficulty); + expect(action.opts.seed).not.toBe(OPTS.seed); + expect(action.opts.seed.length).toBeGreaterThan(0); + }); +}); From 5f236d5c7f6fcafd45674c9385971dac1a34efd7 Mon Sep 17 00:00:00 2001 From: gethi Date: Sun, 19 Jul 2026 16:36:42 +0100 Subject: [PATCH 31/43] docs: verified Burnham research for Burn'em leader swap Co-Authored-By: Claude Fable 5 --- .../research/2026-07-19-burnham-research.md | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 docs/superpowers/research/2026-07-19-burnham-research.md diff --git a/docs/superpowers/research/2026-07-19-burnham-research.md b/docs/superpowers/research/2026-07-19-burnham-research.md new file mode 100644 index 0000000..0e09cbe --- /dev/null +++ b/docs/superpowers/research/2026-07-19-burnham-research.md @@ -0,0 +1,61 @@ +# Research: Andy Burnham → "Burn'em" parody character + +**Date:** 2026-07-19 +**Method:** deep-research workflow — 5 search angles, 25 sources fetched, 118 claims extracted, top 25 adversarially verified (3-vote), 23 confirmed / 2 refuted. +**Purpose:** source material for the Burn'em leader replacing Starmless (UK slot) after the Starmer→Burnham handover of July 2026. + +## Verified findings + +1. **Career arc (high, 3-0).** Born 1970 Aintree (Merseyside), MP for Leigh 2001, Brown-era cabinet (Health Secretary 2009), two failed leadership bids (4th, 10.4% in 2010; 2nd, 19% vs Corbyn's 59% in 2015), three-term Mayor of Greater Manchester (2017; 67% 2021; 63.4% 2024). Returned via Makerfield by-election 18 June 2026 (seat vacated for him), Labour leader 17 July 2026, incoming PM. Arc: left Westminster a loser, came back a king. [IfG, Britannica, Wikipedia, Commons Library CBP-10853] + +2. **"King of the North" (high, 3-0).** Born in the Oct 2020 COVID tier-3 standoff — accused Westminster of "contempt", demanded £90M, settled at £65M. Used affirmatively (CNN) and mockingly ("self-styled", "the myth of the King of the North", "plastic Northerner" — UnHerd, New Statesman). GoT framing mainstream (NME: "it's all gone a bit Game of Thrones, hasn't it?"). Press mostly says "King **of** the North"; the GoT/meme form is "King **in** the North". [Wikipedia, Britannica, CNN, GMCA] + +3. **Buses = signature policy (high, 3-0).** First franchising outside London (Bee Network, Sept 2023–Jan 2025), beat Stagecoach/Rotala in the High Court (Mar 2022), £2 capped fare (Sept 2022). Robert Ford: turned "a rather bland technocratic bit of policy… into a David versus Goliath fight"; "a very effective communicator, a very effective storyteller". NB franchising ≠ nationalisation — the exaggeration is the parody's. [IfG, routeone, GMCA] + +4. **Voice (high, 3-0, Matt Forde's impression guide).** "Basically Scouse… he definitely sounds more Scouse than Manc." Evertonian from the Liverpool–Manchester "no man's land"; "He's a Woolly Backer… the small differences are really magnified." [Times Radio video, verified against captions] + +5. **Two verbal-tic templates (high, 3-0, Forde).** + - Rhetorical tag questions mid-speech: "…isn't it?", "…don't we?", "…can't it?" ("Britain can be fairer, can't it?") + - The **"handbrake turn"**: blokey levity → grave pronouncement with no transition. Forde's demo pivots from Harry Kane and beers with the lads straight into "let me be clear, the Iranians carry on, I will use nuclear force." (Forde attributes the habit to Labour politicians generally, demonstrated through Burnham.) + +6. **Character core (high, 3-0).** Shapeshifter blank: "he can be everything to anyone and nothing to anyone… who is this guy?" (The Daily T). Press positions him as the plain-speaking storyteller vs "technocratic", "widely unpopular" Starmer. Satire angle: man of the people whose identity dissolves on inspection (Cambridge-educated, claimed Mancunian, actually Merseyside). + +7. **Established satire target (high, 3-0).** Three Private Eye covers Oct 2025–Jun 2026 (+ "Crash And Burnham", "Manc to the Future" videos); TikTok @jackandwood "Andy Burnham: King in the North | Northern Comedy Sketch"; Forde (Times Radio/Sky/Telegraph) and Jon Culshaw (July 2026) impressions. + +8. **Grievance + authenticity rhetoric (high, 3-0, GMCA Belfast speech Nov 2024).** Regions "go on bended knee and bow down and beg and plead for the scraps they're prepared to throw out for us" (recurs in his book *Head North*). Biography-as-credential: "thousands of CVs", unpaid Middleton Guardian job, "To get on in life, I had to head South." Spectator's counter (Cambridge, could afford unpaid work, Tessa Jowell career break) = the hardship story that wilts under questioning. + +9. **Triumphal boosterism (medium, 2-1 — single speech).** "Greater Manchester is back — big time"; "They didn't do it for us down in Whitehall. We have done this ourselves."; "it's not arrogance, it's just confidence." + +10. **Leader-era register (high, 3-0).** First leader speech (17 July 2026): give people "hope back", fight "a political culture and an economic model that simply doesn't work well enough for ordinary people". Continues 2010's "aspirational socialism". + +11. **Hillsborough (high, 3-0) — THIRD RAIL.** Pushed in cabinet for the Hillsborough Independent Panel after being heckled at the 2009 Anfield memorial; 2016 "rotten to the core" Commons speech drew rare applause. Explains the moral-crusader self-image and the gravity side of the handbrake turn. **The tragedy itself is never the joke — the flavour bank should not reference it.** + +## Refuted — do not use + +- "What Manchester does today, the world does tomorrow" as his stock opener (1-2 against; one speech title only). +- CNN's career-arc summary (Blair-era cabinet framing wrong; use IfG/Britannica arc). + +## Unverified leads (visual hooks — no claims survived verification) + +Famous "Bambi" eyelashes + bushy brows (Grazia meme coverage), dark "Ken-doll" hair sweep, "king of the casuals" dad-wear (Irish Times). Treat any SVG design using these as our invention, not sourced caricature convention. + +## Open questions + +- What do editorial cartoonists actually exaggerate visually? +- Broadcast-satire treatments beyond Forde/Culshaw (Dead Ringers, HIGNFY)? +- Specific GoT meme templates in circulation? +- Corpus evidence of repeated catchphrases across multiple speeches? + +## Key sources + +- https://www.instituteforgovernment.org.uk/explainer/mayor-greater-manchester (primary) +- https://www.youtube.com/watch?v=jHUMDFrdMDU — Matt Forde's Burnham impression guide (primary) +- https://www.greatermanchester-ca.gov.uk/news/what-manchester-does-today-the-world-does-tomorrow-... — Belfast speech (primary; fetched via search index, direct 403) +- https://www.private-eye.co.uk/covers/Andy_Burnham (primary) +- https://www.tiktok.com/@jackandwood/video/7285786731700391200 (primary, via oEmbed) +- https://en.wikipedia.org/wiki/Andy_Burnham · https://www.britannica.com/biography/Andy-Burnham +- https://www.cnn.com/2026/07/17/uk/andy-burnham-labour-leader-uk-intl · https://labourlist.org/2026/07/read-andy-burnhams-first-speech-as-labour-leader-in-full/ +- https://unherd.com/2024/02/its-easy-to-mock-andy-burnham/ · https://spectator.com/article/burnhams-coronation-speech-was-sub-disney-schmaltz/ +- https://graziadaily.co.uk/life/in-the-news/andy-burnham-eyelashes/ · https://www.irishtimes.com/world/uk/2026/07/03/king-of-the-casuals-... + +**Caveats:** 2026 political events were days old at research time; GMCA and TikTok sources verified indirectly; boosterism finding rests on one speech (2-1). From 9cea3901d8b98e1abc9cd880b3392a9536691c9b Mon Sep 17 00:00:00 2001 From: gethi Date: Sun, 19 Jul 2026 17:15:11 +0100 Subject: [PATCH 32/43] docs: Burn'em leader swap spec (replaces Starmless) Co-Authored-By: Claude Fable 5 --- .../2026-07-19-burnem-leader-swap-design.md | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-19-burnem-leader-swap-design.md diff --git a/docs/superpowers/specs/2026-07-19-burnem-leader-swap-design.md b/docs/superpowers/specs/2026-07-19-burnem-leader-swap-design.md new file mode 100644 index 0000000..0ce057e --- /dev/null +++ b/docs/superpowers/specs/2026-07-19-burnem-leader-swap-design.md @@ -0,0 +1,63 @@ +# nuke — Burn'em leader swap (Starmless → Burn'em) + +**Date:** 2026-07-19 +**Status:** approved in brainstorming session +**Research basis:** `docs/superpowers/research/2026-07-19-burnham-research.md` (deep-research, 23 verified findings) +**Premise:** Keir Starmer has been replaced as UK PM by Andy Burnham (July 2026). The UK cast slot follows suit: Starmless is fully replaced by **Burn'em**, a "King in t' North" caricature. + +## 1. Identity — full replace + +`'starmless'` → `'burnem'` in every reference. Starmless ceases to exist in the codebase. + +- `src/engine/types.ts` — `LeaderId` union member renamed. +- `src/engine/balance.ts` — `LEADER_PROFILES.burnem = { name: "Burn'em", country: '🇬🇧 UK', startPop: 25, startFactories: 6, startAp: 6 }` (stats unchanged from Starmless; no bonusRule). +- `src/engine/flavor/starmless.ts` → `src/engine/flavor/burnem.ts` — all-new lines (§3); `flavor/index.ts` re-export renamed. +- `src/engine/flavor/disparage.ts` — Starmless snap-back entry replaced by a Burn'em one. +- `src/engine/ai/starmless.ts` → `src/engine/ai/burnem.ts` — new planner (§2); `ai/dispatch.ts` case renamed. +- All tests referencing `starmless` (including the AI-duel per-leader win breakdown and any flavor/dispatch tests) updated. +- UI: `portraits/faces.tsx` (BurnemFace replaces StarmlessFace), `portraits/index.ts` (`FACES.burnem`, `PORTRAIT_META.burnem`), `tokens.css` (`--c-starm` → `--c-burnem`, value `#8a1729` kept), `content/cast.ts`, any newspaper/masthead copy naming Starmless. +- Docs: `docs/superpowers/flavour-bank.md` cast table updated. Historical specs/plans are NOT edited. + +## 2. AI personality — the handbrake turn (`planBurnem`) + +Two-state planner, replacing Cautious+Scapegoat: + +**Placid state (default).** Never launches first. Priorities per round: woo the leader with the lowest relationship toward him; build factories, then defence; bank remaining AP. Uses the shared scoring primitives (`threatScore`, `wasAttackedBy`, etc.) and the projected-stockpile conventions of the other planners (producers before consumers; local projection counter for multi-consume orders). + +**Provoked state (permanent once entered).** Full aggression against the primary provoker: build toward warheads + delivery every round, launch maximum affordable salvos at the provoker; if the provoker is eliminated, redirect to the current top grudge target. State derives from existing resolution-time grudge/aggression tracking — no new persistent state shape unless the existing one cannot express "was ever attacked", in which case extend the AI state minimally. + +**Patience fallback (deadlock guard).** Treated as provoked (against the strongest surviving rival by threat score) when either: (a) round ≥ 6 and he has never been attacked, or (b) only 2 leaders survive. Prevents the known never-attack-first 1v1 stall (cf. the Netanyahoo endgame fix). + +**Difficulty interaction.** Same randomization/lookahead wrappers as the rest of the cast — `planAi` orchestration unchanged. + +**Balance gates.** `npm run test:run` green including: headless all-AI termination test, 80-seed AI-duel with per-leader win breakdown (Burn'em must neither dominate nor flatline — same soft expectations the suite already encodes), and new planner unit tests (§5). + +## 3. Flavour bank (`flavor/burnem.ts`) + +Same line-bank schema as the other leaders. Register rules: + +- **Tag questions:** the signature tic — the large majority of lines end in "…isn't it?" / "…don't we?" / "…can't it?" or similar. +- **Placid-state material:** northern grievance and boosterism — "on bended knee, begging for scraps", "The North is back. Big time.", "It's not arrogance, it's just confidence.", "They didn't do it for us down in Whitehall. We did this ourselves, didn't we?", "To get on in life, I had to head South.", giving people "hope back", "ordinary people", bus obsession (Bee Network, £2 fares, "the buses run on time now"), King in t' North self-styling, Woolly Backer / more-Scouse-than-Manc authenticity gags, thousands-of-CVs origin story. +- **Handbrake-turn material:** the friendly-to-apocalyptic pivot in one line — blokey opener, grave nuclear pronouncement, no transition, still ends warmly or with a tag question. ("Let me be clear — they carry on like this, I will use nuclear force. Anyway. How's the family?", "I've been very patient, haven't I?") +- **Hard exclusions:** no Hillsborough references (the tragedy is never the joke); no religious markers (existing cast rule); the refuted Disraeli line ("What Manchester does today…") is not used as a stock phrase. +- Disparage snap-back line for Burn'em added to `flavor/disparage.ts`. + +## 4. Portrait + UI content + +**BurnemFace** (geometric SVG, modeled on the "VOTE ANDY FOR US" campaign placard caricature the user supplied): long pale face; flat dark side-swept hair; very thick black rectangular eyebrows; black rectangular glasses; stern flat mouth; black casual zip-up crew-neck (no suit, no tie). Corner prop in the cast tradition (Disparage's pint, Mileigh's chainsaw): a mini red placard with white/yellow block text "FOR US". Signature color `--c-burnem: #8a1729` (inherits the renamed `--c-starm` token). + +**Cast copy** (`src/ui/content/cast.ts`): profile "King in t' North", mood "We need to bring people with us, don't we?". Setup tile catch/tag copy: "IT'S NOT ARROGANCE. IT'S CONFIDENCE." + +Any UI copy naming Starmless (newspaper sections, obituaries content, market tickers) renames to Burn'em with ticker/sym updates as needed. + +## 5. Testing + +- **Rename ripple:** full suite stays green after the id swap (search for `starmless` case-insensitively across src/ tests/ must return zero hits post-change, excluding docs/). +- **New planner unit tests** (`tests/engine/ai/burnem.test.ts` or the suite's existing per-planner layout): (1) placid state emits no launches when unprovoked; (2) attack on Burn'em → provoked state → launches target the provoker and persist in later rounds; (3) patience fallback: unattacked at round 6 → provoked vs strongest rival; 2-survivor game → provoked; (4) producers-before-consumers order in emitted batches. Assertions unconditional (no `if (x) expect(...)`). +- **Balance:** termination + AI-duel seed tests pass; if the handbrake behavior shifts the win distribution outside the suite's expectations, rebalance the planner's placid-state build priorities (not the test thresholds) first. +- **UI:** Portrait test updated for `burnem`; Setup renders the new tile. + +## 6. Out of scope + +- Any other cast changes; commissioned art; Framer Motion; changes to the handbrake concept for other leaders. +- Editing historical specs/plans that mention Starmless. From 8e35b4e27fa3155136d956da14aa88303917b65e Mon Sep 17 00:00:00 2001 From: gethi Date: Sun, 19 Jul 2026 17:23:20 +0100 Subject: [PATCH 33/43] docs: implementation plan for Burn'em leader swap Co-Authored-By: Claude Fable 5 --- .../plans/2026-07-19-burnem-leader-swap.md | 440 ++++++++++++++++++ 1 file changed, 440 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-19-burnem-leader-swap.md diff --git a/docs/superpowers/plans/2026-07-19-burnem-leader-swap.md b/docs/superpowers/plans/2026-07-19-burnem-leader-swap.md new file mode 100644 index 0000000..d608826 --- /dev/null +++ b/docs/superpowers/plans/2026-07-19-burnem-leader-swap.md @@ -0,0 +1,440 @@ +# Burn'em Leader Swap Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace Starmless with Burn'em (Andy Burnham parody): full id swap, new handbrake-turn AI personality, new flavour bank, new portrait. + +**Architecture:** Mechanical rename first (suite stays green at every commit), then content/behavior swaps in separate commits: flavour bank, planner, portrait/UI copy. Provoked state derives from the existing persistent `grudge` map — no new engine state. + +**Tech Stack:** TypeScript 5 engine (pure functions, seeded RNG), vitest, React 18 UI. + +**Spec:** `docs/superpowers/specs/2026-07-19-burnem-leader-swap-design.md` +**Research:** `docs/superpowers/research/2026-07-19-burnham-research.md` + +## Global Constraints + +- Every commit typechecks (`npm run typecheck`) and passes `npm run test:run`. +- No Hillsborough references, no religious markers, no Disraeli "What Manchester does today…" line in any flavour content. +- Flavour register: most lines end in a tag question ("…isn't it?" / "…don't we?" / "…can't it?"); handbrake lines pivot blokey→apocalyptic with no transition. +- After the swap, `grep -ri starmless src/ tests/` returns zero hits. Historical docs/ are not edited. +- Product emojis in UI copy are design language — keep them. +- Tests: no `if (cond) expect(...)` guarded assertions. + +## Guardrails (better-memory) + +- **[[31c145bf]]** (conf 0.75, 3 evid): multi-consume order batches need a local projection counter; emit producers BEFORE consumers — `buildToward`/`launchSalvo` already institutionalise this; keep `[...build.orders, ...salvo.orders]` ordering. +- **[[0409087f]]** (conf 0.55): never-attack-first planners stall 1v1 endgames — the patience fallback in Task 3 exists because of this; the headless termination test is the tripwire. +- **[[ef7d086e]]** (conf 0.55): planner/behavior changes silently shift lookahead/scoring tests outside the stated file list — run the full engine suite, not just burnem tests. +- **[[063500c1]]** (conf 0.7): unconditional assertions only. +- **[[326f4462]]** (conf 0.55): imports added only in the commit that uses them. + +## File Structure + +``` +Task 1 (rename): src/engine/types.ts, balance.ts, ai/{starmless→burnem}.ts, ai/dispatch.ts, + flavor/{starmless→burnem}.ts, flavor/index.ts, flavor/disparage.ts, + src/ui/{portraits/*, content/cast.ts, util/newspaper.ts, components/WorldMap.tsx, + screens/AiConferring.tsx}, tests/** (33 files, 162 occurrences) +Task 2 (flavour): src/engine/flavor/burnem.ts (rewrite), flavor/disparage.ts, tests/engine/flavor/pick.test.ts +Task 3 (planner): src/engine/ai/burnem.ts (rewrite), src/engine/balance.ts (drop scapegoat weight), + tests/engine/ai/burnem.test.ts (rewrite) +Task 4 (portrait/UI): src/ui/portraits/faces.tsx, portraits/index.ts, tokens.css, content/cast.ts, + util/newspaper.ts copy +Task 5 (docs+verify): docs/superpowers/flavour-bank.md, full sweep +``` + +--- + +### Task 1: Mechanical rename — starmless → burnem, behavior unchanged + +**Files:** all 33 files matching `starmless` (case-insensitive) under `src/` and `tests/` (162 occurrences; enumerate with `grep -ril starmless src tests`). + +**Interfaces:** +- Produces: `LeaderId` member `'burnem'`; `LEADER_PROFILES.burnem` (name `"Burn'em"`, country `'🇬🇧 UK'`, stats unchanged 25/6/6); `planBurnem` (old Starmless logic, renamed); `burnemBank` (old lines, renamed export); files `src/engine/ai/burnem.ts`, `src/engine/flavor/burnem.ts`, `tests/engine/ai/burnem.test.ts`. + +- [ ] **Step 1: Rename files** + +```bash +git mv src/engine/ai/starmless.ts src/engine/ai/burnem.ts +git mv src/engine/flavor/starmless.ts src/engine/flavor/burnem.ts +git mv tests/engine/ai/starmless.test.ts tests/engine/ai/burnem.test.ts +``` + +- [ ] **Step 2: Global token replacements** (in the renamed files and all other hits) + +Case-sensitive, whole-word-aware, in this order: +1. `'starmless'` → `'burnem'` (id literals) +2. `planStarmless` → `planBurnem`; `starmlessBank` → `burnemBank`; `StarmlessFace` → `BurnemFace` +3. `STARMLESS_` constant prefixes → `BURNEM_`; `starmlessScapegoatPct` → `burnemScapegoatPct` (removed in Task 3, renamed here so the suite stays green) +4. Display strings: `Starmless` → `Burn'em` (LEADER_PROFILES name, UI copy, newspaper/market/obit copy, test assertion strings that check display names) +5. Comments mentioning Starmless in surviving files: update to Burn'em (historical accuracy notes may say "formerly Starmless"). + +`content/cast.ts` keeps its old profile/mood text this task (Task 4 replaces it). `flavor/burnem.ts` keeps old lines this task (Task 2 replaces them). + +- [ ] **Step 3: Verify zero stragglers + suite green** + +Run: `grep -ri starmless src tests` — Expected: no output. +Run: `npm run typecheck && npm run test:run` — Expected: clean, all pass (display-name assertions updated in step 2.4). + +- [ ] **Step 4: Commit** + +```bash +git add -A +git commit -m "refactor(engine,ui): rename starmless leader id to burnem (Burn'em), behavior unchanged" +``` + +--- + +### Task 2: Burn'em flavour bank + +**Files:** +- Rewrite: `src/engine/flavor/burnem.ts` +- Modify: `src/engine/flavor/disparage.ts` (snap-back entry for burnem) +- Test: `tests/engine/flavor/pick.test.ts` (only if it asserts specific starmless-era line text) + +**Interfaces:** +- Consumes: `FlavorBank` type from `./index` (same shape as current file: `preRoundMood`, `preRoundMoodSnapBack`, `launch`, `hit`, `woo`, `beingWooed`, `propagandaSend`, `propagandaReceive`, `buildFactory`, `buildDefence`, `reaction`, `death`, `finalRetaliation`). +- Produces: `burnemBank: FlavorBank`. +- Note: the `{scapegoat}` substitution token is a Starmless-ism — the new bank does NOT use it (pick.ts filters such lines only when present; absence is fine). + +- [ ] **Step 1: Write the new bank** — full replacement of `burnemBank`: + +```ts +import type { FlavorBank } from './index'; + +// Burn'em — King in t' North. Register rules (see spec §3): tag questions on +// most lines; "handbrake turn" lines pivot blokey→apocalyptic with no +// transition; northern grievance + boosterism; bus obsession. Hard exclusions: +// no Hillsborough, no religious markers, no "What Manchester does today…". +export const burnemBank: FlavorBank = { + preRoundMood: [ + 'We need to bring people with us, don\'t we?', + 'The North is back. Big time.', + 'It\'s not arrogance, it\'s just confidence.', + 'The buses run on time now. That\'s how civilisations survive, isn\'t it?', + 'On bended knee, begging for scraps. Well. Not anymore, are we?', + ], + preRoundMoodSnapBack: + 'I\'ve heard what he said. We rise above it up here, don\'t we? Anyway, he\'s barred from the tram.', + launch: [ + 'Let me be clear — they carried on, so I\'m using nuclear force. Anyway. How\'s the family?', + 'I\'ve been very patient, haven\'t I? Well. Patience is a renewable resource. This isn\'t.', + 'They didn\'t leave us a choice down in Whitehall— sorry, force of habit. THEY didn\'t leave us a choice.', + 'This one\'s for every town that ever lost its bus route. It\'s fair, that, isn\'t it?', + ], + hit: [ + 'That\'s a kindness taken for weakness, that. Big mistake. Massive.', + 'We\'ve been hit. The handbrake\'s off now, isn\'t it?', + ], + woo: [ + 'Come up north. We\'ll do the match, couple of pints, £2 bus home. That\'s diplomacy, that.', + 'I\'m offering you a franchise partnership. Not the buses. The buses are ours.', + ], + beingWooed: [ + 'That\'s very warm, that. We take friendship seriously up here, don\'t we?', + ], + propagandaSend: [ + 'We\'ve sent over some leaflets. Just facts about our transport network. Devastating, honest facts.', + ], + propagandaReceive: [ + 'They\'re calling me a plastic Northerner. From a bunker. In London. You couldn\'t write it, could you?', + ], + buildFactory: [ + 'New factory. Good growth in every postcode, hope in every heart. And a bus stop right outside.', + 'They didn\'t build this for us down in Whitehall. We built it ourselves, didn\'t we?', + 'To get on in life, I had to head South. Nobody should have to. So we\'re building here.', + ], + buildDefence: [ + 'We\'re putting a shield up. It\'s not paranoia, it\'s just confidence, isn\'t it?', + ], + reaction: [ + 'I\'m watching all this very calmly. Very calmly indeed. Aren\'t I?', + 'Everything\'s connected to the buses if you look closely enough, isn\'t it?', + ], + death: [ + 'Tell them… the North remembers… and tell them the 135 to Bury still runs on ti—', + ], + finalRetaliation: [ + 'Right. Last orders, everyone. Last orders. It didn\'t have to be like this, did it?', + ], +}; +``` + +- [ ] **Step 2: Disparage snap-back** — in `flavor/disparage.ts`, replace the burnem-targeted line (renamed in Task 1) with: + +```ts + burnem: 'King in t\' North? He\'s from MERSEYSIDE. I\'ve seen more of Manchester from the airport bar.', +``` + +(Match the file's existing per-leader snap-back structure — key renamed in Task 1, text replaced here.) + +- [ ] **Step 3: Run flavour tests** + +Run: `npx vitest run tests/engine/flavor` — Expected: PASS. If `pick.test.ts` asserted old Starmless line text or `{scapegoat}` behavior via the starmless bank, repoint those assertions at a bank that still uses `{scapegoat}` (khameneverhere) or at the new burnem lines. + +- [ ] **Step 4: Full suite + commit** + +Run: `npm run typecheck && npm run test:run` — Expected: clean / all pass. + +```bash +git add src/engine/flavor tests/engine/flavor +git commit -m "feat(engine): Burn'em flavour bank — tag questions and handbrake turns" +``` + +--- + +### Task 3: Handbrake-turn planner + +**Files:** +- Rewrite: `src/engine/ai/burnem.ts` +- Modify: `src/engine/balance.ts` (delete `burnemScapegoatPct` from `AI_SCORING_WEIGHTS`), `tests/engine/balance.test.ts` (if it asserts that weight) +- Rewrite: `tests/engine/ai/burnem.test.ts` + +**Interfaces:** +- Consumes: `threatScore(state, viewer, target)`, from `./scoring`; `buildToward(state, leaderId, plan, budget)`, `launchSalvo(state, leaderId, {budget, rankedTargets, maxLaunches})` from `./aggression`; `validateOrder` from `../orders`; `Leader.grudge` (persistent, bumped only by landed impacts — resolution.ts:249-260). +- Produces: `planBurnem(state: GameState, leaderId: LeaderId): Order[]` — same signature dispatch.ts already calls. + +- [ ] **Step 1: Rewrite the test file first** — `tests/engine/ai/burnem.test.ts`. Reuse the state-builder helpers the old starmless test used (read the Task-1-renamed file before deleting its body; other per-planner tests, e.g. `tests/engine/ai/netanyahoo.test.ts`, use the same pattern). Cases: + +1. **Placid, unprovoked (round < 6, 3+ alive):** no `grudge` entries against anyone → orders contain zero `launch` orders; contain at least one `woo` order targeting the other leader with the lowest `favourability` value on Burn'em's record; may contain `build-*` orders. +2. **Provoked by impact:** set `state.leaders.burnem.grudge.chump = 2`, give Burn'em stockpiled missiles+warheads → orders contain at least one launch, and EVERY launch order targets `'chump'`. Assert `orders.filter(o => o.kind === 'launch').length >= 1` unconditionally, then assert all targets. +3. **Provocation is permanent + redirects:** grudge vs chump but chump dead → launches target the top surviving grudge/threat target instead; assert launches exist and target the expected survivor. +4. **Patience fallback (round):** round = 6, no grudges, 3 alive, armed → launches exist, targeting the highest-`threatScore` rival. +5. **Patience fallback (2 survivors):** round 2, no grudges, only Burn'em + one other alive, armed → launches exist. +6. **Producers before consumers:** in any provoked-state order list containing both `build-*` and `launch` orders, the last `build-*` index < first `launch` index. Assert both indices > -1 first (no vacuous pass). + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx vitest run tests/engine/ai/burnem.test.ts` +Expected: FAIL (old Cautious+Scapegoat logic doesn't match). + +- [ ] **Step 3: Implement `planBurnem`** — full replacement: + +```ts +import type { GameState, LeaderId, Order } from '../types'; +import { apCostOf, validateOrder } from '../orders'; +import { threatScore } from './scoring'; +import { buildToward, launchSalvo, type BuildPlanEntry } from './aggression'; + +/** + * Burn'em — the Handbrake Turn (spec 2026-07-19 §2). + * + * Placid by default: woos, builds factories and defence, banks AP, never + * launches first. The first landed hit on him (persistent grudge > 0) flips + * him permanently into full aggression against the provoker. Patience + * fallback (round >= 6 unprovoked, or 2 survivors) prevents the + * never-attack-first 1v1 stall (cf. Netanyahoo endgame fix). + */ +const DEPLOY_COST = 4; +const BURNEM_PATIENCE_ROUND = 6; +const BURNEM_MAX_LAUNCHES = 4; + +const PLACID_BUILD_PLAN: BuildPlanEntry[] = [ + { build: { item: 'factory' }, target: 8 }, + { build: { item: 'defence', type: 'shield' }, target: 2 }, +]; + +// Missile before warheads: delivery-first avoids the armed-but-undeliverable +// zero-fire failure. Producers precede consumers in the emitted batch. +const PROVOKED_BUILD_PLAN: BuildPlanEntry[] = [ + { build: { item: 'missile' }, target: 3 }, + { build: { item: 'warhead', yield: 'medium' }, target: 2 }, + { build: { item: 'warhead', yield: 'small' }, target: 2 }, + { build: { item: 'factory' }, target: 7 }, +]; + +export function planBurnem(state: GameState, leaderId: LeaderId): Order[] { + const me = state.leaders[leaderId]; + if (!me || !me.alive) return []; + + let budget = me.ap; + const others = state.cast.filter((t) => t !== leaderId && state.leaders[t]?.alive); + + // --- Provocation: any surviving leader who ever landed a hit (grudge persists). --- + const provokers = others.filter((t) => (me.grudge[t] ?? 0) > 0); + let target: LeaderId | undefined; + if (provokers.length > 0) { + target = provokers.reduce((best, t) => + (me.grudge[t] ?? 0) > (me.grudge[best] ?? 0) ? t : best, + ); + } else if (me.grudge && Object.entries(me.grudge).some(([id, g]) => (g ?? 0) > 0)) { + // Provoker(s) dead: stay provoked, redirect to the strongest survivor. + target = pickStrongestRival(state, leaderId, others); + } else if (state.round >= BURNEM_PATIENCE_ROUND || others.length === 1) { + // Patience fallback — deadlock guard. + target = pickStrongestRival(state, leaderId, others); + } + + const orders: Order[] = []; + + if (target !== undefined) { + // --- Provoked: handbrake off. --- + const build = buildToward(state, leaderId, PROVOKED_BUILD_PLAN, budget); + const salvo = launchSalvo(state, leaderId, { + budget: budget - build.apSpent, + rankedTargets: [target], + maxLaunches: BURNEM_MAX_LAUNCHES, + }); + budget -= build.apSpent + salvo.apSpent; + orders.push(...build.orders, ...salvo.orders); + } else { + // --- Placid: friendliest man in the apocalypse. --- + const wooTarget = others.reduce((best, t) => + (me.favourability[t] ?? 0) < (me.favourability[best] ?? 0) ? t : best, + others[0]); + if (wooTarget !== undefined) { + const woo: Order = { kind: 'woo', target: wooTarget }; + if (budget >= apCostOf(woo) && validateOrder(state, leaderId, woo).ok) { + orders.push(woo); + budget -= apCostOf(woo); + } + } + const build = buildToward(state, leaderId, PLACID_BUILD_PLAN, budget); + budget -= build.apSpent; + orders.push(...build.orders); + if (me.stockpile.shields >= 1 && budget >= DEPLOY_COST) { + const deploy: Order = { kind: 'deploy-defence', type: 'shield' }; + if (validateOrder(state, leaderId, deploy).ok) { + orders.push(deploy); + budget -= DEPLOY_COST; + } + } + // Remaining AP banks implicitly. + } + + return orders; +} + +function pickStrongestRival( + state: GameState, viewer: LeaderId, others: LeaderId[], +): LeaderId | undefined { + if (others.length === 0) return undefined; + return others.reduce((best, t) => + threatScore(state, viewer, t) > threatScore(state, viewer, best) ? t : best, + ); +} +``` + +Adjust `Order` literal shapes (`woo`, `deploy-defence`, build kinds) to the exact union in `src/engine/orders.ts`/`types.ts` — copy the shapes the other planners emit; do not invent kinds. If `state.round` is named differently, use the actual field. + +- [ ] **Step 4: Remove the scapegoat weight** + +Delete `burnemScapegoatPct` from `AI_SCORING_WEIGHTS` in `balance.ts`; fix `balance.test.ts` if it enumerates weights. + +- [ ] **Step 5: Run the new planner tests** + +Run: `npx vitest run tests/engine/ai/burnem.test.ts` — Expected: PASS (6 tests). + +- [ ] **Step 6: Full engine suite — watch the blast radius** + +Run: `npm run test:run` — Expected: all pass. Pay attention to: `ai-duel.test.ts` (80-seed win breakdown), termination/integration tests, `lookahead.test.ts`, `dispatcher.test.ts`. If the duel distribution breaks its soft expectations, tune `PROVOKED_BUILD_PLAN` targets / `BURNEM_MAX_LAUNCHES` / `BURNEM_PATIENCE_ROUND` — do NOT loosen test thresholds. + +- [ ] **Step 7: Commit** + +```bash +git add src/engine/ai/burnem.ts src/engine/balance.ts tests/engine/ai/burnem.test.ts tests/engine/balance.test.ts +git commit -m "feat(engine): Burn'em handbrake-turn planner with patience fallback" +``` + +--- + +### Task 4: Portrait + UI content + +**Files:** +- Modify: `src/ui/portraits/faces.tsx` (replace `BurnemFace` body), `src/ui/portraits/index.ts` (PORTRAIT_META color), `src/ui/tokens.css` (`--c-starm` → `--c-burnem`), `src/ui/content/cast.ts`, `src/ui/util/newspaper.ts` (any remaining display copy), `src/ui/components/WorldMap.tsx` (country label stays UK — verify only) +- Test: `tests/ui/Portrait.test.tsx` (add/adjust a burnem case if it enumerated leaders) + +**Interfaces:** +- Consumes: `FACES`/`PORTRAIT_META` records from slice-1; token `--c-burnem`. +- Produces: `BurnemFace(): JSX.Element` keyed as `FACES.burnem`. + +- [ ] **Step 1: tokens.css** — rename `--c-starm: #8a1729;` to `--c-burnem: #8a1729;` (Task 1 may have already renamed the var name; ensure value unchanged and no `--c-starm` references remain: `grep -r "c-starm" src`). + +- [ ] **Step 2: Replace `BurnemFace`** in `faces.tsx` (modeled on the "VOTE ANDY FOR US" placard caricature: long pale face, flat dark side-sweep, heavy rectangular brows, rectangular glasses, stern flat mouth, black zip-up, mini placard prop): + +```tsx +export function BurnemFace() { + return ( + + ); +} +``` + +- [ ] **Step 3: `content/cast.ts`** — replace the burnem entry: + +```ts + burnem: { profile: "King in t' North", mood: "We need to bring people with us, don't we?" }, +``` + +- [ ] **Step 4: Sweep remaining display copy** + +`grep -rn "Burn'em\|burnem" src/ui` — verify newspaper market ticker/obit copy reads naturally with the new name (ticker sym e.g. `BRN`), Setup tile shows "UK · KING IN T' NORTH". Adjust `tests/ui/Portrait.test.tsx` if it enumerated cast ids. + +- [ ] **Step 5: Verify + commit** + +Run: `npm run typecheck && npm run test:run` — Expected: clean / all pass. +Run `npm run dev` and eyeball the Setup tile + an Action event with Burn'em. + +```bash +git add src/ui tests/ui +git commit -m "feat(ui): Burn'em portrait (campaign-placard caricature) and cast copy" +``` + +--- + +### Task 5: Docs + final sweep + +- [ ] **Step 1:** Update `docs/superpowers/flavour-bank.md` cast table: Starmless row → Burn'em (Cautious+Scapegoat → "Handbrake Turn — placid until provoked, then total"), note the register rules (tag questions, handbrake pivot, Hillsborough exclusion). +- [ ] **Step 2:** `grep -ri starmless src tests` → zero hits. `npm run test:run && npm run typecheck && npm run build` → green. +- [ ] **Step 3:** `graphify update .` +- [ ] **Step 4:** Commit: + +```bash +git add docs/superpowers/flavour-bank.md +git commit -m "docs: flavour-bank cast table — Burn'em replaces Starmless" +``` + +--- + +## Self-Review Notes + +- Spec coverage: §1 rename → Task 1; §2 planner → Task 3; §3 flavour → Task 2; §4 portrait/UI → Task 4; §5 testing → embedded per task; §6 docs → Task 5. +- Type consistency: `planBurnem` signature matches dispatch.ts call; `burnemBank` matches `FlavorBank`; `BurnemFace` slots into the `FACES` record from slice 1. Order-literal shapes flagged for verification against the real union rather than guessed. +- Flavour content pre-checked against exclusions: no Hillsborough, no religious markers, no Disraeli line; tag-question density satisfied; `{scapegoat}` token absent by design. From ca23d8a2c1312c1ed9d3cc71222800b228b70ae1 Mon Sep 17 00:00:00 2001 From: gethi Date: Sun, 19 Jul 2026 17:33:12 +0100 Subject: [PATCH 34/43] refactor(engine,ui): rename starmless leader id to burnem (Burn'em), behavior unchanged --- src/engine/ai/{starmless.ts => burnem.ts} | 18 ++--- src/engine/ai/dispatch.ts | 4 +- src/engine/balance.ts | 8 +-- src/engine/flavor/{starmless.ts => burnem.ts} | 2 +- src/engine/flavor/index.ts | 4 +- src/engine/types.ts | 2 +- src/ui/components/WorldMap.tsx | 4 +- src/ui/content/cast.ts | 2 +- src/ui/portraits/faces.tsx | 2 +- src/ui/portraits/index.ts | 6 +- src/ui/screens/AiConferring.tsx | 2 +- src/ui/tokens.css | 2 +- src/ui/util/newspaper.ts | 4 +- tests/engine/ai-duel.test.ts | 2 +- tests/engine/ai/aggression.test.ts | 4 +- .../ai/{starmless.test.ts => burnem.test.ts} | 72 +++++++++---------- tests/engine/ai/carnage.test.ts | 14 ++-- tests/engine/ai/dispatcher.test.ts | 10 +-- tests/engine/ai/lookahead.test.ts | 12 ++-- tests/engine/ai/netanyahoo.test.ts | 4 +- tests/engine/balance.test.ts | 4 +- tests/engine/cameo.test.ts | 4 +- tests/engine/determinism.test.ts | 6 +- tests/engine/finalRetaliation.test.ts | 54 +++++++------- tests/engine/flavor/pick.test.ts | 4 +- tests/engine/integration.test.ts | 2 +- tests/engine/reducer.test.ts | 4 +- tests/engine/resolution.test.ts | 16 ++--- tests/engine/state.test.ts | 10 +-- tests/engine/winConditions.test.ts | 18 ++--- tests/ui/Action.playbackSteps.test.ts | 4 +- tests/ui/Setup.multihuman.test.tsx | 4 +- tests/ui/WorldMap.test.tsx | 2 +- tests/ui/newspaper.test.ts | 16 ++--- 34 files changed, 163 insertions(+), 163 deletions(-) rename src/engine/ai/{starmless.ts => burnem.ts} (88%) rename src/engine/flavor/{starmless.ts => burnem.ts} (98%) rename tests/engine/ai/{starmless.test.ts => burnem.test.ts} (57%) diff --git a/src/engine/ai/starmless.ts b/src/engine/ai/burnem.ts similarity index 88% rename from src/engine/ai/starmless.ts rename to src/engine/ai/burnem.ts index 1513dbb..a38c918 100644 --- a/src/engine/ai/starmless.ts +++ b/src/engine/ai/burnem.ts @@ -6,7 +6,7 @@ import { nextRandom } from '../rng'; import { buildToward, launchSalvo, type BuildPlanEntry } from './aggression'; /** - * Starmless — Cautious + Scapegoat personality (P4c.2 rework). + * Burn'em — Cautious + Scapegoat personality (P4c.2 rework). * * Defensive baseline, but with a new kill instinct: launches when retaliating * OR when a finishable (low-population) opponent exists. Low launch cap — he @@ -14,21 +14,21 @@ import { buildToward, launchSalvo, type BuildPlanEntry } from './aggression'; */ const PROPAGANDA_COST = 1; const DEPLOY_COST = 4; -const STARMLESS_FINISH_POP_M = 8; -const STARMLESS_MAX_LAUNCHES = 2; +const BURNEM_FINISH_POP_M = 8; +const BURNEM_MAX_LAUNCHES = 2; // Factory target 7 (starts at 6) so at most one factory is built per low-AP // round — leaving budget for the missile + warhead stock the kill instinct // needs. A plan with warheads but NO delivery vehicle would recreate the // zero-fire bug, so the missile entry is mandatory. -const STARMLESS_BUILD_PLAN: BuildPlanEntry[] = [ +const BURNEM_BUILD_PLAN: BuildPlanEntry[] = [ { build: { item: 'factory' }, target: 7 }, { build: { item: 'missile' }, target: 2 }, { build: { item: 'warhead', yield: 'small' }, target: 3 }, { build: { item: 'defence', type: 'shield' }, target: 2 }, ]; -export function planStarmless(state: GameState, leaderId: LeaderId): Order[] { +export function planBurnem(state: GameState, leaderId: LeaderId): Order[] { const me = state.leaders[leaderId]; if (!me || !me.alive) return []; @@ -54,7 +54,7 @@ export function planStarmless(state: GameState, leaderId: LeaderId): Order[] { } // Scapegoat roll (reads rngState without advancing shared state). const roll = nextRandom(state.rngState).value; - const doScapegoat = roll < AI_SCORING_WEIGHTS.starmlessScapegoatPct; + const doScapegoat = roll < AI_SCORING_WEIGHTS.burnemScapegoatPct; if (doScapegoat) { const candidates = others.filter((t) => t !== primaryAttacker); if (candidates.length > 0) { @@ -72,7 +72,7 @@ export function planStarmless(state: GameState, leaderId: LeaderId): Order[] { } else { // New P4c.2 kill instinct: finish off a low-population opponent. const finishable = others - .filter((t) => state.leaders[t].population <= STARMLESS_FINISH_POP_M) + .filter((t) => state.leaders[t].population <= BURNEM_FINISH_POP_M) .sort((a, b) => state.leaders[a].population - state.leaders[b].population); if (finishable.length > 0) launchTarget = finishable[0]; } @@ -87,10 +87,10 @@ export function planStarmless(state: GameState, leaderId: LeaderId): Order[] { const salvo = launchSalvo(state, leaderId, { budget: offenceBudget, rankedTargets, - maxLaunches: STARMLESS_MAX_LAUNCHES, + maxLaunches: BURNEM_MAX_LAUNCHES, }); const build = buildToward( - state, leaderId, STARMLESS_BUILD_PLAN, offenceBudget - salvo.apSpent, + state, leaderId, BURNEM_BUILD_PLAN, offenceBudget - salvo.apSpent, ); budget -= salvo.apSpent + build.apSpent; diff --git a/src/engine/ai/dispatch.ts b/src/engine/ai/dispatch.ts index 3d68d74..5efd44a 100644 --- a/src/engine/ai/dispatch.ts +++ b/src/engine/ai/dispatch.ts @@ -3,7 +3,7 @@ import { planChump } from './chump'; import { planCarnage } from './carnage'; import { planKhameneverhere } from './khameneverhere'; import { planNetanyahoo } from './netanyahoo'; -import { planStarmless } from './starmless'; +import { planBurnem } from './burnem'; import { planMileighHem } from './mileighhem'; /** @@ -18,7 +18,7 @@ export function dispatch(state: GameState, leaderId: LeaderId): Order[] { case 'carnage': return planCarnage(state, leaderId); case 'khameneverhere': return planKhameneverhere(state, leaderId); case 'netanyahoo': return planNetanyahoo(state, leaderId); - case 'starmless': return planStarmless(state, leaderId); + case 'burnem': return planBurnem(state, leaderId); case 'mileigh-hem': return planMileighHem(state, leaderId); case 'player1': case 'player2': diff --git a/src/engine/balance.ts b/src/engine/balance.ts index c5663e4..dd5ce43 100644 --- a/src/engine/balance.ts +++ b/src/engine/balance.ts @@ -26,8 +26,8 @@ export const LEADER_PROFILES: Record< startFactories: 6, startAp: 6, }, - starmless: { - name: 'Starmless', + burnem: { + name: 'Burn\'em', country: '🇬🇧 UK', startPop: 25, startFactories: 6, @@ -145,8 +145,8 @@ export const AI_SCORING_WEIGHTS = { }, // Carnage's escalation multiplier on attacker's threat next round. carnageEscalationMultiplier: 2, - // Starmless's scapegoat probability on retaliation (35 %). - starmlessScapegoatPct: 0.35, + // Burn'em's scapegoat probability on retaliation (35 %). + burnemScapegoatPct: 0.35, // Mileigh-hem's all-out activation trigger. mileighActivationApThreshold: 4, // Khameneverhere grudge weight per impact (multiplied by warhead yield index 1/2/4). diff --git a/src/engine/flavor/starmless.ts b/src/engine/flavor/burnem.ts similarity index 98% rename from src/engine/flavor/starmless.ts rename to src/engine/flavor/burnem.ts index cd9a651..bf908ee 100644 --- a/src/engine/flavor/starmless.ts +++ b/src/engine/flavor/burnem.ts @@ -3,7 +3,7 @@ import type { FlavorBank } from './index'; // Scapegoat-substitution lines embed `{scapegoat}` — the picker substitutes a // leader name at call time. Where no scapegoat applies, pick.ts treats the // raw {scapegoat}-bearing string as ineligible (filtered before random draw). -export const starmlessBank: FlavorBank = { +export const burnemBank: FlavorBank = { preRoundMood: [ 'Long-term decisions. Sound foundations. Working people.', 'So, let me be very clear…', diff --git a/src/engine/flavor/index.ts b/src/engine/flavor/index.ts index 8688d09..bf10f3b 100644 --- a/src/engine/flavor/index.ts +++ b/src/engine/flavor/index.ts @@ -3,7 +3,7 @@ import { chumpBank } from './chump'; import { khameneverhereBank } from './khameneverhere'; import { netanyahooBank } from './netanyahoo'; import { carnageBank } from './carnage'; -import { starmlessBank } from './starmless'; +import { burnemBank } from './burnem'; import { mileighhemBank } from './mileighhem'; export interface FlavorBank { @@ -29,7 +29,7 @@ const BANKS: Partial> = { khameneverhere: khameneverhereBank, netanyahoo: netanyahooBank, carnage: carnageBank, - starmless: starmlessBank, + burnem: burnemBank, 'mileigh-hem': mileighhemBank, }; diff --git a/src/engine/types.ts b/src/engine/types.ts index 641d522..cda2ba1 100644 --- a/src/engine/types.ts +++ b/src/engine/types.ts @@ -1,7 +1,7 @@ export type LeaderId = | 'chump' | 'khameneverhere' - | 'starmless' + | 'burnem' | 'carnage' | 'mileigh-hem' | 'netanyahoo' diff --git a/src/ui/components/WorldMap.tsx b/src/ui/components/WorldMap.tsx index c492b78..5d871cc 100644 --- a/src/ui/components/WorldMap.tsx +++ b/src/ui/components/WorldMap.tsx @@ -16,7 +16,7 @@ export interface CountryPos { export const COUNTRY_POS: Record = { chump: { cx: 23, cy: 36, label: 'USA' }, carnage: { cx: 24, cy: 24, label: 'CANADA' }, - starmless: { cx: 44, cy: 13, label: 'UK' }, + burnem: { cx: 44, cy: 13, label: 'UK' }, netanyahoo: { cx: 58, cy: 40, label: 'ISR' }, khameneverhere: { cx: 64, cy: 38, label: 'IRAN' }, 'mileigh-hem': { cx: 33, cy: 78, label: 'ARG' }, @@ -216,7 +216,7 @@ export default function WorldMap({ game, event }: WorldMapProps) { ? HUMAN_ACCENTS[id] : PORTRAIT_META[id].color; const label = p.label ?? stripFlag(game.leaders[id].country).toUpperCase(); - const isUK = id === 'starmless'; + const isUK = id === 'burnem'; return ( = { chump: { profile: 'Coward', mood: "Many people are saying we should hit somebody. We won't, but we should." }, khameneverhere: { profile: 'Grudge', mood: 'Orders filed. Orders unsigned.' }, - starmless: { profile: 'Cautious', mood: 'So, let me be very clear…' }, + burnem: { profile: 'Cautious', mood: 'So, let me be very clear…' }, carnage: { profile: 'Rational', mood: 'Nostalgia is not a strategy.' }, 'mileigh-hem': { profile: 'Glass cannon', mood: 'Banking. Cooking. ¡Viva la libertad, carajo!' }, netanyahoo: { profile: 'Warmonger', mood: 'Drawing a fresh red line on the bomb diagram.' }, diff --git a/src/ui/portraits/faces.tsx b/src/ui/portraits/faces.tsx index 71e3636..3ebbd30 100644 --- a/src/ui/portraits/faces.tsx +++ b/src/ui/portraits/faces.tsx @@ -92,7 +92,7 @@ export function KhameneverhereFace(): JSX.Element { ); } -export function StarmlessFace(): JSX.Element { +export function BurnemFace(): JSX.Element { return (