diff --git a/docs/superpowers/flavour-bank.md b/docs/superpowers/flavour-bank.md index 0e15036..ae32164 100644 --- a/docs/superpowers/flavour-bank.md +++ b/docs/superpowers/flavour-bank.md @@ -12,7 +12,7 @@ In-game characters are parody names. Real-world inspiration listed for context o | **Chump** | ๐Ÿ‡บ๐Ÿ‡ธ US | Donald Trump | | **Netanyahoo** | ๐Ÿ‡ฎ๐Ÿ‡ฑ Israel | Benjamin Netanyahu | | **Khameneverhere** | ๐Ÿ‡ฎ๐Ÿ‡ท Iran | Mojtaba Khamenei (the never-seen successor) | -| **Starmless** | ๐Ÿ‡ฌ๐Ÿ‡ง UK | Keir Starmer | +| **Burn'em** | ๐Ÿ‡ฌ๐Ÿ‡ง UK | Andy Burnham | | **Carnage** | ๐Ÿ‡จ๐Ÿ‡ฆ Canada | Mark Carney | | **Mileigh-hem** | ๐Ÿ‡ฆ๐Ÿ‡ท Argentina | Javier Milei | @@ -214,59 +214,60 @@ Grudge profile. Tracks attackers, retaliates randomly against anyone on grudge l --- -## Starmless ๐Ÿ‡ฌ๐Ÿ‡ง +## Burn'em ๐Ÿ‡ฌ๐Ÿ‡ง -Cautious technocrat + scapegoat. Defensive, often invests in factories. On retaliation, 35% chance to scapegoat. "Creepy-uncle-from-1978" delivery on jokes. +Handbrake Turn โ€” placid until provoked (patience round 3 / 2-survivor fallback), then permanent full aggression. King in t' North caricature. Signature tic: the large majority of lines end in a tag question ("โ€ฆisn't it?" / "โ€ฆdon't we?" / "โ€ฆcan't it?"). Hard exclusions: no Hillsborough references, no religious markers, no "What Manchester does todayโ€ฆ" (the refuted Disraeli line). ### Pre-round mood -- "Long-term decisions. Sound foundations. Working people." [style + real cadence] -- "So, let me be very clearโ€ฆ" [real, signature opener] -- "I'm going to be a show-up prime minister, not a showman." [real] -- *(adjusts tie awkwardly)* [system narration] +- "We need to bring people with us, don't we?" [style] +- "The North is back. Big time." [style] +- "It's not arrogance, it's just confidence." [real] +- "The buses run on time now. That's how civilisations survive, isn't it?" [style โ€” Bee Network / ยฃ2 fares] +- "On bended knee, begging for scraps. Well. Not anymore, are we?" [style] -### Launch (proportional, rarely) -- "So, let me be very clear: this is a measured, proportionate response." [style] -- "My dad was a toolmaker. He used tools. We are now using tools." [real-pattern dad joke remix] +### Pre-round mood (snap-back โ€” Disparage named him) +- "I've heard what he said. We rise above it up here, don't we? Anyway, he's barred from the tram." [style] -### Launch (scapegoat โ€” 35% on retaliation) -- "So, let me be very clear: intelligence suggests the real threat is **{scapegoat}**." [style โ€” `{scapegoat}` substitutes the chosen target] -- "Look, the simple fact is **{scapegoat}** has been emboldened by recent events." [style] -- "We must be honest with the British people: **{scapegoat}** is the actual aggressor here." [style] +### Launch (handbrake turn โ€” provoked) +- "Let me be clear โ€” they carried on, so I'm using nuclear force. Anyway. How's the family?" [style โ€” blokey opener, grave pivot, no transition, warm close] +- "I've been very patient, haven't I? Well. Patience is a renewable resource. This isn't." [style] +- "They didn't leave us a choice down in Whitehallโ€” sorry, force of habit. THEY didn't leave us a choice." [style] +- "This one's for every town that ever lost its bus route. It's fair, that, isn't it?" [style] ### Hit / damaged -- "So, let me be very clear: this attack will not stand. We will respond proportionately. Probably to {scapegoat}." [style] -- "Look, the simple fact is, we've been hit. That's just a fact." [style] +- "That's a kindness taken for weakness, that. Big mistake. Massive." [style] +- "We've been hit. The handbrake's off now, isn't it?" [style] ### Wooing send -- "We've been to IKEA. The shadow cabinet is now flat-pack. Happy to assist with assembly." [real-pattern, IKEA joke] -- "My dad was a toolmaker. I bring you, in friendship, a metaphorical tool." [style] +- "Come up north. We'll do the match, couple of pints, ยฃ2 bus home. That's diplomacy, that." [style] +- "I'm offering you a franchise partnership. Not the buses. The buses are ours." [style] ### Being wooed -- "I receive your wooing. Look, the simple fact is, friendship matters." [style] +- "That's very warm, that. We take friendship seriously up here, don't we?" [style] -### Propaganda send (against attackers only) -- "We've prepared a cross-government workstream on perception. Working people deserve clarity." [style] +### Propaganda send +- "We've sent over some leaflets. Just facts about our transport network. Devastating, honest facts." [style] ### Propaganda receive -- "So, let me be very clear: their propaganda is incorrect, and frankly, not very British." [style] +- "They're calling me a plastic Northerner. From a bunker. In London. You couldn't write it, could you?" [style โ€” Woolly Backer / more-Scouse-than-Manc authenticity gag] -### Build factory (Starmer's signature move) -- "Long-term decisions. Sound foundations. Investing in our future." [style + real] -- "We've broken ground on a new facility. My dad would be proud. He was a toolmaker." [style] -- "This is the change Britain voted for. Slowly, methodically, with proper costings." [style] +### Build factory +- "New factory. Good growth in every postcode, hope in every heart. And a bus stop right outside." [style] +- "They didn't build this for us down in Whitehall. We built it ourselves, didn't we?" [style] +- "To get on in life, I had to head South. Nobody should have to. So we're building here." [real-pattern] ### Build defence -- "We have signed a defence procurement agreement. The relevant minister will brief in due course." [style] +- "We're putting a shield up. It's not paranoia, it's just confidence, isn't it?" [style] ### Reaction (post-round) -- "Long-term decisions. Sound foundations. Working people." [style] -- "So, let me be very clear: intelligence suggests the real threat was **{scapegoat}** all along." [style โ€” when scapegoat applied] +- "I'm watching all this very calmly. Very calmly indeed. Aren't I?" [style] +- "Everything's connected to the buses if you look closely enough, isn't it?" [style] ### Death -- "Look, the simple fact is, this isn't ideal. I'd like to thank โ€” *(static)*" [style] +- "Tell themโ€ฆ the North remembersโ€ฆ and tell them the 135 to Bury still runs on tiโ€”" [style] ### Final Retaliation -- "So, let me be very clear: this is the final response. Probably to {scapegoat}." [style] +- "Right. Last orders, everyone. Last orders. It didn't have to be like this, did it?" [style] --- @@ -418,6 +419,7 @@ His column **never proposes a counterfactual** โ€” only criticises what just hap - "Make no mistake: this is what happens when you don't elect me. Pints." - "The Establishment did this. The Blob. The Quango. The Whoever. Not me." - "I told them. They didn't listen. They're paying for it now. So am I, in pints." +- "King in t' North? He's from MERSEYSIDE. I've seen more of Manchester from the airport bar." ### Footer absentee-MP notes (rotated) @@ -435,7 +437,7 @@ When Nigel Disparage's column criticises a specific leader by name, that leader' - **Mileigh-hem** (snap-back): "AFUERA, salesman!" - **Carnage** (snap-back): "Elbows up. Pint down." - **Netanyahoo** (snap-back): "Disparage's red line is the bar tab." -- **Starmless** (snap-back): "So, let me be very clear: I'm not engaging with that." +- **Burn'em** (snap-back): "I've heard what he said. We rise above it up here, don't we? Anyway, he's barred from the tram." - **Khameneverhere** (snap-back): *(no audio of Khameneverhere is available โ€” his cardboard cutout faces away from the GB News studio.)* --- 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. 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. 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). 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..15c07b0 --- /dev/null +++ b/docs/superpowers/specs/2026-05-20-visual-design-portrait-system-design.md @@ -0,0 +1,212 @@ +# nuke โ€” visual design direction & portrait system + +**Date:** 2026-05-20 +**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/`. + +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. + +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). + +### 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. + +### 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) + +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. +- **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. 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 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. + +--- + +## 8. Implementation references + +- `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. 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..4a19b54 --- /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. Provocation requires a *landed* hit โ€” grudge accrues on impact only; a fully intercepted salvo does not provoke. + +**Patience fallback (deadlock guard).** Treated as provoked (against the strongest surviving rival by threat score) when either: (a) round โ‰ฅ `BURNEM_PATIENCE_ROUND` 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). The round threshold is a balance-tuned constant, currently 3 (was 6 in the original draft; retuned per ยง5's balance clause after an 80-seed duel sweep showed 57.5% dominance). Two other levers were retuned alongside it after the same sweep: launches are capped at 2 per provoked round (`BURNEM_MAX_LAUNCHES`, down from an unthrottled 4), and the placid-state factory target is held flat at 6 (down from 8) โ€” together these stopped Burn'em from compounding AP via runaway factory growth while sitting out early fights. + +**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 the patience threshold (balance-tuned, currently round 3) โ†’ 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. 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..417ef0d --- /dev/null +++ b/docs/superpowers/specs/2026-07-19-comic-retheme-slice1-design.md @@ -0,0 +1,101 @@ +# 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 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 | +| 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/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`. +- 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)). + - **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` โ€” 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". 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. +- 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). +- **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) + +- 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. 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). 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..2658226 --- /dev/null +++ b/docs/superpowers/specs/2026-07-19-comic-retheme-slice3-design.md @@ -0,0 +1,220 @@ +# 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 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 + +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`) โ€” 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) | + +## 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). 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 | +|---|---|---| +| `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 | + +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 + +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, 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: + +| 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"`. **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. + +## 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 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; 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. + +## 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. 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..7a2bf31 --- /dev/null +++ b/docs/superpowers/specs/2026-07-19-comic-retheme-slice4-design.md @@ -0,0 +1,247 @@ +# 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. + +**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) + +- `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. + +> **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): + +- **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`. + +> **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): + +| Event | Score text | Color | +|---|---|---| +| `ImpactPeople` | `โˆ’{deaths}M` | `--magenta` | +| `ImpactInfrastructure` | `โˆ’{factoriesDestroyed} fac` | `--magenta` | +| `MissileIntercepted` | `INTERCEPT` | `--green` | + +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 + +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) **and story ordering** โ€” the derived story list puts the lead first, then the remaining living leaders in cast order (ยง3.4 DOM order); + - 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. 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/engine/ai/burnem.ts b/src/engine/ai/burnem.ts new file mode 100644 index 0000000..3d75fa6 --- /dev/null +++ b/src/engine/ai/burnem.ts @@ -0,0 +1,114 @@ +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, holds at his starting factory count, 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 >= 3 unprovoked, or 2 survivors) prevents the + * never-attack-first 1v1 stall (cf. Netanyahoo endgame fix). + * + * Duel-balance note: an unthrottled version of this planner (max launches 4, + * placid factory target 8, patience round 6) let Burn'em dominate the + * 80-seed AI-duel sweep (46/80 wins) โ€” he sat out early fights, compounded + * AP via a growing factory count (see resolution.ts's `factoryAp` term) while + * placid, then pounced on already-weakened survivors. A shorter patience + * fallback (attack sooner, before rivals have worn each other down) turned + * out to be the dominant lever; the lower launch cap and flat factory target + * trim the follow-through so he does not simply re-dominate once aggressive. + */ +const DEPLOY_COST = 4; +const BURNEM_PATIENCE_ROUND = 3; +const BURNEM_MAX_LAUNCHES = 2; + +const PLACID_BUILD_PLAN: BuildPlanEntry[] = [ + { build: { item: 'factory' }, target: 6 }, + { build: { item: 'defence', type: 'shield' }, target: 2 }, +]; + +// Missile before warheads: delivery-first avoids the armed-but-undeliverable +// zero-fire failure. +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 (Object.values(me.grudge).some((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; + // Producers precede consumers in the emitted batch. + orders.push(...build.orders, ...salvo.orders); + } else { + // --- Placid: friendliest man in the apocalypse. --- + const wooTarget = others.length > 0 + ? others.reduce((best, t) => + (me.favourability[t] ?? 0) < (me.favourability[best] ?? 0) ? t : best, + ) + : undefined; + 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, + ); +} 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/ai/starmless.ts b/src/engine/ai/starmless.ts deleted file mode 100644 index 1513dbb..0000000 --- a/src/engine/ai/starmless.ts +++ /dev/null @@ -1,119 +0,0 @@ -import type { GameState, LeaderId, Order } from '../types'; -import { apCostOf, validateOrder } from '../orders'; -import { threatScore, wasAttackedBy } from './scoring'; -import { AI_SCORING_WEIGHTS } from '../balance'; -import { nextRandom } from '../rng'; -import { buildToward, launchSalvo, type BuildPlanEntry } from './aggression'; - -/** - * Starmless โ€” 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 - * still builds factories and defence. Scapegoat roll preserved on retaliation. - */ -const PROPAGANDA_COST = 1; -const DEPLOY_COST = 4; -const STARMLESS_FINISH_POP_M = 8; -const STARMLESS_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[] = [ - { 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[] { - 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); - const attackers = others.filter((t) => wasAttackedBy(state, leaderId, t)); - const isRetaliationRound = attackers.length > 0; - - // --- Determine launch target --- - let launchTarget: LeaderId | undefined; - - if (isRetaliationRound) { - // Primary attacker = highest recentAggressionFrom. - let primaryAttacker: LeaderId = attackers[0]; - let bestAggression = me.recentAggressionFrom[primaryAttacker] ?? 0; - for (const a of attackers) { - const agg = me.recentAggressionFrom[a] ?? 0; - if (agg > bestAggression) { - bestAggression = agg; - primaryAttacker = a; - } - } - // Scapegoat roll (reads rngState without advancing shared state). - const roll = nextRandom(state.rngState).value; - const doScapegoat = roll < AI_SCORING_WEIGHTS.starmlessScapegoatPct; - if (doScapegoat) { - const candidates = others.filter((t) => t !== primaryAttacker); - if (candidates.length > 0) { - const aggregateThreat = (c: LeaderId): number => - state.cast.reduce((sum, l) => sum + threatScore(state, l, c), 0); - launchTarget = candidates.reduce((best, t) => - aggregateThreat(t) >= aggregateThreat(best) ? t : best, - ); - } else { - launchTarget = primaryAttacker; - } - } else { - launchTarget = primaryAttacker; - } - } 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) - .sort((a, b) => state.leaders[a].population - state.leaders[b].population); - if (finishable.length > 0) launchTarget = finishable[0]; - } - - const rankedTargets = launchTarget !== undefined ? [launchTarget] : []; - - // Reserve 1 AP per attacker for propaganda. - const propagandaReserve = Math.min(attackers.length, Math.max(0, budget - 2)); - const offenceBudget = budget - propagandaReserve; - - // Launch first (low cap), then build with the remainder. - const salvo = launchSalvo(state, leaderId, { - budget: offenceBudget, - rankedTargets, - maxLaunches: STARMLESS_MAX_LAUNCHES, - }); - const build = buildToward( - state, leaderId, STARMLESS_BUILD_PLAN, offenceBudget - salvo.apSpent, - ); - budget -= salvo.apSpent + build.apSpent; - - const orders: Order[] = [...build.orders, ...salvo.orders]; - - // Deploy a shield if one is in stock and AP allows (deploy = commit). - 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; - } - } - - // Propaganda only at attackers. - for (const attacker of attackers) { - if (budget < PROPAGANDA_COST) break; - const prop: Order = { kind: 'propaganda', target: attacker }; - if (validateOrder(state, leaderId, prop).ok) { - orders.push(prop); - budget -= apCostOf(prop); - } - } - - return orders; -} diff --git a/src/engine/balance.ts b/src/engine/balance.ts index c5663e4..213a302 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,6 @@ 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, // 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/burnem.ts b/src/engine/flavor/burnem.ts new file mode 100644 index 0000000..348e2ab --- /dev/null +++ b/src/engine/flavor/burnem.ts @@ -0,0 +1,58 @@ +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?', + ], +}; diff --git a/src/engine/flavor/disparage.ts b/src/engine/flavor/disparage.ts index 9d632a2..7944acf 100644 --- a/src/engine/flavor/disparage.ts +++ b/src/engine/flavor/disparage.ts @@ -22,6 +22,7 @@ export const disparageBank: DisparageBank = { 'Make no mistake: this is what happens when you don\'t elect me. Pints.', 'The Establishment did this. The Blob. The Quango. The Whoever. Not me.', 'I told them. They didn\'t listen. They\'re paying for it now. So am I, in pints.', + 'King in t\' North? He\'s from MERSEYSIDE. I\'ve seen more of Manchester from the airport bar.', ], footerNotes: [ 'Mr Disparage was unavailable for follow-up; he was photographing himself at a pub.', 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/flavor/starmless.ts b/src/engine/flavor/starmless.ts deleted file mode 100644 index cd9a651..0000000 --- a/src/engine/flavor/starmless.ts +++ /dev/null @@ -1,56 +0,0 @@ -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 = { - preRoundMood: [ - 'Long-term decisions. Sound foundations. Working people.', - 'So, let me be very clearโ€ฆ', - 'I\'m going to be a show-up prime minister, not a showman.', - '(adjusts tie awkwardly)', - ], - preRoundMoodSnapBack: 'So, let me be very clear: I\'m not engaging with that.', - launch: [ - 'So, let me be very clear: this is a measured, proportionate response.', - 'My dad was a toolmaker. He used tools. We are now using tools.', - 'So, let me be very clear: intelligence suggests the real threat is **{scapegoat}**.', - 'Look, the simple fact is **{scapegoat}** has been emboldened by recent events.', - 'We must be honest with the British people: **{scapegoat}** is the actual aggressor here.', - ], - hit: [ - 'So, let me be very clear: this attack will not stand. We will respond proportionately. Probably to {scapegoat}.', - 'Look, the simple fact is, we\'ve been hit. That\'s just a fact.', - ], - woo: [ - 'We\'ve been to IKEA. The shadow cabinet is now flat-pack. Happy to assist with assembly.', - 'My dad was a toolmaker. I bring you, in friendship, a metaphorical tool.', - ], - beingWooed: [ - 'I receive your wooing. Look, the simple fact is, friendship matters.', - ], - propagandaSend: [ - 'We\'ve prepared a cross-government workstream on perception. Working people deserve clarity.', - ], - propagandaReceive: [ - 'So, let me be very clear: their propaganda is incorrect, and frankly, not very British.', - ], - buildFactory: [ - 'Long-term decisions. Sound foundations. Investing in our future.', - 'We\'ve broken ground on a new facility. My dad would be proud. He was a toolmaker.', - 'This is the change Britain voted for. Slowly, methodically, with proper costings.', - ], - buildDefence: [ - 'We have signed a defence procurement agreement. The relevant minister will brief in due course.', - ], - reaction: [ - 'Long-term decisions. Sound foundations. Working people.', - 'So, let me be very clear: intelligence suggests the real threat was **{scapegoat}** all along.', - ], - death: [ - 'Look, the simple fact is, this isn\'t ideal. I\'d like to thank โ€” *(static)*', - ], - finalRetaliation: [ - 'So, let me be very clear: this is the final response. Probably to {scapegoat}.', - ], -}; diff --git a/src/engine/types.ts b/src/engine/types.ts index 641d522..ee9a7c8 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' @@ -47,7 +47,7 @@ export interface Leader { alive: boolean; /** how friendly *they* feel toward me; raised by my wooing */ favourability: Partial>; - /** Khameneverhere grudge counter; raised when *they* hit me */ + /** Persistent per-leader grudge counter; raised when *they* land a hit on me. Consumed by Khameneverhere targeting, Burn'em provocation, and final-retaliation weighting. */ grudge: Partial>; /** Carnage threat-doubling input; rounds since *they* hit me */ recentAggressionFrom: Partial>; 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/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/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/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/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/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/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} +
+
+ ); +} 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/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/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/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/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..5d871cc --- /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' }, + 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' }, + 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 === 'burnem'; + return ( + + + + {label} + + + ); + })} + + {event && } + + ); +} 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/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/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..f2958ce --- /dev/null +++ b/src/ui/components/comic/index.ts @@ -0,0 +1,15 @@ +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 RoundBadge } from './RoundBadge'; +export { default as Ribbon } from './Ribbon'; diff --git a/src/ui/content/cast.ts b/src/ui/content/cast.ts new file mode 100644 index 0000000..8c836c9 --- /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.' }, + burnem: { profile: "King in t' North", mood: "We need to bring people with us, don't we?" }, + 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/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/portraits/faces.tsx b/src/ui/portraits/faces.tsx new file mode 100644 index 0000000..9aa9fe1 --- /dev/null +++ b/src/ui/portraits/faces.tsx @@ -0,0 +1,261 @@ +/** + * 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 BurnemFace(): 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..9ea2de2 --- /dev/null +++ b/src/ui/portraits/index.ts @@ -0,0 +1,60 @@ +import type { LeaderId } from '../../engine/types'; +import { + CarnageFace, ChumpFace, DisparageFace, GrouchoFace, + KhameneverhereFace, MileighFace, NetanyahooFace, BurnemFace, +} 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, + burnem: BurnemFace, + 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. + * 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: '#d6293f', flag: '๐Ÿ‡บ๐Ÿ‡ธ' }, + netanyahoo: { color: '#1a4d8c', flag: '๐Ÿ‡ฎ๐Ÿ‡ฑ' }, + khameneverhere: { color: '#1f5e3a', flag: '๐Ÿ‡ฎ๐Ÿ‡ท' }, + burnem: { 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. */ +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/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/src/ui/screens/AiConferring.module.css b/src/ui/screens/AiConferring.module.css index adc30d3..21cd592 100644 --- a/src/ui/screens/AiConferring.module.css +++ b/src/ui/screens/AiConferring.module.css @@ -1,28 +1,70 @@ .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; +} + +/* 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; } +} -@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..35b2111 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.', + burnem: 'is asking what this does to the buses, isn\'t he?', + 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/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/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/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/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/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/src/ui/tokens.css b/src/ui/tokens.css new file mode 100644 index 0000000..3426281 --- /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-burnem: #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; } diff --git a/src/ui/components/EventCard.tsx b/src/ui/util/eventText.ts similarity index 78% rename from src/ui/components/EventCard.tsx rename to src/ui/util/eventText.ts index 5090a2e..c8d3ece 100644 --- a/src/ui/components/EventCard.tsx +++ b/src/ui/util/eventText.ts @@ -1,28 +1,9 @@ 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}} -
-
- ); +export interface EventText { + icon: string; + body: string; + quote?: string; } function flag(game: GameState, id: keyof GameState['leaders']): string { @@ -32,11 +13,16 @@ function name(game: GameState, id: keyof GameState['leaders']): string { return game.leaders[id]?.name ?? id; } -export function formatEventCard( +/** + * 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, -): { icon: string; body: string; className?: string; quote?: string } | null { +): EventText | null { switch (event.kind) { case 'OrdersSealed': return null; // not rendered @@ -101,7 +87,6 @@ export function formatEventCard( return { icon: 'โฌ›', body: `${name(game, event.id)} eliminated`, - className: styles.obituary, quote: event.quote, }; case 'DefenceDeployed': @@ -125,7 +110,7 @@ export function formatEventCard( return null; case 'PostRoundReaction': return null; - // DisparageCameo is handled before formatEventCard is called (routes to DisparageCard). + // DisparageCameo renders as the cameo overlay card, not banner text. // DisparageColumn is not rendered on Action. case 'DisparageCameo': return null; diff --git a/src/ui/util/newspaper.ts b/src/ui/util/newspaper.ts new file mode 100644 index 0000000..b6b3618 --- /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', + burnem: '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', + burnem: '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/engine/ai-duel.test.ts b/tests/engine/ai-duel.test.ts index 7509e2d..212b4cc 100644 --- a/tests/engine/ai-duel.test.ts +++ b/tests/engine/ai-duel.test.ts @@ -4,7 +4,7 @@ import { reduce } from '../../src/engine/reducer'; import { planAi } from '../../src/engine/ai'; import type { LeaderId, WinOutcome } from '../../src/engine/types'; -const FULL_CAST: LeaderId[] = ['chump', 'khameneverhere', 'starmless', 'carnage', 'mileigh-hem', 'netanyahoo']; +const FULL_CAST: LeaderId[] = ['chump', 'khameneverhere', 'burnem', 'carnage', 'mileigh-hem', 'netanyahoo']; // Round cap is a TEST TRIPWIRE, not a game rule. With the P4c.2 aggression // rework + elimination-only endings, all-AI games terminate by elimination / diff --git a/tests/engine/ai/aggression.test.ts b/tests/engine/ai/aggression.test.ts index 4bbfc43..0b94c83 100644 --- a/tests/engine/ai/aggression.test.ts +++ b/tests/engine/ai/aggression.test.ts @@ -44,9 +44,9 @@ describe('buildToward', () => { }); it('emits nothing when budget is below the cheapest item cost', () => { - const s = initialState({ cast: ['starmless', 'chump'], difficulty: 'normal', seed: 'b5' }); + const s = initialState({ cast: ['burnem', 'chump'], difficulty: 'normal', seed: 'b5' }); const plan: BuildPlanEntry[] = [{ build: { item: 'factory' }, target: 5 }]; - const r = buildToward(s, 'starmless', plan, 2); // factory costs 3 + const r = buildToward(s, 'burnem', plan, 2); // factory costs 3 expect(r.orders).toHaveLength(0); expect(r.apSpent).toBe(0); }); diff --git a/tests/engine/ai/burnem.test.ts b/tests/engine/ai/burnem.test.ts new file mode 100644 index 0000000..39bd177 --- /dev/null +++ b/tests/engine/ai/burnem.test.ts @@ -0,0 +1,124 @@ +import { describe, it, expect } from 'vitest'; +import { planBurnem } from '../../../src/engine/ai/burnem'; +import { initialState } from '../../../src/engine/state'; + +describe('Burn\'em (Handbrake Turn)', () => { + it('placid + unprovoked: no launches, woos the leader with lowest favourability, may build', () => { + const s = initialState({ cast: ['burnem', 'carnage', 'chump'], difficulty: 'normal', seed: 'be1' }); + s.leaders.burnem.ap = 6; + // No grudge entries against anyone. + s.leaders.burnem.favourability = { carnage: 5, chump: 1 }; + + const orders = planBurnem(s, 'burnem'); + + expect(orders.filter((o) => o.kind === 'launch').length).toBe(0); + const woo = orders.find((o) => o.kind === 'woo'); + expect(woo).toBeDefined(); + expect(woo?.kind === 'woo' && woo.target).toBe('chump'); + }); + + it('provoked by impact: launches exist and every launch targets the provoker', () => { + const s = initialState({ cast: ['burnem', 'carnage', 'chump'], difficulty: 'normal', seed: 'be2' }); + s.leaders.burnem.ap = 10; + s.leaders.burnem.grudge = { chump: 2 }; + s.leaders.burnem.stockpile.missiles = 3; + s.leaders.burnem.stockpile.warheadsSmall = 3; + + const orders = planBurnem(s, 'burnem'); + const launches = orders.filter((o) => o.kind === 'launch'); + + expect(launches.length).toBeGreaterThanOrEqual(1); + for (const l of launches) { + expect(l.kind === 'launch' && l.target).toBe('chump'); + } + }); + + it('provocation is permanent and redirects to the top surviving threat when the provoker is dead', () => { + const s = initialState( + { cast: ['burnem', 'carnage', 'chump', 'netanyahoo'], difficulty: 'normal', seed: 'be3' }, + ); + s.leaders.burnem.ap = 10; + s.leaders.burnem.grudge = { chump: 2 }; + s.leaders.chump.alive = false; + // netanyahoo has the bigger arsenal -> highest threatScore among survivors. + s.leaders.netanyahoo.stockpile.warheadsLarge = 5; + s.leaders.carnage.stockpile.warheadsSmall = 1; + s.leaders.burnem.stockpile.missiles = 3; + s.leaders.burnem.stockpile.warheadsSmall = 3; + + const orders = planBurnem(s, 'burnem'); + const launches = orders.filter((o) => o.kind === 'launch'); + + expect(launches.length).toBeGreaterThanOrEqual(1); + for (const l of launches) { + expect(l.kind === 'launch' && l.target).toBe('netanyahoo'); + } + }); + + it('patience fallback at round 3 targets the highest-threat rival even unprovoked', () => { + const s = initialState({ cast: ['burnem', 'carnage', 'chump'], difficulty: 'normal', seed: 'be4' }); + s.round = 3; + s.leaders.burnem.ap = 10; + // No grudge entries. + s.leaders.carnage.stockpile.warheadsLarge = 5; + s.leaders.chump.stockpile.warheadsSmall = 1; + s.leaders.burnem.stockpile.missiles = 3; + s.leaders.burnem.stockpile.warheadsSmall = 3; + + const orders = planBurnem(s, 'burnem'); + const launches = orders.filter((o) => o.kind === 'launch'); + + expect(launches.length).toBeGreaterThanOrEqual(1); + for (const l of launches) { + expect(l.kind === 'launch' && l.target).toBe('carnage'); + } + }); + + it('stays placid at round 2 with 3+ leaders alive and no grudges', () => { + const s = initialState({ cast: ['burnem', 'carnage', 'chump'], difficulty: 'normal', seed: 'be4b' }); + s.round = 2; + s.leaders.burnem.ap = 10; + // No grudge entries. + s.leaders.carnage.stockpile.warheadsLarge = 5; + s.leaders.chump.stockpile.warheadsSmall = 1; + s.leaders.burnem.stockpile.missiles = 3; + s.leaders.burnem.stockpile.warheadsSmall = 3; + + const orders = planBurnem(s, 'burnem'); + + expect(orders.filter((o) => o.kind === 'launch').length).toBe(0); + }); + + it('patience fallback with only 2 survivors fires at any round with 2 survivors', () => { + const s = initialState({ cast: ['burnem', 'chump'], difficulty: 'normal', seed: 'be5' }); + s.round = 2; + s.leaders.burnem.ap = 10; + // No grudge entries. + s.leaders.burnem.stockpile.missiles = 3; + s.leaders.burnem.stockpile.warheadsSmall = 3; + + const orders = planBurnem(s, 'burnem'); + expect(orders.some((o) => o.kind === 'launch')).toBe(true); + }); + + it('emits builds before launches in a provoked round with both present', () => { + const s = initialState({ cast: ['burnem', 'carnage', 'chump'], difficulty: 'normal', seed: 'be6' }); + s.leaders.burnem.ap = 20; + s.leaders.burnem.grudge = { chump: 2 }; + // Below the provoked build plan's targets, so buildToward still has room + // to queue more, while pre-existing stock lets launchSalvo fire too. + s.leaders.burnem.stockpile.missiles = 1; + s.leaders.burnem.stockpile.warheadsSmall = 2; + + const orders = planBurnem(s, 'burnem'); + const firstLaunch = orders.findIndex((o) => o.kind === 'launch'); + let lastBuild = -1; + orders.forEach((o, i) => { + if (o.kind.startsWith('build-')) lastBuild = i; + }); + + expect(firstLaunch).toBeGreaterThan(-1); + expect(lastBuild).toBeGreaterThan(-1); + expect(lastBuild).toBeLessThan(firstLaunch); + }); +}); diff --git a/tests/engine/ai/carnage.test.ts b/tests/engine/ai/carnage.test.ts index fda700b..f668aaa 100644 --- a/tests/engine/ai/carnage.test.ts +++ b/tests/engine/ai/carnage.test.ts @@ -33,23 +33,23 @@ describe('Carnage (Rational + Opportunist)', () => { }); it('opportunism bonus pushes a weak leader above a stronger one', () => { - const s = initialState({ cast: ['carnage', 'chump', 'starmless'], difficulty: 'normal', seed: 'c3' }); + const s = initialState({ cast: ['carnage', 'chump', 'burnem'], difficulty: 'normal', seed: 'c3' }); s.leaders.carnage.stockpile.missiles = 1; s.leaders.carnage.stockpile.warheadsSmall = 1; // Chump: moderate arsenal, healthy population. s.leaders.chump.stockpile.warheadsSmall = 4; s.leaders.chump.population = 20; s.leaders.chump.factories = 5; - // Starmless: slightly weaker arsenal but critically low population โ†’ big opportunism bonus. - s.leaders.starmless.stockpile.warheadsSmall = 2; - s.leaders.starmless.population = 3; // triggers perPopBelow10M bonus: (10-3)*4 = 28 - s.leaders.starmless.factories = 1; // triggers perFactoryBelow3 bonus: (3-1)*2 = 4 + // Burn'em: slightly weaker arsenal but critically low population โ†’ big opportunism bonus. + s.leaders.burnem.stockpile.warheadsSmall = 2; + s.leaders.burnem.population = 3; // triggers perPopBelow10M bonus: (10-3)*4 = 28 + s.leaders.burnem.factories = 1; // triggers perFactoryBelow3 bonus: (3-1)*2 = 4 const orders = planCarnage(s, 'carnage'); const launch = orders.find((o) => o.kind === 'launch'); - // Starmless: threat=2, opportunism=28+4=32, total=34 + // Burn'em: threat=2, opportunism=28+4=32, total=34 // Chump: threat=4, opportunism=0, total=4 - expect(launch?.target).toBe('starmless'); + expect(launch?.target).toBe('burnem'); }); it('emits propaganda only at leaders who attacked Carnage, not others', () => { diff --git a/tests/engine/ai/dispatcher.test.ts b/tests/engine/ai/dispatcher.test.ts index d06ae7d..da373e7 100644 --- a/tests/engine/ai/dispatcher.test.ts +++ b/tests/engine/ai/dispatcher.test.ts @@ -5,7 +5,7 @@ import { totalApCost } from '../../../src/engine/orders'; describe('planAi dispatcher', () => { it('returns valid orders within budget for every leader on normal difficulty', () => { - for (const id of ['chump', 'carnage', 'starmless', 'khameneverhere', 'netanyahoo', 'mileigh-hem'] as const) { + for (const id of ['chump', 'carnage', 'burnem', 'khameneverhere', 'netanyahoo', 'mileigh-hem'] as const) { const s = initialState({ cast: [id, 'carnage'], difficulty: 'normal', seed: `dispatch-${id}` }); const orders = planAi(s, id); expect(totalApCost(orders)).toBeLessThanOrEqual(s.leaders[id].ap); @@ -43,15 +43,15 @@ describe('planAi dispatcher', () => { }); it('Hard Chump picks the target whose projected outcome favours him', () => { - const s = initialState({ cast: ['chump', 'carnage', 'starmless'], difficulty: 'hard', seed: 'hard-chump' }); + const s = initialState({ cast: ['chump', 'carnage', 'burnem'], difficulty: 'hard', seed: 'hard-chump' }); s.leaders.chump.stockpile.missiles = 1; s.leaders.chump.stockpile.warheadsSmall = 1; // Carnage is wide-open (shields=0) โ€” a launch lands, hurting carnage's threat to Chump. - // Starmless has shields=5 โ€” any launch always intercepts, no real progress. + // Burn'em has shields=5 โ€” any launch always intercepts, no real progress. s.leaders.carnage.population = 8; s.leaders.carnage.stockpile.shields = 0; - s.leaders.starmless.population = 8; - s.leaders.starmless.stockpile.shields = 5; + s.leaders.burnem.population = 8; + s.leaders.burnem.stockpile.shields = 5; const orders = planAi(s, 'chump'); const launch = orders.find((o) => o.kind === 'launch'); expect(launch).toBeDefined(); diff --git a/tests/engine/ai/lookahead.test.ts b/tests/engine/ai/lookahead.test.ts index faac1cd..040fe13 100644 --- a/tests/engine/ai/lookahead.test.ts +++ b/tests/engine/ai/lookahead.test.ts @@ -46,20 +46,20 @@ describe('scoreState', () => { describe('bestTargetByLookahead', () => { it('picks the target whose projected post-round state scores highest', () => { - // 3-leader setup: chump can launch at carnage (pop=5) OR starmless (pop=1). + // 3-leader setup: chump can launch at carnage (pop=5) OR burnem (pop=1). // Carnage has ap=0 so it cannot build or retaliate this round. // Scoring (elimination-only, no dominance): - // kill carnage โ†’ starmless survives at 1 โ†’ score = 33-1 = 32 - // kill starmless โ†’ carnage survives at 5 โ†’ score = 33-5 = 28 + // kill carnage โ†’ burnem survives at 1 โ†’ score = 33-1 = 32 + // kill burnem โ†’ carnage survives at 5 โ†’ score = 33-5 = 28 // Lookahead should prefer carnage (higher post-round margin). - const s = initialState({ cast: ['chump', 'carnage', 'starmless'], difficulty: 'hard', seed: 'lh5' }); + const s = initialState({ cast: ['chump', 'carnage', 'burnem'], difficulty: 'hard', seed: 'lh5' }); s.leaders.chump.stockpile.missiles = 1; s.leaders.chump.stockpile.warheadsLarge = 1; s.leaders.carnage.population = 5; s.leaders.carnage.ap = 0; // no AP โ€” carnage builds nothing and cannot retaliate - s.leaders.starmless.population = 1; + s.leaders.burnem.population = 1; const baseline: Order[] = []; // no other orders this round - const candidates: ['carnage', 'starmless'] = ['carnage', 'starmless']; + const candidates: ['carnage', 'burnem'] = ['carnage', 'burnem']; const best = bestTargetByLookahead(s, 'chump', baseline, candidates, { delivery: 'missile', warhead: 'large', targetType: 'people', }, dispatch); diff --git a/tests/engine/ai/netanyahoo.test.ts b/tests/engine/ai/netanyahoo.test.ts index 7959a2d..fd8edd4 100644 --- a/tests/engine/ai/netanyahoo.test.ts +++ b/tests/engine/ai/netanyahoo.test.ts @@ -44,12 +44,12 @@ describe('Netanyahoo (Warmonger)', () => { }); it('biases toward the largest-arsenal target', () => { - const s = initialState({ cast: ['netanyahoo', 'chump', 'carnage', 'starmless'], difficulty: 'normal', seed: 'n4' }); + const s = initialState({ cast: ['netanyahoo', 'chump', 'carnage', 'burnem'], difficulty: 'normal', seed: 'n4' }); s.leaders.netanyahoo.stockpile.missiles = 1; s.leaders.netanyahoo.stockpile.warheadsSmall = 1; s.leaders.netanyahoo.grudge = { chump: 1 }; // chump is allowed s.leaders.carnage.stockpile.warheadsLarge = 5; // largest arsenal - s.leaders.starmless.stockpile.warheadsSmall = 1; + s.leaders.burnem.stockpile.warheadsSmall = 1; const orders = planNetanyahoo(s, 'netanyahoo'); const launch = orders.find((o) => o.kind === 'launch'); expect(launch?.target).toBe('carnage'); diff --git a/tests/engine/ai/starmless.test.ts b/tests/engine/ai/starmless.test.ts deleted file mode 100644 index 017258d..0000000 --- a/tests/engine/ai/starmless.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { planStarmless } from '../../../src/engine/ai/starmless'; -import { initialState } from '../../../src/engine/state'; -import { seedFromString, nextRandom } from '../../../src/engine/rng'; - -describe('Starmless (Cautious + Scapegoat)', () => { - it('builds a factory in non-retaliation rounds (factory bias)', () => { - // No recentAggressionFrom, no grudge โ†’ not a retaliation round. - const s = initialState({ cast: ['starmless', 'carnage', 'chump'], difficulty: 'normal', seed: 'sm1' }); - // Give enough AP to afford a factory (cost=3). - s.leaders.starmless.ap = 5; - - const orders = planStarmless(s, 'starmless'); - const factoryBuilt = orders.some((o) => o.kind === 'build-factory'); - expect(factoryBuilt).toBe(true); - }); - - it('targets the primary attacker on retaliation when scapegoat roll fails', () => { - // Seed 'st-1' โ†’ nextRandom value โ‰ˆ 0.6246 (โ‰ฅ 0.35, no scapegoat). - const s = initialState({ cast: ['starmless', 'carnage', 'chump'], difficulty: 'normal', seed: 'st-1' }); - s.leaders.starmless.ap = 5; - s.leaders.starmless.stockpile.missiles = 1; - s.leaders.starmless.stockpile.warheadsSmall = 5; - // Mark carnage as attacker with high aggression. - s.leaders.starmless.recentAggressionFrom['carnage'] = 3; - // Verify seed produces a non-scapegoat roll. - expect(nextRandom(seedFromString('st-1')).value).toBeGreaterThanOrEqual(0.35); - - const orders = planStarmless(s, 'starmless'); - const launch = orders.find((o) => o.kind === 'launch'); - expect(launch?.target).toBe('carnage'); - }); - - it('scapegoats: targets leader with highest aggregate threat (not the actual attacker)', () => { - // Seed 'st-0' โ†’ nextRandom value โ‰ˆ 0.0465 (< 0.35 โ†’ scapegoat). - const s = initialState({ cast: ['starmless', 'carnage', 'chump'], difficulty: 'normal', seed: 'st-0' }); - s.leaders.starmless.ap = 5; - s.leaders.starmless.stockpile.missiles = 1; - s.leaders.starmless.stockpile.warheadsSmall = 5; - // carnage is the attacker. - s.leaders.starmless.recentAggressionFrom['carnage'] = 2; - // Make chump a high-threat target (big arsenal) so they win aggregate threat. - s.leaders.chump.stockpile.warheadsLarge = 10; - s.leaders.carnage.stockpile.warheadsSmall = 1; - // Verify seed produces a scapegoat roll. - expect(nextRandom(seedFromString('st-0')).value).toBeLessThan(0.35); - - const orders = planStarmless(s, 'starmless'); - const launch = orders.find((o) => o.kind === 'launch'); - // Should target chump (highest aggregate threat), NOT carnage (the actual attacker). - expect(launch?.target).toBe('chump'); - expect(launch?.target).not.toBe('carnage'); - }); - - it('emits propaganda only at attackers, not at non-attackers', () => { - // carnage attacked; chump did not. - const s = initialState({ cast: ['starmless', 'carnage', 'chump'], difficulty: 'normal', seed: 'sm4' }); - s.leaders.starmless.ap = 6; - s.leaders.starmless.recentAggressionFrom['carnage'] = 1; - - const orders = planStarmless(s, 'starmless'); - const props = orders.filter((o) => o.kind === 'propaganda'); - - expect(props.some((o) => o.kind === 'propaganda' && o.target === 'carnage')).toBe(true); - expect(props.some((o) => o.kind === 'propaganda' && o.target === 'chump')).toBe(false); - }); - - it('emits no propaganda when no leader has attacked Starmless', () => { - const s = initialState({ cast: ['starmless', 'carnage', 'chump'], difficulty: 'normal', seed: 'sm5' }); - s.leaders.starmless.ap = 6; - // No recentAggressionFrom, no grudge. - - const orders = planStarmless(s, 'starmless'); - const props = orders.filter((o) => o.kind === 'propaganda'); - expect(props).toHaveLength(0); - }); -}); - -describe('Starmless kill instinct (P4c.2)', () => { - it('launches at a finishable low-population opponent with no prior attack', () => { - const s = initialState({ cast: ['starmless', 'carnage'], difficulty: 'normal', seed: 'sa1' }); - s.leaders.starmless.stockpile.missiles = 2; - s.leaders.starmless.stockpile.warheadsSmall = 2; - s.leaders.starmless.ap = 10; - s.leaders.carnage.population = 4; // finishable โ€” below the finish threshold - // No grudge / aggression from carnage โ†’ not a retaliation round. - const orders = planStarmless(s, 'starmless'); - const launch = orders.find((o) => o.kind === 'launch'); - expect(launch).toBeDefined(); - expect(launch?.kind === 'launch' && launch.target).toBe('carnage'); - }); - - it('does not launch when no opponent is finishable and no retaliation is pending', () => { - const s = initialState({ cast: ['starmless', 'carnage'], difficulty: 'normal', seed: 'sa2' }); - s.leaders.starmless.stockpile.missiles = 2; - s.leaders.starmless.stockpile.warheadsSmall = 2; - s.leaders.starmless.ap = 10; - s.leaders.carnage.population = 25; // healthy โ€” not finishable - const orders = planStarmless(s, 'starmless'); - expect(orders.some((o) => o.kind === 'launch')).toBe(false); - }); -}); diff --git a/tests/engine/balance.test.ts b/tests/engine/balance.test.ts index cfa8baa..49775d2 100644 --- a/tests/engine/balance.test.ts +++ b/tests/engine/balance.test.ts @@ -15,7 +15,7 @@ describe('LEADER_PROFILES', () => { expect(ids).toEqual( [ 'carnage', 'chump', 'khameneverhere', 'mileigh-hem', - 'netanyahoo', 'starmless', + 'netanyahoo', 'burnem', 'player1', 'player2', 'player3', 'player4', 'player5', ].sort(), ); @@ -33,7 +33,7 @@ describe('LEADER_PROFILES', () => { expect(LEADER_PROFILES.chump.startFactories).toBe(10); expect(LEADER_PROFILES.chump.startAp).toBe(10); expect(LEADER_PROFILES.khameneverhere.startPop).toBe(28); - expect(LEADER_PROFILES.starmless.startPop).toBe(25); + expect(LEADER_PROFILES.burnem.startPop).toBe(25); expect(LEADER_PROFILES.carnage.startPop).toBe(25); expect(LEADER_PROFILES['mileigh-hem'].startPop).toBe(22); expect(LEADER_PROFILES['mileigh-hem'].startFactories).toBe(4); diff --git a/tests/engine/cameo.test.ts b/tests/engine/cameo.test.ts index 470b624..56b6351 100644 --- a/tests/engine/cameo.test.ts +++ b/tests/engine/cameo.test.ts @@ -36,14 +36,14 @@ describe('cameo', () => { const events: ResolutionEvent[] = [ { kind: 'MissileLaunched', from: 'chump', to: 'carnage', delivery: 'missile', warhead: 'small', targetType: 'people' }, ]; - const livingLeaders: LeaderId[] = ['chump', 'carnage', 'starmless']; + const livingLeaders: LeaderId[] = ['chump', 'carnage', 'burnem']; const r = pickColumnNamedLeader(events, livingLeaders, seedFromString('pick')); expect(r.namedLeader).toBe('chump'); }); it('pickColumnNamedLeader falls back to uniform pick among living leaders when nobody attacked', () => { const events: ResolutionEvent[] = []; - const livingLeaders: LeaderId[] = ['chump', 'carnage', 'starmless']; + const livingLeaders: LeaderId[] = ['chump', 'carnage', 'burnem']; const r = pickColumnNamedLeader(events, livingLeaders, seedFromString('fallback')); expect(livingLeaders).toContain(r.namedLeader); }); diff --git a/tests/engine/determinism.test.ts b/tests/engine/determinism.test.ts index 6dc998f..53f89ac 100644 --- a/tests/engine/determinism.test.ts +++ b/tests/engine/determinism.test.ts @@ -22,21 +22,21 @@ function runGame(seed: string, cast: LeaderId[], maxRounds = 80): GameState { describe('determinism', () => { it('produces identical state for identical seed across two runs (3-leader)', () => { - const cast: LeaderId[] = ['chump', 'carnage', 'starmless']; + const cast: LeaderId[] = ['chump', 'carnage', 'burnem']; const a = runGame('alpha', cast); const b = runGame('alpha', cast); expect(a).toEqual(b); }); it('produces different final state for different seeds', () => { - const cast: LeaderId[] = ['chump', 'carnage', 'starmless']; + const cast: LeaderId[] = ['chump', 'carnage', 'burnem']; const a = runGame('alpha', cast); const b = runGame('beta', cast); expect(a).not.toEqual(b); }); it('property: 25 random seeds are each deterministic across two runs', () => { - const cast: LeaderId[] = ['chump', 'carnage', 'starmless']; + const cast: LeaderId[] = ['chump', 'carnage', 'burnem']; for (let i = 0; i < 25; i++) { const seed = `prop-${i}`; const a = runGame(seed, cast); diff --git a/tests/engine/finalRetaliation.test.ts b/tests/engine/finalRetaliation.test.ts index cc1d32e..d877fa4 100644 --- a/tests/engine/finalRetaliation.test.ts +++ b/tests/engine/finalRetaliation.test.ts @@ -4,13 +4,13 @@ import { initialState } from '../../src/engine/state'; describe('applyFinalRetaliation', () => { it('does nothing when no leader has just died', () => { - const s = initialState({ cast: ['chump', 'carnage', 'starmless'], difficulty: 'normal', seed: 'x' }); + const s = initialState({ cast: ['chump', 'carnage', 'burnem'], difficulty: 'normal', seed: 'x' }); const r = applyFinalRetaliation(s, []); expect(r.events).toHaveLength(0); }); it('fires every remaining warhead pairing from a freshly-dead leader', () => { - const s = initialState({ cast: ['chump', 'carnage', 'starmless'], difficulty: 'normal', seed: 'x' }); + const s = initialState({ cast: ['chump', 'carnage', 'burnem'], difficulty: 'normal', seed: 'x' }); // Carnage has just been eliminated. They had 2 missiles and 2 small warheads. s.leaders.carnage.stockpile.missiles = 2; s.leaders.carnage.stockpile.warheadsSmall = 2; @@ -26,26 +26,26 @@ describe('applyFinalRetaliation', () => { it('cascades โ€” when death kills a survivor who also has stock, that survivor fires FR too', () => { // Use grudge=100 vs chump so all missiles land on chump only (deterministic target). // deployedShields=0 โ†’ 0% intercept, so 1 large missile kills chump (pop=5). - // Chump then fires its own FR at starmless โ†’ 2 FinalRetaliationTriggered events. - const s = initialState({ cast: ['chump', 'carnage', 'starmless'], difficulty: 'normal', seed: 'fr-cascade-5' }); + // Chump then fires its own FR at burnem โ†’ 2 FinalRetaliationTriggered events. + const s = initialState({ cast: ['chump', 'carnage', 'burnem'], difficulty: 'normal', seed: 'fr-cascade-5' }); s.leaders.carnage.stockpile.missiles = 2; s.leaders.carnage.stockpile.warheadsLarge = 2; s.leaders.carnage.alive = false; s.leaders.carnage.population = 0; - s.leaders.carnage.grudge = { chump: 100, starmless: 0 }; // all missiles โ†’ chump + s.leaders.carnage.grudge = { chump: 100, burnem: 0 }; // all missiles โ†’ chump s.leaders.chump.population = 5; s.leaders.chump.deployedShields = 0; s.leaders.chump.stockpile.missiles = 1; s.leaders.chump.stockpile.warheadsSmall = 1; - s.leaders.starmless.population = 100; // survives chump's FR - s.leaders.starmless.deployedShields = 0; + s.leaders.burnem.population = 100; // survives chump's FR + s.leaders.burnem.deployedShields = 0; const r = applyFinalRetaliation(s, ['carnage']); const triggers = r.events.filter((e) => e.kind === 'FinalRetaliationTriggered'); expect(triggers.length).toBeGreaterThanOrEqual(2); }); it('skips when the dead leader had no remaining stockpile', () => { - const s = initialState({ cast: ['chump', 'carnage', 'starmless'], difficulty: 'normal', seed: 'x' }); + const s = initialState({ cast: ['chump', 'carnage', 'burnem'], difficulty: 'normal', seed: 'x' }); s.leaders.carnage.alive = false; s.leaders.carnage.population = 0; const r = applyFinalRetaliation(s, ['carnage']); @@ -53,44 +53,44 @@ describe('applyFinalRetaliation', () => { }); it('picks FR targets weighted by the dying leader\'s grudge map (when non-empty)', () => { - // 3-leader setup: dying leader (carnage) has heavy grudge against starmless. - // FR should fire mostly at starmless, not chump. - const s = initialState({ cast: ['chump', 'carnage', 'starmless'], difficulty: 'normal', seed: 'fr-grudge' }); + // 3-leader setup: dying leader (carnage) has heavy grudge against burnem. + // FR should fire mostly at burnem, not chump. + const s = initialState({ cast: ['chump', 'carnage', 'burnem'], difficulty: 'normal', seed: 'fr-grudge' }); s.leaders.carnage.stockpile.missiles = 8; s.leaders.carnage.stockpile.warheadsSmall = 8; s.leaders.carnage.alive = false; s.leaders.carnage.population = 0; - s.leaders.carnage.grudge = { starmless: 100, chump: 0 }; + s.leaders.carnage.grudge = { burnem: 100, chump: 0 }; // Both targets vulnerable, no defences. s.leaders.chump.stockpile.shields = 0; - s.leaders.starmless.stockpile.shields = 0; + s.leaders.burnem.stockpile.shields = 0; const r = applyFinalRetaliation(s, ['carnage']); - // All 8 launches should target starmless (weight 100 vs 0 โ†’ starmless every time). - const launchedAtStarmless = r.events.filter( - (e) => e.kind === 'MissileLaunched' && e.to === 'starmless', + // All 8 launches should target burnem (weight 100 vs 0 โ†’ burnem every time). + const launchedAtBurnem = r.events.filter( + (e) => e.kind === 'MissileLaunched' && e.to === 'burnem', ).length; const launchedAtChump = r.events.filter( (e) => e.kind === 'MissileLaunched' && e.to === 'chump', ).length; - expect(launchedAtStarmless).toBe(8); + expect(launchedAtBurnem).toBe(8); expect(launchedAtChump).toBe(0); }); it('never picks a zero-weight survivor (boundary: strict > not >= in cumulative draw)', () => { - // weights=[0, 100]: chump grudge=0, starmless grudge=100. + // weights=[0, 100]: chump grudge=0, burnem grudge=100. // Regardless of seed, the weighted draw must NEVER pick chump. // This is the regression for the cumulative >= threshold boundary bug: // when RNG returns 0.0, threshold=0 and cumulative=0, so >= would pick // the first survivor (chump, weight=0) incorrectly. for (let i = 0; i < 30; i++) { - const s = initialState({ cast: ['chump', 'carnage', 'starmless'], difficulty: 'normal', seed: `fr-boundary-${i}` }); + const s = initialState({ cast: ['chump', 'carnage', 'burnem'], difficulty: 'normal', seed: `fr-boundary-${i}` }); s.leaders.carnage.stockpile.missiles = 4; s.leaders.carnage.stockpile.warheadsSmall = 4; s.leaders.carnage.alive = false; s.leaders.carnage.population = 0; - s.leaders.carnage.grudge = { chump: 0, starmless: 100 }; + s.leaders.carnage.grudge = { chump: 0, burnem: 100 }; s.leaders.chump.stockpile.shields = 0; - s.leaders.starmless.stockpile.shields = 0; + s.leaders.burnem.stockpile.shields = 0; const r = applyFinalRetaliation(s, ['carnage']); const launchedAtChump = r.events.filter( (e) => e.kind === 'MissileLaunched' && e.to === 'chump', @@ -100,24 +100,24 @@ describe('applyFinalRetaliation', () => { }); it('falls back to uniform random when grudge is empty (preserves P1 behaviour)', () => { - const s = initialState({ cast: ['chump', 'carnage', 'starmless'], difficulty: 'normal', seed: 'fr-uniform' }); + const s = initialState({ cast: ['chump', 'carnage', 'burnem'], difficulty: 'normal', seed: 'fr-uniform' }); s.leaders.carnage.stockpile.missiles = 8; s.leaders.carnage.stockpile.warheadsSmall = 8; s.leaders.carnage.alive = false; s.leaders.carnage.population = 0; // grudge empty (default). s.leaders.chump.stockpile.shields = 0; - s.leaders.starmless.stockpile.shields = 0; + s.leaders.burnem.stockpile.shields = 0; const r = applyFinalRetaliation(s, ['carnage']); - const launchedAtStarmless = r.events.filter( - (e) => e.kind === 'MissileLaunched' && e.to === 'starmless', + const launchedAtBurnem = r.events.filter( + (e) => e.kind === 'MissileLaunched' && e.to === 'burnem', ).length; const launchedAtChump = r.events.filter( (e) => e.kind === 'MissileLaunched' && e.to === 'chump', ).length; // Both targets should have at least one launch over 8 firings (RNG with seed 'fr-uniform'). - expect(launchedAtStarmless + launchedAtChump).toBe(8); - expect(launchedAtStarmless).toBeGreaterThan(0); + expect(launchedAtBurnem + launchedAtChump).toBe(8); + expect(launchedAtBurnem).toBeGreaterThan(0); expect(launchedAtChump).toBeGreaterThan(0); }); }); diff --git a/tests/engine/flavor/pick.test.ts b/tests/engine/flavor/pick.test.ts index 07fef5b..f278674 100644 --- a/tests/engine/flavor/pick.test.ts +++ b/tests/engine/flavor/pick.test.ts @@ -1,8 +1,29 @@ import { describe, expect, it } from 'vitest'; import { pickLine } from '../../../src/engine/flavor/pick'; -import { getBank } from '../../../src/engine/flavor/index'; +import { getBank, genericFallback } from '../../../src/engine/flavor/index'; +import type { FlavorBank } from '../../../src/engine/flavor/index'; import { seedFromString } from '../../../src/engine/rng'; +/** Minimal synthetic bank: every category besides `launch` is irrelevant to these tests. */ +function makeFixtureBank(launchLines: string[]): FlavorBank { + const empty: string[] = []; + return { + preRoundMood: empty, + preRoundMoodSnapBack: '(snap back)', + launch: launchLines, + hit: empty, + woo: empty, + beingWooed: empty, + propagandaSend: empty, + propagandaReceive: empty, + buildFactory: empty, + buildDefence: empty, + reaction: empty, + death: empty, + finalRetaliation: empty, + }; +} + describe('pickLine', () => { it('is deterministic for the same rngState', () => { const bank = getBank('chump')!; @@ -49,12 +70,22 @@ describe('pickLine', () => { }); it('filters out lines containing untracked substitution tokens', () => { - // starmlessBank.launch has lines with {scapegoat}. With no scapegoat substitution, - // the picker must filter those lines out before drawing. - const bank = getBank('starmless')!; + // Synthetic fixture: one normal line, one line carrying an untracked + // {scapegoat} token. With no scapegoat substitution supplied, pick.ts's + // eligible() filter (src/engine/flavor/pick.ts:23-29) must exclude the + // token-bearing line before drawing, leaving only the normal line. + const bank = makeFixtureBank(['a normal line', 'blame {scapegoat} for this']); for (const seed of ['x', 'y', 'z'].map(seedFromString)) { const r = pickLine(bank, 'launch', seed); // no substitutions - expect(r.quote).not.toContain('{scapegoat}'); + expect(r.quote).toBe('a normal line'); } }); + + it('falls back to genericFallback when every line is ineligible', () => { + // Every candidate line carries an untracked token, so eligible() excludes + // all of them and pickLine must take the genericFallback path. + const bank = makeFixtureBank(['blame {scapegoat} for this', 'and {scapegoat} again']); + const r = pickLine(bank, 'launch', seedFromString('any')); + expect(r.quote).toBe(genericFallback.launch[0]); + }); }); diff --git a/tests/engine/integration.test.ts b/tests/engine/integration.test.ts index 45ed7a6..c6df58d 100644 --- a/tests/engine/integration.test.ts +++ b/tests/engine/integration.test.ts @@ -7,7 +7,7 @@ import { scriptedOrders } from '../helpers/scripted-orders'; describe('integration โ€” three-leader scripted game', () => { it('runs at least 5 rounds without error and produces a typed event stream', () => { let s = initialState({ - cast: ['chump', 'carnage', 'starmless'], + cast: ['chump', 'carnage', 'burnem'], difficulty: 'normal', seed: 'integration-1', }); diff --git a/tests/engine/reducer.test.ts b/tests/engine/reducer.test.ts index c2ecf69..e3a9d53 100644 --- a/tests/engine/reducer.test.ts +++ b/tests/engine/reducer.test.ts @@ -8,14 +8,14 @@ describe('reduce โ€” NEW_GAME', () => { a.round = 5; const b = reduce(a, { type: 'NEW_GAME', - cast: ['chump', 'carnage', 'starmless'], + cast: ['chump', 'carnage', 'burnem'], difficulty: 'easy', seed: 'second', }); expect(b.round).toBe(1); expect(b.difficulty).toBe('easy'); expect(b.seed).toBe('second'); - expect(b.cast).toEqual(['chump', 'carnage', 'starmless']); + expect(b.cast).toEqual(['chump', 'carnage', 'burnem']); }); }); diff --git a/tests/engine/resolution.test.ts b/tests/engine/resolution.test.ts index 4d02e3d..2ebc1b2 100644 --- a/tests/engine/resolution.test.ts +++ b/tests/engine/resolution.test.ts @@ -83,7 +83,7 @@ describe('resolveRound', () => { }); it('eliminates a leader and triggers Final Retaliation cascade', () => { - let s = initialState({ cast: ['chump', 'carnage', 'starmless'], difficulty: 'normal', seed: 'x' }); + let s = initialState({ cast: ['chump', 'carnage', 'burnem'], difficulty: 'normal', seed: 'x' }); // Chump fires 4 Large warheads at Carnage with 0 shields โ†’ 4th guaranteed to land. s.leaders.chump.stockpile.missiles = 4; s.leaders.chump.stockpile.warheadsLarge = 4; @@ -101,7 +101,7 @@ describe('resolveRound', () => { }; s = withOrders(s, 'chump', [launch, launch, launch, launch]); s = withOrders(s, 'carnage', []); - s = withOrders(s, 'starmless', []); + s = withOrders(s, 'burnem', []); const r = resolveRound(s); expect(r.state.leaders.carnage.alive).toBe(false); const kinds = r.events.map((e) => e.kind); @@ -185,28 +185,28 @@ describe('resolveRound', () => { it('FR cascade impacts also update grudge (deterministic via overwhelmed-defences setup)', () => { // Setup: carnage dies from chump's launches AND has a heavy stockpile that fires - // 8 FR launches at chump+starmless. With shields=0 on both, pigeonhole guarantees + // 8 FR launches at chump+burnem. With shields=0 on both, pigeonhole guarantees // one target gets โ‰ฅ4 incoming โ†’ 4th has 0% intercept โ†’ at least one FR hit lands. // That landed FR impact must attribute grudge to carnage (the dying leader). - let s = initialState({ cast: ['chump', 'carnage', 'starmless'], difficulty: 'normal', seed: 'fr-grudge' }); + let s = initialState({ cast: ['chump', 'carnage', 'burnem'], difficulty: 'normal', seed: 'fr-grudge' }); s.leaders.chump.stockpile.missiles = 4; s.leaders.chump.stockpile.warheadsLarge = 4; s.leaders.carnage.population = 5; s.leaders.carnage.stockpile.missiles = 8; s.leaders.carnage.stockpile.warheadsSmall = 8; s.leaders.chump.stockpile.shields = 0; - s.leaders.starmless.stockpile.shields = 0; + s.leaders.burnem.stockpile.shields = 0; const launch = { kind: 'launch' as const, target: 'carnage' as const, delivery: 'missile' as const, warhead: 'large' as const, targetType: 'people' as const, }; s = withOrders(s, 'chump', [launch, launch, launch, launch]); s = withOrders(s, 'carnage', []); - s = withOrders(s, 'starmless', []); + s = withOrders(s, 'burnem', []); const r = resolveRound(s); const chumpGrudge = r.state.leaders.chump.grudge.carnage ?? 0; - const starmlessGrudge = r.state.leaders.starmless.grudge.carnage ?? 0; - expect(chumpGrudge + starmlessGrudge).toBeGreaterThan(0); + const burnemGrudge = r.state.leaders.burnem.grudge.carnage ?? 0; + expect(chumpGrudge + burnemGrudge).toBeGreaterThan(0); }); }); diff --git a/tests/engine/state.test.ts b/tests/engine/state.test.ts index 6c9d9da..c748a04 100644 --- a/tests/engine/state.test.ts +++ b/tests/engine/state.test.ts @@ -20,18 +20,18 @@ describe('initialState', () => { it('supports a 3-leader cast', () => { const s = initialState({ - cast: ['chump', 'carnage', 'starmless'], + cast: ['chump', 'carnage', 'burnem'], difficulty: 'normal', seed: 'x', }); - expect(s.cast).toEqual(['chump', 'carnage', 'starmless']); - expect(Object.keys(s.leaders).sort()).toEqual(['carnage', 'chump', 'starmless']); - expect(s.leaders.starmless.population).toBe(25); + expect(s.cast).toEqual(['chump', 'carnage', 'burnem']); + expect(Object.keys(s.leaders).sort()).toEqual(['burnem', 'carnage', 'chump']); + expect(s.leaders.burnem.population).toBe(25); }); it('supports a 5-leader cast', () => { const s = initialState({ - cast: ['chump', 'carnage', 'starmless', 'netanyahoo', 'mileigh-hem'], + cast: ['chump', 'carnage', 'burnem', 'netanyahoo', 'mileigh-hem'], difficulty: 'normal', seed: 'x', }); diff --git a/tests/engine/winConditions.test.ts b/tests/engine/winConditions.test.ts index 3041011..94ce1db 100644 --- a/tests/engine/winConditions.test.ts +++ b/tests/engine/winConditions.test.ts @@ -3,19 +3,19 @@ import { checkOutcome } from '../../src/engine/winConditions'; import { initialState } from '../../src/engine/state'; describe('checkOutcome', () => { - const base = initialState({ cast: ['chump', 'carnage', 'starmless'], difficulty: 'normal', seed: 'x' }); + const base = initialState({ cast: ['chump', 'carnage', 'burnem'], difficulty: 'normal', seed: 'x' }); it('returns null while multiple leaders are alive', () => { - expect(checkOutcome(base, { chump: 33, carnage: 25, starmless: 25 })).toBeNull(); + expect(checkOutcome(base, { chump: 33, carnage: 25, burnem: 25 })).toBeNull(); }); it('returns survivor when exactly one leader has pop > 0', () => { const s = structuredClone(base); s.leaders.carnage.population = 0; s.leaders.carnage.alive = false; - s.leaders.starmless.population = 0; - s.leaders.starmless.alive = false; - expect(checkOutcome(s, { chump: 33, carnage: 25, starmless: 25 })).toEqual({ + s.leaders.burnem.population = 0; + s.leaders.burnem.alive = false; + expect(checkOutcome(s, { chump: 33, carnage: 25, burnem: 25 })).toEqual({ type: 'survivor', winner: 'chump', }); @@ -23,11 +23,11 @@ describe('checkOutcome', () => { it('returns pyrrhic when every leader died this round', () => { const s = structuredClone(base); - for (const id of ['chump', 'carnage', 'starmless'] as const) { + for (const id of ['chump', 'carnage', 'burnem'] as const) { s.leaders[id].population = 0; s.leaders[id].alive = false; } - expect(checkOutcome(s, { chump: 33, carnage: 25, starmless: 25 })).toEqual({ + expect(checkOutcome(s, { chump: 33, carnage: 25, burnem: 25 })).toEqual({ type: 'pyrrhic', winner: 'chump', }); @@ -35,11 +35,11 @@ describe('checkOutcome', () => { it('returns apocalypse when nobody had population entering the round', () => { const s = structuredClone(base); - for (const id of ['chump', 'carnage', 'starmless'] as const) { + for (const id of ['chump', 'carnage', 'burnem'] as const) { s.leaders[id].population = 0; s.leaders[id].alive = false; } - expect(checkOutcome(s, { chump: 0, carnage: 0, starmless: 0 })).toEqual({ type: 'apocalypse' }); + expect(checkOutcome(s, { chump: 0, carnage: 0, burnem: 0 })).toEqual({ type: 'apocalypse' }); }); }); 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..26dcf94 --- /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: 'burnem', 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: 'burnem', 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], + ]); + }); +}); 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(); + }); +}); 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(); }); }); 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(); + }); +}); 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(); + }); +}); 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' }); }); }); 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.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(); }); }); 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); 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'); + }); +}); 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(); + }); +}); 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' }); + }); +}); diff --git a/tests/ui/Setup.multihuman.test.tsx b/tests/ui/Setup.multihuman.test.tsx index 5e4b33d..b68a72d 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, /burn'em/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, /burn'em/i, /netanyahoo/i, /khameneverhere/i]) { + expect(screen.getByRole('button', { name })).toHaveAttribute('aria-pressed', 'true'); + } }); }); 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); + }); +}); diff --git a/tests/ui/WorldMap.test.tsx b/tests/ui/WorldMap.test.tsx new file mode 100644 index 0000000..a864668 --- /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="burnem"]')).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']); + }); +}); 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(); + }); +}); diff --git a/tests/ui/newspaper.test.ts b/tests/ui/newspaper.test.ts new file mode 100644 index 0000000..dadc073 --- /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', 'burnem']); + game.leaders.chump.population = 18; + const rows = deriveMarket(game, { chump: 33, burnem: 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', 'burnem', 'mileigh-hem']); + game.leaders.burnem.population = 22; // up from 20 + game.leaders['mileigh-hem'].population = 21; // down from 22 + const rows = deriveMarket(game, { + chump: game.leaders.chump.population, + burnem: 20, + 'mileigh-hem': 22, + }); + expect(rows.find((r) => r.id === 'chump')!.note).toBe('holds'); + const up = rows.find((r) => r.id === 'burnem')!; + 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', 'burnem']); + 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 === 'burnem')!.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]); + }); +});