diff --git a/docs/superpowers/plans/2026-07-26-fun-while-dying-slice1-characters-difficulty.md b/docs/superpowers/plans/2026-07-26-fun-while-dying-slice1-characters-difficulty.md new file mode 100644 index 0000000..9c79e10 --- /dev/null +++ b/docs/superpowers/plans/2026-07-26-fun-while-dying-slice1-characters-difficulty.md @@ -0,0 +1,118 @@ +# Slice 1 — Characters & difficulty Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development. Steps use checkbox (`- [ ]`) syntax. + +**Goal:** Netanyahoo becomes a big-but-rare warmonger, Khameneverhere's grudge goes large, and `easy` gets light non-lethal noise instead of chaotic randomness — so games play differently, large nukes become a character trait, and easy stops being harsher than normal. + +**Architecture:** Three engine edits (two build plans + the easy randomization knob), each pure/deterministic, then a balance-verification pass. No win-condition/scoring/UI change. + +**Tech Stack:** TypeScript engine (pure fns, seeded RNG), vitest, `vite-node`. + +**Spec:** `docs/superpowers/specs/2026-07-26-fun-while-dying-slice1-characters-difficulty-design.md` + +## Global Constraints + +- Every commit: `npm run typecheck` clean AND `npm run test:run` green. +- Tests: unconditional assertions only. +- Rebalance via CONSTANTS (build targets, easy pct), never by loosening a test threshold. +- Producers-before-consumers order preserved (`[...build.orders, ...salvo.orders]`). +- No win-condition/`checkOutcome`/`scoreState`/UI change (keeps hard-mode lookahead stable). + +## Guardrails (better-memory) + +- Planner/build-plan changes shift hard-mode lookahead + dispatcher tests OUTSIDE the edited files — run the FULL engine suite, update coincidentally-pinned expectations (mem: changing scoring/planner inputs silently shifts lookahead tests). +- `buildToward` walks the plan in order and stops when AP runs out — front-load the tier you want reachable (large), or it never builds (the exact reason large nukes never appeared). +- Every commit independently typechecks; if `applyRandomization` gains a param, update its call site same commit. + +## File Structure + +``` +src/engine/ai/netanyahoo.ts (modify: NETANYAHOO_BUILD_PLAN → big-but-rare) +src/engine/ai/khameneverhere.ts (modify: KHAMENEVERHERE_BUILD_PLAN → add large tier) +src/engine/ai/index.ts (modify: DIFFICULTY_RANDOM_PCT.easy; applyRandomization diff-aware candidate pool + threaded diff) +tests/engine/ai/netanyahoo.test.ts (update to new build plan) +tests/engine/ai/khameneverhere.test.ts (update to new build plan) +tests/engine/ai/index.test.ts OR a new easy-randomization test (candidate-pool + pct) +tests/engine/ai/lookahead.test.ts, dispatcher.test.ts (update only if coincidentally pinned) +``` + +--- + +### Task 1: Netanyahoo — big-but-rare warmonger + +**Files:** Modify `src/engine/ai/netanyahoo.ts`; Test `tests/engine/ai/netanyahoo.test.ts`. + +- [ ] **Step 1: Update tests first** — netanyahoo.test.ts: assert the planner, given enough AP/rounds, emits `build-warhead` with `yield:'large'` and NO `yield:'small'`; still launches first; still respects the Chump-exception. Keep existing target-ranking assertions. Unconditional asserts. (Read the current test to reuse its state-builders.) +- [ ] **Step 2: Run — expect FAIL** (`npx vitest run tests/engine/ai/netanyahoo.test.ts`). +- [ ] **Step 3: Implement** — replace `NETANYAHOO_BUILD_PLAN`: + +```ts +const NETANYAHOO_BUILD_PLAN: BuildPlanEntry[] = [ + { build: { item: 'missile' }, target: 4 }, + { build: { item: 'warhead', yield: 'large' }, target: 3 }, + { build: { item: 'warhead', yield: 'medium' }, target: 2 }, +]; +``` +Update the file's doc comment to describe the big-but-rare doctrine (large-first, no small). Planner body/launch logic unchanged. +- [ ] **Step 4: Run — expect PASS.** Then `npx vitest run tests/engine/ai` (sibling planners/lookahead may shift — note failures for Task 4). + +--- + +### Task 2: Khameneverhere — grudge goes large + +**Files:** Modify `src/engine/ai/khameneverhere.ts`; Test `tests/engine/ai/khameneverhere.test.ts`. + +- [ ] **Step 1: Update tests first** — assert the planner can emit `build-warhead` `yield:'large'` when AP allows; grudge target ranking unchanged; still launch-first. Unconditional. +- [ ] **Step 2: Run — expect FAIL.** +- [ ] **Step 3: Implement** — replace `KHAMENEVERHERE_BUILD_PLAN`: + +```ts +const KHAMENEVERHERE_BUILD_PLAN: BuildPlanEntry[] = [ + { build: { item: 'missile' }, target: 5 }, + { build: { item: 'warhead', yield: 'large' }, target: 2 }, + { build: { item: 'warhead', yield: 'medium' }, target: 2 }, + { build: { item: 'warhead', yield: 'small' }, target: 2 }, +]; +``` +Update the doc comment (grudge now goes large, falls back to medium/small so never disarmed). +- [ ] **Step 4: Run — expect PASS**, then `npx vitest run tests/engine/ai`. + +--- + +### Task 3: Easy — light non-lethal noise + +**Files:** Modify `src/engine/ai/index.ts`; Test: new/extended easy-randomization test. + +- [ ] **Step 1: Write tests first** — assert: (a) `DIFFICULTY_RANDOM_PCT.easy === 0.12` (normal 0.1, hard 0); (b) the easy candidate pool excludes `build-warhead medium` and `build-warhead large`, while normal's includes them. If the candidate pool is internal, expose it via a small pure helper `randomizationCandidates(diff): Order[]` and test that directly (easy list has no medium/large warhead; normal does). Unconditional asserts. +- [ ] **Step 2: Run — expect FAIL.** +- [ ] **Step 3: Implement:** + - `DIFFICULTY_RANDOM_PCT.easy` `0.3 → 0.12`. + - Extract the candidate list into `function randomizationCandidates(diff: Difficulty): Order[]`: full 8-item list for non-easy; on `easy`, omit `{build-warhead medium}` and `{build-warhead large}` (keep factory, missile, bomber, small warhead, shield, aa). + - Thread `diff` into `applyRandomization` (add a `diff: Difficulty` param) and build `candidates` from `randomizationCandidates(diff)`. Update the call site (`applyRandomization(state, leaderId, orders, DIFFICULTY_RANDOM_PCT[diff], diff)`). +- [ ] **Step 4: Run — expect PASS**, then `npm run typecheck` (catches the call-site/param wiring). + +--- + +### Task 4: Balance verification & suite green + +**Files:** whichever tests the changes shifted (lookahead/dispatcher/balance); no new source unless a rebalance is needed. + +- [ ] **Step 1: Full suite** — `npm run test:run`. Update any tests that pinned old planner behaviour coincidentally (lookahead target picks, dispatcher routing) to the new correct behaviour — do NOT weaken assertions, correct them to what the new doctrine legitimately does. +- [ ] **Step 2: Termination gate** — confirm `tests/engine/ai-duel.test.ts` (`unfinished === 0` across 80 seeds) and `integration.test.ts` termination pass. If any game now stalls (Netanyahoo arming too slowly to end a 1v1), the Chump-exception/only-opponent fallback should prevent it; if not, adjust Netanyahoo's build targets (constants) so he can still finish — re-run. +- [ ] **Step 3: Distribution sanity** — read the duel breakdown the test prints. Expectation: no leader degenerately dominates (soft ≤ ~40/80); more pyrrhic/apocalypse is fine (on-theme). If Netanyahoo or Khameneverhere runs away or flatlines, tune build targets / easy pct (constants) and re-run. Record the final breakdown in the report. +- [ ] **Step 4: Typecheck + build** — `npm run typecheck && npm run build` clean. +- [ ] **Step 5: Commit** (single commit for the coupled slice; branch `feat/character-personalities`): + +```bash +git add src/engine/ai tests/engine +git commit -m "feat(engine): big-nuke Netanyahoo/Khameneverhere doctrines + easy non-lethal noise" +``` + +--- + +## Self-Review Notes + +- Spec coverage: §2 → Task 1; §3 → Task 2; §4 → Task 3; §5 balance/tests → Task 4. +- The one cross-file ripple is planner-behaviour → lookahead/dispatcher tests (Task 4 Step 1) and the `applyRandomization` signature (Task 3 Step 3) — both called out. +- No win-condition/scoring/UI change, so hard-mode `scoreState` and the RoundSummary/Winners UI are untouched; large-nuke visibility is now a deliberate build-plan trait, not randomness. +- If TDD reveals `buildToward` still can't reach large at realistic AP for Netanyahoo (economy too thin for a target-3 large tier), reduce the large target or add a factory entry first — flag as DONE_WITH_CONCERNS with the duel breakdown rather than silently under-arming him. diff --git a/docs/superpowers/specs/2026-07-26-fun-while-dying-slice1-characters-difficulty-design.md b/docs/superpowers/specs/2026-07-26-fun-while-dying-slice1-characters-difficulty-design.md new file mode 100644 index 0000000..31fba83 --- /dev/null +++ b/docs/superpowers/specs/2026-07-26-fun-while-dying-slice1-characters-difficulty-design.md @@ -0,0 +1,76 @@ +# nuke — "fun while dying" slice 1: characters & difficulty + +**Date:** 2026-07-26 +**Status:** approved in brainstorming; pending spec review +**Design frame:** Make games play differently and the cast read louder — the variety the playability assessment (`docs/playability/playability-usability-assessment.md`, criteria C3/C6/C7) says the game lacks. Two named leaders get distinct big-nuke/all-in doctrines, and `easy` is reworked so its difficulty is *character*, not chaotic randomness (fixing the data finding that easy is harsher than normal). Slice 1 of 3; slice 2 (newspaper) shipped in PR #16; slice 3 (score-how-you-died) follows. + +## 0. Decisions (locked in brainstorming) + +- **Netanyahoo → big-but-rare warmonger.** A deliberate big-bomb doctrine: builds toward *large* warheads over volume; fires fewer, huge strikes rather than spamming small. +- **Khameneverhere → grudge big-nuke builder.** His vengeance goes large — build plan reaches large warheads, fired at his top grudge target. +- **Easy → light non-lethal noise.** Keep a little randomness for per-game variety, but never let it manufacture big/concentrated strikes; lower the rate. Fixes the easy-harsher-than-normal paradox. +- **Scope: these two leaders + easy only.** Other planners (Chump/Carnage/Mileigh/Burn'em) unchanged this slice. + +## 1. Background (why large nukes never appear today) + +From the AI-behaviour study: large warheads only ever appear via the easy/normal *randomness knob*, never as strategy (hard fired 0 large across 100 games). Root cause (verified in code): `launchSalvo` fires largest-yield-first, so the constraint is *building*. Only Netanyahoo has a large-warhead build entry and it sits **last** in his plan (`{large, target:2}` after missile/small/medium), so AP is exhausted before `buildToward` reaches it. Khameneverhere has no large entry at all. This slice makes large warheads a **deliberate character trait** for these two by making the large tier reachable. + +## 2. Netanyahoo — big-but-rare (`src/engine/ai/netanyahoo.ts`) + +Keep the warmonger frame (launch-first uncapped salvo → `launchSalvo` fires largest-first; Chump-exception until provoked, with the only-opponent deadlock fallback; propaganda at Chump). **Change only the build plan** so AP flows to large warheads first: + +```ts +const NETANYAHOO_BUILD_PLAN: BuildPlanEntry[] = [ + { build: { item: 'missile' }, target: 4 }, // delivery, first + { build: { item: 'warhead', yield: 'large' }, target: 3 }, // primary doctrine + { build: { item: 'warhead', yield: 'medium' }, target: 2 }, // fallback yield +]; +``` + +- Drop the small-warhead tier entirely — Netanyahoo does not do small nukes. +- Large is now reachable (front of the plan after delivery), so over a few rounds he accumulates large warheads and `launchSalvo` fires them. Because large costs 3 AP to build, he arms slowly → **rare but devastating** strikes. This is the doctrine, not a bug. +- `netanyahoo-launch-bonus` (existing `bonusRule`) already rewards his launching — unchanged. + +## 3. Khameneverhere — grudge goes large (`src/engine/ai/khameneverhere.ts`) + +Keep the grudge frame (top-grudge-first ranking, launch-first uncapped, no diplomacy). Add a large tier, front-loaded enough to be reachable: + +```ts +const KHAMENEVERHERE_BUILD_PLAN: BuildPlanEntry[] = [ + { build: { item: 'missile' }, target: 5 }, + { build: { item: 'warhead', yield: 'large' }, target: 2 }, // vengeance goes big + { build: { item: 'warhead', yield: 'medium' }, target: 2 }, + { build: { item: 'warhead', yield: 'small' }, target: 2 }, +]; +``` + +- Large first among warheads → when he can afford it, his grudge strikes land large; when he can't, he falls back to medium/small so he is never disarmed. He stays launch-happy (unlike Netanyahoo's rarer cadence) but now hits harder against whoever wronged him. + +## 4. Easy difficulty — light non-lethal noise (`src/engine/ai/index.ts`) + +Two changes to `applyRandomization` / its config: +1. **Lower the rate:** `DIFFICULTY_RANDOM_PCT.easy` `0.3 → 0.12`. (Normal stays `0.1`, hard `0`.) +2. **Non-lethal candidate pool on easy:** the random-swap `candidates` list currently includes `build-warhead medium` and `build-warhead large` — the exact source of easy's chaotic big strikes (12.4% of easy launches were large, ~3× normal). Make the candidate pool **difficulty-aware**: on easy, exclude `build-warhead medium` and `build-warhead large` (keep factory, missile, bomber, small warhead, shield, aa). Normal keeps the full pool (its large-warhead rate of 3.8% is acceptable and we don't want to shift normal balance). + +Effect: easy still varies game-to-game (12% noise) so no two easy games are identical, but the noise can no longer manufacture big/concentrated strikes → easy stops being deadlier than normal. This is the "difficulty = character, not chaos" fix. + +Determinism note: randomization seeds from `state.rngState` (existing "shadow" read), so easy stays reproducible per seed while differing across seeds — unchanged mechanism, just gentler content. + +## 5. Balance & tests + +- **Hard gate (must stay green):** `tests/engine/ai-duel.test.ts` asserts `unfinished === 0` across 80 seeds (every all-AI game terminates); `tests/engine/integration.test.ts` termination. Big-nuke doctrines accelerate deaths, which helps termination, but Netanyahoo's slower arming must not stall — the existing Chump-exception/only-opponent fallback prevents that; verify with the duel run. +- **Distribution:** the duel test prints a per-leader win breakdown but has **no hard threshold**. Target: no single leader degenerately dominates (soft ≤ ~40/80) and more pyrrhic/apocalypse outcomes are *fine* (on-theme). If a leader runs away or games stall, rebalance the **constants** (build targets, easy pct) — never loosen a test threshold. +- **Per-planner tests:** `tests/engine/ai/netanyahoo.test.ts` and `.../khameneverhere.test.ts` assert build/launch behaviour — update to the new build plans (e.g. Netanyahoo emits `build-warhead large` when AP allows and no longer emits small; Khameneverhere can emit large). Assertions unconditional. +- **Lookahead / dispatcher ripple:** hard-mode lookahead (`lookahead.ts`) uses `dispatch` for opponents and only rewrites launch *targets*; changing build plans can shift `lookahead.test.ts` / `dispatcher.test.ts` expectations. Run the full engine suite and update any coincidentally-pinned expectations (mem: planner/scoring changes shift lookahead tests outside the planned files). +- **Easy randomization test:** add a unit test that the easy candidate pool excludes medium/large warheads (and normal does not), and that `DIFFICULTY_RANDOM_PCT.easy` is the lowered value. + +## 6. Out of scope +- Other leaders' personalities (Chump/Carnage/Mileigh/Burn'em) — untouched. +- Newspaper content (slice 2, shipped). Scoring/outcome (slice 3). +- Win-condition / `checkOutcome` / `scoreState` changes — NONE (keeps hard-mode lookahead stable). +- UI — none; this is engine-only. + +## 7. Constraints +- Every commit typechecks (`tsc --noEmit`) and passes `npm run test:run`; no guarded assertions. +- Rebalance via constants, not test thresholds. +- Keep producers-before-consumers order in emitted batches (existing `[...build.orders, ...salvo.orders]`). diff --git a/src/engine/ai/index.ts b/src/engine/ai/index.ts index 27d2062..c886cb0 100644 --- a/src/engine/ai/index.ts +++ b/src/engine/ai/index.ts @@ -5,12 +5,38 @@ import { isHuman } from '../state'; import { dispatch } from './dispatch'; import { bestTargetByLookahead } from './lookahead'; -const DIFFICULTY_RANDOM_PCT: Record = { - easy: 0.3, +export const DIFFICULTY_RANDOM_PCT: Record = { + easy: 0.12, normal: 0.1, hard: 0, }; +/** + * Candidate pool for random order-swaps in `applyRandomization`. Non-easy + * difficulties get the full 8-item pool. Easy excludes `build-warhead` + * medium/large — those are the source of easy's chaotic big/concentrated + * strikes (12.4% of easy launches were large, ~3x normal) — so easy's noise + * stays non-lethal: it varies the game without manufacturing big strikes. + */ +export function randomizationCandidates(diff: Difficulty): Order[] { + const full: Order[] = [ + { kind: 'build-factory' }, + { kind: 'build-missile' }, + { kind: 'build-bomber' }, + { kind: 'build-warhead', yield: 'small' }, + { kind: 'build-warhead', yield: 'medium' }, + { kind: 'build-warhead', yield: 'large' }, + { kind: 'build-defence', type: 'shield' }, + { kind: 'build-defence', type: 'aa' }, + ]; + if (diff === 'easy') { + return full.filter( + (o) => !(o.kind === 'build-warhead' && (o.yield === 'medium' || o.yield === 'large')), + ); + } + return full; +} + export function planAi(state: GameState, leaderId: LeaderId, difficulty?: Difficulty): Order[] { const me = state.leaders[leaderId]; // Alive-check first by design: dead leaders (AI or human) return [] — same shape, @@ -62,7 +88,7 @@ export function planAi(state: GameState, leaderId: LeaderId, difficulty?: Diffic // Easy / Normal randomization: replace each order with probability difficulty-pct. if (DIFFICULTY_RANDOM_PCT[diff] > 0) { - orders = applyRandomization(state, leaderId, orders, DIFFICULTY_RANDOM_PCT[diff]); + orders = applyRandomization(state, leaderId, orders, DIFFICULTY_RANDOM_PCT[diff], diff); } return orders; @@ -73,20 +99,12 @@ function applyRandomization( leaderId: LeaderId, orders: Order[], pct: number, + diff: Difficulty, ): Order[] { let rngState = state.rngState; const me = state.leaders[leaderId]; let remainingBudget = me.ap; - const candidates: Order[] = [ - { kind: 'build-factory' }, - { kind: 'build-missile' }, - { kind: 'build-bomber' }, - { kind: 'build-warhead', yield: 'small' }, - { kind: 'build-warhead', yield: 'medium' }, - { kind: 'build-warhead', yield: 'large' }, - { kind: 'build-defence', type: 'shield' }, - { kind: 'build-defence', type: 'aa' }, - ]; + const candidates = randomizationCandidates(diff); const out: Order[] = []; for (const o of orders) { diff --git a/src/engine/ai/khameneverhere.ts b/src/engine/ai/khameneverhere.ts index 1aee1f8..dee9cd5 100644 --- a/src/engine/ai/khameneverhere.ts +++ b/src/engine/ai/khameneverhere.ts @@ -3,16 +3,19 @@ import { topGrudgeTarget } from './scoring'; import { buildToward, launchSalvo, type BuildPlanEntry } from './aggression'; /** - * Khameneverhere — Grudge personality (P4c.2 rework). + * Khameneverhere — Grudge personality, vengeance-goes-large doctrine (slice 1). * * Very aggressive, launch-focused. Ranks targets by grudge (top grudge first, * then remaining living leaders). Launch-first uncapped salvo, then build the - * remainder toward a raised stockpile with medium warheads. No diplomacy. + * remainder toward a raised stockpile. Large is front-loaded among warheads + * (right after delivery) so his grudge strikes land large when AP allows; + * medium and small are fallback yields so he is never disarmed. No diplomacy. */ const KHAMENEVERHERE_BUILD_PLAN: BuildPlanEntry[] = [ - { build: { item: 'missile' }, target: 6 }, - { build: { item: 'warhead', yield: 'small' }, target: 4 }, - { build: { item: 'warhead', yield: 'medium' }, target: 3 }, + { build: { item: 'missile' }, target: 5 }, + { build: { item: 'warhead', yield: 'large' }, target: 2 }, + { build: { item: 'warhead', yield: 'medium' }, target: 2 }, + { build: { item: 'warhead', yield: 'small' }, target: 2 }, ]; export function planKhameneverhere(state: GameState, leaderId: LeaderId): Order[] { diff --git a/src/engine/ai/netanyahoo.ts b/src/engine/ai/netanyahoo.ts index 8c3d3e1..b628a69 100644 --- a/src/engine/ai/netanyahoo.ts +++ b/src/engine/ai/netanyahoo.ts @@ -4,19 +4,22 @@ import { threatScore, wasAttackedBy } from './scoring'; import { buildToward, launchSalvo, type BuildPlanEntry } from './aggression'; /** - * Netanyahoo — Warmonger personality (P4c.2 rework). + * Netanyahoo — Warmonger personality, big-but-rare doctrine (slice 1). * * Hardest-hitting personality. Launch-first (uncapped salvo), then build the - * remainder toward a yield ramp. Chump-exception preserved: no launch at Chump - * until Chump has attacked first. Propaganda exclusively at Chump. + * remainder toward large warheads over volume — Netanyahoo does not do small + * nukes. Large is front-loaded (right after delivery) so `buildToward` reaches + * it before AP runs out; because large costs 3 AP, he arms slowly, producing + * rare but devastating strikes. Medium is the fallback yield when AP can't + * stretch to large. Chump-exception preserved: no launch at Chump until Chump + * has attacked first. Propaganda exclusively at Chump. */ const PROPAGANDA_COST = 1; const NETANYAHOO_BUILD_PLAN: BuildPlanEntry[] = [ - { build: { item: 'missile' }, target: 6 }, - { build: { item: 'warhead', yield: 'small' }, target: 4 }, - { build: { item: 'warhead', yield: 'medium' }, target: 3 }, - { build: { item: 'warhead', yield: 'large' }, target: 2 }, + { build: { item: 'missile' }, target: 4 }, + { build: { item: 'warhead', yield: 'large' }, target: 3 }, + { build: { item: 'warhead', yield: 'medium' }, target: 2 }, ]; export function planNetanyahoo(state: GameState, leaderId: LeaderId): Order[] { diff --git a/tests/engine/ai/index.test.ts b/tests/engine/ai/index.test.ts new file mode 100644 index 0000000..61e0d0b --- /dev/null +++ b/tests/engine/ai/index.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from 'vitest'; +import { DIFFICULTY_RANDOM_PCT, randomizationCandidates } from '../../../src/engine/ai/index'; + +describe('DIFFICULTY_RANDOM_PCT', () => { + it('easy is lowered to light non-lethal noise; normal/hard unchanged', () => { + expect(DIFFICULTY_RANDOM_PCT.easy).toBe(0.12); + expect(DIFFICULTY_RANDOM_PCT.normal).toBe(0.1); + expect(DIFFICULTY_RANDOM_PCT.hard).toBe(0); + }); +}); + +describe('randomizationCandidates', () => { + it('easy excludes build-warhead medium/large, keeps the rest of the pool', () => { + const candidates = randomizationCandidates('easy'); + expect(candidates.some((o) => o.kind === 'build-warhead' && o.yield === 'medium')).toBe(false); + expect(candidates.some((o) => o.kind === 'build-warhead' && o.yield === 'large')).toBe(false); + expect(candidates.some((o) => o.kind === 'build-warhead' && o.yield === 'small')).toBe(true); + expect(candidates.some((o) => o.kind === 'build-factory')).toBe(true); + expect(candidates.some((o) => o.kind === 'build-missile')).toBe(true); + expect(candidates.some((o) => o.kind === 'build-bomber')).toBe(true); + expect(candidates.some((o) => o.kind === 'build-defence' && o.type === 'shield')).toBe(true); + expect(candidates.some((o) => o.kind === 'build-defence' && o.type === 'aa')).toBe(true); + expect(candidates.length).toBe(6); + }); + + it('normal keeps the full 8-item pool, including medium/large warheads', () => { + const candidates = randomizationCandidates('normal'); + expect(candidates.some((o) => o.kind === 'build-warhead' && o.yield === 'medium')).toBe(true); + expect(candidates.some((o) => o.kind === 'build-warhead' && o.yield === 'large')).toBe(true); + expect(candidates.length).toBe(8); + }); + + it('hard keeps the full 8-item pool too (unused since hard pct is 0, but not special-cased)', () => { + const candidates = randomizationCandidates('hard'); + expect(candidates.length).toBe(8); + }); +}); diff --git a/tests/engine/ai/khameneverhere.test.ts b/tests/engine/ai/khameneverhere.test.ts index 47fe1c0..1dfe035 100644 --- a/tests/engine/ai/khameneverhere.test.ts +++ b/tests/engine/ai/khameneverhere.test.ts @@ -46,12 +46,13 @@ describe('Khameneverhere aggression (P4c.2)', () => { expect(launches.every((o) => o.kind === 'launch' && o.target === 'carnage')).toBe(true); }); - it('builds medium warheads into the ramp', () => { + it('builds large warheads (grudge goes large) with medium as fallback', () => { const s = initialState({ cast: ['khameneverhere', 'carnage'], difficulty: 'normal', seed: 'ka2' }); s.leaders.khameneverhere.stockpile.missiles = 6; s.leaders.khameneverhere.stockpile.warheadsSmall = 4; - s.leaders.khameneverhere.ap = 12; + s.leaders.khameneverhere.ap = 24; const orders = planKhameneverhere(s, 'khameneverhere'); + expect(orders.some((o) => o.kind === 'build-warhead' && o.yield === 'large')).toBe(true); expect(orders.some((o) => o.kind === 'build-warhead' && o.yield === 'medium')).toBe(true); }); }); diff --git a/tests/engine/ai/netanyahoo.test.ts b/tests/engine/ai/netanyahoo.test.ts index fd8edd4..f2c393b 100644 --- a/tests/engine/ai/netanyahoo.test.ts +++ b/tests/engine/ai/netanyahoo.test.ts @@ -95,13 +95,16 @@ describe('Netanyahoo aggression (P4c.2)', () => { expect(orders.filter((o) => o.kind === 'launch').length).toBeGreaterThanOrEqual(2); }); - it('builds toward a yield ramp (medium/large warheads), not only small', () => { + it('builds toward the big-but-rare doctrine: large warheads, medium fallback, never small', () => { + // warheadsSmall starts at 0 (not pre-stocked to any old-plan target) so this + // scenario genuinely discriminates the new plan from the old small-first one. const s = initialState({ cast: ['netanyahoo', 'carnage'], difficulty: 'normal', seed: 'na3' }); s.leaders.netanyahoo.stockpile.missiles = 6; - s.leaders.netanyahoo.stockpile.warheadsSmall = 4; - s.leaders.netanyahoo.ap = 12; + s.leaders.netanyahoo.ap = 24; const orders = planNetanyahoo(s, 'netanyahoo'); + expect(orders.some((o) => o.kind === 'build-warhead' && o.yield === 'large')).toBe(true); expect(orders.some((o) => o.kind === 'build-warhead' && o.yield === 'medium')).toBe(true); + expect(orders.some((o) => o.kind === 'build-warhead' && o.yield === 'small')).toBe(false); }); it('emits builds before launches (validateOrderSequence ordering)', () => {