227 lines
7.2 KiB
TypeScript
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');
|
|
}
|
|
});
|