From 599abb8f757e6e2d312ce23587bc39ee9156a058 Mon Sep 17 00:00:00 2001 From: gethi Date: Sun, 17 May 2026 12:16:09 +0100 Subject: [PATCH 01/10] =?UTF-8?q?engine:=20remove=20dominance=20win=20cond?= =?UTF-8?q?ition=20=E2=80=94=20games=20end=20by=20elimination=20only?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/engine/balance.ts | 2 -- src/engine/index.ts | 1 - src/engine/state.ts | 3 +-- src/engine/types.ts | 5 ++--- src/engine/winConditions.ts | 12 ----------- src/ui/screens/Winners.tsx | 13 +----------- tests/engine/ai-duel.test.ts | 1 - tests/engine/ai/lookahead.test.ts | 14 +++++++------ tests/engine/balance.test.ts | 2 -- tests/engine/determinism.test.ts | 4 ---- tests/engine/integration.test.ts | 24 ---------------------- tests/engine/state.test.ts | 5 ++--- tests/engine/winConditions.test.ts | 33 +----------------------------- 13 files changed, 15 insertions(+), 104 deletions(-) diff --git a/src/engine/balance.ts b/src/engine/balance.ts index b9f747e..c5663e4 100644 --- a/src/engine/balance.ts +++ b/src/engine/balance.ts @@ -122,8 +122,6 @@ export const AP_BANK_CAP = 4; export const PROPAGANDA_TRANSFER_M = 1; /** Favourability points decayed per round per relationship. Tunable. */ export const WOO_FAVOURABILITY_DECAY = 1; -export const DOMINANCE_THRESHOLD_DEFAULT = 2; - /** * Per-leader scoring weights. First-pass values; balance-pass deferred to P4. * Each personality module reads from this table to compose its own scoring function. diff --git a/src/engine/index.ts b/src/engine/index.ts index 250fa64..7af9304 100644 --- a/src/engine/index.ts +++ b/src/engine/index.ts @@ -11,6 +11,5 @@ export { AP_BANK_CAP, PROPAGANDA_TRANSFER_M, WOO_FAVOURABILITY_DECAY, - DOMINANCE_THRESHOLD_DEFAULT, } from './balance'; export { planAi } from './ai'; diff --git a/src/engine/state.ts b/src/engine/state.ts index c5a8f8e..9db5dbe 100644 --- a/src/engine/state.ts +++ b/src/engine/state.ts @@ -1,5 +1,5 @@ import type { Difficulty, GameConfig, GameState, Leader, LeaderId } from './types'; -import { DOMINANCE_THRESHOLD_DEFAULT, LEADER_PROFILES } from './balance'; +import { LEADER_PROFILES } from './balance'; import { seedFromString } from './rng'; import { shuffleMastheads } from './masthead'; @@ -63,7 +63,6 @@ export function initialState(opts: NewGameOpts): GameState { log: [], outcome: null, config: { - dominanceThreshold: DOMINANCE_THRESHOLD_DEFAULT, fastPlay: false, ...opts.config, }, diff --git a/src/engine/types.ts b/src/engine/types.ts index c4b4a02..641d522 100644 --- a/src/engine/types.ts +++ b/src/engine/types.ts @@ -21,7 +21,7 @@ export type DefenceType = 'shield' | 'aa'; export type TargetType = 'people' | 'infra'; -export type WinType = 'survivor' | 'pyrrhic' | 'apocalypse' | 'dominance'; +export type WinType = 'survivor' | 'pyrrhic' | 'apocalypse'; export type BonusRule = 'chump-defence-waste' | 'mileigh-aggression-bonus' | 'netanyahoo-launch-bonus'; @@ -99,13 +99,12 @@ export interface GameConfig { startPopOverride?: Partial>; /** Per-game name/country overrides for player slots. Keys should be 'player1'..'player5'; entries for AI leaders are merged but the Setup UI does not surface them. Setup screen populates this from user input. */ playerProfiles?: Partial>; - dominanceThreshold: number; fastPlay: boolean; } export type WinOutcome = | { type: 'apocalypse' } - | { type: 'survivor' | 'pyrrhic' | 'dominance'; winner: LeaderId }; + | { type: 'survivor' | 'pyrrhic'; winner: LeaderId }; export type SoftWarning = | { kind: 'warhead-no-delivery'; orderIndex: number } diff --git a/src/engine/winConditions.ts b/src/engine/winConditions.ts index f2da1b9..d0eba9b 100644 --- a/src/engine/winConditions.ts +++ b/src/engine/winConditions.ts @@ -26,17 +26,5 @@ export function checkOutcome( return { type: 'apocalypse' }; } - // 3) Dominance — leading population >= threshold * second-highest. - const sortedByPop = [...alive].sort( - (a, b) => state.leaders[b].population - state.leaders[a].population, - ); - if (sortedByPop.length >= 2) { - const lead = state.leaders[sortedByPop[0]].population; - const next = state.leaders[sortedByPop[1]].population; - if (next > 0 && lead >= state.config.dominanceThreshold * next) { - return { type: 'dominance', winner: sortedByPop[0] }; - } - } - return null; } diff --git a/src/ui/screens/Winners.tsx b/src/ui/screens/Winners.tsx index d378eb1..dd0b6a9 100644 --- a/src/ui/screens/Winners.tsx +++ b/src/ui/screens/Winners.tsx @@ -7,7 +7,6 @@ function pickHeadline(outcome: WinOutcome, leaders: GameState['leaders']): strin case 'apocalypse': return 'WINNER: NOBODY'; case 'survivor': case 'pyrrhic': - case 'dominance': return `${leaders[outcome.winner].name.toUpperCase()} WINS`; } } @@ -16,7 +15,6 @@ function pickSubLine( outcome: WinOutcome, leaders: GameState['leaders'], initialPopulations: Partial>, - cast: LeaderId[], ): string { switch (outcome.type) { case 'apocalypse': @@ -30,15 +28,6 @@ function pickSubLine( const initial = initialPopulations[outcome.winner] ?? winner.population; return `${winner.name} had ${initial}M when the bombs flew. They have 0M now. So does everyone else. Briefly, they had more.`; } - case 'dominance': { - const winner = leaders[outcome.winner]; - const others = cast - .filter((id) => id !== outcome.winner) - .map((id) => leaders[id].population) - .sort((a, b) => b - a); - const secondPop = others[0] ?? 0; - return `${winner.name} rules over ${winner.population}M. The next-largest has ${secondPop}M.`; - } } } @@ -46,7 +35,7 @@ export default function Winners({ state, dispatch }: ScreenProps) { const game = state.game!; const outcome = game.outcome!; const headline = pickHeadline(outcome, game.leaders); - const subLine = pickSubLine(outcome, game.leaders, state.initialPopulations, game.cast); + const subLine = pickSubLine(outcome, game.leaders, state.initialPopulations); const tollRows = game.cast.map((id) => { const leader = game.leaders[id]; diff --git a/tests/engine/ai-duel.test.ts b/tests/engine/ai-duel.test.ts index d90975a..a24c448 100644 --- a/tests/engine/ai-duel.test.ts +++ b/tests/engine/ai-duel.test.ts @@ -11,7 +11,6 @@ function runOneGame(seed: string, maxRounds = 100): { winner: LeaderId | null; t cast: FULL_CAST, difficulty: 'normal', seed, - config: { dominanceThreshold: 1.5 }, }); let rounds = 0; while (!s.outcome && rounds < maxRounds) { diff --git a/tests/engine/ai/lookahead.test.ts b/tests/engine/ai/lookahead.test.ts index cec076e..a427786 100644 --- a/tests/engine/ai/lookahead.test.ts +++ b/tests/engine/ai/lookahead.test.ts @@ -46,16 +46,18 @@ describe('scoreState', () => { describe('bestTargetByLookahead', () => { it('picks the target whose projected post-round state scores highest', () => { - // 3-leader setup: chump can launch at carnage OR starmless. Both are weak (pop=5). - // Carnage has shields=0 (launch lands); starmless has shields=5 (launch always intercepts). - // Hard AI should prefer carnage (where the launch actually does damage, raising score). + // 3-leader setup: chump can launch at carnage (pop=5) OR starmless (pop=1). + // Carnage has ap=0 so it cannot build or retaliate this round. + // Scoring (elimination-only, no dominance): + // kill carnage → starmless survives at 1 → score = 33-1 = 32 + // kill starmless → carnage survives at 5 → score = 33-5 = 28 + // Lookahead should prefer carnage (higher post-round margin). const s = initialState({ cast: ['chump', 'carnage', 'starmless'], difficulty: 'hard', seed: 'lh5' }); s.leaders.chump.stockpile.missiles = 1; s.leaders.chump.stockpile.warheadsLarge = 1; s.leaders.carnage.population = 5; - s.leaders.carnage.stockpile.shields = 0; - s.leaders.starmless.population = 5; - s.leaders.starmless.stockpile.shields = 5; + s.leaders.carnage.ap = 0; // no AP — carnage builds nothing and cannot retaliate + s.leaders.starmless.population = 1; const baseline: Order[] = []; // no other orders this round const candidates: ['carnage', 'starmless'] = ['carnage', 'starmless']; const best = bestTargetByLookahead(s, 'chump', baseline, candidates, { diff --git a/tests/engine/balance.test.ts b/tests/engine/balance.test.ts index 2f87124..cfa8baa 100644 --- a/tests/engine/balance.test.ts +++ b/tests/engine/balance.test.ts @@ -7,7 +7,6 @@ import { AP_BANK_CAP, PROPAGANDA_TRANSFER_M, WOO_FAVOURABILITY_DECAY, - DOMINANCE_THRESHOLD_DEFAULT, } from '../../src/engine/balance'; describe('LEADER_PROFILES', () => { @@ -80,7 +79,6 @@ describe('economy constants', () => { expect(AP_BANK_CAP).toBe(4); expect(PROPAGANDA_TRANSFER_M).toBeGreaterThan(0); expect(WOO_FAVOURABILITY_DECAY).toBeGreaterThan(0); - expect(DOMINANCE_THRESHOLD_DEFAULT).toBe(2); }); }); diff --git a/tests/engine/determinism.test.ts b/tests/engine/determinism.test.ts index 9dc8c2d..6dc998f 100644 --- a/tests/engine/determinism.test.ts +++ b/tests/engine/determinism.test.ts @@ -5,14 +5,10 @@ import { scriptedOrders } from '../helpers/scripted-orders'; import type { GameState, LeaderId } from '../../src/engine/types'; function runGame(seed: string, cast: LeaderId[], maxRounds = 80): GameState { - // dominanceThreshold=1.5 — see Task 17 second test for context. The scripted-orders - // cycle creates an unbounded shield-stockpile + slow propaganda dynamic that - // makes 2× dominance require ~150 rounds; 1.5× terminates within ~80. let s = initialState({ cast, difficulty: 'normal', seed, - config: { dominanceThreshold: 1.5 }, }); while (!s.outcome && s.round <= maxRounds) { for (const id of cast) { diff --git a/tests/engine/integration.test.ts b/tests/engine/integration.test.ts index cacdf54..45ed7a6 100644 --- a/tests/engine/integration.test.ts +++ b/tests/engine/integration.test.ts @@ -47,30 +47,6 @@ describe('integration — three-leader scripted game', () => { } }); - it('reaches an outcome within 100 rounds for sample seeds', () => { - for (const seed of ['s1', 's2', 's3']) { - let s = initialState({ - cast: ['chump', 'carnage', 'starmless'], - difficulty: 'normal', - seed, - // dominanceThreshold=1.5 ensures termination within ~80 rounds with the - // scripted-orders cycle; default threshold=2 requires ~150 rounds because - // shields accumulate and propaganda gain is slow. - config: { dominanceThreshold: 1.5 }, - }); - let rounds = 0; - while (!s.outcome && rounds < 100) { - for (const id of s.cast) { - const orders = scriptedOrders(s, id); - s = reduce(s, { type: 'SUBMIT_ORDERS', leaderId: id, orders }); - } - s = reduce(s, { type: 'RESOLVE_ROUND' }); - rounds++; - } - expect(s.outcome).not.toBeNull(); - } - }); - it('runs a mixed-cast round end-to-end (player1 + 2 AI), and persists orderHistory', () => { let s = initialState({ cast: ['player1', 'chump', 'carnage'], diff --git a/tests/engine/state.test.ts b/tests/engine/state.test.ts index 82e84e6..6c9d9da 100644 --- a/tests/engine/state.test.ts +++ b/tests/engine/state.test.ts @@ -73,9 +73,8 @@ describe('initialState', () => { expect(a.rngState).not.toBe(c.rngState); }); - it('defaults config dominanceThreshold to 2 and fastPlay to false', () => { - const s = initialState({ cast: ['chump', 'carnage'], difficulty: 'normal', seed: 'x' }); - expect(s.config.dominanceThreshold).toBe(2); + it('defaults config fastPlay to false', () => { + const s = initialState({ cast: ['chump', 'carnage'], difficulty: 'normal', seed: 'cfg' }); expect(s.config.fastPlay).toBe(false); }); diff --git a/tests/engine/winConditions.test.ts b/tests/engine/winConditions.test.ts index febad39..3041011 100644 --- a/tests/engine/winConditions.test.ts +++ b/tests/engine/winConditions.test.ts @@ -5,7 +5,7 @@ import { initialState } from '../../src/engine/state'; describe('checkOutcome', () => { const base = initialState({ cast: ['chump', 'carnage', 'starmless'], difficulty: 'normal', seed: 'x' }); - it('returns null while multiple leaders are alive and no dominance', () => { + it('returns null while multiple leaders are alive', () => { expect(checkOutcome(base, { chump: 33, carnage: 25, starmless: 25 })).toBeNull(); }); @@ -42,35 +42,4 @@ describe('checkOutcome', () => { expect(checkOutcome(s, { chump: 0, carnage: 0, starmless: 0 })).toEqual({ type: 'apocalypse' }); }); - it('returns dominance when one leader has 2× the next-highest population', () => { - const s = structuredClone(base); - s.leaders.chump.population = 30; - s.leaders.carnage.population = 14; - s.leaders.starmless.population = 10; - expect(checkOutcome(s, { chump: 33, carnage: 25, starmless: 25 })).toEqual({ - type: 'dominance', - winner: 'chump', - }); - }); - - it('does not return dominance when ratio is below threshold', () => { - const s = structuredClone(base); - s.leaders.chump.population = 30; - s.leaders.carnage.population = 16; // 30 / 16 = 1.87 < 2 - s.leaders.starmless.population = 10; - expect(checkOutcome(s, { chump: 33, carnage: 25, starmless: 25 })).toBeNull(); - }); - - it('survivor takes priority over dominance', () => { - const s = structuredClone(base); - s.leaders.carnage.population = 0; - s.leaders.carnage.alive = false; - s.leaders.starmless.population = 0; - s.leaders.starmless.alive = false; - s.leaders.chump.population = 100; - expect(checkOutcome(s, { chump: 33, carnage: 25, starmless: 25 })).toEqual({ - type: 'survivor', - winner: 'chump', - }); - }); }); From be9679a347683a56125181caaa6f01e15f27331f Mon Sep 17 00:00:00 2001 From: gethi Date: Sun, 17 May 2026 12:26:08 +0100 Subject: [PATCH 02/10] =?UTF-8?q?engine:=20add=20buildToward=20AI=20helper?= =?UTF-8?q?=20=E2=80=94=20capped,=20priority-ordered=20builds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/engine/ai/aggression.ts | 80 +++++++++++++++++++++++++++++ tests/engine/ai/aggression.test.ts | 82 ++++++++++++++++++++++++++++++ 2 files changed, 162 insertions(+) create mode 100644 src/engine/ai/aggression.ts create mode 100644 tests/engine/ai/aggression.test.ts diff --git a/src/engine/ai/aggression.ts b/src/engine/ai/aggression.ts new file mode 100644 index 0000000..c2b2b30 --- /dev/null +++ b/src/engine/ai/aggression.ts @@ -0,0 +1,80 @@ +import type { GameState, LeaderId, Order, Yield } from '../types'; +import { apCostOf, validateOrder } from '../orders'; +import { warheadFieldFor } from '../launches'; + +// --- buildToward --------------------------------------------------------- + +export type BuildItem = + | { item: 'factory' } + | { item: 'missile' } + | { item: 'bomber' } + | { item: 'warhead'; yield: Yield } + | { item: 'defence'; type: 'shield' | 'aa' }; + +export interface BuildPlanEntry { + build: BuildItem; + /** Build up to this many TOTAL (current stockpile + queued by this call). */ + target: number; +} + +export interface BuildResult { + orders: Order[]; + apSpent: number; +} + +function buildOrderFor(b: BuildItem): Order { + switch (b.item) { + case 'factory': return { kind: 'build-factory' }; + case 'missile': return { kind: 'build-missile' }; + case 'bomber': return { kind: 'build-bomber' }; + case 'warhead': return { kind: 'build-warhead', yield: b.yield }; + case 'defence': return { kind: 'build-defence', type: b.type }; + } +} + +function currentCount(state: GameState, leaderId: LeaderId, b: BuildItem): number { + const me = state.leaders[leaderId]; + switch (b.item) { + case 'factory': return me.factories; + case 'missile': return me.stockpile.missiles; + case 'bomber': return me.stockpile.bombers; + case 'warhead': return me.stockpile[warheadFieldFor(b.yield)]; + case 'defence': return b.type === 'shield' ? me.stockpile.shields : me.stockpile.aa; + } +} + +/** + * Walk an ordered, capped build plan. For each entry, emit build orders until + * the leader's count of that item (current stockpile + orders queued by this + * call) reaches `target`, the budget cannot afford the item, or validation + * fails. The cap makes an unbounded build loop inexpressible. + */ +export function buildToward( + state: GameState, + leaderId: LeaderId, + plan: BuildPlanEntry[], + budget: number, +): BuildResult { + const orders: Order[] = []; + let remaining = budget; + for (const entry of plan) { + const order = buildOrderFor(entry.build); + const cost = apCostOf(order); + const baseCount = currentCount(state, leaderId, entry.build); + let queued = 0; + while ( + baseCount + queued < entry.target && + remaining >= cost && + // validateOrder currently always returns ok for build orders while the + // leader is alive; it is kept here as a forward-compatible guard so + // buildToward automatically respects any future build-validation rule + // (and to halt cleanly if ever called for a dead leader). + validateOrder(state, leaderId, order).ok + ) { + orders.push(order); + queued++; + remaining -= cost; + } + } + return { orders, apSpent: budget - remaining }; +} diff --git a/tests/engine/ai/aggression.test.ts b/tests/engine/ai/aggression.test.ts new file mode 100644 index 0000000..0109d9a --- /dev/null +++ b/tests/engine/ai/aggression.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect } from 'vitest'; +import { buildToward, type BuildPlanEntry } from '../../../src/engine/ai/aggression'; +import { initialState } from '../../../src/engine/state'; + +describe('buildToward', () => { + it('builds each plan entry up to its target and no further', () => { + const s = initialState({ cast: ['netanyahoo', 'chump'], difficulty: 'normal', seed: 'b1' }); + s.leaders.netanyahoo.stockpile.missiles = 0; + const plan: BuildPlanEntry[] = [{ build: { item: 'missile' }, target: 3 }]; + const r = buildToward(s, 'netanyahoo', plan, 10); + expect(r.orders.filter((o) => o.kind === 'build-missile')).toHaveLength(3); + expect(r.apSpent).toBe(3); // build-missile costs 1 + }); + + it('counts existing stockpile toward the target', () => { + const s = initialState({ cast: ['netanyahoo', 'chump'], difficulty: 'normal', seed: 'b2' }); + s.leaders.netanyahoo.stockpile.missiles = 2; + const plan: BuildPlanEntry[] = [{ build: { item: 'missile' }, target: 3 }]; + const r = buildToward(s, 'netanyahoo', plan, 10); + expect(r.orders.filter((o) => o.kind === 'build-missile')).toHaveLength(1); + }); + + it('walks entries in priority order and stops at budget exhaustion', () => { + const s = initialState({ cast: ['netanyahoo', 'chump'], difficulty: 'normal', seed: 'b3' }); + const plan: BuildPlanEntry[] = [ + { build: { item: 'missile' }, target: 5 }, + { build: { item: 'warhead', yield: 'small' }, target: 5 }, + ]; + const r = buildToward(s, 'netanyahoo', plan, 3); // only 3 AP, missile costs 1 + expect(r.orders.filter((o) => o.kind === 'build-missile')).toHaveLength(3); + expect(r.orders.filter((o) => o.kind === 'build-warhead')).toHaveLength(0); + }); + + it('respects per-yield warhead targets independently', () => { + const s = initialState({ cast: ['netanyahoo', 'chump'], difficulty: 'normal', seed: 'b4' }); + const plan: BuildPlanEntry[] = [ + { build: { item: 'warhead', yield: 'small' }, target: 2 }, + { build: { item: 'warhead', yield: 'large' }, target: 1 }, + ]; + const r = buildToward(s, 'netanyahoo', plan, 20); + expect(r.orders.filter((o) => o.kind === 'build-warhead' && o.yield === 'small')).toHaveLength(2); + expect(r.orders.filter((o) => o.kind === 'build-warhead' && o.yield === 'large')).toHaveLength(1); + expect(r.apSpent).toBe(2 * 1 + 1 * 3); // small=1, large=3 + }); + + it('emits nothing when budget is below the cheapest item cost', () => { + const s = initialState({ cast: ['starmless', 'chump'], difficulty: 'normal', seed: 'b5' }); + const plan: BuildPlanEntry[] = [{ build: { item: 'factory' }, target: 5 }]; + const r = buildToward(s, 'starmless', plan, 2); // factory costs 3 + expect(r.orders).toHaveLength(0); + expect(r.apSpent).toBe(0); + }); + + it('emits nothing for an already-satisfied plan', () => { + const s = initialState({ cast: ['netanyahoo', 'chump'], difficulty: 'normal', seed: 'b6' }); + s.leaders.netanyahoo.stockpile.missiles = 9; + const plan: BuildPlanEntry[] = [{ build: { item: 'missile' }, target: 3 }]; + const r = buildToward(s, 'netanyahoo', plan, 10); + expect(r.orders).toHaveLength(0); + }); + + it('builds bombers and defences from the plan', () => { + const s = initialState({ cast: ['carnage', 'chump'], difficulty: 'normal', seed: 'b7' }); + const plan: BuildPlanEntry[] = [ + { build: { item: 'bomber' }, target: 1 }, + { build: { item: 'defence', type: 'shield' }, target: 1 }, + ]; + const r = buildToward(s, 'carnage', plan, 20); + expect(r.orders.filter((o) => o.kind === 'build-bomber')).toHaveLength(1); + expect(r.orders.filter((o) => o.kind === 'build-defence')).toHaveLength(1); + }); + + it('emits nothing for a dead leader', () => { + const s = initialState({ cast: ['netanyahoo', 'chump'], difficulty: 'normal', seed: 'b8' }); + s.leaders.netanyahoo.alive = false; + s.leaders.netanyahoo.population = 0; + const plan: BuildPlanEntry[] = [{ build: { item: 'missile' }, target: 3 }]; + const r = buildToward(s, 'netanyahoo', plan, 10); + expect(r.orders).toHaveLength(0); + expect(r.apSpent).toBe(0); + }); +}); From fb59c8ad8fc5e0030e2c39357c37cc544106898c Mon Sep 17 00:00:00 2001 From: gethi Date: Sun, 17 May 2026 12:39:27 +0100 Subject: [PATCH 03/10] =?UTF-8?q?engine:=20add=20launchSalvo=20AI=20helper?= =?UTF-8?q?=20=E2=80=94=20largest-yield-first=20multi-launch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/engine/ai/aggression.ts | 78 ++++++++++++++++++++++++ tests/engine/ai/aggression.test.ts | 95 +++++++++++++++++++++++++++++- 2 files changed, 172 insertions(+), 1 deletion(-) diff --git a/src/engine/ai/aggression.ts b/src/engine/ai/aggression.ts index c2b2b30..ccfaa0d 100644 --- a/src/engine/ai/aggression.ts +++ b/src/engine/ai/aggression.ts @@ -1,6 +1,7 @@ import type { GameState, LeaderId, Order, Yield } from '../types'; import { apCostOf, validateOrder } from '../orders'; import { warheadFieldFor } from '../launches'; +import { ACTION_COSTS } from '../balance'; // --- buildToward --------------------------------------------------------- @@ -78,3 +79,80 @@ export function buildToward( } return { orders, apSpent: budget - remaining }; } + +// --- launchSalvo --------------------------------------------------------- + +export interface LaunchSalvoOpts { + /** AP available for launches this round. */ + budget: number; + /** Targets ranked best-first. Each must be alive and non-self. */ + rankedTargets: LeaderId[]; + /** Hard cap on launches emitted. Omit to fire until AP/ammo run out. */ + maxLaunches?: number; + /** false (default) = focus-fire rankedTargets[0]; true = cycle targets. */ + spread?: boolean; + /** Per-target targetType selector. Default: () => 'people'. */ + targetTypeFor?: (target: LeaderId) => 'people' | 'infra'; +} + +export interface SalvoResult { + orders: Order[]; + apSpent: number; +} + +const YIELD_ORDER: Yield[] = ['large', 'medium', 'small']; + +/** + * Pair available delivery vehicles with warheads, largest-yield-first, and + * emit launch orders until budget, ammo, or maxLaunches runs out. A projected + * stockpile is tracked internally so the salvo never over-commits. + */ +export function launchSalvo( + state: GameState, + leaderId: LeaderId, + opts: LaunchSalvoOpts, +): SalvoResult { + const me = state.leaders[leaderId]; + const orders: Order[] = []; + if (!me || opts.rankedTargets.length === 0) return { orders, apSpent: 0 }; + + let remaining = opts.budget; + let bombers = me.stockpile.bombers; + let missiles = me.stockpile.missiles; + const warheads: Record = { + large: me.stockpile.warheadsLarge, + medium: me.stockpile.warheadsMedium, + small: me.stockpile.warheadsSmall, + }; + const targetTypeFor = opts.targetTypeFor ?? ((): 'people' => 'people'); + + let launched = 0; + while (true) { + if (opts.maxLaunches !== undefined && launched >= opts.maxLaunches) break; + if (remaining < ACTION_COSTS.launch) break; + if (bombers + missiles < 1) break; + const y = YIELD_ORDER.find((yy) => warheads[yy] > 0); + if (y === undefined) break; + + const delivery: 'bomber' | 'missile' = bombers >= 1 ? 'bomber' : 'missile'; + const target = opts.spread + ? opts.rankedTargets[launched % opts.rankedTargets.length] + : opts.rankedTargets[0]; + const launch: Order = { + kind: 'launch', + target, + delivery, + warhead: y, + targetType: targetTypeFor(target), + }; + if (!validateOrder(state, leaderId, launch).ok) break; + + orders.push(launch); + remaining -= ACTION_COSTS.launch; + warheads[y] -= 1; + if (delivery === 'bomber') bombers -= 1; + else missiles -= 1; + launched += 1; + } + return { orders, apSpent: opts.budget - remaining }; +} diff --git a/tests/engine/ai/aggression.test.ts b/tests/engine/ai/aggression.test.ts index 0109d9a..4bbfc43 100644 --- a/tests/engine/ai/aggression.test.ts +++ b/tests/engine/ai/aggression.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { buildToward, type BuildPlanEntry } from '../../../src/engine/ai/aggression'; +import { buildToward, launchSalvo, type BuildPlanEntry } from '../../../src/engine/ai/aggression'; import { initialState } from '../../../src/engine/state'; describe('buildToward', () => { @@ -80,3 +80,96 @@ describe('buildToward', () => { expect(r.apSpent).toBe(0); }); }); + +describe('launchSalvo', () => { + it('fires until AP and ammo run out when no cap is given', () => { + const s = initialState({ cast: ['netanyahoo', 'chump'], difficulty: 'normal', seed: 'l1' }); + s.leaders.netanyahoo.stockpile.missiles = 3; + s.leaders.netanyahoo.stockpile.warheadsSmall = 3; + const r = launchSalvo(s, 'netanyahoo', { budget: 100, rankedTargets: ['chump'] }); + expect(r.orders).toHaveLength(3); // 3 missile+warhead pairs + expect(r.apSpent).toBe(6); // launch costs 2 + }); + + it('honours maxLaunches', () => { + const s = initialState({ cast: ['netanyahoo', 'chump'], difficulty: 'normal', seed: 'l2' }); + s.leaders.netanyahoo.stockpile.missiles = 5; + s.leaders.netanyahoo.stockpile.warheadsSmall = 5; + const r = launchSalvo(s, 'netanyahoo', { budget: 100, rankedTargets: ['chump'], maxLaunches: 2 }); + expect(r.orders).toHaveLength(2); + }); + + it('stops when budget cannot cover another launch', () => { + const s = initialState({ cast: ['netanyahoo', 'chump'], difficulty: 'normal', seed: 'l3' }); + s.leaders.netanyahoo.stockpile.missiles = 5; + s.leaders.netanyahoo.stockpile.warheadsSmall = 5; + const r = launchSalvo(s, 'netanyahoo', { budget: 5, rankedTargets: ['chump'] }); + expect(r.orders).toHaveLength(2); // 5 AP / 2 per launch = 2 + }); + + it('pairs largest-yield warheads first', () => { + const s = initialState({ cast: ['netanyahoo', 'chump'], difficulty: 'normal', seed: 'l4' }); + s.leaders.netanyahoo.stockpile.missiles = 3; + s.leaders.netanyahoo.stockpile.warheadsSmall = 1; + s.leaders.netanyahoo.stockpile.warheadsMedium = 1; + s.leaders.netanyahoo.stockpile.warheadsLarge = 1; + const r = launchSalvo(s, 'netanyahoo', { budget: 100, rankedTargets: ['chump'] }); + const yields = r.orders.map((o) => (o.kind === 'launch' ? o.warhead : null)); + expect(yields).toEqual(['large', 'medium', 'small']); + }); + + it('prefers bomber delivery when a bomber is in stock', () => { + const s = initialState({ cast: ['carnage', 'chump'], difficulty: 'normal', seed: 'l5' }); + s.leaders.carnage.stockpile.bombers = 1; + s.leaders.carnage.stockpile.missiles = 1; + s.leaders.carnage.stockpile.warheadsSmall = 2; + const r = launchSalvo(s, 'carnage', { budget: 100, rankedTargets: ['chump'] }); + expect(r.orders).toHaveLength(2); + if (r.orders[0].kind === 'launch') expect(r.orders[0].delivery).toBe('bomber'); + if (r.orders[1].kind === 'launch') expect(r.orders[1].delivery).toBe('missile'); + }); + + it('focus-fires rankedTargets[0] by default', () => { + const s = initialState({ cast: ['netanyahoo', 'chump', 'carnage'], difficulty: 'normal', seed: 'l6' }); + s.leaders.netanyahoo.stockpile.missiles = 3; + s.leaders.netanyahoo.stockpile.warheadsSmall = 3; + const r = launchSalvo(s, 'netanyahoo', { budget: 100, rankedTargets: ['carnage', 'chump'] }); + expect(r.orders.every((o) => o.kind === 'launch' && o.target === 'carnage')).toBe(true); + }); + + it('cycles targets when spread is true', () => { + const s = initialState({ cast: ['mileigh-hem', 'chump', 'carnage'], difficulty: 'normal', seed: 'l7' }); + s.leaders['mileigh-hem'].stockpile.missiles = 4; + s.leaders['mileigh-hem'].stockpile.warheadsSmall = 4; + const r = launchSalvo(s, 'mileigh-hem', { budget: 100, rankedTargets: ['chump', 'carnage'], spread: true }); + const targets = r.orders.map((o) => (o.kind === 'launch' ? o.target : null)); + expect(targets).toEqual(['chump', 'carnage', 'chump', 'carnage']); + }); + + it('never emits more launches than the projected stockpile can arm', () => { + const s = initialState({ cast: ['netanyahoo', 'chump'], difficulty: 'normal', seed: 'l8' }); + s.leaders.netanyahoo.stockpile.missiles = 1; + s.leaders.netanyahoo.stockpile.warheadsSmall = 5; + const r = launchSalvo(s, 'netanyahoo', { budget: 100, rankedTargets: ['chump'] }); + expect(r.orders).toHaveLength(1); // only 1 delivery vehicle + }); + + it('returns nothing for empty rankedTargets', () => { + const s = initialState({ cast: ['netanyahoo', 'chump'], difficulty: 'normal', seed: 'l9' }); + s.leaders.netanyahoo.stockpile.missiles = 3; + s.leaders.netanyahoo.stockpile.warheadsSmall = 3; + const r = launchSalvo(s, 'netanyahoo', { budget: 100, rankedTargets: [] }); + expect(r.orders).toHaveLength(0); + expect(r.apSpent).toBe(0); + }); + + it('respects the targetTypeFor selector', () => { + const s = initialState({ cast: ['chump', 'carnage'], difficulty: 'normal', seed: 'l10' }); + s.leaders.chump.stockpile.missiles = 1; + s.leaders.chump.stockpile.warheadsSmall = 1; + const r = launchSalvo(s, 'chump', { + budget: 100, rankedTargets: ['carnage'], targetTypeFor: () => 'infra', + }); + expect(r.orders[0].kind === 'launch' && r.orders[0].targetType).toBe('infra'); + }); +}); From d76924b873f26f4efb65f87de85d0cc2fbcfeab2 Mon Sep 17 00:00:00 2001 From: gethi Date: Sun, 17 May 2026 12:51:58 +0100 Subject: [PATCH 04/10] =?UTF-8?q?engine:=20Netanyahoo=20rework=20=E2=80=94?= =?UTF-8?q?=20fix=20zero-fire=20bug,=20multi-launch=20yield=20ramp?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/engine/ai/netanyahoo.ts | 118 +++++++++-------------------- tests/engine/ai/netanyahoo.test.ts | 47 ++++++++++++ 2 files changed, 83 insertions(+), 82 deletions(-) diff --git a/src/engine/ai/netanyahoo.ts b/src/engine/ai/netanyahoo.ts index 57fed2b..f3c3c9b 100644 --- a/src/engine/ai/netanyahoo.ts +++ b/src/engine/ai/netanyahoo.ts @@ -1,103 +1,57 @@ import type { GameState, LeaderId, Order } from '../types'; import { apCostOf, validateOrder } from '../orders'; import { threatScore, wasAttackedBy } from './scoring'; +import { buildToward, launchSalvo, type BuildPlanEntry } from './aggression'; /** - * Netanyahoo — Warmonger personality. + * Netanyahoo — Warmonger personality (P4c.2 rework). * - * Behavioural rules: - * 1. High base launch bias; always launches if stockpile allows. - * 2. Chump-exception: no launch at Chump until Chump has attacked first - * (`wasAttackedBy(state, 'netanyahoo', 'chump') === true`). - * 3. Propaganda exclusively at Chump (always, when Chump is alive and budget ≥ 1). - * 4. Bias toward the largest-arsenal target (highest threatScore), excluding - * Chump unless the Chump-exception has been triggered. - * 5. No woo of others; no woo of Chump in P2. + * Hardest-hitting personality. Launch-first (uncapped salvo), then build the + * remainder toward a yield ramp. Chump-exception preserved: no launch at Chump + * until Chump has attacked first. Propaganda exclusively at Chump. */ +const PROPAGANDA_COST = 1; + +const NETANYAHOO_BUILD_PLAN: BuildPlanEntry[] = [ + { build: { item: 'missile' }, target: 6 }, + { build: { item: 'warhead', yield: 'small' }, target: 4 }, + { build: { item: 'warhead', yield: 'medium' }, target: 3 }, + { build: { item: 'warhead', yield: 'large' }, target: 2 }, +]; + export function planNetanyahoo(state: GameState, leaderId: LeaderId): Order[] { const me = state.leaders[leaderId]; if (!me || !me.alive) return []; - const orders: Order[] = []; let budget = me.ap; - const LAUNCH_COST = 2; - const PROPAGANDA_COST = 1; - - // --- Living non-self leaders --- const others = state.cast.filter((t) => t !== leaderId && state.leaders[t]?.alive); - - // --- Chump state --- const chumpAlive = state.cast.includes('chump') && state.leaders['chump']?.alive === true; const chumpProvoked = wasAttackedBy(state, leaderId, 'chump'); - // --- Candidates for launch (exclude Chump unless provoked) --- + // Launch candidates: exclude Chump unless he has attacked first. const launchCandidates = others.filter((t) => t !== 'chump' || chumpProvoked); - - // --- Pick the highest-threatScore candidate --- - let launchTarget: LeaderId | undefined; - if (launchCandidates.length > 0) { - launchTarget = launchCandidates.reduce((best, t) => - threatScore(state, leaderId, t) >= threatScore(state, leaderId, best) ? t : best, - ); - } - - // Determine whether a launch is feasible this round. - const canLaunch = - launchTarget !== undefined && - me.stockpile.missiles >= 1 && - me.stockpile.warheadsSmall >= 1 && - budget >= LAUNCH_COST; - - // --- Reserve AP: launch first, then propaganda --- - const launchReserve = canLaunch ? LAUNCH_COST : 0; - const propagandaReserve = - chumpAlive && budget >= launchReserve + PROPAGANDA_COST ? PROPAGANDA_COST : 0; - const buildBudget = budget - launchReserve - propagandaReserve; - - // --- 1. Builds: build missiles/warheads with leftover budget --- - let remaining = buildBudget; - - // Build missiles (warmonger stockpile bias). - while (remaining >= 1) { - const o: Order = { kind: 'build-missile' }; - if (validateOrder(state, leaderId, o).ok) { - orders.push(o); - remaining -= 1; - } else { - break; - } - } - - // Build small warheads with whatever is left. - while (remaining >= 1) { - const o: Order = { kind: 'build-warhead', yield: 'small' }; - if (validateOrder(state, leaderId, o).ok) { - orders.push(o); - remaining -= 1; - } else { - break; - } - } - - budget -= buildBudget - remaining; - - // --- 2. Launch at the best candidate --- - if (canLaunch && launchTarget !== undefined && budget >= LAUNCH_COST) { - const launch: Order = { - kind: 'launch', - target: launchTarget, - delivery: 'missile', - warhead: 'small', - targetType: 'people', - }; - if (validateOrder(state, leaderId, launch).ok) { - orders.push(launch); - budget -= apCostOf(launch); - } - } - - // --- 3. Propaganda exclusively at Chump --- + // Rank by threat, highest first. + const rankedTargets = [...launchCandidates].sort( + (a, b) => threatScore(state, leaderId, b) - threatScore(state, leaderId, a), + ); + + // Reserve 1 AP for propaganda at Chump. + const propagandaReserve = chumpAlive && budget >= PROPAGANDA_COST ? PROPAGANDA_COST : 0; + const offenceBudget = budget - propagandaReserve; + + // Launch first — salvo self-limits to ammo on hand; warmonger has no cap. + const salvo = launchSalvo(state, leaderId, { budget: offenceBudget, rankedTargets }); + // Build with whatever the salvo left unspent. + const build = buildToward( + state, leaderId, NETANYAHOO_BUILD_PLAN, offenceBudget - salvo.apSpent, + ); + budget -= salvo.apSpent + build.apSpent; + + // Builds must precede launches in the submitted array. + const orders: Order[] = [...build.orders, ...salvo.orders]; + + // Propaganda exclusively at Chump. if (chumpAlive && budget >= PROPAGANDA_COST) { const prop: Order = { kind: 'propaganda', target: 'chump' }; if (validateOrder(state, leaderId, prop).ok) { diff --git a/tests/engine/ai/netanyahoo.test.ts b/tests/engine/ai/netanyahoo.test.ts index d26925c..000a53b 100644 --- a/tests/engine/ai/netanyahoo.test.ts +++ b/tests/engine/ai/netanyahoo.test.ts @@ -61,3 +61,50 @@ describe('Netanyahoo missile bias regression (P4c.1)', () => { expect(orders.some((o) => o.kind === 'build-bomber')).toBe(false); }); }); + +describe('Netanyahoo aggression (P4c.2)', () => { + it('actually fires when armed — the zero-fire bug is gone', () => { + const s = initialState({ cast: ['netanyahoo', 'carnage'], difficulty: 'normal', seed: 'na1' }); + s.leaders.netanyahoo.stockpile.missiles = 3; + s.leaders.netanyahoo.stockpile.warheadsSmall = 3; + s.leaders.netanyahoo.ap = 10; + const orders = planNetanyahoo(s, 'netanyahoo'); + expect(orders.filter((o) => o.kind === 'launch').length).toBeGreaterThanOrEqual(1); + }); + + it('fires a multi-launch salvo when richly armed', () => { + const s = initialState({ cast: ['netanyahoo', 'carnage'], difficulty: 'normal', seed: 'na2' }); + s.leaders.netanyahoo.stockpile.missiles = 4; + s.leaders.netanyahoo.stockpile.warheadsSmall = 4; + s.leaders.netanyahoo.ap = 12; + const orders = planNetanyahoo(s, 'netanyahoo'); + expect(orders.filter((o) => o.kind === 'launch').length).toBeGreaterThanOrEqual(2); + }); + + it('builds toward a yield ramp (medium/large warheads), not only small', () => { + const s = initialState({ cast: ['netanyahoo', 'carnage'], difficulty: 'normal', seed: 'na3' }); + s.leaders.netanyahoo.stockpile.missiles = 6; + s.leaders.netanyahoo.stockpile.warheadsSmall = 4; + s.leaders.netanyahoo.ap = 12; + const orders = planNetanyahoo(s, 'netanyahoo'); + expect(orders.some((o) => o.kind === 'build-warhead' && o.yield === 'medium')).toBe(true); + }); + + it('emits builds before launches (validateOrderSequence ordering)', () => { + const s = initialState({ cast: ['netanyahoo', 'carnage'], difficulty: 'normal', seed: 'na4' }); + s.leaders.netanyahoo.stockpile.missiles = 2; + s.leaders.netanyahoo.stockpile.warheadsSmall = 2; + s.leaders.netanyahoo.ap = 12; + const orders = planNetanyahoo(s, 'netanyahoo'); + const firstLaunch = orders.findIndex((o) => o.kind === 'launch'); + let lastBuild = -1; + orders.forEach((o, i) => { + if (o.kind.startsWith('build-')) lastBuild = i; + }); + // Both must be present for this scenario, and every build must precede + // the first launch — the reducer validates the order array in sequence. + expect(firstLaunch).toBeGreaterThan(-1); + expect(lastBuild).toBeGreaterThan(-1); + expect(lastBuild).toBeLessThan(firstLaunch); + }); +}); From ecc3f2c173da18525f24d131295213974bb4cb62 Mon Sep 17 00:00:00 2001 From: gethi Date: Sun, 17 May 2026 13:03:29 +0100 Subject: [PATCH 05/10] =?UTF-8?q?engine:=20Khameneverhere=20rework=20?= =?UTF-8?q?=E2=80=94=20multi-launch=20grudge=20salvo,=20raised=20stockpile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/engine/ai/khameneverhere.ts | 101 ++++++------------------- tests/engine/ai/khameneverhere.test.ts | 23 ++++++ 2 files changed, 48 insertions(+), 76 deletions(-) diff --git a/src/engine/ai/khameneverhere.ts b/src/engine/ai/khameneverhere.ts index 0ea1ad2..1aee1f8 100644 --- a/src/engine/ai/khameneverhere.ts +++ b/src/engine/ai/khameneverhere.ts @@ -1,90 +1,39 @@ import type { GameState, LeaderId, Order } from '../types'; -import { apCostOf, validateOrder } from '../orders'; import { topGrudgeTarget } from './scoring'; +import { buildToward, launchSalvo, type BuildPlanEntry } from './aggression'; /** - * Khameneverhere — Grudge personality. + * Khameneverhere — Grudge personality (P4c.2 rework). * - * Behavioural rules: - * 1. Each round, launch focus on the leader at the top of the grudge list. - * 2. Fallback when grudge empty: pick the first living non-self leader. - * 3. Build a moderate stockpile (target ~3 missiles + 3 small warheads). - * Not heavy on defences — offence over defence. - * 4. Does not woo or propagandise much; launch-focused. + * Very aggressive, launch-focused. Ranks targets by grudge (top grudge first, + * then remaining living leaders). Launch-first uncapped salvo, then build the + * remainder toward a raised stockpile with medium warheads. No diplomacy. */ +const KHAMENEVERHERE_BUILD_PLAN: BuildPlanEntry[] = [ + { build: { item: 'missile' }, target: 6 }, + { build: { item: 'warhead', yield: 'small' }, target: 4 }, + { build: { item: 'warhead', yield: 'medium' }, target: 3 }, +]; + export function planKhameneverhere(state: GameState, leaderId: LeaderId): Order[] { const me = state.leaders[leaderId]; if (!me || !me.alive) return []; - const orders: Order[] = []; - let budget = me.ap; - - const MISSILE_TARGET = 3; - const WARHEAD_TARGET = 3; - const LAUNCH_COST = 2; - const BUILD_MISSILE_COST = 1; - const BUILD_WARHEAD_COST = 1; - - // Count pending builds already in orders (to avoid double-counting). - const pendingMissiles = () => orders.filter((o) => o.kind === 'build-missile').length; - const pendingWarheads = () => - orders.filter((o) => o.kind === 'build-warhead' && (o as Extract).yield === 'small').length; - - // Determine whether a launch will be possible this round (missiles + warheads already in stock). - // Reserve LAUNCH_COST AP so the build loop doesn't crowd it out. - const canLaunchNow = me.stockpile.missiles >= 1 && me.stockpile.warheadsSmall >= 1; - const launchReserve = canLaunchNow ? LAUNCH_COST : 0; - let buildBudget = budget - launchReserve; - - // --- 1. Build missiles up to target stockpile --- - while ( - buildBudget >= BUILD_MISSILE_COST && - me.stockpile.missiles + pendingMissiles() < MISSILE_TARGET - ) { - const o: Order = { kind: 'build-missile' }; - if (validateOrder(state, leaderId, o).ok) { - orders.push(o); - buildBudget -= BUILD_MISSILE_COST; - } else { - break; - } - } - - // --- 2. Build small warheads up to target --- - while ( - buildBudget >= BUILD_WARHEAD_COST && - me.stockpile.warheadsSmall + pendingWarheads() < WARHEAD_TARGET - ) { - const o: Order = { kind: 'build-warhead', yield: 'small' }; - if (validateOrder(state, leaderId, o).ok) { - orders.push(o); - buildBudget -= BUILD_WARHEAD_COST; - } else { - break; - } - } + const others = state.cast.filter((t) => t !== leaderId && state.leaders[t]?.alive); - budget -= (budget - launchReserve) - buildBudget; // consume what was actually spent on builds + // Rank: top grudge target first, then the remaining living leaders. + const top = topGrudgeTarget(state, leaderId); + const rankedTargets: LeaderId[] = + top !== null && others.includes(top) + ? [top, ...others.filter((t) => t !== top)] + : [...others]; - // --- 3. Launch at the top grudge target (fallback: first living non-self leader) --- - if (me.stockpile.missiles >= 1 && me.stockpile.warheadsSmall >= 1 && budget >= LAUNCH_COST) { - const others = state.cast.filter((t) => t !== leaderId && state.leaders[t]?.alive); - const top = - topGrudgeTarget(state, leaderId) ?? others[0]; - if (top !== undefined) { - const launch: Order = { - kind: 'launch', - target: top, - delivery: 'missile', - warhead: 'small', - targetType: 'people', - }; - if (validateOrder(state, leaderId, launch).ok) { - orders.push(launch); - budget -= apCostOf(launch); - } - } - } + // Launch first (uncapped), then build with the remainder. + const salvo = launchSalvo(state, leaderId, { budget: me.ap, rankedTargets }); + const build = buildToward( + state, leaderId, KHAMENEVERHERE_BUILD_PLAN, me.ap - salvo.apSpent, + ); - return orders; + // Builds precede launches in the submitted array. + return [...build.orders, ...salvo.orders]; } diff --git a/tests/engine/ai/khameneverhere.test.ts b/tests/engine/ai/khameneverhere.test.ts index 181c82a..47fe1c0 100644 --- a/tests/engine/ai/khameneverhere.test.ts +++ b/tests/engine/ai/khameneverhere.test.ts @@ -32,3 +32,26 @@ describe('Khameneverhere (Grudge)', () => { expect(orders.some((o) => o.kind.startsWith('build-'))).toBe(true); }); }); + +describe('Khameneverhere aggression (P4c.2)', () => { + it('fires a multi-launch salvo at the top grudge target when armed', () => { + const s = initialState({ cast: ['khameneverhere', 'carnage', 'chump'], difficulty: 'normal', seed: 'ka1' }); + s.leaders.khameneverhere.stockpile.missiles = 3; + s.leaders.khameneverhere.stockpile.warheadsSmall = 3; + s.leaders.khameneverhere.ap = 12; + s.leaders.khameneverhere.grudge = { carnage: 9 }; + const orders = planKhameneverhere(s, 'khameneverhere'); + const launches = orders.filter((o) => o.kind === 'launch'); + expect(launches.length).toBeGreaterThanOrEqual(2); + expect(launches.every((o) => o.kind === 'launch' && o.target === 'carnage')).toBe(true); + }); + + it('builds medium warheads into the ramp', () => { + const s = initialState({ cast: ['khameneverhere', 'carnage'], difficulty: 'normal', seed: 'ka2' }); + s.leaders.khameneverhere.stockpile.missiles = 6; + s.leaders.khameneverhere.stockpile.warheadsSmall = 4; + s.leaders.khameneverhere.ap = 12; + const orders = planKhameneverhere(s, 'khameneverhere'); + expect(orders.some((o) => o.kind === 'build-warhead' && o.yield === 'medium')).toBe(true); + }); +}); From bdb721ed0c43f2a62d6cd23027f0ed91111f7898 Mon Sep 17 00:00:00 2001 From: gethi Date: Sun, 17 May 2026 13:11:20 +0100 Subject: [PATCH 06/10] =?UTF-8?q?engine:=20Mileigh-hem=20rework=20?= =?UTF-8?q?=E2=80=94=20fix=20zero-fire=20bug,=20build=20logic=20+=20spread?= =?UTF-8?q?=20salvo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/engine/ai/mileighhem.ts | 165 +++++++++-------------------- tests/engine/ai/mileighhem.test.ts | 28 +++++ 2 files changed, 78 insertions(+), 115 deletions(-) diff --git a/src/engine/ai/mileighhem.ts b/src/engine/ai/mileighhem.ts index 8778aef..484f3ce 100644 --- a/src/engine/ai/mileighhem.ts +++ b/src/engine/ai/mileighhem.ts @@ -1,140 +1,75 @@ -import type { GameState, LeaderId, Order, Yield } from '../types'; +import type { GameState, LeaderId, Order } from '../types'; import { apCostOf, validateOrder } from '../orders'; import { wasAttackedBy } from './scoring'; import { AI_SCORING_WEIGHTS } from '../balance'; +import { buildToward, launchSalvo, type BuildPlanEntry } from './aggression'; /** - * Mileigh-hem — Glass cannon personality. + * Mileigh-hem — Glass cannon personality (P4c.2 rework). * - * Two modes gated by an activation trigger: + * Two modes gated by the activation trigger (apBanked + ap >= threshold). * - * Activation trigger: me.apBanked + me.ap >= mileighActivationApThreshold (4). + * Activated: launch-first SPREAD salvo (cycles targets), then build a cheap + * fast offensive stockpile with the remainder. No defence — glass cannon. * - * All-out mode (activated): - * Pair every available missile/bomber + warhead into launches, largest warhead first - * (greedy yield order: large → medium → small). Targets are leaders who attacked or - * propagandised him (approximated via wasAttackedBy; see note below). Skips defences. - * - * Diplomatic mode (otherwise): - * Emit up to 2 woo orders and up to 2 propaganda orders at attackers/propagandisers. - * Skips defences entirely. - * - * Note on propaganda-received tracking: the engine does not track "who propagandised me" - * as a separate counter. For P2 we approximate propagandisers via wasAttackedBy - * (grudge OR recentAggressionFrom), which captures most aggression. A dedicated - * propagandaReceivedFrom counter is deferred to P4. + * Diplomatic (not activated): up to 2 woo + up to 2 propaganda at attackers. */ +const MILEIGH_BUILD_PLAN: BuildPlanEntry[] = [ + { build: { item: 'missile' }, target: 4 }, + { build: { item: 'warhead', yield: 'small' }, target: 3 }, + { build: { item: 'warhead', yield: 'medium' }, target: 2 }, +]; + export function planMileighHem(state: GameState, leaderId: LeaderId): Order[] { const me = state.leaders[leaderId]; if (!me || !me.alive) return []; - const orders: Order[] = []; - - // Living non-self leaders. const others = state.cast.filter((t) => t !== leaderId && state.leaders[t]?.alive); - - // Leaders who attacked or propagandised Mileigh-hem (P2 approximation via grudge/aggression). - const targets = others.filter((t) => wasAttackedBy(state, leaderId, t)); + const attackers = others.filter((t) => wasAttackedBy(state, leaderId, t)); const totalAp = me.apBanked + me.ap; const activated = totalAp >= AI_SCORING_WEIGHTS.mileighActivationApThreshold; if (activated) { - // --- All-out mode --- - // Pair every available delivery vehicle + warhead, largest warhead first. - // Targets: attackers first; if none, all others. - const launchTargets = targets.length > 0 ? targets : others; - - // Build a pool of (delivery, warhead) pairs, greedily largest first. - type Pair = { delivery: 'missile' | 'bomber'; warhead: Yield }; - const pairs: Pair[] = []; - - // Track available stockpile counts (we consume logically to avoid double-pairing). - let missiles = me.stockpile.missiles; - let bombers = me.stockpile.bombers; - let large = me.stockpile.warheadsLarge; - let medium = me.stockpile.warheadsMedium; - let small = me.stockpile.warheadsSmall; - - // Yield priority: large → medium → small. - const yieldPriority: Yield[] = ['large', 'medium', 'small']; - - while (missiles + bombers > 0) { - // Pick the largest available warhead. - let selectedWarhead: Yield | null = null; - for (const y of yieldPriority) { - if (y === 'large' && large > 0) { selectedWarhead = 'large'; large--; break; } - if (y === 'medium' && medium > 0) { selectedWarhead = 'medium'; medium--; break; } - if (y === 'small' && small > 0) { selectedWarhead = 'small'; small--; break; } - } - if (selectedWarhead === null) break; // no warheads left - - // Pick delivery: prefer missile, fall back to bomber. - let delivery: 'missile' | 'bomber'; - if (missiles > 0) { - delivery = 'missile'; - missiles--; - } else { - delivery = 'bomber'; - bombers--; - } - - pairs.push({ delivery, warhead: selectedWarhead }); - } - - // Emit launch orders, cycling through launchTargets. - let budget = me.ap; - for (let i = 0; i < pairs.length; i++) { - const { delivery, warhead } = pairs[i]; - const target = launchTargets[i % launchTargets.length]; - if (!target) break; - if (budget < 2) break; // launch costs 2 AP - - const launch: Order = { - kind: 'launch', - target, - delivery, - warhead, - targetType: 'people', - }; - if (validateOrder(state, leaderId, launch).ok) { - orders.push(launch); - budget -= apCostOf(launch); - } - } - } else { - // --- Diplomatic mode --- - let budget = me.ap; - - const WOO_COST = 1; // flat woo costs 1 AP (P4b) - const PROPAGANDA_COST = 1; + // All-out mode: spread salvo first, then build with the remainder. + const rankedTargets = attackers.length > 0 ? attackers : others; + const salvo = launchSalvo(state, leaderId, { + budget: me.ap, + rankedTargets, + spread: true, + }); + const build = buildToward(state, leaderId, MILEIGH_BUILD_PLAN, me.ap - salvo.apSpent); + return [...build.orders, ...salvo.orders]; + } - // Woo up to 2 leaders (targeting attackers first, then others if budget allows). - // Woo targets: use targets (attackers) if present, else all others. - const wooPool = targets.length > 0 ? targets : others; - let wooCount = 0; - for (const t of wooPool) { - if (wooCount >= 2) break; - if (budget < WOO_COST) break; - const woo: Order = { kind: 'woo', target: t }; - if (validateOrder(state, leaderId, woo).ok) { - orders.push(woo); - budget -= apCostOf(woo); - wooCount++; - } + // --- Diplomatic mode (unchanged from P4b) --- + const orders: Order[] = []; + let budget = me.ap; + const WOO_COST = 1; + const PROPAGANDA_COST = 1; + + const wooPool = attackers.length > 0 ? attackers : others; + let wooCount = 0; + for (const t of wooPool) { + if (wooCount >= 2) break; + if (budget < WOO_COST) break; + const woo: Order = { kind: 'woo', target: t }; + if (validateOrder(state, leaderId, woo).ok) { + orders.push(woo); + budget -= apCostOf(woo); + wooCount++; } + } - // Propaganda at attackers (up to 2 orders). - let propCount = 0; - for (const t of targets) { - if (propCount >= 2) break; - if (budget < PROPAGANDA_COST) break; - const prop: Order = { kind: 'propaganda', target: t }; - if (validateOrder(state, leaderId, prop).ok) { - orders.push(prop); - budget -= apCostOf(prop); - propCount++; - } + let propCount = 0; + for (const t of attackers) { + if (propCount >= 2) break; + if (budget < PROPAGANDA_COST) break; + const prop: Order = { kind: 'propaganda', target: t }; + if (validateOrder(state, leaderId, prop).ok) { + orders.push(prop); + budget -= apCostOf(prop); + propCount++; } } diff --git a/tests/engine/ai/mileighhem.test.ts b/tests/engine/ai/mileighhem.test.ts index 43d885d..32d4018 100644 --- a/tests/engine/ai/mileighhem.test.ts +++ b/tests/engine/ai/mileighhem.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from 'vitest'; import { planMileighHem } from '../../../src/engine/ai/mileighhem'; import { initialState } from '../../../src/engine/state'; +import type { Order } from '../../../src/engine/types'; describe('Mileigh-hem (Glass cannon)', () => { it('activates all-out mode when apBanked + ap >= 4 and emits launch orders', () => { @@ -166,3 +167,30 @@ describe('Mileigh-hem (Glass cannon)', () => { expect(launches.length).toBeGreaterThanOrEqual(1); }); }); + +describe('Mileigh-hem aggression (P4c.2)', () => { + it('builds delivery + warheads in activated mode — the zero-fire bug is gone', () => { + const s = initialState({ cast: ['mileigh-hem', 'carnage'], difficulty: 'normal', seed: 'ma1' }); + // Activated by AP alone: ap=10, apBanked=0 → 10 >= 4 (mileighActivationApThreshold). + // grudge sets rankedTargets (carnage), not the activation flag. + s.leaders['mileigh-hem'].ap = 10; + s.leaders['mileigh-hem'].grudge = { carnage: 3 }; + const orders = planMileighHem(s, 'mileigh-hem'); + expect(orders.some((o) => o.kind === 'build-missile' || o.kind === 'build-warhead')).toBe(true); + }); + + it('fires a spread salvo across targets when armed in activated mode', () => { + const s = initialState({ cast: ['mileigh-hem', 'carnage', 'chump'], difficulty: 'normal', seed: 'ma2' }); + s.leaders['mileigh-hem'].ap = 12; + s.leaders['mileigh-hem'].stockpile.missiles = 4; + s.leaders['mileigh-hem'].stockpile.warheadsSmall = 4; + s.leaders['mileigh-hem'].grudge = { carnage: 2, chump: 2 }; + const orders = planMileighHem(s, 'mileigh-hem'); + const launches = orders.filter( + (o): o is Extract => o.kind === 'launch', + ); + const targets = new Set(launches.map((o) => o.target)); + expect(launches.length).toBeGreaterThanOrEqual(2); + expect(targets.size).toBeGreaterThanOrEqual(2); // spread across targets + }); +}); From cc9b807cfaad390bca2d79235ae24b9048cc8491 Mon Sep 17 00:00:00 2001 From: gethi Date: Sun, 17 May 2026 13:21:30 +0100 Subject: [PATCH 07/10] =?UTF-8?q?engine:=20Carnage=20rework=20=E2=80=94=20?= =?UTF-8?q?multi-launch=20salvo,=20P4c.1=20bomber=20bias=20preserved?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/engine/ai/carnage.ts | 117 ++++++++++---------------------- tests/engine/ai/carnage.test.ts | 39 ++++++++--- 2 files changed, 65 insertions(+), 91 deletions(-) diff --git a/src/engine/ai/carnage.ts b/src/engine/ai/carnage.ts index d69c7b3..b66601d 100644 --- a/src/engine/ai/carnage.ts +++ b/src/engine/ai/carnage.ts @@ -2,38 +2,37 @@ import type { GameState, LeaderId, Order } from '../types'; import { apCostOf, validateOrder } from '../orders'; import { threatScore, opportunismScore, wasAttackedBy } from './scoring'; import { AI_SCORING_WEIGHTS } from '../balance'; +import { buildToward, launchSalvo, type BuildPlanEntry } from './aggression'; /** - * Carnage — Rational + Opportunist personality. + * Carnage — Rational + Opportunist personality (P4c.2 rework). * - * Behavioural rules: - * 1. Score targets by threat = threatScore (arsenal + recent_aggression) with - * escalation: if the target hit Carnage last round (recentAggressionFrom[target] > 0), - * multiply their threat score by carnageEscalationMultiplier. - * 2. Add opportunismScore to the combined score to apply "finish them" bonus for - * weak targets. - * 3. Launch at the highest (threat + opportunism) candidate. - * 4. Propaganda only at leaders who have attacked Carnage - * (wasAttackedBy(state, 'carnage', candidate) === true). - * 5. Reserve AP: launch (2) first, then propaganda (1 per attacker, capped by budget). + * Aggressive-rational. Ranks targets by threat (with escalation against + * leaders who hit Carnage last round) + opportunism. Keeps the P4c.1 bomber + * bias: builds toward a fleet of 3 reusable bombers; launchSalvo prefers bomber + * delivery. Moderate launch cap. Propaganda only at attackers. */ +const PROPAGANDA_COST = 1; +const CARNAGE_MAX_LAUNCHES = 3; + +// P4c.2 supersedes P4c.1's single-shot bomber rule: Carnage builds a small +// REUSABLE bomber fleet so his moderate-cap salvo has real multi-launch. +// Bombers return on impact (P4c.1 rule), so 3 bombers = 3 launches/round. +const CARNAGE_BUILD_PLAN: BuildPlanEntry[] = [ + { build: { item: 'bomber' }, target: 3 }, + { build: { item: 'warhead', yield: 'small' }, target: 4 }, + { build: { item: 'warhead', yield: 'medium' }, target: 2 }, +]; + export function planCarnage(state: GameState, leaderId: LeaderId): Order[] { const me = state.leaders[leaderId]; if (!me || !me.alive) return []; - const orders: Order[] = []; let budget = me.ap; - const LAUNCH_COST = 2; - const PROPAGANDA_COST = 1; - - // --- Living non-self leaders --- const others = state.cast.filter((t) => t !== leaderId && state.leaders[t]?.alive); - - // --- Identify attackers (for propaganda targeting) --- const attackers = others.filter((t) => wasAttackedBy(state, leaderId, t)); - // --- Score each candidate --- function combinedScore(target: LeaderId): number { const base = threatScore(state, leaderId, target); const escalated = @@ -43,74 +42,26 @@ export function planCarnage(state: GameState, leaderId: LeaderId): Order[] { return escalated + opportunismScore(state, target); } - // --- Pick the highest-scoring launch candidate --- - let launchTarget: LeaderId | undefined; - if (others.length > 0) { - launchTarget = others.reduce((best, t) => - combinedScore(t) >= combinedScore(best) ? t : best, - ); - } - - const hasDelivery = me.stockpile.bombers >= 1 || me.stockpile.missiles >= 1; - const canLaunch = - launchTarget !== undefined && - hasDelivery && - me.stockpile.warheadsSmall >= 1 && - budget >= LAUNCH_COST; - - // --- Reserve AP: launch, then propaganda at attackers --- - const launchReserve = canLaunch ? LAUNCH_COST : 0; - - // Count how many propaganda orders we can afford after launch reserve. - const propagandaBudget = budget - launchReserve; - const propagandaSlots = Math.min(attackers.length, Math.max(0, propagandaBudget)); - - const totalReserve = launchReserve + propagandaSlots * PROPAGANDA_COST; - const buildBudget = budget - totalReserve; + const rankedTargets = [...others].sort((a, b) => combinedScore(b) - combinedScore(a)); - // --- 1. Builds --- - let remaining = buildBudget; + // Reserve 1 AP per attacker for propaganda (capped so it never starves offence). + const propagandaReserve = Math.min(attackers.length, Math.max(0, budget - 2)); + const offenceBudget = budget - propagandaReserve; - // P4c.1: Carnage values bombers (reusable assets). If he owns none, - // build one as first priority. Single-shot — projection-safe. - if (me.stockpile.bombers === 0 && remaining >= 1) { - const o: Order = { kind: 'build-bomber' }; - if (validateOrder(state, leaderId, o).ok) { - orders.push(o); - remaining -= 1; - } - } - - // Build small warheads with the rest. - while (remaining >= 1) { - const o: Order = { kind: 'build-warhead', yield: 'small' }; - if (validateOrder(state, leaderId, o).ok) { - orders.push(o); - remaining -= 1; - } else { - break; - } - } - - budget -= buildBudget - remaining; + // Launch first (moderate cap), then build with the remainder. + const salvo = launchSalvo(state, leaderId, { + budget: offenceBudget, + rankedTargets, + maxLaunches: CARNAGE_MAX_LAUNCHES, + }); + const build = buildToward( + state, leaderId, CARNAGE_BUILD_PLAN, offenceBudget - salvo.apSpent, + ); + budget -= salvo.apSpent + build.apSpent; - // --- 2. Launch at the best combined-score target --- - if (canLaunch && launchTarget !== undefined && budget >= LAUNCH_COST) { - const delivery: 'bomber' | 'missile' = me.stockpile.bombers >= 1 ? 'bomber' : 'missile'; - const launch: Order = { - kind: 'launch', - target: launchTarget, - delivery, - warhead: 'small', - targetType: 'people', - }; - if (validateOrder(state, leaderId, launch).ok) { - orders.push(launch); - budget -= apCostOf(launch); - } - } + const orders: Order[] = [...build.orders, ...salvo.orders]; - // --- 3. Propaganda only at leaders who attacked Carnage --- + // Propaganda only at leaders who attacked Carnage. for (const attacker of attackers) { if (budget < PROPAGANDA_COST) break; const prop: Order = { kind: 'propaganda', target: attacker }; diff --git a/tests/engine/ai/carnage.test.ts b/tests/engine/ai/carnage.test.ts index cbb7212..fda700b 100644 --- a/tests/engine/ai/carnage.test.ts +++ b/tests/engine/ai/carnage.test.ts @@ -96,16 +96,19 @@ describe('Carnage AI bomber bias (P4c.1)', () => { expect(orders.some((o) => o.kind === 'build-missile')).toBe(false); }); - it('does NOT build a second bomber when one is already owned', () => { - let s = initialState({ - cast: ['carnage', 'chump'], - difficulty: 'normal', - seed: 'carnage-already-has-bomber', - }); + it('builds toward a 3-bomber fleet (P4c.2 supersedes the single-shot rule)', () => { + const s = initialState({ cast: ['carnage', 'chump'], difficulty: 'normal', seed: 'carnage-fleet' }); s.leaders.carnage.stockpile.bombers = 1; - s.leaders.carnage.stockpile.missiles = 0; - s.leaders.carnage.ap = 6; + s.leaders.carnage.ap = 10; + const orders = planCarnage(s, 'carnage'); + // 1 owned + 2 built = fleet of 3. + expect(orders.filter((o) => o.kind === 'build-bomber')).toHaveLength(2); + }); + it('does not exceed the 3-bomber fleet cap', () => { + const s = initialState({ cast: ['carnage', 'chump'], difficulty: 'normal', seed: 'carnage-fleet-cap' }); + s.leaders.carnage.stockpile.bombers = 3; + s.leaders.carnage.ap = 10; const orders = planCarnage(s, 'carnage'); expect(orders.filter((o) => o.kind === 'build-bomber')).toHaveLength(0); }); @@ -151,3 +154,23 @@ describe('Carnage AI bomber bias (P4c.1)', () => { } }); }); + +describe('Carnage aggression (P4c.2)', () => { + it('fires a multi-launch salvo when armed', () => { + const s = initialState({ cast: ['carnage', 'chump'], difficulty: 'normal', seed: 'ca1' }); + s.leaders.carnage.stockpile.bombers = 3; + s.leaders.carnage.stockpile.warheadsSmall = 3; + s.leaders.carnage.ap = 12; + const orders = planCarnage(s, 'carnage'); + expect(orders.filter((o) => o.kind === 'launch').length).toBeGreaterThanOrEqual(2); + }); + + it('still builds bombers (not missiles) when no delivery owned — P4c.1 bias preserved', () => { + const s = initialState({ cast: ['carnage', 'chump'], difficulty: 'normal', seed: 'ca2' }); + s.leaders.carnage.stockpile.bombers = 0; + s.leaders.carnage.ap = 10; + const orders = planCarnage(s, 'carnage'); + expect(orders.some((o) => o.kind === 'build-bomber')).toBe(true); + expect(orders.some((o) => o.kind === 'build-missile')).toBe(false); + }); +}); From a6da0ba7e2f8726a7b65b6199db8f9d75d0f3fbb Mon Sep 17 00:00:00 2001 From: gethi Date: Sun, 17 May 2026 13:31:47 +0100 Subject: [PATCH 08/10] =?UTF-8?q?engine:=20Starmless=20rework=20=E2=80=94?= =?UTF-8?q?=20new=20kill=20instinct,=20multi-launch=20under=20low=20cap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/engine/ai/starmless.ts | 161 ++++++++++-------------------- tests/engine/ai/starmless.test.ts | 25 +++++ 2 files changed, 79 insertions(+), 107 deletions(-) diff --git a/src/engine/ai/starmless.ts b/src/engine/ai/starmless.ts index d4c848a..1513dbb 100644 --- a/src/engine/ai/starmless.ts +++ b/src/engine/ai/starmless.ts @@ -3,42 +3,46 @@ import { apCostOf, validateOrder } from '../orders'; import { threatScore, wasAttackedBy } from './scoring'; import { AI_SCORING_WEIGHTS } from '../balance'; import { nextRandom } from '../rng'; +import { buildToward, launchSalvo, type BuildPlanEntry } from './aggression'; /** - * Starmless — Cautious + Scapegoat personality. + * Starmless — Cautious + Scapegoat personality (P4c.2 rework). * - * Behavioural rules: - * 1. Defensive baseline: in non-retaliation rounds, prefer building factories - * (~60 % of build decisions are factories; warheads otherwise). - * 2. Retaliation gate: triggered when wasAttackedBy(state, leaderId, any) === true. - * 3. On retaliation, 35 % chance (starmlessScapegoatPct roll) to scapegoat — - * pick a target OTHER than the actual attacker, specifically the candidate with - * the highest aggregateThreat (sum of threatScore from all leaders toward them). - * 4. Propaganda only at leaders who attacked Starmless (wasAttackedBy filter). + * Defensive baseline, but with a new kill instinct: launches when retaliating + * OR when a finishable (low-population) opponent exists. Low launch cap — he + * still builds factories and defence. Scapegoat roll preserved on retaliation. */ +const PROPAGANDA_COST = 1; +const DEPLOY_COST = 4; +const STARMLESS_FINISH_POP_M = 8; +const STARMLESS_MAX_LAUNCHES = 2; + +// Factory target 7 (starts at 6) so at most one factory is built per low-AP +// round — leaving budget for the missile + warhead stock the kill instinct +// needs. A plan with warheads but NO delivery vehicle would recreate the +// zero-fire bug, so the missile entry is mandatory. +const STARMLESS_BUILD_PLAN: BuildPlanEntry[] = [ + { build: { item: 'factory' }, target: 7 }, + { build: { item: 'missile' }, target: 2 }, + { build: { item: 'warhead', yield: 'small' }, target: 3 }, + { build: { item: 'defence', type: 'shield' }, target: 2 }, +]; + export function planStarmless(state: GameState, leaderId: LeaderId): Order[] { const me = state.leaders[leaderId]; if (!me || !me.alive) return []; - const orders: Order[] = []; let budget = me.ap; - const LAUNCH_COST = 2; - const PROPAGANDA_COST = 1; - const FACTORY_COST = 3; - - // --- Living non-self leaders --- const others = state.cast.filter((t) => t !== leaderId && state.leaders[t]?.alive); - - // --- Identify attackers --- const attackers = others.filter((t) => wasAttackedBy(state, leaderId, t)); const isRetaliationRound = attackers.length > 0; // --- Determine launch target --- let launchTarget: LeaderId | undefined; - if (isRetaliationRound && others.length > 0) { - // Identify the primary attacker (highest recentAggressionFrom value, tie-break by grudge). + if (isRetaliationRound) { + // Primary attacker = highest recentAggressionFrom. let primaryAttacker: LeaderId = attackers[0]; let bestAggression = me.recentAggressionFrom[primaryAttacker] ?? 0; for (const a of attackers) { @@ -48,117 +52,60 @@ export function planStarmless(state: GameState, leaderId: LeaderId): Order[] { primaryAttacker = a; } } - - // Scapegoat roll: read rngState without advancing the shared state. + // Scapegoat roll (reads rngState without advancing shared state). const roll = nextRandom(state.rngState).value; const doScapegoat = roll < AI_SCORING_WEIGHTS.starmlessScapegoatPct; - if (doScapegoat) { - // Pick candidate with highest aggregate threat (sum of threatScore from all leaders toward them). - // Candidates: alive, not self, NOT the primary attacker. const candidates = others.filter((t) => t !== primaryAttacker); if (candidates.length > 0) { - function aggregateThreat(c: LeaderId): number { - return state.cast.reduce( - (sum, l) => sum + threatScore(state, l, c), - 0, - ); - } + const aggregateThreat = (c: LeaderId): number => + state.cast.reduce((sum, l) => sum + threatScore(state, l, c), 0); launchTarget = candidates.reduce((best, t) => aggregateThreat(t) >= aggregateThreat(best) ? t : best, ); } else { - // No other candidate — fall back to primary attacker. launchTarget = primaryAttacker; } } else { - // Normal retaliation: target the primary attacker. launchTarget = primaryAttacker; } + } else { + // New P4c.2 kill instinct: finish off a low-population opponent. + const finishable = others + .filter((t) => state.leaders[t].population <= STARMLESS_FINISH_POP_M) + .sort((a, b) => state.leaders[a].population - state.leaders[b].population); + if (finishable.length > 0) launchTarget = finishable[0]; } - const canLaunch = - launchTarget !== undefined && - me.stockpile.missiles >= 1 && - me.stockpile.warheadsSmall >= 1 && - budget >= LAUNCH_COST; - - // --- Reserve AP: launch first, then propaganda at attackers --- - const launchReserve = canLaunch ? LAUNCH_COST : 0; - const propagandaSlots = Math.min( - attackers.length, - Math.max(0, budget - launchReserve), - ); - const totalReserve = launchReserve + propagandaSlots * PROPAGANDA_COST; - let buildBudget = budget - totalReserve; - - // --- 1. Build orders: factory bias in non-retaliation; warheads otherwise --- - let remaining = buildBudget; + const rankedTargets = launchTarget !== undefined ? [launchTarget] : []; - if (!isRetaliationRound) { - // Prefer factory (~60 % factory bias). Build factory if we can afford it, - // then fill remainder with warheads. - if (remaining >= FACTORY_COST) { - const o: Order = { kind: 'build-factory' }; - if (validateOrder(state, leaderId, o).ok) { - orders.push(o); - remaining -= FACTORY_COST; - } - } - } + // Reserve 1 AP per attacker for propaganda. + const propagandaReserve = Math.min(attackers.length, Math.max(0, budget - 2)); + const offenceBudget = budget - propagandaReserve; - // 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' }; - if (validateOrder(state, leaderId, o).ok) { - orders.push(o); - remaining -= 1; - } else { - break; - } - } + // Launch first (low cap), then build with the remainder. + const salvo = launchSalvo(state, leaderId, { + budget: offenceBudget, + rankedTargets, + maxLaunches: STARMLESS_MAX_LAUNCHES, + }); + const build = buildToward( + state, leaderId, STARMLESS_BUILD_PLAN, offenceBudget - salvo.apSpent, + ); + budget -= salvo.apSpent + build.apSpent; - budget -= buildBudget - remaining; + const orders: Order[] = [...build.orders, ...salvo.orders]; - // --- 2. Launch if a target was chosen --- - if (canLaunch && launchTarget !== undefined && budget >= LAUNCH_COST) { - const launch: Order = { - kind: 'launch', - target: launchTarget, - delivery: 'missile', - warhead: 'small', - targetType: 'people', - }; - if (validateOrder(state, leaderId, launch).ok) { - orders.push(launch); - budget -= apCostOf(launch); + // Deploy a shield if one is in stock and AP allows (deploy = commit). + if (me.stockpile.shields >= 1 && budget >= DEPLOY_COST) { + const deploy: Order = { kind: 'deploy-defence', type: 'shield' }; + if (validateOrder(state, leaderId, deploy).ok) { + orders.push(deploy); + budget -= DEPLOY_COST; } } - // --- 3. Propaganda only at attackers --- + // Propaganda only at attackers. for (const attacker of attackers) { if (budget < PROPAGANDA_COST) break; const prop: Order = { kind: 'propaganda', target: attacker }; diff --git a/tests/engine/ai/starmless.test.ts b/tests/engine/ai/starmless.test.ts index e1a4159..017258d 100644 --- a/tests/engine/ai/starmless.test.ts +++ b/tests/engine/ai/starmless.test.ts @@ -75,3 +75,28 @@ describe('Starmless (Cautious + Scapegoat)', () => { expect(props).toHaveLength(0); }); }); + +describe('Starmless kill instinct (P4c.2)', () => { + it('launches at a finishable low-population opponent with no prior attack', () => { + const s = initialState({ cast: ['starmless', 'carnage'], difficulty: 'normal', seed: 'sa1' }); + s.leaders.starmless.stockpile.missiles = 2; + s.leaders.starmless.stockpile.warheadsSmall = 2; + s.leaders.starmless.ap = 10; + s.leaders.carnage.population = 4; // finishable — below the finish threshold + // No grudge / aggression from carnage → not a retaliation round. + const orders = planStarmless(s, 'starmless'); + const launch = orders.find((o) => o.kind === 'launch'); + expect(launch).toBeDefined(); + expect(launch?.kind === 'launch' && launch.target).toBe('carnage'); + }); + + it('does not launch when no opponent is finishable and no retaliation is pending', () => { + const s = initialState({ cast: ['starmless', 'carnage'], difficulty: 'normal', seed: 'sa2' }); + s.leaders.starmless.stockpile.missiles = 2; + s.leaders.starmless.stockpile.warheadsSmall = 2; + s.leaders.starmless.ap = 10; + s.leaders.carnage.population = 25; // healthy — not finishable + const orders = planStarmless(s, 'starmless'); + expect(orders.some((o) => o.kind === 'launch')).toBe(false); + }); +}); From 5044ac5fe2df8c0f12dd239e836fcee31dfa4e80 Mon Sep 17 00:00:00 2001 From: gethi Date: Sun, 17 May 2026 13:40:07 +0100 Subject: [PATCH 09/10] =?UTF-8?q?engine:=20Chump=20rework=20=E2=80=94=20ca?= =?UTF-8?q?pped=20opportunistic=20salvo,=20defence=20preserved?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/engine/ai/chump.ts | 161 +++++++++++++--------------------- tests/engine/ai/chump.test.ts | 27 ++++++ 2 files changed, 86 insertions(+), 102 deletions(-) diff --git a/src/engine/ai/chump.ts b/src/engine/ai/chump.ts index deb6a7c..3520a6b 100644 --- a/src/engine/ai/chump.ts +++ b/src/engine/ai/chump.ts @@ -1,124 +1,81 @@ import type { GameState, LeaderId, Order } from '../types'; import { apCostOf, validateOrder } from '../orders'; import { defenceVisibilityScore, opportunismScore } from './scoring'; +import { buildToward, launchSalvo, type BuildPlanEntry } from './aggression'; /** - * Chump — Coward personality. + * Chump — Coward personality (P4c.2 rework). * - * Behavioural rules: - * 1. High build-defence + build-warhead bias. - * 2. Launch when target's defence is low (defenceVisibilityScore === 0) OR target - * is otherwise weak (opportunismScore > 0), BUT only if budget allows after - * reserving AP for propaganda. - * 3. Wooing-suppression: never launch at a leader who has wooed Chump - * (me.favourability[t] > 0). - * 4. Prefers Infra targeting (when target has factories > 2); only targets - * People when target can't rebuild. - * 5. Heavy propagandist: emits propaganda when AP allows. + * Defensive, but opportunistic: launches at weak / undefended targets under a + * low cap. Never launches at a leader who has wooed Chump. Heavy defence build + * + deploy and propaganda preserved. Prefers infra targeting when the target + * still has factories to lose. */ +const PROPAGANDA_COST = 1; +const DEPLOY_COST = 4; +const CHUMP_MAX_LAUNCHES = 2; + +// The missile entry is mandatory — a plan with warheads but no delivery +// vehicle would recreate the zero-fire bug. Defence stays first (coward). +const CHUMP_BUILD_PLAN: BuildPlanEntry[] = [ + { build: { item: 'defence', type: 'shield' }, target: 3 }, + { build: { item: 'missile' }, target: 2 }, + { build: { item: 'warhead', yield: 'small' }, target: 4 }, +]; + export function planChump(state: GameState, leaderId: LeaderId): Order[] { const me = state.leaders[leaderId]; if (!me || !me.alive) return []; - const orders: Order[] = []; let budget = me.ap; - const LAUNCH_COST = 2; - const PROPAGANDA_COST = 1; - - // --- Identify the other living leaders --- - const others = state.cast.filter((t) => t !== leaderId && state.leaders[t].alive); + const others = state.cast.filter((t) => t !== leaderId && state.leaders[t]?.alive); - // --- Find a weak, unwooed target for a potential launch --- + // Eligible launch targets: not protected by Chump's own favourability toward + // them (a leader who wooed Chump raises me.favourability[t] > 0). const eligible = others.filter((t) => (me.favourability[t] ?? 0) <= 0); - - const hasLaunchResources = - me.stockpile.missiles >= 1 && - me.stockpile.warheadsSmall >= 1 && - budget >= LAUNCH_COST; - - let weakTarget: LeaderId | undefined; - if (hasLaunchResources && eligible.length > 0) { - weakTarget = eligible.find( - (t) => opportunismScore(state, t) > 0 || defenceVisibilityScore(state, t) === 0, - ); - } - - const canLaunch = - weakTarget !== undefined && - hasLaunchResources; - - // Reserve AP for launch (if feasible) and for propaganda. - const launchReserve = canLaunch ? LAUNCH_COST : 0; - const propagandaReserve = others.length > 0 && budget >= launchReserve + PROPAGANDA_COST - ? PROPAGANDA_COST - : 0; - const buildBudget = budget - launchReserve - propagandaReserve; - - // --- 1. Build orders: defence first, then warheads --- - let remaining = buildBudget; - - // 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; - } - } - } - - // Build small warheads with leftover build budget. - const warheadCost = 1; - while (remaining >= warheadCost) { - const o: Order = { kind: 'build-warhead', yield: 'small' }; - if (validateOrder(state, leaderId, o).ok) { - orders.push(o); - remaining -= warheadCost; - } else { - break; - } - } - - budget -= buildBudget - remaining; // consume what was actually spent on builds - - // --- 2. Launch if conditions are met --- - if (canLaunch && weakTarget !== undefined && budget >= LAUNCH_COST) { - const t = state.leaders[weakTarget]; - const targetType: 'infra' | 'people' = t.factories > 2 ? 'infra' : 'people'; - const launch: Order = { - kind: 'launch', - target: weakTarget, - delivery: 'missile', - warhead: 'small', - targetType, - }; - if (validateOrder(state, leaderId, launch).ok) { - orders.push(launch); - budget -= apCostOf(launch); + // Weak targets: low defence OR otherwise vulnerable. Ranked weakest-first. + const weakTargets = eligible + .filter((t) => opportunismScore(state, t) > 0 || defenceVisibilityScore(state, t) === 0) + .sort((a, b) => opportunismScore(state, b) - opportunismScore(state, a)); + + // Infra targeting when the target still has factories to lose. + const targetTypeFor = (t: LeaderId): 'people' | 'infra' => + state.leaders[t].factories > 2 ? 'infra' : 'people'; + + // Reserve 1 AP for propaganda. + const propagandaReserve = others.length > 0 && budget >= 1 ? PROPAGANDA_COST : 0; + const offenceBudget = budget - propagandaReserve; + + // Launch first (low cap), then build with the remainder. + const salvo = launchSalvo(state, leaderId, { + budget: offenceBudget, + rankedTargets: weakTargets, + maxLaunches: CHUMP_MAX_LAUNCHES, + targetTypeFor, + }); + const build = buildToward( + state, leaderId, CHUMP_BUILD_PLAN, offenceBudget - salvo.apSpent, + ); + budget -= salvo.apSpent + build.apSpent; + + const orders: Order[] = [...build.orders, ...salvo.orders]; + + // Deploy a shield if one is in stock and AP allows. + if (me.stockpile.shields >= 1 && budget >= DEPLOY_COST) { + const deploy: Order = { kind: 'deploy-defence', type: 'shield' }; + if (validateOrder(state, leaderId, deploy).ok) { + orders.push(deploy); + budget -= DEPLOY_COST; } } - // --- 3. Propaganda — broadcast to first available target --- + // Propaganda — broadcast to the first available target. if (budget >= PROPAGANDA_COST && others.length > 0) { - const propTarget = others[0]; - const propOrder: Order = { kind: 'propaganda', target: propTarget }; - if (validateOrder(state, leaderId, propOrder).ok) { - orders.push(propOrder); - budget -= apCostOf(propOrder); + const prop: Order = { kind: 'propaganda', target: others[0] }; + if (validateOrder(state, leaderId, prop).ok) { + orders.push(prop); + budget -= apCostOf(prop); } } diff --git a/tests/engine/ai/chump.test.ts b/tests/engine/ai/chump.test.ts index 356cc18..5f1e240 100644 --- a/tests/engine/ai/chump.test.ts +++ b/tests/engine/ai/chump.test.ts @@ -61,3 +61,30 @@ describe('Chump (Coward)', () => { expect(orders.some((o) => o.kind === 'propaganda')).toBe(true); }); }); + +describe('Chump aggression (P4c.2)', () => { + it('fires a capped multi-launch salvo at a weak target', () => { + const s = initialState({ cast: ['chump', 'carnage'], difficulty: 'normal', seed: 'cha1' }); + s.leaders.chump.stockpile.missiles = 3; + s.leaders.chump.stockpile.warheadsSmall = 3; + s.leaders.chump.ap = 12; + s.leaders.carnage.population = 4; // weak — opportunismScore > 0 + s.leaders.carnage.factories = 0; + const orders = planChump(s, 'chump'); + const launches = orders.filter((o) => o.kind === 'launch'); + expect(launches.length).toBeGreaterThanOrEqual(1); + expect(launches.length).toBeLessThanOrEqual(2); // low cap + }); + + it('never launches at a leader who has wooed Chump', () => { + const s = initialState({ cast: ['chump', 'carnage'], difficulty: 'normal', seed: 'cha2' }); + s.leaders.chump.stockpile.missiles = 3; + s.leaders.chump.stockpile.warheadsSmall = 3; + s.leaders.chump.ap = 12; + s.leaders.carnage.population = 4; + s.leaders.carnage.factories = 0; + s.leaders.chump.favourability = { carnage: 5 }; // carnage wooed chump + const orders = planChump(s, 'chump'); + expect(orders.some((o) => o.kind === 'launch')).toBe(false); + }); +}); From b159201351685657ff750f836facf13c157e4ba8 Mon Sep 17 00:00:00 2001 From: gethi Date: Sun, 17 May 2026 13:52:31 +0100 Subject: [PATCH 10/10] test: AI-duel termination assertion; docs: P4c slice 2 status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Balance tuning (1 round): two planners caused a two-leader endgame stall. Netanyahoo's Chump-exception blocked all launches when Chump was the sole remaining opponent → added last-resort fallback (fires at Chump when no other target exists). Chump's build plan had shields before warheads, causing the deploy cycle to starve warhead production → reordered to missile → warhead → shield. Both planner test suites updated/extended to cover the new behaviour. Result: 40/40 seeds terminate within 32 rounds (cap=60). --- README.md | 6 ++- src/engine/ai/chump.ts | 8 +-- src/engine/ai/netanyahoo.ts | 7 ++- tests/engine/ai-duel.test.ts | 81 +++++++----------------------- tests/engine/ai/netanyahoo.test.ts | 22 ++++++-- 5 files changed, 52 insertions(+), 72 deletions(-) diff --git a/README.md b/README.md index 3b1d427..f91e735 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ What's in `src/engine/`: - Full action set (factories, missiles, bombers, S/M/L warheads, shields, AA, launches with people/infra targeting, propaganda, wooing). - Spec §3 phase order: defences → builds → propaganda → wooing → launches → final retaliations → status update. - Spec §6 overwhelm intercept curve. -- All four win conditions: survivor, pyrrhic, apocalypse, dominance. +- Win conditions: survivor, pyrrhic, apocalypse (elimination-only — dominance removed in P4c.2). - Per-leader AP bonus rules (Netanyahoo launch bonus; Mileigh-hem aggression bonus; Chump defence-waste hooked but inert until P2). - Determinism: same seed + same orders → identical events (property-tested across 25 seeds). @@ -176,3 +176,7 @@ What's NOT in this phase (deferred to P4c / P5): ## Phase 4c slice 1 status Phase 4c slice 1 (Bomber Reuse + Carnage Bias) ships two tightly-coupled changes: a bomber-reuse engine rule (a bomber that impacts returns to the attacker's stockpile; an intercepted bomber stays gone) and Carnage AI personality tuning (builds bombers instead of missiles, prefers bomber delivery when stock is available). Full AI scoring-weight tuning, Approach B/C lookahead upgrades, and threat-aware defence deployment per personality are deferred to slice 2+, which will use the new duel distribution as its balance baseline. Verification: `npm run test:run` (255 tests). + +## Phase 4c slice 2 status + +(AI aggression rework + elimination-only endings.) The six AI planners were reworked onto two shared helpers — `buildToward` (capped, priority-ordered builds) and `launchSalvo` (largest-yield-first multi-launch). This fixed two planners that structurally could not fire (Netanyahoo's runaway missile-build loop starved warhead production; Mileigh-hem had no build logic), gave Starmless a kill instinct against low-population opponents, and let the whole cast fire multi-launch salvos with a medium/large warhead ramp. The dominance win condition was removed: games now end only by survivor / apocalypse / pyrrhic. The AI-duel test asserts every seeded all-AI game terminates within 60 rounds. Verify: `npm run test:run`. diff --git a/src/engine/ai/chump.ts b/src/engine/ai/chump.ts index 3520a6b..fe7fa96 100644 --- a/src/engine/ai/chump.ts +++ b/src/engine/ai/chump.ts @@ -15,12 +15,14 @@ const PROPAGANDA_COST = 1; const DEPLOY_COST = 4; const CHUMP_MAX_LAUNCHES = 2; -// The missile entry is mandatory — a plan with warheads but no delivery -// vehicle would recreate the zero-fire bug. Defence stays first (coward). +// Warheads come before the second shield refill so the shield-deploy cycle +// cannot starve warhead production. Missile is first to ensure a delivery +// vehicle is always available (a plan with warheads but no delivery would +// recreate the zero-fire bug). const CHUMP_BUILD_PLAN: BuildPlanEntry[] = [ - { build: { item: 'defence', type: 'shield' }, target: 3 }, { build: { item: 'missile' }, target: 2 }, { build: { item: 'warhead', yield: 'small' }, target: 4 }, + { build: { item: 'defence', type: 'shield' }, target: 3 }, ]; export function planChump(state: GameState, leaderId: LeaderId): Order[] { diff --git a/src/engine/ai/netanyahoo.ts b/src/engine/ai/netanyahoo.ts index f3c3c9b..8c3d3e1 100644 --- a/src/engine/ai/netanyahoo.ts +++ b/src/engine/ai/netanyahoo.ts @@ -29,8 +29,11 @@ export function planNetanyahoo(state: GameState, leaderId: LeaderId): Order[] { const chumpAlive = state.cast.includes('chump') && state.leaders['chump']?.alive === true; const chumpProvoked = wasAttackedBy(state, leaderId, 'chump'); - // Launch candidates: exclude Chump unless he has attacked first. - const launchCandidates = others.filter((t) => t !== 'chump' || chumpProvoked); + // Launch candidates: exclude Chump unless he has attacked first — OR unless + // Chump is the only remaining opponent (deadlock prevention: Netanyahoo must + // be able to finish the game even if Chump never fires first). + const preferredCandidates = others.filter((t) => t !== 'chump' || chumpProvoked); + const launchCandidates = preferredCandidates.length > 0 ? preferredCandidates : others; // Rank by threat, highest first. const rankedTargets = [...launchCandidates].sort( (a, b) => threatScore(state, leaderId, b) - threatScore(state, leaderId, a), diff --git a/tests/engine/ai-duel.test.ts b/tests/engine/ai-duel.test.ts index a24c448..eabf605 100644 --- a/tests/engine/ai-duel.test.ts +++ b/tests/engine/ai-duel.test.ts @@ -6,14 +6,16 @@ import type { LeaderId, WinType } from '../../src/engine/types'; const FULL_CAST: LeaderId[] = ['chump', 'khameneverhere', 'starmless', 'carnage', 'mileigh-hem', 'netanyahoo']; -function runOneGame(seed: string, maxRounds = 100): { winner: LeaderId | null; type: WinType | null; rounds: number } { - let s = initialState({ - cast: FULL_CAST, - difficulty: 'normal', - seed, - }); +// Round cap is a TEST TRIPWIRE, not a game rule. With the P4c.2 aggression +// rework + elimination-only endings, all-AI games terminate by elimination / +// apocalypse / pyrrhic. If this assertion ever fires, that is a balance bug to +// fix — not a cap to add to the game. +const ROUND_CAP = 60; + +function runOneGame(seed: string): { type: WinType | null; rounds: number } { + let s = initialState({ cast: FULL_CAST, difficulty: 'normal', seed }); let rounds = 0; - while (!s.outcome && rounds < maxRounds) { + while (!s.outcome && rounds < ROUND_CAP) { for (const id of FULL_CAST) { const orders = planAi(s, id); s = reduce(s, { type: 'SUBMIT_ORDERS', leaderId: id, orders }); @@ -21,66 +23,21 @@ function runOneGame(seed: string, maxRounds = 100): { winner: LeaderId | null; t s = reduce(s, { type: 'RESOLVE_ROUND' }); rounds++; } - return { - winner: s.outcome?.type === 'apocalypse' ? null : s.outcome?.winner ?? null, - type: s.outcome?.type ?? null, - rounds, - }; + return { type: s.outcome?.type ?? null, rounds }; } -describe('AI-duel headless', () => { - // P2 ships the duel infrastructure WITHOUT balance assertions. - // - // The first run of this test (commit during P2 Task 12) showed: - // chump 17 / khameneverhere 0 / starmless 0 / carnage 6 / mileigh-hem 0 / netanyahoo 39 / unfinished 38 - // - // That distribution reflects two known P2 imbalances: - // - Mutual shield-saturation in 6-leader games leads to ~38% stalemates - // within 100 rounds (P1's shield-stalemate; raising maxRounds to 300 - // does not help — the equilibrium is stable). - // - Reactive AIs (Khameneverhere/Starmless/Mileigh-hem) require an - // attacker to bootstrap; in a passive-AI grid they never launch. - // - // Per the plan's documented assumption, the AI scoring weights are first-pass - // numbers; full balance tuning is deferred to P4. This test therefore asserts - // 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. - // - // P4c slice 1 shifts the distribution again: bomber reuse (engine rule) means - // successful bomber launches return the bomber to stock, and Carnage now builds - // and launches bombers preferentially. Slice 2+ treats the post-slice-1 - // distribution as its new baseline for balance assertions and further tuning. - 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. - const wins: Record = { - chump: 0, khameneverhere: 0, starmless: 0, - carnage: 0, 'mileigh-hem': 0, netanyahoo: 0, - player1: 0, player2: 0, player3: 0, player4: 0, player5: 0, - NOBODY: 0, - }; +describe('AI-duel headless (P4c.2)', () => { + it('every seeded all-AI game terminates within the round cap', () => { + const SEEDS = 40; let unfinished = 0; - for (let i = 0; i < 100; i++) { + let maxRounds = 0; + for (let i = 0; i < SEEDS; i++) { const r = runOneGame(`duel-${i}`); - if (r.winner) wins[r.winner]++; - else if (r.type === 'apocalypse') wins.NOBODY++; - else unfinished++; + if (r.type === null) unfinished++; + if (r.rounds > maxRounds) maxRounds = r.rounds; } - - // Print distribution for human review (P4 balance pass uses this). // eslint-disable-next-line no-console - console.table({ wins, unfinished }); - - // Sanity: 100 games started, 100 outcomes counted (no engine crash mid-game). - const totalCounted = - wins.chump + wins.khameneverhere + wins.starmless + wins.carnage + - wins['mileigh-hem'] + wins.netanyahoo + wins.NOBODY + unfinished; - expect(totalCounted).toBe(100); + console.log(`AI-duel: ${SEEDS} games, max rounds = ${maxRounds}, unfinished = ${unfinished}`); + expect(unfinished).toBe(0); }, 60_000); }); diff --git a/tests/engine/ai/netanyahoo.test.ts b/tests/engine/ai/netanyahoo.test.ts index 000a53b..7959a2d 100644 --- a/tests/engine/ai/netanyahoo.test.ts +++ b/tests/engine/ai/netanyahoo.test.ts @@ -3,14 +3,28 @@ import { planNetanyahoo } from '../../../src/engine/ai/netanyahoo'; import { initialState } from '../../../src/engine/state'; describe('Netanyahoo (Warmonger)', () => { - it('does not launch at Chump until Chump has attacked first', () => { - const s = initialState({ cast: ['netanyahoo', 'chump'], difficulty: 'normal', seed: 'n1' }); + it('does not launch at Chump when other targets are available and Chump is unprovoked', () => { + // Chump-exception: avoid Chump when other targets exist and Chump hasn't attacked. + const s = initialState({ cast: ['netanyahoo', 'chump', 'carnage'], difficulty: 'normal', seed: 'n1' }); s.leaders.netanyahoo.stockpile.missiles = 1; s.leaders.netanyahoo.stockpile.warheadsSmall = 1; - // No grudge / aggression from chump → Chump-exception fires. + // No grudge / aggression from chump → Chump-exception fires; carnage is the preferred target. const orders = planNetanyahoo(s, 'netanyahoo'); const launch = orders.find((o) => o.kind === 'launch'); - expect(launch).toBeUndefined(); + expect(launch).toBeDefined(); // a launch must actually happen — not a vacuous pass + expect(launch?.target).toBe('carnage'); // the only non-Chump target → Chump-exception held + }); + + it('launches at Chump as a last resort when Chump is the only remaining opponent', () => { + // Deadlock prevention: when all other opponents are eliminated, Netanyahoo + // must be able to finish the game even if Chump never fired first. + const s = initialState({ cast: ['netanyahoo', 'chump'], difficulty: 'normal', seed: 'n1-lastresort' }); + s.leaders.netanyahoo.stockpile.missiles = 1; + s.leaders.netanyahoo.stockpile.warheadsSmall = 1; + // No grudge — but Chump is the only target, so the fallback fires. + const orders = planNetanyahoo(s, 'netanyahoo'); + const launch = orders.find((o) => o.kind === 'launch'); + expect(launch?.target).toBe('chump'); }); it('launches at Chump once Chump has attacked', () => {