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

340 lines
13 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import test from 'node:test';
import assert from 'node:assert/strict';
import { baseDeck, ixBattleCards, type Card } from '../game/cards';
import {
applyAction,
createGame,
joinGame,
newPlayer,
viewGame,
type Game,
type Action,
} from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
const all = [...baseDeck(), ...ixBattleCards()];
const card = (kind: Card['kind']) => all.find((c) => c.kind === kind)!;
/** Assign fixture cards by transferring their exact physical IDs from the deck. */
function setHand(g: Game, index: number, selected: Card[]) {
const owner = g.players[index];
const ids = new Set(selected.map((c) => c.id));
assert.equal(ids.size, selected.length);
const held = new Map(owner.hand.map((c) => [c.id, c]));
for (const previous of owner.hand)
if (!ids.has(previous.id)) g.deck.push(previous);
owner.hand = selected.map((wanted) => {
if (held.has(wanted.id)) return held.get(wanted.id)!;
assert.ok(
!g.players.some(
(p, i) => i !== index && p.hand.some((c) => c.id === wanted.id),
),
`Another player owns ${wanted.id}`,
);
const at = g.deck.findIndex((c) => c.id === wanted.id);
assert.ok(at >= 0, `Fixture deck must contain ${wanted.id}`);
return g.deck.splice(at, 1)[0];
});
}
function fixture() {
let g = createGame('IXCARDS2', newPlayer('a', 'Atreides', 'atreides'));
for (const [id, f] of [
['g', 'guild'],
['e', 'emperor'],
['b', 'beneGesserit'],
] as const)
joinGame(g, newPlayer(id, f, f));
g.players.forEach((p) => (p.ready = true));
g = applyAction(g, 'a', { type: 'start' });
g = applyAction(g, 'b', { type: 'predict', faction: 'atreides', turn: 5 });
for (const p of g.players)
if (p.traitorChoices.length)
g = applyAction(g, p.id, {
type: 'traitor',
leader: p.traitorChoices[0],
});
assert.equal(g.status, 'playing');
g.phase = 6;
g.active = 'a';
g.order = ['a', 'g', 'e', 'b'];
g.storm = 18;
// Return dealt cards before replacing setup hands for this battle fixture.
g.deck.push(...g.players.flatMap((p) => p.hand));
g.deck.push(...ixBattleCards());
g.players.forEach((p) => {
p.forces = ['a', 'g'].includes(p.id) ? { 'arrakeen:10': 5 } : {};
p.reserves = Object.keys(p.forces).length ? 15 : 20;
p.hand = [];
p.traitors = [];
p.spice = 20;
});
return g;
}
function allow(state: Game) {
let g = state;
while (g.response)
g = applyAction(
g,
g.players.find((p) => !g.response!.passed.includes(p.id))!.id,
{ type: 'passResponse' },
);
return g;
}
const choose = (g: Game) =>
allow(
applyAction(g, 'a', {
type: 'chooseBattle',
territory: 'arrakeen',
target: 'g',
}),
);
function prepared(state: Game) {
let g = choose(state);
while (g.battle?.preparation)
g = applyAction(g, g.battle.preparation.owner, {
type: 'declineBattlePower',
});
return g;
}
function plan(g: Game, id: string, choices: Partial<Action> = {}) {
return applyAction(g, id, {
type: 'battlePlan',
dial: 0,
leader: id === 'a' ? 'atreides-0' : 'guild-0',
...choices,
});
}
function resolve(g: Game) {
g = applyAction(g, 'a', { type: 'traitorCall', call: false });
return applyAction(g, 'g', { type: 'traitorCall', call: false });
}
function battle(
weapon: Card['kind'],
ownDefense?: Card['kind'],
otherDefense?: Card['kind'],
ownDial = 0,
otherDial = 0,
otherWeapon?: Card['kind'],
) {
const before = fixture();
setHand(before, 0, [card(weapon), ...(ownDefense ? [card(ownDefense)] : [])]);
// When both sides use a Shield, they must use distinct printed copies.
const opposingDefense = otherDefense
? before.deck.find((c) => c.kind === otherDefense)!
: undefined;
setHand(before, 1, [
...(opposingDefense ? [opposingDefense] : []),
...(otherWeapon ? [card(otherWeapon)] : []),
]);
const g = plan(prepared(before), 'a', {
weapon: card(weapon).id,
defense: ownDefense ? card(ownDefense).id : null,
dial: ownDial,
});
return plan(g, 'g', {
defense: opposingDefense?.id ?? null,
weapon: otherWeapon ? card(otherWeapon).id : null,
dial: otherDial,
});
}
const tooth = (g: Game, activate: boolean) =>
applyAction(g, 'a', { type: 'decision', activate });
void test('Poison Tooth waits for revealed plans, an owner-only decision and then traitor choices, with no premature casualties', () => {
const before = fixture();
setHand(before, 0, [card('poisonTooth')]);
let g = plan(prepared(before), 'a', { weapon: card('poisonTooth').id });
assert.equal(g.decision, null);
assert.equal(viewGame(g, 'g').battle!.plans.a, undefined);
assert.throws(() => tooth(g, true));
g = plan(g, 'g');
assert.equal(g.decision?.kind, 'poisonTooth');
assert.equal(g.battle?.revealed, true);
assert.throws(
() => applyAction(g, 'g', { type: 'decision', activate: true }),
/pending decision/,
);
assert.throws(
() => applyAction(g, 'g', { type: 'traitorCall', call: false }),
/pending decision/,
);
assert.throws(
() => applyAction(g, 'a', { type: 'decision', activate: 'yes' }),
/activate Poison Tooth/,
);
g = tooth(JSON.parse(JSON.stringify(g)), true);
for (const p of g.players) assert.equal(p.leaders[0].dead, false);
for (const p of g.players)
assert.equal(viewGame(g, p.id).battle!.poisonTooth.a, true);
assert.equal(g.decision, null);
assert.throws(() => tooth(g, false));
g = resolve(g);
assert.equal(g.players[0].leaders[0].dead, true);
assert.equal(g.players[1].leaders[0].dead, true);
assert.equal(g.players[0].spice, 30);
assert.ok(g.discard.some((c) => c.kind === 'poisonTooth'));
});
void test('Chemistry protects either leader from the tooth, whereas Snooper and Shield Snooper do not', () => {
for (const defense of ['chemistry', 'snooper', 'shieldSnooper'] as const) {
let g = tooth(battle('poisonTooth', defense), true);
g = resolve(g);
assert.equal(g.players[0].leaders[0].dead, defense !== 'chemistry');
assert.equal(g.players[1].leaders[0].dead, true);
let other = tooth(battle('poisonTooth', undefined, defense), true);
other = resolve(other);
assert.equal(other.players[0].leaders[0].dead, true);
assert.equal(other.players[1].leaders[0].dead, defense !== 'chemistry');
}
});
void test('an unused tooth has no poison effect and may be retained only by the winner', () => {
let g = resolve(tooth(battle('poisonTooth'), false));
assert.equal(g.players[0].leaders[0].dead, false);
assert.equal(g.players[1].leaders[0].dead, false);
assert.equal(g.decision?.kind, 'battleCards');
g = applyAction(g, 'a', { type: 'decision', discard: [] });
assert.ok(g.players[0].hand.some((c) => c.kind === 'poisonTooth'));
const lost = resolve(
tooth(battle('poisonTooth', undefined, undefined, 0, 2), false),
);
assert.ok(lost.discard.some((c) => c.kind === 'poisonTooth'));
});
void test('activating a tooth never supplies poison defense to its user', () => {
const g = resolve(
tooth(battle('poisonTooth', undefined, 'chemistry', 0, 0, 'poison'), true),
);
assert.equal(g.players[0].leaders[0].dead, true);
assert.equal(g.players[1].leaders[0].dead, false);
assert.equal(g.players[0].forces['arrakeen:10'], undefined);
});
void test('Artillery kills unshielded leaders, suppresses all leader strength, grants no bounty and is discarded by a winner', () => {
const g = resolve(battle('artillery'));
assert.equal(g.players[0].leaders[0].dead, true);
assert.equal(g.players[1].leaders[0].dead, true);
assert.equal(g.players[0].spice, 20);
assert.equal(g.players[1].spice, 20);
assert.ok(g.discard.some((c) => c.kind === 'artillery'));
assert.equal(g.decision, null);
const shielded = resolve(battle('artillery', 'shield', undefined, 0, 1));
assert.equal(shielded.players[0].leaders[0].dead, false);
assert.equal(shielded.players[0].forces['arrakeen:10'], undefined);
assert.equal(shielded.players[1].forces['arrakeen:10'], 4);
});
void test('each side can shield against artillery; Weirding Way cannot protect from it', () => {
for (const defense of ['shield', 'shieldSnooper', 'weirdingWay'] as const) {
const g = resolve(
battle('artillery', 'shield', defense, 0, 0, 'worthless'),
);
assert.equal(g.players[0].leaders[0].dead, false);
assert.equal(g.players[1].leaders[0].dead, defense === 'weirdingWay');
assert.equal(g.players[0].spice, 20);
}
});
void test('artillery does not cancel an opposing poison weapon and suppresses bounty for that death too', () => {
const g = resolve(
battle('artillery', 'shield', 'shieldSnooper', 2, 0, 'poison'),
);
assert.equal(g.players[0].leaders[0].dead, true);
assert.equal(g.players[1].leaders[0].dead, false);
assert.equal(g.players[0].spice, 20);
assert.equal(g.players[0].forces['arrakeen:10'], 3);
});
void test('traitor wins override tooth/artillery effects and retain the winner’s otherwise single-use weapon', () => {
for (const weapon of ['poisonTooth', 'artillery'] as const) {
let g = battle(weapon);
g.players[0].traitors = ['guild-0'];
if (weapon === 'poisonTooth') g = tooth(g, true);
g = applyAction(g, 'a', { type: 'traitorCall', call: true });
g = applyAction(g, 'g', { type: 'traitorCall', call: false });
assert.equal(g.players[0].leaders[0].dead, false);
assert.equal(g.players[0].forces['arrakeen:10'], 5);
assert.equal(g.players[0].spice, 25);
assert.equal(g.decision?.kind, 'battleCards');
g = applyAction(g, 'a', { type: 'decision', discard: [] });
assert.ok(g.players[0].hand.some((c) => c.kind === weapon));
}
});
void test('lasgun/shield explosion overrides artillery and destroys bystanding armies and spice', () => {
let g = battle('artillery', 'shield', undefined, 0, 0, 'lasgun');
g.players[2].forces = { 'arrakeen:10': 2 };
g.players[2].reserves = 18;
g.spice['arrakeen:10'] = 8;
g = resolve(g);
assert.ok(g.players.every((p) => !p.forces['arrakeen:10']));
assert.equal(g.spice['arrakeen:10'], undefined);
assert.equal(g.players[0].spice, 20);
assert.equal(g.players[1].spice, 20);
});
void test('Voice addresses Tooth as poison or by name, while Artillery must be named specifically', () => {
for (const [weapon, kind, forbidden] of [
['poisonTooth', 'poison', true],
['poisonTooth', 'poisonTooth', true],
['artillery', 'artillery', true],
['artillery', 'projectile', false],
] as const) {
const before = fixture();
setHand(before, 1, [card(weapon)]);
before.players[0].ally = 'b';
before.players[3].ally = 'a';
let g = choose(before);
g = allow(applyAction(g, 'b', { type: 'voice', kind, must: false }));
g = applyAction(g, 'a', { type: 'declineBattlePower' });
if (forbidden)
assert.throws(() => plan(g, 'g', { weapon: card(weapon).id }), /Voice/);
else assert.ok(plan(g, 'g', { weapon: card(weapon).id }).battle?.plans.g);
}
});
void test('AI chooses legally at every difficulty and stronger policies preserve a tooth when already winning', () => {
for (const difficulty of DIFFICULTIES) {
const g = battle('poisonTooth');
g.players[0].bot = difficulty;
const action = botActions(viewGame(g, 'a'))[0];
assert.equal(action.type, 'decision');
const result = applyAction(g, 'a', action);
assert.equal(result.decision, null);
if (difficulty !== 'Easy') assert.equal(action.activate, false);
const losing = battle('poisonTooth', undefined, undefined, 2, 0);
// A weak Atreides leader loses without the tooth but wins by dial when it kills both leaders.
losing.battle!.plans.a.leader = 'atreides-4';
losing.players[0].bot = difficulty;
const choice = botActions(viewGame(losing, 'a'))[0];
if (difficulty !== 'Easy') assert.equal(choice.activate, true);
assert.equal(applyAction(losing, 'a', choice).decision, null);
}
});
void test('advanced artillery preserves spice costs and automatic casualties while suppressing a surviving leader and KH bonus', () => {
const before = fixture();
before.advanced = true;
before.players[0].specialKaramaUsed = true;
before.players[0].battleLosses = 7;
setHand(before, 0, [card('artillery'), card('shield')]);
// Keep another battle pending so phase-7 stronghold income does not obscure
// the support payments now that the sole casualty outcome settles immediately.
for (const player of before.players.slice(2)) {
player.forces = { 'carthag:11': 1 };
player.reserves = 19;
}
let g = prepared(before);
g = plan(g, 'a', {
weapon: card('artillery').id,
defense: card('shield').id,
dial: 1,
support: 1,
kwisatz: true,
});
g = plan(g, 'g', { dial: 2, support: 2 });
g = resolve(g);
assert.notEqual(g.decision?.kind, 'battleLosses');
assert.equal(g.phase, 6);
assert.equal(g.players[0].leaders[0].dead, false);
assert.equal(g.players[0].kwisatz?.dead, false);
assert.equal(g.players[0].kwisatz?.usedAt, 'arrakeen');
assert.equal(g.players[0].spice, 19);
assert.equal(g.players[1].spice, 18);
assert.equal(g.players[1].forces['arrakeen:10'], 3);
assert.equal(g.players[1].tanks, 2);
for (const p of g.players)
assert.equal(
p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0),
20,
);
});