Skip to content

Commit 41b085b

Browse files
emp3thyclaude
andcommitted
fix: address Claude BugBot findings on PR #3
Two medium bugs caught: 1. Weighted FR target selection boundary (finalRetaliation.ts): cumulative >= threshold incorrectly picked zero-weight survivors when RNG returned exactly 0.0. Changed to strict > so zero-weight targets are never chosen. Regression test added pinning weights=[0, 100] always picks survivor[1]. 2. Circular ES module dependency between lookahead.ts and index.ts. Refactored: - Created dispatch.ts (bare leaderId switch); imports per-leader files only. - lookahead.ts no longer imports planAi; bestTargetByLookahead accepts an opponentPlanner callback. - chump.ts removed its Hard-mode branch; per-leader files are pure baseline planners ignorant of difficulty + lookahead. - index.ts (planAi) orchestrates Hard mode at the top: dispatch for baseline, bestTargetByLookahead with dispatch as the opponent planner to retarget any launch order in Hard difficulty. Import graph is now acyclic. Hard-mode behaviour preserved (the Hard-Chump integration test still pins target selection through the projected score). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 9dc9aa6 commit 41b085b

7 files changed

Lines changed: 94 additions & 42 deletions

File tree

src/engine/ai/chump.ts

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import type { GameState, LeaderId, Order } from '../types';
22
import { apCostOf, validateOrder } from '../orders';
33
import { defenceVisibilityScore, opportunismScore } from './scoring';
4-
import { bestTargetByLookahead } from './lookahead';
54

65
/**
76
* Chump — Coward personality.
@@ -40,19 +39,9 @@ export function planChump(state: GameState, leaderId: LeaderId): Order[] {
4039

4140
let weakTarget: LeaderId | undefined;
4241
if (hasLaunchResources && eligible.length > 0) {
43-
if (state.difficulty === 'hard') {
44-
// Hard mode: use 1-ply lookahead to pick the best target among candidates.
45-
// Candidates are all unwooed opponents; K is implicitly bounded by cast size.
46-
const baselineOrders: Order[] = []; // builds come after launch decision
47-
const lookaheadTarget = bestTargetByLookahead(state, leaderId, baselineOrders, eligible, {
48-
delivery: 'missile', warhead: 'small', targetType: 'people',
49-
});
50-
weakTarget = lookaheadTarget ?? undefined;
51-
} else {
52-
weakTarget = eligible.find(
53-
(t) => opportunismScore(state, t) > 0 || defenceVisibilityScore(state, t) === 0,
54-
);
55-
}
42+
weakTarget = eligible.find(
43+
(t) => opportunismScore(state, t) > 0 || defenceVisibilityScore(state, t) === 0,
44+
);
5645
}
5746

5847
const canLaunch =

src/engine/ai/dispatch.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import type { GameState, LeaderId, Order } from '../types';
2+
import { planChump } from './chump';
3+
import { planCarnage } from './carnage';
4+
import { planKhameneverhere } from './khameneverhere';
5+
import { planNetanyahoo } from './netanyahoo';
6+
import { planStarmless } from './starmless';
7+
import { planMileighHem } from './mileighhem';
8+
9+
/**
10+
* Route a leader to their per-personality baseline planner.
11+
* Per-leader files are pure baseline planners: they know nothing about
12+
* difficulty levels or lookahead. Hard-mode orchestration lives in planAi
13+
* (index.ts), which calls this function then post-processes for lookahead.
14+
*/
15+
export function dispatch(state: GameState, leaderId: LeaderId): Order[] {
16+
switch (leaderId) {
17+
case 'chump': return planChump(state, leaderId);
18+
case 'carnage': return planCarnage(state, leaderId);
19+
case 'khameneverhere': return planKhameneverhere(state, leaderId);
20+
case 'netanyahoo': return planNetanyahoo(state, leaderId);
21+
case 'starmless': return planStarmless(state, leaderId);
22+
case 'mileigh-hem': return planMileighHem(state, leaderId);
23+
}
24+
}

src/engine/ai/index.ts

Lines changed: 33 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,8 @@
11
import type { Difficulty, GameState, LeaderId, Order } from '../types';
22
import { apCostOf, validateOrder } from '../orders';
33
import { nextRandom } from '../rng';
4-
import { planChump } from './chump';
5-
import { planCarnage } from './carnage';
6-
import { planKhameneverhere } from './khameneverhere';
7-
import { planNetanyahoo } from './netanyahoo';
8-
import { planStarmless } from './starmless';
9-
import { planMileighHem } from './mileighhem';
4+
import { dispatch } from './dispatch';
5+
import { bestTargetByLookahead } from './lookahead';
106

