Files
Dune/tests/reinforcements.test.ts

156 lines
7.9 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { baseDeck, leaders, type Card } from '../game/cards';
import { richeseCards } from '../game/richese-cards';
import { ecazTreacheryCards } from '../game/ecaz-cards';
import { BattleResolutionQuoteError, quoteBattleResolution, type BattleResolutionInput } from '../game/battle-resolution-quote';
import { isReinforcements, quoteReinforcements, ReinforcementsError, REINFORCEMENTS_CARD } from '../game/reinforcements';
const physicalCards = [...baseDeck(), ...richeseCards(), ...ecazTreacheryCards()];
const reinforcement = ecazTreacheryCards().find(c => c.id === REINFORCEMENTS_CARD)!;
function fixture(slot: 'weapon' | 'defense' = 'weapon'): BattleResolutionInput {
const attackerLeader = leaders('emperor')[0];
const defenderLeader = leaders('guild')[0];
return {
advanced: true, turn: 2, territory: 'arrakeen',
attacker: {
id: 'a', faction: 'emperor', hand: [reinforcement], spice: 10,
leader: attackerLeader,
plan: { dial: 2, support: 2, leader: attackerLeader.id, weapon: null, defense: null, [slot]: reinforcement.id },
forces: { normal: 5, elite: 0, eliteStrength: 2, freeSupport: false },
reinforcementsReserves: { normal: 1, elite: 2 },
},
defender: {
id: 'd', faction: 'guild', hand: [], spice: 10,
leader: defenderLeader,
plan: { dial: 2, support: 2, leader: defenderLeader.id, weapon: null, defense: null },
forces: { normal: 5, elite: 0, eliteStrength: 2, freeSupport: false },
},
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, pendingAuditorPresent: false, pendingRetentionPresent: false,
};
}
function putCard(g: BattleResolutionInput, side: 'attacker' | 'defender', slot: 'weapon' | 'defense', kind: Card['kind'] | 'stoneBurner') {
const card = physicalCards.find(c => (c.kind === kind || c.effect === kind) && !g.attacker.hand.some(h => h.id === c.id) && !g.defender.hand.some(h => h.id === c.id))!;
assert.ok(card);
g[side].hand = [...g[side].hand, card];
g[side].plan[slot] = card.id;
}
void test('canonical identity and reserve cost boundaries preserve the physical counter types', () => {
assert.equal(isReinforcements(reinforcement), true);
assert.equal(isReinforcements({ ...reinforcement, name: 'Reinforcement' }), false);
assert.equal(isReinforcements({ ...reinforcement, effect: 'recruits' }), false);
assert.equal(isReinforcements({ ...reinforcement, extra: true } as Card), false);
assert.deepEqual(quoteReinforcements(3, 0), { normal: 3, elite: 0, bonus: 2 });
assert.deepEqual(quoteReinforcements(1, 2), { normal: 1, elite: 2, bonus: 2 });
assert.deepEqual(quoteReinforcements(0, 3), { normal: 0, elite: 3, bonus: 2 });
assert.deepEqual(quoteReinforcements(Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER), { normal: 3, elite: 0, bonus: 2 });
for (const reserves of [[2, 0], [1, 1], [-1, 4], [1.5, 3], [NaN, 3], [Infinity, 3], [Number.MAX_SAFE_INTEGER + 1, 0]])
assert.throws(() => quoteReinforcements(reserves[0], reserves[1]), ReinforcementsError);
});
void test('either slot adds two score but not dial, support, payment or casualties; card always discards', () => {
for (const slot of ['weapon', 'defense'] as const) {
const g = fixture(slot);
const before = structuredClone(g);
const q = quoteBattleResolution(g);
assert.deepEqual(q.reinforcements, [{ player: 'a', card: REINFORCEMENTS_CARD, normal: 1, elite: 2 }]);
assert.equal(q.result, 'normal');
assert.equal(q.winner, 'a');
assert.equal(q.scores!.attacker, 2 + g.attacker.leader!.strength + 2);
assert.equal(q.scores!.defender, 2 + g.defender.leader!.strength);
assert.equal(q.casualties!.dial, 2);
assert.equal(q.casualties!.support, 2);
assert.deepEqual(q.casualties!.forces, g.attacker.forces);
assert.deepEqual(q.casualties!.options, [{ normal: 2, elite: 0, paidNormal: 2, paidElite: 0 }]);
assert.equal(q.payments.find(p => p.player === 'a')!.cost, 2);
assert.deepEqual(q.discarded, [{ player: 'a', card: REINFORCEMENTS_CARD }]);
assert.deepEqual(q.winnerCards, []);
assert.deepEqual(g, before);
}
const ordinary = fixture();
ordinary.attacker.hand = [];
ordinary.attacker.plan.weapon = null;
delete ordinary.attacker.reinforcementsReserves;
assert.equal(quoteBattleResolution(ordinary).reinforcements, undefined);
});
void test('traitor, mutual traitors and explosion all pay the reserve cost and discard independently of winner', () => {
for (const outcome of ['ownTraitor', 'opponentTraitor', 'mutualTraitors', 'explosion'] as const) {
const g = fixture();
if (outcome === 'explosion') {
putCard(g, 'attacker', 'defense', 'shield');
putCard(g, 'defender', 'weapon', 'lasgun');
} else {
if (outcome !== 'opponentTraitor') {
g.voters[0].called = true;
g.voters[0].traitors = [g.defender.plan.leader!];
}
if (outcome !== 'ownTraitor') {
g.voters[1].called = true;
g.voters[1].traitors = [g.attacker.plan.leader!];
}
}
const q = quoteBattleResolution(g);
assert.equal(q.result, outcome === 'ownTraitor' || outcome === 'opponentTraitor' ? 'traitor' : outcome);
assert.deepEqual(q.reinforcements, [{ player: 'a', card: REINFORCEMENTS_CARD, normal: 1, elite: 2 }]);
assert.equal(q.discarded.filter(c => c.card === REINFORCEMENTS_CARD).length, 1);
assert.equal(q.winnerCards.includes(REINFORCEMENTS_CARD), false);
assert.equal(q.scores, null);
assert.equal(q.casualties, null);
}
});
void test('the losing card cannot enter a Moritani Nexus retention window', () => {
const g = fixture();
g.nexusMoritani = true;
g.attacker.plan.dial = 0;
g.attacker.plan.support = 0;
g.defender.plan.dial = 4;
g.defender.plan.support = 4;
const q = quoteBattleResolution(g);
assert.equal(q.winner, 'd');
assert.deepEqual(q.reinforcements, [{ player: 'a', card: REINFORCEMENTS_CARD, normal: 1, elite: 2 }]);
assert.equal(q.retention, null);
assert.deepEqual(q.discarded, [{ player: 'a', card: REINFORCEMENTS_CARD }]);
});
void test('selected card requires own funded typed reserves and rejects malformed or duplicate commitments', () => {
const missing = fixture();
delete missing.attacker.reinforcementsReserves;
assert.throws(() => quoteBattleResolution(missing), BattleResolutionQuoteError);
for (const reserves of [{ normal: 2, elite: 0 }, { normal: -1, elite: 4 }, { normal: 1.5, elite: 2 }, { normal: 0, elite: NaN }]) {
const g = fixture();
g.attacker.reinforcementsReserves = reserves;
assert.throws(() => quoteBattleResolution(g), BattleResolutionQuoteError);
}
const surplus = fixture();
surplus.defender.reinforcementsReserves = { normal: 3, elite: 0 };
assert.throws(() => quoteBattleResolution(surplus), BattleResolutionQuoteError);
const fake = fixture();
fake.attacker.hand = [{ ...reinforcement, name: 'Other' }];
assert.throws(() => quoteBattleResolution(fake), BattleResolutionQuoteError);
const duplicate = fixture();
duplicate.attacker.plan.defense = reinforcement.id;
assert.throws(() => quoteBattleResolution(duplicate), BattleResolutionQuoteError);
});
void test('Stone ignores the Reinforcements score bonus but still charges its original typed reserve cost', () => {
const g = fixture();
putCard(g, 'defender', 'weapon', 'stoneBurner');
g.defender.stoneMode = 'ignore';
g.defender.plan.dial = 1;
g.defender.plan.support = 1;
const q = quoteBattleResolution(g);
assert.equal(q.winner, 'd', 'three undialed attacker counters lose to four; +2 is not a physical token bonus');
assert.deepEqual(q.stone, {winner: 'defender', attacker: [3], defender: [4]});
assert.deepEqual(q.reinforcements, [{player: 'a', card: REINFORCEMENTS_CARD, normal: 1, elite: 2}]);
assert.deepEqual(q.discarded, [{player: 'a', card: REINFORCEMENTS_CARD}]);
});