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 = {}) { 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, ); });