Files
Dune/tests/stronghold-bots.test.ts
2026-09-08 07:37:52 +10:00

270 lines
9.4 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction,
createGame,
newPlayer,
viewGame,
type Game,
} from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES, type Difficulty } from '../game/bot-profiles';
import { baseDeck, type Card } from '../game/cards';
import { richeseCards } from '../game/richese-cards';
import {
createStrongholdCards,
type StrongholdId,
} from '../game/stronghold-cards';
import { MOBILE_STRONGHOLD, MOBILE_LOCATION, territory } from '../game/board';
const seat = (g: Game, id: string) => g.players.find((p) => p.id === id)!;
const location = (t: string) => `${t}:${territory(t).sectors[0]}`;
function fixture(t: StrongholdId, defender = false) {
const g = createGame('STRONGHOLDAI', newPlayer('p', 'Pilot', 'guild'), true);
g.players.push(newPlayer('q', 'Opponent', 'emperor'));
Object.assign(g, {
status: 'playing',
phase: 6,
turn: 2,
active: defender ? 'q' : 'p',
order: defender ? ['q', 'p'] : ['p', 'q'],
storm: 18,
deck: baseDeck(),
strongholdCards: createStrongholdCards(),
});
g.strongholdCards!.owners[t] = 'p';
g.strongholdCards!.claimedTurn = 1;
for (const p of g.players) {
p.spice = 20;
p.hand = [];
p.traitors = [];
p.traitorChoices = [];
p.forces = { [location(t)]: 6 };
p.reserves = 14;
}
if (t === MOBILE_STRONGHOLD) g.mobileStronghold = { location: 'red_chasm:7' };
return g;
}
function hold(g: Game, id: string, kind: Card['kind']) {
const index = g.deck.findIndex((c) => c.kind === kind);
assert.ok(index >= 0);
const card = g.deck.splice(index, 1)[0];
seat(g, id).hand.push(card);
return card;
}
function prepare(state: Game) {
let g = state;
for (let n = 0; n < 30; n++) {
if (g.decision?.kind === 'strongholdCopy') return g;
if (g.response) {
g = applyAction(
g,
g.players.find((p) => !g.response!.passed.includes(p.id))!.id,
{ type: 'passResponse' },
);
continue;
}
if (g.battle?.preLeader && !g.battle.preLeader.closed) {
const id = [g.battle.attacker, g.battle.defender].find(
(id) => !g.battle!.preLeader!.ready.includes(id),
);
if (id) {
g = applyAction(g, id, {
type: 'battlePreparationReady',
event: g.battle.event,
});
continue;
}
}
if (g.battle?.preparation) {
g = applyAction(g, g.battle.preparation.owner, {
type: 'declineBattlePower',
});
continue;
}
return g;
}
throw Error('Unfinished preparation');
}
const begin = (g: Game, t: string) =>
prepare(
applyAction(g, g.active!, {
type: 'chooseBattle',
territory: t,
target: g.active === 'p' ? 'q' : 'p',
}),
);
function proposals(g: Game, difficulty: Difficulty, id = 'p') {
const v = viewGame(g, id);
v.players.find((p) => p.id === id)!.bot = difficulty;
const snapshot = structuredClone(v),
actions = botActions(v);
assert.deepEqual(v, snapshot);
return actions;
}
function mobile() {
const g = fixture(MOBILE_STRONGHOLD);
seat(g, 'p').forces = {
[MOBILE_LOCATION]: 6,
'arrakeen:10': 1,
'carthag:11': 1,
[location('habbanya_ridge_sietch')]: 1,
};
seat(g, 'p').reserves = 11;
g.strongholdCards!.owners.arrakeen = 'q';
hold(g, 'q', 'shield');
return begin(g, MOBILE_STRONGHOLD);
}
function privateVariant(g: Game) {
const variant = structuredClone(g),
q = seat(variant, 'q');
assert.equal(q.hand.length, 1);
const replacement = variant.deck.findIndex((c) => c.kind === 'poison');
assert.ok(replacement >= 0);
const old = q.hand[0];
q.hand[0] = variant.deck.splice(replacement, 1)[0];
variant.deck.push(old);
q.spice = 9999;
q.traitors = ['guild-4'];
return variant;
}
void test('every AI level proposes only current event-bound mobile copies and every proposal is an authoritative legal action', () => {
for (const difficulty of DIFFICULTIES) {
const g = mobile(),
snapshot = structuredClone(g);
assert.equal(g.decision?.kind, 'strongholdCopy');
if (g.decision?.kind !== 'strongholdCopy')
throw Error('Missing copy decision');
const actions = proposals(g, difficulty);
assert.equal(actions.length, 3);
assert.deepEqual(
new Set(actions.map((a) => a.stronghold)),
new Set(g.decision.choices),
);
assert.deepEqual(proposals(g, difficulty, 'q'), []);
for (const action of actions) {
assert.equal(action.type, 'decision');
assert.equal(action.event, g.decision.event);
const next = applyAction(JSON.parse(JSON.stringify(g)), 'p', action);
assert.equal(next.battle!.strongholdCopy, action.stronghold);
assert.deepEqual(next.battle!.plans, {});
for (const p of next.players)
assert.equal(
viewGame(next, p.id).battle!.strongholdCopy,
action.stronghold,
);
assert.throws(() => applyAction(next, 'p', action));
assert.throws(() => applyAction(g, 'p', { ...action, event: 'stale' }));
}
assert.deepEqual(g, snapshot);
}
});
void test('all AI levels offer actual supported Arrakeen plans with no personal spice and no pledged funds', () => {
for (const difficulty of DIFFICULTIES)
for (const count of [4, 6, 10, 20]) {
const initial = fixture('arrakeen');
seat(initial, 'p').forces = { 'arrakeen:10': count };
seat(initial, 'p').reserves = 20 - count;
seat(initial, 'p').spice = 0;
hold(initial, 'p', 'shield');
hold(initial, 'q', 'shield');
const g = begin(initial, 'arrakeen'),
actions = proposals(g, difficulty);
assert.ok(actions.length);
assert.ok(
actions.some((a) => Number(a.support) > 0),
difficulty,
);
for (const action of actions) {
assert.equal(action.type, 'battlePlan');
assert.ok(Number(action.support ?? 0) <= 2);
const next = applyAction(g, 'p', action);
assert.equal(next.battle!.plans.p.support, action.support ?? 0);
assert.equal(seat(next, 'p').spice, 0);
assert.equal(viewGame(next, 'p').aid.available, 0);
}
// Commit a generated supported plan and settle the opponent's ordinary answer.
const supported = actions.find((a) => Number(a.support) > 0)!;
let next = applyAction(g, 'p', supported);
next = applyAction(next, 'q', {
type: 'battlePlan',
dial: 0,
support: 0,
leader: 'emperor-1',
});
next = applyAction(next, 'p', { type: 'traitorCall', call: false });
next = applyAction(next, 'q', { type: 'traitorCall', call: false });
assert.equal(seat(next, 'p').spice, 0);
assert.ok(
next.log.some(
(entry) => entry.automatic?.name === 'Stronghold support',
),
);
}
});
void test('Habbanya defender profiles emit legal plans and choose an event-bound Stone mode before winning the undialed tie', () => {
for (const difficulty of DIFFICULTIES) {
const initial = fixture('habbanya_ridge_sietch', true);
const stone = richeseCards().find((c) => c.effect === 'stoneBurner')!;
seat(initial, 'p').hand.push(stone);
initial.richeseCache = richeseCards().filter((c) => c.id !== stone.id);
const g = begin(initial, 'habbanya_ridge_sietch');
assert.equal(viewGame(g, 'p').battle!.tieWinner, 'p');
const plans = proposals(g, difficulty).filter(
(a) => a.type === 'battlePlan',
);
assert.ok(plans.length);
for (const action of plans)
assert.doesNotThrow(() => applyAction(g, 'p', action));
const stonePlan = plans.find((a) => a.weapon === stone.id && a.dial === 0)!;
assert.ok(stonePlan, difficulty);
let revealed = applyAction(g, 'p', stonePlan);
revealed = applyAction(revealed, 'q', {
type: 'battlePlan',
dial: 0,
support: 0,
leader: 'emperor-1',
});
assert.equal(revealed.decision?.kind, 'stoneBurner');
assert.deepEqual(proposals(revealed, difficulty, 'q'), []);
const choices = proposals(revealed, difficulty);
assert.equal(choices.length, 1);
const choice = choices[0];
assert.equal(choice.type, 'decision');
assert.equal(choice.event, revealed.battle!.event);
assert.ok(['kill', 'ignore'].includes(String(choice.mode)));
let next = applyAction(JSON.parse(JSON.stringify(revealed)), 'p', choice);
assert.throws(() => applyAction(next, 'p', choice));
next = applyAction(next, 'p', { type: 'traitorCall', call: false });
next = applyAction(next, 'q', { type: 'traitorCall', call: false });
assert.equal(seat(next, 'p').forces[location('habbanya_ridge_sietch')], 6);
assert.equal(
seat(next, 'q').forces[location('habbanya_ridge_sietch')],
undefined,
);
}
});
void test('foreign private hands, spice and traitors cannot change mobile-copy or supported-plan proposals at any level', () => {
const copy = mobile();
const initial = fixture('arrakeen');
seat(initial, 'p').spice = 0;
hold(initial, 'p', 'shield');
hold(initial, 'q', 'shield');
const planning = begin(initial, 'arrakeen');
for (const g of [copy, planning]) {
const variant = privateVariant(g);
assert.deepEqual(viewGame(variant, 'p'), viewGame(g, 'p'));
for (const difficulty of DIFFICULTIES) {
const actions = proposals(g, difficulty);
assert.deepEqual(proposals(variant, difficulty), actions);
for (const action of actions) {
assert.doesNotThrow(() => applyAction(g, 'p', action));
assert.doesNotThrow(() => applyAction(variant, 'p', action));
}
}
}
});