Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down Expand Up @@ -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`.
158 changes: 158 additions & 0 deletions src/engine/ai/aggression.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import type { GameState, LeaderId, Order, Yield } from '../types';
import { apCostOf, validateOrder } from '../orders';
import { warheadFieldFor } from '../launches';
import { ACTION_COSTS } from '../balance';

// --- 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 };
}

// --- 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<Yield, number> = {
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 };
}
117 changes: 34 additions & 83 deletions src/engine/ai/carnage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand All @@ -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 };
Expand Down
Loading
Loading