Files
Dune/tests/bot-advisor-setup.test.ts
2026-09-08 07:37:52 +10:00

185 lines
6.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 } from '../game/cards';
import { MOBILE_STRONGHOLD, TERRITORIES, territory } from '../game/board';
import { isAdvisor } from '../game/advisors';
function fixture(advanced = true, withFremen = true, bgFirst = true): Game {
const g = createGame(
'ADVISORSETUPBOT',
newPlayer('b', 'Sisterhood', 'beneGesserit'),
advanced,
);
g.players.push(
newPlayer('o', 'Other', withFremen ? 'fremen' : 'atreides'),
newPlayer('h', 'Harkonnen', 'harkonnen'),
);
if (!bgFirst) g.players.reverse();
Object.assign(g, {
status: 'setup',
phase: 0,
turn: 1,
storm: 10,
order: g.players.map((p) => p.id),
});
for (const p of g.players)
Object.assign(p, {
hand: [],
forces: {},
reserves: 20,
spice: 0,
traitors: [],
traitorChoices: [],
});
if (!advanced) {
const bg = g.players.find((p) => p.id === 'b')!;
bg.reserves = 19;
bg.forces = { 'polar_sink:0': 1 };
}
return g;
}
function actions(g: Game, difficulty: Difficulty, id = 'b') {
const view = viewGame(g, id);
view.players.find((p) => p.id === id)!.bot = difficulty;
const before = structuredClone(view);
const result = botActions(view);
assert.deepEqual(view, before);
for (const action of result)
assert.doesNotThrow(
() => applyAction(g, id, action),
`${difficulty}: ${JSON.stringify(action)}`,
);
return result;
}
void test('all profiles complete own traitor and prediction choices while waiting for human or AI Fremen placement', () => {
for (const difficulty of DIFFICULTIES)
for (const bgFirst of [false, true])
for (const fremenBot of [false, true]) {
let g = fixture(true, true, bgFirst);
const bg = g.players.find((p) => p.id === 'b')!;
if (fremenBot) g.players.find((p) => p.id === 'o')!.bot = 'Medium';
bg.traitorChoices = ['fremen-0', 'harkonnen-0'];
const traitor = actions(g, difficulty);
assert.equal(traitor[0].type, 'traitor');
g = applyAction(g, 'b', traitor[0]);
const prediction = actions(g, difficulty);
assert.equal(prediction[0].type, 'predict');
assert.ok(prediction.every((a) => a.type !== 'advisorSetup'));
g = applyAction(g, 'b', prediction[0]);
assert.deepEqual(
actions(g, difficulty),
[],
'Only the real Fremen placement is pending for this bot.',
);
assert.equal(g.players.find((p) => p.id === 'b')!.reserves, 20);
const fremen = actions(g, difficulty, 'o');
assert.equal(
fremen[0].type,
'fremenSetup',
'BG waiting does not block the Fremen actor.',
);
g = applyAction(g, 'o', fremen[0]);
assert.equal(g.players.find((p) => p.id === 'o')!.reserves, 10);
assert.ok(
actions(g, difficulty).every((a) => a.type === 'advisorSetup'),
);
}
});
void test('Advanced advisor setup offers every printed sector including storm and two-fighter strongholds, but never mobile stronghold', () => {
for (const difficulty of DIFFICULTIES) {
const g = fixture(true, false);
g.players[1].forces = { 'arrakeen:10': 2 };
g.players[1].reserves = 18;
g.players[2].forces = { 'arrakeen:10': 2 };
g.players[2].reserves = 18;
g.mobileStronghold = { location: 'red_chasm:7' };
const choices = actions(g, difficulty);
assert.ok(
choices.every(
(a) => a.type === 'advisorSetup' && a.territory !== MOBILE_STRONGHOLD,
),
);
assert.deepEqual(
new Set(choices.map((a) => `${String(a.territory)}:${Number(a.sector)}`)),
new Set(TERRITORIES.flatMap((t) => t.sectors.map((s) => `${t.id}:${s}`))),
);
const occupied = choices.find((a) => a.territory === 'arrakeen')!;
assert.equal(occupied.sector, g.storm);
const joined = applyAction(g, 'b', occupied);
assert.equal(joined.players[0].reserves, 19);
assert.equal(joined.players[0].forces['arrakeen:10'], 1);
assert.equal(isAdvisor(joined.players[0], 'arrakeen'), true);
const empty = applyAction(
g,
'b',
choices.find((a) => a.territory === 'polar_sink')!,
);
assert.equal(
isAdvisor(empty.players[0], 'polar_sink'),
false,
'An uncontested starting force becomes a fighter automatically.',
);
}
});
void test('Basic BG has no advisor placement and finished or inconsistent reserve custody cannot place a second starting force', () => {
for (const difficulty of DIFFICULTIES) {
let basic = fixture(false);
const prediction = actions(basic, difficulty);
assert.deepEqual(
prediction.map((a) => a.type),
['predict'],
);
basic = applyAction(basic, 'b', prediction[0]);
assert.deepEqual(actions(basic, difficulty), []);
let done = fixture(true, false);
done = applyAction(done, 'b', actions(done, difficulty)[0]);
assert.ok(done.players[0].advisorSetup);
const next = actions(done, difficulty);
assert.deepEqual(
next.map((a) => a.type),
['predict'],
);
done = applyAction(done, 'b', next[0]);
assert.deepEqual(actions(done, difficulty), []);
const inconsistent = fixture(true, false);
inconsistent.players[0].reserves = 19;
assert.deepEqual(
actions(inconsistent, difficulty).map((a) => a.type),
['predict'],
);
}
});
void test('advisor setup waiting and destinations depend on public placement, never enemy cards, balances or human versus bot status', () => {
for (const difficulty of DIFFICULTIES)
for (const placed of [false, true]) {
const g = fixture();
g.players[0].prediction = { faction: 'fremen', turn: 4 };
if (placed) {
g.players[1].reserves = 10;
g.players[1].forces = {
[`sietch_tabr:${territory('sietch_tabr').sectors[0]}`]: 10,
};
}
const baseline = actions(g, difficulty);
const changed = structuredClone(g);
changed.players[1].spice = 1000;
changed.players[1].hand = baseDeck().slice(0, 4);
changed.players[1].bot = 'Brutal';
changed.players[1].traitors = ['beneGesserit-0'];
assert.deepEqual(actions(changed, difficulty), baseline);
}
});