diff --git a/docs/superpowers/plans/2026-07-26-fun-while-dying-slice2-newspaper-content.md b/docs/superpowers/plans/2026-07-26-fun-while-dying-slice2-newspaper-content.md new file mode 100644 index 0000000..b69929f --- /dev/null +++ b/docs/superpowers/plans/2026-07-26-fun-while-dying-slice2-newspaper-content.md @@ -0,0 +1,227 @@ +# Slice 2 — Newspaper content (adverts + ironic weather) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax. + +**Goal:** Make the RoundSummary tabloid varied and funny — a rotating pool of 15 surreal adverts, a bigger rotating classifieds window, ironic weather that varies by round, and more corrections. Content + pure selection helpers only; no engine/AI/layout change. + +**Architecture:** All new content + selection lives in `src/ui/util/newspaper.ts` as pure functions of `reportedRound`. `src/ui/screens/RoundSummary.tsx` swaps its static renders for the new pickers. Deterministic, reproducible, testable. + +**Tech Stack:** React 18 + TS + CSS modules, vitest. `vite-node` available. + +**Spec:** `docs/superpowers/specs/2026-07-26-fun-while-dying-slice2-newspaper-content-design.md` + +## Global Constraints + +- Product emojis (☢ ▲▼ flags) are design language — keep them. +- Every commit: `npm run typecheck` clean AND `npm run test:run` green. +- Tests: unconditional assertions only (no `if (x) expect(...)`). +- Advert voice is LOCKED (spec §2.1): original surreal inanity + prepper spoof, in the Nuclear Ducks / Tinned Sunshine register. NO borrowed Monty Python / Marx quotes. Exactly 15 adverts, using the locked list. +- Selection is a pure function of `reportedRound` — no RNG, no engine state. + +## Guardrails (better-memory) + +- Product design-language emojis are not "whimsy" to strip (mem: 'No whimsy' scopes to assistant text, not product UI). +- `deriveForecast` arity change ripples to its caller + any test — update all call sites in the same commit (mem: every commit typechecks; changing a signature shifts tests outside the planned file). + +## File Structure + +``` +src/ui/util/newspaper.ts (modify: + Advert type, ADVERTS, pickAdvert; expand CLASSIFIEDS + pickClassifieds; deriveForecast gains round param + rotating rows; expand CORRECTIONS) +src/ui/screens/RoundSummary.tsx (modify: use pickAdvert, pickClassifieds, deriveForecast(thisRoundLost, reportedRound)) +tests/ui/newspaper.test.ts (modify/extend: pool sizes, rotation, distinctness, tier mapping preserved) +``` + +--- + +### Task 1: Content pools + selection helpers (`newspaper.ts`) + +**Files:** +- Modify: `src/ui/util/newspaper.ts` +- Test: `tests/ui/newspaper.test.ts` + +**Interfaces produced:** +- `export interface Advert { title: string; body: string }` +- `export const ADVERTS: readonly Advert[]` (exactly 15) +- `export function pickAdvert(reportedRound: number): Advert` +- `export function pickClassifieds(reportedRound: number, n?: number): Classified[]` (default n=4) +- `export function deriveForecast(thisRoundLost: number, reportedRound: number): Forecast` (round param ADDED) +- `CLASSIFIEDS` expanded to ≥16; `CORRECTIONS` expanded to ≥8. + +- [ ] **Step 1: Write failing tests** — add to `tests/ui/newspaper.test.ts`: + +```ts +import { describe, it, expect } from 'vitest'; +import { + ADVERTS, pickAdvert, CLASSIFIEDS, pickClassifieds, CORRECTIONS, deriveForecast, +} from '../../src/ui/util/newspaper'; + +describe('adverts', () => { + it('pool is exactly 15 with non-empty title + body', () => { + expect(ADVERTS).toHaveLength(15); + for (const a of ADVERTS) { + expect(a.title.length).toBeGreaterThan(0); + expect(a.body.length).toBeGreaterThan(0); + } + }); + it('pickAdvert rotates by round and wraps', () => { + expect(pickAdvert(1)).toBe(ADVERTS[0]); + expect(pickAdvert(2)).not.toBe(pickAdvert(1)); + expect(pickAdvert(16)).toBe(pickAdvert(1)); // 15-wrap + }); +}); + +describe('classifieds', () => { + it('pool is at least 16', () => { + expect(CLASSIFIEDS.length).toBeGreaterThanOrEqual(16); + }); + it('pickClassifieds returns n distinct items and rotates by round', () => { + const r1 = pickClassifieds(1, 4); + const r2 = pickClassifieds(2, 4); + expect(r1).toHaveLength(4); + expect(new Set(r1.map((c) => c.text)).size).toBe(4); // distinct within a round + expect(r1.map((c) => c.text).join('|')).not.toBe(r2.map((c) => c.text).join('|')); + }); +}); + +describe('corrections', () => { + it('pool is at least 8', () => { + expect(CORRECTIONS.length).toBeGreaterThanOrEqual(8); + }); +}); + +describe('deriveForecast (round-varied, tier preserved)', () => { + it('maps damage to the right tier outlook + uv', () => { + expect(deriveForecast(0, 1).outlook).toBe('FALLOUT: NONE'); + expect(deriveForecast(0, 1).uv).toBe(1); + expect(deriveForecast(3, 1).outlook).toBe('FALLOUT: LIGHT'); + expect(deriveForecast(10, 1).outlook).toBe('FALLOUT: HEAVY'); + expect(deriveForecast(10, 1).uv).toBe(4); + expect(deriveForecast(20, 1).outlook).toBe('FALLOUT: BIBLICAL'); + expect(deriveForecast(20, 1).uv).toBe(5); + }); + it('same damage tier reads differently across rounds (rows rotate)', () => { + const a = deriveForecast(10, 1).rows.map((r) => r.value).join('|'); + const b = deriveForecast(10, 2).rows.map((r) => r.value).join('|'); + expect(a).not.toBe(b); + }); +}); +``` + +- [ ] **Step 2: Run — expect FAIL** + +Run: `npx vitest run tests/ui/newspaper.test.ts` +Expected: FAIL (ADVERTS/pickAdvert/pickClassifieds undefined; deriveForecast arity; pool sizes). + +- [ ] **Step 3: Implement in `newspaper.ts`** + +Add the Advert type + the locked 15-entry `ADVERTS` (spec §2.1 list verbatim in voice — each `{ title, body }`; body carries pitch + price/CTA; use `\n` in title only if the two-line look is wanted, otherwise plain). Add: + +```ts +export interface Advert { title: string; body: string } + +export const ADVERTS: readonly Advert[] = [ /* the 15 from spec §2.1, in order */ ]; + +export function pickAdvert(reportedRound: number): Advert { + return ADVERTS[(reportedRound - 1) % ADVERTS.length]; +} +``` + +Expand `CLASSIFIEDS` to ≥16 (keep the existing 4, add ≥12 in the same surreal-inanity/prepper voice; some may share a `tag`). Add: + +```ts +export function pickClassifieds(reportedRound: number, n = 4): Classified[] { + const start = (reportedRound - 1) % CLASSIFIEDS.length; + return Array.from({ length: Math.min(n, CLASSIFIEDS.length) }, (_, i) => + CLASSIFIEDS[(start + i) % CLASSIFIEDS.length], + ); +} +``` + +Weather: change `deriveForecast(thisRoundLost: number, reportedRound: number)`. Keep the four damage tiers driving `outlook`/`temp`/`tempLabel`/`uv` (and the documented UV-ladder divergence note — do NOT revert it). Replace the fixed `rows` with per-damage-state rotating variants: + +```ts +// Each damage state has >=2 ironic row-set variants; pick by round so the same +// tier reads differently round-to-round. Keep the Fallout/Visibility/Wind/Outlook +// shape; make the copy deadpan-wasteland ironic. +type RowSet = ForecastRow[]; +const CALM_ROWSETS: RowSet[] = [ /* >=2 sets for thisRoundLost === 0 */ ]; +const HIT_ROWSETS: RowSet[] = [ /* >=2 sets for thisRoundLost > 0 */ ]; +// inside deriveForecast: +const sets = thisRoundLost > 0 ? HIT_ROWSETS : CALM_ROWSETS; +const rows = sets[(reportedRound - 1) % sets.length]; +``` + +Provide ≥3 variants each, ironic (e.g. "Wind: mushroom-shaped, gusting to apocalyptic"; "Visibility: nil to 200 yards, improves once the dust is you"; "UV: put factor 50 on the survivors"; "Outlook: unseasonably terminal"). Keep `{ label, value }` shape. + +Expand `CORRECTIONS` to ≥8 in-voice items. `pickCorrection` unchanged. + +- [ ] **Step 4: Run — expect PASS** + +Run: `npx vitest run tests/ui/newspaper.test.ts` → PASS. + +- [ ] **Step 5: Typecheck** — `npm run typecheck`. Expected FAIL only at the `deriveForecast` call site in RoundSummary.tsx (fixed in Task 2). If any OTHER file calls deriveForecast, note it for Task 2. Do not commit yet if the suite is red from the arity change — implement Task 2 first, then commit both together. (If cleaner, commit Task 1 with a temporary second arg at the call site; either way the branch must end green.) + +--- + +### Task 2: Wire RoundSummary + green suite + +**Files:** +- Modify: `src/ui/screens/RoundSummary.tsx` +- Test: `tests/ui/RoundSummary.render.test.tsx` (only if it asserts old static content) + +**Interfaces consumed:** `pickAdvert`, `pickClassifieds`, `deriveForecast(thisRoundLost, reportedRound)` from Task 1. + +- [ ] **Step 1: Update imports** — add `pickAdvert`, `pickClassifieds`, `type Advert` to the `newspaper` import; `CLASSIFIEDS` may stay imported only if still referenced (it won't be — remove it to satisfy `noUnusedLocals`). + +- [ ] **Step 2: Forecast call** — line ~87: `const forecast = deriveForecast(thisRoundLost, reportedRound);` + +- [ ] **Step 3: Classifieds render** — replace the `CLASSIFIEDS.map(...)` block (lines ~295–301) with `pickClassifieds(reportedRound).map((c, i) => (...))`, keying by index `i` (expanded pool may repeat `tag`, so `key={c.tag}` would collide): + +```tsx +
CLASSIFIEDS
+{pickClassifieds(reportedRound).map((c, i) => ( +

+ {c.tag} + {c.text} +

+))} +``` + +- [ ] **Step 4: Advert block** — replace the hardcoded block (lines ~304–310): + +```tsx +const advert = pickAdvert(reportedRound); // hoist near other per-render derivations +// ... +
+
ADVERTISEMENT
+
+ {advert.title.split('\n').map((line, i) => ( + {i > 0 &&
}{line}
+ ))} +
+
{advert.body}
+
+``` + +- [ ] **Step 5: Full verify** + +Run: `npm run typecheck` → clean. +Run: `npm run test:run` → all green. Update `RoundSummary.render.test.tsx` only if it asserted the literal "NUCLEAR DUCKS" text or the old 4 classifieds (repoint to assert the ad block renders *an* advert title + body, and that classifieds render 4 rows). + +- [ ] **Step 6: Visual check** — `npm run dev`, open a RoundSummary (play a round or dev-nav); confirm the advert + classifieds change across rounds and the weather reads ironically. (Not a gate; sanity only.) + +- [ ] **Step 7: Commit** (both tasks together, branch already `feat/newspaper-content`) + +```bash +git add src/ui/util/newspaper.ts src/ui/screens/RoundSummary.tsx tests/ui/newspaper.test.ts tests/ui/RoundSummary.render.test.tsx +git commit -m "feat(ui): rotating surreal adverts, expanded classifieds, ironic varied weather" +``` + +--- + +## Self-Review Notes + +- Spec coverage: §2.1 adverts → Task 1 ADVERTS/pickAdvert + Task 2 ad block; §2.2 classifieds → Task 1 pool+pickClassifieds + Task 2 render; §2.3 weather → Task 1 deriveForecast rotation + Task 2 call; §2.4 corrections → Task 1. §4 tests → Task 1 Step 1 + Task 2 Step 5. +- Arity change to `deriveForecast` is the one cross-file ripple — handled explicitly (Task 1 Step 5 + Task 2 Step 2), suite must be green at the single commit. +- Advert list is locked (15) in the spec; implementer transcribes the voice, does not invent a different register or add borrowed quotes. +- No engine/AI/scoring/layout change; RoundSummary CSS modules untouched. diff --git a/docs/superpowers/specs/2026-07-26-fun-while-dying-slice2-newspaper-content-design.md b/docs/superpowers/specs/2026-07-26-fun-while-dying-slice2-newspaper-content-design.md new file mode 100644 index 0000000..78d0205 --- /dev/null +++ b/docs/superpowers/specs/2026-07-26-fun-while-dying-slice2-newspaper-content-design.md @@ -0,0 +1,87 @@ +# nuke — "fun while dying" slice 2: newspaper content (adverts + ironic weather) + +**Date:** 2026-07-26 +**Status:** approved in brainstorming; pending spec review +**Design frame:** In a "nobody wins" game the reward is an entertaining death. The RoundSummary tabloid is the primary fun-while-dying surface — it is also, per the playability assessment (`docs/playability/playability-usability-assessment.md`, C2/C10), the causal-feedback + satisfaction surface. This slice makes it varied and funny. Engine/AI untouched. + +Part of a 3-slice effort: (1) characters & difficulty, (2) **this** — newspaper content, (3) score-how-you-died. Slices ship as independent PRs. + +## 0. Current state (what exists) + +`src/ui/util/newspaper.ts` + `src/ui/screens/RoundSummary.tsx`: +- **Classifieds:** `CLASSIFIEDS` = 4 fixed items; RoundSummary renders **all four every round** (`.map`), so they never change game-to-game or round-to-round. +- **Advert block:** a single hardcoded "NUCLEAR DUCKS" ad in `RoundSummary.tsx` — identical every round. +- **Weather:** `deriveForecast(thisRoundLost)` picks one of four damage tiers (NONE/LIGHT/HEAVY/BIBLICAL) with mostly-static rows; only real variance is by damage. Same damage → identical forecast every time. +- **Corrections:** `CORRECTIONS` = 3 items, rotated by `(round-1) % 3`. + +Result: after two or three rounds the paper repeats itself. Low replay fun. + +## 1. Goals + +- Adverts and classifieds feel fresh across a ~15-round game and across replays. +- The weather forecast is ironic/deadpan and varies round-to-round, while still *reacting* to the round's carnage (keep the damage-tier link — weather commenting on the body count is the joke). +- Deterministic selection (by round number) so output is reproducible and testable; consecutive rounds differ; repeats within one game are rare. +- No engine, AI, or layout changes — content pools + selection helpers + minimal RoundSummary wiring only. + +## 2. Design + +### 2.1 Adverts (new rotating pool) + +**Tone (locked via visual companion, 2026-07-26):** surreal, original *inanity* — straight-faced adverts selling impossible/abstract things, blithe about the apocalypse printed next to them. The register is **Nuclear Ducks / Tinned Sunshine**, NOT borrowed comedy quotes. Do NOT reuse Monty Python / Marx Brothers lines (Argument Clinic, dead parrot, the Larch, "hovercraft full of eels", Freedonian moustaches, etc. are OUT — they were rejected as quote-shoehorning). Write in the spirit, not the reference. Two flavours mix in one pool: +- **Abstract inanity:** selling impossible/abstract goods in deadpan ad-copy. +- **Prepper spoof:** doomsday-prepper marketing played straight — fear-upsell selling the reader their own private WMD and survival tat. + +Format per entry: `{ title: string; body: string }` where body carries the pitch and (usually) a price / call-to-action. The ad-block title may contain `\n` for the two-line look. + +- Add `export const ADVERTS: readonly Advert[]` to `newspaper.ts` — **exactly 15 entries** (locked list below; implementer may polish wording, keep the voice + concepts, keep the count at 15): + 1. Nuclear Ducks — "They float. They glow. They outlive you." · £2 each, 3 for the end of the world + 2. Tinned Sunshine — "Open in the event of nuclear winter. May contain bees. Definitely contains bees." · aisle 4, keeps for years + 3. Powdered Optimism — "Just add water and look away. One tub lasts a whole denial." · now hope-free + 4. A Small Amount of Later — "Buy time. Not much. Some." · terms shorter than expected + 5. Pre-Apologised Letters — "Regret, posted in advance." · box of 50, stamps optional + 6. Spare Ceiling — "For when yours leaves suddenly. Fits most skies." · flat-packed, like everything now + 7. Genuine Distance — "Put some between yourself and things. Miles or feelings." · by the yard, cut to length + 8. Assorted Consequences — "Grab bag. Some yours, some the neighbours'." · no refunds, obviously + 9. **Emergency Trousers (B):** "Filling a need — because you've filled yours. Sold in pairs; you'll go through the first." + 10. **Emergency Trousers (C):** "The alert came. So did you. Step into something dignified. Or these." *(B and C both ship as separate entries)* + 11. Your Own Private Nuke — "Why wait for a superpower? Deter the neighbours today. Collateral: the neighbours." · 0% APR, 100% MAD + 12. The Family-Size Warhead — "Big enough to share. Nobody will." · serves everyone, once + 13. Backyard Silo Kit — "Turn that unloved patio into mutually assured deterrence." · flat-packed, spade not included + 14. The Doomsday Direct-Debit — "Prep now, pay later. There is no later. Prep now." · cancel anytime (you can't) + 15. Prepper's Pantry — "Forty years of beans for the forty minutes you have left. You've always *bean* prepared." · bulk only +- Add `export function pickAdvert(reportedRound: number): Advert` → `ADVERTS[(reportedRound - 1) % ADVERTS.length]`. +- `RoundSummary.tsx`: replace the hardcoded NUCLEAR DUCKS block with `pickAdvert(reportedRound)`, rendering `title` (kept in the existing yellow ad-block styling) + `body`. The block keeps its current classes; only content is dynamic. Title `\n` → existing `
` treatment or split on `\n`. + +### 2.2 Classifieds (expand + rotate a subset) +- Expand `CLASSIFIEDS` to ~16 items (keep the 4 existing, add ~12 in-voice). +- Add `export function pickClassifieds(reportedRound: number, n = 4): Classified[]` — returns `n` items starting at a round-derived offset, wrapping the array, so each round shows a different window. Never returns duplicates within a single call (n ≤ pool length). +- `RoundSummary.tsx`: render `pickClassifieds(reportedRound)` instead of the whole `CLASSIFIEDS` array. Same per-item markup. + +### 2.3 Weather (ironic + varied, still damage-reactive) +- Keep `deriveForecast(thisRoundLost)`'s four damage tiers and the UV ladder (do NOT "fix" the documented UV divergence — spec note in the file stays). +- Within each tier, make the flavour rows **rotate by round**: add a per-tier pool of ironic row-sets (Fallout / Visibility / Wind / Outlook lines) and pick one set by round number, so the same damage tier reads differently on round 3 vs round 8. Add more deadpan wasteland lines ("Wind: mushroom-shaped, gusting to apocalyptic"; "UV: put the factor 50 on the survivors"; "Outlook: unseasonably terminal"). +- Signature change: `deriveForecast(thisRoundLost: number, reportedRound: number)` gains the round param for rotation. Update the one caller in `RoundSummary.tsx`. +- Keep `outlook`/`temp`/`tempLabel`/`uv` tied to the damage tier (the carnage-reactive spine); only the `rows` flavour rotates. + +### 2.4 Corrections (light expansion) +- Expand `CORRECTIONS` from 3 to ~8 in-voice items. `pickCorrection` unchanged (already rotates by round). + +## 3. Selection principle +All selection is a pure function of `reportedRound` (already threaded into RoundSummary). Deterministic, reproducible, varies each round, and with pools of ~8–16 vs ~15 rounds, in-game repeats are rare. No RNG, no engine state. + +## 4. Testing (`tests/ui/newspaper.test.ts`) +- `ADVERTS` is exactly 15; `CLASSIFIEDS` ≥16; `CORRECTIONS` ≥8 — unconditional asserts. +- `pickAdvert` / `pickClassifieds` / `deriveForecast` return **different** content for two different rounds (rotation works) — assert inequality, not just "defined". +- `pickClassifieds(r, 4)` returns 4 distinct items (no dup within a round). +- `deriveForecast` still maps damage → correct tier outlook/uv (existing behaviour preserved) across all four tiers. +- Existing newspaper tests stay green (update any that asserted the old static 4-classified render or the old `deriveForecast` arity). + +## 5. Out of scope +- The NUCLEAR DUCKS visual styling / ad-block layout (reused as-is). +- Market report, box score, obituaries, news stories (unchanged this slice). +- Scoring/outcome changes (slice 3). Character/AI changes (slice 1). + +## 6. Constraints +- Product emojis (☢ ▲▼ flags) are design language — keep them. +- Every commit typechecks (`tsc --noEmit`) and passes `npm run test:run`; no guarded assertions. +- Content stays in the cast's satirical voice; no religious markers; punch up (leaders, war, bureaucracy), consistent with the flavour-bank tone rules. diff --git a/src/ui/screens/RoundSummary.tsx b/src/ui/screens/RoundSummary.tsx index 45f0577..d037413 100644 --- a/src/ui/screens/RoundSummary.tsx +++ b/src/ui/screens/RoundSummary.tsx @@ -9,12 +9,13 @@ import DisparageColumn from '../components/DisparageColumn'; import { Btn, RelBadge, Stamp } from '../components/comic'; import { BOX_SCORE_EMPTY, - CLASSIFIEDS, deriveBoxScore, deriveForecast, deriveMarket, derivePhotoCaption, deriveStories, + pickAdvert, + pickClassifieds, pickCorrection, } from '../util/newspaper'; import styles from './RoundSummary.module.css'; @@ -84,11 +85,12 @@ export default function RoundSummary({ state, dispatch }: ScreenProps) { 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 forecast = deriveForecast(thisRoundLost, reportedRound); 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 advert = pickAdvert(reportedRound); const lostStamp = thisRoundLost > 0 ? `−${thisRoundLost}M` : undefined; // Eliminated this round = alive=false AND prev > 0 (detection preserved). @@ -293,8 +295,8 @@ export default function RoundSummary({ state, dispatch }: ScreenProps) {
CLASSIFIEDS
- {CLASSIFIEDS.map((c) => ( -

+ {pickClassifieds(reportedRound).map((c, i) => ( +

{c.tag} {c.text}

@@ -303,10 +305,12 @@ export default function RoundSummary({ state, dispatch }: ScreenProps) {
ADVERTISEMENT
-
NUCLEAR
DUCKS
-
- If it walks, talks, and quacks — it's covered. Limited supply. +
+ {advert.title.split('\n').map((line, i) => ( + {i > 0 &&
}{line}
+ ))}
+
{advert.body}
diff --git a/src/ui/util/newspaper.ts b/src/ui/util/newspaper.ts index a1c2e68..f001a52 100644 --- a/src/ui/util/newspaper.ts +++ b/src/ui/util/newspaper.ts @@ -33,28 +33,116 @@ export interface Forecast { * 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 FORECAST_TIERS = { + none: { outlook: 'FALLOUT: NONE', temp: '20°', tempLabel: 'seasonal, suspicious', uv: 1 }, + light: { outlook: 'FALLOUT: LIGHT', temp: '400°', tempLabel: 'localised high', uv: 2 }, + heavy: { outlook: 'FALLOUT: HEAVY', temp: '1,200°', tempLabel: 'ground zero high', uv: 4 }, + biblical: { outlook: 'FALLOUT: BIBLICAL', temp: '5,800°', tempLabel: 'surface of the sun, briefly', uv: 5 }, +}; + +/** + * Ironic row-set variants, keyed by whether this round drew blood. Rotating + * by round (rather than by the finer-grained fallout tier) keeps the same + * "hit"/"calm" state reading differently round-to-round (spec §2.3). + */ +type RowSet = ForecastRow[]; + +const CALM_ROWSETS: RowSet[] = [ + [ + { label: 'Fallout', value: 'None reported' }, + { label: 'Visibility', value: 'Unlimited. For now.' }, + { label: 'Wind', value: 'Light breeze' }, + { label: 'Outlook', value: 'Worse. Always worse.' }, + ], + [ + { label: 'Fallout', value: 'None reported. Suspicious.' }, + { label: 'Visibility', value: 'Unlimited. Make the most of it.' }, + { label: 'Wind', value: 'Calm, ominously so.' }, + { label: 'Outlook', value: 'Fine. For now.' }, + ], + [ + { label: 'Fallout', value: 'Nothing on the wind but rumour.' }, + { label: 'Visibility', value: 'Clear. Historians will note the date.' }, + { label: 'Wind', value: 'Light breeze, holding its breath.' }, + { label: 'Outlook', value: 'A pause. Not a peace.' }, + ], +]; + +const HIT_ROWSETS: RowSet[] = [ + [ + { label: 'Fallout', value: 'Confirmed. Drifting east, as fallout does.' }, + { label: 'Visibility', value: 'Nil to 200 yards.' }, + { label: 'Wind', value: 'Mushroom-shaped, gusting to apocalyptic.' }, + { label: 'Outlook', value: 'Worse. Always worse.' }, + ], + [ + { label: 'Fallout', value: 'Present and accounted for.' }, + { label: 'Visibility', value: 'Nil to 200 yards, improves once the dust is you.' }, + { label: 'Wind', value: 'Brisk, radioactive, unseasonal.' }, + { label: 'Outlook', value: 'Unseasonably terminal.' }, + ], + [ + { label: 'Fallout', value: 'Heavier than advertised.' }, + { label: 'Visibility', value: "Ash-limited. Bring a torch, or don't bother." }, + { label: 'Wind', value: 'Gusting toward whichever border complains loudest.' }, + { label: 'Outlook', value: 'Grim, with a chance of grimmer.' }, + ], +]; + +export function deriveForecast(thisRoundLost: number, reportedRound: 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' }; + thisRoundLost === 0 ? FORECAST_TIERS.none : + thisRoundLost <= 5 ? FORECAST_TIERS.light : + thisRoundLost <= 14 ? FORECAST_TIERS.heavy : + FORECAST_TIERS.biblical; + + const sets = thisRoundLost > 0 ? HIT_ROWSETS : CALM_ROWSETS; + const rows = sets[(reportedRound - 1) % sets.length]; - 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.' }, - ], + rows, }; } +/* ============================================================ + * ADVERTISEMENT + * ============================================================ */ + +export interface Advert { + title: string; + body: string; +} + +/** + * Locked 15-entry pool (spec §2.1): original surreal inanity + prepper spoof, + * Nuclear Ducks / Tinned Sunshine register. No borrowed comedy quotes. + */ +export const ADVERTS: readonly Advert[] = [ + { title: 'Nuclear Ducks', body: 'They float. They glow. They outlive you. £2 each, 3 for the end of the world.' }, + { title: 'Tinned Sunshine', body: 'Open in the event of nuclear winter. May contain bees. Definitely contains bees. Aisle 4, keeps for years.' }, + { title: 'Powdered Optimism', body: 'Just add water and look away. One tub lasts a whole denial. Now hope-free.' }, + { title: 'A Small Amount of Later', body: 'Buy time. Not much. Some. Terms shorter than expected.' }, + { title: 'Pre-Apologised Letters', body: 'Regret, posted in advance. Box of 50, stamps optional.' }, + { title: 'Spare Ceiling', body: 'For when yours leaves suddenly. Fits most skies. Flat-packed, like everything now.' }, + { title: 'Genuine Distance', body: 'Put some between yourself and things. Miles or feelings. By the yard, cut to length.' }, + { title: 'Assorted Consequences', body: "Grab bag. Some yours, some the neighbours'. No refunds, obviously." }, + { title: 'Emergency Trousers (B)', body: "Filling a need — because you've filled yours. Sold in pairs; you'll go through the first." }, + { title: 'Emergency Trousers (C)', body: 'The alert came. So did you. Step into something dignified. Or these.' }, + { title: 'Your Own Private Nuke', body: 'Why wait for a superpower? Deter the neighbours today. Collateral: the neighbours. 0% APR, 100% MAD.' }, + { title: 'The Family-Size Warhead', body: 'Big enough to share. Nobody will. Serves everyone, once.' }, + { title: 'Backyard Silo Kit', body: 'Turn that unloved patio into mutually assured deterrence. Flat-packed, spade not included.' }, + { title: 'The Doomsday Direct-Debit', body: "Prep now, pay later. There is no later. Prep now. Cancel anytime (you can't)." }, + { title: "Prepper's Pantry", body: "Forty years of beans for the forty minutes you have left. You've always bean prepared. Bulk only." }, +]; + +export function pickAdvert(reportedRound: number): Advert { + return ADVERTS[(reportedRound - 1) % ADVERTS.length]; +} + /* ============================================================ * MARKET REPORT * ============================================================ */ @@ -414,6 +502,11 @@ 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.', + 'CORRECTION: The Ministry of Defence denies denying anything. We regret the confusion.', + "CORRECTION: 'Surgical strike' was reviewed by our medical desk and rejected.", + 'CORRECTION: We described the ceasefire as "holding." It was not holding. Nothing is holding.', + 'CORRECTION: The general we quoted has since been promoted, demoted, and promoted again. We regret nothing, because we no longer know what happened.', + 'CORRECTION: An earlier edition named the aggressor. All parties have since claimed the title. We defer to the crater.', ]; export function pickCorrection(reportedRound: number): string { @@ -430,4 +523,23 @@ export const CLASSIFIEDS: readonly Classified[] = [ { 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!' }, + { tag: 'FOR SALE', text: 'Slightly-used bunker. One careful owner, several careless neighbours.' }, + { tag: 'WANTED', text: 'Someone to explain the chain of command. Urgently.' }, + { tag: 'FOUND', text: 'One (1) launch key. Turns out it was in the other pocket.' }, + { tag: 'SERVICES', text: 'Will draft a strongly-worded letter to any nation, any grievance. Postage not included.' }, + { tag: 'FOR SALE', text: 'Gently detonated warhead casing. Ideal planter. Some assembly required.' }, + { tag: 'WANTED', text: 'Volunteer to stand closer to the blast, for scientific curiosity.' }, + { tag: 'NOTICE', text: 'The Ministry of Reassurance regrets it has nothing reassuring to say.' }, + { tag: 'PERSONAL', text: 'Retired general, all limbs present, seeks quiet hobby. No missiles, please.' }, + { tag: 'FOR SALE', text: 'Emergency trousers, gently worn. See also: emergency.' }, + { tag: 'WANTED', text: "A neighbour who isn't planning something. Any neighbour." }, + { tag: 'LOST', text: 'The point of all this. Last seen sometime before round one.' }, + { tag: 'SERVICES', text: 'Bunker cleaning, hazard pay negotiable, references from survivors only.' }, ]; + +export function pickClassifieds(reportedRound: number, n = 4): Classified[] { + const start = (reportedRound - 1) % CLASSIFIEDS.length; + return Array.from({ length: Math.min(n, CLASSIFIEDS.length) }, (_, i) => + CLASSIFIEDS[(start + i) % CLASSIFIEDS.length], + ); +} diff --git a/tests/ui/newspaper.test.ts b/tests/ui/newspaper.test.ts index dadc073..8dd3352 100644 --- a/tests/ui/newspaper.test.ts +++ b/tests/ui/newspaper.test.ts @@ -1,5 +1,9 @@ import { describe, it, expect } from 'vitest'; import { + ADVERTS, + pickAdvert, + CLASSIFIEDS, + pickClassifieds, deriveForecast, deriveMarket, deriveBoxScore, @@ -26,14 +30,14 @@ describe('deriveForecast', () => { [15, 'FALLOUT: BIBLICAL', 5], ]; for (const [lost, outlook, uv] of cases) { - const f = deriveForecast(lost); + const f = deriveForecast(lost, 1); expect(f.outlook).toBe(outlook); expect(f.uv).toBe(uv); } }); it('reports quiet conditions when nothing was lost', () => { - const f = deriveForecast(0); + const f = deriveForecast(0, 1); expect(f.temp).toBe('20°'); expect(f.rows).toEqual([ { label: 'Fallout', value: 'None reported' }, @@ -44,18 +48,63 @@ describe('deriveForecast', () => { }); it('reports fallout conditions when people were lost', () => { - const f = deriveForecast(15); + const f = deriveForecast(15, 1); 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: 'Fallout', value: 'Confirmed. Drifting east, as fallout does.' }, + { label: 'Visibility', value: 'Nil to 200 yards.' }, + { label: 'Wind', value: 'Mushroom-shaped, gusting to apocalyptic.' }, { label: 'Outlook', value: 'Worse. Always worse.' }, ]); }); }); +describe('deriveForecast (round-varied, tier preserved)', () => { + it('maps damage to the right tier outlook + uv', () => { + expect(deriveForecast(0, 1).outlook).toBe('FALLOUT: NONE'); + expect(deriveForecast(0, 1).uv).toBe(1); + expect(deriveForecast(3, 1).outlook).toBe('FALLOUT: LIGHT'); + expect(deriveForecast(10, 1).outlook).toBe('FALLOUT: HEAVY'); + expect(deriveForecast(10, 1).uv).toBe(4); + expect(deriveForecast(20, 1).outlook).toBe('FALLOUT: BIBLICAL'); + expect(deriveForecast(20, 1).uv).toBe(5); + }); + it('same damage tier reads differently across rounds (rows rotate)', () => { + const a = deriveForecast(10, 1).rows.map((r) => r.value).join('|'); + const b = deriveForecast(10, 2).rows.map((r) => r.value).join('|'); + expect(a).not.toBe(b); + }); +}); + +describe('adverts', () => { + it('pool is exactly 15 with non-empty title + body', () => { + expect(ADVERTS).toHaveLength(15); + for (const a of ADVERTS) { + expect(a.title.length).toBeGreaterThan(0); + expect(a.body.length).toBeGreaterThan(0); + } + }); + it('pickAdvert rotates by round and wraps', () => { + expect(pickAdvert(1)).toBe(ADVERTS[0]); + expect(pickAdvert(2)).not.toBe(pickAdvert(1)); + expect(pickAdvert(16)).toBe(pickAdvert(1)); // 15-wrap + }); +}); + +describe('classifieds (rotation)', () => { + it('pool is at least 16', () => { + expect(CLASSIFIEDS.length).toBeGreaterThanOrEqual(16); + }); + it('pickClassifieds returns n distinct items and rotates by round', () => { + const r1 = pickClassifieds(1, 4); + const r2 = pickClassifieds(2, 4); + expect(r1).toHaveLength(4); + expect(new Set(r1.map((c) => c.text)).size).toBe(4); // distinct within a round + expect(r1.map((c) => c.text).join('|')).not.toBe(r2.map((c) => c.text).join('|')); + }); +}); + describe('deriveMarket', () => { it('computes rounded percent change from prevPopulations', () => { const game = makeGame(['player1', 'chump', 'burnem']); @@ -281,11 +330,15 @@ describe('derivePhotoCaption', () => { }); describe('pickCorrection', () => { - it('rotates through the three corrections by reported round', () => { - expect(CORRECTIONS).toHaveLength(3); + it('rotates through the corrections pool by reported round and wraps', () => { expect(pickCorrection(1)).toBe(CORRECTIONS[0]); expect(pickCorrection(2)).toBe(CORRECTIONS[1]); - expect(pickCorrection(3)).toBe(CORRECTIONS[2]); - expect(pickCorrection(4)).toBe(CORRECTIONS[0]); + expect(pickCorrection(CORRECTIONS.length + 1)).toBe(CORRECTIONS[0]); + }); +}); + +describe('corrections (pool)', () => { + it('pool is at least 8', () => { + expect(CORRECTIONS.length).toBeGreaterThanOrEqual(8); }); });