From 21dd2719ace5cc778f432dba7e415063f152e1cb Mon Sep 17 00:00:00 2001 From: gethi Date: Sat, 16 May 2026 18:45:55 +0100 Subject: [PATCH 1/7] =?UTF-8?q?docs:=20P4c=20slice=202=20design=20spec=20?= =?UTF-8?q?=E2=80=94=20AI=20aggression=20rework=20+=20elimination-only=20e?= =?UTF-8?q?ndings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...16-phase-4c-slice2-ai-aggression-design.md | 331 ++++++++++++++++++ 1 file changed, 331 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-16-phase-4c-slice2-ai-aggression-design.md diff --git a/docs/superpowers/specs/2026-05-16-phase-4c-slice2-ai-aggression-design.md b/docs/superpowers/specs/2026-05-16-phase-4c-slice2-ai-aggression-design.md new file mode 100644 index 0000000..6670bca --- /dev/null +++ b/docs/superpowers/specs/2026-05-16-phase-4c-slice2-ai-aggression-design.md @@ -0,0 +1,331 @@ +# Phase 4c slice 2 — AI aggression rework + elimination-only endings design spec + +**Date:** 2026-05-16 +**Status:** drafted from playtesting brainstorming session; pending user review +**Source of feedback:** playtesting after P4c slice 1 merge (PR #8, commit `d8382f7`). User notes captured in conversation: Netanyahoo (warmonger) fired zero shots all game; Carnage barely fired; Starmless never finishes off weakened opponents; the game ended with a living loser (player at 3M population vs Starmless at 23M). "Not much of a nuclear war if nobody is actually firing nukes. People need to be more aggressive." + +--- + +## 1. Overview + +Playtesting revealed the AI cast does not fight. Investigation of all six planners, the order validator, and the win conditions showed this is **structural, not a tuning miss**: + +- **Netanyahoo never fires (hard bug).** His build loop is `while (remaining >= 1) build-missile`. `build-missile` is uncapped in `validateOrder` (always `ok`), so the loop consumes his entire build budget every round. The warhead-build loop below it never executes. He stockpiles missiles forever but never builds a warhead, so `canLaunch` (which requires `warheadsSmall >= 1`) is never satisfied. The warmonger cannot arm a single missile. +- **Mileigh-hem never fires (hard bug).** `planMileighHem` has no build logic at all — both its modes only spend existing stockpile. Every leader starts with an empty stockpile, so Mileigh-hem never acquires a weapon. +- **Starmless has no kill instinct.** `launchTarget` is only assigned inside `if (isRetaliationRound)`. If never attacked, Starmless never fires — even at an opponent he could trivially finish. +- **The firing AIs are throttled.** Carnage, Khameneverhere, and Chump each reserve exactly one launch per round and only ever build/launch `small` warheads (2 deaths). Eliminating an 18–33M leader needs 9–16 hits; at one hit per round, nobody dies before the game ends. +- **Games end with a living loser.** `checkOutcome`'s dominance rule ends the game when the leading population is `>= dominanceThreshold * runner-up` (default `2`). Starmless 23M vs player 3M → `23 >= 2*3` → instant dominance win while the loser is still alive. + +This slice repairs the two broken planners, gives Starmless a kill instinct, raises the whole cast's firing rate (volume **and** yield), and removes the dominance rule so games end by elimination. + +Phase order: **P3 ✓ → P4a ✓ → P4b ✓ → P4c.1 ✓ → P4c.2 (this slice) → P4c.3+ → P5**. + +### 1.1 Decisions locked during brainstorming + +| Decision | Choice | +|---|---| +| Win condition | **Elimination only.** Remove the dominance rule. Games end on survivor / apocalypse / pyrrhic. | +| Aggression model | **Volume + yield.** AIs fire multiple launches per round and build/launch medium and large warheads. | +| Personalities | **Keep the spectrum.** Distinct aggression levels preserved; Starmless and Chump stay defensive but gain a kill instinct. | +| Duel test | **Termination assertion only.** Assert every seeded game reaches an outcome within a round cap. No win-share assertion. | +| Architecture | **Approach B** — two shared helpers (`buildToward`, `launchSalvo`) + per-planner tuning. | + +--- + +## 2. Scope + +### 2.1 Win condition — elimination only + +`checkOutcome` (`src/engine/winConditions.ts`) keeps three endings and drops one: + +- **Survivor** — exactly one leader alive. Kept. +- **Apocalypse** — nobody alive, nobody had population at round start. Kept. +- **Pyrrhic** — nobody alive, but someone had population at round start. Kept. +- **Dominance** — leading population `>= threshold * runner-up`. **Removed.** + +Dead-code cleanup that follows from the removal: + +- `DOMINANCE_THRESHOLD_DEFAULT` constant — removed from `src/engine/balance.ts`. +- `dominanceThreshold` field — removed from `GameConfig` in `src/engine/types.ts`. +- The `dominanceThreshold` wiring in `initialState` (`src/engine/state.ts`) — removed. + +The implementation plan must grep the UI layer (`src/ui/**`) for `dominanceThreshold` / `dominance` before deleting and confirm no consumer reads it. If a UI consumer exists, the plan adjusts it; the spec assumption is that none does (dominance outcomes render through the generic `WinOutcome` discriminated union, which keeps its `dominance` variant only if still referenced — see §4). + +There is **no in-game round cap.** Elimination-only means a game runs until `checkOutcome` returns non-null. The duel test's round cap (§5.3) is a test tripwire, not a game rule. + +### 2.2 Shared helpers — `src/engine/ai/aggression.ts` (new file) + +Two pure functions, no React, deterministic (no RNG unless explicitly threaded). They are the Approach-B core: the mechanical build/launch work lifted out of the six planners so it is written and tested once. + +#### 2.2.1 `buildToward` + +```ts +export type BuildItem = + | { item: 'factory' } + | { item: 'missile' } + | { item: 'bomber' } + | { item: 'warhead'; yield: Yield } + | { item: 'defence'; type: 'shield' | 'aa' }; + +export interface BuildPlanEntry { + build: BuildItem; + /** Build up to this many TOTAL (current stockpile + queued). */ + target: number; +} + +export interface BuildResult { + orders: Order[]; + apSpent: number; +} + +export function buildToward( + state: GameState, + leaderId: LeaderId, + plan: BuildPlanEntry[], + budget: number, +): BuildResult; +``` + +Behaviour: walk `plan` in order. For each entry, while the leader's current count of that item (counting current stockpile **plus** orders already queued by this call) is below `target` AND `budget` covers the item's AP cost AND `validateOrder` passes, emit the build order and decrement budget. Move to the next entry when the target is met or the budget cannot afford the item. Return the emitted orders and total AP spent. + +The `target` cap is what **structurally prevents Netanyahoo's runaway loop** — an unbounded `while` is no longer expressible through this helper. + +Count semantics per `BuildItem`: +- `factory` → `leader.factories` +- `missile` → `stockpile.missiles` +- `bomber` → `stockpile.bombers` +- `warhead` of yield Y → `stockpile.warheads{Small,Medium,Large}` for Y +- `defence` shield/aa → `stockpile.shields` / `stockpile.aa` + +`buildToward` only emits **build** orders (`build-factory`, `build-missile`, `build-bomber`, `build-warhead`, `build-defence`). It never emits `deploy-defence` — deployment stays in the planners that want it (Starmless, Chump), since it consumes stockpile rather than adding to it. + +#### 2.2.2 `launchSalvo` + +```ts +export interface LaunchSalvoOpts { + /** AP available for launches this round. */ + budget: number; + /** Targets ranked best-first. Must be alive, non-self. */ + rankedTargets: LeaderId[]; + /** Hard cap on launches emitted. Omit for no cap (fire until out of AP/ammo). */ + maxLaunches?: number; + /** false (default) = focus-fire rankedTargets[0]; true = cycle through rankedTargets. */ + spread?: boolean; + /** Per-target targetType selector. Default: () => 'people'. */ + targetTypeFor?: (target: LeaderId) => 'people' | 'infra'; +} + +export interface SalvoResult { + orders: Order[]; + apSpent: number; +} + +export function launchSalvo( + state: GameState, + leaderId: LeaderId, + opts: LaunchSalvoOpts, +): SalvoResult; +``` + +Behaviour: pair available delivery vehicles with available warheads, **largest yield first** (`large → medium → small`), and emit `launch` orders until any of: `budget` cannot cover `LAUNCH_COST` (2 AP), no delivery+warhead pair remains, `maxLaunches` reached, or `rankedTargets` is empty. + +- **Delivery preference:** bomber if `stockpile.bombers >= 1`, else missile. (Bombers are reusable post-P4c.1, so they are the better asset.) +- **Targeting:** `spread === false` (default) sends every launch at `rankedTargets[0]` — concentrate damage to push one leader to 0. `spread === true` cycles `rankedTargets[i % rankedTargets.length]`. +- **Validation:** each launch is validated against a **projected** stockpile (mirroring `validateOrderSequence`'s projection — delivery and warhead decremented per emitted launch) so the salvo never emits more launches than the leader can actually arm. +- `targetTypeFor` defaults to `'people'`. + +`launchSalvo` does not pick targets — the planner supplies `rankedTargets` from its personality scoring. This keeps target selection per-personality. + +### 2.3 Per-planner rework + +Every planner adopts the same shape: **rank targets (personality scoring) → split AP between build and launch → `buildToward` for builds → `launchSalvo` for launches.** Personality is expressed through three per-planner choices, kept as local constants/logic in each planner file (not a global table — Approach B, not C): + +1. **Target ranking** — which scoring function orders `rankedTargets`. +2. **Build plan** — the `BuildPlanEntry[]` passed to `buildToward`, including the yield ramp. +3. **AP split + launch cap** — how much AP is reserved for launching vs building, and `maxLaunches`. + +Aggression spectrum, hardest-hitting first: + +#### Netanyahoo — Warmonger (hardest) + +- **Bug fixed.** Missile builds are capped through `buildToward`; the runaway loop is gone. +- **Build plan:** ramp into yield — missiles + small warheads to a baseline, then medium, then large. Example plan: `[{missile, target: 6}, {warhead small, target: 4}, {warhead medium, target: 3}, {warhead large, target: 2}]`. Exact targets tuned during implementation. +- **AP split:** thin build reserve; nearly all AP to `launchSalvo` with **no `maxLaunches` cap** (fire everything armed). +- **Targeting:** focus-fire highest `threatScore`. **Chump-exception preserved** — no launch at Chump until `wasAttackedBy(state, 'netanyahoo', 'chump')`. +- **Propaganda:** exclusively at Chump, unchanged. + +#### Khameneverhere — Grudge (very aggressive) + +- Existing capped 3+3 build pattern becomes a `buildToward` plan with raised targets and medium warheads added. +- `launchSalvo` focus-fires the **top grudge target** (`topGrudgeTarget`, fallback: first living non-self leader). High `maxLaunches`. + +#### Mileigh-hem — Glass cannon (aggressive, swingy) + +- **Bug fixed.** Gains build logic via `buildToward`. +- Keeps the two-mode identity gated on `mileighActivationApThreshold`: + - **Activated:** `buildToward` a cheap fast stockpile (small/medium warheads, minimal/no defence) + `launchSalvo` with `spread: true` (all-out, cycles targets). + - **Not activated:** diplomatic mode (woo + propaganda) unchanged. + +#### Carnage — Rational + Opportunist (aggressive-rational) + +- Keeps the P4c.1 bomber bias (build a bomber when none owned; bomber-preferred delivery — `launchSalvo` already prefers bombers). +- `buildToward` bombers + a warhead mix; `launchSalvo` focus-fires the top `threat + opportunism` combined score. Moderate `maxLaunches`. +- Escalation multiplier and attacker-propaganda logic unchanged. + +#### Starmless — Cautious + Scapegoat (defensive, new kill instinct) + +- **New opportunism path.** A launch target is now chosen when **either**: it is a retaliation round (existing), **or** a finishable opponent exists — an opponent whose population is below a finish threshold (`STARMLESS_FINISH_POP_M`, tuned during implementation; ~8M is the working value). When both apply, retaliation takes precedence for target choice; the scapegoat roll still applies on retaliation rounds. +- Otherwise: defensive building (factories, then defence) as today. +- **Low `maxLaunches`** (1–2). Starmless still builds and defends — he does not barrage. +- Scapegoat roll (`starmlessScapegoatPct`) preserved. + +#### Chump — Coward (defensive, opportunistic) + +- Existing opportunism-launch path (`opportunismScore > 0 || defenceVisibilityScore === 0`) is routed through `launchSalvo` with a **low `maxLaunches`**. +- Heavy build-defence + propaganda, and the wooing-suppression rule (never launch at a leader with `favourability[t] > 0`), preserved. +- Infra-vs-people targeting preserved via `targetTypeFor`. + +Approximate launches/round when armed: **Netanyahoo / Mileigh-hem(all-out) > Khameneverhere > Carnage > Starmless / Chump.** + +--- + +## 3. Round flow + +**Unchanged.** `resolveRound`'s phase order (defences → builds → propaganda → wooing → launches → elimination → final retaliation) is untouched. AIs simply emit more `launch` orders and a wider yield mix. The launch phase already handles arbitrary launch counts. + +--- + +## 4. Engine schema changes + +- **Removed:** `GameConfig.dominanceThreshold` (`src/engine/types.ts`). +- **`WinOutcome`:** the `dominance` variant of the `WinOutcome` discriminated union is **removed** if and only if no consumer (UI or tests) still references it after the dominance rule is gone. The plan verifies this. If a UI surface displays outcome text by variant, removing the variant is a typed change the compiler will enforce — acceptable. +- No new event kinds. No `Leader` or `Stockpile` field changes. No `Order` shape changes. +- `src/engine/ai/aggression.ts` is a new module; its exported types (`BuildItem`, `BuildPlanEntry`, `BuildResult`, `LaunchSalvoOpts`, `SalvoResult`) are AI-layer types, not engine-state types. + +--- + +## 5. Testing + +P4c.1 baseline: 255 tests. This slice adds helper tests, per-planner behavioural tests, and the duel termination assertion; it removes the dominance win-condition tests. Net count is set by the implementation plan. Engine TDD strict. + +### 5.1 Helper tests — `tests/engine/ai/aggression.test.ts` (new) + +`buildToward`: +- Builds each plan entry up to its `target` and no further (cap respected). +- Walks entries in priority order; stops at budget exhaustion mid-plan. +- Counts already-queued orders toward the target (no over-build within one call). +- Emits nothing when budget is below the cheapest item's cost. + +`launchSalvo`: +- Pairs largest-yield-first (large before medium before small). +- Honours `maxLaunches`; omitting it fires until AP/ammo exhausted. +- `spread: false` focus-fires `rankedTargets[0]`; `spread: true` cycles targets. +- Prefers bomber delivery when a bomber is in stockpile; falls back to missile. +- Never emits more launches than the projected stockpile can arm. +- Empty `rankedTargets` → no orders. + +### 5.2 Per-planner tests — `tests/engine/ai/*.test.ts` (6 suites updated) + +Reworked assertions plus new behavioural tests for the headline fixes: +- **Netanyahoo** actually emits `launch` orders once armed (the bug-fix regression). Still builds missiles, never bombers (P4c.1 regression kept). +- **Mileigh-hem** emits `build-*` orders, then `launch` orders, in activated mode. +- **Starmless** emits a `launch` at a finishable (low-population) opponent with no prior attack on him. +- **Carnage / Khameneverhere / Chump** emit multi-launch salvos when AP and stockpile allow; caps respected. + +### 5.3 Duel test — `tests/engine/ai-duel.test.ts` + +Replace the "no crash" assertion with a **termination assertion**: run N seeded six-AI games; assert each reaches `outcome !== null` within a round cap (~60 rounds — generous headroom). No win-share assertion (per brainstorming decision — win-share is subjective and would churn with every tweak). If the assertion fires, that is a balance bug to fix within this slice, not a cap to add to the game. + +### 5.4 Win-condition tests — `tests/engine/winConditions.test.ts` + +Remove the dominance cases. Keep survivor / apocalypse / pyrrhic cases. + +### 5.5 What is NOT tested + +- Exact AI-duel win distribution (deferred — subjective, no assertion per decision). +- UI behaviour — no UI changes in this slice. +- Approach B/C lookahead — out of scope (§7). + +--- + +## 6. Assumptions (3 buckets) + +### 6.1 Real concerns + +1. **Game termination under elimination-only.** Removing dominance plus raising aggression should make mutual destruction fast and certain — but a pathological all-defence stall is theoretically possible. Mitigation: the duel termination assertion (§5.3) is the tripwire; the defence AP economy (`build-defence` 4 + `deploy-defence` 4 = 8 AP per intercept-capable unit) makes out-defending sustained large-warhead salvos economically impossible, so a true stall is not expected. If the assertion fires, balance tuning is part of this slice. +2. **`WinOutcome.dominance` variant removal.** If a UI surface switches on the outcome variant, removing `dominance` is a compiler-enforced change. The plan verifies the UI before deciding to remove the variant vs leave it unreferenced. + +### 6.2 Verified safe + +1. **Engine purity preserved** — `src/engine/ai/aggression.ts` imports no React. +2. **Determinism preserved** — `buildToward` and `launchSalvo` are deterministic; no new RNG consumption. Starmless's existing scapegoat roll is unchanged. +3. **P4c.1 compatibility** — bomber-reuse rule and Carnage bomber bias are untouched and composed with (not replaced by) the new helpers. +4. **No `Order` / `Leader` / `ResolutionEvent` shape changes** — UI consumers see no difference beyond more launch events per round. +5. **TDD posture continues** — engine TDD strict; helper tests drive the helper implementation. + +### 6.3 Minor / accepted + +1. **No in-game round cap.** Elimination-only means games can, in principle, run long. Accepted — aggression makes long games unlikely, and a cap would contradict the brainstorming decision. +2. **Win-share balance is judged by playtesting**, not assertions. Accepted per decision. +3. **Per-planner build-plan tuning values** (stockpile targets, finish threshold, launch caps) are first-pass in the plan and refined by playtest — same posture as prior phases' "balance-pass" constants. + +--- + +## 7. Out of scope + +### 7.1 Deferred to P4c slice 3+ + +- Approach B / C lookahead upgrades (sliding-window history, personality-fit modelling). +- Threat-aware defence deployment per personality. +- AI-duel win-share / balance assertions (only termination is asserted this slice). + +### 7.2 Deferred to P5 (polish) + +- Persistence, replay scrubber UI, animations, audio, SVG art, PWA — unchanged from prior specs. + +--- + +## 8. File list + +### 8.1 Modified + +``` +src/engine/winConditions.ts — remove dominance branch +src/engine/balance.ts — remove DOMINANCE_THRESHOLD_DEFAULT +src/engine/types.ts — remove GameConfig.dominanceThreshold (+ WinOutcome.dominance if unreferenced) +src/engine/state.ts — remove dominanceThreshold wiring in initialState +src/engine/ai/netanyahoo.ts — buildToward + launchSalvo; missile-loop bug fixed +src/engine/ai/khameneverhere.ts — buildToward + launchSalvo; raised stockpile targets +src/engine/ai/mileighhem.ts — buildToward (new build logic); launchSalvo spread mode +src/engine/ai/carnage.ts — buildToward + launchSalvo; P4c.1 bomber bias retained +src/engine/ai/starmless.ts — buildToward + launchSalvo; new opportunism finish path +src/engine/ai/chump.ts — buildToward + launchSalvo (low cap); defence retained +tests/engine/ai/netanyahoo.test.ts — reworked + bug-fix regression +tests/engine/ai/khameneverhere.test.ts — reworked +tests/engine/ai/mileighhem.test.ts — reworked + build-then-fire test +tests/engine/ai/carnage.test.ts — reworked +tests/engine/ai/starmless.test.ts — reworked + finish-path test +tests/engine/ai/chump.test.ts — reworked +tests/engine/ai-duel.test.ts — termination assertion +tests/engine/winConditions.test.ts — remove dominance cases +README.md — Phase 4c slice 2 status section +``` + +### 8.2 New files + +``` +src/engine/ai/aggression.ts — buildToward + launchSalvo helpers +tests/engine/ai/aggression.test.ts — helper tests +``` + +### 8.3 Deleted files + +None. + +--- + +## 9. References + +- P4c.1 spec: `docs/superpowers/specs/2026-05-14-phase-4c-slice1-bomber-reuse-design.md` +- P4c.1 plan: `docs/superpowers/plans/2026-05-14-phase-4c-slice1-bomber-reuse.md` +- Engine entry points: `src/engine/winConditions.ts` (`checkOutcome`), `src/engine/ai/*.ts` (six planners), `src/engine/ai/scoring.ts` (`threatScore`, `opportunismScore`, `wasAttackedBy`, `topGrudgeTarget`), `src/engine/orders.ts` (`validateOrder`, `apCostOf`). From 063b8c17cc258410f4aa2207c97ee2162956f2ea Mon Sep 17 00:00:00 2001 From: gethi Date: Sun, 17 May 2026 11:30:37 +0100 Subject: [PATCH 2/7] =?UTF-8?q?docs:=20P4c=20slice=202=20implementation=20?= =?UTF-8?q?plan=20=E2=80=94=20AI=20aggression=20rework?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...026-05-17-phase-4c-slice2-ai-aggression.md | 1627 +++++++++++++++++ 1 file changed, 1627 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-17-phase-4c-slice2-ai-aggression.md diff --git a/docs/superpowers/plans/2026-05-17-phase-4c-slice2-ai-aggression.md b/docs/superpowers/plans/2026-05-17-phase-4c-slice2-ai-aggression.md new file mode 100644 index 0000000..2ab525f --- /dev/null +++ b/docs/superpowers/plans/2026-05-17-phase-4c-slice2-ai-aggression.md @@ -0,0 +1,1627 @@ +# Phase 4c slice 2 — AI aggression rework + elimination-only endings — 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:** Make the AI cast actually fight — repair two planners that structurally cannot fire, give every personality a multi-launch yield-ramping offence, give Starmless a kill instinct, and remove the dominance win condition so games end by elimination. + +**Architecture:** Approach B — two shared, tested helpers in a new `src/engine/ai/aggression.ts` (`buildToward` for capped builds, `launchSalvo` for largest-yield-first multi-launch) called by all six planners. Each planner keeps its own target ranking and build plan, so the aggression spectrum survives. The dominance win condition and its `dominanceThreshold` config are deleted; games end on survivor / apocalypse / pyrrhic only. + +**Tech Stack:** TypeScript 5.4 strict, Vitest 1.5, React 18 (UI only — one file). Engine purity rule: no React under `src/engine/**`. Deterministic seeded RNG threaded through `state.rngState`. + +**Spec:** `docs/superpowers/specs/2026-05-16-phase-4c-slice2-ai-aggression-design.md` + +--- + +## Background the implementer must know + +- **Why Netanyahoo never fires:** his build loop is `while (remaining >= 1) build-missile`. `build-missile` is uncapped in `validateOrder` (always `ok`), so the loop eats his entire build budget every round. The warhead loop below it never runs → `warheadsSmall` stays 0 → his `canLaunch` (needs `warheadsSmall >= 1`) is never true. +- **Why Mileigh-hem never fires:** `planMileighHem` has no build orders at all; both its modes only spend existing stockpile, which starts empty. +- **Validation ordering rule:** the reducer and `validateOrderSequence` validate an order array *in sequence*, projecting stockpile mutations. A `launch` order placed before the `build-missile`/`build-warhead` that arms it will fail validation. **Builds must always precede launches in the submitted array.** +- **Round-start ammo:** a planner's launches this round consume ammo the leader *already owned at round start*. Builds this round resolve in the build phase and add ammo for *next* round. Both happen in the same submitted array; ordering builds-before-launches keeps `validateOrderSequence`'s projection valid. +- **The launch-first-budget pattern (used by every aggressive planner):** compute `offenceBudget`, call `launchSalvo` first — it self-limits to ammo on hand and only spends 2 AP per armed launch — then call `buildToward` with `offenceBudget - salvo.apSpent`. A leader with no ammo spends nothing on the salvo and all of it on building; a well-armed leader fires hard and builds with the remainder. This is self-balancing. The emitted array is then ordered `[...builds, ...launches, ...diplomacy]`. +- **`launchSalvo` self-limits:** it tracks a *projected* stockpile internally (decrementing as it emits launches) so it never emits more launches than the leader can arm. `validateOrder` alone cannot catch this — it checks the real, unmutated stockpile. + +--- + +## File Structure + +**New files:** +- `src/engine/ai/aggression.ts` — `buildToward` + `launchSalvo` helpers and their exported types. Pure, no React, no RNG. +- `tests/engine/ai/aggression.test.ts` — helper unit tests. + +**Modified — engine:** +- `src/engine/winConditions.ts` — remove the dominance branch from `checkOutcome`. +- `src/engine/types.ts` — remove `dominance` from `WinType` and `WinOutcome`; remove `dominanceThreshold` from `GameConfig`. +- `src/engine/balance.ts` — remove `DOMINANCE_THRESHOLD_DEFAULT`. +- `src/engine/index.ts` — remove the `DOMINANCE_THRESHOLD_DEFAULT` re-export. +- `src/engine/state.ts` — remove the `dominanceThreshold` wiring in `initialState`. +- `src/engine/ai/netanyahoo.ts`, `khameneverhere.ts`, `mileighhem.ts`, `carnage.ts`, `starmless.ts`, `chump.ts` — rework onto the helpers. + +**Modified — UI:** +- `src/ui/screens/Winners.tsx` — remove the `dominance` cases from `pickHeadline` and `pickSubLine`. + +**Modified — tests:** +- `tests/engine/winConditions.test.ts` — remove dominance cases. +- `tests/engine/state.test.ts` — remove the `dominanceThreshold` default assertion. +- `tests/engine/balance.test.ts` — remove the `DOMINANCE_THRESHOLD_DEFAULT` import + assertion. +- `tests/engine/integration.test.ts` — remove the "reaches an outcome within 100 rounds" test (scripted games terminated *only* via dominance). +- `tests/engine/determinism.test.ts` — remove the now-invalid `dominanceThreshold` config from `runGame`. +- `tests/engine/ai/netanyahoo.test.ts`, `khameneverhere.test.ts`, `mileighhem.test.ts`, `carnage.test.ts`, `starmless.test.ts`, `chump.test.ts` — rework assertions. +- `tests/engine/ai-duel.test.ts` — replace "no crash" with a termination assertion. +- `README.md` — Phase 4c slice 2 status; correct "four win conditions" → three. + +--- + +## Task 1: Remove the dominance win condition (elimination-only endings) + +Self-contained, independent of the helpers. Removes the dominance rule and all its dead code across engine, UI, and tests. + +**Files:** +- Modify: `src/engine/winConditions.ts` +- Modify: `src/engine/types.ts` +- Modify: `src/engine/balance.ts` +- Modify: `src/engine/index.ts` +- Modify: `src/engine/state.ts` +- Modify: `src/ui/screens/Winners.tsx` +- Modify: `tests/engine/winConditions.test.ts` +- Modify: `tests/engine/state.test.ts` +- Modify: `tests/engine/balance.test.ts` +- Modify: `tests/engine/integration.test.ts` +- Modify: `tests/engine/determinism.test.ts` + +- [ ] **Step 1: Update `winConditions.test.ts` — remove dominance cases** + +Delete these three `it(...)` blocks entirely: `'returns dominance when one leader has 2× the next-highest population'`, `'does not return dominance when ratio is below threshold'`, and `'survivor takes priority over dominance'`. Rename the first remaining test from `'returns null while multiple leaders are alive and no dominance'` to `'returns null while multiple leaders are alive'`. The file keeps four tests: returns-null, survivor, pyrrhic, apocalypse. + +- [ ] **Step 2: Run the win-condition tests to verify they fail** + +Run: `npx vitest run tests/engine/winConditions.test.ts` +Expected: FAIL — the renamed/kept tests still pass, but the file will not yet compile cleanly once later steps land. At this point it should still PASS (no source change yet). This step just confirms the edited test file is green before touching source. Expected: PASS, 4 tests. + +- [ ] **Step 3: Remove the dominance branch from `checkOutcome`** + +In `src/engine/winConditions.ts`, delete the entire "3) Dominance" block. The function becomes: + +```ts +import type { GameState, LeaderId, WinOutcome } from './types'; + +export function checkOutcome( + state: GameState, + startOfRoundPop: Partial>, +): WinOutcome | null { + const alive = state.cast.filter((id) => state.leaders[id].population > 0); + + // 1) Survivor — exactly one leader alive. + if (alive.length === 1) { + return { type: 'survivor', winner: alive[0] }; + } + + // 2) Pyrrhic / apocalypse — nobody alive. + if (alive.length === 0) { + let bestId: LeaderId | undefined; + let bestPop = -1; + for (const id of state.cast) { + const p = startOfRoundPop[id] ?? 0; + if (p > bestPop) { + bestPop = p; + bestId = id; + } + } + if (bestPop > 0 && bestId) return { type: 'pyrrhic', winner: bestId }; + return { type: 'apocalypse' }; + } + + return null; +} +``` + +- [ ] **Step 4: Remove `dominance` from the type union and `dominanceThreshold` from config** + +In `src/engine/types.ts`: +- Line 24 — change `export type WinType = 'survivor' | 'pyrrhic' | 'apocalypse' | 'dominance';` to `export type WinType = 'survivor' | 'pyrrhic' | 'apocalypse';` +- In `GameConfig`, delete the line `dominanceThreshold: number;` +- In `WinOutcome`, change `| { type: 'survivor' | 'pyrrhic' | 'dominance'; winner: LeaderId };` to `| { type: 'survivor' | 'pyrrhic'; winner: LeaderId };` + +- [ ] **Step 5: Remove `DOMINANCE_THRESHOLD_DEFAULT` from balance and its re-export** + +In `src/engine/balance.ts`, delete the line `export const DOMINANCE_THRESHOLD_DEFAULT = 2;`. +In `src/engine/index.ts`, delete `DOMINANCE_THRESHOLD_DEFAULT,` from the balance re-export block. + +- [ ] **Step 6: Remove the `dominanceThreshold` wiring in `initialState`** + +In `src/engine/state.ts`: +- Change the import `import { DOMINANCE_THRESHOLD_DEFAULT, LEADER_PROFILES } from './balance';` to `import { LEADER_PROFILES } from './balance';` +- In the returned `config` object, delete the line `dominanceThreshold: DOMINANCE_THRESHOLD_DEFAULT,`. The `config` block becomes: + +```ts + config: { + fastPlay: false, + ...opts.config, + }, +``` + +- [ ] **Step 7: Remove the `dominance` cases from `Winners.tsx`** + +In `src/ui/screens/Winners.tsx`: +- In `pickHeadline`, delete the `case 'dominance':` line (it sits between `case 'pyrrhic':` and the `return` — just remove that one line; `survivor` and `pyrrhic` still fall through to the same `return`). +- In `pickSubLine`, delete the entire `case 'dominance': { ... }` block (lines forming that case). + +The two switches now cover `apocalypse`, `survivor`, `pyrrhic` — exhaustive against the narrowed `WinOutcome`, so the compiler is satisfied. + +- [ ] **Step 8: Fix `state.test.ts`, `balance.test.ts`, `integration.test.ts`, `determinism.test.ts`** + +`tests/engine/state.test.ts` — delete the test `it('defaults config dominanceThreshold to 2 and fastPlay to false', ...)`. If a `fastPlay` default still needs coverage, replace it with: + +```ts + it('defaults config fastPlay to false', () => { + const s = initialState({ cast: ['chump', 'carnage'], difficulty: 'normal', seed: 'cfg' }); + expect(s.config.fastPlay).toBe(false); + }); +``` + +`tests/engine/balance.test.ts` — remove `DOMINANCE_THRESHOLD_DEFAULT` from the import list (line 10) and delete the assertion `expect(DOMINANCE_THRESHOLD_DEFAULT).toBe(2);` (and its enclosing `it(...)` if that is the only assertion in it). + +`tests/engine/integration.test.ts` — delete the entire test `it('reaches an outcome within 100 rounds for sample seeds', ...)`. Scripted games never eliminate anyone; they terminated *only* via dominance, so this test's premise is gone. The other two integration tests stay. + +`tests/engine/determinism.test.ts` — in `runGame`, remove `config: { dominanceThreshold: 1.5 },` from the `initialState` call and delete the stale 3-line comment above it. `runGame` still caps at `maxRounds = 80`, so determinism comparisons remain valid even though scripted games no longer reach an outcome. + +- [ ] **Step 9: Run the full suite and typecheck** + +Run: `npm test -- --run` +Expected: PASS — all green. The dominance tests are gone; nothing references the removed symbols. + +Run: `npx tsc --noEmit` +Expected: clean, no output. (If `tsc` flags an unreferenced `dominance` anywhere, fix that reference — there should be none.) + +- [ ] **Step 10: Commit** + +```bash +git add src/engine/winConditions.ts src/engine/types.ts src/engine/balance.ts src/engine/index.ts src/engine/state.ts src/ui/screens/Winners.tsx tests/engine/winConditions.test.ts tests/engine/state.test.ts tests/engine/balance.test.ts tests/engine/integration.test.ts tests/engine/determinism.test.ts +git commit -m "engine: remove dominance win condition — games end by elimination only" +``` + +--- + +## Task 2: `buildToward` helper + +The capped build helper. A planner passes an ordered, capped build plan; `buildToward` emits build orders up to each cap, in priority order, until budget runs out. + +**Files:** +- Create: `src/engine/ai/aggression.ts` +- Create: `tests/engine/ai/aggression.test.ts` + +- [ ] **Step 1: Write the failing tests for `buildToward`** + +Create `tests/engine/ai/aggression.test.ts`: + +```ts +import { describe, it, expect } from 'vitest'; +import { buildToward, type BuildPlanEntry } from '../../../src/engine/ai/aggression'; +import { initialState } from '../../../src/engine/state'; + +describe('buildToward', () => { + it('builds each plan entry up to its target and no further', () => { + const s = initialState({ cast: ['netanyahoo', 'chump'], difficulty: 'normal', seed: 'b1' }); + s.leaders.netanyahoo.stockpile.missiles = 0; + const plan: BuildPlanEntry[] = [{ build: { item: 'missile' }, target: 3 }]; + const r = buildToward(s, 'netanyahoo', plan, 10); + expect(r.orders.filter((o) => o.kind === 'build-missile')).toHaveLength(3); + expect(r.apSpent).toBe(3); // build-missile costs 1 + }); + + it('counts existing stockpile toward the target', () => { + const s = initialState({ cast: ['netanyahoo', 'chump'], difficulty: 'normal', seed: 'b2' }); + s.leaders.netanyahoo.stockpile.missiles = 2; + const plan: BuildPlanEntry[] = [{ build: { item: 'missile' }, target: 3 }]; + const r = buildToward(s, 'netanyahoo', plan, 10); + expect(r.orders.filter((o) => o.kind === 'build-missile')).toHaveLength(1); + }); + + it('walks entries in priority order and stops at budget exhaustion', () => { + const s = initialState({ cast: ['netanyahoo', 'chump'], difficulty: 'normal', seed: 'b3' }); + const plan: BuildPlanEntry[] = [ + { build: { item: 'missile' }, target: 5 }, + { build: { item: 'warhead', yield: 'small' }, target: 5 }, + ]; + const r = buildToward(s, 'netanyahoo', plan, 3); // only 3 AP, missile costs 1 + expect(r.orders.filter((o) => o.kind === 'build-missile')).toHaveLength(3); + expect(r.orders.filter((o) => o.kind === 'build-warhead')).toHaveLength(0); + }); + + it('respects per-yield warhead targets independently', () => { + const s = initialState({ cast: ['netanyahoo', 'chump'], difficulty: 'normal', seed: 'b4' }); + const plan: BuildPlanEntry[] = [ + { build: { item: 'warhead', yield: 'small' }, target: 2 }, + { build: { item: 'warhead', yield: 'large' }, target: 1 }, + ]; + const r = buildToward(s, 'netanyahoo', plan, 20); + expect(r.orders.filter((o) => o.kind === 'build-warhead' && o.yield === 'small')).toHaveLength(2); + expect(r.orders.filter((o) => o.kind === 'build-warhead' && o.yield === 'large')).toHaveLength(1); + expect(r.apSpent).toBe(2 * 1 + 1 * 3); // small=1, large=3 + }); + + it('emits nothing when budget is below the cheapest item cost', () => { + const s = initialState({ cast: ['starmless', 'chump'], difficulty: 'normal', seed: 'b5' }); + const plan: BuildPlanEntry[] = [{ build: { item: 'factory' }, target: 5 }]; + const r = buildToward(s, 'starmless', plan, 2); // factory costs 3 + expect(r.orders).toHaveLength(0); + expect(r.apSpent).toBe(0); + }); + + it('emits nothing for an already-satisfied plan', () => { + const s = initialState({ cast: ['netanyahoo', 'chump'], difficulty: 'normal', seed: 'b6' }); + s.leaders.netanyahoo.stockpile.missiles = 9; + const plan: BuildPlanEntry[] = [{ build: { item: 'missile' }, target: 3 }]; + const r = buildToward(s, 'netanyahoo', plan, 10); + expect(r.orders).toHaveLength(0); + }); + + it('builds bombers and defences from the plan', () => { + const s = initialState({ cast: ['carnage', 'chump'], difficulty: 'normal', seed: 'b7' }); + const plan: BuildPlanEntry[] = [ + { build: { item: 'bomber' }, target: 1 }, + { build: { item: 'defence', type: 'shield' }, target: 1 }, + ]; + const r = buildToward(s, 'carnage', plan, 20); + expect(r.orders.filter((o) => o.kind === 'build-bomber')).toHaveLength(1); + expect(r.orders.filter((o) => o.kind === 'build-defence')).toHaveLength(1); + }); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `npx vitest run tests/engine/ai/aggression.test.ts` +Expected: FAIL — `Cannot find module '../../../src/engine/ai/aggression'`. + +- [ ] **Step 3: Implement `buildToward`** + +Create `src/engine/ai/aggression.ts`: + +```ts +import type { GameState, LeaderId, Order, Yield } from '../types'; +import { ACTION_COSTS } from '../balance'; +import { apCostOf, validateOrder } from '../orders'; +import { warheadFieldFor } from '../launches'; + +// --- buildToward --------------------------------------------------------- + +export type BuildItem = + | { item: 'factory' } + | { item: 'missile' } + | { item: 'bomber' } + | { item: 'warhead'; yield: Yield } + | { item: 'defence'; type: 'shield' | 'aa' }; + +export interface BuildPlanEntry { + build: BuildItem; + /** Build up to this many TOTAL (current stockpile + queued by this call). */ + target: number; +} + +export interface BuildResult { + orders: Order[]; + apSpent: number; +} + +function buildOrderFor(b: BuildItem): Order { + switch (b.item) { + case 'factory': return { kind: 'build-factory' }; + case 'missile': return { kind: 'build-missile' }; + case 'bomber': return { kind: 'build-bomber' }; + case 'warhead': return { kind: 'build-warhead', yield: b.yield }; + case 'defence': return { kind: 'build-defence', type: b.type }; + } +} + +function currentCount(state: GameState, leaderId: LeaderId, b: BuildItem): number { + const me = state.leaders[leaderId]; + switch (b.item) { + case 'factory': return me.factories; + case 'missile': return me.stockpile.missiles; + case 'bomber': return me.stockpile.bombers; + case 'warhead': return me.stockpile[warheadFieldFor(b.yield)]; + case 'defence': return b.type === 'shield' ? me.stockpile.shields : me.stockpile.aa; + } +} + +function matchesBuildItem(o: Order, b: BuildItem): boolean { + switch (b.item) { + case 'factory': return o.kind === 'build-factory'; + case 'missile': return o.kind === 'build-missile'; + case 'bomber': return o.kind === 'build-bomber'; + case 'warhead': return o.kind === 'build-warhead' && o.yield === b.yield; + case 'defence': return o.kind === 'build-defence' && o.type === b.type; + } +} + +/** + * Walk an ordered, capped build plan. For each entry, emit build orders until + * the leader's count of that item (current stockpile + orders queued by this + * call) reaches `target`, the budget cannot afford the item, or validation + * fails. The cap makes an unbounded build loop inexpressible. + */ +export function buildToward( + state: GameState, + leaderId: LeaderId, + plan: BuildPlanEntry[], + budget: number, +): BuildResult { + const orders: Order[] = []; + let remaining = budget; + for (const entry of plan) { + const order = buildOrderFor(entry.build); + const cost = apCostOf(order); + const baseCount = currentCount(state, leaderId, entry.build); + while ( + baseCount + orders.filter((o) => matchesBuildItem(o, entry.build)).length < entry.target && + remaining >= cost && + validateOrder(state, leaderId, order).ok + ) { + orders.push(order); + remaining -= cost; + } + } + return { orders, apSpent: budget - remaining }; +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `npx vitest run tests/engine/ai/aggression.test.ts` +Expected: PASS — 7 tests. + +- [ ] **Step 5: Commit** + +```bash +git add src/engine/ai/aggression.ts tests/engine/ai/aggression.test.ts +git commit -m "engine: add buildToward AI helper — capped, priority-ordered builds" +``` + +--- + +## Task 3: `launchSalvo` helper + +The volume+yield core. Pairs delivery vehicles with warheads largest-yield-first, emits launches until AP, ammo, or the cap runs out. + +**Files:** +- Modify: `src/engine/ai/aggression.ts` +- Modify: `tests/engine/ai/aggression.test.ts` + +- [ ] **Step 1: Write the failing tests for `launchSalvo`** + +Append to `tests/engine/ai/aggression.test.ts` (add `launchSalvo` to the import from `aggression`): + +```ts +import { launchSalvo } from '../../../src/engine/ai/aggression'; + +describe('launchSalvo', () => { + it('fires until AP and ammo run out when no cap is given', () => { + const s = initialState({ cast: ['netanyahoo', 'chump'], difficulty: 'normal', seed: 'l1' }); + s.leaders.netanyahoo.stockpile.missiles = 3; + s.leaders.netanyahoo.stockpile.warheadsSmall = 3; + const r = launchSalvo(s, 'netanyahoo', { budget: 100, rankedTargets: ['chump'] }); + expect(r.orders).toHaveLength(3); // 3 missile+warhead pairs + expect(r.apSpent).toBe(6); // launch costs 2 + }); + + it('honours maxLaunches', () => { + const s = initialState({ cast: ['netanyahoo', 'chump'], difficulty: 'normal', seed: 'l2' }); + s.leaders.netanyahoo.stockpile.missiles = 5; + s.leaders.netanyahoo.stockpile.warheadsSmall = 5; + const r = launchSalvo(s, 'netanyahoo', { budget: 100, rankedTargets: ['chump'], maxLaunches: 2 }); + expect(r.orders).toHaveLength(2); + }); + + it('stops when budget cannot cover another launch', () => { + const s = initialState({ cast: ['netanyahoo', 'chump'], difficulty: 'normal', seed: 'l3' }); + s.leaders.netanyahoo.stockpile.missiles = 5; + s.leaders.netanyahoo.stockpile.warheadsSmall = 5; + const r = launchSalvo(s, 'netanyahoo', { budget: 5, rankedTargets: ['chump'] }); + expect(r.orders).toHaveLength(2); // 5 AP / 2 per launch = 2 + }); + + it('pairs largest-yield warheads first', () => { + const s = initialState({ cast: ['netanyahoo', 'chump'], difficulty: 'normal', seed: 'l4' }); + s.leaders.netanyahoo.stockpile.missiles = 3; + s.leaders.netanyahoo.stockpile.warheadsSmall = 1; + s.leaders.netanyahoo.stockpile.warheadsMedium = 1; + s.leaders.netanyahoo.stockpile.warheadsLarge = 1; + const r = launchSalvo(s, 'netanyahoo', { budget: 100, rankedTargets: ['chump'] }); + const yields = r.orders.map((o) => (o.kind === 'launch' ? o.warhead : null)); + expect(yields).toEqual(['large', 'medium', 'small']); + }); + + it('prefers bomber delivery when a bomber is in stock', () => { + const s = initialState({ cast: ['carnage', 'chump'], difficulty: 'normal', seed: 'l5' }); + s.leaders.carnage.stockpile.bombers = 1; + s.leaders.carnage.stockpile.missiles = 1; + s.leaders.carnage.stockpile.warheadsSmall = 2; + const r = launchSalvo(s, 'carnage', { budget: 100, rankedTargets: ['chump'] }); + expect(r.orders).toHaveLength(2); + if (r.orders[0].kind === 'launch') expect(r.orders[0].delivery).toBe('bomber'); + if (r.orders[1].kind === 'launch') expect(r.orders[1].delivery).toBe('missile'); + }); + + it('focus-fires rankedTargets[0] by default', () => { + const s = initialState({ cast: ['netanyahoo', 'chump', 'carnage'], difficulty: 'normal', seed: 'l6' }); + s.leaders.netanyahoo.stockpile.missiles = 3; + s.leaders.netanyahoo.stockpile.warheadsSmall = 3; + const r = launchSalvo(s, 'netanyahoo', { budget: 100, rankedTargets: ['carnage', 'chump'] }); + expect(r.orders.every((o) => o.kind === 'launch' && o.target === 'carnage')).toBe(true); + }); + + it('cycles targets when spread is true', () => { + const s = initialState({ cast: ['mileigh-hem', 'chump', 'carnage'], difficulty: 'normal', seed: 'l7' }); + s.leaders['mileigh-hem'].stockpile.missiles = 4; + s.leaders['mileigh-hem'].stockpile.warheadsSmall = 4; + const r = launchSalvo(s, 'mileigh-hem', { budget: 100, rankedTargets: ['chump', 'carnage'], spread: true }); + const targets = r.orders.map((o) => (o.kind === 'launch' ? o.target : null)); + expect(targets).toEqual(['chump', 'carnage', 'chump', 'carnage']); + }); + + it('never emits more launches than the projected stockpile can arm', () => { + const s = initialState({ cast: ['netanyahoo', 'chump'], difficulty: 'normal', seed: 'l8' }); + s.leaders.netanyahoo.stockpile.missiles = 1; + s.leaders.netanyahoo.stockpile.warheadsSmall = 5; + const r = launchSalvo(s, 'netanyahoo', { budget: 100, rankedTargets: ['chump'] }); + expect(r.orders).toHaveLength(1); // only 1 delivery vehicle + }); + + it('returns nothing for empty rankedTargets', () => { + const s = initialState({ cast: ['netanyahoo', 'chump'], difficulty: 'normal', seed: 'l9' }); + s.leaders.netanyahoo.stockpile.missiles = 3; + s.leaders.netanyahoo.stockpile.warheadsSmall = 3; + const r = launchSalvo(s, 'netanyahoo', { budget: 100, rankedTargets: [] }); + expect(r.orders).toHaveLength(0); + expect(r.apSpent).toBe(0); + }); + + it('respects the targetTypeFor selector', () => { + const s = initialState({ cast: ['chump', 'carnage'], difficulty: 'normal', seed: 'l10' }); + s.leaders.chump.stockpile.missiles = 1; + s.leaders.chump.stockpile.warheadsSmall = 1; + const r = launchSalvo(s, 'chump', { + budget: 100, rankedTargets: ['carnage'], targetTypeFor: () => 'infra', + }); + expect(r.orders[0].kind === 'launch' && r.orders[0].targetType).toBe('infra'); + }); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `npx vitest run tests/engine/ai/aggression.test.ts` +Expected: FAIL — `launchSalvo` is not exported. + +- [ ] **Step 3: Implement `launchSalvo`** + +Append to `src/engine/ai/aggression.ts`: + +```ts +// --- launchSalvo --------------------------------------------------------- + +export interface LaunchSalvoOpts { + /** AP available for launches this round. */ + budget: number; + /** Targets ranked best-first. Each must be alive and non-self. */ + rankedTargets: LeaderId[]; + /** Hard cap on launches emitted. Omit to fire until AP/ammo run out. */ + maxLaunches?: number; + /** false (default) = focus-fire rankedTargets[0]; true = cycle targets. */ + spread?: boolean; + /** Per-target targetType selector. Default: () => 'people'. */ + targetTypeFor?: (target: LeaderId) => 'people' | 'infra'; +} + +export interface SalvoResult { + orders: Order[]; + apSpent: number; +} + +const YIELD_ORDER: Yield[] = ['large', 'medium', 'small']; + +/** + * Pair available delivery vehicles with warheads, largest-yield-first, and + * emit launch orders until budget, ammo, or maxLaunches runs out. A projected + * stockpile is tracked internally so the salvo never over-commits. + */ +export function launchSalvo( + state: GameState, + leaderId: LeaderId, + opts: LaunchSalvoOpts, +): SalvoResult { + const me = state.leaders[leaderId]; + const orders: Order[] = []; + if (!me || opts.rankedTargets.length === 0) return { orders, apSpent: 0 }; + + let remaining = opts.budget; + let bombers = me.stockpile.bombers; + let missiles = me.stockpile.missiles; + const warheads: Record = { + large: me.stockpile.warheadsLarge, + medium: me.stockpile.warheadsMedium, + small: me.stockpile.warheadsSmall, + }; + const targetTypeFor = opts.targetTypeFor ?? ((): 'people' => 'people'); + + let launched = 0; + while (true) { + if (opts.maxLaunches !== undefined && launched >= opts.maxLaunches) break; + if (remaining < ACTION_COSTS.launch) break; + if (bombers + missiles < 1) break; + const y = YIELD_ORDER.find((yy) => warheads[yy] > 0); + if (y === undefined) break; + + const delivery: 'bomber' | 'missile' = bombers >= 1 ? 'bomber' : 'missile'; + const target = opts.spread + ? opts.rankedTargets[launched % opts.rankedTargets.length] + : opts.rankedTargets[0]; + const launch: Order = { + kind: 'launch', + target, + delivery, + warhead: y, + targetType: targetTypeFor(target), + }; + if (!validateOrder(state, leaderId, launch).ok) break; + + orders.push(launch); + remaining -= ACTION_COSTS.launch; + warheads[y] -= 1; + if (delivery === 'bomber') bombers -= 1; + else missiles -= 1; + launched += 1; + } + return { orders, apSpent: opts.budget - remaining }; +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `npx vitest run tests/engine/ai/aggression.test.ts` +Expected: PASS — 17 tests (7 `buildToward` + 10 `launchSalvo`). + +- [ ] **Step 5: Run the full suite + typecheck** + +Run: `npm test -- --run` — Expected: all green. +Run: `npx tsc --noEmit` — Expected: clean. + +- [ ] **Step 6: Commit** + +```bash +git add src/engine/ai/aggression.ts tests/engine/ai/aggression.test.ts +git commit -m "engine: add launchSalvo AI helper — largest-yield-first multi-launch" +``` + +--- + +## Task 4: Netanyahoo — Warmonger rework + +Fixes the zero-fire bug. Capped `buildToward` plan with a yield ramp; uncapped `launchSalvo`. Chump-exception and Chump-propaganda preserved. + +**Files:** +- Modify: `src/engine/ai/netanyahoo.ts` +- Modify: `tests/engine/ai/netanyahoo.test.ts` + +- [ ] **Step 1: Update `netanyahoo.test.ts`** + +The existing four `'Netanyahoo (Warmonger)'` tests and the P4c.1 missile-bias regression all still hold under the rework (verified against the new code below). Keep them as-is. Append a new describe block: + +```ts +import { planNetanyahoo } from '../../../src/engine/ai/netanyahoo'; + +describe('Netanyahoo aggression (P4c.2)', () => { + it('actually fires when armed — the zero-fire bug is gone', () => { + const s = initialState({ cast: ['netanyahoo', 'carnage'], difficulty: 'normal', seed: 'na1' }); + s.leaders.netanyahoo.stockpile.missiles = 3; + s.leaders.netanyahoo.stockpile.warheadsSmall = 3; + s.leaders.netanyahoo.ap = 10; + const orders = planNetanyahoo(s, 'netanyahoo'); + expect(orders.filter((o) => o.kind === 'launch').length).toBeGreaterThanOrEqual(1); + }); + + it('fires a multi-launch salvo when richly armed', () => { + const s = initialState({ cast: ['netanyahoo', 'carnage'], difficulty: 'normal', seed: 'na2' }); + s.leaders.netanyahoo.stockpile.missiles = 4; + s.leaders.netanyahoo.stockpile.warheadsSmall = 4; + s.leaders.netanyahoo.ap = 12; + const orders = planNetanyahoo(s, 'netanyahoo'); + expect(orders.filter((o) => o.kind === 'launch').length).toBeGreaterThanOrEqual(2); + }); + + it('builds toward a yield ramp (medium/large warheads), not only small', () => { + const s = initialState({ cast: ['netanyahoo', 'carnage'], difficulty: 'normal', seed: 'na3' }); + s.leaders.netanyahoo.stockpile.missiles = 6; + s.leaders.netanyahoo.stockpile.warheadsSmall = 4; + s.leaders.netanyahoo.ap = 12; + const orders = planNetanyahoo(s, 'netanyahoo'); + expect(orders.some((o) => o.kind === 'build-warhead' && o.yield === 'medium')).toBe(true); + }); + + it('emits builds before launches (validateOrderSequence ordering)', () => { + const s = initialState({ cast: ['netanyahoo', 'carnage'], difficulty: 'normal', seed: 'na4' }); + s.leaders.netanyahoo.stockpile.missiles = 2; + s.leaders.netanyahoo.stockpile.warheadsSmall = 2; + s.leaders.netanyahoo.ap = 12; + const orders = planNetanyahoo(s, 'netanyahoo'); + const firstLaunch = orders.findIndex((o) => o.kind === 'launch'); + const lastBuild = orders.map((o) => o.kind).lastIndexOf('build-missile'); + if (firstLaunch !== -1 && lastBuild !== -1) { + expect(lastBuild).toBeLessThan(firstLaunch); + } + }); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `npx vitest run tests/engine/ai/netanyahoo.test.ts` +Expected: FAIL — the new aggression tests fail against the current buggy planner. + +- [ ] **Step 3: Rewrite `netanyahoo.ts`** + +Replace the entire contents of `src/engine/ai/netanyahoo.ts` with: + +```ts +import type { GameState, LeaderId, Order } from '../types'; +import { apCostOf, validateOrder } from '../orders'; +import { threatScore, wasAttackedBy } from './scoring'; +import { buildToward, launchSalvo, type BuildPlanEntry } from './aggression'; + +/** + * Netanyahoo — Warmonger personality (P4c.2 rework). + * + * Hardest-hitting personality. Launch-first (uncapped salvo), then build the + * remainder toward a yield ramp. Chump-exception preserved: no launch at Chump + * until Chump has attacked first. Propaganda exclusively at Chump. + */ +const PROPAGANDA_COST = 1; + +const NETANYAHOO_BUILD_PLAN: BuildPlanEntry[] = [ + { build: { item: 'missile' }, target: 6 }, + { build: { item: 'warhead', yield: 'small' }, target: 4 }, + { build: { item: 'warhead', yield: 'medium' }, target: 3 }, + { build: { item: 'warhead', yield: 'large' }, target: 2 }, +]; + +export function planNetanyahoo(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 chumpAlive = state.cast.includes('chump') && state.leaders['chump']?.alive === true; + const chumpProvoked = wasAttackedBy(state, leaderId, 'chump'); + + // Launch candidates: exclude Chump unless he has attacked first. + const launchCandidates = others.filter((t) => t !== 'chump' || chumpProvoked); + // Rank by threat, highest first. + const rankedTargets = [...launchCandidates].sort( + (a, b) => threatScore(state, leaderId, b) - threatScore(state, leaderId, a), + ); + + // Reserve 1 AP for propaganda at Chump. + const propagandaReserve = chumpAlive && budget >= PROPAGANDA_COST ? PROPAGANDA_COST : 0; + const offenceBudget = budget - propagandaReserve; + + // Launch first — salvo self-limits to ammo on hand; warmonger has no cap. + const salvo = launchSalvo(state, leaderId, { budget: offenceBudget, rankedTargets }); + // Build with whatever the salvo left unspent. + const build = buildToward( + state, leaderId, NETANYAHOO_BUILD_PLAN, offenceBudget - salvo.apSpent, + ); + budget -= salvo.apSpent + build.apSpent; + + // Builds must precede launches in the submitted array. + const orders: Order[] = [...build.orders, ...salvo.orders]; + + // Propaganda exclusively at Chump. + if (chumpAlive && budget >= PROPAGANDA_COST) { + const prop: Order = { kind: 'propaganda', target: 'chump' }; + if (validateOrder(state, leaderId, prop).ok) { + orders.push(prop); + budget -= apCostOf(prop); + } + } + + return orders; +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `npx vitest run tests/engine/ai/netanyahoo.test.ts` +Expected: PASS — the four original tests, the P4c.1 regression, and the four new P4c.2 tests. + +- [ ] **Step 5: Run the full suite + typecheck** + +Run: `npm test -- --run` — Expected: all green. +Run: `npx tsc --noEmit` — Expected: clean. + +- [ ] **Step 6: Commit** + +```bash +git add src/engine/ai/netanyahoo.ts tests/engine/ai/netanyahoo.test.ts +git commit -m "engine: Netanyahoo rework — fix zero-fire bug, multi-launch yield ramp" +``` + +--- + +## Task 5: Khameneverhere — Grudge rework + +Very aggressive. `buildToward` with raised targets + medium warheads; uncapped `launchSalvo` focus-firing the top grudge target. + +**Files:** +- Modify: `src/engine/ai/khameneverhere.ts` +- Modify: `tests/engine/ai/khameneverhere.test.ts` + +- [ ] **Step 1: Read the existing test file** + +Read `tests/engine/ai/khameneverhere.test.ts` to see which existing assertions survive. The grudge-targeting behaviour is preserved; tests that assert "launches at top grudge target" still hold. Tests that assert the old `MISSILE_TARGET = 3` / `WARHEAD_TARGET = 3` exact stockpile caps must be updated to the new targets (missile 6, small 4, medium 3). + +- [ ] **Step 2: Update `khameneverhere.test.ts`** + +Keep grudge-targeting and fallback tests. Replace any exact-stockpile-cap assertions, and append: + +```ts +import { planKhameneverhere } from '../../../src/engine/ai/khameneverhere'; + +describe('Khameneverhere aggression (P4c.2)', () => { + it('fires a multi-launch salvo at the top grudge target when armed', () => { + const s = initialState({ cast: ['khameneverhere', 'carnage', 'chump'], difficulty: 'normal', seed: 'ka1' }); + s.leaders.khameneverhere.stockpile.missiles = 3; + s.leaders.khameneverhere.stockpile.warheadsSmall = 3; + s.leaders.khameneverhere.ap = 12; + s.leaders.khameneverhere.grudge = { carnage: 9 }; + const orders = planKhameneverhere(s, 'khameneverhere'); + const launches = orders.filter((o) => o.kind === 'launch'); + expect(launches.length).toBeGreaterThanOrEqual(2); + expect(launches.every((o) => o.kind === 'launch' && o.target === 'carnage')).toBe(true); + }); + + it('builds medium warheads into the ramp', () => { + const s = initialState({ cast: ['khameneverhere', 'carnage'], difficulty: 'normal', seed: 'ka2' }); + s.leaders.khameneverhere.stockpile.missiles = 6; + s.leaders.khameneverhere.stockpile.warheadsSmall = 4; + s.leaders.khameneverhere.ap = 12; + const orders = planKhameneverhere(s, 'khameneverhere'); + expect(orders.some((o) => o.kind === 'build-warhead' && o.yield === 'medium')).toBe(true); + }); +}); +``` + +- [ ] **Step 3: Run the tests to verify they fail** + +Run: `npx vitest run tests/engine/ai/khameneverhere.test.ts` +Expected: FAIL — the multi-launch test fails against the current one-launch planner. + +- [ ] **Step 4: Rewrite `khameneverhere.ts`** + +Replace the entire contents of `src/engine/ai/khameneverhere.ts` with: + +```ts +import type { GameState, LeaderId, Order } from '../types'; +import { topGrudgeTarget } from './scoring'; +import { buildToward, launchSalvo, type BuildPlanEntry } from './aggression'; + +/** + * Khameneverhere — Grudge personality (P4c.2 rework). + * + * Very aggressive, launch-focused. Ranks targets by grudge (top grudge first, + * then remaining living leaders). Launch-first uncapped salvo, then build the + * remainder toward a raised stockpile with medium warheads. No diplomacy. + */ +const KHAMENEVERHERE_BUILD_PLAN: BuildPlanEntry[] = [ + { build: { item: 'missile' }, target: 6 }, + { build: { item: 'warhead', yield: 'small' }, target: 4 }, + { build: { item: 'warhead', yield: 'medium' }, target: 3 }, +]; + +export function planKhameneverhere(state: GameState, leaderId: LeaderId): Order[] { + const me = state.leaders[leaderId]; + if (!me || !me.alive) return []; + + const others = state.cast.filter((t) => t !== leaderId && state.leaders[t]?.alive); + + // Rank: top grudge target first, then the remaining living leaders. + const top = topGrudgeTarget(state, leaderId); + const rankedTargets: LeaderId[] = + top !== null && others.includes(top) + ? [top, ...others.filter((t) => t !== top)] + : [...others]; + + // Launch first (uncapped), then build with the remainder. + const salvo = launchSalvo(state, leaderId, { budget: me.ap, rankedTargets }); + const build = buildToward( + state, leaderId, KHAMENEVERHERE_BUILD_PLAN, me.ap - salvo.apSpent, + ); + + // Builds precede launches in the submitted array. + return [...build.orders, ...salvo.orders]; +} +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `npx vitest run tests/engine/ai/khameneverhere.test.ts` +Expected: PASS. + +- [ ] **Step 6: Run the full suite + typecheck** + +Run: `npm test -- --run` — Expected: all green. +Run: `npx tsc --noEmit` — Expected: clean. + +- [ ] **Step 7: Commit** + +```bash +git add src/engine/ai/khameneverhere.ts tests/engine/ai/khameneverhere.test.ts +git commit -m "engine: Khameneverhere rework — multi-launch grudge salvo, raised stockpile" +``` + +--- + +## Task 6: Mileigh-hem — Glass cannon rework + +Fixes the zero-fire bug (no build logic). Keeps the two-mode identity: activated → build + spread-fire salvo; not activated → diplomatic mode unchanged. + +**Files:** +- Modify: `src/engine/ai/mileighhem.ts` +- Modify: `tests/engine/ai/mileighhem.test.ts` + +- [ ] **Step 1: Read the existing test file** + +Read `tests/engine/ai/mileighhem.test.ts`. Diplomatic-mode tests (woo/propaganda when not activated) survive unchanged. All-out-mode tests that assumed a pre-seeded stockpile survive; the new behaviour adds build orders in activated mode. + +- [ ] **Step 2: Update `mileighhem.test.ts`** + +Keep the diplomatic-mode tests. Append: + +```ts +import { planMileighHem } from '../../../src/engine/ai/mileighhem'; + +describe('Mileigh-hem aggression (P4c.2)', () => { + it('builds delivery + warheads in activated mode — the zero-fire bug is gone', () => { + const s = initialState({ cast: ['mileigh-hem', 'carnage'], difficulty: 'normal', seed: 'ma1' }); + // Activated: apBanked + ap >= 4. Make him an attacker target so he is activated. + s.leaders['mileigh-hem'].ap = 10; + s.leaders['mileigh-hem'].grudge = { carnage: 3 }; + const orders = planMileighHem(s, 'mileigh-hem'); + expect(orders.some((o) => o.kind === 'build-missile' || o.kind === 'build-warhead')).toBe(true); + }); + + it('fires a spread salvo across targets when armed in activated mode', () => { + const s = initialState({ cast: ['mileigh-hem', 'carnage', 'chump'], difficulty: 'normal', seed: 'ma2' }); + s.leaders['mileigh-hem'].ap = 12; + s.leaders['mileigh-hem'].stockpile.missiles = 4; + s.leaders['mileigh-hem'].stockpile.warheadsSmall = 4; + s.leaders['mileigh-hem'].grudge = { carnage: 2, chump: 2 }; + const orders = planMileighHem(s, 'mileigh-hem'); + const targets = new Set(orders.filter((o) => o.kind === 'launch').map((o) => (o.kind === 'launch' ? o.target : ''))); + expect(orders.filter((o) => o.kind === 'launch').length).toBeGreaterThanOrEqual(2); + expect(targets.size).toBeGreaterThanOrEqual(2); // spread across targets + }); +}); +``` + +- [ ] **Step 3: Run the tests to verify they fail** + +Run: `npx vitest run tests/engine/ai/mileighhem.test.ts` +Expected: FAIL — the build test fails (current planner builds nothing). + +- [ ] **Step 4: Rewrite `mileighhem.ts`** + +Replace the entire contents of `src/engine/ai/mileighhem.ts` with: + +```ts +import type { GameState, LeaderId, Order } from '../types'; +import { apCostOf, validateOrder } from '../orders'; +import { wasAttackedBy } from './scoring'; +import { AI_SCORING_WEIGHTS } from '../balance'; +import { buildToward, launchSalvo, type BuildPlanEntry } from './aggression'; + +/** + * Mileigh-hem — Glass cannon personality (P4c.2 rework). + * + * Two modes gated by the activation trigger (apBanked + ap >= threshold). + * + * Activated: launch-first SPREAD salvo (cycles targets), then build a cheap + * fast offensive stockpile with the remainder. No defence — glass cannon. + * + * Diplomatic (not activated): up to 2 woo + up to 2 propaganda at attackers. + */ +const MILEIGH_BUILD_PLAN: BuildPlanEntry[] = [ + { build: { item: 'missile' }, target: 4 }, + { build: { item: 'warhead', yield: 'small' }, target: 3 }, + { build: { item: 'warhead', yield: 'medium' }, target: 2 }, +]; + +export function planMileighHem(state: GameState, leaderId: LeaderId): Order[] { + const me = state.leaders[leaderId]; + if (!me || !me.alive) return []; + + const others = state.cast.filter((t) => t !== leaderId && state.leaders[t]?.alive); + const attackers = others.filter((t) => wasAttackedBy(state, leaderId, t)); + + const totalAp = me.apBanked + me.ap; + const activated = totalAp >= AI_SCORING_WEIGHTS.mileighActivationApThreshold; + + if (activated) { + // All-out mode: spread salvo first, then build with the remainder. + const rankedTargets = attackers.length > 0 ? attackers : others; + const salvo = launchSalvo(state, leaderId, { + budget: me.ap, + rankedTargets, + spread: true, + }); + const build = buildToward(state, leaderId, MILEIGH_BUILD_PLAN, me.ap - salvo.apSpent); + return [...build.orders, ...salvo.orders]; + } + + // --- Diplomatic mode (unchanged from P4b) --- + const orders: Order[] = []; + let budget = me.ap; + const WOO_COST = 1; + const PROPAGANDA_COST = 1; + + const wooPool = attackers.length > 0 ? attackers : others; + let wooCount = 0; + for (const t of wooPool) { + if (wooCount >= 2) break; + if (budget < WOO_COST) break; + const woo: Order = { kind: 'woo', target: t }; + if (validateOrder(state, leaderId, woo).ok) { + orders.push(woo); + budget -= apCostOf(woo); + wooCount++; + } + } + + let propCount = 0; + for (const t of attackers) { + if (propCount >= 2) break; + if (budget < PROPAGANDA_COST) break; + const prop: Order = { kind: 'propaganda', target: t }; + if (validateOrder(state, leaderId, prop).ok) { + orders.push(prop); + budget -= apCostOf(prop); + propCount++; + } + } + + return orders; +} +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `npx vitest run tests/engine/ai/mileighhem.test.ts` +Expected: PASS. + +- [ ] **Step 6: Run the full suite + typecheck** + +Run: `npm test -- --run` — Expected: all green. +Run: `npx tsc --noEmit` — Expected: clean. + +- [ ] **Step 7: Commit** + +```bash +git add src/engine/ai/mileighhem.ts tests/engine/ai/mileighhem.test.ts +git commit -m "engine: Mileigh-hem rework — fix zero-fire bug, build logic + spread salvo" +``` + +--- + +## Task 7: Carnage — Rational + Opportunist rework + +Keeps the P4c.1 bomber bias. `buildToward` builds a bomber + warhead mix; moderate-capped `launchSalvo` focus-fires the top threat+opportunism target. Attacker-propaganda preserved. + +**Files:** +- Modify: `src/engine/ai/carnage.ts` +- Modify: `tests/engine/ai/carnage.test.ts` + +- [ ] **Step 1: Read the existing test file** + +Read `tests/engine/ai/carnage.test.ts`. The P4c.1 bomber-bias tests (`'Carnage AI bomber bias (P4c.1)'`) must still hold: Carnage builds a bomber when none owned, does not build a second, launches with bomber delivery when available. The new code preserves all of this — `buildToward` with `{ bomber, target: 1 }` builds exactly one bomber, and `launchSalvo` prefers bomber delivery. + +- [ ] **Step 2: Update `carnage.test.ts`** + +Keep the existing combined-score targeting tests and the P4c.1 bomber-bias tests. Append: + +```ts +import { planCarnage } from '../../../src/engine/ai/carnage'; + +describe('Carnage aggression (P4c.2)', () => { + it('fires a multi-launch salvo when armed', () => { + const s = initialState({ cast: ['carnage', 'chump'], difficulty: 'normal', seed: 'ca1' }); + s.leaders.carnage.stockpile.bombers = 1; + s.leaders.carnage.stockpile.missiles = 2; + s.leaders.carnage.stockpile.warheadsSmall = 3; + s.leaders.carnage.ap = 12; + const orders = planCarnage(s, 'carnage'); + expect(orders.filter((o) => o.kind === 'launch').length).toBeGreaterThanOrEqual(2); + }); + + it('still builds exactly one bomber when none owned (P4c.1 bias preserved)', () => { + const s = initialState({ cast: ['carnage', 'chump'], difficulty: 'normal', seed: 'ca2' }); + s.leaders.carnage.stockpile.bombers = 0; + s.leaders.carnage.ap = 10; + const orders = planCarnage(s, 'carnage'); + expect(orders.filter((o) => o.kind === 'build-bomber')).toHaveLength(1); + }); +}); +``` + +- [ ] **Step 3: Run the tests to verify they fail** + +Run: `npx vitest run tests/engine/ai/carnage.test.ts` +Expected: FAIL — the multi-launch test fails against the current one-launch planner. + +- [ ] **Step 4: Rewrite `carnage.ts`** + +Replace the entire contents of `src/engine/ai/carnage.ts` with: + +```ts +import type { GameState, LeaderId, Order } from '../types'; +import { apCostOf, validateOrder } from '../orders'; +import { threatScore, opportunismScore, wasAttackedBy } from './scoring'; +import { AI_SCORING_WEIGHTS } from '../balance'; +import { buildToward, launchSalvo, type BuildPlanEntry } from './aggression'; + +/** + * Carnage — Rational + Opportunist personality (P4c.2 rework). + * + * Aggressive-rational. Ranks targets by threat (with escalation against + * leaders who hit Carnage last round) + opportunism. Keeps the P4c.1 bomber + * bias: builds one reusable bomber when none owned; launchSalvo prefers bomber + * delivery. Moderate launch cap. Propaganda only at attackers. + */ +const PROPAGANDA_COST = 1; +const CARNAGE_MAX_LAUNCHES = 3; + +const CARNAGE_BUILD_PLAN: BuildPlanEntry[] = [ + { build: { item: 'bomber' }, target: 1 }, + { build: { item: 'warhead', yield: 'small' }, target: 4 }, + { build: { item: 'warhead', yield: 'medium' }, target: 2 }, +]; + +export function planCarnage(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)); + + function combinedScore(target: LeaderId): number { + const base = threatScore(state, leaderId, target); + const escalated = + (me.recentAggressionFrom[target] ?? 0) > 0 + ? base * AI_SCORING_WEIGHTS.carnageEscalationMultiplier + : base; + return escalated + opportunismScore(state, target); + } + + const rankedTargets = [...others].sort((a, b) => combinedScore(b) - combinedScore(a)); + + // Reserve 1 AP per attacker for propaganda (capped so it never starves offence). + const propagandaReserve = Math.min(attackers.length, Math.max(0, budget - 2)); + const offenceBudget = budget - propagandaReserve; + + // Launch first (moderate cap), then build with the remainder. + const salvo = launchSalvo(state, leaderId, { + budget: offenceBudget, + rankedTargets, + maxLaunches: CARNAGE_MAX_LAUNCHES, + }); + const build = buildToward( + state, leaderId, CARNAGE_BUILD_PLAN, offenceBudget - salvo.apSpent, + ); + budget -= salvo.apSpent + build.apSpent; + + const orders: Order[] = [...build.orders, ...salvo.orders]; + + // Propaganda only at leaders who attacked Carnage. + 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; +} +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `npx vitest run tests/engine/ai/carnage.test.ts` +Expected: PASS — combined-score targeting, P4c.1 bomber bias, and new P4c.2 tests. + +- [ ] **Step 6: Run the full suite + typecheck** + +Run: `npm test -- --run` — Expected: all green. +Run: `npx tsc --noEmit` — Expected: clean. + +- [ ] **Step 7: Commit** + +```bash +git add src/engine/ai/carnage.ts tests/engine/ai/carnage.test.ts +git commit -m "engine: Carnage rework — multi-launch salvo, P4c.1 bomber bias preserved" +``` + +--- + +## Task 8: Starmless — Cautious + Scapegoat rework, new kill instinct + +Adds the opportunism finish path: Starmless now launches when retaliating **or** when a finishable (low-population) opponent exists. Low launch cap. Keeps factory/defence building, deploy logic, scapegoat roll. + +**Files:** +- Modify: `src/engine/ai/starmless.ts` +- Modify: `tests/engine/ai/starmless.test.ts` + +- [ ] **Step 1: Read the existing test file** + +Read `tests/engine/ai/starmless.test.ts`. Retaliation-targeting and scapegoat-roll tests survive. The new behaviour adds: Starmless launches at a low-population opponent even with no prior attack on him. + +- [ ] **Step 2: Update `starmless.test.ts`** + +Keep retaliation + scapegoat tests. Append: + +```ts +import { planStarmless } from '../../../src/engine/ai/starmless'; + +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); + }); +}); +``` + +- [ ] **Step 3: Run the tests to verify they fail** + +Run: `npx vitest run tests/engine/ai/starmless.test.ts` +Expected: FAIL — the finishable-target test fails (current planner only launches on retaliation). + +- [ ] **Step 4: Rewrite `starmless.ts`** + +Replace the entire contents of `src/engine/ai/starmless.ts` with: + +```ts +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; + +const STARMLESS_BUILD_PLAN: BuildPlanEntry[] = [ + { build: { item: 'factory' }, target: 8 }, + { build: { item: 'defence', type: 'shield' }, target: 2 }, + { build: { item: 'warhead', yield: 'small' }, target: 3 }, +]; + +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; +} +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `npx vitest run tests/engine/ai/starmless.test.ts` +Expected: PASS. + +Note on test ordering: `deploy-defence` is appended *after* launches in the array. `validateOrderSequence` projects a `build-defence` (+1 shield) before a `deploy-defence` (−1 shield) only if the build is earlier in the array — which it is (`build.orders` come first). A deploy of a *pre-owned* shield (not built this round) is also valid since the real stockpile has it. No ordering hazard. + +- [ ] **Step 6: Run the full suite + typecheck** + +Run: `npm test -- --run` — Expected: all green. +Run: `npx tsc --noEmit` — Expected: clean. + +- [ ] **Step 7: Commit** + +```bash +git add src/engine/ai/starmless.ts tests/engine/ai/starmless.test.ts +git commit -m "engine: Starmless rework — new kill instinct, multi-launch under low cap" +``` + +--- + +## Task 9: Chump — Coward rework + +Routes Chump's existing opportunism-launch path through `launchSalvo` with a low cap. Heavy defence build + deploy, propaganda, and wooing-suppression preserved. + +**Files:** +- Modify: `src/engine/ai/chump.ts` +- Modify: `tests/engine/ai/chump.test.ts` + +- [ ] **Step 1: Read the existing test file** + +Read `tests/engine/ai/chump.test.ts`. The wooing-suppression rule (never launch at a leader with `favourability[t] > 0`), the weak-target selection, and infra-vs-people targeting all survive. Defence-building tests survive. + +- [ ] **Step 2: Update `chump.test.ts`** + +Keep the existing tests. Append: + +```ts +import { planChump } from '../../../src/engine/ai/chump'; + +describe('Chump aggression (P4c.2)', () => { + it('fires a capped multi-launch salvo at a weak target', () => { + const s = initialState({ cast: ['chump', 'carnage'], difficulty: 'normal', seed: 'cha1' }); + s.leaders.chump.stockpile.missiles = 3; + s.leaders.chump.stockpile.warheadsSmall = 3; + s.leaders.chump.ap = 12; + s.leaders.carnage.population = 4; // weak — opportunismScore > 0 + s.leaders.carnage.factories = 0; + const orders = planChump(s, 'chump'); + const launches = orders.filter((o) => o.kind === 'launch'); + expect(launches.length).toBeGreaterThanOrEqual(1); + expect(launches.length).toBeLessThanOrEqual(2); // low cap + }); + + it('never launches at a leader who has wooed Chump', () => { + const s = initialState({ cast: ['chump', 'carnage'], difficulty: 'normal', seed: 'cha2' }); + s.leaders.chump.stockpile.missiles = 3; + s.leaders.chump.stockpile.warheadsSmall = 3; + s.leaders.chump.ap = 12; + s.leaders.carnage.population = 4; + s.leaders.carnage.factories = 0; + s.leaders.chump.favourability = { carnage: 5 }; // carnage wooed chump + const orders = planChump(s, 'chump'); + expect(orders.some((o) => o.kind === 'launch')).toBe(false); + }); +}); +``` + +- [ ] **Step 3: Run the tests to verify they fail** + +Run: `npx vitest run tests/engine/ai/chump.test.ts` +Expected: FAIL — the multi-launch test fails against the current one-launch planner. + +- [ ] **Step 4: Rewrite `chump.ts`** + +Replace the entire contents of `src/engine/ai/chump.ts` with: + +```ts +import type { GameState, LeaderId, Order } from '../types'; +import { apCostOf, validateOrder } from '../orders'; +import { defenceVisibilityScore, opportunismScore } from './scoring'; +import { buildToward, launchSalvo, type BuildPlanEntry } from './aggression'; + +/** + * Chump — Coward personality (P4c.2 rework). + * + * Defensive, but opportunistic: launches at weak / undefended targets under a + * low cap. Never launches at a leader who has wooed Chump. Heavy defence build + * + deploy and propaganda preserved. Prefers infra targeting when the target + * still has factories to lose. + */ +const PROPAGANDA_COST = 1; +const DEPLOY_COST = 4; +const CHUMP_MAX_LAUNCHES = 2; + +const CHUMP_BUILD_PLAN: BuildPlanEntry[] = [ + { build: { item: 'defence', type: 'shield' }, target: 3 }, + { build: { item: 'warhead', yield: 'small' }, target: 4 }, +]; + +export function planChump(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); + + // Eligible launch targets: not protected by Chump's own favourability toward + // them (a leader who wooed Chump raises me.favourability[t] > 0). + const eligible = others.filter((t) => (me.favourability[t] ?? 0) <= 0); + // Weak targets: low defence OR otherwise vulnerable. Ranked weakest-first. + const weakTargets = eligible + .filter((t) => opportunismScore(state, t) > 0 || defenceVisibilityScore(state, t) === 0) + .sort((a, b) => opportunismScore(state, b) - opportunismScore(state, a)); + + // Infra targeting when the target still has factories to lose. + const targetTypeFor = (t: LeaderId): 'people' | 'infra' => + state.leaders[t].factories > 2 ? 'infra' : 'people'; + + // Reserve 1 AP for propaganda. + const propagandaReserve = others.length > 0 && budget >= 1 ? PROPAGANDA_COST : 0; + const offenceBudget = budget - propagandaReserve; + + // Launch first (low cap), then build with the remainder. + const salvo = launchSalvo(state, leaderId, { + budget: offenceBudget, + rankedTargets: weakTargets, + maxLaunches: CHUMP_MAX_LAUNCHES, + targetTypeFor, + }); + const build = buildToward( + state, leaderId, CHUMP_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. + 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 — broadcast to the first available target. + if (budget >= PROPAGANDA_COST && others.length > 0) { + const prop: Order = { kind: 'propaganda', target: others[0] }; + if (validateOrder(state, leaderId, prop).ok) { + orders.push(prop); + budget -= apCostOf(prop); + } + } + + return orders; +} +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `npx vitest run tests/engine/ai/chump.test.ts` +Expected: PASS. + +- [ ] **Step 6: Run the full suite + typecheck** + +Run: `npm test -- --run` — Expected: all green. +Run: `npx tsc --noEmit` — Expected: clean. + +- [ ] **Step 7: Commit** + +```bash +git add src/engine/ai/chump.ts tests/engine/ai/chump.test.ts +git commit -m "engine: Chump rework — capped opportunistic salvo, defence preserved" +``` + +--- + +## Task 10: AI-duel termination assertion + README + +Replaces the duel test's "no crash" assertion with a termination assertion: every seeded all-AI game reaches an outcome within a round cap. This is the success criterion for the whole slice. + +**Files:** +- Modify: `tests/engine/ai-duel.test.ts` +- Modify: `README.md` + +- [ ] **Step 1: Rewrite `ai-duel.test.ts`** + +Replace the entire contents of `tests/engine/ai-duel.test.ts` with: + +```ts +import { describe, it, expect } from 'vitest'; +import { initialState } from '../../src/engine/state'; +import { reduce } from '../../src/engine/reducer'; +import { planAi } from '../../src/engine/ai'; +import type { LeaderId, WinType } from '../../src/engine/types'; + +const FULL_CAST: LeaderId[] = ['chump', 'khameneverhere', 'starmless', '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 / +// apocalypse / pyrrhic. If this assertion ever fires, that is a balance bug to +// fix — not a cap to add to the game. +const ROUND_CAP = 60; + +function runOneGame(seed: string): { type: WinType | null; rounds: number } { + let s = initialState({ cast: FULL_CAST, difficulty: 'normal', seed }); + let rounds = 0; + while (!s.outcome && rounds < ROUND_CAP) { + for (const id of FULL_CAST) { + const orders = planAi(s, id); + s = reduce(s, { type: 'SUBMIT_ORDERS', leaderId: id, orders }); + } + s = reduce(s, { type: 'RESOLVE_ROUND' }); + rounds++; + } + return { type: s.outcome?.type ?? null, rounds }; +} + +describe('AI-duel headless (P4c.2)', () => { + it('every seeded all-AI game terminates within the round cap', () => { + const SEEDS = 40; + let unfinished = 0; + let maxRounds = 0; + for (let i = 0; i < SEEDS; i++) { + const r = runOneGame(`duel-${i}`); + if (r.type === null) unfinished++; + if (r.rounds > maxRounds) maxRounds = r.rounds; + } + // eslint-disable-next-line no-console + console.log(`AI-duel: ${SEEDS} games, max rounds = ${maxRounds}, unfinished = ${unfinished}`); + expect(unfinished).toBe(0); + }, 60_000); +}); +``` + +- [ ] **Step 2: Run the duel test** + +Run: `npx vitest run tests/engine/ai-duel.test.ts` +Expected: PASS — `unfinished` is 0; the console line reports the max round count. + +**If the test FAILS (`unfinished > 0`):** a balance bug is blocking termination. This is the spec's documented real concern (§6.1.1) and fixing it is part of this task. Diagnose by raising `ROUND_CAP` temporarily and logging the stuck game's final state — typically a leader hoarding AP into defence. Tune the offending planner's build plan (lower the defence target, raise warhead targets) or launch cap, re-run, and keep the change. Do not add an in-game round cap — that contradicts the elimination-only decision. + +- [ ] **Step 3: Update `README.md`** + +Two edits: +1. Correct the win-conditions line. Change `- All four win conditions: survivor, pyrrhic, apocalypse, dominance.` to `- Win conditions: survivor, pyrrhic, apocalypse (elimination-only — dominance removed in P4c.2).` +2. Append a new section after the existing Phase 4c slice 1 status section: + +```markdown +## Phase 4c slice 2 status + +(AI aggression rework + elimination-only endings.) The six AI planners were reworked onto two shared helpers — `buildToward` (capped, priority-ordered builds) and `launchSalvo` (largest-yield-first multi-launch). This fixed two planners that structurally could not fire (Netanyahoo's runaway missile-build loop starved warhead production; Mileigh-hem had no build logic), gave Starmless a kill instinct against low-population opponents, and let the whole cast fire multi-launch salvos with a medium/large warhead ramp. The dominance win condition was removed: games now end only by survivor / apocalypse / pyrrhic. The AI-duel test asserts every seeded all-AI game terminates within 60 rounds. Verify: `npm run test:run`. +``` + +- [ ] **Step 4: Run the full suite + typecheck** + +Run: `npm test -- --run` — Expected: all green. +Run: `npx tsc --noEmit` — Expected: clean. + +- [ ] **Step 5: Commit** + +```bash +git add tests/engine/ai-duel.test.ts README.md +git commit -m "test: AI-duel termination assertion; docs: P4c slice 2 status" +``` + +--- + +## Final steps (after all tasks) + +1. Dispatch a final branch-wide code review covering all 10 commits. +2. Run `npm test -- --run` and `npx tsc --noEmit` once more — both must be green/clean. +3. Use `superpowers:finishing-a-development-branch`: push + PR + babysit BugBot + merge + main pull + worktree prune (the standing flow for this project). + +--- + +## Notes on test count + +P4c.1 baseline is 255. This slice removes ~6 dominance-related tests (Task 1) and adds ~17 helper tests (Tasks 2–3) plus ~2–4 behavioural tests per planner (Tasks 4–9) and reworks the duel test. The exact final count is whatever the suite reports — every task ends with `npm test -- --run` green. Do not hard-code a target number; assert green, not a count. + +--- + +## Self-review + +**Spec coverage:** +- §2.1 win condition removal → Task 1 (incl. the wider blast radius: `index.ts` re-export, `Winners.tsx`, `state`/`balance`/`integration`/`determinism` tests — found via grep, beyond the spec's file list). +- §2.2.1 `buildToward` → Task 2. §2.2.2 `launchSalvo` → Task 3. +- §2.3 per-planner rework → Tasks 4–9, one per personality, spectrum preserved via per-planner build plans + launch caps. +- §2.4 UI projection unchanged → no task needed (correct — `projectInventory` is untouched). +- §2.5 / §5.3 duel termination assertion → Task 10. +- §4 schema changes (`GameConfig.dominanceThreshold`, `WinOutcome.dominance`) → Task 1 Steps 4. `WinOutcome.dominance` IS removed (not left unreferenced) because the only consumer, `Winners.tsx`, is updated in the same task — cleaner than dead code, and the spec permits it (§6.1.2). +- §5 testing → helper tests (Task 2–3), per-planner tests (Tasks 4–9), duel (Task 10), winConditions (Task 1). + +**Placeholder scan:** No TBD/TODO. Per-planner build-plan numbers and launch caps are concrete first-pass values (spec §6.3 explicitly designates these as first-pass, playtest-tuned — not placeholders). Task 10 Step 2 gives a concrete diagnose-and-tune procedure if the termination assertion fails. + +**Type consistency:** `BuildItem`, `BuildPlanEntry`, `BuildResult`, `LaunchSalvoOpts`, `SalvoResult` are defined in Task 2/3 and imported identically in Tasks 4–9. `buildToward(state, leaderId, plan, budget)` and `launchSalvo(state, leaderId, opts)` signatures are used consistently. Every planner uses the same `[...build.orders, ...salvo.orders, ...diplomacy]` array ordering, satisfying the validateOrderSequence builds-before-launches rule. From 3f29272dc38f9305757ec309855c1afa8a8a836e Mon Sep 17 00:00:00 2001 From: gethi Date: Sun, 17 May 2026 11:47:28 +0100 Subject: [PATCH 3/7] =?UTF-8?q?docs:=20P4c.2=20plan=20=E2=80=94=20add=20co?= =?UTF-8?q?nfidence=20ratings;=20fix=20build-plan=20delivery-vehicle=20bug?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...026-05-17-phase-4c-slice2-ai-aggression.md | 131 ++++++++++++++---- 1 file changed, 106 insertions(+), 25 deletions(-) diff --git a/docs/superpowers/plans/2026-05-17-phase-4c-slice2-ai-aggression.md b/docs/superpowers/plans/2026-05-17-phase-4c-slice2-ai-aggression.md index 2ab525f..a9c1116 100644 --- a/docs/superpowers/plans/2026-05-17-phase-4c-slice2-ai-aggression.md +++ b/docs/superpowers/plans/2026-05-17-phase-4c-slice2-ai-aggression.md @@ -56,6 +56,8 @@ Self-contained, independent of the helpers. Removes the dominance rule and all its dead code across engine, UI, and tests. +**Confidence: 95%** — pure deletion; the full blast radius was grepped at plan-write time and every reference is enumerated below. Residual risk: the `Winners.tsx` switch edit, but the narrowed `WinOutcome` makes the compiler enforce exhaustiveness. + **Files:** - Modify: `src/engine/winConditions.ts` - Modify: `src/engine/types.ts` @@ -73,10 +75,10 @@ Self-contained, independent of the helpers. Removes the dominance rule and all i Delete these three `it(...)` blocks entirely: `'returns dominance when one leader has 2× the next-highest population'`, `'does not return dominance when ratio is below threshold'`, and `'survivor takes priority over dominance'`. Rename the first remaining test from `'returns null while multiple leaders are alive and no dominance'` to `'returns null while multiple leaders are alive'`. The file keeps four tests: returns-null, survivor, pyrrhic, apocalypse. -- [ ] **Step 2: Run the win-condition tests to verify they fail** +- [ ] **Step 2: Run the win-condition tests to confirm the edited file is green** Run: `npx vitest run tests/engine/winConditions.test.ts` -Expected: FAIL — the renamed/kept tests still pass, but the file will not yet compile cleanly once later steps land. At this point it should still PASS (no source change yet). This step just confirms the edited test file is green before touching source. Expected: PASS, 4 tests. +Expected: PASS — 4 tests (returns-null, survivor, pyrrhic, apocalypse). Source is unchanged at this point, so the four kept tests stay green. This is a removal task, not a red-green TDD cycle — there is no "failing" intermediate state; the suite stays green throughout as dead code is removed in lockstep across Steps 3–8. - [ ] **Step 3: Remove the dominance branch from `checkOutcome`** @@ -186,6 +188,8 @@ git commit -m "engine: remove dominance win condition — games end by eliminati The capped build helper. A planner passes an ordered, capped build plan; `buildToward` emits build orders up to each cap, in priority order, until budget runs out. +**Confidence: 95%** — new pure function, full code + tests given. `apCostOf` / `validateOrder` / `warheadFieldFor` signatures verified against source at plan-write time. + **Files:** - Create: `src/engine/ai/aggression.ts` - Create: `tests/engine/ai/aggression.test.ts` @@ -384,6 +388,8 @@ git commit -m "engine: add buildToward AI helper — capped, priority-ordered bu The volume+yield core. Pairs delivery vehicles with warheads largest-yield-first, emits launches until AP, ammo, or the cap runs out. +**Confidence: 93%** — new pure function, full code + tests given. The projected-stockpile self-limiting logic is the one subtle part; the `'never emits more launches than the projected stockpile can arm'` test pins it. + **Files:** - Modify: `src/engine/ai/aggression.ts` - Modify: `tests/engine/ai/aggression.test.ts` @@ -600,6 +606,8 @@ git commit -m "engine: add launchSalvo AI helper — largest-yield-first multi-l Fixes the zero-fire bug. Capped `buildToward` plan with a yield ramp; uncapped `launchSalvo`. Chump-exception and Chump-propaganda preserved. +**Confidence: 94%** — full rewrite given; all four existing tests + the P4c.1 regression were traced against the new code at plan-write time and confirmed to pass unchanged. + **Files:** - Modify: `src/engine/ai/netanyahoo.ts` - Modify: `tests/engine/ai/netanyahoo.test.ts` @@ -753,17 +761,19 @@ git commit -m "engine: Netanyahoo rework — fix zero-fire bug, multi-launch yie Very aggressive. `buildToward` with raised targets + medium warheads; uncapped `launchSalvo` focus-firing the top grudge target. +**Confidence: 94%** — full rewrite given; all three existing tests traced against the new code at plan-write time and confirmed to pass unchanged. + **Files:** - Modify: `src/engine/ai/khameneverhere.ts` - Modify: `tests/engine/ai/khameneverhere.test.ts` -- [ ] **Step 1: Read the existing test file** +- [ ] **Step 1: Confirm existing tests survive the rework** -Read `tests/engine/ai/khameneverhere.test.ts` to see which existing assertions survive. The grudge-targeting behaviour is preserved; tests that assert "launches at top grudge target" still hold. Tests that assert the old `MISSILE_TARGET = 3` / `WARHEAD_TARGET = 3` exact stockpile caps must be updated to the new targets (missile 6, small 4, medium 3). +`tests/engine/ai/khameneverhere.test.ts` has three tests — `'targets the top of the grudge list when launching'`, `'fallback: if grudge empty, picks any living non-self leader'`, and `'builds when no stockpile yet'`. All three pass unchanged against the new planner code below (verified at plan-write time — the file contains no exact-stockpile-cap assertions). Keep all three. - [ ] **Step 2: Update `khameneverhere.test.ts`** -Keep grudge-targeting and fallback tests. Replace any exact-stockpile-cap assertions, and append: +Keep all three existing tests unchanged. Append: ```ts import { planKhameneverhere } from '../../../src/engine/ai/khameneverhere'; @@ -866,17 +876,19 @@ git commit -m "engine: Khameneverhere rework — multi-launch grudge salvo, rais Fixes the zero-fire bug (no build logic). Keeps the two-mode identity: activated → build + spread-fire salvo; not activated → diplomatic mode unchanged. +**Confidence: 93%** — full rewrite given; all seven existing tests traced against the new code at plan-write time and confirmed to pass unchanged (incl. the exact `launches.length === 3` largest-yield-first test). + **Files:** - Modify: `src/engine/ai/mileighhem.ts` - Modify: `tests/engine/ai/mileighhem.test.ts` -- [ ] **Step 1: Read the existing test file** +- [ ] **Step 1: Confirm existing tests survive the rework** -Read `tests/engine/ai/mileighhem.test.ts`. Diplomatic-mode tests (woo/propaganda when not activated) survive unchanged. All-out-mode tests that assumed a pre-seeded stockpile survive; the new behaviour adds build orders in activated mode. +`tests/engine/ai/mileighhem.test.ts` has seven tests (activation trigger, diplomatic woo/propaganda, no-defence in either mode, attacker-only targeting in all-out mode, largest-yield-first, banked-AP activation). All seven pass unchanged against the new planner code below (verified at plan-write time — the new all-out mode still emits launches largest-yield-first via `launchSalvo`, still skips defence, and the diplomatic branch is unchanged). Keep all seven. - [ ] **Step 2: Update `mileighhem.test.ts`** -Keep the diplomatic-mode tests. Append: +Keep all seven existing tests unchanged. Append: ```ts import { planMileighHem } from '../../../src/engine/ai/mileighhem'; @@ -1015,40 +1027,68 @@ git commit -m "engine: Mileigh-hem rework — fix zero-fire bug, build logic + s ## Task 7: Carnage — Rational + Opportunist rework -Keeps the P4c.1 bomber bias. `buildToward` builds a bomber + warhead mix; moderate-capped `launchSalvo` focus-fires the top threat+opportunism target. Attacker-propaganda preserved. +Keeps and extends the P4c.1 bomber bias. `buildToward` builds a 3-bomber reusable fleet + warhead mix; moderate-capped `launchSalvo` focus-fires the top threat+opportunism target. Attacker-propaganda preserved. + +**Confidence: 92%** — full rewrite given. The confidence-lift pass found that a 1-bomber plan would give Carnage only one launch/round; fixed to a 3-bomber fleet. One P4c.1 test (`'does NOT build a second bomber'`) is superseded and explicitly replaced in Step 2 — that test change is the only residual risk, and it is fully specified. **Files:** - Modify: `src/engine/ai/carnage.ts` - Modify: `tests/engine/ai/carnage.test.ts` -- [ ] **Step 1: Read the existing test file** +- [ ] **Step 1: Confirm which existing tests survive, and which must change** -Read `tests/engine/ai/carnage.test.ts`. The P4c.1 bomber-bias tests (`'Carnage AI bomber bias (P4c.1)'`) must still hold: Carnage builds a bomber when none owned, does not build a second, launches with bomber delivery when available. The new code preserves all of this — `buildToward` with `{ bomber, target: 1 }` builds exactly one bomber, and `launchSalvo` prefers bomber delivery. +`tests/engine/ai/carnage.test.ts` has nine tests: five in `describe('Carnage (Rational + Opportunist)')` and four in `describe('Carnage AI bomber bias (P4c.1)')`. + +- The five targeting/propaganda tests pass unchanged against the new planner (verified at plan-write time). +- Three of the four P4c.1 tests pass unchanged: `'builds a bomber when none owned and budget allows'` still holds (`some(build-bomber)` true, `some(build-missile)` false — the new `CARNAGE_BUILD_PLAN` has no missile entry); and both delivery tests (`'launches with delivery=bomber...'`, `'falls back to delivery=missile...'`) still hold because `launchSalvo` prefers bomber delivery. +- **One P4c.1 test must be REPLACED:** `'does NOT build a second bomber when one is already owned'` hard-asserts `filter(build-bomber).length === 0` for an owned bomber. P4c.2 supersedes the single-shot rule — Carnage now builds toward a 3-bomber fleet — so Step 2 replaces it with two fleet tests. - [ ] **Step 2: Update `carnage.test.ts`** -Keep the existing combined-score targeting tests and the P4c.1 bomber-bias tests. Append: +Keep the five targeting/propaganda tests and three of the four P4c.1 tests. Inside `describe('Carnage AI bomber bias (P4c.1)')`, **delete** the `'does NOT build a second bomber when one is already owned'` test and add the two fleet tests in its place. Then append the new top-level `describe('Carnage aggression (P4c.2)')` block. ```ts import { planCarnage } from '../../../src/engine/ai/carnage'; +// --- Inside describe('Carnage AI bomber bias (P4c.1)'), REPLACING the deleted +// 'does NOT build a second bomber' test: --- + + it('builds toward a 3-bomber fleet (P4c.2 supersedes the single-shot rule)', () => { + const s = initialState({ cast: ['carnage', 'chump'], difficulty: 'normal', seed: 'carnage-fleet' }); + s.leaders.carnage.stockpile.bombers = 1; + s.leaders.carnage.ap = 10; + const orders = planCarnage(s, 'carnage'); + // 1 owned + 2 built = fleet of 3. + expect(orders.filter((o) => o.kind === 'build-bomber')).toHaveLength(2); + }); + + it('does not exceed the 3-bomber fleet cap', () => { + const s = initialState({ cast: ['carnage', 'chump'], difficulty: 'normal', seed: 'carnage-fleet-cap' }); + s.leaders.carnage.stockpile.bombers = 3; + s.leaders.carnage.ap = 10; + const orders = planCarnage(s, 'carnage'); + expect(orders.filter((o) => o.kind === 'build-bomber')).toHaveLength(0); + }); + +// --- New top-level describe block: --- + describe('Carnage aggression (P4c.2)', () => { it('fires a multi-launch salvo when armed', () => { const s = initialState({ cast: ['carnage', 'chump'], difficulty: 'normal', seed: 'ca1' }); - s.leaders.carnage.stockpile.bombers = 1; - s.leaders.carnage.stockpile.missiles = 2; + s.leaders.carnage.stockpile.bombers = 3; s.leaders.carnage.stockpile.warheadsSmall = 3; s.leaders.carnage.ap = 12; const orders = planCarnage(s, 'carnage'); expect(orders.filter((o) => o.kind === 'launch').length).toBeGreaterThanOrEqual(2); }); - it('still builds exactly one bomber when none owned (P4c.1 bias preserved)', () => { + it('still builds bombers (not missiles) when no delivery owned — P4c.1 bias preserved', () => { const s = initialState({ cast: ['carnage', 'chump'], difficulty: 'normal', seed: 'ca2' }); s.leaders.carnage.stockpile.bombers = 0; s.leaders.carnage.ap = 10; const orders = planCarnage(s, 'carnage'); - expect(orders.filter((o) => o.kind === 'build-bomber')).toHaveLength(1); + expect(orders.some((o) => o.kind === 'build-bomber')).toBe(true); + expect(orders.some((o) => o.kind === 'build-missile')).toBe(false); }); }); ``` @@ -1080,8 +1120,11 @@ import { buildToward, launchSalvo, type BuildPlanEntry } from './aggression'; const PROPAGANDA_COST = 1; const CARNAGE_MAX_LAUNCHES = 3; +// P4c.2 supersedes P4c.1's single-shot bomber rule: Carnage builds a small +// REUSABLE bomber fleet so his moderate-cap salvo has real multi-launch. +// Bombers return on impact (P4c.1 rule), so 3 bombers = 3 launches/round. const CARNAGE_BUILD_PLAN: BuildPlanEntry[] = [ - { build: { item: 'bomber' }, target: 1 }, + { build: { item: 'bomber' }, target: 3 }, { build: { item: 'warhead', yield: 'small' }, target: 4 }, { build: { item: 'warhead', yield: 'medium' }, target: 2 }, ]; @@ -1160,17 +1203,19 @@ git commit -m "engine: Carnage rework — multi-launch salvo, P4c.1 bomber bias Adds the opportunism finish path: Starmless now launches when retaliating **or** when a finishable (low-population) opponent exists. Low launch cap. Keeps factory/defence building, deploy logic, scapegoat roll. +**Confidence: 92%** — full rewrite given; all five existing tests traced against the new code at plan-write time. The confidence-lift pass found the build plan had no delivery vehicle (would recreate the zero-fire bug); fixed by adding a `missile` entry, with `factory` target 7 so the factory-bias test still holds on a 5-AP round. + **Files:** - Modify: `src/engine/ai/starmless.ts` - Modify: `tests/engine/ai/starmless.test.ts` -- [ ] **Step 1: Read the existing test file** +- [ ] **Step 1: Confirm existing tests survive the rework** -Read `tests/engine/ai/starmless.test.ts`. Retaliation-targeting and scapegoat-roll tests survive. The new behaviour adds: Starmless launches at a low-population opponent even with no prior attack on him. +`tests/engine/ai/starmless.test.ts` has five tests — factory-bias in non-retaliation, primary-attacker targeting (scapegoat roll fails), scapegoat targeting, propaganda-only-at-attackers, no-propaganda-without-attacker. All five pass unchanged against the new planner code below (verified at plan-write time — `factory` stays first in the build plan so the factory-bias test holds; the retaliation + scapegoat path is preserved verbatim). Keep all five. - [ ] **Step 2: Update `starmless.test.ts`** -Keep retaliation + scapegoat tests. Append: +Keep all five existing tests unchanged. Append: ```ts import { planStarmless } from '../../../src/engine/ai/starmless'; @@ -1230,10 +1275,15 @@ 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: 8 }, - { build: { item: 'defence', type: 'shield' }, target: 2 }, + { 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[] { @@ -1352,17 +1402,19 @@ git commit -m "engine: Starmless rework — new kill instinct, multi-launch unde Routes Chump's existing opportunism-launch path through `launchSalvo` with a low cap. Heavy defence build + deploy, propaganda, and wooing-suppression preserved. +**Confidence: 92%** — full rewrite given; all five existing tests traced against the new code at plan-write time. The confidence-lift pass found the build plan had no delivery vehicle; fixed by adding a `missile` entry after defence. + **Files:** - Modify: `src/engine/ai/chump.ts` - Modify: `tests/engine/ai/chump.test.ts` -- [ ] **Step 1: Read the existing test file** +- [ ] **Step 1: Confirm existing tests survive the rework** -Read `tests/engine/ai/chump.test.ts`. The wooing-suppression rule (never launch at a leader with `favourability[t] > 0`), the weak-target selection, and infra-vs-people targeting all survive. Defence-building tests survive. +`tests/engine/ai/chump.test.ts` has five tests — defence/warhead build bias, wooing-suppression (no launch at a wooer), launch-at-weak-targets, infra targeting, propaganda-when-AP-allows. All five pass unchanged against the new planner code below (verified at plan-write time). Keep all five. - [ ] **Step 2: Update `chump.test.ts`** -Keep the existing tests. Append: +Keep all five existing tests unchanged. Append: ```ts import { planChump } from '../../../src/engine/ai/chump'; @@ -1422,8 +1474,11 @@ const PROPAGANDA_COST = 1; const DEPLOY_COST = 4; const CHUMP_MAX_LAUNCHES = 2; +// The missile entry is mandatory — a plan with warheads but no delivery +// vehicle would recreate the zero-fire bug. Defence stays first (coward). const CHUMP_BUILD_PLAN: BuildPlanEntry[] = [ { build: { item: 'defence', type: 'shield' }, target: 3 }, + { build: { item: 'missile' }, target: 2 }, { build: { item: 'warhead', yield: 'small' }, target: 4 }, ]; @@ -1510,6 +1565,8 @@ git commit -m "engine: Chump rework — capped opportunistic salvo, defence pres Replaces the duel test's "no crash" assertion with a termination assertion: every seeded all-AI game reaches an outcome within a round cap. This is the success criterion for the whole slice. +**Confidence: 80% — surfaced for user attention.** Writing the test is trivial (~95%), but whether the assertion *passes* is empirically unknown until the reworked AIs are run — it cannot be lifted above 90% by plan edits alone. The unfixable-by-planning risk: if aggressive elimination-only games stall, T10 Step 2's diagnose-and-tune procedure absorbs it, but tuning could expand scope. This is the spec's documented real concern (§6.1.1). See the "Confidence summary" section below. + **Files:** - Modify: `tests/engine/ai-duel.test.ts` - Modify: `README.md` @@ -1605,6 +1662,30 @@ git commit -m "test: AI-duel termination assertion; docs: P4c slice 2 status" --- +## Confidence summary + +Per-task confidence after the confidence-lift pass (read the existing planner test files, traced every existing test against the new code, fixed the build-plan bugs): + +| Task | Confidence | Note | +|------|-----------|------| +| T1 — remove dominance | 95% | pure deletion, blast radius grepped | +| T2 — `buildToward` | 95% | new pure fn, signatures verified | +| T3 — `launchSalvo` | 93% | new pure fn, projection logic pinned by test | +| T4 — Netanyahoo | 94% | existing tests traced, pass unchanged | +| T5 — Khameneverhere | 94% | existing tests traced, pass unchanged | +| T6 — Mileigh-hem | 93% | existing tests traced, pass unchanged | +| T7 — Carnage | 92% | bomber-fleet fix applied; one P4c.1 test replaced | +| T8 — Starmless | 92% | missing-delivery build-plan bug fixed | +| T9 — Chump | 92% | missing-delivery build-plan bug fixed | +| **T10 — duel termination** | **80%** | **surfaced — see below** | + +**Bugs the confidence-lift pass surfaced and fixed in this plan (before execution):** +1. **Starmless and Chump build plans had no missile/bomber entry** — they would have built warheads with no delivery vehicle, recreating the exact zero-fire bug this slice exists to fix. Fixed: a `missile` entry added to both `STARMLESS_BUILD_PLAN` and `CHUMP_BUILD_PLAN`. +2. **Carnage's plan built only 1 bomber** — with no missile builds, that is exactly one launch per round, contradicting the "multi-launch" design. Fixed: `bomber` target raised to 3 (a reusable fleet); the superseded P4c.1 test is explicitly replaced in T7. +3. **T1 Step 2 had contradictory wording** ("Expected: FAIL … should still PASS"). Fixed: rewritten as a coherent green-throughout removal step. + +**T10 — the one task that cannot be lifted above 90% by planning.** Writing the termination assertion is trivial; whether it *passes* depends on the empirical behaviour of six reworked AIs in elimination-only games, which is unknowable until run. The mitigation (diagnose-and-tune) is embedded in T10 Step 2, and the spec flagged this as real concern §6.1.1. **Decision point for the user:** proceed with T10 as written (tune-if-it-fails inside this slice), or treat a stall as a scope boundary and split duel-tuning into a follow-up slice. Recommended: proceed as written — the defence AP economy makes a true stall unlikely, and the embedded procedure bounds the work. + ## Notes on test count P4c.1 baseline is 255. This slice removes ~6 dominance-related tests (Task 1) and adds ~17 helper tests (Tasks 2–3) plus ~2–4 behavioural tests per planner (Tasks 4–9) and reworks the duel test. The exact final count is whatever the suite reports — every task ends with `npm test -- --run` green. Do not hard-code a target number; assert green, not a count. From 21869dfd72803aaea2962215a9f967833ffc390d Mon Sep 17 00:00:00 2001 From: gethi Date: Sun, 17 May 2026 17:11:15 +0100 Subject: [PATCH 4/7] ui: group repeated build events and sum impacts per attacker on the events screen Co-Authored-By: Claude Opus 4.7 (1M context) --- src/ui/components/EventCard.tsx | 22 +++++--- src/ui/screens/Action.tsx | 5 +- src/ui/util/eventGrouping.ts | 88 ++++++++++++++++++++++++++++++ tests/ui/eventGrouping.test.ts | 96 +++++++++++++++++++++++++++++++++ 4 files changed, 204 insertions(+), 7 deletions(-) create mode 100644 src/ui/util/eventGrouping.ts create mode 100644 tests/ui/eventGrouping.test.ts diff --git a/src/ui/components/EventCard.tsx b/src/ui/components/EventCard.tsx index 6e75388..5090a2e 100644 --- a/src/ui/components/EventCard.tsx +++ b/src/ui/components/EventCard.tsx @@ -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 ; - const result = formatEventCard(event, game); + const result = formatEventCard(event, game, count); if (!result) return null; const { icon, body, className, quote } = result; return ( @@ -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': diff --git a/src/ui/screens/Action.tsx b/src/ui/screens/Action.tsx index a2ecd6e..1ada479 100644 --- a/src/ui/screens/Action.tsx +++ b/src/ui/screens/Action.tsx @@ -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'; @@ -81,7 +82,9 @@ export default function Action({ state, dispatch }: ScreenProps) { return (

