Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
a917c54
docs: P4a (Satire + Hotseat) design spec
emp3thy May 12, 2026
49d2420
docs: P4a spec — drop 'mobile-first 320px' framing
emp3thy May 12, 2026
175fb7a
docs: P4a (Satire + Hotseat) implementation plan
emp3thy May 12, 2026
2196d51
engine: extend schema for P4a — mastheadOrder, lastColumnNamedLeader,…
emp3thy May 12, 2026
5a81ae1
engine: flavor bank for Chump (12-category data)
emp3thy May 12, 2026
dd63e83
engine: flavor bank for Netanyahoo
emp3thy May 12, 2026
46bfa5e
engine: flavor bank for Khameneverhere
emp3thy May 12, 2026
e96e555
engine: flavor bank for Starmless (with {scapegoat} substitution token)
emp3thy May 12, 2026
e4db223
engine: flavor bank for Carnage
emp3thy May 12, 2026
7322264
engine: flavor bank for Mileigh-hem
emp3thy May 12, 2026
149b9ea
engine: khameneverhere buildDefence — restore second line from combin…
emp3thy May 12, 2026
3c691c3
engine: flavor picker, Disparage bank, FlavorBank type + index lookup
emp3thy May 12, 2026
94cac98
engine: masthead pool + Fisher-Yates shuffle; state seeds mastheadOrd…
emp3thy May 12, 2026
69b9586
engine: cameo module — Disparage trigger probabilities + named-leader…
emp3thy May 12, 2026
8b816a1
engine: emit PreRoundMood per living non-human leader at round start
emp3thy May 12, 2026
6ab184f
engine: emit PostRoundReaction per living non-human leader at round end
emp3thy May 12, 2026
63b1aa3
engine: emit DisparageColumn at end of resolution; set lastColumnName…
emp3thy May 12, 2026
7a03fc0
engine: emit DisparageCameo after impacts (probabilistic)
emp3thy May 12, 2026
6a1f095
engine: populate quote fields on MissileLaunched / Impact* / Propagan…
emp3thy May 12, 2026
8f03474
fix: remove unused shouldRollColumn import in cameo.test.ts (tsc TS6133)
emp3thy May 12, 2026
3b0c71f
engine: analyseOrderSequence soft-warn helper (3 warnings)
emp3thy May 12, 2026
d85fc88
ui: store routes multi-human PLAYER_SUBMIT through buffer + hotseat s…
emp3thy May 12, 2026
488e400
ui: HotseatHandoff curtain screen (country-forward layout)
emp3thy May 12, 2026
400d738
ui: Setup multi-human roster — add/remove up to 5 humans
emp3thy May 12, 2026
9ef6364
ui: Planning soft-warn panel + LeaderCard mood-line rendering
emp3thy May 12, 2026
89e13a6
ui: Action renders event quotes; DisparageCard for cameo events; exha…
emp3thy May 12, 2026
972d9c1
ui: T12 fixups — restore '🍺 FROM CLACTON' emoji; render both Propagan…
emp3thy May 12, 2026
6bd0879
ui: RoundSummary — masthead from state, populated reactions, OBITUARY…
emp3thy May 12, 2026
5425c79
docs: Phase 4a status note in README
emp3thy May 12, 2026
6d11f42
polish: fold final-review nits
emp3thy May 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,3 +130,27 @@ What's NOT in this phase (deferred to P4a / P4b):
- Soft-warn validation in Planning — P4a
- AI scoring-weight balance pass + AI-duel balance assertions — P4b
- Approach B / C upgrades to Hard-mode lookahead — P4b

## Phase 4a status

Phase 4a (Satire + Hotseat) ships multi-human hotseat support plus the full satirical voice — flavor banks wired into events, Disparage cameo on Action and Round Summary, masthead rotation, and 3 soft-warns on Planning. Verification: `npm run test:run` (217 tests, 35 files).

What's in this phase:

- **Hotseat Handoff** — 2–5 humans on one device, country-forward curtain between turns, cast-index turn order. Solo (1 human) skips the curtain.
- **Flavor banks** — six leader banks + Disparage bank under `src/engine/flavor/`. Engine picks lines deterministically (seeded RNG) at resolution time, bakes them into existing event variants (`attackerQuote`, `targetQuote`, etc.) and emits new flavor events (`PreRoundMood`, `PostRoundReaction`).
- **Disparage cameo** — `cameo.ts` rolls Action overlays (~17.5 % per impact) and the Round Summary column (~33 % per round). Column-named leader's next-round Planning mood line snap-backs; skipped silently if eliminated.
- **Masthead rotation** — 15-name pool Fisher-Yates shuffled at game start; one per round; apocalypse override.
- **Soft-warn validation** — Planning aside panel with 3 warnings (warhead-no-delivery, delivery-no-warhead, woo-non-attacker). Hidden when no warnings.

