Skip to content
Merged
1,708 changes: 1,708 additions & 0 deletions docs/superpowers/plans/2026-05-17-phase-4c-slice2-ai-aggression.md

Large diffs are not rendered by default.

Large diffs are not rendered by default.

8 changes: 7 additions & 1 deletion src/engine/resolution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,11 +122,16 @@ export function resolveRound(state: GameState): ResolveResult {

// P4a: Disparage cameo. For each ImpactPeople/ImpactInfrastructure event,
// probabilistically inject a DisparageCameo event immediately after it.
// P4c.3: cap at one cameo per round — once one fires, stop rolling.
{
const expanded: ResolutionEvent[] = [];
let cameoEmitted = false;
for (const e of events) {
expanded.push(e);
if (e.kind === 'ImpactPeople' || e.kind === 'ImpactInfrastructure') {
if (
!cameoEmitted &&
(e.kind === 'ImpactPeople' || e.kind === 'ImpactInfrastructure')
) {
const roll = shouldRollCameo(s.rngState);
s.rngState = roll.rngState;
if (roll.fire) {
Expand All @@ -137,6 +142,7 @@ export function resolveRound(state: GameState): ResolveResult {
afterImpact: { from: e.from, to: e.target },
quote: linePick.line,
});
cameoEmitted = true;
}
}
}
Expand Down
22 changes: 16 additions & 6 deletions src/ui/components/EventCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@ 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 }: EventCardProps) {
export default function EventCard({ event, game, count = 1 }: EventCardProps) {
if (event.kind === 'DisparageCameo') return <DisparageCard event={event} />;
const result = formatEventCard(event, game);
const result = formatEventCard(event, game, count);
if (!result) return null;
const { icon, body, className, quote } = result;
return (
Expand All @@ -33,23 +35,31 @@ function name(game: GameState, id: keyof GameState['leaders']): string {
export function formatEventCard(
event: ResolutionEvent,
game: GameState,
count = 1,
): { icon: string; body: string; className?: string; quote?: string } | null {
switch (event.kind) {
case 'OrdersSealed':
return null; // not rendered
case 'FactoryBuilt':
return { icon: '⚙', body: `${flag(game, event.by)} ${name(game, event.by)} builds 1 factory`, quote: event.quote };
return {
icon: '⚙',
body: `${flag(game, event.by)} ${name(game, event.by)} builds ${count} ${count === 1 ? 'factory' : 'factories'}`,
quote: event.quote,
};
case 'DeliveryBuilt':
return {
icon: event.type === 'missile' ? '🚀' : '🛩',
body: `${flag(game, event.by)} ${name(game, event.by)} builds 1 ${event.type}`,
body: `${flag(game, event.by)} ${name(game, event.by)} builds ${count} ${event.type}${count === 1 ? '' : 's'}`,
};
case 'WarheadBuilt':
return { icon: '☢', body: `${flag(game, event.by)} ${name(game, event.by)} builds 1 ${event.yield} warhead` };
return {
icon: '☢',
body: `${flag(game, event.by)} ${name(game, event.by)} builds ${count} ${event.yield} warhead${count === 1 ? '' : 's'}`,
};
case 'DefenceBuilt':
return {
icon: '🛡',
body: `${flag(game, event.by)} ${name(game, event.by)} builds 1 ${event.type === 'shield' ? 'shield' : 'AA'}`,
body: `${flag(game, event.by)} ${name(game, event.by)} builds ${count} ${event.type === 'shield' ? (count === 1 ? 'shield' : 'shields') : 'AA'}`,
quote: event.quote,
};
case 'PropagandaTransfer':
Expand Down
5 changes: 4 additions & 1 deletion src/ui/screens/Action.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { ScreenProps } from '../App';
import type { ResolutionEvent } from '../../engine/types';
import EventCard from '../components/EventCard';
import PhaseTracker from '../components/PhaseTracker';
import { groupPhaseEvents } from '../util/eventGrouping';
import styles from './Action.module.css';

type Phase = 'DEFENCES' | 'BUILDS' | 'PROPAGANDA' | 'WOOING' | 'LAUNCHES' | 'FINAL_RETALIATIONS';
Expand Down Expand Up @@ -81,7 +82,9 @@ export default function Action({ state, dispatch }: ScreenProps) {
return (
<section key={phase} className={styles.phaseSection}>
<h2 className={styles.phaseHeader}>{PHASE_LABELS[phase]}</h2>
{events.map((e, i) => <EventCard key={i} event={e} game={game} />)}
{groupPhaseEvents(events).map((g, i) => (
<EventCard key={i} event={g.event} count={g.count} game={game} />
))}
</section>
);
})}
Expand Down
9 changes: 8 additions & 1 deletion src/ui/screens/Planning.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,14 @@ export default function Planning({ state, dispatch }: ScreenProps) {
target={game.leaders[id]}
mood={moodByLeader[id]}
targetType={targetTypes[id] ?? 'people'}
onTargetTypeChange={(next) => setTargetTypes((prev) => ({ ...prev, [id]: next }))}
onTargetTypeChange={(next) => {
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}
Expand Down
1 change: 1 addition & 0 deletions src/ui/screens/RoundSummary.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@
}
.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; }

Expand Down
23 changes: 17 additions & 6 deletions src/ui/screens/RoundSummary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,22 @@ function pickHeadline(
return `ROUND ${round - 1} SETTLES`;
}

function pickSubhead(events: ResolutionEvent[], leaders: GameState['leaders']): string {
const impacts = events.filter((e): e is Extract<ResolutionEvent, { kind: 'ImpactPeople' }> =>
e.kind === 'ImpactPeople',
);
if (impacts.length === 0) return 'No casualties this round.';
const biggest = impacts.reduce((a, b) => (a.deaths > b.deaths ? a : b));
export function pickSubhead(events: ResolutionEvent[], leaders: GameState['leaders']): string {
// Sum people-deaths per attacker→target pair, then name the biggest pairing.
// A single round can land several strikes from one attacker on one target;
// the subhead must report the total, not the largest single hit.
const pairs = new Map<string, { from: LeaderId; target: LeaderId; deaths: number }>();
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, { from: e.from, target: e.target, deaths: e.deaths });
}
const all = [...pairs.values()];
if (all.length === 0) return 'No casualties this round.';
let biggest = all[0];
for (const p of all) if (p.deaths > biggest.deaths) biggest = p;
return `${leaders[biggest.from].name} hits ${leaders[biggest.target].name} for ${biggest.deaths}M.`;
}

Expand Down Expand Up @@ -91,6 +101,7 @@ export default function RoundSummary({ state, dispatch }: ScreenProps) {
<div key={id} className={styles.reactionRow}>
<span className={styles.reactionFlag}>{leader.country.split(' ')[0]}</span>
<span className={styles.reactionName}>{leader.name}</span>
<span className={styles.reactionStats}>👥 {leader.population}M · 🏭 {leader.factories}</span>
<span className={styles.reactionDelta}>{delta >= 0 ? `+${delta}` : delta}M</span>
{quote && <span className={styles.reactionQuote}>"{quote}"</span>}
</div>
Expand Down
88 changes: 88 additions & 0 deletions src/ui/util/eventGrouping.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import type { ResolutionEvent } from '../../engine/types';

/**
* One render unit for the Action screen: an event plus how many identical
* build events it stands for. `count` is 1 for everything except a collapsed
* run of build events.
*/
export interface GroupedEvent {
event: ResolutionEvent;
count: number;
}

/** Grouping key for a build event, or null if `e` is not a build event. */
function buildKey(e: ResolutionEvent): string | null {
switch (e.kind) {
case 'FactoryBuilt': return `factory|${e.by}`;
case 'DeliveryBuilt': return `delivery|${e.by}|${e.type}`;
case 'WarheadBuilt': return `warhead|${e.by}|${e.yield}`;
case 'DefenceBuilt': return `defence|${e.by}|${e.type}`;
default: return null;
}
}

/**
* Collapse a phase's events for display:
* - build events (factory / delivery / warhead / defence) are counted per
* (leader, item) and folded into one GroupedEvent positioned at the first
* such build — so a leader's builds group whether or not they are adjacent
* (the human's build clicks arrive interleaved; the AI's do not);
* - ImpactPeople / ImpactInfrastructure events are summed per
* (target, attacker) pair into a single event positioned at the first hit.
* Every other event passes through unchanged with `count` 1.
*
* Pure: the input array and its events are never mutated.
*/
export function groupPhaseEvents(events: ResolutionEvent[]): GroupedEvent[] {
// Pre-aggregate: sum impacts per (target, attacker); count builds per item.
const peopleSum = new Map<string, number>();
const infraSum = new Map<string, number>();
const buildCount = new Map<string, number>();
for (const e of events) {
if (e.kind === 'ImpactPeople') {
const k = `${e.target}|${e.from}`;
peopleSum.set(k, (peopleSum.get(k) ?? 0) + e.deaths);
} else if (e.kind === 'ImpactInfrastructure') {
const k = `${e.target}|${e.from}`;
infraSum.set(k, (infraSum.get(k) ?? 0) + e.factoriesDestroyed);
} else {
const bk = buildKey(e);
if (bk !== null) buildCount.set(bk, (buildCount.get(bk) ?? 0) + 1);
}
}

const seenPeople = new Set<string>();
const seenInfra = new Set<string>();
const seenBuild = new Set<string>();
const out: GroupedEvent[] = [];

for (const e of events) {
if (e.kind === 'ImpactPeople') {
const k = `${e.target}|${e.from}`;
if (seenPeople.has(k)) continue;
seenPeople.add(k);
out.push({ event: { ...e, deaths: peopleSum.get(k) ?? e.deaths }, count: 1 });
continue;
}
if (e.kind === 'ImpactInfrastructure') {
const k = `${e.target}|${e.from}`;
if (seenInfra.has(k)) continue;
seenInfra.add(k);
out.push({
event: { ...e, factoriesDestroyed: infraSum.get(k) ?? e.factoriesDestroyed },
count: 1,
});
continue;
}
const bk = buildKey(e);
if (bk !== null) {
if (seenBuild.has(bk)) continue;
seenBuild.add(bk);
out.push({ event: e, count: buildCount.get(bk) ?? 1 });
continue;
}
out.push({ event: e, count: 1 });
}

return out;
}
35 changes: 35 additions & 0 deletions tests/engine/resolution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,41 @@ describe('resolveRound — P4a flavor events', () => {
expect(fired).toBe(true);
});

it('emits at most one DisparageCameo per round (P4c.3 cap)', () => {
// 4 missiles + shields=0 + aa=0 → 4 guaranteed impacts → up to 4 cameo
// rolls per round. Across 40 seeds the pre-cap code emits 2+ on some seed;
// the cap must hold every round regardless.
const launchOrder = {
kind: 'launch' as const,
target: 'carnage' as const,
delivery: 'missile' as const,
warhead: 'small' as const,
targetType: 'people' as const,
};
for (let n = 0; n < 40; n++) {
let s = initialState({
cast: ['chump', 'carnage'],
difficulty: 'normal',
seed: `cameo-cap-${n}`,
});
s.leaders.chump.stockpile.missiles = 4;
s.leaders.chump.stockpile.warheadsSmall = 4;
s.leaders.chump.ap = 20;
s.leaders.carnage.stockpile.shields = 0;
s.leaders.carnage.stockpile.aa = 0;
s.leaders.carnage.population = 1000;
s = reduce(s, {
type: 'SUBMIT_ORDERS',
leaderId: 'chump',
orders: [launchOrder, launchOrder, launchOrder, launchOrder],
});
s = reduce(s, { type: 'SUBMIT_ORDERS', leaderId: 'carnage', orders: [] });
const r = resolveRound(s);
const cameos = r.events.filter((e) => e.kind === 'DisparageCameo');
expect(cameos.length).toBeLessThanOrEqual(1);
}
});

it('emits DisparageColumn for at least some seeds; sets lastColumnNamedLeader', () => {
let fired = false;
for (const seedStr of ['seed-a', 'seed-b', 'seed-c', 'seed-d', 'seed-e', 'seed-f']) {
Expand Down
36 changes: 35 additions & 1 deletion tests/ui/Planning.targetRow.test.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { describe, expect, it, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
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';
import type { Order } from '../../src/engine/types';

function makeStateWithMood(): UiState {
const game = initialState({
Expand Down Expand Up @@ -38,4 +39,37 @@ describe('<TargetRow>', () => {
// After click, the button should have the diploOn class
expect(wooBtn.className).toMatch(/diploOn|on/i);
});

it('retargets queued launches when the target type is toggled to infra', () => {
const game = initialState({ cast: ['player1', 'chump'], difficulty: 'normal', seed: 'tt-toggle' });
game.leaders.player1.ap = 10;
game.leaders.player1.stockpile.missiles = 2;
game.leaders.player1.stockpile.warheadsSmall = 2;
const state: UiState = {
screen: 'planning',
game,
events: [],
prevPopulations: {},
initialPopulations: {},
lastNewGameOpts: null,
activeHumanTurn: 'player1',
pendingHumanOrders: {},
};
const dispatch = vi.fn();
render(<Planning state={state} dispatch={dispatch} />);

const row = screen.getByLabelText(/Target row for/i);
// Queue a launch — defaults to people targeting.
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 }));

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);
expect(launches[0].kind === 'launch' && launches[0].targetType).toBe('infra');
});
});
Loading
Loading