{PHASE_LABELS[phase]}

- {events.map((e, i) => )} + {groupPhaseEvents(events).map((g, i) => ( + + ))}
); })} diff --git a/src/ui/util/eventGrouping.ts b/src/ui/util/eventGrouping.ts new file mode 100644 index 0000000..ffe28d4 --- /dev/null +++ b/src/ui/util/eventGrouping.ts @@ -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(); + const infraSum = new Map(); + const buildCount = new Map(); + 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(); + const seenInfra = new Set(); + const seenBuild = new Set(); + 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; +} diff --git a/tests/ui/eventGrouping.test.ts b/tests/ui/eventGrouping.test.ts new file mode 100644 index 0000000..9ce1fc7 --- /dev/null +++ b/tests/ui/eventGrouping.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect } from 'vitest'; +import { groupPhaseEvents } from '../../src/ui/util/eventGrouping'; +import type { ResolutionEvent } from '../../src/engine/types'; + +describe('groupPhaseEvents', () => { + it('collapses consecutive identical build events into one with a count', () => { + const events: ResolutionEvent[] = [ + { kind: 'WarheadBuilt', by: 'chump', yield: 'small' }, + { kind: 'WarheadBuilt', by: 'chump', yield: 'small' }, + { kind: 'WarheadBuilt', by: 'chump', yield: 'small' }, + ]; + const g = groupPhaseEvents(events); + expect(g).toHaveLength(1); + expect(g[0].count).toBe(3); + expect(g[0].event.kind).toBe('WarheadBuilt'); + }); + + it('does not collapse builds of a different item or a different leader', () => { + const events: ResolutionEvent[] = [ + { kind: 'WarheadBuilt', by: 'chump', yield: 'small' }, + { kind: 'WarheadBuilt', by: 'chump', yield: 'large' }, + { kind: 'WarheadBuilt', by: 'carnage', yield: 'small' }, + ]; + const g = groupPhaseEvents(events); + expect(g).toHaveLength(3); + expect(g.every((x) => x.count === 1)).toBe(true); + }); + + it('collapses a leader\'s same-item builds even when interleaved with another item', () => { + // The human's build clicks arrive interleaved (missile, bomber, missile…), + // unlike the AI's item-grouped buildToward output. + const events: ResolutionEvent[] = [ + { kind: 'DeliveryBuilt', by: 'player1', type: 'missile' }, + { kind: 'DeliveryBuilt', by: 'player1', type: 'bomber' }, + { kind: 'DeliveryBuilt', by: 'player1', type: 'missile' }, + { kind: 'DeliveryBuilt', by: 'player1', type: 'bomber' }, + ]; + const g = groupPhaseEvents(events); + expect(g).toHaveLength(2); + const missiles = g.find((x) => x.event.kind === 'DeliveryBuilt' && x.event.type === 'missile'); + const bombers = g.find((x) => x.event.kind === 'DeliveryBuilt' && x.event.type === 'bomber'); + expect(missiles?.count).toBe(2); + expect(bombers?.count).toBe(2); + }); + + it('sums people deaths per (target, attacker) into one event', () => { + const events: ResolutionEvent[] = [ + { kind: 'ImpactPeople', from: 'netanyahoo', target: 'carnage', warhead: 'small', deaths: 2 }, + { kind: 'ImpactPeople', from: 'netanyahoo', target: 'carnage', warhead: 'small', deaths: 2 }, + { kind: 'ImpactPeople', from: 'netanyahoo', target: 'carnage', warhead: 'small', deaths: 2 }, + ]; + const g = groupPhaseEvents(events); + expect(g).toHaveLength(1); + const e = g[0].event; + expect(e.kind === 'ImpactPeople' && e.deaths).toBe(6); + }); + + it('keeps separate impact lines for different attackers on the same target', () => { + const events: ResolutionEvent[] = [ + { kind: 'ImpactPeople', from: 'netanyahoo', target: 'carnage', warhead: 'small', deaths: 2 }, + { kind: 'ImpactPeople', from: 'chump', target: 'carnage', warhead: 'small', deaths: 3 }, + ]; + const g = groupPhaseEvents(events); + expect(g).toHaveLength(2); + }); + + it('sums infrastructure impacts per (target, attacker)', () => { + const events: ResolutionEvent[] = [ + { kind: 'ImpactInfrastructure', from: 'chump', target: 'carnage', warhead: 'small', factoriesDestroyed: 1 }, + { kind: 'ImpactInfrastructure', from: 'chump', target: 'carnage', warhead: 'small', factoriesDestroyed: 2 }, + ]; + const g = groupPhaseEvents(events); + expect(g).toHaveLength(1); + const e = g[0].event; + expect(e.kind === 'ImpactInfrastructure' && e.factoriesDestroyed).toBe(3); + }); + + it('does not mutate the input events', () => { + const events: ResolutionEvent[] = [ + { kind: 'ImpactPeople', from: 'chump', target: 'carnage', warhead: 'small', deaths: 2 }, + { kind: 'ImpactPeople', from: 'chump', target: 'carnage', warhead: 'small', deaths: 4 }, + ]; + const snapshot = JSON.stringify(events); + groupPhaseEvents(events); + expect(JSON.stringify(events)).toBe(snapshot); + }); + + it('passes non-grouped events through unchanged with count 1', () => { + const events: ResolutionEvent[] = [ + { kind: 'MissileLaunched', from: 'chump', to: 'carnage', delivery: 'missile', warhead: 'small', targetType: 'people' }, + ]; + const g = groupPhaseEvents(events); + expect(g).toHaveLength(1); + expect(g[0].count).toBe(1); + }); +}); From 3b51b6fe865d79a5ee6cd824b84ccc8ea606bfda Mon Sep 17 00:00:00 2001 From: gethi Date: Sun, 17 May 2026 17:11:20 +0100 Subject: [PATCH 5/7] ui: retarget queued launches when a target's people/infra toggle flips Co-Authored-By: Claude Opus 4.7 (1M context) --- src/ui/screens/Planning.tsx | 9 ++++++- tests/ui/Planning.targetRow.test.tsx | 36 +++++++++++++++++++++++++++- 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/src/ui/screens/Planning.tsx b/src/ui/screens/Planning.tsx index 0ef8e4d..1fb8552 100644 --- a/src/ui/screens/Planning.tsx +++ b/src/ui/screens/Planning.tsx @@ -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} diff --git a/tests/ui/Planning.targetRow.test.tsx b/tests/ui/Planning.targetRow.test.tsx index 8e23390..aa32bd1 100644 --- a/tests/ui/Planning.targetRow.test.tsx +++ b/tests/ui/Planning.targetRow.test.tsx @@ -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({ @@ -38,4 +39,37 @@ describe('', () => { // 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(); + + 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'); + }); }); From 29da11037d47b9959c66c2e725ae026edfcadd92 Mon Sep 17 00:00:00 2001 From: gethi Date: Sun, 17 May 2026 17:11:27 +0100 Subject: [PATCH 6/7] engine: cap Disparage cameo at one per round Co-Authored-By: Claude Opus 4.7 (1M context) --- src/engine/resolution.ts | 8 +++++++- tests/engine/resolution.test.ts | 35 +++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/src/engine/resolution.ts b/src/engine/resolution.ts index 456abbf..27ecc7b 100644 --- a/src/engine/resolution.ts +++ b/src/engine/resolution.ts @@ -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) { @@ -137,6 +142,7 @@ export function resolveRound(state: GameState): ResolveResult { afterImpact: { from: e.from, to: e.target }, quote: linePick.line, }); + cameoEmitted = true; } } } diff --git a/tests/engine/resolution.test.ts b/tests/engine/resolution.test.ts index 70af78f..4d02e3d 100644 --- a/tests/engine/resolution.test.ts +++ b/tests/engine/resolution.test.ts @@ -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']) { From 1c29086adbf610d93f377c33663413e959fa0d99 Mon Sep 17 00:00:00 2001 From: gethi Date: Sun, 17 May 2026 17:11:35 +0100 Subject: [PATCH 7/7] =?UTF-8?q?ui:=20RoundSummary=20=E2=80=94=20sum=20stri?= =?UTF-8?q?kes=20in=20subhead,=20show=20population/factory=20counts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- src/ui/screens/RoundSummary.module.css | 1 + src/ui/screens/RoundSummary.tsx | 23 ++++++++++++++------ tests/ui/roundSummary.test.ts | 30 ++++++++++++++++++++++++++ 3 files changed, 48 insertions(+), 6 deletions(-) create mode 100644 tests/ui/roundSummary.test.ts diff --git a/src/ui/screens/RoundSummary.module.css b/src/ui/screens/RoundSummary.module.css index bc9aeec..9e1f900 100644 --- a/src/ui/screens/RoundSummary.module.css +++ b/src/ui/screens/RoundSummary.module.css @@ -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; } diff --git a/src/ui/screens/RoundSummary.tsx b/src/ui/screens/RoundSummary.tsx index 5bdca17..f9ed70a 100644 --- a/src/ui/screens/RoundSummary.tsx +++ b/src/ui/screens/RoundSummary.tsx @@ -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 => - 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(); + 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.`; } @@ -91,6 +101,7 @@ export default function RoundSummary({ state, dispatch }: ScreenProps) {
{leader.country.split(' ')[0]} {leader.name} + 👥 {leader.population}M · 🏭 {leader.factories} {delta >= 0 ? `+${delta}` : delta}M {quote && "{quote}"}
diff --git a/tests/ui/roundSummary.test.ts b/tests/ui/roundSummary.test.ts new file mode 100644 index 0000000..a768144 --- /dev/null +++ b/tests/ui/roundSummary.test.ts @@ -0,0 +1,30 @@ +import { describe, it, expect } from 'vitest'; +import { pickSubhead } from '../../src/ui/screens/RoundSummary'; +import { initialState } from '../../src/engine/state'; +import type { ResolutionEvent } from '../../src/engine/types'; + +describe('pickSubhead', () => { + const game = initialState({ cast: ['player1', 'carnage'], difficulty: 'normal', seed: 'rs' }); + + it('sums multiple strikes from one attacker on one target', () => { + const events: ResolutionEvent[] = [ + { kind: 'ImpactPeople', from: 'player1', target: 'carnage', warhead: 'large', deaths: 6 }, + { kind: 'ImpactPeople', from: 'player1', target: 'carnage', warhead: 'large', deaths: 6 }, + ]; + expect(pickSubhead(events, game.leaders)).toContain('for 12M'); + }); + + it('names the biggest attacker→target pairing by summed deaths', () => { + const events: ResolutionEvent[] = [ + { kind: 'ImpactPeople', from: 'player1', target: 'carnage', warhead: 'small', deaths: 4 }, + { kind: 'ImpactPeople', from: 'player1', target: 'carnage', warhead: 'small', deaths: 4 }, + { kind: 'ImpactPeople', from: 'carnage', target: 'player1', warhead: 'large', deaths: 7 }, + ]; + // player1→carnage totals 8M, beating carnage→player1's single 7M. + expect(pickSubhead(events, game.leaders)).toContain('for 8M'); + }); + + it('reports no casualties when there are no people impacts', () => { + expect(pickSubhead([], game.leaders)).toBe('No casualties this round.'); + }); +});