What's NOT in this phase (deferred to P4b / P5):

- AI scoring-weight balance pass + AI-duel balance assertions — P4b
- Approach B / C upgrades to Hard-mode lookahead — P4b
- Persistence (localStorage save/load + Resume + action log) — P5
- Replay timeline scrubber UI on Winners — P5
- Animations (Framer Motion, missile arcs, damage badges, Fast Resolve toggle) — P5
- Flavor presentation: speech-bubble animations, mood-line treatment — engine carries the data; P5 prettifies
- Audio (`play(name)` wrapper, sfx + ambient music) — P5
- SVG art (leader portraits, world map, Freedonia flag, mushroom-cloud illustration, ruined-iconography) — P5
- PWA manifest + service worker — P5
3,737 changes: 3,737 additions & 0 deletions docs/superpowers/plans/2026-05-12-phase-4a-satire-hotseat.md

Large diffs are not rendered by default.

385 changes: 385 additions & 0 deletions docs/superpowers/specs/2026-05-12-phase-4a-satire-hotseat-design.md

Large diffs are not rendered by default.

56 changes: 56 additions & 0 deletions src/engine/cameo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import type { LeaderId, ResolutionEvent } from './types';
import { nextRandom, nextInt } from './rng';

/** ~17.5% per ImpactPeople/ImpactInfrastructure event (spec Q5 trigger). */
export const CAMEO_PROB = 0.175;
/** ~33% per round (spec §2.3). */
export const COLUMN_PROB = 1 / 3;

export interface RollResult {
fire: boolean;
rngState: number;
}

export function shouldRollCameo(rngState: number): RollResult {
const r = nextRandom(rngState);
return { fire: r.value < CAMEO_PROB, rngState: r.state };
}

export function shouldRollColumn(rngState: number): RollResult {
const r = nextRandom(rngState);
return { fire: r.value < COLUMN_PROB, rngState: r.state };
}

export interface PickResult {
namedLeader: LeaderId | undefined;
rngState: number;
}

/**
* Pick a leader for the Disparage column to criticise. Prefers a leader who
* launched a missile this round; falls back to uniform random among living leaders.
* Returns undefined if there are no living leaders.
*/
export function pickColumnNamedLeader(
events: ResolutionEvent[],
livingLeaders: LeaderId[],
rngState: number,
): PickResult {
if (livingLeaders.length === 0) {
return { namedLeader: undefined, rngState };
}

const attackers = new Set<LeaderId>();
for (const e of events) {
if (e.kind === 'MissileLaunched' && livingLeaders.includes(e.from)) {
attackers.add(e.from);
}
}

const pool = attackers.size > 0
? [...attackers]
: livingLeaders;

const step = nextInt(rngState, pool.length);
return { namedLeader: pool[step.value], rngState: step.state };
}
56 changes: 56 additions & 0 deletions src/engine/flavor/carnage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import type { FlavorBank } from './index';

export const carnageBank: FlavorBank = {
preRoundMood: [
'Elbows up.',
'Nostalgia is not a strategy.',
'We are masters in our own home.',
'(adjusts central-banker glasses, calmly)',
],
preRoundMoodSnapBack: 'Elbows up. Pint down.',
launch: [
'Elbows up. We don\'t forget.',
'We will never, ever, in any way, shape or form, be part of {target}.',
'Nostalgia is not a strategy. Neither is mercy.',
'It\'s us, not you.',
'Elbows up — and through. They had their chance.',
'We will not allow {target} to fester in their current condition. That would be unkind.',
],
hit: [
'You hit us. Now we are *very* clear about who you are.',
'Elbows up.',
'(removes glasses, polishes them)',
],
woo: [
'Friendship matters. Especially with friends who have arsenals.',
'I\'m glad you\'ve upgraded yourself to ally.',
],
beingWooed: [
'Received with diplomatic warmth. We remain masters in our own home.',
],
propagandaSend: [
'We are circulating accurate factual records. Of {target}\'s recent behaviour.',
'Negativity isn\'t strength. Their negativity, specifically, is being widely noted.',
],
propagandaReceive: [
'We are masters in our own home. Their pamphlets fail to reach the doorstep.',
],
buildFactory: [
'Investing in productive capacity. Negativity won\'t pay the rent.',
'(announces the build with a small, polite smile)',
],
buildDefence: [
'Defence is not aggression. Defence is *reasonable*.',
],
reaction: [
'Elbows up. We don\'t forget.',
'Elbows up — and through.',
'We are masters in our own home. Slightly fewer of us, mind, but still masters.',
],
death: [
'It has been a privilege. Elbows up.',
],
finalRetaliation: [
'Elbows up, eh.',
],
};
58 changes: 58 additions & 0 deletions src/engine/flavor/chump.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import type { FlavorBank } from './index';

