import test from 'node:test'; import assert from 'node:assert/strict'; import { baseDeck, ixBattleCards, type Card } from '../game/cards'; import { weaponKills, validBattleCardPair } from '../game/battle-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 }); } void test('combined and alternate-role cards obey the full projectile/poison defense matrix', () => { const defenses = [ undefined, card('shield'), card('snooper'), card('shieldSnooper'), card('weirdingWay'), card('chemistry'), ]; const expectations: [Card['kind'], boolean[]][] = [ ['projectile', [true, false, true, false, false, true]], ['poison', [true, true, false, false, true, false]], ['poisonBlade', [true, true, true, false, true, true]], ['weirdingWay', [true, false, true, false, false, true]], ['chemistry', [true, true, false, false, true, false]], ['lasgun', [true, true, true, true, true, true]], ]; for (const [kind, kills] of expectations) assert.deepEqual( defenses.map((d) => weaponKills(card(kind), d)), kills, kind, ); }); void test('alternate roles require the complementary slot and cannot reuse the same card', () => { assert.equal(validBattleCardPair(card('chemistry')), false); assert.equal(validBattleCardPair(undefined, card('weirdingWay')), false); assert.equal( validBattleCardPair(card('weirdingWay'), card('chemistry')), true, ); assert.equal( validBattleCardPair(card('chemistry'), card('weirdingWay')), true, ); assert.equal( validBattleCardPair(card('chemistry'), card('chemistry')), false, ); assert.equal( validBattleCardPair(card('poisonBlade'), card('shieldSnooper')), true, ); }); void test('Poison Blade kills through either single defense but Shield Snooper protects in authoritative combat', () => { for (const kind of ['shield', 'snooper', 'shieldSnooper'] as const) { const before = fixture(); setHand(before, 0, [card('poisonBlade')]); setHand(before, 1, [card(kind)]); let g = plan(prepared(before), 'a', { weapon: card('poisonBlade').id }); g = plan(g, 'g', { defense: card(kind).id }); g = resolve(g); assert.equal(g.players[1].leaders[0].dead, kind !== 'shieldSnooper'); assert.equal(g.players[0].forces['arrakeen:10'], 5); } }); void test('Shield Snooper triggers lasgun explosion while Weirding Way does not', () => { for (const kind of ['shieldSnooper', 'weirdingWay'] as const) { const before = fixture(); setHand(before, 0, [card('lasgun')]); setHand(before, 1, [card(kind), card('poison')]); let g = plan(prepared(before), 'a', { weapon: card('lasgun').id }); g = plan(g, 'g', { defense: card(kind).id, weapon: card('poison').id }); g = resolve(g); assert.equal( g.players[0].forces['arrakeen:10'] ?? 0, kind === 'shieldSnooper' ? 0 : 5, ); assert.equal(g.players[1].leaders[0].dead, true); } }); void test('server rejects incomplete role swaps without mutating state, and accepts complementary cards', () => { const before = fixture(); setHand(before, 1, [card('chemistry'), card('weirdingWay')]); const g = prepared(before), frozen = structuredClone(g); assert.throws( () => plan(g, 'g', { weapon: card('chemistry').id }), /another defense/, ); assert.throws( () => plan(g, 'g', { defense: card('weirdingWay').id }), /another weapon/, ); assert.throws( () => plan(g, 'g', { weapon: card('weirdingWay').id, defense: card('weirdingWay').id, }), /both slots/, ); assert.deepEqual(g, frozen); assert.ok( plan(g, 'g', { weapon: card('weirdingWay').id, defense: card('chemistry').id, }).battle?.plans.g, ); }); function voiced(hand: Card[], kind: string, must: boolean) { const before = fixture(); setHand(before, 1, hand); before.players[0].ally = 'b'; before.players[3].ally = 'a'; let g = choose(before); g = allow(applyAction(g, 'b', { type: 'voice', kind, must })); return applyAction(g, 'a', { type: 'declineBattlePower' }); } void test('Voice forbids combined cards through either generic type and can name them specifically', () => { for (const kind of ['projectile', 'poison', 'poisonBlade']) { const g = voiced([card('poisonBlade')], kind, false); assert.throws( () => plan(g, 'g', { weapon: card('poisonBlade').id }), /Voice/, ); assert.ok(plan(g, 'g').battle?.plans.g); } for (const kind of ['shield', 'snooper', 'shieldSnooper']) { const g = voiced([card('shieldSnooper')], kind, true); assert.throws(() => plan(g, 'g'), /Voice/); assert.ok( plan(g, 'g', { defense: card('shieldSnooper').id }).battle?.plans.g, ); } }); void test('generic Voice prohibition follows the selected role of Chemistry and Weirding Way', () => { const hand = [ card('chemistry'), card('weirdingWay'), card('poison'), card('shield'), ]; const noPoison = voiced(hand, 'poison', false); assert.ok( plan(noPoison, 'g', { weapon: card('weirdingWay').id, defense: card('chemistry').id, }).battle?.plans.g, ); assert.throws( () => plan(noPoison, 'g', { weapon: card('chemistry').id, defense: card('shield').id, }), /Voice/, ); const noProjectile = voiced(hand, 'projectile', false); assert.ok( plan(noProjectile, 'g', { weapon: card('poison').id, defense: card('weirdingWay').id, }).battle?.plans.g, ); assert.throws( () => plan(noProjectile, 'g', { weapon: card('weirdingWay').id }), /Voice/, ); }); void test('Voice compulsion cannot force a held card into its alternate role', () => { const chemistry = voiced([card('chemistry'), card('shield')], 'poison', true); assert.ok(plan(chemistry, 'g').battle?.plans.g); const weirding = voiced( [card('weirdingWay'), card('poison')], 'shield', true, ); assert.ok(plan(weirding, 'g').battle?.plans.g); const required = voiced( [card('weirdingWay'), card('poison')], 'projectile', true, ); assert.throws( () => plan(required, 'g', { weapon: card('poison').id, defense: card('weirdingWay').id, }), /Voice/, ); assert.ok( plan(required, 'g', { weapon: card('weirdingWay').id }).battle?.plans.g, ); }); void test('named Voice prohibition applies in both alternate roles', () => { for (const kind of ['weirdingWay', 'chemistry'] as const) { const g = voiced([card(kind), card('poison'), card('shield')], kind, false); assert.throws( () => plan(g, 'g', { weapon: card(kind).id, defense: card('shield').id }), /Voice/, ); assert.throws( () => plan(g, 'g', { weapon: card('poison').id, defense: card(kind).id }), /Voice/, ); } }); void test('prescience reveals only the effective slot, preserves alternate-role legality and keeps the other card private', () => { for (const [field, value, choices] of [ [ 'weapon', card('poison').id, { weapon: card('poison').id, defense: card('weirdingWay').id }, ], [ 'defense', card('weirdingWay').id, { weapon: card('poison').id, defense: card('weirdingWay').id }, ], [ 'defense', card('shield').id, { weapon: card('chemistry').id, defense: card('shield').id }, ], [ 'weapon', card('chemistry').id, { weapon: card('chemistry').id, defense: card('shield').id }, ], ] as const) { const before = fixture(); setHand(before, 1, [ card('poison'), card('weirdingWay'), card('chemistry'), card('shield'), ]); let g = choose(before); g = allow(applyAction(g, 'a', { type: 'prescience', field })); g = applyAction(g, 'g', { type: 'prescienceAnswer', value }); const insight = viewGame(g, 'a').battle!.insight!; assert.equal(insight.value, value); assert.equal(viewGame(g, 'e').battle!.insight, null); g = plan(JSON.parse(JSON.stringify(g)), 'g', choices); assert.equal(viewGame(g, 'a').battle!.plans.g, undefined); assert.throws(() => plan(g, 'a', { weapon: value }), /weapon|defense/); } }); void test('prescience rejects an alternate slot with no possible complementary card', () => { for (const [field, kind] of [ ['weapon', 'chemistry'], ['defense', 'weirdingWay'], ] as const) { const before = fixture(); setHand(before, 1, [card(kind)]); let g = choose(before); g = allow(applyAction(g, 'a', { type: 'prescience', field })); assert.throws( () => applyAction(g, 'g', { type: 'prescienceAnswer', value: card(kind).id }), /legal battle plan/, ); } }); void test('all AI policies find legal expansion-card plans and remain independent of opponent hidden hands', () => { for (const difficulty of DIFFICULTIES) { const before = fixture(); setHand(before, 1, ixBattleCards().slice(-4)); before.players[1].bot = difficulty; const g = prepared(before); const actions = botActions(viewGame(g, 'g')); const accepted = actions.find((action) => { try { applyAction(g, 'g', action); return true; } catch { return false; } }); assert.ok(accepted, difficulty); setHand(g, 0, [card('lasgun'), card('shield')]); g.deck.reverse(); assert.deepEqual(botActions(viewGame(g, 'g')), actions); } }); void test('Hard and Brutal use revealed Poison Blade information to select the combined defense', () => { for (const difficulty of ['Hard', 'Brutal'] as const) { const before = fixture(); before.players[0].bot = difficulty; setHand(before, 0, [ card('shieldSnooper'), card('shield'), card('snooper'), ]); setHand(before, 1, [card('poisonBlade')]); let g = choose(before); g = allow(applyAction(g, 'a', { type: 'prescience', field: 'weapon' })); g = applyAction(g, 'g', { type: 'prescienceAnswer', value: card('poisonBlade').id, }); const actions = botActions(viewGame(g, 'a')); const selected = actions.find((action) => { try { applyAction(g, 'a', action); return true; } catch { return false; } }); assert.equal(selected?.defense, card('shieldSnooper').id, difficulty); } });