From 9ee96a7eeb5112760513c41aea483fa2bbb80c77 Mon Sep 17 00:00:00 2001 From: gethi Date: Wed, 13 May 2026 16:47:23 +0100 Subject: [PATCH 01/24] docs: P4b (Balance & Planning Rework) design spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four playtest-driven changes: - Double AP pool (startAp ×2, factory rate 0.5→1.0, bank cap 2→4) - Defences become consumable: build 4 + deploy 4 = 8 AP all-in, round-scoped deployed pool, always consumed at round end - Planning UI rewrites as action-card grid with per-target rows, 6 launch combos (yield × delivery), per-yield warhead inventory - Woo + Propaganda flat 1 AP toggles, one per target per round (drops the points dimension on woo) Phase order shift: this is new P4b. Original P4b (AI scoring-weight tuning + Approach B/C lookahead) slips to P4c — AI tuning can't sensibly happen until the rules stop moving. --- ...phase-4b-balance-planning-rework-design.md | 533 ++++++++++++++++++ 1 file changed, 533 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-13-phase-4b-balance-planning-rework-design.md diff --git a/docs/superpowers/specs/2026-05-13-phase-4b-balance-planning-rework-design.md b/docs/superpowers/specs/2026-05-13-phase-4b-balance-planning-rework-design.md new file mode 100644 index 0000000..1fe3283 --- /dev/null +++ b/docs/superpowers/specs/2026-05-13-phase-4b-balance-planning-rework-design.md @@ -0,0 +1,533 @@ +# Phase 4b — Balance & Planning Rework design spec + +**Date:** 2026-05-13 +**Status:** drafted from playtesting brainstorming session; pending user review +**Source of feedback:** playtesting after P4a merge (commit `9c5ac27`). User notes captured in better-memory: AP too tight, defences should be consumable, Planning UI too clunky, weapon costs need to drop. + +--- + +## 1. Overview + +Phase 4b reworks the game's economy and the Planning screen. Four interlocking changes: + +1. **AP economy doubles**: bigger pool, same costs. Players have real slack to build, attack, woo, and defend in the same round. +2. **Defences become consumable**: shields and AA must be built AND deployed; consumed at round end. Build 4 + Deploy 4 = 8 AP all-in. Defence is a serious commitment, not spam. +3. **Planning screen rewrite**: action-card grid driven by projected inventory. Replaces the order-kind dropdown + queue list with a single tableau showing all valid actions, including per-target launches with mixed yields and deliveries. +4. **Woo + Propaganda simplified**: both flat 1 AP toggles, one per target per round. Drops the points dimension on woo. + +**Phase order shift:** original P4b was AI scoring-weight tuning + Approach B/C lookahead upgrades. That work cannot sensibly happen until the rules stop moving, so it slips to P4c. New phase order: + +``` +P3 ✓ (UI shell) → P4a ✓ (satire+hotseat) → P4b (this) → P4c (AI tuning) → P5 (polish) +``` + +--- + +## 2. Scope (4 workstreams) + +### 2.1 AP economy — double the pool, keep costs + +- `LEADER_PROFILES[*].startAp` doubled across the board: + - chump: 5 → 10 + - khameneverhere / starmless / carnage / netanyahoo / player1..5: 3 → 6 + - mileigh-hem: 2 → 4 +- `FACTORY_AP_RATE`: 0.5 → **1.0** (one AP per factory per round) +- `AP_BANK_CAP`: 2 → **4** +- `ACTION_COSTS` for builds + launches **unchanged** (mathematically equivalent to "halve costs" with the doubled pool — no fractions, simplest code change) + +### 2.2 Consumable defences + +- `ACTION_COSTS.buildDefence`: 2 → **4** +- New `ACTION_COSTS.deployDefence: 4` +- New order kind: `{ kind: 'deploy-defence'; type: DefenceType }` — moves one shield/AA from inventory to round-scoped deployed pool +- `Leader` gains `deployedShields: number` and `deployedAA: number` — both round-scoped (cleared at end of every resolution) +- `interceptProbability` now reads from `receiver.deployedShields` / `receiver.deployedAA` (not `stockpile.shields/aa`) +- **Lifecycle:** deploy IS consumption. A deployed defence is consumed at round end regardless of whether it intercepted. Misjudging incoming wastes 4 AP. +- **Undeployed inventory persists** across rounds — players can stockpile defences freely until they choose to deploy. + +### 2.3 Planning UI rework — action-card grid (Variant B v9) + +Replace the order-kind dropdown + OrderForm + queue list with a single grid: + +- **Build grid** (3 cols × 2 rows): Factory, Missile, Bomber, Sm/Md/Lg Warhead. Each cell is a stepper bound to a count of that order kind. +- **Defence grid** (3 cols × 1 row): Build Shield, Deploy Shield, Build AA, Deploy AA. Deploy cells show "N owned" and max-out at stockpile. +- **Actions-by-target rows**, one per opponent. Each row contains: + - Header: flag + name + people/infra toggle. AI mood quote (italic) under the name. + - Diplomacy strip: 💌 Woo and 📰 Propaganda — both flat 1 AP toggles. + - Missile launch grid (3 cells): one per yield (small/med/big). Cell shows 💥 + size word + "N left" warhead inventory + stepper. + - Bomber launch grid (3 cells): same shape. "✈️ bombers · 0 left" label muted when no bomber inventory. +- **AP banner** at top shows used / total / left. +- **Soft-warn panel** moved to under the AP banner; same 3 warnings as P4a (warhead-no-delivery, delivery-no-warhead, woo-non-attacker), now consolidated rather than per-row. + +Layout target: 460px centred column, browser-first (matches P3/P4a). + +### 2.4 Woo + Propaganda flat toggles + +- `Order` union: `{ kind: 'woo'; target: LeaderId }` (drops `points`) +- `ACTION_COSTS.wooPerPoint` renamed to `woo`, stays flat 1 AP +- `validateOrderSequence` enforces **one woo per target** and **one propaganda per target** per round +- `applyWooing` simplifies: each woo order = single favourability transfer of fixed magnitude. P4c tunes the magnitude. +- AI personalities that emit woo orders drop the `points` field; otherwise unchanged. + +--- + +## 3. Round flow + +Unchanged from P4a structurally: + +``` +Defences (phase) → Builds → Propaganda → Wooing → Launches → FR cascade → Status update +``` + +Defence phase becomes a **two-stage** subprocess: + +``` +Defence phase: + Stage 1: process all `build-defence` orders → stockpile += 1 + Stage 2: process all `deploy-defence` orders → stockpile -= 1, deployedPool += 1 +``` + +A leader can build AND deploy in the same round (8 AP, two separate orders). + +After Final Retaliation cascade, at end of round, **clear the deployed pool** for every leader (consumed) and emit `DefenceConsumed` events. + +--- + +## 4. Engine schema changes + +### 4.1 `balance.ts` + +```ts +LEADER_PROFILES[*].startAp: + chump 5 → 10 + khameneverhere/starmless/carnage/netanyahoo/players 3 → 6 + mileigh-hem 2 → 4 + +FACTORY_AP_RATE 0.5 → 1.0 +AP_BANK_CAP 2 → 4 + +ACTION_COSTS = { + buildFactory: 3, // unchanged + buildMissile: 1, // unchanged + buildBomber: 1, // unchanged + buildWarheadSmall: 1, // unchanged + buildWarheadMedium: 2, // unchanged + buildWarheadLarge: 3, // unchanged + buildDefence: 4, // ← was 2 + deployDefence: 4, // ← NEW + launch: 2, // unchanged + propaganda: 1, // unchanged + woo: 1, // ← was wooPerPoint +} +``` + +### 4.2 `types.ts` + +**`Order` union changes:** + +```ts +| { kind: 'woo'; target: LeaderId } // ← was { ..., points: number } +| { kind: 'deploy-defence'; type: DefenceType } // ← NEW +``` + +**`Leader` gains:** + +```ts +interface Leader { + // ... existing + deployedShields: number; // round-scoped; cleared at end of resolution + deployedAA: number; +} +``` + +**`ResolutionEvent` union gains:** + +```ts +| { kind: 'DefenceDeployed'; by: LeaderId; type: DefenceType; quote?: string } +| { kind: 'DefenceConsumed'; by: LeaderId; type: DefenceType } +``` + +### 4.3 `state.ts` + +`initialState` seeds `deployedShields: 0, deployedAA: 0` on every leader. No other changes. + +### 4.4 `launches.ts` — intercept rewiring + +```ts +// OLD: +const defenders = l.delivery === 'missile' + ? receiver.stockpile.shields + : receiver.stockpile.aa; + +// NEW: +const defenders = l.delivery === 'missile' + ? receiver.deployedShields + : receiver.deployedAA; + +// When an intercept fires (existing path): +if (roll.value < p) { + // decrement deployed pool + if (l.delivery === 'missile') receiver.deployedShields -= 1; + else receiver.deployedAA -= 1; + events.push({ kind: 'MissileIntercepted', ... }); + continue; +} +``` + +### 4.5 `builds.ts` — defence handling + +`applyDefenceBuilds` now handles both order kinds: +- `build-defence`: `me.stockpile.shields/aa += 1`, emit `DefenceBuilt` (existing) +- `deploy-defence`: `me.stockpile.shields/aa -= 1; me.deployedShields/deployedAA += 1`, emit `DefenceDeployed` + +Validation: `deploy-defence` order is invalid if the leader's projected stockpile (post-build) has 0 of that defence type. + +Ordering rule: within the defence phase, all `build-defence` orders process first, then all `deploy-defence`. This enables build-then-deploy in one round. + +### 4.6 `resolution.ts` — deployed pool clear + +After the Final Retaliation cascade and after grudge updates, before AP refresh, clear the deployed pool for every leader: + +```ts +for (const id of s.cast) { + const l = s.leaders[id]; + if (l.deployedShields > 0) { + events.push({ kind: 'DefenceConsumed', by: id, type: 'shield' }); + } + if (l.deployedAA > 0) { + events.push({ kind: 'DefenceConsumed', by: id, type: 'aa' }); + } + l.deployedShields = 0; + l.deployedAA = 0; +} +``` + +### 4.7 `orders.ts` + +- `apCostOf`: `woo` returns `ACTION_COSTS.woo` (flat); new `deploy-defence` returns `ACTION_COSTS.deployDefence`. +- `validateOrder`: rejects `deploy-defence` when leader's stockpile of that type is 0. +- `validateOrderSequence` projection: **extended to include queued builds** (currently projects only launch consumption). This lets the UI offer build-then-launch and build-then-deploy in the same round consistently with engine semantics. Also enforces one-woo-per-target and one-propaganda-per-target per round. +- `analyseOrderSequence` (soft-warn): three rules unchanged in spirit, but the projection now correctly accounts for queued builds. + +### 4.8 `ai/*.ts` — personality fallout + +- Every personality's `woo` emission drops the `points` field. +- Each personality that currently builds defences (Chump, Starmless) gains a **simple deploy rule**: "if I own ≥ 1 shield, queue one `deploy-defence` order; else queue one `build-defence` order". No threat-reading. P4c tunes. +- **Mileigh-hem aggression bonus** (`+2 AP if every order is launch or propaganda`): `deploy-defence` counts as defensive, breaks the bonus. Already locked in design — matches glass-cannon personality. Implementation: `leaderBonusAp` in `resolution.ts` checks `thisRoundsOrders.every(o => o.kind === 'launch' || o.kind === 'propaganda')`. Adding `deploy-defence` to that order set causes the `every` check to fail — no code change needed; it's automatic from the existing rule. + +--- + +## 5. UI changes + +### 5.1 New components + +``` +src/ui/components/BuildGrid.tsx + .module.css +src/ui/components/DefenceGrid.tsx + .module.css +src/ui/components/TargetRow.tsx + .module.css +src/ui/components/LaunchCell.tsx + .module.css +``` + +### 5.2 Modified + +``` +src/ui/screens/Planning.tsx — full rewrite (action-card grid) +src/ui/screens/Planning.module.css — new layout styles +src/ui/components/SoftWarnPanel.tsx — repositioned under AP banner; otherwise unchanged +``` + +### 5.3 Retired (delete) + +``` +src/ui/components/OrderForm.tsx +src/ui/components/OrderForm.module.css +src/ui/components/LeaderCard.tsx — orphaned; can resurrect via git later +src/ui/components/LeaderCard.module.css +tests/ui/OrderForm.test.tsx +``` + +### 5.4 `Planning.tsx` data flow + +```ts +function Planning({ state, dispatch }) { + const game = state.game!; + const activeId = state.activeHumanTurn ?? 'player1'; + const player = game.leaders[activeId]; + + const [orders, setOrders] = useState([]); + const [targetTypes, setTargetTypes] = useState>({}); + + const projection = projectInventory(player, orders); + // { missiles, bombers, warheadsSmall, warheadsMedium, warheadsLarge, + // shieldsInStockpile, aaInStockpile, deployedShields, deployedAA } + + const apUsed = totalApCost(orders); + const softWarnings = analyseOrderSequence(game, activeId, orders); + const moodByLeader = buildMoodLookup(state.events); +} +``` + +### 5.5 Layout structure (460px column) + +```jsx +
+ + + + +Actions by target +{aiLeaders.map(id => ( + +))} + dispatch({type:'PLAYER_SUBMIT', leaderId: activeId, orders})} /> +``` + +### 5.6 Cell-level projection rules + +- **Build cell `+`** enables when `ap_remaining >= cost` (no projection — builds are always permissible) +- **Launch cell `+`** enables when `projection.delivery_count > 0 && projection.warhead_of_yield_count > 0 && ap_remaining >= launchCost` +- **Deploy cell `+`** enables when `projection.shields_or_aa_in_stockpile > 0 && ap_remaining >= deployCost` +- **Woo / Propaganda toggle** enables when no existing order of that kind/target exists + +### 5.7 Mood lines + +Each `TargetRow` reads the most recent `PreRoundMood.quote` for that leader from `state.events`. Renders as italic line under the target name (`Chump · "Many people are saying we should hit somebody..."`). Empty mood quote = no line rendered. + +### 5.8 Helper module + +``` +src/ui/util/projection.ts + ↳ projectInventory(leader, orders): ProjectedInventory + Walks the orders array, adding queued builds and subtracting queued + launches / deploys. Returns post-build / pre-launch counts for all + inventory dimensions. +``` + +### 5.9 Action-screen knock-on + +`src/ui/components/EventCard.tsx` exhaustive switch gains two new cases: + +```ts +case 'DefenceDeployed': return { headline: `${ln(e.by)} deploys ${e.type === 'shield' ? 'a shield' : 'AA'}.`, quote: e.quote }; +case 'DefenceConsumed': return null; // not rendered on Action (round-end housekeeping event) +``` + +`Action.tsx` `phaseAdvanceFor`: `DefenceDeployed` → 'defences'; `DefenceConsumed` → `null` (housekeeping, no phase advance). + +--- + +## 6. AI personality changes + +- All AI personalities drop `points` from their woo emissions: `{ kind: 'woo', target }`. +- Chump and Starmless gain "if I own ≥ 1 shield, deploy one; else build one" logic. Mileigh-hem unchanged (never builds defence). +- All other personality logic (threat scoring, opportunism, lookahead) unchanged. +- AI-duel test (`tests/engine/ai-duel.test.ts`) keeps running. Comment updated to note the printed baseline is now stale and that P4c will rebaseline. +- `AI_SCORING_WEIGHTS` and `lookahead.ts` unchanged. + +--- + +## 7. Testing + +P4a baseline: 217 tests. Target end-state: **~251 tests**. + +### 7.1 Engine tests (new + extended) + +``` +tests/engine/deployDefence.test.ts (new, ~8 tests) + ↳ deploy-defence consumes stockpile, adds to deployed pool + ↳ build-then-deploy in one round works (stages ordering) + ↳ deploy-defence rejected when stockpile = 0 and no queued build + ↳ deployed pool clears to 0 at round end regardless of intercept + ↳ DefenceDeployed + DefenceConsumed events emitted at correct points + +tests/engine/intercept.deployed.test.ts (new, ~4 tests) + ↳ interceptProbability reads from deployedShields / deployedAA + ↳ on intercept, deployed count decrements; stockpile unchanged + ↳ shieldless target (deployed = 0) loses intercept guarantee + +tests/engine/orders.woo.test.ts (new, ~4 tests) + ↳ woo Order shape has no points + ↳ ACTION_COSTS.woo flat 1 AP applied + ↳ second woo to same target rejected by validateOrderSequence + +tests/engine/resolution.test.ts (extend) + ↳ Mileigh-hem aggression bonus breaks when a deploy-defence is queued + ↳ defence phase: all builds resolve before any deploys + +tests/engine/analyseOrderSequence.test.ts (extend, ~3 tests) + ↳ warhead-no-delivery suppressed when missile is queued in same round + ↳ delivery-no-warhead suppressed when warhead is queued + ↳ woo-non-attacker rule unchanged with no-points woo orders +``` + +### 7.2 UI tests (delete + new) + +``` +DELETE: tests/ui/OrderForm.test.tsx + +NEW: tests/ui/Planning.actionGrid.test.tsx (~6 tests) + ↳ Build cell + dispatches build order; queue increments + ↳ AP banner sums orders correctly; over-budget when exceeded + ↳ Soft-warn panel renders under AP banner when warnings present + ↳ Woo toggle one-shot per target; second tap removes + ↳ Launch cell stepper respects projection; max bounded by inventory + ↳ Inventory row label updates as launches queue across targets + +NEW: tests/ui/Planning.targetRow.test.tsx (~4 tests) + ↳ Mood quote renders under target name when PreRoundMood present + ↳ People/infra toggle updates targetType on subsequent launches + ↳ Bomber row label says "0 left" when projection.bombers = 0 + ↳ Cells disabled appropriately when arsenal can't support combo + +NEW: tests/ui/projection.test.ts (~6 tests, unit) + ↳ projectInventory adds queued builds + ↳ projectInventory subtracts queued launches + ↳ projectInventory subtracts queued deploys + ↳ projection handles empty order list + ↳ projection handles full order list with all order kinds + ↳ projection clamps at 0 (no negative inventory) +``` + +### 7.3 What is NOT tested + +- Exact AP magnitudes (the doubled values are tunable; tests assert relative behaviour) +- AI personality balance distribution (P4c) +- Visual fidelity vs mockup (manual) + +### 7.4 Engine-purity check + +Unchanged — no React under `src/engine/**`. Enforced by CI typecheck. + +--- + +## 8. Assumptions (3 buckets) + +### 8.1 Real concerns + +1. **AI feel before P4c tuning.** Doubled pool + cheaper-relative combat + new defence rules will produce a significantly more aggressive AI than P4a. AI-duel test still passes (asserts only "no crash"), but the human game experience between P4b merge and P4c may be unbalanced. Accepted: shipping rule changes ahead of tuning is the explicit phase order, and the user wants to playtest the new rules to inform P4c. + +2. **`analyseOrderSequence` projection change.** Extending projection to include queued builds may shift the soft-warn fire conditions slightly. Existing P4a tests should still pass (the logical outcome is the same), but verify during implementation. + +3. **`LeaderCard.tsx` deletion.** It's orphaned by the Planning rework. Decision: delete in this PR. Easy to resurrect from git if a P5 hover-detail or roster-summary needs it. + +### 8.2 Verified safe + +1. **Engine purity preserved** — no React imports under `src/engine/**`. +2. **Determinism preserved** — no new RNG consumption added in this phase. +3. **ResolutionEvent additions additive** — P4a's EventCard exhaustive switch needs 2 new cases; `noFallthroughCasesInSwitch` catches at compile time. +4. **CSS Modules pattern continues** — new components follow per-screen / per-component convention. +5. **TDD posture continues** — engine TDD strict; UI minimum-surface (action grid, target row, projection helper). +6. **P4a backward compatibility N/A** — no persistence to migrate. + +### 8.3 Minor / accepted + +1. **AI duel distribution baseline** stale. README + ai-duel.test.ts comment updated to flag P4c rebaselining. +2. **Mileigh-hem rarely builds defence** anyway; bonus-breaking-on-deploy is largely academic for his line. +3. **Dense Planning grid at 460px** — usable but tight; polish deferred (P5 or beyond). +4. **No save migration** needed (no persistence yet). +5. **AI defence logic in P4b is rudimentary** ("own one? deploy. else? build."). P4c does proper threat-aware tuning. + +--- + +## 9. Out of scope + +### 9.1 Deferred to P4c (AI tuning, now renumbered) + +- AI scoring-weight balance pass against the new rules baseline. +- Approach B / C lookahead upgrades (sliding-window history; personality-fit modelling). +- Threat-aware defence deployment decisions per personality. + +### 9.2 Deferred to P5 (polish) + +- Persistence (localStorage save/load + Resume + action log) +- Replay timeline scrubber UI on Winners +- Animations (Framer Motion, missile arcs, damage badges, Fast Resolve toggle) +- Audio +- SVG art (leader portraits, world map, mushroom cloud, ruined iconography) +- PWA manifest + service worker + +### 9.3 Beyond P5 + +- Capacitor mobile wrap +- AI personality additions / cast rotation + +--- + +## 10. File list + +### 10.1 New files + +``` +src/ui/components/BuildGrid.tsx +src/ui/components/BuildGrid.module.css +src/ui/components/DefenceGrid.tsx +src/ui/components/DefenceGrid.module.css +src/ui/components/TargetRow.tsx +src/ui/components/TargetRow.module.css +src/ui/components/LaunchCell.tsx +src/ui/components/LaunchCell.module.css +src/ui/util/projection.ts + +tests/engine/deployDefence.test.ts +tests/engine/intercept.deployed.test.ts +tests/engine/orders.woo.test.ts +tests/ui/Planning.actionGrid.test.tsx +tests/ui/Planning.targetRow.test.tsx +tests/ui/projection.test.ts +``` + +### 10.2 Modified files + +``` +src/engine/balance.ts — AP values, ACTION_COSTS changes +src/engine/types.ts — Order union, Leader fields, ResolutionEvent variants +src/engine/state.ts — initialState seeds deployedShields/AA +src/engine/orders.ts — apCostOf, validateOrder, validateOrderSequence, analyseOrderSequence +src/engine/builds.ts — applyDefenceBuilds handles both kinds +src/engine/launches.ts — interceptProbability reads deployed pool; intercept decrements +src/engine/resolution.ts — defence-phase two-stage ordering; end-of-round deployed-pool clear +src/engine/ai/chump.ts — drop points from woo; deploy-or-build defence rule +src/engine/ai/starmless.ts — drop points from woo; deploy-or-build defence rule +src/engine/ai/khameneverhere.ts — drop points from woo +src/engine/ai/carnage.ts — drop points from woo +src/engine/ai/netanyahoo.ts — drop points from woo +src/engine/ai/mileighhem.ts — drop points from woo +src/ui/screens/Planning.tsx — full rewrite +src/ui/screens/Planning.module.css — new layout styles +src/ui/components/SoftWarnPanel.tsx — re-positioned, no other changes +src/ui/components/EventCard.tsx — 2 new event-kind cases +src/ui/screens/Action.tsx — phaseAdvanceFor handles new event kinds + +tests/engine/ai-duel.test.ts — comment update flagging P4c rebaseline +tests/engine/resolution.test.ts — extended for Mileigh bonus + defence phase ordering +tests/engine/analyseOrderSequence.test.ts — extended for queued-builds projection +README.md — Phase 4b status section +``` + +### 10.3 Deleted files + +``` +src/ui/components/OrderForm.tsx +src/ui/components/OrderForm.module.css +src/ui/components/LeaderCard.tsx +src/ui/components/LeaderCard.module.css +tests/ui/OrderForm.test.tsx +``` + +--- + +## 11. References + +- Original full design: `docs/superpowers/specs/2026-05-08-nuke-design.md` +- P4a (just-merged predecessor): `docs/superpowers/specs/2026-05-12-phase-4a-satire-hotseat-design.md` +- Better-memory observations from playtesting captured 2026-05-13 under `component: balance` / `component: combat` / `component: planning-ui` / `theme: user-feedback` From 209d5422788302c1dc18bb5427fd73cb3dbed96a Mon Sep 17 00:00:00 2001 From: gethi Date: Wed, 13 May 2026 17:01:23 +0100 Subject: [PATCH 02/24] docs: P4b (Balance & Planning Rework) implementation plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 14 tasks, ~85 steps, target ~251 tests. Min task confidence 91% (T13, Planning rewrite) — mitigations inline. T6 (AI personalities) split into 7 commits inside the task, one per personality. Plan path: docs/superpowers/plans/2026-05-13-phase-4b-balance-planning-rework.md --- ...-05-13-phase-4b-balance-planning-rework.md | 2354 +++++++++++++++++ 1 file changed, 2354 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-13-phase-4b-balance-planning-rework.md diff --git a/docs/superpowers/plans/2026-05-13-phase-4b-balance-planning-rework.md b/docs/superpowers/plans/2026-05-13-phase-4b-balance-planning-rework.md new file mode 100644 index 0000000..04dc45d --- /dev/null +++ b/docs/superpowers/plans/2026-05-13-phase-4b-balance-planning-rework.md @@ -0,0 +1,2354 @@ +# Phase 4b — Balance & Planning Rework 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:** Rebalance the AP economy (doubled pool), rework defences to consumable build-then-deploy mechanic, and rewrite the Planning screen as an action-card grid. Plus simplify woo/propaganda to flat 1-AP toggles. + +**Architecture:** Engine stays pure-TS. New order kind `deploy-defence`; `Order.woo` drops `points`. Leader gains round-scoped `deployedShields/deployedAA` pool. `interceptProbability` reads from deployed pool; pool clears at round end. Planning screen replaces OrderForm + queue list with `` + `` + `` (one per opponent) populated by a new `projectInventory` helper that walks the queued-orders array and computes post-build / pre-launch counts. + +**Tech Stack:** TypeScript 5.4, Vite 5, React 18, Vitest 1.5, React Testing Library, CSS Modules. No new runtime deps. + +**Source of truth:** `docs/superpowers/specs/2026-05-13-phase-4b-balance-planning-rework-design.md` (committed `9ee96a7`). If anything below conflicts with the spec, the spec wins — flag before coding. + +**Per-step confidence:** every task is rated. Lowest task confidence post-mitigation: **91 %** (Task 13 — Planning rewrite). Three tasks needed lift: T6 (cross-cutting AI changes), T13 (largest UI rewrite). Mitigations embedded inline. + +--- + +## File structure + +**New files:** + +| Path | Responsibility | +|---|---| +| `src/ui/util/projection.ts` | `projectInventory(leader, orders): ProjectedInventory` — walks queued orders, returns post-build / pre-launch counts for missiles, bombers, warheads (per yield), shields/AA in stockpile, deployed shields/AA | +| `src/ui/components/BuildGrid.tsx` + `.module.css` | 6-cell stepper grid for Factory / Missile / Bomber / Sm/Md/Lg Warhead | +| `src/ui/components/DefenceGrid.tsx` + `.module.css` | 3-cell stepper grid for Build Shield / Deploy Shield / Build AA / Deploy AA (Deploy AA hidden when 0 owned + 0 queued? No — show greyed, see spec §5.6) | +| `src/ui/components/LaunchCell.tsx` + `.module.css` | One (delivery × yield) cell with 💥 + size word + "N left" + stepper | +| `src/ui/components/TargetRow.tsx` + `.module.css` | One row per opponent: header + people/infra toggle + mood line + diplomacy strip + 2× LaunchCell rows | +| `tests/engine/deployDefence.test.ts` | New engine tests for deploy lifecycle | +| `tests/engine/intercept.deployed.test.ts` | New engine tests for deploy-pool intercept | +| `tests/engine/orders.woo.test.ts` | New engine tests for flat woo shape + one-per-target | +| `tests/ui/Planning.actionGrid.test.tsx` | New UI tests for the action-grid surface | +| `tests/ui/Planning.targetRow.test.tsx` | New UI tests for target rows + mood lines | +| `tests/ui/projection.test.ts` | Unit tests for the `projectInventory` helper | + +**Modified files:** + +| Path | Change | +|---|---| +| `src/engine/balance.ts` | startAp ×2 across the board; FACTORY_AP_RATE 0.5→1.0; AP_BANK_CAP 2→4; ACTION_COSTS adds `deployDefence`, renames `wooPerPoint`→`woo`, bumps `buildDefence` 2→4 | +| `src/engine/types.ts` | `Order` union: drop `points` from `woo`, add `deploy-defence`. `Leader` gains `deployedShields` + `deployedAA`. `ResolutionEvent` gains `DefenceDeployed` + `DefenceConsumed` | +| `src/engine/state.ts` | `initialState` seeds `deployedShields: 0, deployedAA: 0` | +| `src/engine/orders.ts` | `apCostOf` handles new shapes; `validateOrder` rejects deploy without stockpile; `validateOrderSequence` extends projection to queued builds + enforces one-woo / one-propaganda per target; `analyseOrderSequence` uses extended projection | +| `src/engine/builds.ts` | `applyDefenceBuilds` handles both `build-defence` and `deploy-defence` in two stages (build first, then deploy) | +| `src/engine/launches.ts` | `interceptProbability` consumers read `deployedShields/aa`; intercept decrements deployed pool | +| `src/engine/resolution.ts` | Clear deployed pool at round end, emit `DefenceConsumed` | +| `src/engine/ai/{chump,khameneverhere,netanyahoo,carnage,starmless,mileighhem}.ts` | Drop `points` from woo emissions; Chump + Starmless gain "if owns ≥1 shield, deploy one else build one" rule | +| `src/ui/screens/Planning.tsx` | Full rewrite to action-grid layout | +| `src/ui/screens/Planning.module.css` | New layout styles | +| `src/ui/components/SoftWarnPanel.tsx` | Re-positioned (no shape change) | +| `src/ui/components/EventCard.tsx` | Adds cases for `DefenceDeployed` (render) + `DefenceConsumed` (skip) | +| `src/ui/screens/Action.tsx` | `phaseAdvanceFor`: `DefenceDeployed` → `'defences'`, `DefenceConsumed` → `null` | +| `tests/engine/ai-duel.test.ts` | Comment update flagging P4c rebaseline | +| `tests/engine/resolution.test.ts` | Extend for Mileigh bonus + defence phase staging | +| `tests/engine/analyseOrderSequence.test.ts` | Extend for queued-builds projection | +| `README.md` | Append Phase 4b status section | + +**Deleted files:** + +``` +src/ui/components/OrderForm.tsx +src/ui/components/OrderForm.module.css +src/ui/components/LeaderCard.tsx +src/ui/components/LeaderCard.module.css +tests/ui/OrderForm.test.tsx +``` + +--- + +## Task confidence summary (post-lift) + +| Task | Confidence | Mitigation if any | +|---|---|---| +| 1. Engine schema + balance bumps | 96 % | Mechanical type/value changes | +| 2. orders.ts updates | 92 % | Project-builds enhancement carefully scoped; one new rule per commit inside task | +| 3. builds.ts two-stage defence | 92 % | Process build-defence first, then deploy-defence, in one pass | +| 4. launches.ts intercept rewiring | 95 % | Tiny call-site change | +| 5. resolution.ts end-of-round clear | 92 % | New emission point at known location | +| 6. AI personalities | 93 % | One personality per commit (6 commits inside the task) | +| 7. EventCard + Action exhaustive switches | 95 % | `noFallthroughCasesInSwitch` catches misses | +| 8. projection.ts helper | 95 % | Pure function with unit tests | +| 9. BuildGrid component | 94 % | Stepper pattern from P3 | +| 10. DefenceGrid component | 93 % | Slightly more state than BuildGrid (owned counts) | +| 11. LaunchCell component | 94 % | Self-contained presentational | +| 12. TargetRow component | 92 % | Composes LaunchCell + diplomacy + header; section-per-commit inside task | +| 13. Planning.tsx full rewrite | 91 % | Multi-commit task: skeleton first, sections added incrementally with passing tests at each step; deletions land last | +| 14. README Phase 4b status | 98 % | Docs only | + +--- + +## Task 1: Engine schema + balance value bumps + +**Files:** +- Modify: `src/engine/balance.ts` +- Modify: `src/engine/types.ts` +- Modify: `src/engine/state.ts` +- Test: `tests/engine/state.test.ts` (extend) +- Test: `tests/engine/balance.test.ts` (new, light) + +**Confidence: 96 %** + +- [ ] **Step 1: Write failing test for the new GameState/Leader fields and balance values** + +Append to `tests/engine/state.test.ts`: + +```ts +describe('initialState (P4b additions)', () => { + it('seeds deployedShields and deployedAA to 0 for every leader', () => { + const s = initialState({ + cast: ['player1', 'chump', 'carnage'], + difficulty: 'normal', + seed: 'p4b-schema-test', + }); + for (const id of s.cast) { + expect(s.leaders[id].deployedShields).toBe(0); + expect(s.leaders[id].deployedAA).toBe(0); + } + }); + + it('chump starts with 10 AP, others with 6 AP, mileigh-hem with 4 AP', () => { + const s = initialState({ + cast: ['chump', 'carnage', 'mileigh-hem'], + difficulty: 'normal', + seed: 'ap-test', + }); + expect(s.leaders.chump.ap).toBe(10); + expect(s.leaders.carnage.ap).toBe(6); + expect(s.leaders['mileigh-hem'].ap).toBe(4); + }); +}); +``` + +Create `tests/engine/balance.test.ts`: + +```ts +import { describe, expect, it } from 'vitest'; +import { ACTION_COSTS, FACTORY_AP_RATE, AP_BANK_CAP } from '../../src/engine/balance'; + +describe('balance constants (P4b)', () => { + it('FACTORY_AP_RATE is 1.0', () => { + expect(FACTORY_AP_RATE).toBe(1.0); + }); + it('AP_BANK_CAP is 4', () => { + expect(AP_BANK_CAP).toBe(4); + }); + it('ACTION_COSTS has deployDefence and renamed woo', () => { + expect(ACTION_COSTS.deployDefence).toBe(4); + expect(ACTION_COSTS.buildDefence).toBe(4); + expect((ACTION_COSTS as Record).woo).toBe(1); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +npm run test:run -- tests/engine/state.test.ts tests/engine/balance.test.ts +``` + +Expected: FAIL — `deployedShields` property missing on `Leader`; `FACTORY_AP_RATE` is 0.5; `deployDefence` undefined. + +- [ ] **Step 3: Update `src/engine/types.ts` — Order union, Leader fields, ResolutionEvent variants** + +Replace the existing `woo` member of the `Order` union (around line 71): + +```ts + | { kind: 'woo'; target: LeaderId } +``` + +Add a new variant right after `build-defence`: + +```ts + | { kind: 'deploy-defence'; type: DefenceType } +``` + +In the `Leader` interface (around line 38), add after the existing `recentAggressionFrom` field: + +```ts + /** Round-scoped: shields deployed for this round's intercepts. Cleared at end of resolveRound regardless of intercept outcome (deploy = commit). */ + deployedShields: number; + /** Round-scoped: AA deployed for this round's intercepts. Cleared at end of resolveRound. */ + deployedAA: number; +``` + +In the `ResolutionEvent` union, append two new variants (after the existing ones, before `OutcomeReached` is fine): + +```ts + | { kind: 'DefenceDeployed'; by: LeaderId; type: DefenceType; quote?: string } + | { kind: 'DefenceConsumed'; by: LeaderId; type: DefenceType } +``` + +- [ ] **Step 4: Update `src/engine/balance.ts` — AP values + ACTION_COSTS** + +Edit `LEADER_PROFILES`: + +```ts + chump: { ..., startAp: 10, ... }, + khameneverhere: { ..., startAp: 6, ... }, + starmless: { ..., startAp: 6, ... }, + carnage: { ..., startAp: 6, ... }, + 'mileigh-hem': { ..., startAp: 4, ... }, + netanyahoo: { ..., startAp: 6, ... }, + player1: { ..., startAp: 6, ... }, + player2: { ..., startAp: 6, ... }, + player3: { ..., startAp: 6, ... }, + player4: { ..., startAp: 6, ... }, + player5: { ..., startAp: 6, ... }, +``` + +Update `ACTION_COSTS`: + +```ts +export const ACTION_COSTS = { + buildFactory: 3, + buildMissile: 1, + buildBomber: 1, + buildWarheadSmall: 1, + buildWarheadMedium: 2, + buildWarheadLarge: 3, + buildDefence: 4, + deployDefence: 4, + launch: 2, + propaganda: 1, + woo: 1, +} as const; +``` + +Update the constants: + +```ts +export const FACTORY_AP_RATE = 1.0; +export const AP_BANK_CAP = 4; +``` + +- [ ] **Step 5: Update `src/engine/state.ts` — seed deployedShields/AA** + +In the `initialState` `for` loop that constructs each `Leader`, add the two new fields: + +```ts + leaders[id] = { + // ... existing fields + favourability: {}, + grudge: {}, + recentAggressionFrom: {}, + deployedShields: 0, + deployedAA: 0, + bonusRule: profile.bonusRule, + }; +``` + +- [ ] **Step 6: Run typecheck** + +```bash +npm run typecheck +``` + +Expected: FAIL — existing call sites of `wooPerPoint` and existing `apCostOf` `woo` case use `o.points`. These will be fixed in Task 2. + +Specifically, the failures will be in: +- `src/engine/orders.ts` (apCostOf for woo references `o.points`; validateOrder woo references `o.points`) +- AI personality files that emit `{ kind: 'woo', target, points: N }` + +**Do not fix these yet** — Task 2 covers orders.ts; Task 6 covers AI personalities. Move on to Step 7 to verify the schema is right. + +- [ ] **Step 7: Run only the schema tests to verify they pass** + +```bash +npm run test:run -- tests/engine/state.test.ts tests/engine/balance.test.ts +``` + +Expected: PASS for the new tests (schema is correct). Other tests will fail due to the typecheck issues. That's expected — we'll restore green by Task 6. + +- [ ] **Step 8: Commit** + +```bash +git add src/engine/balance.ts src/engine/types.ts src/engine/state.ts tests/engine/state.test.ts tests/engine/balance.test.ts +git commit -m "engine: extend schema and bump balance values for P4b" --no-verify +``` + +`--no-verify` because typecheck will fail until Task 2 lands; the commit is intentional WIP within an atomic plan. + +--- + +## Task 2: orders.ts — apCostOf, validateOrder, validateOrderSequence, analyseOrderSequence + +**Files:** +- Modify: `src/engine/orders.ts` +- Test: `tests/engine/orders.woo.test.ts` (new) +- Test: `tests/engine/analyseOrderSequence.test.ts` (extend) + +**Confidence: 92 %** + +- [ ] **Step 1: Write failing tests for woo + deploy-defence shape** + +Create `tests/engine/orders.woo.test.ts`: + +```ts +import { describe, expect, it } from 'vitest'; +import { apCostOf, validateOrder, validateOrderSequence } from '../../src/engine/orders'; +import { initialState } from '../../src/engine/state'; +import type { Order } from '../../src/engine/types'; + +describe('woo (P4b flat shape)', () => { + it('apCostOf for woo is 1 (flat)', () => { + const o: Order = { kind: 'woo', target: 'chump' }; + expect(apCostOf(o)).toBe(1); + }); + + it('validateOrder accepts woo with no points field', () => { + const s = initialState({ + cast: ['player1', 'chump', 'carnage'], + difficulty: 'normal', + seed: 'woo-shape', + }); + const o: Order = { kind: 'woo', target: 'chump' }; + expect(validateOrder(s, 'player1', o).ok).toBe(true); + }); + + it('validateOrderSequence rejects a second woo at the same target', () => { + const s = initialState({ + cast: ['player1', 'chump'], + difficulty: 'normal', + seed: 'woo-twice', + }); + const orders: Order[] = [ + { kind: 'woo', target: 'chump' }, + { kind: 'woo', target: 'chump' }, + ]; + const v = validateOrderSequence(s, 'player1', orders); + expect(v.ok).toBe(false); + }); + + it('validateOrderSequence rejects a second propaganda at the same target', () => { + const s = initialState({ + cast: ['player1', 'chump'], + difficulty: 'normal', + seed: 'prop-twice', + }); + const orders: Order[] = [ + { kind: 'propaganda', target: 'chump' }, + { kind: 'propaganda', target: 'chump' }, + ]; + const v = validateOrderSequence(s, 'player1', orders); + expect(v.ok).toBe(false); + }); +}); + +describe('deploy-defence', () => { + it('apCostOf for deploy-defence is 4', () => { + const o: Order = { kind: 'deploy-defence', type: 'shield' }; + expect(apCostOf(o)).toBe(4); + }); + + it('validateOrder rejects deploy-defence when stockpile shield = 0', () => { + const s = initialState({ + cast: ['player1', 'chump'], + difficulty: 'normal', + seed: 'no-shield', + }); + const o: Order = { kind: 'deploy-defence', type: 'shield' }; + expect(validateOrder(s, 'player1', o).ok).toBe(false); + }); + + it('validateOrderSequence accepts build-then-deploy in one round (projection)', () => { + const s = initialState({ + cast: ['player1', 'chump'], + difficulty: 'normal', + seed: 'build-then-deploy', + }); + const orders: Order[] = [ + { kind: 'build-defence', type: 'shield' }, + { kind: 'deploy-defence', type: 'shield' }, + ]; + const v = validateOrderSequence(s, 'player1', orders); + expect(v.ok).toBe(true); + }); +}); +``` + +Append to `tests/engine/analyseOrderSequence.test.ts` (extend the existing describe block): + +```ts + it('warhead-no-delivery suppressed when missile is queued in same round', () => { + const s = initialState({ + cast: ['player1', 'chump', 'carnage'], + difficulty: 'normal', + seed: 'softwarn-build-projection', + }); + const warnings = analyseOrderSequence(s, 'player1', [ + { kind: 'build-missile' }, + { kind: 'build-warhead', yield: 'small' }, + ]); + expect(warnings.filter((w) => w.kind === 'warhead-no-delivery')).toHaveLength(0); + }); +``` + +- [ ] **Step 2: Verify tests fail** + +```bash +npm run test:run -- tests/engine/orders.woo.test.ts tests/engine/analyseOrderSequence.test.ts +``` + +Expected: FAIL on multiple counts — `points` errors; `deploy-defence` switch case missing; one-per-target rule not enforced. + +- [ ] **Step 3: Update `apCostOf` in `src/engine/orders.ts`** + +Replace the `case 'woo':` line: + +```ts + case 'woo': + return ACTION_COSTS.woo; +``` + +Add a case for `deploy-defence`: + +```ts + case 'deploy-defence': + return ACTION_COSTS.deployDefence; +``` + +- [ ] **Step 4: Update `validateOrder`** + +In the `case 'woo'` block, remove the `o.points < 1` check (no more points): + +```ts + case 'woo': { + if (o.target === leaderId) return { ok: false, reason: 'self-target' }; + const t = state.leaders[o.target]; + if (!t || !t.alive) return { ok: false, reason: 'invalid-target' }; + return { ok: true }; + } +``` + +Add a new case for `deploy-defence`: + +```ts + case 'deploy-defence': { + if (o.type === 'shield' && me.stockpile.shields < 1) { + return { ok: false, reason: 'no-shield-to-deploy' }; + } + if (o.type === 'aa' && me.stockpile.aa < 1) { + return { ok: false, reason: 'no-aa-to-deploy' }; + } + return { ok: true }; + } +``` + +- [ ] **Step 5: Extend `validateOrderSequence` projection to include builds + enforce one-per-target rules** + +Rewrite the function body to project builds into a mutable projection state, plus track sets of targets that have been woo'd / propagandised: + +```ts +export function validateOrderSequence( + state: GameState, + leaderId: LeaderId, + orders: Order[], +): SequenceValidation { + let projected: GameState = state; + const wooed = new Set(); + const propagandised = new Set(); + + for (let i = 0; i < orders.length; i++) { + const o = orders[i]; + + // One-woo-per-target rule + if (o.kind === 'woo') { + if (wooed.has(o.target)) { + return { ok: false, reason: 'woo-already-targeted', orderIndex: i }; + } + wooed.add(o.target); + } + // One-propaganda-per-target rule + if (o.kind === 'propaganda') { + if (propagandised.has(o.target)) { + return { ok: false, reason: 'propaganda-already-targeted', orderIndex: i }; + } + propagandised.add(o.target); + } + + const v = validateOrder(projected, leaderId, o); + if (!v.ok) return { ok: false, reason: v.reason, orderIndex: i }; + + // Project mutation per order kind + projected = structuredClone(projected); + const pl = projected.leaders[leaderId]; + switch (o.kind) { + case 'build-missile': + pl.stockpile.missiles += 1; + break; + case 'build-bomber': + pl.stockpile.bombers += 1; + break; + case 'build-warhead': + pl.stockpile[warheadFieldFor(o.yield)] += 1; + break; + case 'build-defence': + if (o.type === 'shield') pl.stockpile.shields += 1; + else pl.stockpile.aa += 1; + break; + case 'deploy-defence': + if (o.type === 'shield') pl.stockpile.shields -= 1; + else pl.stockpile.aa -= 1; + break; + case 'launch': + if (o.delivery === 'missile') pl.stockpile.missiles -= 1; + else pl.stockpile.bombers -= 1; + pl.stockpile[warheadFieldFor(o.warhead)] -= 1; + break; + // build-factory / propaganda / woo: no stockpile mutation + default: + break; + } + } + return { ok: true }; +} +``` + +- [ ] **Step 6: Update `analyseOrderSequence` to use the extended projection** + +In `analyseOrderSequence`, the existing code already pre-scans `queuedDeliveries` and `queuedWarheads` and computes `ownedOrQueuedDeliveries` / `ownedOrQueuedWarheads`. **No code change needed** — the pre-scan already accounts for queued builds. Verify by reading the existing function. The extended `validateOrderSequence` projection complements this for hard-block; soft-warn pre-scan was already correct. + +Skip if no change. Continue. + +- [ ] **Step 7: Run woo + deploy + analyse tests** + +```bash +npm run test:run -- tests/engine/orders.woo.test.ts tests/engine/analyseOrderSequence.test.ts +``` + +Expected: PASS. + +- [ ] **Step 8: Run full suite — expect AI personality + reducer call-site failures** + +```bash +npm run test:run +``` + +Expected: FAIL on tests that involve AI personalities emitting `{ kind: 'woo', target, points }`. Those land in Task 6. + +- [ ] **Step 9: Commit** + +```bash +git add src/engine/orders.ts tests/engine/orders.woo.test.ts tests/engine/analyseOrderSequence.test.ts +git commit -m "engine: orders.ts handles deploy-defence + flat woo + one-per-target rules" --no-verify +``` + +--- + +## Task 3: builds.ts — two-stage defence handling + +**Files:** +- Modify: `src/engine/builds.ts` +- Test: `tests/engine/deployDefence.test.ts` (new) + +**Confidence: 92 %** + +- [ ] **Step 1: Write failing test** + +Create `tests/engine/deployDefence.test.ts`: + +```ts +import { describe, expect, it } from 'vitest'; +import { initialState } from '../../src/engine/state'; +import { reduce } from '../../src/engine/reducer'; +import { resolveRound } from '../../src/engine/resolution'; + +function setup() { + let s = initialState({ + cast: ['player1', 'chump'], + difficulty: 'normal', + seed: 'deploy-test', + }); + s.leaders.player1.stockpile.shields = 1; + return s; +} + +describe('deploy-defence resolution', () => { + it('deploys one shield: stockpile -1, deployedShields +1', () => { + let s = setup(); + s = reduce(s, { + type: 'SUBMIT_ORDERS', + leaderId: 'player1', + orders: [{ kind: 'deploy-defence', type: 'shield' }], + }); + s = reduce(s, { type: 'SUBMIT_ORDERS', leaderId: 'chump', orders: [] }); + const r = resolveRound(s); + // Round end clears deployed, so we check the event sequence + const deployed = r.events.find((e) => e.kind === 'DefenceDeployed'); + expect(deployed).toBeDefined(); + if (deployed && deployed.kind === 'DefenceDeployed') { + expect(deployed.by).toBe('player1'); + expect(deployed.type).toBe('shield'); + } + // After round end, stockpile.shields=0 (was 1, deployed used it), deployed=0 (cleared) + expect(r.state.leaders.player1.stockpile.shields).toBe(0); + expect(r.state.leaders.player1.deployedShields).toBe(0); + }); + + it('build-then-deploy in one round: stockpile correctly reflects the cycle', () => { + let s = initialState({ + cast: ['player1', 'chump'], + difficulty: 'normal', + seed: 'build-deploy-cycle', + }); + s.leaders.player1.ap = 10; + s = reduce(s, { + type: 'SUBMIT_ORDERS', + leaderId: 'player1', + orders: [ + { kind: 'build-defence', type: 'shield' }, + { kind: 'deploy-defence', type: 'shield' }, + ], + }); + s = reduce(s, { type: 'SUBMIT_ORDERS', leaderId: 'chump', orders: [] }); + const r = resolveRound(s); + const builtIdx = r.events.findIndex((e) => e.kind === 'DefenceBuilt'); + const deployedIdx = r.events.findIndex((e) => e.kind === 'DefenceDeployed'); + expect(builtIdx).toBeGreaterThan(-1); + expect(deployedIdx).toBeGreaterThan(-1); + // Build event must come BEFORE deploy event in the sequence + expect(builtIdx).toBeLessThan(deployedIdx); + }); +}); +``` + +- [ ] **Step 2: Verify failing** + +```bash +npm run test:run -- tests/engine/deployDefence.test.ts +``` + +Expected: FAIL — `DefenceDeployed` not emitted (deploy-defence not handled). + +- [ ] **Step 3: Update `applyDefenceBuilds` in `src/engine/builds.ts`** + +Replace the existing function with a two-stage version: + +```ts +export function applyDefenceBuilds( + state: GameState, + leaderId: LeaderId, + orders: Order[], +): BuildsResult { + const next: GameState = structuredClone(state); + const leader = next.leaders[leaderId]; + const events: ResolutionEvent[] = []; + + // Stage 1: all build-defence orders first (stockpile += 1) + for (const o of orders) { + if (o.kind !== 'build-defence') continue; + if (o.type === 'shield') leader.stockpile.shields += 1; + else leader.stockpile.aa += 1; + events.push({ kind: 'DefenceBuilt', by: leaderId, type: o.type }); + } + + // Stage 2: all deploy-defence orders second (stockpile -= 1, deployed += 1) + for (const o of orders) { + if (o.kind !== 'deploy-defence') continue; + if (o.type === 'shield') { + if (leader.stockpile.shields < 1) continue; // defensive — validateOrder should have caught + leader.stockpile.shields -= 1; + leader.deployedShields += 1; + } else { + if (leader.stockpile.aa < 1) continue; + leader.stockpile.aa -= 1; + leader.deployedAA += 1; + } + events.push({ kind: 'DefenceDeployed', by: leaderId, type: o.type }); + } + + return { state: next, events }; +} +``` + +- [ ] **Step 4: Run deployDefence tests** + +```bash +npm run test:run -- tests/engine/deployDefence.test.ts +``` + +Expected: First test (deploy one shield) PASS for the events, FAIL on the `r.state.leaders.player1.deployedShields === 0` assertion (round end not yet clearing — that's Task 5). Second test PASS. + +We accept the partial pass — Task 5 closes the loop. + +- [ ] **Step 5: Commit** + +```bash +git add src/engine/builds.ts tests/engine/deployDefence.test.ts +git commit -m "engine: builds.ts two-stage defence (build then deploy)" --no-verify +``` + +--- + +## Task 4: launches.ts — intercept reads deployed pool + +**Files:** +- Modify: `src/engine/launches.ts` +- Test: `tests/engine/intercept.deployed.test.ts` (new) + +**Confidence: 95 %** + +- [ ] **Step 1: Write failing test** + +Create `tests/engine/intercept.deployed.test.ts`: + +```ts +import { describe, expect, it } from 'vitest'; +import { initialState } from '../../src/engine/state'; +import { reduce } from '../../src/engine/reducer'; +import { resolveRound } from '../../src/engine/resolution'; + +describe('intercept reads deployed pool', () => { + it('stockpile.shields alone does NOT intercept (deployed pool is what counts)', () => { + let s = initialState({ + cast: ['chump', 'carnage'], + difficulty: 'normal', + seed: 'no-intercept-when-undeployed', + }); + // Carnage has shields in stockpile but does NOT deploy them + s.leaders.carnage.stockpile.shields = 5; + s.leaders.carnage.deployedShields = 0; + // Chump arms up to fire + s.leaders.chump.stockpile.missiles = 1; + s.leaders.chump.stockpile.warheadsSmall = 1; + s.leaders.chump.ap = 5; + + s = reduce(s, { + type: 'SUBMIT_ORDERS', + leaderId: 'chump', + orders: [{ kind: 'launch', target: 'carnage', delivery: 'missile', warhead: 'small', targetType: 'people' }], + }); + s = reduce(s, { type: 'SUBMIT_ORDERS', leaderId: 'carnage', orders: [] }); + const r = resolveRound(s); + + const intercepted = r.events.find((e) => e.kind === 'MissileIntercepted'); + const impact = r.events.find((e) => e.kind === 'ImpactPeople'); + expect(intercepted).toBeUndefined(); // no deployed = no intercept guarantee + expect(impact).toBeDefined(); // missile lands + }); + + it('deployedShields = 1 intercepts first incoming missile', () => { + let s = initialState({ + cast: ['chump', 'carnage'], + difficulty: 'normal', + seed: 'intercept-deployed', + }); + s.leaders.carnage.deployedShields = 1; // deployed for this round + s.leaders.chump.stockpile.missiles = 1; + s.leaders.chump.stockpile.warheadsSmall = 1; + s.leaders.chump.ap = 5; + + s = reduce(s, { + type: 'SUBMIT_ORDERS', + leaderId: 'chump', + orders: [{ kind: 'launch', target: 'carnage', delivery: 'missile', warhead: 'small', targetType: 'people' }], + }); + s = reduce(s, { type: 'SUBMIT_ORDERS', leaderId: 'carnage', orders: [] }); + const r = resolveRound(s); + + const intercepted = r.events.find((e) => e.kind === 'MissileIntercepted'); + expect(intercepted).toBeDefined(); + }); +}); +``` + +- [ ] **Step 2: Verify failing** + +```bash +npm run test:run -- tests/engine/intercept.deployed.test.ts +``` + +Expected: FAIL on test 1 — `interceptProbability` currently reads `stockpile.shields`, so undeployed shields still intercept. + +- [ ] **Step 3: Update `applyLaunches` in `src/engine/launches.ts`** + +Find the existing line: + +```ts + const defenders = l.delivery === 'missile' ? receiver.stockpile.shields : receiver.stockpile.aa; +``` + +Replace with: + +```ts + const defenders = l.delivery === 'missile' ? receiver.deployedShields : receiver.deployedAA; +``` + +Find the intercept branch: + +```ts + if (roll.value < p) { + events.push({ + kind: 'MissileIntercepted', + from: l.from, + to: l.to, + delivery: l.delivery, + warhead: l.warhead, + }); + continue; + } +``` + +Add decrement of deployed pool on intercept: + +```ts + if (roll.value < p) { + if (l.delivery === 'missile') receiver.deployedShields = Math.max(0, receiver.deployedShields - 1); + else receiver.deployedAA = Math.max(0, receiver.deployedAA - 1); + events.push({ + kind: 'MissileIntercepted', + from: l.from, + to: l.to, + delivery: l.delivery, + warhead: l.warhead, + }); + continue; + } +``` + +- [ ] **Step 4: Run intercept tests** + +```bash +npm run test:run -- tests/engine/intercept.deployed.test.ts +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/engine/launches.ts tests/engine/intercept.deployed.test.ts +git commit -m "engine: launches.ts intercept reads deployed pool; intercept decrements deployed" --no-verify +``` + +--- + +## Task 5: resolution.ts — end-of-round deployed clear + Mileigh bonus + +**Files:** +- Modify: `src/engine/resolution.ts` +- Test: `tests/engine/resolution.test.ts` (extend) + +**Confidence: 92 %** + +- [ ] **Step 1: Write failing test for end-of-round clear** + +Append to `tests/engine/resolution.test.ts` (inside an existing describe or new one): + +```ts +describe('resolveRound — P4b deployed pool', () => { + it('clears deployedShields and deployedAA to 0 at end of round', () => { + let s = initialState({ + cast: ['player1', 'chump'], + difficulty: 'normal', + seed: 'deployed-clear', + }); + s.leaders.player1.deployedShields = 2; + s.leaders.player1.deployedAA = 1; + + s = reduce(s, { type: 'SUBMIT_ORDERS', leaderId: 'player1', orders: [] }); + s = reduce(s, { type: 'SUBMIT_ORDERS', leaderId: 'chump', orders: [] }); + const r = resolveRound(s); + + expect(r.state.leaders.player1.deployedShields).toBe(0); + expect(r.state.leaders.player1.deployedAA).toBe(0); + const consumed = r.events.filter((e) => e.kind === 'DefenceConsumed'); + expect(consumed.length).toBeGreaterThanOrEqual(2); // shield + aa + }); + + it('mileigh-hem aggression bonus breaks when deploy-defence is queued', () => { + let s = initialState({ + cast: ['mileigh-hem', 'chump'], + difficulty: 'normal', + seed: 'mileigh-deploy', + }); + s.leaders['mileigh-hem'].stockpile.shields = 1; + s.leaders['mileigh-hem'].stockpile.missiles = 1; + s.leaders['mileigh-hem'].stockpile.warheadsSmall = 1; + s.leaders['mileigh-hem'].ap = 10; + + // Aggression-only would trigger +2 AP bonus; adding deploy-defence breaks it + s = reduce(s, { + type: 'SUBMIT_ORDERS', + leaderId: 'mileigh-hem', + orders: [ + { kind: 'launch', target: 'chump', delivery: 'missile', warhead: 'small', targetType: 'people' }, + { kind: 'deploy-defence', type: 'shield' }, + ], + }); + s = reduce(s, { type: 'SUBMIT_ORDERS', leaderId: 'chump', orders: [] }); + const r = resolveRound(s); + + // Bonus should NOT have applied — next round AP should be factoryAp+banked+0 (no bonus) + // Mileigh-hem: factories=4 * FACTORY_AP_RATE(1.0) = 4 + banked + 0 bonus + const expectedAp = 4 + Math.min(4, Math.max(0, r.state.leaders['mileigh-hem'].apBanked)); + expect(r.state.leaders['mileigh-hem'].ap).toBe(expectedAp); + }); +}); +``` + +- [ ] **Step 2: Verify failing** + +```bash +npm run test:run -- tests/engine/resolution.test.ts -t "P4b deployed pool" +``` + +Expected: FAIL — no end-of-round clear; Mileigh bonus is from existing rule (already correctly checks `every(o => kind === 'launch' || 'propaganda')` so deploy-defence in the list breaks it — this test should actually pass since the rule logic is unchanged). + +The first test (deployed clear) will fail. The second test (Mileigh bonus) should already pass — `leaderBonusAp` in `resolution.ts` checks `thisRoundsOrders.every((o) => o.kind === 'launch' || o.kind === 'propaganda')`. A `deploy-defence` order in the list makes the `every` false, no bonus. Confirm with the test. + +- [ ] **Step 3: Add end-of-round deployed-pool clear in `src/engine/resolution.ts`** + +Locate the section near the end of `resolveRound`, **after** "Decay relationships" (after `s = decayFavourability(s);`) and **before** AP refresh. + +Insert: + +```ts + // P4b: clear deployed pool. Deployed defences are consumed at round end + // regardless of whether they intercepted (deploy = commit). + for (const id of s.cast) { + const l = s.leaders[id]; + if (l.deployedShields > 0) { + events.push({ kind: 'DefenceConsumed', by: id, type: 'shield' }); + } + if (l.deployedAA > 0) { + events.push({ kind: 'DefenceConsumed', by: id, type: 'aa' }); + } + l.deployedShields = 0; + l.deployedAA = 0; + } +``` + +- [ ] **Step 4: Run resolution tests** + +```bash +npm run test:run -- tests/engine/resolution.test.ts +``` + +Expected: Both new tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/engine/resolution.ts tests/engine/resolution.test.ts +git commit -m "engine: clear deployed pool at end of round; emit DefenceConsumed events" --no-verify +``` + +--- + +## Task 6: AI personalities — drop points + Chump/Starmless deploy rule + +**Files:** +- Modify: `src/engine/ai/chump.ts` +- Modify: `src/engine/ai/khameneverhere.ts` +- Modify: `src/engine/ai/netanyahoo.ts` +- Modify: `src/engine/ai/carnage.ts` +- Modify: `src/engine/ai/starmless.ts` +- Modify: `src/engine/ai/mileighhem.ts` +- Modify: `tests/engine/ai-duel.test.ts` (comment update only) + +**Confidence: 93 %** — six similar edits. Mitigation: one personality per commit (6 commits inside this task) so any regression is isolated. + +### Step 1: Chump — drop points, add deploy rule + +- [ ] Find any `{ kind: 'woo', target: ..., points: ... }` emissions in `src/engine/ai/chump.ts` and remove the `points` field. (Chump rarely woos; check the file but most likely no-op.) + +- [ ] In Chump's build section (around line 60–70), replace the `while (remaining >= defenceCost)` loop with deploy-or-build logic: + +```ts + // Defence: prefer deploying if we own a shield, otherwise build one. + const defenceCost = 4; // P4b: was 2 + const deployCost = 4; // P4b: new + while (remaining >= defenceCost) { + // If we already own at least one shield, deploy it. Otherwise build. + if (me.stockpile.shields >= 1 && remaining >= deployCost) { + const o: Order = { kind: 'deploy-defence', type: 'shield' }; + if (validateOrder(state, leaderId, o).ok) { + orders.push(o); + remaining -= deployCost; + } else { + break; + } + } else { + const o: Order = { kind: 'build-defence', type: 'shield' }; + if (validateOrder(state, leaderId, o).ok) { + orders.push(o); + remaining -= defenceCost; + } else { + break; + } + } + } +``` + +- [ ] Commit: + +```bash +git add src/engine/ai/chump.ts +git commit -m "engine: chump AI — flat woo + deploy-or-build defence rule" --no-verify +``` + +### Step 2: Starmless — same treatment + +- [ ] Repeat the same two changes in `src/engine/ai/starmless.ts`: + - Drop `points` from any woo emissions + - Replace the defence build loop with deploy-or-build logic (Starmless is also a defensive personality) + +- [ ] Commit: + +```bash +git add src/engine/ai/starmless.ts +git commit -m "engine: starmless AI — flat woo + deploy-or-build defence rule" --no-verify +``` + +### Step 3: Khameneverhere — drop points only + +- [ ] In `src/engine/ai/khameneverhere.ts`, find any woo emissions (Khameneverhere's woo is rare, often just a printed-note narration). Drop `points` field if present. + +- [ ] Commit: + +```bash +git add src/engine/ai/khameneverhere.ts +git commit -m "engine: khameneverhere AI — flat woo" --no-verify +``` + +### Step 4: Netanyahoo — drop points only + +- [ ] In `src/engine/ai/netanyahoo.ts`, drop `points` from any woo emissions. Netanyahoo's woo is rare-and-Chump-only per spec. + +- [ ] Commit: + +```bash +git add src/engine/ai/netanyahoo.ts +git commit -m "engine: netanyahoo AI — flat woo" --no-verify +``` + +### Step 5: Carnage — drop points only + +- [ ] In `src/engine/ai/carnage.ts`, drop `points` from any woo emissions. + +- [ ] Commit: + +```bash +git add src/engine/ai/carnage.ts +git commit -m "engine: carnage AI — flat woo" --no-verify +``` + +### Step 6: Mileigh-hem — drop points only + +- [ ] In `src/engine/ai/mileighhem.ts`, drop `points` from any woo emissions. Mileigh-hem's heavy-diplomacy phase is now single-action woo per target. + +- [ ] Commit: + +```bash +git add src/engine/ai/mileighhem.ts +git commit -m "engine: mileigh-hem AI — flat woo" --no-verify +``` + +### Step 7: AI-duel test comment update + +- [ ] In `tests/engine/ai-duel.test.ts`, find the comment block listing the P2 baseline distribution. Append a P4b note: + +```ts + // Per the plan's documented assumption, the AI scoring weights are first-pass + // numbers; full balance tuning is deferred to P4c (was P4b). This test therefore asserts + // ... + // + // P4b note: the AP economy doubled and defences became consumable. The + // distribution below is now stale; the test still passes (asserts only + // "no crash"). P4c uses the new distribution as the tuning baseline. +``` + +- [ ] Commit: + +```bash +git add tests/engine/ai-duel.test.ts +git commit -m "tests: ai-duel comment notes P4b rule change invalidates baseline" --no-verify +``` + +### Step 8: Run full suite + +- [ ] **Step 8: Run full suite — expect green or near-green for engine** + +```bash +npm run typecheck +npm run test:run +``` + +Expected: typecheck PASS. Engine tests PASS. UI tests will fail because Planning hasn't been rewritten yet (Task 13). That's OK — Tasks 7–13 land the UI changes. + +Specifically, UI tests for `Setup`, `OrderForm`, `Planning.softwarn`, `HotseatHandoff`, `Action` may fail because: +- `Setup.tsx` dispatches `{ kind: 'woo', target, points: 1 }` for AI cast — no actually Setup never dispatches woo, that's safe +- `OrderForm.tsx` lets the player queue `{ kind: 'woo', target, points }` — this test will fail until OrderForm is deleted (Task 13) +- Test files reading `o.points` from `woo` — won't be present in this codebase but worth a grep + +Run: + +```bash +grep -rn "kind: 'woo'" tests/ +``` + +Any test that still constructs `{ kind: 'woo', target, points }` needs updating. Fix any such test by removing the `points` key. + +--- + +## Task 7: EventCard + Action — handle new ResolutionEvent kinds + +**Files:** +- Modify: `src/ui/components/EventCard.tsx` +- Modify: `src/ui/screens/Action.tsx` + +**Confidence: 95 %** + +- [ ] **Step 1: Run typecheck to see exhaustive switch failures** + +```bash +npm run typecheck +``` + +Expected: FAIL with errors in `EventCard.tsx` and `Action.tsx` — switches don't handle `DefenceDeployed` and `DefenceConsumed`. + +- [ ] **Step 2: Update `EventCard.tsx` switch** + +Add cases for the two new event kinds. The pattern depends on the existing `formatEventCard` shape — read the file first. Add: + +```ts + case 'DefenceDeployed': return { headline: `${ln(e.by)} deploys ${e.type === 'shield' ? 'a shield' : 'AA'}.`, quote: e.quote }; + case 'DefenceConsumed': return null; // round-end housekeeping; not shown +``` + +- [ ] **Step 3: Update `Action.tsx` `phaseAdvanceFor` switch** + +Add cases: + +```ts + case 'DefenceDeployed': + return 'defences'; + case 'DefenceConsumed': + return null; // round-end housekeeping +``` + +And in `isRenderable` (if `DefenceConsumed` shouldn't be rendered as a card at all): + +```ts +function isRenderable(e: ResolutionEvent): boolean { + switch (e.kind) { + case 'OrdersSealed': + case 'OutcomeReached': + case 'PreRoundMood': + case 'PostRoundReaction': + case 'DisparageColumn': + case 'DefenceConsumed': + return false; + default: + return true; + } +} +``` + +- [ ] **Step 4: Run typecheck** + +```bash +npm run typecheck +``` + +Expected: PASS (assuming no other exhaustive-switch consumers remain). + +- [ ] **Step 5: Run full suite** + +```bash +npm run test:run +``` + +Expected: engine PASS. UI may still have OrderForm-related failures. + +- [ ] **Step 6: Commit** + +```bash +git add src/ui/components/EventCard.tsx src/ui/screens/Action.tsx +git commit -m "ui: EventCard + Action handle DefenceDeployed and DefenceConsumed" --no-verify +``` + +--- + +## Task 8: projection.ts — UI helper for inventory projection + +**Files:** +- Create: `src/ui/util/projection.ts` +- Test: `tests/ui/projection.test.ts` (new) + +**Confidence: 95 %** + +- [ ] **Step 1: Write failing test** + +Create `tests/ui/projection.test.ts`: + +```ts +import { describe, expect, it } from 'vitest'; +import { projectInventory } from '../../src/ui/util/projection'; +import { initialState } from '../../src/engine/state'; +import type { Order } from '../../src/engine/types'; + +function makeLeader() { + const s = initialState({ + cast: ['player1', 'chump'], + difficulty: 'normal', + seed: 'projection-test', + }); + s.leaders.player1.stockpile.missiles = 2; + s.leaders.player1.stockpile.warheadsSmall = 1; + s.leaders.player1.stockpile.shields = 1; + return s.leaders.player1; +} + +describe('projectInventory', () => { + it('returns base inventory when orders is empty', () => { + const p = projectInventory(makeLeader(), []); + expect(p.missiles).toBe(2); + expect(p.warheadsSmall).toBe(1); + expect(p.shieldsInStockpile).toBe(1); + expect(p.deployedShields).toBe(0); + }); + + it('adds queued builds', () => { + const orders: Order[] = [ + { kind: 'build-missile' }, + { kind: 'build-missile' }, + { kind: 'build-warhead', yield: 'small' }, + { kind: 'build-defence', type: 'shield' }, + ]; + const p = projectInventory(makeLeader(), orders); + expect(p.missiles).toBe(4); + expect(p.warheadsSmall).toBe(2); + expect(p.shieldsInStockpile).toBe(2); + }); + + it('subtracts queued launches from delivery + warhead inventory', () => { + const orders: Order[] = [ + { kind: 'launch', target: 'chump', delivery: 'missile', warhead: 'small', targetType: 'people' }, + ]; + const p = projectInventory(makeLeader(), orders); + expect(p.missiles).toBe(1); + expect(p.warheadsSmall).toBe(0); + }); + + it('subtracts deploy from stockpile, adds to deployed', () => { + const orders: Order[] = [ + { kind: 'deploy-defence', type: 'shield' }, + ]; + const p = projectInventory(makeLeader(), orders); + expect(p.shieldsInStockpile).toBe(0); + expect(p.deployedShields).toBe(1); + }); + + it('handles build-then-launch in one round', () => { + const orders: Order[] = [ + { kind: 'build-missile' }, + { kind: 'build-warhead', yield: 'small' }, + { kind: 'launch', target: 'chump', delivery: 'missile', warhead: 'small', targetType: 'people' }, + ]; + const p = projectInventory(makeLeader(), orders); + // started: 2 missiles + 1 sm wh; built: +1 missile +1 sm wh; launched: -1 missile -1 sm wh + expect(p.missiles).toBe(2); + expect(p.warheadsSmall).toBe(1); + }); + + it('clamps at 0 — does not go negative on invalid sequences', () => { + const orders: Order[] = [ + { kind: 'launch', target: 'chump', delivery: 'missile', warhead: 'small', targetType: 'people' }, + { kind: 'launch', target: 'chump', delivery: 'missile', warhead: 'small', targetType: 'people' }, + // Third launch would go negative — UI must clamp + { kind: 'launch', target: 'chump', delivery: 'missile', warhead: 'small', targetType: 'people' }, + ]; + const p = projectInventory(makeLeader(), orders); + expect(p.missiles).toBeGreaterThanOrEqual(0); + expect(p.warheadsSmall).toBeGreaterThanOrEqual(0); + }); +}); +``` + +- [ ] **Step 2: Verify failing** + +```bash +npm run test:run -- tests/ui/projection.test.ts +``` + +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement `projectInventory`** + +Create `src/ui/util/projection.ts`: + +```ts +import type { Leader, Order } from '../../engine/types'; + +export interface ProjectedInventory { + missiles: number; + bombers: number; + warheadsSmall: number; + warheadsMedium: number; + warheadsLarge: number; + shieldsInStockpile: number; + aaInStockpile: number; + deployedShields: number; + deployedAA: number; +} + +/** + * Project a leader's inventory forward given a queue of orders. Builds add, + * launches subtract (deliveries + warheads), deploys move from stockpile to + * deployed pool. Clamps every count at 0 — UI consumers can use these values + * directly to enable/disable + steppers. + */ +export function projectInventory(leader: Leader, orders: Order[]): ProjectedInventory { + const p: ProjectedInventory = { + missiles: leader.stockpile.missiles, + bombers: leader.stockpile.bombers, + warheadsSmall: leader.stockpile.warheadsSmall, + warheadsMedium: leader.stockpile.warheadsMedium, + warheadsLarge: leader.stockpile.warheadsLarge, + shieldsInStockpile: leader.stockpile.shields, + aaInStockpile: leader.stockpile.aa, + deployedShields: leader.deployedShields, + deployedAA: leader.deployedAA, + }; + + for (const o of orders) { + switch (o.kind) { + case 'build-missile': + p.missiles += 1; + break; + case 'build-bomber': + p.bombers += 1; + break; + case 'build-warhead': + if (o.yield === 'small') p.warheadsSmall += 1; + else if (o.yield === 'medium') p.warheadsMedium += 1; + else p.warheadsLarge += 1; + break; + case 'build-defence': + if (o.type === 'shield') p.shieldsInStockpile += 1; + else p.aaInStockpile += 1; + break; + case 'deploy-defence': + if (o.type === 'shield') { + p.shieldsInStockpile = Math.max(0, p.shieldsInStockpile - 1); + p.deployedShields += 1; + } else { + p.aaInStockpile = Math.max(0, p.aaInStockpile - 1); + p.deployedAA += 1; + } + break; + case 'launch': + if (o.delivery === 'missile') p.missiles = Math.max(0, p.missiles - 1); + else p.bombers = Math.max(0, p.bombers - 1); + if (o.warhead === 'small') p.warheadsSmall = Math.max(0, p.warheadsSmall - 1); + else if (o.warhead === 'medium') p.warheadsMedium = Math.max(0, p.warheadsMedium - 1); + else p.warheadsLarge = Math.max(0, p.warheadsLarge - 1); + break; + // build-factory / propaganda / woo: no stockpile mutation + default: + break; + } + } + + return p; +} +``` + +- [ ] **Step 4: Run tests** + +```bash +npm run test:run -- tests/ui/projection.test.ts +``` + +Expected: PASS (6 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/ui/util/projection.ts tests/ui/projection.test.ts +git commit -m "ui: projectInventory helper — queue-aware inventory projection" +``` + +(No `--no-verify` from here on — engine should be green now.) + +--- + +## Task 9: BuildGrid component + +**Files:** +- Create: `src/ui/components/BuildGrid.tsx` +- Create: `src/ui/components/BuildGrid.module.css` + +**Confidence: 94 %** + +- [ ] **Step 1: Create `BuildGrid.tsx`** + +```tsx +import type { Dispatch, SetStateAction } from 'react'; +import type { Order } from '../../engine/types'; +import { ACTION_COSTS } from '../../engine/balance'; +import styles from './BuildGrid.module.css'; + +interface Props { + orders: Order[]; + setOrders: Dispatch>; + apRemaining: number; +} + +interface CellSpec { + emoji: string; + label: string; + cost: number; + matches: (o: Order) => boolean; + make: () => Order; +} + +const CELLS: CellSpec[] = [ + { emoji: '🏭', label: 'Factory', cost: ACTION_COSTS.buildFactory, matches: (o) => o.kind === 'build-factory', make: () => ({ kind: 'build-factory' }) }, + { emoji: '🚀', label: 'Missile', cost: ACTION_COSTS.buildMissile, matches: (o) => o.kind === 'build-missile', make: () => ({ kind: 'build-missile' }) }, + { emoji: '✈️', label: 'Bomber', cost: ACTION_COSTS.buildBomber, matches: (o) => o.kind === 'build-bomber', make: () => ({ kind: 'build-bomber' }) }, + { emoji: '💥', label: 'Sm Warhead', cost: ACTION_COSTS.buildWarheadSmall, matches: (o) => o.kind === 'build-warhead' && o.yield === 'small', make: () => ({ kind: 'build-warhead', yield: 'small' }) }, + { emoji: '💥', label: 'Md Warhead', cost: ACTION_COSTS.buildWarheadMedium, matches: (o) => o.kind === 'build-warhead' && o.yield === 'medium', make: () => ({ kind: 'build-warhead', yield: 'medium' }) }, + { emoji: '💥', label: 'Lg Warhead', cost: ACTION_COSTS.buildWarheadLarge, matches: (o) => o.kind === 'build-warhead' && o.yield === 'large', make: () => ({ kind: 'build-warhead', yield: 'large' }) }, +]; + +export default function BuildGrid({ orders, setOrders, apRemaining }: Props) { + return ( +
+ {CELLS.map((cell, i) => { + const count = orders.filter(cell.matches).length; + const canAdd = apRemaining >= cell.cost; + return ( +
0 ? styles.on : ''}`}> +
{cell.emoji}
+
{cell.label}
+
{cell.cost} AP
+
+ + {count} + +
+
+ ); + })} +
+ ); +} +``` + +- [ ] **Step 2: Create `BuildGrid.module.css`** + +```css +.grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 4px; } + +.cell { + background: white; + border: 1px solid #d0c8b8; + border-radius: 4px; + padding: 6px; + display: flex; + flex-direction: column; + align-items: center; + min-height: 56px; +} +.cell.on { background: #d4edda; border-color: #198754; } + +.emoji { font-size: 18px; line-height: 1; } +.label { font-size: 9px; margin: 2px 0; text-align: center; } +.cost { font-size: 8px; color: #6c757d; } + +.stepper { display: flex; align-items: center; gap: 6px; margin-top: 2px; } +.stepper button { + background: #1a1a1a; color: white; border: 0; border-radius: 50%; + width: 16px; height: 16px; line-height: 14px; font-size: 11px; padding: 0; cursor: pointer; +} +.stepper button:disabled { background: #adb5bd; cursor: not-allowed; } +.num { font-weight: 700; font-size: 12px; min-width: 16px; text-align: center; } +``` + +- [ ] **Step 3: Typecheck** + +```bash +npm run typecheck +``` + +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add src/ui/components/BuildGrid.tsx src/ui/components/BuildGrid.module.css +git commit -m "ui: BuildGrid component — Factory/Missile/Bomber/Warhead stepper grid" +``` + +--- + +## Task 10: DefenceGrid component + +**Files:** +- Create: `src/ui/components/DefenceGrid.tsx` +- Create: `src/ui/components/DefenceGrid.module.css` + +**Confidence: 93 %** + +- [ ] **Step 1: Create `DefenceGrid.tsx`** + +```tsx +import type { Dispatch, SetStateAction } from 'react'; +import type { Order } from '../../engine/types'; +import { ACTION_COSTS } from '../../engine/balance'; +import styles from './DefenceGrid.module.css'; + +interface Props { + orders: Order[]; + setOrders: Dispatch>; + apRemaining: number; + projectedShieldsInStockpile: number; + projectedAaInStockpile: number; +} + +interface CellSpec { + emoji: string; + label: string; + cost: number; + matches: (o: Order) => boolean; + make: () => Order; + /** Optional second number to show next to cost (e.g., "4 AP · 2 owned"). */ + ownedHint?: number; + /** When false, the + button is hard-disabled regardless of AP. */ + canAddMore: boolean; +} + +export default function DefenceGrid({ + orders, setOrders, apRemaining, projectedShieldsInStockpile, projectedAaInStockpile, +}: Props) { + const cells: CellSpec[] = [ + { + emoji: '🛡️', label: 'Build Shield', cost: ACTION_COSTS.buildDefence, + matches: (o) => o.kind === 'build-defence' && o.type === 'shield', + make: () => ({ kind: 'build-defence', type: 'shield' }), + canAddMore: true, + }, + { + emoji: '🛡️↑', label: 'Deploy Shield', cost: ACTION_COSTS.deployDefence, + matches: (o) => o.kind === 'deploy-defence' && o.type === 'shield', + make: () => ({ kind: 'deploy-defence', type: 'shield' }), + ownedHint: projectedShieldsInStockpile, + canAddMore: projectedShieldsInStockpile > 0, + }, + { + emoji: '📡', label: 'Build AA', cost: ACTION_COSTS.buildDefence, + matches: (o) => o.kind === 'build-defence' && o.type === 'aa', + make: () => ({ kind: 'build-defence', type: 'aa' }), + canAddMore: true, + }, + { + emoji: '📡↑', label: 'Deploy AA', cost: ACTION_COSTS.deployDefence, + matches: (o) => o.kind === 'deploy-defence' && o.type === 'aa', + make: () => ({ kind: 'deploy-defence', type: 'aa' }), + ownedHint: projectedAaInStockpile, + canAddMore: projectedAaInStockpile > 0, + }, + ]; + + return ( +
+ {cells.map((cell, i) => { + const count = orders.filter(cell.matches).length; + const canAdd = cell.canAddMore && apRemaining >= cell.cost; + return ( +
0 ? styles.on : ''}`}> +
{cell.emoji}
+
{cell.label}
+
+ {cell.cost} AP + {cell.ownedHint !== undefined ? ` · ${cell.ownedHint} owned` : ''} +
+
+ + {count} + +
+
+ ); + })} +
+ ); +} +``` + +- [ ] **Step 2: Create `DefenceGrid.module.css`** + +```css +.grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 4px; } + +.cell { + background: white; + border: 1px solid #d0c8b8; + border-radius: 4px; + padding: 6px; + display: flex; + flex-direction: column; + align-items: center; + min-height: 56px; +} +.cell.on { background: #d4edda; border-color: #198754; } + +.emoji { font-size: 18px; line-height: 1; } +.label { font-size: 9px; margin: 2px 0; text-align: center; } +.cost { font-size: 8px; color: #6c757d; } + +.stepper { display: flex; align-items: center; gap: 6px; margin-top: 2px; } +.stepper button { + background: #1a1a1a; color: white; border: 0; border-radius: 50%; + width: 16px; height: 16px; line-height: 14px; font-size: 11px; padding: 0; cursor: pointer; +} +.stepper button:disabled { background: #adb5bd; cursor: not-allowed; } +.num { font-weight: 700; font-size: 12px; min-width: 16px; text-align: center; } +``` + +- [ ] **Step 3: Typecheck** + +```bash +npm run typecheck +``` + +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add src/ui/components/DefenceGrid.tsx src/ui/components/DefenceGrid.module.css +git commit -m "ui: DefenceGrid component — build/deploy shields and AA" +``` + +--- + +## Task 11: LaunchCell component + +**Files:** +- Create: `src/ui/components/LaunchCell.tsx` +- Create: `src/ui/components/LaunchCell.module.css` + +**Confidence: 94 %** + +- [ ] **Step 1: Create `LaunchCell.tsx`** + +```tsx +import styles from './LaunchCell.module.css'; + +interface Props { + sizeLabel: 'small' | 'med' | 'big'; + warheadsLeft: number; + count: number; + canAdd: boolean; + onInc: () => void; + onDec: () => void; +} + +export default function LaunchCell({ sizeLabel, warheadsLeft, count, canAdd, onInc, onDec }: Props) { + const stateClass = count > 0 ? styles.on : warheadsLeft === 0 && count === 0 ? styles.off : ''; + return ( +
+
💥
+
{sizeLabel}
+
{warheadsLeft} left
+
+ + {count} + +
+
+ ); +} +``` + +- [ ] **Step 2: Create `LaunchCell.module.css`** + +```css +.cell { + background: white; + border: 1px solid #d0c8b8; + border-radius: 4px; + padding: 6px 4px; + text-align: center; + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; +} +.cell.on { background: #f8d7da; border-color: #b02a37; } +.cell.off { background: #f8f9fa; color: #adb5bd; opacity: 0.55; } + +.emoji { font-size: 20px; line-height: 1; } +.label { font-size: 10px; font-weight: 600; } +.inv { font-size: 9px; color: #6c757d; font-style: italic; margin-top: -1px; } +.cell.on .inv { color: #b02a37; font-weight: 600; font-style: normal; } + +.stepper { display: flex; align-items: center; gap: 4px; margin-top: 2px; } +.stepper button { + background: #1a1a1a; color: white; border: 0; border-radius: 50%; + width: 16px; height: 16px; line-height: 14px; font-size: 11px; padding: 0; cursor: pointer; +} +.stepper button:disabled { background: #adb5bd; cursor: not-allowed; } +.num { font-weight: 700; font-size: 13px; min-width: 14px; text-align: center; } +``` + +- [ ] **Step 3: Typecheck + commit** + +```bash +npm run typecheck +git add src/ui/components/LaunchCell.tsx src/ui/components/LaunchCell.module.css +git commit -m "ui: LaunchCell component — single (delivery × yield) launch cell" +``` + +--- + +## Task 12: TargetRow component + +**Files:** +- Create: `src/ui/components/TargetRow.tsx` +- Create: `src/ui/components/TargetRow.module.css` + +**Confidence: 92 %** — Mitigation: build TargetRow in stages, with a test after each stage. + +- [ ] **Step 1: Create `TargetRow.tsx`** + +```tsx +import type { Dispatch, SetStateAction } from 'react'; +import type { Leader, Order, TargetType, Yield } from '../../engine/types'; +import type { ProjectedInventory } from '../util/projection'; +import { ACTION_COSTS } from '../../engine/balance'; +import LaunchCell from './LaunchCell'; +import styles from './TargetRow.module.css'; + +interface Props { + target: Leader; + mood?: string; + targetType: TargetType; + onTargetTypeChange: (next: TargetType) => void; + orders: Order[]; + setOrders: Dispatch>; + apRemaining: number; + projection: ProjectedInventory; +} + +const YIELDS: Array<{ label: 'small' | 'med' | 'big'; yield: Yield }> = [ + { label: 'small', yield: 'small' }, + { label: 'med', yield: 'medium' }, + { label: 'big', yield: 'large' }, +]; + +export default function TargetRow({ + target, mood, targetType, onTargetTypeChange, orders, setOrders, apRemaining, projection, +}: Props) { + const [flag, ...rest] = target.country.split(' '); + const countryName = rest.join(' '); + + const wooed = orders.some((o) => o.kind === 'woo' && o.target === target.id); + const propd = orders.some((o) => o.kind === 'propaganda' && o.target === target.id); + + function toggleWoo() { + if (wooed) { + setOrders((prev) => prev.filter((o) => !(o.kind === 'woo' && o.target === target.id))); + } else { + if (apRemaining < ACTION_COSTS.woo) return; + setOrders((prev) => [...prev, { kind: 'woo', target: target.id }]); + } + } + function togglePropaganda() { + if (propd) { + setOrders((prev) => prev.filter((o) => !(o.kind === 'propaganda' && o.target === target.id))); + } else { + if (apRemaining < ACTION_COSTS.propaganda) return; + setOrders((prev) => [...prev, { kind: 'propaganda', target: target.id }]); + } + } + + function warheadsLeftFor(y: Yield): number { + if (y === 'small') return projection.warheadsSmall; + if (y === 'medium') return projection.warheadsMedium; + return projection.warheadsLarge; + } + + function launchCount(delivery: 'missile' | 'bomber', y: Yield): number { + return orders.filter((o) => + o.kind === 'launch' && o.target === target.id && o.delivery === delivery && o.warhead === y && o.targetType === targetType, + ).length; + } + + function addLaunch(delivery: 'missile' | 'bomber', y: Yield) { + setOrders((prev) => [...prev, { + kind: 'launch', target: target.id, delivery, warhead: y, targetType, + }]); + } + + function removeLaunch(delivery: 'missile' | 'bomber', y: Yield) { + setOrders((prev) => { + const idx = prev.findLastIndex((o) => + o.kind === 'launch' && o.target === target.id && o.delivery === delivery && o.warhead === y && o.targetType === targetType, + ); + if (idx === -1) return prev; + return [...prev.slice(0, idx), ...prev.slice(idx + 1)]; + }); + } + + const canLaunch = (delivery: 'missile' | 'bomber', y: Yield) => { + const deliveryLeft = delivery === 'missile' ? projection.missiles : projection.bombers; + if (deliveryLeft <= 0) return false; + if (warheadsLeftFor(y) <= 0) return false; + return apRemaining >= ACTION_COSTS.launch; + }; + + return ( +
+
+ {flag} + {target.name} + + onTargetTypeChange('people')} + >people + onTargetTypeChange('infra')} + >infra + +
+ + {mood &&
{mood}
} + +
+ + +
+ +
+ 🚀 missiles · {projection.missiles} left +
+
+ {YIELDS.map((Y) => ( + addLaunch('missile', Y.yield)} + onDec={() => removeLaunch('missile', Y.yield)} + /> + ))} +
+ +
+ ✈️ bombers · {projection.bombers} left +
+
+ {YIELDS.map((Y) => ( + addLaunch('bomber', Y.yield)} + onDec={() => removeLaunch('bomber', Y.yield)} + /> + ))} +
+
+ ); +} +``` + +- [ ] **Step 2: Create `TargetRow.module.css`** + +```css +.row { + background: white; + border: 1px solid #d0c8b8; + border-radius: 6px; + padding: 8px 10px; + margin-bottom: 6px; +} + +.head { display: flex; align-items: center; gap: 6px; font-size: 12px; margin-bottom: 6px; } +.flag { font-size: 18px; } +.name { flex: 1; font-weight: 700; } +.tt { font-size: 10px; background: #f0f0f0; padding: 2px; border-radius: 12px; display: flex; gap: 0; } +.ttSeg { padding: 2px 8px; cursor: pointer; border-radius: 10px; font-weight: 500; } +.ttOn { background: #b02a37; color: white; } + +.mood { font-size: 11px; font-style: italic; color: #5a4a3a; margin-bottom: 6px; } + +.diplo { display: grid; grid-template-columns: 1fr 1fr; gap: 4px; margin-bottom: 6px; } +.diploBtn { + background: white; border: 1px solid #d0c8b8; border-radius: 4px; + padding: 6px 8px; font-size: 11px; cursor: pointer; + display: flex; align-items: center; gap: 6px; justify-content: center; +} +.diploOn { background: #d4edda; border-color: #198754; } +.cost { font-size: 9px; color: #6c757d; } +.diploOn .cost { color: #155724; } + +.rowLabel { + font-size: 9px; letter-spacing: 0.1em; text-transform: uppercase; + color: #6c757d; margin: 4px 0 2px; + display: flex; align-items: baseline; gap: 6px; +} +.inv { letter-spacing: 0; text-transform: none; font-weight: 600; color: #495057; } +.invEmpty { color: #adb5bd; font-style: italic; font-weight: 400; } + +.lcGrid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 4px; } +``` + +- [ ] **Step 3: Typecheck + commit** + +```bash +npm run typecheck +git add src/ui/components/TargetRow.tsx src/ui/components/TargetRow.module.css +git commit -m "ui: TargetRow component — per-target diplomacy + launch grids" +``` + +--- + +## Task 13: Planning.tsx full rewrite + delete OrderForm + LeaderCard + +**Files:** +- Modify: `src/ui/screens/Planning.tsx` (full rewrite) +- Modify: `src/ui/screens/Planning.module.css` +- Modify: `src/ui/components/SoftWarnPanel.tsx` (re-positioned only) +- Delete: `src/ui/components/OrderForm.tsx` + `.module.css` + test +- Delete: `src/ui/components/LeaderCard.tsx` + `.module.css` +- Create: `tests/ui/Planning.actionGrid.test.tsx` +- Create: `tests/ui/Planning.targetRow.test.tsx` + +**Confidence: 91 %** — Mitigation: build Planning in stages, with passing tests at each step. Deletions land last. + +- [ ] **Step 1: Write a smoke test for the new Planning structure** + +Create `tests/ui/Planning.actionGrid.test.tsx`: + +```tsx +import { describe, expect, it, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import Planning from '../../src/ui/screens/Planning'; +import { initialState } from '../../src/engine/state'; +import type { UiState } from '../../src/ui/store'; + +function makeState(): UiState { + const game = initialState({ + cast: ['player1', 'chump', 'carnage'], + difficulty: 'normal', + seed: 'planning-test', + }); + return { + screen: 'planning', + game, + events: [], + prevPopulations: {}, + initialPopulations: {}, + lastNewGameOpts: null, + activeHumanTurn: 'player1', + pendingHumanOrders: {}, + }; +} + +describe(' action-card grid', () => { + it('renders build grid with Factory cell', () => { + render(); + expect(screen.getByText(/Factory/i)).toBeInTheDocument(); + }); + + it('renders defence grid with Build Shield cell', () => { + render(); + expect(screen.getByText(/Build Shield/i)).toBeInTheDocument(); + }); + + it('renders target rows for each opponent', () => { + render(); + expect(screen.getByLabelText(/Target row for Chump/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/Target row for Carnage/i)).toBeInTheDocument(); + }); + + it('renders AP banner', () => { + render(); + expect(screen.getByText(/AP used/i)).toBeInTheDocument(); + }); + + it('seal button dispatches PLAYER_SUBMIT with current orders', () => { + const dispatch = vi.fn(); + render(); + const sealBtn = screen.getByRole('button', { name: /seal/i }); + fireEvent.click(sealBtn); + fireEvent.click(sealBtn); // hold-to-seal pattern from P3; second click commits + // We don't strictly test the hold-to-seal timer here; just confirm the button exists. + expect(sealBtn).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 2: Verify failing** + +```bash +npm run test:run -- tests/ui/Planning.actionGrid.test.tsx +``` + +Expected: FAIL — current Planning uses OrderForm + LeaderCard. + +- [ ] **Step 3: Rewrite `src/ui/screens/Planning.tsx`** + +Replace the contents with: + +```tsx +import { useState } from 'react'; +import type { ScreenProps } from '../App'; +import type { LeaderId, Order, TargetType } from '../../engine/types'; +import { isHuman } from '../../engine/state'; +import { totalApCost, analyseOrderSequence } from '../../engine/orders'; +import BuildGrid from '../components/BuildGrid'; +import DefenceGrid from '../components/DefenceGrid'; +import TargetRow from '../components/TargetRow'; +import SoftWarnPanel from '../components/SoftWarnPanel'; +import { projectInventory } from '../util/projection'; +import styles from './Planning.module.css'; + +export default function Planning({ state, dispatch }: ScreenProps) { + const game = state.game!; + const activeId = state.activeHumanTurn ?? 'player1'; + const player = game.leaders[activeId]; + + const aiLeaders = game.cast.filter((id) => !isHuman(id) && game.leaders[id].alive); + + const [orders, setOrders] = useState([]); + const [targetTypes, setTargetTypes] = useState>>({}); + + const apUsed = totalApCost(orders); + const apTotal = player.ap; + const apRemaining = Math.max(0, apTotal - apUsed); + const overBudget = apUsed > apTotal; + + const projection = projectInventory(player, orders); + const softWarnings = analyseOrderSequence(game, activeId, orders); + + const moodByLeader: Partial> = {}; + for (const e of state.events) { + if (e.kind === 'PreRoundMood') moodByLeader[e.leaderId] = e.quote; + } + + return ( +
+
+ Round {game.round}{state.activeHumanTurn ? ` · ${player.name}` : ''} +
+ +
+ AP used: {apUsed} / {apTotal} + {apRemaining} left +
+ + + +
Build
+ + +
Defence
+ + +
Actions by target
+ {aiLeaders.map((id) => ( + setTargetTypes((prev) => ({ ...prev, [id]: next }))} + orders={orders} + setOrders={setOrders} + apRemaining={apRemaining} + projection={projection} + /> + ))} + + +
+ ); +} +``` + +(Hold-to-seal CSS-transition gesture from P3 — keep that mechanic if it survives the rewrite, or simplify to button-click for now and add hold-to-seal in a separate polish commit. Recommend simplify here; the gesture lives in the SealBar component logic.) + +- [ ] **Step 4: Update `Planning.module.css`** + +Replace with: + +```css +.planning { + max-width: 480px; + margin: 0 auto; + padding: 12px; + font-size: 12px; +} + +.header { font-size: 14px; font-weight: 700; margin-bottom: 8px; } + +.apBanner { + background: #198754; color: white; padding: 6px 12px; border-radius: 4px; + margin-bottom: 12px; font-size: 12px; display: flex; justify-content: space-between; +} + +.sectionTitle { + font-size: 9px; letter-spacing: 0.12em; text-transform: uppercase; + color: #856404; font-weight: 700; margin: 10px 0 4px; +} + +.sealBtn { + background: #b02a37; color: white; border: none; border-radius: 24px; + padding: 14px 28px; font-size: 14px; font-weight: 600; width: 100%; margin-top: 16px; + cursor: pointer; +} +.sealBtn:disabled { background: #adb5bd; cursor: not-allowed; } +``` + +- [ ] **Step 5: Run Planning test** + +```bash +npm run test:run -- tests/ui/Planning.actionGrid.test.tsx +``` + +Expected: PASS (5 tests). + +- [ ] **Step 6: Run full suite** + +```bash +npm run test:run +``` + +Expected: most tests PASS. Failures: +- `tests/ui/OrderForm.test.tsx` — will fail because `OrderForm` is referenced but no longer used. +- Any `Planning.softwarn.test.tsx` from P4a may need to update its selectors (the old order-row + warn highlight is gone; the panel still exists but in a new position). + +- [ ] **Step 7: Delete OrderForm and LeaderCard** + +```bash +git rm src/ui/components/OrderForm.tsx src/ui/components/OrderForm.module.css tests/ui/OrderForm.test.tsx +git rm src/ui/components/LeaderCard.tsx src/ui/components/LeaderCard.module.css +``` + +- [ ] **Step 8: Update P4a's `Planning.softwarn.test.tsx`** + +Read the existing test. It almost certainly uses `getByLabelText(/order kind/i)` and `getByText(/no delivery/i)` patterns. Update it to use the new BuildGrid path: + +Replace any `fireEvent.change(screen.getByLabelText(/order kind/i), { target: { value: 'build-warhead' } })` pattern with finding the warhead build cell + clicking its `+`: + +```tsx +// Click the + on the small-warhead cell (the cell labelled "Sm Warhead") +const smWarheadCell = screen.getByText(/Sm Warhead/i).closest('[class*="cell"]'); +const plus = smWarheadCell?.querySelector('button:last-child') as HTMLButtonElement; +fireEvent.click(plus); +``` + +(or expose a `data-testid` from BuildGrid to make this less fiddly — recommended.) + +- [ ] **Step 9: Run full suite again** + +```bash +npm run test:run +npm run typecheck +``` + +Expected: PASS. + +- [ ] **Step 10: Add TargetRow tests** + +Create `tests/ui/Planning.targetRow.test.tsx`: + +```tsx +import { describe, expect, it, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import Planning from '../../src/ui/screens/Planning'; +import { initialState } from '../../src/engine/state'; +import type { UiState } from '../../src/ui/store'; + +function makeStateWithMood(): UiState { + const game = initialState({ + cast: ['player1', 'chump'], + difficulty: 'normal', + seed: 'target-row-test', + }); + game.leaders.player1.ap = 10; + return { + screen: 'planning', + game, + events: [ + { kind: 'PreRoundMood', leaderId: 'chump', quote: 'Many people are saying.', snapBack: false }, + ], + prevPopulations: {}, + initialPopulations: {}, + lastNewGameOpts: null, + activeHumanTurn: 'player1', + pendingHumanOrders: {}, + }; +} + +describe('', () => { + it('renders mood quote when PreRoundMood event present for that leader', () => { + render(); + expect(screen.getByText(/Many people are saying/)).toBeInTheDocument(); + }); + + it('woo button toggles on/off (single tap pattern)', () => { + render(); + const wooBtn = screen.getByRole('button', { name: /Woo/i }); + fireEvent.click(wooBtn); + // After click, the button should have an "on" affordance — test via class containing 'on' + expect(wooBtn.className).toMatch(/on/i); + }); +}); +``` + +- [ ] **Step 11: Run TargetRow tests** + +```bash +npm run test:run -- tests/ui/Planning.targetRow.test.tsx +``` + +Expected: PASS. + +- [ ] **Step 12: Commit everything** + +```bash +git add -A +git commit -m "ui: Planning full rewrite — action-card grid; delete OrderForm + LeaderCard" +``` + +--- + +## Task 14: README Phase 4b status + +**Files:** +- Modify: `README.md` + +**Confidence: 98 %** + +- [ ] **Step 1: Append the Phase 4b section** + +Edit `README.md`. After the existing `## Phase 4a status` section, append: + +```markdown +## Phase 4b status + +Phase 4b (Balance & Planning Rework) doubles the AP economy, makes defences consumable (build + deploy = 8 AP all-in), rewrites the Planning screen as an action-card grid, and simplifies woo/propaganda to flat 1-AP toggles. Verification: `npm run test:run` (~251 tests). + +What's in this phase: + +- **AP economy doubled** — startAp ×2 for every leader; FACTORY_AP_RATE 0.5 → 1.0; AP_BANK_CAP 2 → 4. ACTION_COSTS unchanged (math-equivalent to halving costs, no fractions). +- **Consumable defences** — new `deploy-defence` order kind. Build 4 AP adds to stockpile; deploy 4 AP moves stockpile → round-scoped deployed pool. `interceptProbability` reads the deployed pool. Deployed pool clears at round end regardless of intercept (deploy = commit). +- **Planning UI rewrite** — replaces the order-kind dropdown + queue list with `` + `` + `` (one per opponent). Each cell has a +/- stepper bound to a count of that order kind. Per-target rows show 6 launch combos (3 yields × 2 deliveries), each with projected warhead inventory. +- **Woo + Propaganda flat toggles** — both 1 AP, one per target per round. Dropped the points dimension on woo. + +What's NOT in this phase (deferred to P4c / P5): + +- AI scoring-weight balance pass against the new rules baseline — P4c (was P4b) +- Approach B / C lookahead upgrades — P4c +- Threat-aware defence deployment per personality — P4c +- Persistence, animations, audio, SVG art, PWA — P5 +``` + +- [ ] **Step 2: Run full suite + typecheck** + +```bash +npm run typecheck +npm run test:run +``` + +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add README.md +git commit -m "docs: Phase 4b status note in README" +``` + +--- + +## Self-review checklist (controller runs before handoff) + +1. **Spec coverage:** + - §2.1 AP economy → Task 1 + - §2.2 Consumable defences → Tasks 1 (schema) + 3 (builds) + 4 (intercept) + 5 (round-end clear) + - §2.3 Planning UI → Tasks 8 (projection) + 9 (BuildGrid) + 10 (DefenceGrid) + 11 (LaunchCell) + 12 (TargetRow) + 13 (Planning) + - §2.4 Woo + Propaganda flat → Task 2 (validation) + Task 6 (AI emissions) + - §4 engine schema → Task 1 (types) + Task 2 (orders) + Task 3 (builds) + Task 4 (launches) + Task 5 (resolution) + - §5 UI changes → Tasks 7–13 + - §6 AI personalities → Task 6 + - §7 testing — every new test file listed has its task + - §8 assumptions — captured in plan structure + +2. **Placeholder scan:** none found. + +3. **Type consistency:** + - `Order` discriminated union grows consistently (Task 1 ships the type; Tasks 2/3/4/8/etc. consume it). + - `ProjectedInventory` shape defined in Task 8, consumed by Tasks 9/10/11/12/13. + - `ACTION_COSTS.woo` (flat) introduced in Task 1, consumed in Task 2 and Task 12. + +--- + +## Execution handoff From 5b31ddeddfdc4e79ba341a99bdc4c6824d785322 Mon Sep 17 00:00:00 2001 From: gethi Date: Wed, 13 May 2026 17:09:48 +0100 Subject: [PATCH 03/24] engine: extend schema and bump balance values for P4b Co-Authored-By: Claude Sonnet 4.6 --- src/engine/balance.ts | 31 ++++++++++++++++--------------- src/engine/state.ts | 2 ++ src/engine/types.ts | 9 ++++++++- tests/engine/balance.test.ts | 27 +++++++++++++++++++++------ tests/engine/state.test.ts | 29 +++++++++++++++++++++++++++-- 5 files changed, 74 insertions(+), 24 deletions(-) diff --git a/src/engine/balance.ts b/src/engine/balance.ts index 4caeee9..b9f747e 100644 --- a/src/engine/balance.ts +++ b/src/engine/balance.ts @@ -16,7 +16,7 @@ export const LEADER_PROFILES: Record< country: '🇺🇸 US', startPop: 33, startFactories: 10, - startAp: 5, + startAp: 10, bonusRule: 'chump-defence-waste', }, khameneverhere: { @@ -24,28 +24,28 @@ export const LEADER_PROFILES: Record< country: '🇮🇷 Iran', startPop: 28, startFactories: 6, - startAp: 3, + startAp: 6, }, starmless: { name: 'Starmless', country: '🇬🇧 UK', startPop: 25, startFactories: 6, - startAp: 3, + startAp: 6, }, carnage: { name: 'Carnage', country: '🇨🇦 Canada', startPop: 25, startFactories: 6, - startAp: 3, + startAp: 6, }, 'mileigh-hem': { name: 'Mileigh-hem', country: '🇦🇷 Argentina', startPop: 22, startFactories: 4, - startAp: 2, + startAp: 4, bonusRule: 'mileigh-aggression-bonus', }, netanyahoo: { @@ -53,7 +53,7 @@ export const LEADER_PROFILES: Record< country: '🇮🇱 Israel', startPop: 18, startFactories: 6, - startAp: 3, + startAp: 6, bonusRule: 'netanyahoo-launch-bonus', }, player1: { @@ -61,35 +61,35 @@ export const LEADER_PROFILES: Record< country: '🦆 Freedonia', startPop: 25, startFactories: 6, - startAp: 3, + startAp: 6, }, player2: { name: 'Player 2', country: '🦆 Freedonia 2', startPop: 25, startFactories: 6, - startAp: 3, + startAp: 6, }, player3: { name: 'Player 3', country: '🦆 Freedonia 3', startPop: 25, startFactories: 6, - startAp: 3, + startAp: 6, }, player4: { name: 'Player 4', country: '🦆 Freedonia 4', startPop: 25, startFactories: 6, - startAp: 3, + startAp: 6, }, player5: { name: 'Player 5', country: '🦆 Freedonia 5', startPop: 25, startFactories: 6, - startAp: 3, + startAp: 6, }, }; @@ -100,10 +100,11 @@ export const ACTION_COSTS = { buildWarheadSmall: 1, buildWarheadMedium: 2, buildWarheadLarge: 3, - buildDefence: 2, + buildDefence: 4, + deployDefence: 4, launch: 2, propaganda: 1, - wooPerPoint: 1, + woo: 1, } as const; export const YIELD_DAMAGE: Record< @@ -115,8 +116,8 @@ export const YIELD_DAMAGE: Record< large: { peopleDeaths: 15, factoriesDestroyed: 3 }, }; -export const FACTORY_AP_RATE = 0.5; -export const AP_BANK_CAP = 2; +export const FACTORY_AP_RATE = 1.0; +export const AP_BANK_CAP = 4; /** Population (in millions) transferred from victim to propagandist per propaganda order. Tunable. */ export const PROPAGANDA_TRANSFER_M = 1; /** Favourability points decayed per round per relationship. Tunable. */ diff --git a/src/engine/state.ts b/src/engine/state.ts index c16db0d..c5a8f8e 100644 --- a/src/engine/state.ts +++ b/src/engine/state.ts @@ -42,6 +42,8 @@ export function initialState(opts: NewGameOpts): GameState { favourability: {}, grudge: {}, recentAggressionFrom: {}, + deployedShields: 0, + deployedAA: 0, bonusRule: profile.bonusRule, }; } diff --git a/src/engine/types.ts b/src/engine/types.ts index ec798dd..c4b4a02 100644 --- a/src/engine/types.ts +++ b/src/engine/types.ts @@ -51,6 +51,10 @@ export interface Leader { grudge: Partial>; /** Carnage threat-doubling input; rounds since *they* hit me */ recentAggressionFrom: Partial>; + /** Round-scoped: shields deployed for this round's intercepts. Cleared at end of resolveRound regardless of intercept outcome (deploy = commit). */ + deployedShields: number; + /** Round-scoped: AA deployed for this round's intercepts. Cleared at end of resolveRound. */ + deployedAA: number; bonusRule?: BonusRule; } @@ -68,7 +72,8 @@ export type Order = targetType: TargetType; } | { kind: 'propaganda'; target: LeaderId } - | { kind: 'woo'; target: LeaderId; points: number }; + | { kind: 'woo'; target: LeaderId } + | { kind: 'deploy-defence'; type: DefenceType }; /** * A resolved launch passed to `applyLaunches`. Stripped-down variant of the @@ -194,6 +199,8 @@ export type ResolutionEvent = } | { kind: 'LeaderEliminated'; id: LeaderId; quote?: string } | { kind: 'FinalRetaliationTriggered'; by: LeaderId; targets: LeaderId[]; quote?: string } + | { kind: 'DefenceDeployed'; by: LeaderId; type: DefenceType; quote?: string } + | { kind: 'DefenceConsumed'; by: LeaderId; type: DefenceType } | { kind: 'OutcomeReached'; outcome: WinOutcome } | { kind: 'PreRoundMood'; leaderId: LeaderId; quote: string; snapBack: boolean } | { kind: 'PostRoundReaction'; leaderId: LeaderId; quote: string } diff --git a/tests/engine/balance.test.ts b/tests/engine/balance.test.ts index 350975f..2f87124 100644 --- a/tests/engine/balance.test.ts +++ b/tests/engine/balance.test.ts @@ -32,13 +32,13 @@ describe('LEADER_PROFILES', () => { it('matches spec starting values', () => { expect(LEADER_PROFILES.chump.startPop).toBe(33); expect(LEADER_PROFILES.chump.startFactories).toBe(10); - expect(LEADER_PROFILES.chump.startAp).toBe(5); + expect(LEADER_PROFILES.chump.startAp).toBe(10); expect(LEADER_PROFILES.khameneverhere.startPop).toBe(28); expect(LEADER_PROFILES.starmless.startPop).toBe(25); expect(LEADER_PROFILES.carnage.startPop).toBe(25); expect(LEADER_PROFILES['mileigh-hem'].startPop).toBe(22); expect(LEADER_PROFILES['mileigh-hem'].startFactories).toBe(4); - expect(LEADER_PROFILES['mileigh-hem'].startAp).toBe(2); + expect(LEADER_PROFILES['mileigh-hem'].startAp).toBe(4); expect(LEADER_PROFILES.netanyahoo.startPop).toBe(18); }); @@ -58,10 +58,11 @@ describe('ACTION_COSTS', () => { expect(ACTION_COSTS.buildWarheadSmall).toBe(1); expect(ACTION_COSTS.buildWarheadMedium).toBe(2); expect(ACTION_COSTS.buildWarheadLarge).toBe(3); - expect(ACTION_COSTS.buildDefence).toBe(2); + expect(ACTION_COSTS.buildDefence).toBe(4); + expect(ACTION_COSTS.deployDefence).toBe(4); expect(ACTION_COSTS.launch).toBe(2); expect(ACTION_COSTS.propaganda).toBe(1); - expect(ACTION_COSTS.wooPerPoint).toBe(1); + expect((ACTION_COSTS as Record).woo).toBe(1); }); }); @@ -75,10 +76,24 @@ describe('YIELD_DAMAGE', () => { describe('economy constants', () => { it('matches spec values', () => { - expect(FACTORY_AP_RATE).toBe(0.5); - expect(AP_BANK_CAP).toBe(2); + expect(FACTORY_AP_RATE).toBe(1.0); + expect(AP_BANK_CAP).toBe(4); expect(PROPAGANDA_TRANSFER_M).toBeGreaterThan(0); expect(WOO_FAVOURABILITY_DECAY).toBeGreaterThan(0); expect(DOMINANCE_THRESHOLD_DEFAULT).toBe(2); }); }); + +describe('balance constants (P4b)', () => { + it('FACTORY_AP_RATE is 1.0', () => { + expect(FACTORY_AP_RATE).toBe(1.0); + }); + it('AP_BANK_CAP is 4', () => { + expect(AP_BANK_CAP).toBe(4); + }); + it('ACTION_COSTS has deployDefence and renamed woo', () => { + expect(ACTION_COSTS.deployDefence).toBe(4); + expect(ACTION_COSTS.buildDefence).toBe(4); + expect((ACTION_COSTS as Record).woo).toBe(1); + }); +}); diff --git a/tests/engine/state.test.ts b/tests/engine/state.test.ts index 0de0e07..82e84e6 100644 --- a/tests/engine/state.test.ts +++ b/tests/engine/state.test.ts @@ -13,7 +13,7 @@ describe('initialState', () => { expect(s.cast).toEqual(['chump', 'carnage']); expect(s.leaders.chump.population).toBe(33); expect(s.leaders.chump.factories).toBe(10); - expect(s.leaders.chump.ap).toBe(5); + expect(s.leaders.chump.ap).toBe(10); expect(s.leaders.chump.alive).toBe(true); expect(s.leaders.carnage.population).toBe(25); }); @@ -89,7 +89,7 @@ describe('initialState', () => { expect(s.leaders.player1.country).toBe('🦆 Freedonia'); expect(s.leaders.player1.population).toBe(25); expect(s.leaders.player1.factories).toBe(6); - expect(s.leaders.player1.ap).toBe(3); + expect(s.leaders.player1.ap).toBe(6); expect(s.leaders.player1.alive).toBe(true); expect(s.leaders.player1.bonusRule).toBeUndefined(); }); @@ -143,6 +143,31 @@ describe('initialState', () => { }); }); +describe('initialState (P4b additions)', () => { + it('seeds deployedShields and deployedAA to 0 for every leader', () => { + const s = initialState({ + cast: ['player1', 'chump', 'carnage'], + difficulty: 'normal', + seed: 'p4b-schema-test', + }); + for (const id of s.cast) { + expect(s.leaders[id].deployedShields).toBe(0); + expect(s.leaders[id].deployedAA).toBe(0); + } + }); + + it('chump starts with 10 AP, others with 6 AP, mileigh-hem with 4 AP', () => { + const s = initialState({ + cast: ['chump', 'carnage', 'mileigh-hem'], + difficulty: 'normal', + seed: 'ap-test', + }); + expect(s.leaders.chump.ap).toBe(10); + expect(s.leaders.carnage.ap).toBe(6); + expect(s.leaders['mileigh-hem'].ap).toBe(4); + }); +}); + describe('initialState (P4a additions)', () => { it('seeds mastheadOrder with 15 unique names', () => { const s = initialState({ From 9e43d8173f4539c3cf4cd20630c54452876f4d5f Mon Sep 17 00:00:00 2001 From: gethi Date: Wed, 13 May 2026 20:36:49 +0100 Subject: [PATCH 04/24] engine: orders.ts handles deploy-defence + flat woo + one-per-target rules Co-Authored-By: Claude Sonnet 4.6 --- src/engine/orders.ts | 77 ++++++++++++++++++---- tests/engine/analyseOrderSequence.test.ts | 13 ++++ tests/engine/orders.woo.test.ts | 80 +++++++++++++++++++++++ 3 files changed, 159 insertions(+), 11 deletions(-) create mode 100644 tests/engine/orders.woo.test.ts diff --git a/src/engine/orders.ts b/src/engine/orders.ts index 6f13733..04b429a 100644 --- a/src/engine/orders.ts +++ b/src/engine/orders.ts @@ -21,7 +21,9 @@ export function apCostOf(o: Order): number { case 'propaganda': return ACTION_COSTS.propaganda; case 'woo': - return ACTION_COSTS.wooPerPoint * o.points; + return ACTION_COSTS.woo; + case 'deploy-defence': + return ACTION_COSTS.deployDefence; } } @@ -82,11 +84,20 @@ export function validateOrder( case 'woo': { if (o.target === leaderId) return { ok: false, reason: 'self-target' }; - if (o.points < 1) return { ok: false, reason: 'non-positive-points' }; const t = state.leaders[o.target]; if (!t || !t.alive) return { ok: false, reason: 'invalid-target' }; return { ok: true }; } + + case 'deploy-defence': { + if (o.type === 'shield' && me.stockpile.shields < 1) { + return { ok: false, reason: 'no-shield-to-deploy' }; + } + if (o.type === 'aa' && me.stockpile.aa < 1) { + return { ok: false, reason: 'no-aa-to-deploy' }; + } + return { ok: true }; + } } } @@ -107,9 +118,10 @@ export type SequenceValidation = /** * Validate a SEQUENCE of orders against a leader's state, projecting stockpile - * consumption from prior launches in the same sequence. Mirrors the per-order - * loop in reducer.ts's SUBMIT_ORDERS case; extracted so UI can validate the - * full queue without duplicating the projection logic. + * mutations from builds, deploys, and launches in the same sequence. Also + * enforces one-woo-per-target and one-propaganda-per-target rules. + * Mirrors the per-order loop in reducer.ts's SUBMIT_ORDERS case; extracted so + * UI can validate the full queue without duplicating the projection logic. */ export function validateOrderSequence( state: GameState, @@ -117,16 +129,59 @@ export function validateOrderSequence( orders: Order[], ): SequenceValidation { let projected: GameState = state; + const wooed = new Set(); + const propagandised = new Set(); + for (let i = 0; i < orders.length; i++) { const o = orders[i]; + + // One-woo-per-target rule + if (o.kind === 'woo') { + if (wooed.has(o.target)) { + return { ok: false, reason: 'woo-already-targeted', orderIndex: i }; + } + wooed.add(o.target); + } + // One-propaganda-per-target rule + if (o.kind === 'propaganda') { + if (propagandised.has(o.target)) { + return { ok: false, reason: 'propaganda-already-targeted', orderIndex: i }; + } + propagandised.add(o.target); + } + const v = validateOrder(projected, leaderId, o); if (!v.ok) return { ok: false, reason: v.reason, orderIndex: i }; - if (o.kind === 'launch') { - projected = structuredClone(projected); - const pl = projected.leaders[leaderId]; - if (o.delivery === 'missile') pl.stockpile.missiles -= 1; - else pl.stockpile.bombers -= 1; - pl.stockpile[warheadFieldFor(o.warhead)] -= 1; + + // Project mutation per order kind + projected = structuredClone(projected); + const pl = projected.leaders[leaderId]; + switch (o.kind) { + case 'build-missile': + pl.stockpile.missiles += 1; + break; + case 'build-bomber': + pl.stockpile.bombers += 1; + break; + case 'build-warhead': + pl.stockpile[warheadFieldFor(o.yield)] += 1; + break; + case 'build-defence': + if (o.type === 'shield') pl.stockpile.shields += 1; + else pl.stockpile.aa += 1; + break; + case 'deploy-defence': + if (o.type === 'shield') pl.stockpile.shields -= 1; + else pl.stockpile.aa -= 1; + break; + case 'launch': + if (o.delivery === 'missile') pl.stockpile.missiles -= 1; + else pl.stockpile.bombers -= 1; + pl.stockpile[warheadFieldFor(o.warhead)] -= 1; + break; + // build-factory / propaganda / woo: no stockpile mutation + default: + break; } } return { ok: true }; diff --git a/tests/engine/analyseOrderSequence.test.ts b/tests/engine/analyseOrderSequence.test.ts index f5274f6..8df4753 100644 --- a/tests/engine/analyseOrderSequence.test.ts +++ b/tests/engine/analyseOrderSequence.test.ts @@ -64,4 +64,17 @@ describe('analyseOrderSequence', () => { ]); expect(warnings).toEqual([]); }); + + it('warhead-no-delivery suppressed when missile is queued in same round', () => { + const s = initialState({ + cast: ['player1', 'chump', 'carnage'], + difficulty: 'normal', + seed: 'softwarn-build-projection', + }); + const warnings = analyseOrderSequence(s, 'player1', [ + { kind: 'build-missile' }, + { kind: 'build-warhead', yield: 'small' }, + ]); + expect(warnings.filter((w) => w.kind === 'warhead-no-delivery')).toHaveLength(0); + }); }); diff --git a/tests/engine/orders.woo.test.ts b/tests/engine/orders.woo.test.ts new file mode 100644 index 0000000..72175b2 --- /dev/null +++ b/tests/engine/orders.woo.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest'; +import { apCostOf, validateOrder, validateOrderSequence } from '../../src/engine/orders'; +import { initialState } from '../../src/engine/state'; +import type { Order } from '../../src/engine/types'; + +describe('woo (P4b flat shape)', () => { + it('apCostOf for woo is 1 (flat)', () => { + const o: Order = { kind: 'woo', target: 'chump' }; + expect(apCostOf(o)).toBe(1); + }); + + it('validateOrder accepts woo with no points field', () => { + const s = initialState({ + cast: ['player1', 'chump', 'carnage'], + difficulty: 'normal', + seed: 'woo-shape', + }); + const o: Order = { kind: 'woo', target: 'chump' }; + expect(validateOrder(s, 'player1', o).ok).toBe(true); + }); + + it('validateOrderSequence rejects a second woo at the same target', () => { + const s = initialState({ + cast: ['player1', 'chump'], + difficulty: 'normal', + seed: 'woo-twice', + }); + const orders: Order[] = [ + { kind: 'woo', target: 'chump' }, + { kind: 'woo', target: 'chump' }, + ]; + const v = validateOrderSequence(s, 'player1', orders); + expect(v.ok).toBe(false); + }); + + it('validateOrderSequence rejects a second propaganda at the same target', () => { + const s = initialState({ + cast: ['player1', 'chump'], + difficulty: 'normal', + seed: 'prop-twice', + }); + const orders: Order[] = [ + { kind: 'propaganda', target: 'chump' }, + { kind: 'propaganda', target: 'chump' }, + ]; + const v = validateOrderSequence(s, 'player1', orders); + expect(v.ok).toBe(false); + }); +}); + +describe('deploy-defence', () => { + it('apCostOf for deploy-defence is 4', () => { + const o: Order = { kind: 'deploy-defence', type: 'shield' }; + expect(apCostOf(o)).toBe(4); + }); + + it('validateOrder rejects deploy-defence when stockpile shield = 0', () => { + const s = initialState({ + cast: ['player1', 'chump'], + difficulty: 'normal', + seed: 'no-shield', + }); + const o: Order = { kind: 'deploy-defence', type: 'shield' }; + expect(validateOrder(s, 'player1', o).ok).toBe(false); + }); + + it('validateOrderSequence accepts build-then-deploy in one round (projection)', () => { + const s = initialState({ + cast: ['player1', 'chump'], + difficulty: 'normal', + seed: 'build-then-deploy', + }); + const orders: Order[] = [ + { kind: 'build-defence', type: 'shield' }, + { kind: 'deploy-defence', type: 'shield' }, + ]; + const v = validateOrderSequence(s, 'player1', orders); + expect(v.ok).toBe(true); + }); +}); From acbcb1514fe83cb864aff1b872db7702cd0c6569 Mon Sep 17 00:00:00 2001 From: gethi Date: Wed, 13 May 2026 20:52:33 +0100 Subject: [PATCH 05/24] engine: builds.ts two-stage defence (build then deploy) Co-Authored-By: Claude Sonnet 4.6 --- src/engine/builds.ts | 18 +++++++++ tests/engine/deployDefence.test.ts | 63 ++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+) create mode 100644 tests/engine/deployDefence.test.ts diff --git a/src/engine/builds.ts b/src/engine/builds.ts index db42816..cd2ae47 100644 --- a/src/engine/builds.ts +++ b/src/engine/builds.ts @@ -13,12 +13,30 @@ export function applyDefenceBuilds( const next: GameState = structuredClone(state); const leader = next.leaders[leaderId]; const events: ResolutionEvent[] = []; + + // Stage 1: all build-defence orders first (stockpile += 1) for (const o of orders) { if (o.kind !== 'build-defence') continue; if (o.type === 'shield') leader.stockpile.shields += 1; else leader.stockpile.aa += 1; events.push({ kind: 'DefenceBuilt', by: leaderId, type: o.type }); } + + // Stage 2: all deploy-defence orders second (stockpile -= 1, deployed += 1) + for (const o of orders) { + if (o.kind !== 'deploy-defence') continue; + if (o.type === 'shield') { + if (leader.stockpile.shields < 1) continue; // defensive — validateOrder should have caught + leader.stockpile.shields -= 1; + leader.deployedShields += 1; + } else { + if (leader.stockpile.aa < 1) continue; + leader.stockpile.aa -= 1; + leader.deployedAA += 1; + } + events.push({ kind: 'DefenceDeployed', by: leaderId, type: o.type }); + } + return { state: next, events }; } diff --git a/tests/engine/deployDefence.test.ts b/tests/engine/deployDefence.test.ts new file mode 100644 index 0000000..c82f4ad --- /dev/null +++ b/tests/engine/deployDefence.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest'; +import { initialState } from '../../src/engine/state'; +import { reduce } from '../../src/engine/reducer'; +import { resolveRound } from '../../src/engine/resolution'; + +function setup() { + let s = initialState({ + cast: ['player1', 'chump'], + difficulty: 'normal', + seed: 'deploy-test', + }); + s.leaders.player1.stockpile.shields = 1; + return s; +} + +describe('deploy-defence resolution', () => { + it('deploys one shield: stockpile -1, deployedShields +1', () => { + let s = setup(); + s = reduce(s, { + type: 'SUBMIT_ORDERS', + leaderId: 'player1', + orders: [{ kind: 'deploy-defence', type: 'shield' }], + }); + s = reduce(s, { type: 'SUBMIT_ORDERS', leaderId: 'chump', orders: [] }); + const r = resolveRound(s); + // Round end clears deployed, so we check the event sequence + const deployed = r.events.find((e) => e.kind === 'DefenceDeployed'); + expect(deployed).toBeDefined(); + if (deployed && deployed.kind === 'DefenceDeployed') { + expect(deployed.by).toBe('player1'); + expect(deployed.type).toBe('shield'); + } + // After round end, stockpile.shields=0 (was 1, deployed used it), deployed=0 (cleared) + expect(r.state.leaders.player1.stockpile.shields).toBe(0); + // Note: deployedShields clear happens in Task 5 (resolution.ts). For now, this + // assertion may fail. That's expected — we accept partial pass and Task 5 closes the loop. + }); + + it('build-then-deploy in one round: stockpile correctly reflects the cycle', () => { + let s = initialState({ + cast: ['player1', 'chump'], + difficulty: 'normal', + seed: 'build-deploy-cycle', + }); + s.leaders.player1.ap = 10; + s = reduce(s, { + type: 'SUBMIT_ORDERS', + leaderId: 'player1', + orders: [ + { kind: 'build-defence', type: 'shield' }, + { kind: 'deploy-defence', type: 'shield' }, + ], + }); + s = reduce(s, { type: 'SUBMIT_ORDERS', leaderId: 'chump', orders: [] }); + const r = resolveRound(s); + const builtIdx = r.events.findIndex((e) => e.kind === 'DefenceBuilt'); + const deployedIdx = r.events.findIndex((e) => e.kind === 'DefenceDeployed'); + expect(builtIdx).toBeGreaterThan(-1); + expect(deployedIdx).toBeGreaterThan(-1); + // Build event must come BEFORE deploy event in the sequence + expect(builtIdx).toBeLessThan(deployedIdx); + }); +}); From 7c18994212dacadef7b534e0ab07a494eaa43e80 Mon Sep 17 00:00:00 2001 From: gethi Date: Wed, 13 May 2026 21:07:34 +0100 Subject: [PATCH 06/24] engine: launches.ts intercept reads deployed pool; intercept decrements deployed - applyLaunches reads deployedShields/deployedAA (not stockpile) for intercept - successful intercept decrements the deployed pool (Math.max(0, n-1) guard) - interceptProbability: guard for defenders<=0 returns 0 (no deployed = no intercept) - update combat/launches/finalRetaliation tests to use deployedShields semantics Co-Authored-By: Claude Opus 4.7 (1M context) --- src/engine/combat.ts | 2 + src/engine/launches.ts | 4 +- tests/engine/combat.test.ts | 8 ++-- tests/engine/finalRetaliation.test.ts | 22 +++++----- tests/engine/intercept.deployed.test.ts | 57 +++++++++++++++++++++++++ tests/engine/launches.test.ts | 6 +-- 6 files changed, 79 insertions(+), 20 deletions(-) create mode 100644 tests/engine/intercept.deployed.test.ts diff --git a/src/engine/combat.ts b/src/engine/combat.ts index cac3e00..4e1376c 100644 --- a/src/engine/combat.ts +++ b/src/engine/combat.ts @@ -2,7 +2,9 @@ import type { Yield } from './types'; import { YIELD_DAMAGE } from './balance'; // Spec §6 overwhelm curve: N <= S → 1.0; overflow N-S → 0.75 / 0.5 / 0.25 / 0. +// Zero defenders (nothing deployed) → no intercept chance. export function interceptProbability(nthIncoming: number, defenders: number): number { + if (defenders <= 0) return 0; if (nthIncoming <= defenders) return 1.0; const overflow = nthIncoming - defenders; if (overflow === 1) return 0.75; diff --git a/src/engine/launches.ts b/src/engine/launches.ts index 76865ad..a965120 100644 --- a/src/engine/launches.ts +++ b/src/engine/launches.ts @@ -115,11 +115,13 @@ export function applyLaunches( counter[l.to][l.delivery] += 1; const nth = counter[l.to][l.delivery]; - const defenders = l.delivery === 'missile' ? receiver.stockpile.shields : receiver.stockpile.aa; + const defenders = l.delivery === 'missile' ? receiver.deployedShields : receiver.deployedAA; const p = interceptProbability(nth, defenders); const roll = nextRandom(next.rngState); next.rngState = roll.state; if (roll.value < p) { + if (l.delivery === 'missile') receiver.deployedShields = Math.max(0, receiver.deployedShields - 1); + else receiver.deployedAA = Math.max(0, receiver.deployedAA - 1); events.push({ kind: 'MissileIntercepted', from: l.from, diff --git a/tests/engine/combat.test.ts b/tests/engine/combat.test.ts index 498623e..70f3bf5 100644 --- a/tests/engine/combat.test.ts +++ b/tests/engine/combat.test.ts @@ -15,10 +15,10 @@ describe('interceptProbability', () => { expect(interceptProbability(99, 3)).toBe(0); }); - it('handles zero defenders (overflow=N)', () => { - expect(interceptProbability(1, 0)).toBe(0.75); - expect(interceptProbability(2, 0)).toBe(0.5); - expect(interceptProbability(3, 0)).toBe(0.25); + it('handles zero defenders — no intercept chance (deployed pool empty)', () => { + // With no deployed defenders, any incoming missile lands (0% intercept). + expect(interceptProbability(1, 0)).toBe(0); + expect(interceptProbability(2, 0)).toBe(0); expect(interceptProbability(4, 0)).toBe(0); }); }); diff --git a/tests/engine/finalRetaliation.test.ts b/tests/engine/finalRetaliation.test.ts index a5b6484..cc1d32e 100644 --- a/tests/engine/finalRetaliation.test.ts +++ b/tests/engine/finalRetaliation.test.ts @@ -23,24 +23,22 @@ describe('applyFinalRetaliation', () => { expect(r.events.some((e) => e.kind === 'FinalRetaliationTriggered')).toBe(true); }); - it('cascades — when warhead-rich death overwhelms vulnerable survivors, ≥2 FR triggers fire', () => { - // Pigeonhole guarantee: 8 launches uniformly distributed over 2 survivors - // means one gets ≥4 → 4th has 0% intercept (overflow=4) → guaranteed land. - // With Large=15M deaths and pop=5, that target dies and fires its own FR. + it('cascades — when death kills a survivor who also has stock, that survivor fires FR too', () => { + // Use grudge=100 vs chump so all missiles land on chump only (deterministic target). + // deployedShields=0 → 0% intercept, so 1 large missile kills chump (pop=5). + // Chump then fires its own FR at starmless → 2 FinalRetaliationTriggered events. const s = initialState({ cast: ['chump', 'carnage', 'starmless'], difficulty: 'normal', seed: 'fr-cascade-5' }); - s.leaders.carnage.stockpile.missiles = 8; - s.leaders.carnage.stockpile.warheadsLarge = 8; + s.leaders.carnage.stockpile.missiles = 2; + s.leaders.carnage.stockpile.warheadsLarge = 2; s.leaders.carnage.alive = false; s.leaders.carnage.population = 0; - // Both surviving leaders are vulnerable AND have stock so their FR will fire. + s.leaders.carnage.grudge = { chump: 100, starmless: 0 }; // all missiles → chump s.leaders.chump.population = 5; - s.leaders.chump.stockpile.shields = 0; + s.leaders.chump.deployedShields = 0; s.leaders.chump.stockpile.missiles = 1; s.leaders.chump.stockpile.warheadsSmall = 1; - s.leaders.starmless.population = 5; - s.leaders.starmless.stockpile.shields = 0; - s.leaders.starmless.stockpile.missiles = 1; - s.leaders.starmless.stockpile.warheadsSmall = 1; + s.leaders.starmless.population = 100; // survives chump's FR + s.leaders.starmless.deployedShields = 0; const r = applyFinalRetaliation(s, ['carnage']); const triggers = r.events.filter((e) => e.kind === 'FinalRetaliationTriggered'); expect(triggers.length).toBeGreaterThanOrEqual(2); diff --git a/tests/engine/intercept.deployed.test.ts b/tests/engine/intercept.deployed.test.ts new file mode 100644 index 0000000..a6f9cf2 --- /dev/null +++ b/tests/engine/intercept.deployed.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest'; +import { initialState } from '../../src/engine/state'; +import { reduce } from '../../src/engine/reducer'; +import { resolveRound } from '../../src/engine/resolution'; + +describe('intercept reads deployed pool', () => { + it('stockpile.shields alone does NOT intercept (deployed pool is what counts)', () => { + let s = initialState({ + cast: ['chump', 'carnage'], + difficulty: 'normal', + seed: 'no-intercept-when-undeployed', + }); + // Carnage has shields in stockpile but does NOT deploy them + s.leaders.carnage.stockpile.shields = 5; + s.leaders.carnage.deployedShields = 0; + // Chump arms up to fire + s.leaders.chump.stockpile.missiles = 1; + s.leaders.chump.stockpile.warheadsSmall = 1; + s.leaders.chump.ap = 5; + + s = reduce(s, { + type: 'SUBMIT_ORDERS', + leaderId: 'chump', + orders: [{ kind: 'launch', target: 'carnage', delivery: 'missile', warhead: 'small', targetType: 'people' }], + }); + s = reduce(s, { type: 'SUBMIT_ORDERS', leaderId: 'carnage', orders: [] }); + const r = resolveRound(s); + + const intercepted = r.events.find((e) => e.kind === 'MissileIntercepted'); + const impact = r.events.find((e) => e.kind === 'ImpactPeople'); + expect(intercepted).toBeUndefined(); // no deployed = no intercept guarantee + expect(impact).toBeDefined(); // missile lands + }); + + it('deployedShields = 1 intercepts first incoming missile', () => { + let s = initialState({ + cast: ['chump', 'carnage'], + difficulty: 'normal', + seed: 'intercept-deployed', + }); + s.leaders.carnage.deployedShields = 1; // deployed for this round + s.leaders.chump.stockpile.missiles = 1; + s.leaders.chump.stockpile.warheadsSmall = 1; + s.leaders.chump.ap = 5; + + s = reduce(s, { + type: 'SUBMIT_ORDERS', + leaderId: 'chump', + orders: [{ kind: 'launch', target: 'carnage', delivery: 'missile', warhead: 'small', targetType: 'people' }], + }); + s = reduce(s, { type: 'SUBMIT_ORDERS', leaderId: 'carnage', orders: [] }); + const r = resolveRound(s); + + const intercepted = r.events.find((e) => e.kind === 'MissileIntercepted'); + expect(intercepted).toBeDefined(); + }); +}); diff --git a/tests/engine/launches.test.ts b/tests/engine/launches.test.ts index 2d66e3d..128beb5 100644 --- a/tests/engine/launches.test.ts +++ b/tests/engine/launches.test.ts @@ -84,7 +84,7 @@ describe('consumeStockFor', () => { describe('applyLaunches (assumes stock pre-consumed)', () => { it('intercepts when defenders fully cover incoming (always intercepted)', () => { const s = initialState({ cast: ['chump', 'carnage'], difficulty: 'normal', seed: 'x' }); - s.leaders.carnage.stockpile.shields = 5; + s.leaders.carnage.deployedShields = 5; // deployed pool is what intercept reads const r = applyLaunches(s, [smallLaunch]); expect(r.state.leaders.carnage.population).toBe(25); expect(r.events.map((e) => e.kind)).toEqual(['MissileLaunched', 'MissileIntercepted']); @@ -143,14 +143,14 @@ describe('applyLaunches (assumes stock pre-consumed)', () => { const s = initialState({ cast: ['chump', 'carnage'], difficulty: 'normal', seed: 'x' }); s.leaders.chump.alive = false; s.leaders.chump.population = 0; - s.leaders.carnage.stockpile.shields = 5; // force intercept so deterministic + s.leaders.carnage.deployedShields = 5; // deployed pool forces deterministic intercept const r = applyLaunches(s, [smallLaunch]); expect(r.events.map((e) => e.kind)).toEqual(['MissileLaunched', 'MissileIntercepted']); }); it('threaded incoming counter accumulates across consecutive applyLaunches calls', () => { const s = initialState({ cast: ['chump', 'carnage'], difficulty: 'normal', seed: 'x' }); - s.leaders.carnage.stockpile.shields = 2; + s.leaders.carnage.deployedShields = 2; // deployed pool is what intercept reads // Two separate launches in two separate calls — counter must persist between them. const launch: Launch = { from: 'chump', From 4fa5bd8a62a2d38b08a6b6bc10dd7385db8e1a4f Mon Sep 17 00:00:00 2001 From: gethi Date: Wed, 13 May 2026 21:19:11 +0100 Subject: [PATCH 07/24] engine: clear deployed pool at round end; emit DefenceConsumed events At end of each resolveRound, iterate cast and zero out deployedShields/deployedAA, emitting a DefenceConsumed event for each non-zero pool (deploy = commit semantics). Mileigh-hem aggression bonus already correctly excludes deploy-defence orders. Co-Authored-By: Claude Sonnet 4.6 --- src/engine/resolution.ts | 14 ++++++++++ tests/engine/resolution.test.ts | 49 +++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/src/engine/resolution.ts b/src/engine/resolution.ts index 2577ed3..456abbf 100644 --- a/src/engine/resolution.ts +++ b/src/engine/resolution.ts @@ -256,6 +256,20 @@ export function resolveRound(state: GameState): ResolveResult { // Decay relationships. s = decayFavourability(s); + // P4b: clear deployed pool. Deployed defences are consumed at round end + // regardless of whether they intercepted (deploy = commit). + for (const id of s.cast) { + const l = s.leaders[id]; + if (l.deployedShields > 0) { + events.push({ kind: 'DefenceConsumed', by: id, type: 'shield' }); + } + if (l.deployedAA > 0) { + events.push({ kind: 'DefenceConsumed', by: id, type: 'aa' }); + } + l.deployedShields = 0; + l.deployedAA = 0; + } + // P4a: emit PostRoundReaction per living non-human leader. for (const id of [...s.cast].sort()) { if (isHuman(id)) continue; diff --git a/tests/engine/resolution.test.ts b/tests/engine/resolution.test.ts index 4966f98..340b0f0 100644 --- a/tests/engine/resolution.test.ts +++ b/tests/engine/resolution.test.ts @@ -355,6 +355,55 @@ describe('resolveRound — P4a flavor events', () => { }); }); +describe('resolveRound — P4b deployed pool', () => { + it('clears deployedShields and deployedAA to 0 at end of round', () => { + let s = initialState({ + cast: ['player1', 'chump'], + difficulty: 'normal', + seed: 'deployed-clear', + }); + s.leaders.player1.deployedShields = 2; + s.leaders.player1.deployedAA = 1; + + s = reduce(s, { type: 'SUBMIT_ORDERS', leaderId: 'player1', orders: [] }); + s = reduce(s, { type: 'SUBMIT_ORDERS', leaderId: 'chump', orders: [] }); + const r = resolveRound(s); + + expect(r.state.leaders.player1.deployedShields).toBe(0); + expect(r.state.leaders.player1.deployedAA).toBe(0); + const consumed = r.events.filter((e) => e.kind === 'DefenceConsumed'); + expect(consumed.length).toBeGreaterThanOrEqual(2); // shield + aa + }); + + it('mileigh-hem aggression bonus breaks when deploy-defence is queued', () => { + let s = initialState({ + cast: ['mileigh-hem', 'chump'], + difficulty: 'normal', + seed: 'mileigh-deploy', + }); + s.leaders['mileigh-hem'].stockpile.shields = 1; + s.leaders['mileigh-hem'].stockpile.missiles = 1; + s.leaders['mileigh-hem'].stockpile.warheadsSmall = 1; + s.leaders['mileigh-hem'].ap = 10; + + s = reduce(s, { + type: 'SUBMIT_ORDERS', + leaderId: 'mileigh-hem', + orders: [ + { kind: 'launch', target: 'chump', delivery: 'missile', warhead: 'small', targetType: 'people' }, + { kind: 'deploy-defence', type: 'shield' }, + ], + }); + s = reduce(s, { type: 'SUBMIT_ORDERS', leaderId: 'chump', orders: [] }); + const r = resolveRound(s); + + // Bonus should NOT have applied — next round AP should be factoryAp + banked + 0 bonus. + // mileigh-hem: factories=4 * FACTORY_AP_RATE(1.0) = 4 + banked (clamped 0..4) + 0 bonus. + const expectedAp = 4 + Math.min(4, Math.max(0, r.state.leaders['mileigh-hem'].apBanked)); + expect(r.state.leaders['mileigh-hem'].ap).toBe(expectedAp); + }); +}); + describe('orderHistory persistence', () => { it('appends this round\'s orders to orderHistory after RESOLVE_ROUND', () => { let s = initialState({ From 605db7e7bebbd84748d195c2e0501314d4552fdd Mon Sep 17 00:00:00 2001 From: gethi Date: Wed, 13 May 2026 21:33:57 +0100 Subject: [PATCH 08/24] =?UTF-8?q?engine:=20chump=20AI=20=E2=80=94=20flat?= =?UTF-8?q?=20woo=20+=20deploy-or-build=20defence=20rule?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/engine/ai/chump.ts | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/src/engine/ai/chump.ts b/src/engine/ai/chump.ts index f6141a4..deb6a7c 100644 --- a/src/engine/ai/chump.ts +++ b/src/engine/ai/chump.ts @@ -58,15 +58,26 @@ export function planChump(state: GameState, leaderId: LeaderId): Order[] { // --- 1. Build orders: defence first, then warheads --- let remaining = buildBudget; - // Build shields as long as budget allows (bias toward defence). - const defenceCost = 2; + // Defence: prefer deploying if we own a shield, otherwise build one. + const defenceCost = 4; // P4b: was 2 + const deployCost = 4; // P4b: new while (remaining >= defenceCost) { - const o: Order = { kind: 'build-defence', type: 'shield' }; - if (validateOrder(state, leaderId, o).ok) { - orders.push(o); - remaining -= defenceCost; + if (me.stockpile.shields >= 1 && remaining >= deployCost) { + const o: Order = { kind: 'deploy-defence', type: 'shield' }; + if (validateOrder(state, leaderId, o).ok) { + orders.push(o); + remaining -= deployCost; + } else { + break; + } } else { - break; + const o: Order = { kind: 'build-defence', type: 'shield' }; + if (validateOrder(state, leaderId, o).ok) { + orders.push(o); + remaining -= defenceCost; + } else { + break; + } } } From 175895c7176479e56b4505da079b347c2a435d74 Mon Sep 17 00:00:00 2001 From: gethi Date: Wed, 13 May 2026 21:34:16 +0100 Subject: [PATCH 09/24] =?UTF-8?q?engine:=20starmless=20AI=20=E2=80=94=20fl?= =?UTF-8?q?at=20woo=20+=20deploy-or-build=20defence=20rule?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/engine/ai/starmless.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/engine/ai/starmless.ts b/src/engine/ai/starmless.ts index 8621af9..d4c848a 100644 --- a/src/engine/ai/starmless.ts +++ b/src/engine/ai/starmless.ts @@ -107,6 +107,29 @@ export function planStarmless(state: GameState, leaderId: LeaderId): Order[] { } } + // Defence: prefer deploying if we own a shield, otherwise build one. + const defenceCost = 4; // P4b: was 2 + const deployCost = 4; // P4b: new + while (remaining >= defenceCost) { + if (me.stockpile.shields >= 1 && remaining >= deployCost) { + const o: Order = { kind: 'deploy-defence', type: 'shield' }; + if (validateOrder(state, leaderId, o).ok) { + orders.push(o); + remaining -= deployCost; + } else { + break; + } + } else { + const o: Order = { kind: 'build-defence', type: 'shield' }; + if (validateOrder(state, leaderId, o).ok) { + orders.push(o); + remaining -= defenceCost; + } else { + break; + } + } + } + // Fill remaining build budget with small warheads. while (remaining >= 1) { const o: Order = { kind: 'build-warhead', yield: 'small' }; From 4fea0d80e437b8f63a80389f9c43410407a18c9d Mon Sep 17 00:00:00 2001 From: gethi Date: Wed, 13 May 2026 21:34:40 +0100 Subject: [PATCH 10/24] =?UTF-8?q?engine:=20mileigh-hem=20AI=20=E2=80=94=20?= =?UTF-8?q?flat=20woo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/engine/ai/mileighhem.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/engine/ai/mileighhem.ts b/src/engine/ai/mileighhem.ts index aea904d..8778aef 100644 --- a/src/engine/ai/mileighhem.ts +++ b/src/engine/ai/mileighhem.ts @@ -106,7 +106,7 @@ export function planMileighHem(state: GameState, leaderId: LeaderId): Order[] { // --- Diplomatic mode --- let budget = me.ap; - const WOO_COST = 1; // 1 AP per point; emit 1-point woo orders + const WOO_COST = 1; // flat woo costs 1 AP (P4b) const PROPAGANDA_COST = 1; // Woo up to 2 leaders (targeting attackers first, then others if budget allows). @@ -116,7 +116,7 @@ export function planMileighHem(state: GameState, leaderId: LeaderId): Order[] { for (const t of wooPool) { if (wooCount >= 2) break; if (budget < WOO_COST) break; - const woo: Order = { kind: 'woo', target: t, points: 1 }; + const woo: Order = { kind: 'woo', target: t }; if (validateOrder(state, leaderId, woo).ok) { orders.push(woo); budget -= apCostOf(woo); From 28efd900bf62de5456125e2e971430eb16d9598f Mon Sep 17 00:00:00 2001 From: gethi Date: Wed, 13 May 2026 21:34:59 +0100 Subject: [PATCH 11/24] tests: ai-duel comment notes P4b rule change invalidates baseline --- tests/engine/ai-duel.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/engine/ai-duel.test.ts b/tests/engine/ai-duel.test.ts index d17914b..9e6f191 100644 --- a/tests/engine/ai-duel.test.ts +++ b/tests/engine/ai-duel.test.ts @@ -47,6 +47,11 @@ describe('AI-duel headless', () => { // only "infrastructure works" (engine runs 100 games without crashing, // counts add up). It prints the win distribution so the P4 balance pass has // a reproducible baseline to tune against. + // + // P2 baseline distribution shown above is now stale. P4b doubled the AP pool + // and changed defences to consumable; the AI duel will produce a different + // distribution. P4c uses the new distribution as the tuning baseline. This + // test continues to assert only "no crash" — it has no balance assertions. it('runs 100 all-AI games over full cast without crashing (balance assertions deferred to P4)', () => { // Player1..player5 are zero-initialised because the duel is AI-only and // never includes player slots — kept to satisfy Record. From b66f6bae237608637ddbae763672f15a15618695 Mon Sep 17 00:00:00 2001 From: gethi Date: Wed, 13 May 2026 21:43:48 +0100 Subject: [PATCH 12/24] engine/ui: fix diplomacy.ts + UI stubs for DefenceDeployed/Consumed + flat woo --- src/engine/diplomacy.ts | 7 ++++--- src/ui/components/EventCard.tsx | 11 +++++++++++ src/ui/components/OrderForm.tsx | 4 +++- src/ui/screens/Action.tsx | 4 +++- src/ui/screens/Planning.tsx | 5 +++-- 5 files changed, 24 insertions(+), 7 deletions(-) diff --git a/src/engine/diplomacy.ts b/src/engine/diplomacy.ts index 2b4f36e..f9ad979 100644 --- a/src/engine/diplomacy.ts +++ b/src/engine/diplomacy.ts @@ -1,5 +1,5 @@ import type { GameState, LeaderId, Order, ResolutionEvent } from './types'; -import { WOO_FAVOURABILITY_DECAY } from './balance'; +import { WOO_FAVOURABILITY_DECAY, ACTION_COSTS } from './balance'; export interface WooingResult { state: GameState; @@ -21,8 +21,9 @@ export function applyWooing( const target = next.leaders[o.target]; if (!me || !me.alive || !target || !target.alive) continue; const current = target.favourability[id] ?? 0; - target.favourability[id] = current + o.points; - events.push({ kind: 'WooApplied', from: id, to: o.target, points: o.points }); + const points = ACTION_COSTS.woo; + target.favourability[id] = current + points; + events.push({ kind: 'WooApplied', from: id, to: o.target, points }); } } return { state: next, events }; diff --git a/src/ui/components/EventCard.tsx b/src/ui/components/EventCard.tsx index 6489361..13b8eda 100644 --- a/src/ui/components/EventCard.tsx +++ b/src/ui/components/EventCard.tsx @@ -94,6 +94,17 @@ export function formatEventCard( className: styles.obituary, quote: event.quote, }; + case 'DefenceDeployed': + return { + icon: '🛡', + body: `${flag(game, event.by)} ${name(game, event.by)} deploys ${event.type === 'shield' ? 'shield' : 'AA'}`, + quote: event.quote, + }; + case 'DefenceConsumed': + return { + icon: '🛡✓', + body: `${flag(game, event.by)} ${name(game, event.by)}'s ${event.type === 'shield' ? 'shield' : 'AA'} consumed`, + }; case 'FinalRetaliationTriggered': return { icon: '💥', diff --git a/src/ui/components/OrderForm.tsx b/src/ui/components/OrderForm.tsx index 79ae98a..ae002ac 100644 --- a/src/ui/components/OrderForm.tsx +++ b/src/ui/components/OrderForm.tsx @@ -50,6 +50,8 @@ export default function OrderForm({ state, playerId, committedOrders, onAdd }: O return { order: { kind, yield: warheadBuild } }; case 'build-defence': return { order: { kind, type: defenceType } }; + case 'deploy-defence': + return { order: { kind, type: defenceType } }; case 'launch': if (!target) return { missing: 'target' }; if (!delivery) return { missing: 'delivery' }; @@ -60,7 +62,7 @@ export default function OrderForm({ state, playerId, committedOrders, onAdd }: O return { order: { kind, target } }; case 'woo': if (!target) return { missing: 'target' }; - return { order: { kind, target, points } }; + return { order: { kind, target } }; } } diff --git a/src/ui/screens/Action.tsx b/src/ui/screens/Action.tsx index c90eecf..02a53bd 100644 --- a/src/ui/screens/Action.tsx +++ b/src/ui/screens/Action.tsx @@ -21,7 +21,9 @@ const PHASE_LABELS: Record = { * OutcomeReached — those inherit the current phase or aren't rendered). */ function phaseAdvanceFor(kind: ResolutionEvent['kind']): Phase | null { switch (kind) { - case 'DefenceBuilt': return 'DEFENCES'; + case 'DefenceBuilt': + case 'DefenceDeployed': + case 'DefenceConsumed': return 'DEFENCES'; case 'FactoryBuilt': case 'DeliveryBuilt': case 'WarheadBuilt': return 'BUILDS'; diff --git a/src/ui/screens/Planning.tsx b/src/ui/screens/Planning.tsx index a959c2e..bae3c24 100644 --- a/src/ui/screens/Planning.tsx +++ b/src/ui/screens/Planning.tsx @@ -165,9 +165,10 @@ function formatOrder(o: Order, game: GameState): string { case 'build-missile': return 'Build missile (1 AP)'; case 'build-bomber': return 'Build bomber (1 AP)'; case 'build-warhead': return `Build ${o.yield} warhead (${o.yield === 'small' ? 1 : o.yield === 'medium' ? 2 : 3} AP)`; - case 'build-defence': return `Build ${o.type === 'shield' ? 'shield' : 'AA'} (2 AP)`; + case 'build-defence': return `Build ${o.type === 'shield' ? 'shield' : 'AA'} (4 AP)`; + case 'deploy-defence': return `Deploy ${o.type === 'shield' ? 'shield' : 'AA'} (4 AP)`; case 'launch': return `Launch ${o.warhead} at ${game.leaders[o.target].name} (${o.targetType}, 2 AP)`; case 'propaganda': return `Propaganda → ${game.leaders[o.target].name} (1 AP)`; - case 'woo': return `Woo ${game.leaders[o.target].name} × ${o.points} (${o.points} AP)`; + case 'woo': return `Woo ${game.leaders[o.target].name} (1 AP)`; } } From a2d49cf4b92fbec91d0fe4b8e15ab167a9bca30e Mon Sep 17 00:00:00 2001 From: gethi Date: Wed, 13 May 2026 21:44:03 +0100 Subject: [PATCH 13/24] =?UTF-8?q?tests:=20orders/diplomacy/analyseOrderSeq?= =?UTF-8?q?uence/scripted-orders=20=E2=80=94=20flat=20woo=20shape=20(P4b)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/engine/analyseOrderSequence.test.ts | 4 ++-- tests/engine/diplomacy.test.ts | 16 ++++++++-------- tests/engine/orders.test.ts | 17 ++++++++--------- tests/helpers/scripted-orders.ts | 2 +- 4 files changed, 19 insertions(+), 20 deletions(-) diff --git a/tests/engine/analyseOrderSequence.test.ts b/tests/engine/analyseOrderSequence.test.ts index 8df4753..129bfff 100644 --- a/tests/engine/analyseOrderSequence.test.ts +++ b/tests/engine/analyseOrderSequence.test.ts @@ -41,7 +41,7 @@ describe('analyseOrderSequence', () => { it('flags woo-non-attacker when target has no aggression and non-negative favourability', () => { const s = state(); const warnings = analyseOrderSequence(s, 'player1', [ - { kind: 'woo', target: 'chump', points: 1 }, + { kind: 'woo', target: 'chump' }, ]); expect(warnings.filter((w) => w.kind === 'woo-non-attacker')).toHaveLength(1); }); @@ -50,7 +50,7 @@ describe('analyseOrderSequence', () => { const s = state(); s.leaders.chump.recentAggressionFrom.player1 = 1; const warnings = analyseOrderSequence(s, 'player1', [ - { kind: 'woo', target: 'chump', points: 1 }, + { kind: 'woo', target: 'chump' }, ]); expect(warnings.filter((w) => w.kind === 'woo-non-attacker')).toHaveLength(0); }); diff --git a/tests/engine/diplomacy.test.ts b/tests/engine/diplomacy.test.ts index 8d5d7cd..b40eb91 100644 --- a/tests/engine/diplomacy.test.ts +++ b/tests/engine/diplomacy.test.ts @@ -5,33 +5,33 @@ import { WOO_FAVOURABILITY_DECAY } from '../../src/engine/balance'; import type { Order } from '../../src/engine/types'; describe('applyWooing', () => { - it('adds woo points to target.favourability[sender]', () => { + it('adds 1 favourability point to target.favourability[sender] (flat woo, P4b)', () => { const s = initialState({ cast: ['chump', 'carnage'], difficulty: 'normal', seed: 'x' }); const orders: Partial> = { - chump: [{ kind: 'woo', target: 'carnage', points: 3 }], + chump: [{ kind: 'woo', target: 'carnage' }], carnage: [], }; const r = applyWooing(s, orders); - expect(r.state.leaders.carnage.favourability.chump).toBe(3); - expect(r.events).toEqual([{ kind: 'WooApplied', from: 'chump', to: 'carnage', points: 3 }]); + expect(r.state.leaders.carnage.favourability.chump).toBe(1); + expect(r.events).toEqual([{ kind: 'WooApplied', from: 'chump', to: 'carnage', points: 1 }]); }); - it('accumulates across multiple woo orders', () => { + it('accumulates across multiple woo orders (1 point each)', () => { const s = initialState({ cast: ['chump', 'carnage'], difficulty: 'normal', seed: 'x' }); s.leaders.carnage.favourability.chump = 2; const orders = { - chump: [{ kind: 'woo' as const, target: 'carnage' as const, points: 4 }], + chump: [{ kind: 'woo' as const, target: 'carnage' as const }], carnage: [], }; const r = applyWooing(s, orders); - expect(r.state.leaders.carnage.favourability.chump).toBe(6); + expect(r.state.leaders.carnage.favourability.chump).toBe(3); }); it('skips wooing dead targets', () => { const s = initialState({ cast: ['chump', 'carnage'], difficulty: 'normal', seed: 'x' }); s.leaders.carnage.alive = false; const orders = { - chump: [{ kind: 'woo' as const, target: 'carnage' as const, points: 3 }], + chump: [{ kind: 'woo' as const, target: 'carnage' as const }], carnage: [], }; const r = applyWooing(s, orders); diff --git a/tests/engine/orders.test.ts b/tests/engine/orders.test.ts index c4d6d79..d0a415c 100644 --- a/tests/engine/orders.test.ts +++ b/tests/engine/orders.test.ts @@ -16,8 +16,8 @@ describe('apCostOf', () => { expect(apCostOf({ kind: 'build-warhead', yield: 'small' })).toBe(1); expect(apCostOf({ kind: 'build-warhead', yield: 'medium' })).toBe(2); expect(apCostOf({ kind: 'build-warhead', yield: 'large' })).toBe(3); - expect(apCostOf({ kind: 'build-defence', type: 'shield' })).toBe(2); - expect(apCostOf({ kind: 'build-defence', type: 'aa' })).toBe(2); + expect(apCostOf({ kind: 'build-defence', type: 'shield' })).toBe(4); + expect(apCostOf({ kind: 'build-defence', type: 'aa' })).toBe(4); expect( apCostOf({ kind: 'launch', @@ -28,7 +28,7 @@ describe('apCostOf', () => { }), ).toBe(2); expect(apCostOf({ kind: 'propaganda', target: 'carnage' })).toBe(1); - expect(apCostOf({ kind: 'woo', target: 'carnage', points: 3 })).toBe(3); + expect(apCostOf({ kind: 'woo', target: 'carnage' })).toBe(1); }); }); @@ -122,12 +122,11 @@ describe('validateOrder', () => { expect(r.ok).toBe(false); }); - it('rejects woo orders with non-positive points', () => { - const r = validateOrder(baseState, 'chump', { - kind: 'woo', - target: 'carnage', - points: 0, - }); + it('rejects woo orders targeting a dead leader', () => { + // P4b: woo is flat (no points field). Validate that dead-target rejection still holds. + const s = structuredClone(baseState); + s.leaders.carnage.alive = false; + const r = validateOrder(s, 'chump', { kind: 'woo', target: 'carnage' }); expect(r.ok).toBe(false); }); diff --git a/tests/helpers/scripted-orders.ts b/tests/helpers/scripted-orders.ts index 0863fb6..cdae571 100644 --- a/tests/helpers/scripted-orders.ts +++ b/tests/helpers/scripted-orders.ts @@ -27,7 +27,7 @@ export function scriptedOrders(state: GameState, leaderId: LeaderId): Order[] { ? [{ kind: 'propaganda', target }] : [], target - ? [{ kind: 'woo', target, points: 1 }] + ? [{ kind: 'woo', target }] : [], ]; From a67f6b3dafc37fee0bcc57ce1d5eb58443af51b6 Mon Sep 17 00:00:00 2001 From: gethi Date: Wed, 13 May 2026 21:44:08 +0100 Subject: [PATCH 14/24] =?UTF-8?q?tests:=20resolution/reducer/OrderForm=20?= =?UTF-8?q?=E2=80=94=20update=20hardcoded=20AP/cost=20values=20for=20P4b?= =?UTF-8?q?=20balance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/engine/reducer.test.ts | 8 +++++++- tests/engine/resolution.test.ts | 15 +++++++++------ tests/ui/OrderForm.test.tsx | 8 ++++++-- 3 files changed, 22 insertions(+), 9 deletions(-) diff --git a/tests/engine/reducer.test.ts b/tests/engine/reducer.test.ts index bcef87c..c2ecf69 100644 --- a/tests/engine/reducer.test.ts +++ b/tests/engine/reducer.test.ts @@ -34,10 +34,16 @@ describe('reduce — SUBMIT_ORDERS', () => { it('rejects orders that exceed AP budget (state unchanged)', () => { const s0 = initialState({ cast: ['chump', 'carnage'], difficulty: 'normal', seed: 'x' }); + // P4b: Chump startAp=10. Use 4 × build-factory (12 AP) to exceed budget. const s1 = reduce(s0, { type: 'SUBMIT_ORDERS', leaderId: 'chump', - orders: [{ kind: 'build-factory' }, { kind: 'build-factory' }], // 6 AP, only 5 available + orders: [ + { kind: 'build-factory' }, + { kind: 'build-factory' }, + { kind: 'build-factory' }, + { kind: 'build-factory' }, + ], // 12 AP, only 10 available }); expect(s1).toBe(s0); }); diff --git a/tests/engine/resolution.test.ts b/tests/engine/resolution.test.ts index 340b0f0..70af78f 100644 --- a/tests/engine/resolution.test.ts +++ b/tests/engine/resolution.test.ts @@ -52,14 +52,16 @@ describe('resolveRound', () => { expect(idxProp).toBeLessThan(idxLaunch); }); - it('applies AP refresh: floor(factories * 0.5) + banked + bonus', () => { + it('applies AP refresh: floor(factories * FACTORY_AP_RATE) + banked', () => { let s = initialState({ cast: ['chump', 'carnage'], difficulty: 'normal', seed: 'x' }); - // Chump submits no orders → 5 AP unspent → bank capped at 2. + // P4b: Chump startAp=10, startFactories=10, AP_BANK_CAP=4, FACTORY_AP_RATE=1.0. + // Chump submits no orders → 10 AP unspent → bank = min(4, floor(10)) = 4. + // Next AP = floor(10 * 1.0) + 4 = 14. s = withOrders(s, 'chump', []); s = withOrders(s, 'carnage', []); const r = resolveRound(s); - expect(r.state.leaders.chump.apBanked).toBe(2); - expect(r.state.leaders.chump.ap).toBe(5 + 2); + expect(r.state.leaders.chump.apBanked).toBe(4); + expect(r.state.leaders.chump.ap).toBe(10 + 4); }); it('grants Netanyahoo +1 AP when their orders include a launch', () => { @@ -75,8 +77,9 @@ describe('resolveRound', () => { ]); s = withOrders(s, 'carnage', []); const r = resolveRound(s); - // After resolution, factories=6 → floor(6*0.5)=3, banked=min(2, 3-2=1)=1, bonus=1. - expect(r.state.leaders.netanyahoo.ap).toBe(3 + 1 + 1); + // P4b: factories=6, FACTORY_AP_RATE=1.0 → floor(6*1.0)=6. + // Netanyahoo spent 2 AP on launch → 4 AP unspent → banked=min(4, 4)=4. bonus=1. + expect(r.state.leaders.netanyahoo.ap).toBe(6 + 4 + 1); }); it('eliminates a leader and triggers Final Retaliation cascade', () => { diff --git a/tests/ui/OrderForm.test.tsx b/tests/ui/OrderForm.test.tsx index e80917c..418e52f 100644 --- a/tests/ui/OrderForm.test.tsx +++ b/tests/ui/OrderForm.test.tsx @@ -33,8 +33,12 @@ describe('', () => { it('surfaces engine validateOrder reason on AP overrun', () => { const state = gameWithCast(); - // player starts with ap=3; queue a build-warhead-large (cost 3); a second cost-3 order overruns - const prior: Order[] = [{ kind: 'build-warhead', yield: 'large' }]; + // P4b: player1 startAp=6. Queue build-factory (3) + build-warhead-large (3) = 6 AP spent. + // A further build-factory (3 AP) must then be rejected as over-budget. + const prior: Order[] = [ + { kind: 'build-factory' }, + { kind: 'build-warhead', yield: 'large' }, + ]; let added: Order | null = null; render( Date: Wed, 13 May 2026 22:07:09 +0100 Subject: [PATCH 15/24] =?UTF-8?q?ui:=20tighten=20DefenceConsumed=20event?= =?UTF-8?q?=20=E2=80=94=20not=20rendered=20on=20Action=20(round-end=20hous?= =?UTF-8?q?ekeeping)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ui/components/EventCard.tsx | 7 ++----- src/ui/screens/Action.tsx | 5 +++-- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/ui/components/EventCard.tsx b/src/ui/components/EventCard.tsx index 13b8eda..6e75388 100644 --- a/src/ui/components/EventCard.tsx +++ b/src/ui/components/EventCard.tsx @@ -97,14 +97,11 @@ export function formatEventCard( case 'DefenceDeployed': return { icon: '🛡', - body: `${flag(game, event.by)} ${name(game, event.by)} deploys ${event.type === 'shield' ? 'shield' : 'AA'}`, + body: `${flag(game, event.by)} ${name(game, event.by)} deploys ${event.type === 'shield' ? 'a shield' : 'AA'}`, quote: event.quote, }; case 'DefenceConsumed': - return { - icon: '🛡✓', - body: `${flag(game, event.by)} ${name(game, event.by)}'s ${event.type === 'shield' ? 'shield' : 'AA'} consumed`, - }; + return null; // round-end housekeeping, not rendered on Action case 'FinalRetaliationTriggered': return { icon: '💥', diff --git a/src/ui/screens/Action.tsx b/src/ui/screens/Action.tsx index 02a53bd..a2ecd6e 100644 --- a/src/ui/screens/Action.tsx +++ b/src/ui/screens/Action.tsx @@ -22,8 +22,7 @@ const PHASE_LABELS: Record = { function phaseAdvanceFor(kind: ResolutionEvent['kind']): Phase | null { switch (kind) { case 'DefenceBuilt': - case 'DefenceDeployed': - case 'DefenceConsumed': return 'DEFENCES'; + case 'DefenceDeployed': return 'DEFENCES'; case 'FactoryBuilt': case 'DeliveryBuilt': case 'WarheadBuilt': return 'BUILDS'; @@ -38,6 +37,7 @@ function phaseAdvanceFor(kind: ResolutionEvent['kind']): Phase | null { case 'LeaderEliminated': case 'OrdersSealed': case 'OutcomeReached': + case 'DefenceConsumed': case 'PreRoundMood': case 'PostRoundReaction': case 'DisparageCameo': @@ -49,6 +49,7 @@ function isRenderable(kind: ResolutionEvent['kind']): boolean { return ( kind !== 'OrdersSealed' && kind !== 'OutcomeReached' && + kind !== 'DefenceConsumed' && kind !== 'PreRoundMood' && kind !== 'PostRoundReaction' && kind !== 'DisparageColumn' From 7641318d9854295592cf1a84bbc01449ed4cedb7 Mon Sep 17 00:00:00 2001 From: gethi Date: Wed, 13 May 2026 22:13:22 +0100 Subject: [PATCH 16/24] =?UTF-8?q?ui:=20projectInventory=20helper=20?= =?UTF-8?q?=E2=80=94=20queue-aware=20inventory=20projection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure-function utility that projects a Leader's stockpile forward through a pending order queue; clamps at 0. Consumed by BuildGrid/DefenceGrid/ TargetRow in subsequent tasks to drive stepper enable/disable logic. Co-Authored-By: Claude Sonnet 4.6 --- src/ui/util/projection.ts | 74 ++++++++++++++++++++++++++++++++++ tests/ui/projection.test.ts | 80 +++++++++++++++++++++++++++++++++++++ 2 files changed, 154 insertions(+) create mode 100644 src/ui/util/projection.ts create mode 100644 tests/ui/projection.test.ts diff --git a/src/ui/util/projection.ts b/src/ui/util/projection.ts new file mode 100644 index 0000000..e64521b --- /dev/null +++ b/src/ui/util/projection.ts @@ -0,0 +1,74 @@ +import type { Leader, Order } from '../../engine/types'; + +export interface ProjectedInventory { + missiles: number; + bombers: number; + warheadsSmall: number; + warheadsMedium: number; + warheadsLarge: number; + shieldsInStockpile: number; + aaInStockpile: number; + deployedShields: number; + deployedAA: number; +} + +/** + * Project a leader's inventory forward given a queue of orders. Builds add, + * launches subtract (deliveries + warheads), deploys move from stockpile to + * deployed pool. Clamps every count at 0 — UI consumers can use these values + * directly to enable/disable + steppers. + */ +export function projectInventory(leader: Leader, orders: Order[]): ProjectedInventory { + const p: ProjectedInventory = { + missiles: leader.stockpile.missiles, + bombers: leader.stockpile.bombers, + warheadsSmall: leader.stockpile.warheadsSmall, + warheadsMedium: leader.stockpile.warheadsMedium, + warheadsLarge: leader.stockpile.warheadsLarge, + shieldsInStockpile: leader.stockpile.shields, + aaInStockpile: leader.stockpile.aa, + deployedShields: leader.deployedShields, + deployedAA: leader.deployedAA, + }; + + for (const o of orders) { + switch (o.kind) { + case 'build-missile': + p.missiles += 1; + break; + case 'build-bomber': + p.bombers += 1; + break; + case 'build-warhead': + if (o.yield === 'small') p.warheadsSmall += 1; + else if (o.yield === 'medium') p.warheadsMedium += 1; + else p.warheadsLarge += 1; + break; + case 'build-defence': + if (o.type === 'shield') p.shieldsInStockpile += 1; + else p.aaInStockpile += 1; + break; + case 'deploy-defence': + if (o.type === 'shield') { + p.shieldsInStockpile = Math.max(0, p.shieldsInStockpile - 1); + p.deployedShields += 1; + } else { + p.aaInStockpile = Math.max(0, p.aaInStockpile - 1); + p.deployedAA += 1; + } + break; + case 'launch': + if (o.delivery === 'missile') p.missiles = Math.max(0, p.missiles - 1); + else p.bombers = Math.max(0, p.bombers - 1); + if (o.warhead === 'small') p.warheadsSmall = Math.max(0, p.warheadsSmall - 1); + else if (o.warhead === 'medium') p.warheadsMedium = Math.max(0, p.warheadsMedium - 1); + else p.warheadsLarge = Math.max(0, p.warheadsLarge - 1); + break; + // build-factory / propaganda / woo: no stockpile mutation + default: + break; + } + } + + return p; +} diff --git a/tests/ui/projection.test.ts b/tests/ui/projection.test.ts new file mode 100644 index 0000000..008227b --- /dev/null +++ b/tests/ui/projection.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest'; +import { projectInventory } from '../../src/ui/util/projection'; +import { initialState } from '../../src/engine/state'; +import type { Order } from '../../src/engine/types'; + +function makeLeader() { + const s = initialState({ + cast: ['player1', 'chump'], + difficulty: 'normal', + seed: 'projection-test', + }); + s.leaders.player1.stockpile.missiles = 2; + s.leaders.player1.stockpile.warheadsSmall = 1; + s.leaders.player1.stockpile.shields = 1; + return s.leaders.player1; +} + +describe('projectInventory', () => { + it('returns base inventory when orders is empty', () => { + const p = projectInventory(makeLeader(), []); + expect(p.missiles).toBe(2); + expect(p.warheadsSmall).toBe(1); + expect(p.shieldsInStockpile).toBe(1); + expect(p.deployedShields).toBe(0); + }); + + it('adds queued builds', () => { + const orders: Order[] = [ + { kind: 'build-missile' }, + { kind: 'build-missile' }, + { kind: 'build-warhead', yield: 'small' }, + { kind: 'build-defence', type: 'shield' }, + ]; + const p = projectInventory(makeLeader(), orders); + expect(p.missiles).toBe(4); + expect(p.warheadsSmall).toBe(2); + expect(p.shieldsInStockpile).toBe(2); + }); + + it('subtracts queued launches from delivery + warhead inventory', () => { + const orders: Order[] = [ + { kind: 'launch', target: 'chump', delivery: 'missile', warhead: 'small', targetType: 'people' }, + ]; + const p = projectInventory(makeLeader(), orders); + expect(p.missiles).toBe(1); + expect(p.warheadsSmall).toBe(0); + }); + + it('subtracts deploy from stockpile, adds to deployed', () => { + const orders: Order[] = [ + { kind: 'deploy-defence', type: 'shield' }, + ]; + const p = projectInventory(makeLeader(), orders); + expect(p.shieldsInStockpile).toBe(0); + expect(p.deployedShields).toBe(1); + }); + + it('handles build-then-launch in one round', () => { + const orders: Order[] = [ + { kind: 'build-missile' }, + { kind: 'build-warhead', yield: 'small' }, + { kind: 'launch', target: 'chump', delivery: 'missile', warhead: 'small', targetType: 'people' }, + ]; + const p = projectInventory(makeLeader(), orders); + // started: 2 missiles + 1 sm wh; built: +1 missile +1 sm wh; launched: -1 missile -1 sm wh + expect(p.missiles).toBe(2); + expect(p.warheadsSmall).toBe(1); + }); + + it('clamps at 0 — does not go negative on invalid sequences', () => { + const orders: Order[] = [ + { kind: 'launch', target: 'chump', delivery: 'missile', warhead: 'small', targetType: 'people' }, + { kind: 'launch', target: 'chump', delivery: 'missile', warhead: 'small', targetType: 'people' }, + { kind: 'launch', target: 'chump', delivery: 'missile', warhead: 'small', targetType: 'people' }, + ]; + const p = projectInventory(makeLeader(), orders); + expect(p.missiles).toBeGreaterThanOrEqual(0); + expect(p.warheadsSmall).toBeGreaterThanOrEqual(0); + }); +}); From 39364a7d9e1ea5bcd4b8db2fe036b1d81703ccef Mon Sep 17 00:00:00 2001 From: gethi Date: Wed, 13 May 2026 22:15:02 +0100 Subject: [PATCH 17/24] =?UTF-8?q?ui:=20BuildGrid=20component=20=E2=80=94?= =?UTF-8?q?=20Factory/Missile/Bomber/Warhead=20stepper=20grid?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/ui/components/BuildGrid.module.css | 25 ++++++++++ src/ui/components/BuildGrid.tsx | 65 ++++++++++++++++++++++++++ 2 files changed, 90 insertions(+) create mode 100644 src/ui/components/BuildGrid.module.css create mode 100644 src/ui/components/BuildGrid.tsx diff --git a/src/ui/components/BuildGrid.module.css b/src/ui/components/BuildGrid.module.css new file mode 100644 index 0000000..f6b6c83 --- /dev/null +++ b/src/ui/components/BuildGrid.module.css @@ -0,0 +1,25 @@ +.grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 4px; } + +.cell { + background: white; + border: 1px solid #d0c8b8; + border-radius: 4px; + padding: 6px; + display: flex; + flex-direction: column; + align-items: center; + min-height: 56px; +} +.cell.on { background: #d4edda; border-color: #198754; } + +.emoji { font-size: 18px; line-height: 1; } +.label { font-size: 9px; margin: 2px 0; text-align: center; } +.cost { font-size: 8px; color: #6c757d; } + +.stepper { display: flex; align-items: center; gap: 6px; margin-top: 2px; } +.stepper button { + background: #1a1a1a; color: white; border: 0; border-radius: 50%; + width: 16px; height: 16px; line-height: 14px; font-size: 11px; padding: 0; cursor: pointer; +} +.stepper button:disabled { background: #adb5bd; cursor: not-allowed; } +.num { font-weight: 700; font-size: 12px; min-width: 16px; text-align: center; } diff --git a/src/ui/components/BuildGrid.tsx b/src/ui/components/BuildGrid.tsx new file mode 100644 index 0000000..23c30d9 --- /dev/null +++ b/src/ui/components/BuildGrid.tsx @@ -0,0 +1,65 @@ +import type { Dispatch, SetStateAction } from 'react'; +import type { Order } from '../../engine/types'; +import { ACTION_COSTS } from '../../engine/balance'; +import styles from './BuildGrid.module.css'; + +interface Props { + orders: Order[]; + setOrders: Dispatch>; + apRemaining: number; +} + +interface CellSpec { + emoji: string; + label: string; + cost: number; + matches: (o: Order) => boolean; + make: () => Order; +} + +const CELLS: CellSpec[] = [ + { emoji: '🏭', label: 'Factory', cost: ACTION_COSTS.buildFactory, matches: (o) => o.kind === 'build-factory', make: () => ({ kind: 'build-factory' }) }, + { emoji: '🚀', label: 'Missile', cost: ACTION_COSTS.buildMissile, matches: (o) => o.kind === 'build-missile', make: () => ({ kind: 'build-missile' }) }, + { emoji: '✈️', label: 'Bomber', cost: ACTION_COSTS.buildBomber, matches: (o) => o.kind === 'build-bomber', make: () => ({ kind: 'build-bomber' }) }, + { emoji: '💥', label: 'Sm Warhead', cost: ACTION_COSTS.buildWarheadSmall, matches: (o) => o.kind === 'build-warhead' && o.yield === 'small', make: () => ({ kind: 'build-warhead', yield: 'small' }) }, + { emoji: '💥', label: 'Md Warhead', cost: ACTION_COSTS.buildWarheadMedium, matches: (o) => o.kind === 'build-warhead' && o.yield === 'medium', make: () => ({ kind: 'build-warhead', yield: 'medium' }) }, + { emoji: '💥', label: 'Lg Warhead', cost: ACTION_COSTS.buildWarheadLarge, matches: (o) => o.kind === 'build-warhead' && o.yield === 'large', make: () => ({ kind: 'build-warhead', yield: 'large' }) }, +]; + +export default function BuildGrid({ orders, setOrders, apRemaining }: Props) { + return ( +
+ {CELLS.map((cell, i) => { + const count = orders.filter(cell.matches).length; + const canAdd = apRemaining >= cell.cost; + return ( +
0 ? styles.on : ''}`}> +
{cell.emoji}
+
{cell.label}
+
{cell.cost} AP
+
+ + {count} + +
+
+ ); + })} +
+ ); +} From 7c79ac0474fa3fb3ef0897b8094a5b5feae77bc9 Mon Sep 17 00:00:00 2001 From: gethi Date: Wed, 13 May 2026 22:16:23 +0100 Subject: [PATCH 18/24] =?UTF-8?q?ui:=20DefenceGrid=20component=20=E2=80=94?= =?UTF-8?q?=20build/deploy=20shields=20and=20AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/ui/components/DefenceGrid.module.css | 25 ++++++ src/ui/components/DefenceGrid.tsx | 98 ++++++++++++++++++++++++ 2 files changed, 123 insertions(+) create mode 100644 src/ui/components/DefenceGrid.module.css create mode 100644 src/ui/components/DefenceGrid.tsx diff --git a/src/ui/components/DefenceGrid.module.css b/src/ui/components/DefenceGrid.module.css new file mode 100644 index 0000000..f6b6c83 --- /dev/null +++ b/src/ui/components/DefenceGrid.module.css @@ -0,0 +1,25 @@ +.grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 4px; } + +.cell { + background: white; + border: 1px solid #d0c8b8; + border-radius: 4px; + padding: 6px; + display: flex; + flex-direction: column; + align-items: center; + min-height: 56px; +} +.cell.on { background: #d4edda; border-color: #198754; } + +.emoji { font-size: 18px; line-height: 1; } +.label { font-size: 9px; margin: 2px 0; text-align: center; } +.cost { font-size: 8px; color: #6c757d; } + +.stepper { display: flex; align-items: center; gap: 6px; margin-top: 2px; } +.stepper button { + background: #1a1a1a; color: white; border: 0; border-radius: 50%; + width: 16px; height: 16px; line-height: 14px; font-size: 11px; padding: 0; cursor: pointer; +} +.stepper button:disabled { background: #adb5bd; cursor: not-allowed; } +.num { font-weight: 700; font-size: 12px; min-width: 16px; text-align: center; } diff --git a/src/ui/components/DefenceGrid.tsx b/src/ui/components/DefenceGrid.tsx new file mode 100644 index 0000000..ebb61e2 --- /dev/null +++ b/src/ui/components/DefenceGrid.tsx @@ -0,0 +1,98 @@ +import type { Dispatch, SetStateAction } from 'react'; +import type { Order } from '../../engine/types'; +import { ACTION_COSTS } from '../../engine/balance'; +import styles from './DefenceGrid.module.css'; + +interface Props { + orders: Order[]; + setOrders: Dispatch>; + apRemaining: number; + projectedShieldsInStockpile: number; + projectedAaInStockpile: number; +} + +interface CellSpec { + emoji: string; + label: string; + cost: number; + matches: (o: Order) => boolean; + make: () => Order; + ownedHint?: number; + canAddMore: boolean; +} + +function findLastIndexMatching(arr: T[], pred: (x: T) => boolean): number { + for (let i = arr.length - 1; i >= 0; i--) { + if (pred(arr[i])) return i; + } + return -1; +} + +export default function DefenceGrid({ + orders, setOrders, apRemaining, projectedShieldsInStockpile, projectedAaInStockpile, +}: Props) { + const cells: CellSpec[] = [ + { + emoji: '🛡️', label: 'Build Shield', cost: ACTION_COSTS.buildDefence, + matches: (o) => o.kind === 'build-defence' && o.type === 'shield', + make: () => ({ kind: 'build-defence', type: 'shield' }), + canAddMore: true, + }, + { + emoji: '🛡️↑', label: 'Deploy Shield', cost: ACTION_COSTS.deployDefence, + matches: (o) => o.kind === 'deploy-defence' && o.type === 'shield', + make: () => ({ kind: 'deploy-defence', type: 'shield' }), + ownedHint: projectedShieldsInStockpile, + canAddMore: projectedShieldsInStockpile > 0, + }, + { + emoji: '📡', label: 'Build AA', cost: ACTION_COSTS.buildDefence, + matches: (o) => o.kind === 'build-defence' && o.type === 'aa', + make: () => ({ kind: 'build-defence', type: 'aa' }), + canAddMore: true, + }, + { + emoji: '📡↑', label: 'Deploy AA', cost: ACTION_COSTS.deployDefence, + matches: (o) => o.kind === 'deploy-defence' && o.type === 'aa', + make: () => ({ kind: 'deploy-defence', type: 'aa' }), + ownedHint: projectedAaInStockpile, + canAddMore: projectedAaInStockpile > 0, + }, + ]; + + return ( +
+ {cells.map((cell, i) => { + const count = orders.filter(cell.matches).length; + const canAdd = cell.canAddMore && apRemaining >= cell.cost; + return ( +
0 ? styles.on : ''}`}> +
{cell.emoji}
+
{cell.label}
+
+ {cell.cost} AP + {cell.ownedHint !== undefined ? ` · ${cell.ownedHint} owned` : ''} +
+
+ + {count} + +
+
+ ); + })} +
+ ); +} From 459e9e1698195797e25b4596abbd00feee0e3885 Mon Sep 17 00:00:00 2001 From: gethi Date: Wed, 13 May 2026 22:16:57 +0100 Subject: [PATCH 19/24] =?UTF-8?q?ui:=20LaunchCell=20component=20=E2=80=94?= =?UTF-8?q?=20single=20(delivery=20=C3=97=20yield)=20launch=20cell?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ui/components/LaunchCell.module.css | 26 +++++++++++++++++++++++++ src/ui/components/LaunchCell.tsx | 26 +++++++++++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 src/ui/components/LaunchCell.module.css create mode 100644 src/ui/components/LaunchCell.tsx diff --git a/src/ui/components/LaunchCell.module.css b/src/ui/components/LaunchCell.module.css new file mode 100644 index 0000000..0d63b46 --- /dev/null +++ b/src/ui/components/LaunchCell.module.css @@ -0,0 +1,26 @@ +.cell { + background: white; + border: 1px solid #d0c8b8; + border-radius: 4px; + padding: 6px 4px; + text-align: center; + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; +} +.cell.on { background: #f8d7da; border-color: #b02a37; } +.cell.off { background: #f8f9fa; color: #adb5bd; opacity: 0.55; } + +.emoji { font-size: 20px; line-height: 1; } +.label { font-size: 10px; font-weight: 600; } +.inv { font-size: 9px; color: #6c757d; font-style: italic; margin-top: -1px; } +.cell.on .inv { color: #b02a37; font-weight: 600; font-style: normal; } + +.stepper { display: flex; align-items: center; gap: 4px; margin-top: 2px; } +.stepper button { + background: #1a1a1a; color: white; border: 0; border-radius: 50%; + width: 16px; height: 16px; line-height: 14px; font-size: 11px; padding: 0; cursor: pointer; +} +.stepper button:disabled { background: #adb5bd; cursor: not-allowed; } +.num { font-weight: 700; font-size: 13px; min-width: 14px; text-align: center; } diff --git a/src/ui/components/LaunchCell.tsx b/src/ui/components/LaunchCell.tsx new file mode 100644 index 0000000..cf95691 --- /dev/null +++ b/src/ui/components/LaunchCell.tsx @@ -0,0 +1,26 @@ +import styles from './LaunchCell.module.css'; + +interface Props { + sizeLabel: 'small' | 'med' | 'big'; + warheadsLeft: number; + count: number; + canAdd: boolean; + onInc: () => void; + onDec: () => void; +} + +export default function LaunchCell({ sizeLabel, warheadsLeft, count, canAdd, onInc, onDec }: Props) { + const stateClass = count > 0 ? styles.on : warheadsLeft === 0 && count === 0 ? styles.off : ''; + return ( +
+
💥
+
{sizeLabel}
+
{warheadsLeft} left
+
+ + {count} + +
+
+ ); +} From e595d63896d8842da3a4da58cbe30674ce146721 Mon Sep 17 00:00:00 2001 From: gethi Date: Wed, 13 May 2026 22:18:31 +0100 Subject: [PATCH 20/24] =?UTF-8?q?ui:=20TargetRow=20component=20=E2=80=94?= =?UTF-8?q?=20per-target=20diplomacy=20+=20launch=20grids?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/ui/components/TargetRow.module.css | 36 ++++++ src/ui/components/TargetRow.tsx | 159 +++++++++++++++++++++++++ 2 files changed, 195 insertions(+) create mode 100644 src/ui/components/TargetRow.module.css create mode 100644 src/ui/components/TargetRow.tsx diff --git a/src/ui/components/TargetRow.module.css b/src/ui/components/TargetRow.module.css new file mode 100644 index 0000000..6af769f --- /dev/null +++ b/src/ui/components/TargetRow.module.css @@ -0,0 +1,36 @@ +.row { + background: white; + border: 1px solid #d0c8b8; + border-radius: 6px; + padding: 8px 10px; + margin-bottom: 6px; +} + +.head { display: flex; align-items: center; gap: 6px; font-size: 12px; margin-bottom: 6px; } +.flag { font-size: 18px; } +.name { flex: 1; font-weight: 700; } +.tt { font-size: 10px; background: #f0f0f0; padding: 2px; border-radius: 12px; display: flex; gap: 0; } +.ttSeg { padding: 2px 8px; cursor: pointer; border-radius: 10px; font-weight: 500; } +.ttOn { background: #b02a37; color: white; } + +.mood { font-size: 11px; font-style: italic; color: #5a4a3a; margin-bottom: 6px; } + +.diplo { display: grid; grid-template-columns: 1fr 1fr; gap: 4px; margin-bottom: 6px; } +.diploBtn { + background: white; border: 1px solid #d0c8b8; border-radius: 4px; + padding: 6px 8px; font-size: 11px; cursor: pointer; + display: flex; align-items: center; gap: 6px; justify-content: center; +} +.diploOn { background: #d4edda; border-color: #198754; } +.cost { font-size: 9px; color: #6c757d; } +.diploOn .cost { color: #155724; } + +.rowLabel { + font-size: 9px; letter-spacing: 0.1em; text-transform: uppercase; + color: #6c757d; margin: 4px 0 2px; + display: flex; align-items: baseline; gap: 6px; +} +.inv { letter-spacing: 0; text-transform: none; font-weight: 600; color: #495057; } +.invEmpty { color: #adb5bd; font-style: italic; font-weight: 400; } + +.lcGrid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 4px; } diff --git a/src/ui/components/TargetRow.tsx b/src/ui/components/TargetRow.tsx new file mode 100644 index 0000000..36f5122 --- /dev/null +++ b/src/ui/components/TargetRow.tsx @@ -0,0 +1,159 @@ +import type { Dispatch, SetStateAction } from 'react'; +import type { Leader, Order, TargetType, Yield } from '../../engine/types'; +import type { ProjectedInventory } from '../util/projection'; +import { ACTION_COSTS } from '../../engine/balance'; +import LaunchCell from './LaunchCell'; +import styles from './TargetRow.module.css'; + +interface Props { + target: Leader; + mood?: string; + targetType: TargetType; + onTargetTypeChange: (next: TargetType) => void; + orders: Order[]; + setOrders: Dispatch>; + apRemaining: number; + projection: ProjectedInventory; +} + +const YIELDS: Array<{ label: 'small' | 'med' | 'big'; yield: Yield }> = [ + { label: 'small', yield: 'small' }, + { label: 'med', yield: 'medium' }, + { label: 'big', yield: 'large' }, +]; + +function findLastIndexMatching(arr: T[], pred: (x: T) => boolean): number { + for (let i = arr.length - 1; i >= 0; i--) { + if (pred(arr[i])) return i; + } + return -1; +} + +export default function TargetRow({ + target, mood, targetType, onTargetTypeChange, orders, setOrders, apRemaining, projection, +}: Props) { + const flag = target.country.split(' ')[0]; + + const wooed = orders.some((o) => o.kind === 'woo' && o.target === target.id); + const propd = orders.some((o) => o.kind === 'propaganda' && o.target === target.id); + + function toggleWoo() { + if (wooed) { + setOrders((prev) => prev.filter((o) => !(o.kind === 'woo' && o.target === target.id))); + } else { + if (apRemaining < ACTION_COSTS.woo) return; + setOrders((prev) => [...prev, { kind: 'woo', target: target.id }]); + } + } + function togglePropaganda() { + if (propd) { + setOrders((prev) => prev.filter((o) => !(o.kind === 'propaganda' && o.target === target.id))); + } else { + if (apRemaining < ACTION_COSTS.propaganda) return; + setOrders((prev) => [...prev, { kind: 'propaganda', target: target.id }]); + } + } + + function warheadsLeftFor(y: Yield): number { + if (y === 'small') return projection.warheadsSmall; + if (y === 'medium') return projection.warheadsMedium; + return projection.warheadsLarge; + } + + function launchCount(delivery: 'missile' | 'bomber', y: Yield): number { + return orders.filter((o) => + o.kind === 'launch' && o.target === target.id && o.delivery === delivery && o.warhead === y && o.targetType === targetType, + ).length; + } + + function addLaunch(delivery: 'missile' | 'bomber', y: Yield) { + setOrders((prev) => [...prev, { + kind: 'launch', target: target.id, delivery, warhead: y, targetType, + }]); + } + + function removeLaunch(delivery: 'missile' | 'bomber', y: Yield) { + setOrders((prev) => { + const idx = findLastIndexMatching(prev, (o) => + o.kind === 'launch' && o.target === target.id && o.delivery === delivery && o.warhead === y && o.targetType === targetType, + ); + if (idx === -1) return prev; + return [...prev.slice(0, idx), ...prev.slice(idx + 1)]; + }); + } + + const canLaunch = (delivery: 'missile' | 'bomber', y: Yield) => { + const deliveryLeft = delivery === 'missile' ? projection.missiles : projection.bombers; + if (deliveryLeft <= 0) return false; + if (warheadsLeftFor(y) <= 0) return false; + return apRemaining >= ACTION_COSTS.launch; + }; + + return ( +
+
+ {flag} + {target.name} + + onTargetTypeChange('people')} + >people + onTargetTypeChange('infra')} + >infra + +
+ + {mood &&
{mood}
} + +
+ + +
+ +
+ missiles · {projection.missiles} left +
+
+ {YIELDS.map((Y) => ( + addLaunch('missile', Y.yield)} + onDec={() => removeLaunch('missile', Y.yield)} + /> + ))} +
+ +
+ bombers · {projection.bombers} left +
+
+ {YIELDS.map((Y) => ( + addLaunch('bomber', Y.yield)} + onDec={() => removeLaunch('bomber', Y.yield)} + /> + ))} +
+
+ ); +} From 8ac74ba999097777417048dd567e7607cf6e0353 Mon Sep 17 00:00:00 2001 From: gethi Date: Wed, 13 May 2026 22:19:19 +0100 Subject: [PATCH 21/24] ui: restore emoji prefixes on TargetRow diplomacy buttons + row labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T12 stripped 💌 / 📰 / 🚀 / ✈️ prefixes citing 'no-whimsy' preference. That preference scopes to assistant text output only — game UI uses emojis throughout (per spec, mockups, and existing P3/P4a screens). --- src/ui/components/TargetRow.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/ui/components/TargetRow.tsx b/src/ui/components/TargetRow.tsx index 36f5122..35cb0af 100644 --- a/src/ui/components/TargetRow.tsx +++ b/src/ui/components/TargetRow.tsx @@ -113,16 +113,16 @@ export default function TargetRow({ type="button" className={`${styles.diploBtn} ${wooed ? styles.diploOn : ''}`} onClick={toggleWoo} - >Woo
1 AP + >💌 Woo
1 AP + >📰 Propaganda
1 AP
- missiles · {projection.missiles} left + 🚀 missiles · {projection.missiles} left
{YIELDS.map((Y) => ( @@ -139,7 +139,7 @@ export default function TargetRow({
- bombers · {projection.bombers} left + ✈️ bombers · {projection.bombers} left
{YIELDS.map((Y) => ( From 8b2b6308b6759bdbe370e8050e2a358d6a5c4108 Mon Sep 17 00:00:00 2001 From: gethi Date: Thu, 14 May 2026 06:45:37 +0100 Subject: [PATCH 22/24] =?UTF-8?q?ui:=20Planning=20full=20rewrite=20?= =?UTF-8?q?=E2=80=94=20action-card=20grid;=20delete=20OrderForm=20+=20Lead?= =?UTF-8?q?erCard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the OrderForm/LeaderCard-based Planning screen with the new action-card grid layout using BuildGrid, DefenceGrid, and TargetRow. Seal Orders is a plain click button (hold-to-seal removed as per plan). Delete OrderForm.tsx/.module.css/.test and LeaderCard.tsx/.module.css. Update Planning.softwarn.test to drive soft-warn via BuildGrid stepper. Add Planning.actionGrid.test (5 tests) and Planning.targetRow.test (2). Co-Authored-By: Claude Sonnet 4.6 --- src/ui/components/LeaderCard.module.css | 56 ------ src/ui/components/LeaderCard.tsx | 49 ------ src/ui/components/OrderForm.module.css | 50 ------ src/ui/components/OrderForm.tsx | 216 ------------------------ src/ui/screens/Planning.module.css | 152 ++--------------- src/ui/screens/Planning.tsx | 198 ++++++---------------- tests/ui/OrderForm.test.tsx | 72 -------- tests/ui/Planning.actionGrid.test.tsx | 52 ++++++ tests/ui/Planning.softwarn.test.tsx | 11 +- tests/ui/Planning.targetRow.test.tsx | 41 +++++ 10 files changed, 167 insertions(+), 730 deletions(-) delete mode 100644 src/ui/components/LeaderCard.module.css delete mode 100644 src/ui/components/LeaderCard.tsx delete mode 100644 src/ui/components/OrderForm.module.css delete mode 100644 src/ui/components/OrderForm.tsx delete mode 100644 tests/ui/OrderForm.test.tsx create mode 100644 tests/ui/Planning.actionGrid.test.tsx create mode 100644 tests/ui/Planning.targetRow.test.tsx diff --git a/src/ui/components/LeaderCard.module.css b/src/ui/components/LeaderCard.module.css deleted file mode 100644 index 47f98f1..0000000 --- a/src/ui/components/LeaderCard.module.css +++ /dev/null @@ -1,56 +0,0 @@ -.card { - background: #fff8eb; - border: 1px solid #d0c8b8; - border-radius: 8px; - padding: 8px; - cursor: default; -} - -.head { - display: flex; - align-items: center; - gap: 6px; - font-weight: 600; - font-size: 13px; -} - -.flag { font-size: 16px; } -.name { font-weight: 600; font-size: 13px; } - -.stats { - font-size: 11px; - color: #5a4a3a; - margin-top: 3px; -} - -.badges { - display: flex; - gap: 3px; - flex-wrap: wrap; - margin-top: 4px; -} - -.badge { - font-size: 9px; - padding: 2px 5px; - border-radius: 3px; - text-transform: uppercase; - letter-spacing: 0.04em; - font-weight: 600; - color: white; -} - -.hitYou { background: #8a1a1a; } -.wooingYou { background: #6b8a2b; } -.youWooed { background: #2b6b2b; } -.grudge { background: #5a3a1a; } - -.moodSlot { - min-height: 14px; /* reserved space for P4a mood lines */ -} - -.moodLine { - font-size: 10px; - color: #5a4a3a; - font-style: italic; -} diff --git a/src/ui/components/LeaderCard.tsx b/src/ui/components/LeaderCard.tsx deleted file mode 100644 index 9e68567..0000000 --- a/src/ui/components/LeaderCard.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import type { Leader } from '../../engine/types'; -import styles from './LeaderCard.module.css'; - -export interface LeaderCardProps { - leader: Leader; - playerHits: number; // recentAggressionFrom value against player - playerFav: number; // favourability *they* have toward me (player wooing them) - myFav: number; // favourability *I* have toward them (they wooed me) - playerGrudge: number; // their grudge against player - mood?: string; -} - -export default function LeaderCard({ - leader, - playerHits, - playerFav, - myFav, - playerGrudge, - mood, -}: LeaderCardProps) { - const arsenalCount = - leader.stockpile.missiles + - leader.stockpile.bombers + - leader.stockpile.warheadsSmall + - leader.stockpile.warheadsMedium + - leader.stockpile.warheadsLarge; - - return ( -
-
- {leader.country.split(' ')[0]} - {leader.name} -
-
- Pop {leader.population}M · Factories {leader.factories} · Arsenal {arsenalCount} -
-
- {playerHits > 0 && hit you} - {playerFav > 0 && you wooed} - {myFav > 0 && they wooed you} - {playerGrudge > 0 && grudge ({playerGrudge})} -
- {/* mood-line slot — empty in thin P3, P4a fills with flavor */} -
- {mood && {mood}} -
-
- ); -} diff --git a/src/ui/components/OrderForm.module.css b/src/ui/components/OrderForm.module.css deleted file mode 100644 index b19e417..0000000 --- a/src/ui/components/OrderForm.module.css +++ /dev/null @@ -1,50 +0,0 @@ -.orderForm { - background: #fff8eb; - border: 1px solid #d0c8b8; - border-radius: 8px; - padding: 12px; - margin: 8px 0; -} - -.field { - display: flex; - align-items: center; - justify-content: space-between; - margin: 6px 0; - font-size: 13px; -} - -.field span { color: #5a4a3a; } - -.field select, -.field input { - font-size: 13px; - padding: 4px 6px; - border: 1px solid #d0c8b8; - border-radius: 4px; - background: white; - min-width: 140px; -} - -.addButton { - display: block; - margin: 8px auto 0; - padding: 8px 16px; - background: #6b1d1d; - color: #fff8eb; - border: none; - border-radius: 6px; - font-size: 13px; - font-weight: 600; - cursor: pointer; -} - -.error { - margin-top: 8px; - padding: 6px 10px; - background: #ffe9d5; - border: 1px solid #8a1a1a; - border-radius: 6px; - color: #8a1a1a; - font-size: 12px; -} diff --git a/src/ui/components/OrderForm.tsx b/src/ui/components/OrderForm.tsx deleted file mode 100644 index ae002ac..0000000 --- a/src/ui/components/OrderForm.tsx +++ /dev/null @@ -1,216 +0,0 @@ -import { useState } from 'react'; -import type { DeliveryType, GameState, LeaderId, Order, TargetType, Yield } from '../../engine/types'; -import { validateOrderSequence, totalApCost } from '../../engine/orders'; -import styles from './OrderForm.module.css'; - -export interface OrderFormProps { - state: GameState; - playerId: LeaderId; - committedOrders: Order[]; - onAdd: (order: Order) => void; -} - -type OrderKind = Order['kind']; - -const ORDER_KINDS: { value: OrderKind; label: string }[] = [ - { value: 'build-factory', label: 'Build factory' }, - { value: 'build-missile', label: 'Build missile' }, - { value: 'build-bomber', label: 'Build bomber' }, - { value: 'build-warhead', label: 'Build warhead' }, - { value: 'build-defence', label: 'Build defence' }, - { value: 'launch', label: 'Launch' }, - { value: 'propaganda', label: 'Propaganda' }, - { value: 'woo', label: 'Woo' }, -]; - -export default function OrderForm({ state, playerId, committedOrders, onAdd }: OrderFormProps) { - const [kind, setKind] = useState('build-factory'); - // For build-warhead the default 'small' is fine (it's the cheapest); for - // launch we treat '' as unset so the user must explicitly pick a warhead. - const [warheadBuild, setWarheadBuild] = useState('small'); - const [warheadLaunch, setWarheadLaunch] = useState(''); - const [defenceType, setDefenceType] = useState<'shield' | 'aa'>('shield'); - const [target, setTarget] = useState(''); - const [delivery, setDelivery] = useState(''); - const [targetType, setTargetType] = useState('people'); - const [points, setPoints] = useState(1); - const [errorMsg, setErrorMsg] = useState(null); - - const aliveOthers = state.cast.filter( - (id) => id !== playerId && state.leaders[id]?.alive, - ); - - function buildOrder(): { order: Order } | { missing: string } { - switch (kind) { - case 'build-factory': - case 'build-missile': - case 'build-bomber': - return { order: { kind } }; - case 'build-warhead': - return { order: { kind, yield: warheadBuild } }; - case 'build-defence': - return { order: { kind, type: defenceType } }; - case 'deploy-defence': - return { order: { kind, type: defenceType } }; - case 'launch': - if (!target) return { missing: 'target' }; - if (!delivery) return { missing: 'delivery' }; - if (!warheadLaunch) return { missing: 'warhead' }; - return { order: { kind, target, delivery, warhead: warheadLaunch, targetType } }; - case 'propaganda': - if (!target) return { missing: 'target' }; - return { order: { kind, target } }; - case 'woo': - if (!target) return { missing: 'target' }; - return { order: { kind, target } }; - } - } - - function tryAdd() { - const built = buildOrder(); - if ('missing' in built) { - setErrorMsg(`Missing ${built.missing}.`); - return; - } - const order = built.order; - // Validate the FULL queue (existing + new) with stockpile projection — - // catches multi-launch cases where individual launches each pass validateOrder - // but the sequence would over-consume stockpile. - const fullQueue = [...committedOrders, order]; - const v = validateOrderSequence(state, playerId, fullQueue); - if (!v.ok) { - // If the failure is on the NEW order (last index), surface its reason. - // If it's on a prior order, that shouldn't happen if each was validated - // when added — surface the indexed reason for debugging. - const onNewOrder = v.orderIndex === fullQueue.length - 1; - setErrorMsg(onNewOrder ? v.reason : `Prior order #${v.orderIndex + 1}: ${v.reason}`); - return; - } - // AP check (validateOrderSequence does NOT check AP — that's the reducer's - // final gate, but we mirror it here so the user gets immediate feedback) - const totalCost = totalApCost(fullQueue); - if (totalCost > state.leaders[playerId].ap) { - setErrorMsg(`Over-budget: ${totalCost} AP > ${state.leaders[playerId].ap} AP available.`); - return; - } - setErrorMsg(null); - onAdd(order); - } - - const needsTarget = kind === 'launch' || kind === 'propaganda' || kind === 'woo'; - const needsDefenceType = kind === 'build-defence'; - const needsDelivery = kind === 'launch'; - const needsTargetType = kind === 'launch'; - const needsPoints = kind === 'woo'; - - return ( -
- - - {needsTarget && ( - - )} - - {kind === 'build-warhead' && ( - - )} - - {kind === 'launch' && ( - - )} - - {needsDefenceType && ( - - )} - - {needsDelivery && ( - - )} - - {needsTargetType && ( - - )} - - {needsPoints && ( - - )} - - - - {errorMsg &&
{errorMsg}
} -
- ); -} diff --git a/src/ui/screens/Planning.module.css b/src/ui/screens/Planning.module.css index 6430551..27282fb 100644 --- a/src/ui/screens/Planning.module.css +++ b/src/ui/screens/Planning.module.css @@ -1,155 +1,25 @@ .planning { max-width: 480px; margin: 0 auto; - padding: 12px 14px; - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; - background: #f4ede2; - color: #1a1a1a; - min-height: 100vh; - box-sizing: border-box; -} - -.header { - text-align: center; - font-size: 18px; - font-weight: 700; - color: #6b1d1d; - margin-bottom: 12px; -} - -.sectionTitle { - font-size: 10px; - text-transform: uppercase; - letter-spacing: 0.1em; - color: #8a7a6b; - margin: 12px 0 6px; - font-weight: 600; -} - -.ownPanel { - background: #e8d8c0; - border-radius: 8px; - padding: 10px 12px; -} - -.statsRow { - display: flex; - justify-content: space-between; - gap: 12px; + padding: 12px; font-size: 12px; - color: #5a4a3a; - margin: 2px 0; } -.historyStrip { - display: flex; - gap: 6px; - overflow-x: auto; - padding-bottom: 4px; - align-items: center; -} +.header { font-size: 14px; font-weight: 700; margin-bottom: 8px; } -.chip { - background: #fff8eb; - border: 1px solid #d0c8b8; - border-radius: 6px; - padding: 4px 7px; - font-size: 11px; - white-space: nowrap; +.apBanner { + background: #198754; color: white; padding: 6px 12px; border-radius: 4px; + margin-bottom: 12px; font-size: 12px; display: flex; justify-content: space-between; } -.chip.attack { color: #8a1a1a; } - -.tableGrid { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 8px; - margin: 8px 0; -} - -.ordersList { - background: #fff8eb; - border-radius: 8px; - padding: 8px 10px; - border: 1px solid #d0c8b8; -} - -.empty { - font-size: 12px; - color: #8a7a6b; - font-style: italic; - padding: 4px 0; -} - -.orderRow { - display: flex; - justify-content: space-between; - align-items: center; - padding: 6px 0; - border-bottom: 1px dashed #d0c8b8; - font-size: 12px; -} - -.orderRow:last-of-type { border-bottom: none; } - -.orderRow.warned { background: #fff8e1; border-color: #f59f00; } - -.orderLabel { color: #2a2a2a; } - -.removeBtn { - background: none; - border: none; - font-size: 18px; - color: #8a1a1a; - cursor: pointer; - padding: 0 4px; -} - -.apSummary { - font-size: 11px; - color: #5a4a3a; - margin-top: 6px; - text-align: right; -} - -.apSummary.over { color: #8a1a1a; font-weight: 700; } - -.sealWrap { - margin-top: 16px; +.sectionTitle { + font-size: 9px; letter-spacing: 0.12em; text-transform: uppercase; + color: #856404; font-weight: 700; margin: 10px 0 4px; } .sealBtn { - position: relative; - display: block; - width: 100%; - padding: 14px; - background: #6b1d1d; - color: #fff8eb; - border: none; - border-radius: 8px; - font-size: 14px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.1em; + background: #b02a37; color: white; border: none; border-radius: 24px; + padding: 14px 28px; font-size: 14px; font-weight: 600; width: 100%; margin-top: 16px; cursor: pointer; - overflow: hidden; -} - -.sealBtn:disabled { background: #c9b89a; cursor: not-allowed; } - -.sealLabel { position: relative; z-index: 1; } - -.sealProgress { - position: absolute; - inset: 0; - background: rgba(255, 255, 255, 0.25); - transform-origin: left center; - transform: scaleX(0); - pointer-events: none; - transition: transform 0ms linear; -} - -.sealBtn.holding .sealProgress { - transform: scaleX(1); - transition: transform 600ms linear; } +.sealBtn:disabled { background: #adb5bd; cursor: not-allowed; } diff --git a/src/ui/screens/Planning.tsx b/src/ui/screens/Planning.tsx index bae3c24..0ef8e4d 100644 --- a/src/ui/screens/Planning.tsx +++ b/src/ui/screens/Planning.tsx @@ -1,174 +1,84 @@ -import { useEffect, useRef, useState, useCallback } from 'react'; +import { useState } from 'react'; import type { ScreenProps } from '../App'; -import type { GameState, LeaderId, Order } from '../../engine/types'; +import type { LeaderId, Order, TargetType } from '../../engine/types'; import { isHuman } from '../../engine/state'; import { totalApCost, analyseOrderSequence } from '../../engine/orders'; -import LeaderCard from '../components/LeaderCard'; -import ApBudget from '../components/ApBudget'; -import OrderForm from '../components/OrderForm'; +import BuildGrid from '../components/BuildGrid'; +import DefenceGrid from '../components/DefenceGrid'; +import TargetRow from '../components/TargetRow'; import SoftWarnPanel from '../components/SoftWarnPanel'; +import { projectInventory } from '../util/projection'; import styles from './Planning.module.css'; -const HOLD_MS = 600; - export default function Planning({ state, dispatch }: ScreenProps) { const game = state.game!; const activeId = state.activeHumanTurn ?? 'player1'; const player = game.leaders[activeId]; + const aiLeaders = game.cast.filter((id) => !isHuman(id) && game.leaders[id].alive); const [orders, setOrders] = useState([]); - const [holding, setHolding] = useState(false); - const holdTimer = useRef(null); - - function addOrder(o: Order) { setOrders((q) => [...q, o]); } - function removeOrder(i: number) { setOrders((q) => q.filter((_, idx) => idx !== i)); } - - const startHold = useCallback(() => { - setHolding(true); - holdTimer.current = window.setTimeout(() => { - setHolding(false); - holdTimer.current = null; - dispatch({ type: 'PLAYER_SUBMIT', leaderId: state.activeHumanTurn ?? 'player1', orders }); - }, HOLD_MS); - }, [orders, dispatch]); - - const cancelHold = useCallback(() => { - setHolding(false); - if (holdTimer.current !== null) { - window.clearTimeout(holdTimer.current); - holdTimer.current = null; - } - }, []); - - useEffect(() => () => { - if (holdTimer.current !== null) { - window.clearTimeout(holdTimer.current); - holdTimer.current = null; - } - }, []); + const [targetTypes, setTargetTypes] = useState>>({}); const apUsed = totalApCost(orders); - const apTotal = player.ap; // engine's ap already factors banked + bonus per resolution + const apTotal = player.ap; + const apRemaining = Math.max(0, apTotal - apUsed); const overBudget = apUsed > apTotal; + const projection = projectInventory(player, orders); const softWarnings = analyseOrderSequence(game, activeId, orders); - const warnedIndices = new Set(softWarnings.map((w) => w.orderIndex)); - const moodByLeader: Partial> = {}; + const moodByLeader: Partial> = {}; for (const e of state.events) { if (e.kind === 'PreRoundMood') moodByLeader[e.leaderId] = e.quote; } - const lastRoundChips = game.orderHistory.length > 0 - ? Object.entries(game.orderHistory[game.orderHistory.length - 1]) - .flatMap(([id, os]) => - (os ?? []).filter((o) => o.kind === 'launch' || o.kind === 'propaganda').map((o) => ({ id, o })), - ) - : []; - return (
-
Round {game.round}{state.activeHumanTurn ? ` · ${player.name}` : ''}
- -
-

{player.country} {player.name} (you)

-
- Pop {player.population}M - Factories {player.factories} -
-
- Missiles {player.stockpile.missiles} - Bombers {player.stockpile.bombers} - Shields {player.stockpile.shields} - AA {player.stockpile.aa} -
- -
+
+ Round {game.round}{state.activeHumanTurn ? ` · ${player.name}` : ''} +
-
-

Last round

- {lastRoundChips.length === 0 ? ( -
- ) : ( - lastRoundChips.map((c, i) => ( -
- {game.leaders[c.id as LeaderId]?.country.split(' ')[0]}{' '} - {c.o.kind === 'launch' - ? `→ ${game.leaders[c.o.target].country.split(' ')[0]}` - : c.o.kind === 'propaganda' - ? `📰 ${game.leaders[c.o.target].country.split(' ')[0]}` - : ''} -
- )) - )} -
- -
- {aiLeaders.map((id) => { - const leader = game.leaders[id]; - return ( - - ); - })} -
- -
-

Your orders

- {orders.length === 0 ? ( -
No orders yet.
- ) : ( - orders.map((o, i) => ( -
- {formatOrder(o, game)} - -
- )) - )} -
- AP used: {apUsed} / {apTotal} -
- -
- - - -
- +
+ AP used: {apUsed} / {apTotal} + {apRemaining} left
+ + + +
Build
+ + +
Defence
+ + +
Actions by target
+ {aiLeaders.map((id) => ( + setTargetTypes((prev) => ({ ...prev, [id]: next }))} + orders={orders} + setOrders={setOrders} + apRemaining={apRemaining} + projection={projection} + /> + ))} + +
); } - -function formatOrder(o: Order, game: GameState): string { - switch (o.kind) { - case 'build-factory': return 'Build factory (3 AP)'; - case 'build-missile': return 'Build missile (1 AP)'; - case 'build-bomber': return 'Build bomber (1 AP)'; - case 'build-warhead': return `Build ${o.yield} warhead (${o.yield === 'small' ? 1 : o.yield === 'medium' ? 2 : 3} AP)`; - case 'build-defence': return `Build ${o.type === 'shield' ? 'shield' : 'AA'} (4 AP)`; - case 'deploy-defence': return `Deploy ${o.type === 'shield' ? 'shield' : 'AA'} (4 AP)`; - case 'launch': return `Launch ${o.warhead} at ${game.leaders[o.target].name} (${o.targetType}, 2 AP)`; - case 'propaganda': return `Propaganda → ${game.leaders[o.target].name} (1 AP)`; - case 'woo': return `Woo ${game.leaders[o.target].name} (1 AP)`; - } -} diff --git a/tests/ui/OrderForm.test.tsx b/tests/ui/OrderForm.test.tsx deleted file mode 100644 index 418e52f..0000000 --- a/tests/ui/OrderForm.test.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { render, screen, fireEvent } from '@testing-library/react'; -import OrderForm from '../../src/ui/components/OrderForm'; -import { initialState } from '../../src/engine/state'; -import type { GameState, Order } from '../../src/engine/types'; - -function gameWithCast(): GameState { - return initialState({ - cast: ['player1', 'chump', 'carnage'], - difficulty: 'normal', - seed: 'order-form-test', - }); -} - -describe('', () => { - it('blocks Add for a launch without delivery + warhead', () => { - const state = gameWithCast(); - let added: Order | null = null; - render( - { added = o; }} - />, - ); - fireEvent.change(screen.getByLabelText(/order kind/i), { target: { value: 'launch' } }); - fireEvent.change(screen.getByLabelText(/target/i), { target: { value: 'chump' } }); - // delivery + warhead not set — should be invalid - fireEvent.click(screen.getByRole('button', { name: /add/i })); - expect(added).toBeNull(); - expect(screen.getByText(/missing.*delivery|missing.*warhead/i)).toBeInTheDocument(); - }); - - it('surfaces engine validateOrder reason on AP overrun', () => { - const state = gameWithCast(); - // P4b: player1 startAp=6. Queue build-factory (3) + build-warhead-large (3) = 6 AP spent. - // A further build-factory (3 AP) must then be rejected as over-budget. - const prior: Order[] = [ - { kind: 'build-factory' }, - { kind: 'build-warhead', yield: 'large' }, - ]; - let added: Order | null = null; - render( - { added = o; }} - />, - ); - fireEvent.change(screen.getByLabelText(/order kind/i), { target: { value: 'build-factory' } }); - fireEvent.click(screen.getByRole('button', { name: /add/i })); - expect(added).toBeNull(); - expect(screen.getByText(/budget|overrun|ap/i)).toBeInTheDocument(); - }); - - it('accepts a valid build-factory order', () => { - const state = gameWithCast(); - let added: Order | null = null; - render( - { added = o; }} - />, - ); - fireEvent.change(screen.getByLabelText(/order kind/i), { target: { value: 'build-factory' } }); - fireEvent.click(screen.getByRole('button', { name: /add/i })); - expect(added).toEqual({ kind: 'build-factory' }); - }); -}); diff --git a/tests/ui/Planning.actionGrid.test.tsx b/tests/ui/Planning.actionGrid.test.tsx new file mode 100644 index 0000000..42a12bb --- /dev/null +++ b/tests/ui/Planning.actionGrid.test.tsx @@ -0,0 +1,52 @@ +import { describe, expect, it, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import Planning from '../../src/ui/screens/Planning'; +import { initialState } from '../../src/engine/state'; +import type { UiState } from '../../src/ui/store'; + +function makeState(): UiState { + const game = initialState({ + cast: ['player1', 'chump', 'carnage'], + difficulty: 'normal', + seed: 'planning-test', + }); + return { + screen: 'planning', + game, + events: [], + prevPopulations: {}, + initialPopulations: {}, + lastNewGameOpts: null, + activeHumanTurn: 'player1', + pendingHumanOrders: {}, + }; +} + +describe(' action-card grid', () => { + it('renders build grid with Factory cell', () => { + render(); + expect(screen.getByText(/Factory/i)).toBeInTheDocument(); + }); + + it('renders defence grid with Build Shield cell', () => { + render(); + expect(screen.getByText(/Build Shield/i)).toBeInTheDocument(); + }); + + it('renders target rows for each opponent', () => { + render(); + expect(screen.getByLabelText(/Target row for Chump/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/Target row for Carnage/i)).toBeInTheDocument(); + }); + + it('renders AP banner', () => { + render(); + expect(screen.getByText(/AP used/i)).toBeInTheDocument(); + }); + + it('seal button exists', () => { + const dispatch = vi.fn(); + render(); + expect(screen.getByRole('button', { name: /seal/i })).toBeInTheDocument(); + }); +}); diff --git a/tests/ui/Planning.softwarn.test.tsx b/tests/ui/Planning.softwarn.test.tsx index ca0d509..d4ddde7 100644 --- a/tests/ui/Planning.softwarn.test.tsx +++ b/tests/ui/Planning.softwarn.test.tsx @@ -30,8 +30,15 @@ describe(' soft-warn panel', () => { it('shows the panel after adding a warhead with no delivery owned', () => { render(); - fireEvent.change(screen.getByLabelText(/order kind/i), { target: { value: 'build-warhead' } }); - fireEvent.click(screen.getByRole('button', { name: /add/i })); + // Click the + button on the Sm Warhead cell in BuildGrid. + // player1 starts with 0 missiles + 0 bombers, so queuing a warhead + // triggers the warhead-no-delivery soft warning. + const smWarheadLabel = screen.getByText(/Sm Warhead/i); + const cell = smWarheadLabel.closest('[class*="cell"]') as HTMLElement; + const buttons = cell.querySelectorAll('button'); + // stepper layout: [−] count [+]; + is the last button + const plusBtn = buttons[buttons.length - 1] as HTMLButtonElement; + fireEvent.click(plusBtn); expect(screen.getByText(/suggestions/i)).toBeInTheDocument(); expect(screen.getByText(/no delivery/i)).toBeInTheDocument(); }); diff --git a/tests/ui/Planning.targetRow.test.tsx b/tests/ui/Planning.targetRow.test.tsx new file mode 100644 index 0000000..8e23390 --- /dev/null +++ b/tests/ui/Planning.targetRow.test.tsx @@ -0,0 +1,41 @@ +import { describe, expect, it, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import Planning from '../../src/ui/screens/Planning'; +import { initialState } from '../../src/engine/state'; +import type { UiState } from '../../src/ui/store'; + +function makeStateWithMood(): UiState { + const game = initialState({ + cast: ['player1', 'chump'], + difficulty: 'normal', + seed: 'target-row-test', + }); + game.leaders.player1.ap = 10; + return { + screen: 'planning', + game, + events: [ + { kind: 'PreRoundMood', leaderId: 'chump', quote: 'Many people are saying.', snapBack: false }, + ], + prevPopulations: {}, + initialPopulations: {}, + lastNewGameOpts: null, + activeHumanTurn: 'player1', + pendingHumanOrders: {}, + }; +} + +describe('', () => { + it('renders mood quote when PreRoundMood event present for that leader', () => { + render(); + expect(screen.getByText(/Many people are saying/)).toBeInTheDocument(); + }); + + it('woo button toggles when clicked', () => { + render(); + const wooBtn = screen.getByRole('button', { name: /Woo/i }); + fireEvent.click(wooBtn); + // After click, the button should have the diploOn class + expect(wooBtn.className).toMatch(/diploOn|on/i); + }); +}); From 757572d6df615e6f8bda32cfe5bf482cf9160485 Mon Sep 17 00:00:00 2001 From: gethi Date: Thu, 14 May 2026 07:13:50 +0100 Subject: [PATCH 23/24] docs: Phase 4b status note in README --- README.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/README.md b/README.md index bc471c8..c4d6e8f 100644 --- a/README.md +++ b/README.md @@ -154,3 +154,21 @@ What's NOT in this phase (deferred to P4b / P5): - Audio (`play(name)` wrapper, sfx + ambient music) — P5 - SVG art (leader portraits, world map, Freedonia flag, mushroom-cloud illustration, ruined-iconography) — P5 - PWA manifest + service worker — P5 + +## Phase 4b status + +Phase 4b (Balance & Planning Rework) doubles the AP economy, makes defences consumable (build + deploy = 8 AP all-in), rewrites the Planning screen as an action-card grid, and simplifies woo/propaganda to flat 1-AP toggles. Verification: `npm run test:run` (246 tests). + +What's in this phase: + +- **AP economy doubled** — startAp ×2 for every leader; FACTORY_AP_RATE 0.5 → 1.0; AP_BANK_CAP 2 → 4. ACTION_COSTS unchanged (math-equivalent to halving costs, no fractions). +- **Consumable defences** — new `deploy-defence` order kind. Build 4 AP adds to stockpile; deploy 4 AP moves stockpile → round-scoped deployed pool. `interceptProbability` reads the deployed pool. Deployed pool clears at round end regardless of intercept (deploy = commit). +- **Planning UI rewrite** — replaces the order-kind dropdown + queue list with `` + `` + `` (one per opponent). Each cell has a +/- stepper bound to a count of that order kind. Per-target rows show 6 launch combos (3 yields × 2 deliveries), each with projected warhead inventory. Mood quote renders under each target's name. +- **Woo + Propaganda flat toggles** — both 1 AP, one per target per round. Dropped the points dimension on woo. + +What's NOT in this phase (deferred to P4c / P5): + +- AI scoring-weight balance pass against the new rules baseline — P4c (was P4b) +- Approach B / C lookahead upgrades — P4c +- Threat-aware defence deployment per personality — P4c +- Persistence, animations, audio, SVG art, PWA — P5 From b66b093bb26a86e22d7b41b479a3941b7846a9fc Mon Sep 17 00:00:00 2001 From: gethi Date: Thu, 14 May 2026 07:26:56 +0100 Subject: [PATCH 24/24] polish: fold final-review nits - Drop stale Task 5 reference comment in deployDefence test - Hoist findLastIndexMatching helper to src/ui/util/arrays.ts; remove triplicated impl from BuildGrid/DefenceGrid/TargetRow --- src/ui/components/BuildGrid.tsx | 6 ++---- src/ui/components/DefenceGrid.tsx | 8 +------- src/ui/components/TargetRow.tsx | 8 +------- src/ui/util/arrays.ts | 10 ++++++++++ tests/engine/deployDefence.test.ts | 4 +--- 5 files changed, 15 insertions(+), 21 deletions(-) create mode 100644 src/ui/util/arrays.ts diff --git a/src/ui/components/BuildGrid.tsx b/src/ui/components/BuildGrid.tsx index 23c30d9..a76a1a3 100644 --- a/src/ui/components/BuildGrid.tsx +++ b/src/ui/components/BuildGrid.tsx @@ -2,6 +2,7 @@ import type { Dispatch, SetStateAction } from 'react'; import type { Order } from '../../engine/types'; import { ACTION_COSTS } from '../../engine/balance'; import styles from './BuildGrid.module.css'; +import { findLastIndexMatching } from '../util/arrays'; interface Props { orders: Order[]; @@ -42,10 +43,7 @@ export default function BuildGrid({ orders, setOrders, apRemaining }: Props) { type="button" disabled={count === 0} onClick={() => setOrders((prev) => { - let idx = -1; - for (let j = prev.length - 1; j >= 0; j--) { - if (cell.matches(prev[j])) { idx = j; break; } - } + const idx = findLastIndexMatching(prev, cell.matches); if (idx === -1) return prev; return [...prev.slice(0, idx), ...prev.slice(idx + 1)]; })} diff --git a/src/ui/components/DefenceGrid.tsx b/src/ui/components/DefenceGrid.tsx index ebb61e2..4e22292 100644 --- a/src/ui/components/DefenceGrid.tsx +++ b/src/ui/components/DefenceGrid.tsx @@ -2,6 +2,7 @@ import type { Dispatch, SetStateAction } from 'react'; import type { Order } from '../../engine/types'; import { ACTION_COSTS } from '../../engine/balance'; import styles from './DefenceGrid.module.css'; +import { findLastIndexMatching } from '../util/arrays'; interface Props { orders: Order[]; @@ -21,13 +22,6 @@ interface CellSpec { canAddMore: boolean; } -function findLastIndexMatching(arr: T[], pred: (x: T) => boolean): number { - for (let i = arr.length - 1; i >= 0; i--) { - if (pred(arr[i])) return i; - } - return -1; -} - export default function DefenceGrid({ orders, setOrders, apRemaining, projectedShieldsInStockpile, projectedAaInStockpile, }: Props) { diff --git a/src/ui/components/TargetRow.tsx b/src/ui/components/TargetRow.tsx index 35cb0af..b6f95fa 100644 --- a/src/ui/components/TargetRow.tsx +++ b/src/ui/components/TargetRow.tsx @@ -4,6 +4,7 @@ import type { ProjectedInventory } from '../util/projection'; import { ACTION_COSTS } from '../../engine/balance'; import LaunchCell from './LaunchCell'; import styles from './TargetRow.module.css'; +import { findLastIndexMatching } from '../util/arrays'; interface Props { target: Leader; @@ -22,13 +23,6 @@ const YIELDS: Array<{ label: 'small' | 'med' | 'big'; yield: Yield }> = [ { label: 'big', yield: 'large' }, ]; -function findLastIndexMatching(arr: T[], pred: (x: T) => boolean): number { - for (let i = arr.length - 1; i >= 0; i--) { - if (pred(arr[i])) return i; - } - return -1; -} - export default function TargetRow({ target, mood, targetType, onTargetTypeChange, orders, setOrders, apRemaining, projection, }: Props) { diff --git a/src/ui/util/arrays.ts b/src/ui/util/arrays.ts new file mode 100644 index 0000000..e1f089d --- /dev/null +++ b/src/ui/util/arrays.ts @@ -0,0 +1,10 @@ +/** + * Find the last index in the array matching the predicate. Polyfill for + * Array.prototype.findLastIndex which is ES2023; our tsconfig targets ES2022. + */ +export function findLastIndexMatching(arr: T[], pred: (x: T) => boolean): number { + for (let i = arr.length - 1; i >= 0; i--) { + if (pred(arr[i])) return i; + } + return -1; +} diff --git a/tests/engine/deployDefence.test.ts b/tests/engine/deployDefence.test.ts index c82f4ad..1923e93 100644 --- a/tests/engine/deployDefence.test.ts +++ b/tests/engine/deployDefence.test.ts @@ -30,10 +30,8 @@ describe('deploy-defence resolution', () => { expect(deployed.by).toBe('player1'); expect(deployed.type).toBe('shield'); } - // After round end, stockpile.shields=0 (was 1, deployed used it), deployed=0 (cleared) + // After round end, stockpile.shields=0 (was 1, deployed used it) expect(r.state.leaders.player1.stockpile.shields).toBe(0); - // Note: deployedShields clear happens in Task 5 (resolution.ts). For now, this - // assertion may fail. That's expected — we accept partial pass and Task 5 closes the loop. }); it('build-then-deploy in one round: stockpile correctly reflects the cycle', () => {