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

227 lines
7.2 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction,
initializeBaseGameForAudit,
createGame,
newPlayer,
joinGame,
viewGame,
type Game,
} from '../game/engine';
import { botActions, runBots } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { FACTIONS } from '../game/catalog';
import { TECH_TOKENS } from '../game/tech-tokens';
function table(advanced = false) {
const g = createGame(
'BOTTEST2',
newPlayer('host', 'Host', 'atreides'),
advanced,
);
for (const [i, f] of FACTIONS.filter((f) => f.expansion === 'base')
.slice(1)
.entries()) {
const p = newPlayer(`bot${i}`, f.name, f.id);
p.bot = DIFFICULTIES[i % 4];
joinGame(g, p);
}
return g;
}
function invariant(g: Game) {
for (const p of g.players) {
assert.equal(
p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0),
20,
p.faction,
);
assert.ok(p.spice >= 0, p.faction);
const temporary =
g.decision?.kind === 'handExchange' && g.decision.player === p.id
? g.decision.count
: 0;
assert.ok(
p.hand.length <= (p.faction === 'harkonnen' ? 8 : 4) + temporary,
p.faction,
);
}
}
void test('only hosts manage AI seats with valid factions and difficulty', () => {
let g = createGame('BOTTEST2', newPlayer('host', 'Host', 'atreides'));
joinGame(g, newPlayer('human', 'Guest', 'fremen'));
assert.throws(
() =>
applyAction(g, 'human', {
type: 'addBot',
faction: 'emperor',
difficulty: 'Easy',
}),
/host/,
);
assert.throws(
() =>
applyAction(g, 'host', {
type: 'addBot',
faction: 'unknown',
difficulty: 'Easy',
}),
/Unknown faction/,
);
assert.throws(
() =>
applyAction(g, 'host', {
type: 'addBot',
faction: 'emperor',
difficulty: 'Cheat',
}),
/difficulty/,
);
assert.throws(
() => applyAction(g, 'host', { type: 'removeBot', target: 'human' }),
/AI seat/,
);
g = applyAction(g, 'host', {
type: 'addBot',
faction: 'emperor',
difficulty: 'Brutal',
});
assert.equal(g.players[2].bot, 'Brutal');
assert.equal(g.players[2].ready, true);
g = applyAction(g, 'host', { type: 'removeBot', target: g.players[2].id });
assert.equal(g.players.length, 2);
});
void test('bot progression does not mutate its input and stops for humans', () => {
const g = table();
const original = structuredClone(g);
const next = runBots(g);
assert.deepEqual(g, original);
assert.equal(next.players[0].ready, false);
assert.ok(next.players.slice(1).every((p) => p.ready));
assert.equal(next.botsPending, false);
});
void test('four AI policies complete games through legal actions with conserved forces', () => {
for (const difficulty of DIFFICULTIES) {
let g = table();
g.players.forEach((p) => {
p.bot = difficulty;
p.ready = true;
});
g = applyAction(g, 'host', { type: 'start' });
for (let batch = 0; batch < 60 && g.status !== 'finished'; batch++) {
g = runBots(g);
invariant(g);
assert.ok(
g.botsPending || g.status === 'finished',
`Stalled ${difficulty}: ${JSON.stringify({ turn: g.turn, phase: g.phase, decision: g.decision, response: g.response, active: g.active, battle: g.battle, ready: g.ready })}`,
);
}
assert.equal(g.status, 'finished', `${difficulty} exceeded action budget`);
}
});
void test('bot choices are unchanged by opponents hidden hands and decks', () => {
let g = table();
g.players.forEach((p) => {
p.bot = 'Hard';
p.ready = true;
});
g = applyAction(g, 'host', { type: 'start' });
const before = botActions(viewGame(g, 'bot0'));
g.players.find((p) => p.id === 'host')!.hand = [];
g.deck.reverse();
assert.deepEqual(botActions(viewGame(g, 'bot0')), before);
});
void test('four AI policies finish tech-token games while conserving forces and public token ownership', () => {
for (const difficulty of DIFFICULTIES) {
let g = applyAction(table(), 'host', { type: 'techTokens', enabled: true });
g.players.forEach((p) => {
p.bot = difficulty;
p.ready = true;
});
g = applyAction(g, 'host', { type: 'start' });
for (let batch = 0; batch < 80 && g.status !== 'finished'; batch++) {
g = runBots(g);
invariant(g);
assert.ok(
g.botsPending || g.status === 'finished',
`Tech game stalled for ${difficulty}: ${JSON.stringify({ phase: g.phase, decision: g.decision, response: g.response })}`,
);
for (const token of TECH_TOKENS) {
const state = g.techTokens![token.id];
assert.ok(g.players.some((p) => p.id === state.owner));
assert.ok(state.spice >= 0 && state.spice <= 3);
if (g.phase !== token.phase) assert.equal(state.spice, 0);
}
}
assert.equal(
g.status,
'finished',
`${difficulty} exceeded tech-game budget`,
);
}
});
void test('all four AI profiles complete Advanced base scenarios from the shared staged initializer without stalling', () => {
for (const difficulty of DIFFICULTIES) {
let g = table(true);
g.players.forEach((p) => {
p.bot = difficulty;
p.ready = true;
});
// The public gate remains closed. Exercise genuine Advanced setup through
// the same staged initializer before testing the implemented game systems.
g = initializeBaseGameForAudit(g);
for (let batch = 0; batch < 100 && g.status !== 'finished'; batch++) {
g = runBots(g);
invariant(g);
for (const p of g.players)
if (p.elites) {
assert.equal(
p.elites.reserves +
p.elites.tanks +
Object.values(p.elites.forces).reduce((a, b) => a + b, 0),
p.faction === 'fremen' ? 3 : 5,
);
assert.ok(p.elites.revived <= 1);
assert.ok(p.elites.reserves <= p.reserves);
assert.ok(p.elites.tanks <= p.tanks);
for (const [key, n] of Object.entries(p.elites.forces))
assert.ok(n <= p.forces[key]);
}
assert.ok(
g.botsPending || g.status === 'finished',
`Advanced subsystem stall ${difficulty}: ${JSON.stringify({ turn: g.turn, phase: g.phase, decision: g.decision, response: g.response, active: g.active, battle: g.battle })}`,
);
}
assert.equal(g.status, 'finished');
}
});
void test('AI finishes games containing Sandtrout while preserving its set-aside card through the phase machine', () => {
for (const difficulty of ['Hard', 'Brutal'] as const) {
let g = table();
g.players.forEach((p) => {
p.bot = difficulty;
p.ready = true;
});
g = applyAction(g, 'host', { type: 'start' });
g.spiceDeck.unshift({ sandtrout: true });
for (let batch = 0; batch < 80 && g.status !== 'finished'; batch++) {
g = runBots(g);
invariant(g);
assert.ok(
g.botsPending || g.status === 'finished',
`Sandtrout stalled ${difficulty}`,
);
const troutCount =
Number(!!g.sandtrout) +
[...g.spiceDeck, ...g.spiceDiscard.flat()].filter(
(c) => 'sandtrout' in c,
).length;
assert.equal(troutCount, 1);
}
assert.equal(g.status, 'finished');
}
});