export const chumpBank: FlavorBank = {
preRoundMood: [
'Many people are saying we should hit somebody. We won\'t, but we should.',
'We\'re winning. We\'re winning so much. People are tired of winning.',
'Bad people. Bad countries. Sad!',
'Tremendous round. Tremendous.',
'Lower gas prices. No more wars. Believe me.',
],
preRoundMoodSnapBack: 'Nigel? Tremendous guy. Should be running things over there.',
launch: [
'We\'re hitting them. Hard. Like nobody\'s ever been hit. People are saying it\'s the best hit.',
'Look — they\'re loser countries. They had it coming. Many people are saying that.',
'This is a tremendous strike. Tremendous. The best strike, frankly.',
'They don\'t like the word *war*. So we\'ll call it… a military operation.',
],
hit: [
'FAKE NEWS. These weren\'t even real bombs. We\'re fine. Better than fine. Stronger than ever.',
'Many people are saying nobody actually hit me.',
'Sad! These are loser countries hitting a winning country. Doesn\'t make sense.',
'We were actually *cooling* as a country. Now they want us to think we\'re hot.',
],
woo: [
'Look, between us, we love this guy. Tremendous guy. The best.',
],
beingWooed: [
'He\'s a great guy, really tremendous, we love him. We won\'t hit him.',
'I\'ve always said — and many people are saying — he\'s one of the good ones.',
],
propagandaSend: [
'Their factories are dirty. Their people unhappy. Many people are saying come over here.',
'Everyone\'s leaving. EVERYONE. They\'re coming to us. Big league.',
],
propagandaReceive: [
'FAKE NEWS! Our people love us. They love us so much.',
],
buildFactory: [
'We\'re building. Beautiful factories. The best factories. People are crying when they see them.',
],
buildDefence: [
'We have a *dome*. Big beautiful dome. Nobody can get through. NOBODY.',
'Many people are saying our defences are the strongest defences in history. Tremendous.',
'If he didn\'t eat junk food, our missile shield would live to 200 years old.',
],
reaction: [
'Sad. Loser country. We\'re not going to hit them back. We don\'t need to. We\'re winning.',
'These weren\'t even real bombs. Many people are saying that.',
'I\'ve been hit by experts. Many experts. Tremendous experts. So this? Nothing.',
],
death: [
'This is a witch hunt. The biggest witch hunt in history.',
'I want to say to my supporters: we love you. We\'re going to be back. Bigger.',
],
finalRetaliation: [
'OK folks, this is going to be tremendous. Watch this. Many people are watching.',
],
};
33 changes: 33 additions & 0 deletions src/engine/flavor/disparage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
export interface DisparageBank {
cameo: string[]; // Action-screen one-liners (~15-20% per impact)
columnLines: string[]; // RoundSummary column body lines (~1-in-3 per round)
footerNotes: string[]; // Rotated absentee-MP footer notes
}

export const disparageBank: DisparageBank = {
cameo: [
'If I were in charge, this wouldn\'t be happening. Probably.',
'Disgraceful. Anyway, off to America again.',
'All the charisma of a damp rag and the diplomatic skills of a low-grade bank clerk.',
'We want our country back. From all this.',
'I told you. I told them all. Now look.',
'Two-tier policing. No further comment. Yet.',
'Frit. The lot of them.',
],
columnLines: [
'I\'d have done this much better if I were leader. Pint?',
'We love war. We just hate the wars run by other people.',
'I haven\'t had time for a constituent surgery — I\'ve been shouting about this on television. Priorities.',
'Disgusting. Disgraceful. I\'d have got our country back. Faster. Without all this. Anyway, off to America.',
'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.',
],
footerNotes: [
'Mr Disparage was unavailable for follow-up; he was photographing himself at a pub.',
'Mr Disparage\'s office referred us to GB News, where he was on air for the eighth time this week.',
'Mr Disparage was scheduled for a constituent surgery, but is currently in Florida.',
'Mr Disparage\'s voting record this Parliament is grade E. He could not be reached for comment.',
'This column was filed from outside the House of Commons, where Mr Disparage has not been seen since Tuesday.',
],
};
54 changes: 54 additions & 0 deletions src/engine/flavor/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import type { LeaderId } from '../types';
import { chumpBank } from './chump';
import { khameneverhereBank } from './khameneverhere';
import { netanyahooBank } from './netanyahoo';
import { carnageBank } from './carnage';
import { starmlessBank } from './starmless';
import { mileighhemBank } from './mileighhem';

