Files
Dune/tests/portable-snooper-bots.test.ts

308 lines
11 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction,
createGame,
newPlayer,
viewGame,
type Game,
type GameView,
} from '../game/engine';
import { baseDeck, ixBattleCards, type Card } from '../game/cards';
import { richeseCards } from '../game/richese-cards';
import { botActions } from '../game/bots';
import { DIFFICULTIES, type Difficulty } from '../game/bot-profiles';
const portable = () =>
richeseCards().find((c) => c.effect === 'portableSnooper')!;
const weapon = (kind: Card['kind']) =>
[...baseDeck(), ...ixBattleCards()].find((c) => c.kind === kind)!;
function fixture(kind: Card['kind'] = 'poison', reverse = false) {
const g = createGame('PORTABLEBOT', newPlayer('p', 'Pilot', 'emperor'));
g.players.push(newPlayer('q', 'Opponent', 'guild'));
g.status = 'playing';
g.phase = 6;
g.turn = 2;
g.active = reverse ? 'q' : 'p';
g.order = reverse ? ['q', 'p'] : ['p', 'q'];
g.storm = 18;
for (const p of g.players) {
p.hand = [];
p.forces = { 'arrakeen:10': 4 };
p.reserves = 16;
p.spice = 10;
p.traitors = [];
}
g.players[0].hand = [portable()];
g.players[1].hand = [weapon(kind)];
g.deck = g.deck.filter(
(c) => !g.players.some((p) => p.hand.some((h) => h.id === c.id)),
);
return applyAction(g, g.active, {
type: 'chooseBattle',
territory: 'arrakeen',
target: reverse ? 'p' : 'q',
});
}
function reveal(g: Game) {
for (const id of ['p', 'q'])
g = applyAction(g, id, {
type: 'battlePlan',
leader: id === 'p' ? 'emperor-0' : 'guild-0',
dial: id === 'p' ? 1 : 0,
weapon: id === 'q' ? g.players[1].hand[0].id : null,
});
return g;
}
function view(g: Game, difficulty: Difficulty, id = 'p') {
const v = viewGame(g, id);
v.players.find((p) => p.id === id)!.bot = difficulty;
return v;
}
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
function kwisatzHeroBattle(useKwisatz: boolean) {
let g = createGame('PORTABLEKH', newPlayer('p', 'Atreides', 'atreides'));
g.players.push(newPlayer('q', 'Opponent', 'guild'));
g.status = 'playing';
g.advanced = true;
g.phase = 6;
g.turn = 2;
g.active = 'p';
g.order = ['p', 'q'];
g.storm = 18;
for (const p of g.players) {
p.forces = { 'arrakeen:10': 4 };
p.reserves = 16;
p.spice = 10;
p.traitors = [];
}
const hero = weapon('hero');
g.players[0].battleLosses = 7;
g.players[0].hand = [hero, portable()];
g.players[1].hand = [weapon('poison')];
g.deck = g.deck.filter(
(c) => !g.players.some((p) => p.hand.some((h) => h.id === c.id)),
);
g = applyAction(g, 'p', {
type: 'chooseBattle',
territory: 'arrakeen',
target: 'q',
});
assert.equal(g.battle!.preparation?.kind, 'prescience');
g = applyAction(g, 'p', { type: 'declineBattlePower' });
if (g.decision?.kind === 'fullPlanOffer') {
g = applyAction(g, 'p', { type: 'decision', decline: true });
}
assert.equal(g.decision, null);
assert.equal(g.response, null);
assert.equal(g.battle!.preparation, undefined);
g = applyAction(g, 'p', {
type: 'battlePlan',
leader: hero.id,
kwisatz: useKwisatz,
dial: 1,
support: 1,
});
g = applyAction(g, 'q', {
type: 'battlePlan',
leader: 'guild-2',
dial: 0,
weapon: weapon('poison').id,
});
assert.equal(g.battle!.revealed, true);
return g;
}
void test('all profiles save a revealed Cheap Hero with KH to turn an Advanced defeat into victory, but spare the card for an ordinary hero', () => {
for (const difficulty of DIFFICULTIES) {
const g = kwisatzHeroBattle(true);
let without = applyAction(g, 'p', { type: 'traitorCall', call: false });
without = applyAction(without, 'q', { type: 'traitorCall', call: false });
assert.equal(without.players[0].forces['arrakeen:10'] ?? 0, 0);
assert.equal(without.players[1].forces['arrakeen:10'], 4);
const v = view(g, difficulty);
assert.equal(v.battle!.plans.p.kwisatz, true);
assert.equal(
v.battle!.cards.find((c) => c.id === v.battle!.plans.p.leader)!.kind,
'hero',
);
const action = botActions(v)[0];
assert.equal(action.type, 'portableSnooper');
let protectedGame = applyAction(g, 'p', action);
protectedGame = applyAction(
reload(protectedGame),
'p',
botActions(view(protectedGame, difficulty))[0],
);
protectedGame = applyAction(protectedGame, 'q', {
type: 'traitorCall',
call: false,
});
assert.equal(protectedGame.players[0].forces['arrakeen:10'], 3);
assert.equal(protectedGame.players[1].forces['arrakeen:10'] ?? 0, 0);
assert.equal(protectedGame.players[0].kwisatz?.dead, false);
assert.equal(protectedGame.players[0].spice, 9);
assert.equal(
protectedGame.discard.filter((c) => c.id === weapon('hero').id).length,
1,
);
assert.equal(
botActions(view(kwisatzHeroBattle(false), difficulty))[0].type,
'traitorCall',
);
}
});
void test('all profiles use eligible late poison protection on either side, preserve the original plan and proceed without replay', () => {
for (const difficulty of DIFFICULTIES)
for (const reverse of [false, true]) {
let g = reveal(fixture('poison', reverse));
const v = view(g, difficulty),
snapshot = structuredClone(v),
action = botActions(v)[0];
assert.equal(action.type, 'portableSnooper');
assert.equal(action.card, portable().id);
assert.equal(action.event, v.battle!.event);
assert.deepEqual(v, snapshot);
const plans = structuredClone(g.battle!.plans);
g = applyAction(g, 'p', action);
assert.deepEqual(g.battle!.plans, plans);
assert.equal(g.battle!.lateDefense!['p'], portable().id);
assert.throws(() => applyAction(g, 'p', action));
const next = botActions(view(reload(g), difficulty))[0];
assert.equal(next.type, 'traitorCall');
assert.equal(next.call, false);
g = applyAction(g, 'p', next);
g = applyAction(g, 'q', { type: 'traitorCall', call: false });
assert.equal(
g.players[0].leaders.find((l) => l.id === 'emperor-0')!.dead,
false,
);
assert.equal(g.players[0].forces['arrakeen:10'], 3);
assert.equal(
g.players[0].hand.filter((c) => c.id === portable().id).length +
g.discard.filter((c) => c.id === portable().id).length,
1,
);
}
});
void test('normal defense candidates include only the exact canonical Portable Snooper and remain authoritative legal plans', () => {
for (const difficulty of DIFFICULTIES) {
const g = fixture(),
v = view(g, difficulty);
const candidates = botActions(v).filter(
(a) => a.type === 'battlePlan' && a.defense === portable().id && a.leader,
);
assert.ok(candidates.length);
for (const candidate of candidates)
assert.doesNotThrow(() => applyAction(g, 'p', candidate));
const forged = view(g, difficulty);
forged.players[0].hand = [{ ...portable(), id: 'forged-portable' }];
assert.ok(botActions(forged).every((a) => a.defense !== 'forged-portable'));
}
});
void test('late defense uses public revealed threats without opponent hand inference and skips ineffectual protection', () => {
for (const difficulty of DIFFICULTIES) {
const g = reveal(fixture()),
v = view(g, difficulty),
action = botActions(v)[0];
v.players[1].hand = richeseCards();
v.players[1].spice = 999;
assert.deepEqual(botActions(v)[0], action);
for (const kind of [
'projectile',
'lasgun',
'poisonBlade',
'artillery',
] as const) {
const other = reveal(fixture(kind));
assert.ok(
botActions(view(other, difficulty)).every(
(a) => a.type !== 'portableSnooper',
),
);
}
// Build the authoritative traitor before revealing, so the server's
// projected call choices agree with the bot's actual held card.
const traitorGame = fixture();
traitorGame.players[0].traitors = ['guild-0'];
const revealedTraitor = reveal(traitorGame);
const traitor = view(revealedTraitor, difficulty);
assert.ok(traitor.battle!.traitorCallChoices?.includes(true));
const traitorAction = botActions(traitor)[0];
assert.deepEqual(traitorAction, {
type: 'traitorCall',
call: true,
});
assert.doesNotThrow(() => applyAction(revealedTraitor, 'p', traitorAction));
}
});
void test('blocked, stale and nonowner views never play the card and genuine interactions retain priority', () => {
for (const difficulty of DIFFICULTIES) {
const g = reveal(fixture());
for (const change of [
(v: GameView) => {
v.portableSnooper!.blocked = 'The revealed plan has no spare slot.';
},
(v: GameView) => {
v.portableSnooper!.event = 'old-battle';
},
(v: GameView) => {
v.portableSnooper = null;
},
]) {
const v = view(g, difficulty);
change(v);
assert.equal(botActions(v)[0].type, 'traitorCall');
}
assert.equal(viewGame(g, 'q').portableSnooper, null);
const truth = view(g, difficulty);
truth.truthtrance = {
stage: 'priority',
queue: [],
passed: [],
} as unknown as NonNullable<GameView['truthtrance']>;
assert.equal(botActions(truth)[0].type, 'truthPass');
const response = view(g, difficulty);
response.response = { kind: 'emperorIncome', owner: 'q', passed: [] };
assert.ok(botActions(response).every((a) => a.type !== 'portableSnooper'));
const decision = view(g, difficulty);
decision.decision = { kind: 'richeseDeclaration', player: 'q' };
assert.deepEqual(botActions(decision), []);
}
});
void test('Tooth evaluation includes an explicitly projected supplemental defense without rewriting the sealed defense slot', () => {
let g = fixture();
const tooth = weapon('poisonTooth');
g.players[0].hand.push(tooth);
g = applyAction(g, 'p', {
type: 'battlePlan',
leader: 'emperor-0',
dial: 1,
weapon: tooth.id,
});
g = applyAction(g, 'q', {
type: 'battlePlan',
leader: 'guild-0',
dial: 0,
weapon: weapon('poison').id,
});
assert.equal(g.decision?.kind, 'poisonTooth');
for (const difficulty of DIFFICULTIES.filter((d) => d !== 'Easy')) {
const without = view(g, difficulty);
assert.equal(botActions(without)[0].activate, true);
// A projection-level scoring regression: normal live timing resolves Tooth
// before late defense. Do not bypass that authoritative action ordering.
const withDefense = structuredClone(without);
withDefense.battle!.lateDefense['p'] = portable().id;
withDefense.battle!.cards.push(portable());
assert.equal(withDefense.battle!.plans['p'].defense, null);
assert.equal(botActions(withDefense)[0].activate, false);
}
});