117
const DIFFICULTY_RANDOM_PCT: Record<Difficulty, number> = {
128
easy: 0.3,
@@ -19,11 +15,39 @@ export function planAi(state: GameState, leaderId: LeaderId, difficulty?: Diffic
1915
const me = state.leaders[leaderId];
2016
if (!me || !me.alive) return [];
2117

22-
// Hard-mode lookahead is implemented inside per-leader files via
23-
// bestTargetByLookahead (see Task 11). The dispatcher itself just routes by
24-
// leaderId and applies the Easy/Normal randomization wrapper.
18+
// Get the per-leader baseline orders (personality-specific, difficulty-agnostic).
2519
let orders = dispatch(state, leaderId);
2620

21+
// Hard mode: replace the target of any launch order with the lookahead-optimal target.
22+
// dispatch is used as the opponent planner so opponents never recurse into Hard.
23+
if (diff === 'hard') {
24+
const launchIndex = orders.findIndex((o) => o.kind === 'launch');
25+
if (launchIndex !== -1) {
26+
const launch = orders[launchIndex];
27+
if (launch.kind === 'launch') {
28+
const others = state.cast.filter((id) => id !== leaderId && state.leaders[id]?.alive);
29+
if (others.length > 0) {
30+
const baseline = orders.filter((_, i) => i !== launchIndex);
31+
const bestTarget = bestTargetByLookahead(
32+
state,
33+
leaderId,
34+
baseline,
35+
others,
36+
{ delivery: launch.delivery, warhead: launch.warhead, targetType: launch.targetType },
37+
dispatch,
38+
);
39+
if (bestTarget !== null) {
40+
orders = [
41+
...baseline.slice(0, launchIndex),
42+
{ ...launch, target: bestTarget },
43+
...baseline.slice(launchIndex),
44+
];
45+
}
46+
}
47+
}
48+
}
49+
}
50+
2751
// Easy / Normal randomization: replace each order with probability difficulty-pct.
2852
if (DIFFICULTY_RANDOM_PCT[diff] > 0) {
2953
orders = applyRandomization(state, leaderId, orders, DIFFICULTY_RANDOM_PCT[diff]);
@@ -32,17 +56,6 @@ export function planAi(state: GameState, leaderId: LeaderId, difficulty?: Diffic
3256
return orders;
3357
}
3458

35-
function dispatch(state: GameState, leaderId: LeaderId): Order[] {
36-
switch (leaderId) {
37-
case 'chump': return planChump(state, leaderId);
38-
case 'carnage': return planCarnage(state, leaderId);
39-
case 'khameneverhere': return planKhameneverhere(state, leaderId);
40-
case 'netanyahoo': return planNetanyahoo(state, leaderId);
41-
case 'starmless': return planStarmless(state, leaderId);
42-
case 'mileigh-hem': return planMileighHem(state, leaderId);
43-
}
44-
}
45-
4659
function applyRandomization(
4760
state: GameState,
4861
leaderId: LeaderId,

src/engine/ai/lookahead.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import type { DeliveryType, GameState, LeaderId, Order, TargetType, Yield } from '../types';
22
import { reduce } from '../reducer';
3-
import { planAi } from './index';
43

54
export interface LookaheadLaunchSpec {
65
delivery: DeliveryType;
@@ -64,8 +63,11 @@ export function scoreState(state: GameState, viewer: LeaderId): number {
6463

6564
/**
6665
* Pick the candidate launch target whose projected post-round state scores
67-
* highest from `viewer`'s perspective. Opponents are simulated at NORMAL
68-
* difficulty (no recursion into Hard).
66+
* highest from `viewer`'s perspective.
67+
*
68+
* `opponentPlanner` is called for each living opponent to produce their orders
69+
* for the simulated round. Callers should pass a planner that does NOT recurse
70+
* into Hard mode (e.g. `dispatch` from dispatch.ts) to avoid Hard→Hard loops.
6971
*
7072
* `baseline` are non-launch orders (builds, propaganda, etc.) the viewer is
7173
* already committed to this round; `launch` describes the launch shape that
@@ -79,6 +81,7 @@ export function bestTargetByLookahead(
7981
baseline: Order[],
8082
candidates: readonly LeaderId[],
8183
launch: LookaheadLaunchSpec,
84+
opponentPlanner: (state: GameState, leaderId: LeaderId) => Order[],
8285
): LeaderId | null {
8386
if (candidates.length === 0) return null;
8487

@@ -97,8 +100,7 @@ export function bestTargetByLookahead(
97100
if (id === viewer) continue;
98101
const opp = state.leaders[id];
99102
if (!opp || !opp.alive) continue;
100-
// Force NORMAL difficulty for opponent simulation to avoid Hard→Hard recursion.
101-
ordersByLeader[id] = planAi(state, id, 'normal');
103+
ordersByLeader[id] = opponentPlanner(state, id);
102104
}
103105
const projected = simulateOneRound(state, ordersByLeader);
104106
const score = scoreState(projected, viewer);

src/engine/finalRetaliation.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ export function applyFinalRetaliation(
7474
target = survivors[0];
7575
for (let i = 0; i < survivors.length; i++) {
7676
cumulative += weights[i];
77-
if (cumulative >= threshold) {
77+
if (cumulative > threshold) {
7878
target = survivors[i];
7979
break;
8080
}

tests/engine/ai/lookahead.test.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, it, expect } from 'vitest';
22
import { simulateOneRound, scoreState, bestTargetByLookahead } from '../../../src/engine/ai/lookahead';
3+
import { dispatch } from '../../../src/engine/ai/dispatch';
34
import { initialState } from '../../../src/engine/state';
45
import type { Order } from '../../../src/engine/types';
56

@@ -58,15 +59,15 @@ describe('bestTargetByLookahead', () => {
5859
const candidates: ['carnage', 'starmless'] = ['carnage', 'starmless'];
5960
const best = bestTargetByLookahead(s, 'chump', baseline, candidates, {
6061
delivery: 'missile', warhead: 'large', targetType: 'people',
61-
});
62+
}, dispatch);
6263
expect(best).toBe('carnage');
6364
});
6465

6566
it('returns null when candidates is empty', () => {
6667
const s = initialState({ cast: ['chump', 'carnage'], difficulty: 'hard', seed: 'lh6' });
6768
const best = bestTargetByLookahead(s, 'chump', [], [], {
6869
delivery: 'missile', warhead: 'small', targetType: 'people',
69-
});
70+
}, dispatch);
7071
expect(best).toBeNull();
7172
});
7273
});

tests/engine/finalRetaliation.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,29 @@ describe('applyFinalRetaliation', () => {
7878
expect(launchedAtChump).toBe(0);
7979
});
8080

81+
it('never picks a zero-weight survivor (boundary: strict > not >= in cumulative draw)', () => {
82+
// weights=[0, 100]: chump grudge=0, starmless grudge=100.
83+
// Regardless of seed, the weighted draw must NEVER pick chump.
84+
// This is the regression for the cumulative >= threshold boundary bug:
85+
// when RNG returns 0.0, threshold=0 and cumulative=0, so >= would pick
86+
// the first survivor (chump, weight=0) incorrectly.
87+
for (let i = 0; i < 30; i++) {
88+
const s = initialState({ cast: ['chump', 'carnage', 'starmless'], difficulty: 'normal', seed: `fr-boundary-${i}` });
89+
s.leaders.carnage.stockpile.missiles = 4;
90+
s.leaders.carnage.stockpile.warheadsSmall = 4;
91+
s.leaders.carnage.alive = false;
92+
s.leaders.carnage.population = 0;
93+
s.leaders.carnage.grudge = { chump: 0, starmless: 100 };
94+
s.leaders.chump.stockpile.shields = 0;
95+
s.leaders.starmless.stockpile.shields = 0;
96+
const r = applyFinalRetaliation(s, ['carnage']);
97+
const launchedAtChump = r.events.filter(
98+
(e) => e.kind === 'MissileLaunched' && e.to === 'chump',
99+
).length;
100+
expect(launchedAtChump).toBe(0);
101+
}
102+
});
103+
81104
it('falls back to uniform random when grudge is empty (preserves P1 behaviour)', () => {
82105
const s = initialState({ cast: ['chump', 'carnage', 'starmless'], difficulty: 'normal', seed: 'fr-uniform' });
83106
s.leaders.carnage.stockpile.missiles = 8;

0 commit comments

Comments
 (0)