export interface FlavorBank {
preRoundMood: string[];
preRoundMoodSnapBack: string;
launch: string[];
hit: string[];
woo: string[];
beingWooed: string[];
propagandaSend: string[];
propagandaReceive: string[];
buildFactory: string[];
buildDefence: string[];
reaction: string[];
death: string[];
finalRetaliation: string[];
}

export type FlavorCategory = Exclude<keyof FlavorBank, 'preRoundMoodSnapBack'>;

const BANKS: Partial<Record<LeaderId, FlavorBank>> = {
chump: chumpBank,
khameneverhere: khameneverhereBank,
netanyahoo: netanyahooBank,
carnage: carnageBank,
starmless: starmlessBank,
'mileigh-hem': mileighhemBank,
};

// Generic fallback — used when a leader has no bank entry or a category is empty.
export const genericFallback: Record<FlavorCategory, string[]> = {
preRoundMood: ['({leader} has filed orders.)'],
launch: ['({leader} launches.)'],
hit: ['({leader} took {damage}M casualties.)'],
woo: ['({leader} sends a friendship overture.)'],
beingWooed: ['({leader} receives a friendship overture.)'],
propagandaSend: ['({leader} drops pamphlets.)'],
propagandaReceive: ['({leader} reads pamphlets.)'],
buildFactory: ['({leader} breaks ground.)'],
buildDefence: ['({leader} reinforces defences.)'],
reaction: ['({leader} responds.)'],
death: ['({leader} has been eliminated.)'],
finalRetaliation: ['({leader} launches Final Retaliation.)'],
};

export function getBank(leaderId: LeaderId): FlavorBank | undefined {
return BANKS[leaderId];
}
60 changes: 60 additions & 0 deletions src/engine/flavor/khameneverhere.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import type { FlavorBank } from './index';

export const khameneverhereBank: FlavorBank = {
preRoundMood: [
'Khameneverhere has filed orders. The orders are unsigned.',
'Khameneverhere has filed orders. He may or may not still be alive.',
'Until they show him, we cannot be sure. Reports cite Schrödinger.',
'The cardboard cutout has been replaced with a fresh cardboard cutout.',
'Orders arrive by fax. Origin unverified.',
],
preRoundMoodSnapBack: '(no audio of Khameneverhere is available — his cardboard cutout faces away from the GB News studio.)',
launch: [
'Death to America, and to anyone who reorganises kitchen drawers.',
'The Great Satan — and by extension, all open-plan offices — must be eliminated.',
'Strike down our enemies. And anyone who programs traffic lights with three-second yellows.',
'Remove the West from this earth. And remove pineapple from pizza.',
'We will obliterate our enemies. We will also obliterate self-checkout machines.',
'Death to capitalism, and to whoever replies-all on a small thread.',
'Down with imperialism — and with whoever serves brussels sprouts at dinner.',
'(orders launched. Orders unsigned.)',
],
hit: [
'(the cardboard cutout tilts but does not respond.)',
'Khameneverhere has been struck. Or possibly was already struck. Sources unclear.',
],
woo: [
'(a printed note is delivered. It is unsigned.)',
],
beingWooed: [
'Wooing attempt directed at empty chair. Effect: indeterminate.',
'(the cardboard cutout tips slightly forward. Aides interpret this as acceptance.)',
],
propagandaSend: [
'Pamphlets are dropped. The pamphlets read: \'Your prime minister also reorganises drawers.\'',
],
propagandaReceive: [
'The cardboard cutout has read your pamphlet.',
],
buildFactory: [
'Production figures are released. The figures are unsigned.',
'His office has authorised additional capacity. He could not be reached for confirmation.',
],
buildDefence: [
'Production figures are released. The figures are unsigned.',
'His office has authorised additional capacity. He could not be reached for confirmation.',
],
reaction: [
'Death to America, and to whoever reorganises kitchen drawers.',
'The wolf\'s tail is now severed. We rest.',
'(orders filed for Khameneverhere. Reaction unsigned.)',
'(no audio is available. There isn\'t enough recording of him to train an AI deepfake.)',
],
death: [
'Authorities are unsure if Khameneverhere was already deceased at time of elimination.',
'(the cardboard cutout finally falls face-down.)',
],
finalRetaliation: [
'Pre-recorded orders activate. The grudge list is honoured.',
],
};
Loading
Loading