Files
Dune/tests/planetologist-battle.test.ts

498 lines
15 KiB
TypeScript

import assert from 'node:assert/strict';
import test, { mock } from 'node:test';
import { baseDeck, leaders, type Card } from '../game/cards';
import { richeseCards } from '../game/richese-cards';
import {
canUsePlanetologistBattleSpecial,
type BattleLeaderSkill,
} from '../game/leader-skill-combat';
import {
BattleResolutionQuoteError,
quoteBattleResolution,
type BattleResolutionInput,
type ResolutionCombatant,
} from '../game/battle-resolution-quote';
import {
applyAction,
createGame,
initializeLeaderSkillsGameForAudit,
joinGame,
newPlayer,
viewGame,
type Action,
type Game,
} from '../game/engine';
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
function special(effect = 'atomics') {
const card = baseDeck().find((candidate) => candidate.effect === effect);
assert.ok(card);
return card;
}
const assignment = (
leader: string,
faceUp = false,
captured = false,
): BattleLeaderSkill => ({
skill: 'planetologist',
leader,
faceUp,
captured,
});
void test('Richese green identities cannot be forged or borrowed by weapon-special and defense components', () => {
const green = richeseCards().find(card => card.effect === 'ornithopter')!;
const eligible = (card: Card) => canUsePlanetologistBattleSpecial({
assignments: [assignment('leader')],
selectedLeader: 'leader',
card,
});
assert.equal(eligible(green), true);
for (const forged of [
{ ...green, id: 'forged-green-special' },
{ ...green, name: 'Stone Burner' },
{ ...green, kind: 'projectile' as const },
{ ...green, effect: 'stoneBurner' },
]) assert.equal(eligible(forged), false);
for (const effect of ['stoneBurner', 'mirrorWeapon', 'portableSnooper'])
assert.equal(eligible(richeseCards().find(card => card.effect === effect)!), false);
assert.equal(eligible(baseDeck().find(card => card.kind === 'hero')!), false);
});
void test('only the actual concealed or captured assigned leader may use the physical substitute', () => {
const card = special();
assert.equal(
canUsePlanetologistBattleSpecial({
assignments: [assignment('leader')],
selectedLeader: 'leader',
card,
}),
true,
);
assert.equal(
canUsePlanetologistBattleSpecial({
assignments: [assignment('leader', false, true)],
selectedLeader: 'leader',
card,
}),
true,
);
for (const input of [
{
assignments: [assignment('leader')],
selectedLeader: 'other',
card,
},
{
assignments: [assignment('leader', true)],
selectedLeader: 'leader',
card,
},
{
assignments: [
{
...assignment('leader'),
skill: 'warmaster' as const,
},
],
selectedLeader: 'leader',
card,
},
])
assert.equal(canUsePlanetologistBattleSpecial(input), false);
});
function battle(): BattleResolutionInput {
const side = (
id: string,
faction: 'emperor' | 'guild',
): ResolutionCombatant => {
const leader = { ...leaders(faction)[0] };
return {
id,
faction,
spice: 10,
hand: [],
leader,
plan: {
dial: id === 'a' ? 1 : 0,
support: 0,
leader: leader.id,
weapon: null,
defense: null,
},
forces: {
normal: 5,
elite: 0,
eliteStrength: 2,
freeSupport: false,
},
};
};
return {
advanced: false,
turn: 2,
territory: 'arrakeen',
attacker: side('a', 'emperor'),
defender: side('d', 'guild'),
voters: [
{ id: 'a', beneficiary: 'a', called: false, traitors: [] },
{ id: 'd', beneficiary: 'd', called: false, traitors: [] },
],
participants: [
{ id: 'a', faction: 'emperor' },
{ id: 'd', faction: 'guild' },
],
physicalCards: baseDeck(),
pendingAuditorPresent: false,
pendingRetentionPresent: false,
};
}
function playPlanetologist(input: BattleResolutionInput, card = special()) {
input.attacker.hand = [card];
input.attacker.plan.weapon = card.id;
input.attacker.leaderSkills = [assignment(input.attacker.plan.leader!)];
return card;
}
void test('a surviving skilled disc gains two without activating the Special and discards it after winning', () => {
const input = battle();
const card = playPlanetologist(input);
const before = structuredClone(input);
const quote = quoteBattleResolution(input);
assert.deepEqual(quote.leaderSkillBonuses.attacker, {
bonus: 2,
applied: [{ skill: 'planetologist', amount: 2, mode: 'skilled' }],
});
assert.equal(quote.effects?.defenderDead, false);
assert.equal(quote.winner, 'a');
assert.deepEqual(quote.discarded, [{ player: 'a', card: card.id }]);
assert.deepEqual(quote.winnerCards, []);
assert.deepEqual(input, before);
});
void test('leader death suppresses the two-point band but never releases the revealed Special', () => {
const input = battle();
const card = playPlanetologist(input, richeseCards().find(card => card.effect === 'residualPoison')!);
input.physicalCards = [...input.physicalCards, card];
const poison = baseDeck().find((candidate) => candidate.kind === 'poison')!;
input.defender.hand = [poison];
input.defender.plan.weapon = poison.id;
const quote = quoteBattleResolution(input);
assert.equal(quote.leaderDeaths.attacker, true);
assert.equal(quote.leaderSkillBonuses.attacker.bonus, 0);
assert.ok(
quote.discarded.some(
(entry) => entry.player === 'a' && entry.card === card.id,
),
);
});
void test('the later discard instruction also binds a successful traitor caller', () => {
const input = battle();
const card = playPlanetologist(input, special('hajr'));
input.voters = [
{
id: 'a',
beneficiary: 'a',
called: true,
traitors: [input.defender.plan.leader!],
},
input.voters[1],
];
const quote = quoteBattleResolution(input);
assert.equal(quote.result, 'traitor');
assert.equal(quote.winner, 'a');
assert.deepEqual(quote.discarded, [{ player: 'a', card: card.id }]);
assert.deepEqual(quote.winnerCards, []);
});
void test('Moritani cleanup cannot retain or reserve the mandatory Planetologist discard', () => {
const input = battle();
const card = playPlanetologist(input, special('truthtrance'));
const worthless = baseDeck().find(
(candidate) => candidate.kind === 'worthless',
)!;
const poison = baseDeck().find(
(candidate) => candidate.kind === 'poison',
)!;
input.attacker.ally = 'm';
input.attacker.hand = [card, worthless];
input.attacker.plan.defense = worthless.id;
input.defender.hand = [poison];
input.defender.plan.weapon = poison.id;
input.participants = [
...input.participants,
{ id: 'm', faction: 'moritani', ally: 'a' },
];
const quote = quoteBattleResolution(input);
assert.equal(quote.winner, 'd');
assert.deepEqual(quote.retention?.played, [worthless.id]);
assert.deepEqual(quote.retention?.eligible, [worthless.id]);
assert.ok(
quote.discarded.some(
(entry) => entry.player === 'a' && entry.card === card.id,
),
);
});
void test('quote validation rejects the Special in another role or with another leader without mutation', () => {
for (const mutate of [
(input: BattleResolutionInput, card: Card) => {
input.attacker.plan.weapon = null;
input.attacker.plan.defense = card.id;
},
(input: BattleResolutionInput) => {
input.attacker.leaderSkills = [assignment('some-other-leader')];
},
(input: BattleResolutionInput) => {
input.attacker.leaderSkills = [assignment(input.attacker.plan.leader!, true)];
},
(input: BattleResolutionInput, card: Card) => {
input.attacker.hand = [{ ...card, name: 'Forged Special' }];
},
(input: BattleResolutionInput) => {
input.attacker.leader!.dead = true;
},
(input: BattleResolutionInput) => {
delete input.attacker.leader;
},
]) {
const input = battle();
const card = playPlanetologist(input);
mutate(input, card);
const before = structuredClone(input);
assert.throws(
() => quoteBattleResolution(input),
BattleResolutionQuoteError,
);
assert.deepEqual(input, before);
}
});
function finishSetup(state: Game) {
let game = state;
for (let step = 0; game.status === 'setup' && step < 10; step += 1) {
const owner = game.players.find((player) => player.traitorChoices.length);
assert.ok(owner, `setup stalled at ${game.setupStage}`);
game = applyAction(game, owner.id, {
type: 'traitor',
leader: owner.traitorChoices[0],
});
}
assert.equal(game.status, 'playing');
return game;
}
function engineBattle(hide: boolean) {
let game = createGame('PLANETBATTLE', newPlayer('a', 'A', 'emperor'));
joinGame(game, newPlayer('d', 'D', 'guild'));
for (const player of game.players)
game = applyAction(game, player.id, { type: 'ready' });
const targetIndex = LEADER_SKILL_CARDS.findIndex(
(card) => card.id === 'planetologist',
);
let shuffleIndex = LEADER_SKILL_CARDS.length - 1;
mock.method(globalThis.crypto, 'getRandomValues', (array: Uint32Array) => {
array[0] = shuffleIndex-- === targetIndex ? 0 : 0xffffffff;
return array;
});
try {
game = initializeLeaderSkillsGameForAudit(game);
} finally {
mock.restoreAll();
}
const attackerOffer = game.leaderSkills!.offers.a;
assert.equal(attackerOffer.cards[0], 'planetologist');
game = applyAction(game, 'a', {
type: 'leaderSkill',
event: attackerOffer.event,
skill: 'planetologist',
leader: 'emperor-0',
});
const defenderOffer = game.leaderSkills!.offers.d;
game = applyAction(game, 'd', {
type: 'leaderSkill',
event: defenderOffer.event,
skill: defenderOffer.cards[0],
leader: 'guild-0',
});
game = finishSetup(game);
for (const player of game.players) {
game.deck.push(...player.hand);
player.hand = [];
player.forces = { 'arrakeen:10': 5 };
player.reserves = 15;
}
const index = game.deck.findIndex((card) => card.effect === 'atomics');
assert.ok(index >= 0);
const [card] = game.deck.splice(index, 1);
game.players[0].hand.push(card);
Object.assign(game, {
phase: 6,
storm: 18,
order: ['a', 'd'],
active: 'a',
ready: [],
decision: null,
response: null,
phaseOpening: null,
});
game = applyAction(game, 'a', {
type: 'chooseBattle',
territory: 'arrakeen',
target: 'd',
});
while (game.decision?.kind === 'leaderSkillVisibility') {
const decision = game.decision;
game = applyAction(game, decision.player, {
type: 'leaderSkillVisibility',
event: decision.event,
hide: decision.player === 'a' ? hide : false,
});
}
while (game.battle?.preparation)
game = applyAction(game, game.battle.preparation.owner, {
type: 'declineBattlePower',
});
return { game, card };
}
function unchanged(game: Game, player: string, action: Action, error: RegExp) {
const before = structuredClone(game);
assert.throws(() => applyAction(game, player, action), error);
assert.deepEqual(game, before);
}
void test('the real engine accepts only a concealed Planetologist plan and preserves actual-Weapon Voice', () => {
const publicSkill = engineBattle(false);
unchanged(
publicSkill.game,
'a',
{
type: 'battlePlan',
dial: 0,
leader: 'emperor-0',
weapon: publicSkill.card.id,
},
/behind the shield/,
);
const { game, card } = engineBattle(true);
unchanged(
game,
'a',
{
type: 'battlePlan',
dial: 0,
leader: 'emperor-1',
weapon: card.id,
},
/eligible Planetologist Special/,
);
const poisonIndex = game.deck.findIndex(
(candidate) => candidate.kind === 'poison',
);
assert.ok(poisonIndex >= 0);
const [poison] = game.deck.splice(poisonIndex, 1);
game.players[0].hand.push(poison);
game.battle!.voice = { target: 'a', kind: 'poison', must: true };
unchanged(
game,
'a',
{
type: 'battlePlan',
dial: 0,
leader: 'emperor-0',
weapon: card.id,
},
/comply with the Voice/,
);
delete game.battle!.voice;
const planned = applyAction(game, 'a', {
type: 'battlePlan',
dial: 0,
leader: 'emperor-0',
weapon: card.id,
});
assert.equal(planned.battle!.plans.a.weapon, card.id);
assert.equal(planned.storm, game.storm, 'Family Atomics utility did not fire');
});
void test('all four bot profiles offer a legal physical Planetologist battle plan', () => {
const { game, card } = engineBattle(true);
for (const difficulty of DIFFICULTIES) {
const view = viewGame(game, 'a');
view.players.find((player) => player.id === 'a')!.bot = difficulty;
const plans = botActions(view).filter(
(action) =>
action.type === 'battlePlan' &&
action.leader === 'emperor-0' &&
action.weapon === card.id,
);
assert.ok(plans.length, `${difficulty} omitted the physical Special`);
assert.doesNotThrow(
() => applyAction(game, 'a', plans[0]),
`${difficulty} emitted an invalid Planetologist plan`,
);
}
});
void test('the real engine resolves and discards a winning Planetologist Special exactly once', () => {
const { game: battleState, card } = engineBattle(true);
let game = applyAction(battleState, 'a', {
type: 'battlePlan',
dial: 0,
leader: 'emperor-0',
weapon: card.id,
});
game = applyAction(game, 'd', {
type: 'battlePlan',
dial: 0,
leader: 'guild-1',
});
game = applyAction(game, 'a', { type: 'traitorCall', call: false });
game = applyAction(game, 'd', { type: 'traitorCall', call: false });
assert.equal(
game.discard.filter((candidate) => candidate.id === card.id).length,
1,
);
assert.equal(
game.players[0].hand.some((candidate) => candidate.id === card.id),
false,
);
assert.equal(game.pendingWinnerDiscards, null);
assert.equal(game.lastBattleContext?.winnerDiscards?.completed, true);
});
void test('a skilled disc that dies between traitor votes cannot supply a stale Planetologist plan', () => {
const { game: battleState, card } = engineBattle(true);
let game = applyAction(battleState, 'a', {
type: 'battlePlan',
dial: 0,
leader: 'emperor-0',
weapon: card.id,
});
game = applyAction(game, 'd', {
type: 'battlePlan',
dial: 0,
leader: 'guild-1',
});
game = applyAction(game, 'a', { type: 'traitorCall', call: false });
const skilled = game.players[0].leaders.find(
(leader) => leader.id === 'emperor-0',
)!;
skilled.dead = true;
skilled.deaths += 1;
unchanged(
game,
'd',
{ type: 'traitorCall', call: false },
/dead or missing leader/,
);
});