import assert from 'node:assert/strict'; import test from 'node:test'; import { applyAction, viewGame, type Game, type Action } from '../game/engine'; import { baseDeck, ixBattleCards, ixStandardCards, type Card } from '../game/cards'; import { richeseCards } from '../game/richese-cards'; import { battleWeaponsExplode, defenseTypes, isShield, weaponKills, } from '../game/battle-cards'; import { quoteDiplomatDefense, copiedDiplomatDefense, } from '../game/diplomat-defense'; import { leaderSkillBattleBonus } from '../game/leader-skill-combat'; import { diplomatDefenseGame, revealDiplomatPlans, takeBattleCard, } from './diplomat-defense-fixture'; import { completedAdvancedNativeSkillsGame, stageAdvancedNativeSkillBattle, openAdvancedNativeSkillBattle, } from './fixture-advanced-native-skills'; function choose(game: Game, card: string | null): Game { assert.equal(game.decision?.kind, 'diplomatDefense'); return applyAction(JSON.parse(JSON.stringify(game)), 'a', { type: 'decision', event: game.decision.event, card, }); } function finish(game: Game, call = false): Game { let next = applyAction(game, 'a', { type: 'traitorCall', call }); next = applyAction(next, 'd', { type: 'traitorCall', call: false }); for (const p of next.players) assert.equal( p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0), 20, ); const ids = [ ...next.deck, ...next.discard, ...next.players.flatMap((p) => p.hand), ].map((c) => c.id); assert.equal(new Set(ids).size, 33); assert.equal(ids.length, 33); return next; } function reject(game: Game, owner: string, action: Action) { const before = JSON.stringify(game); assert.throws(() => applyAction(game, owner, action)); assert.equal(JSON.stringify(game), before); } void test('Diplomat quotes one physical Worthless from either slot and never copies absent or unsupported defenses', () => { const cards = baseDeck(), worthless = cards.filter((c) => c.kind === 'worthless'), snooper = cards.find((c) => c.kind === 'snooper')!; const assignments = [ { skill: 'diplomat' as const, leader: 'trainer', faceUp: true, captured: false, }, ]; const quote = quoteDiplomatDefense({ assignments, selectedLeader: 'other', weapon: worthless[0], defense: worthless[1], opposingDefense: snooper, })!; assert.deepEqual( quote.cards, worthless.slice(0, 2).map((c) => c.id), ); assert.equal(copiedDiplomatDefense(quote, worthless[0].id).kind, 'snooper'); assert.equal(worthless[0].kind, 'worthless'); assert.throws(() => copiedDiplomatDefense(quote, 'unplayed')); for (const opposingDefense of [ undefined, worthless[0], { ...snooper, id: 'unrecognized' }, { ...snooper, kind: 'shieldSnooper' as const }, ]) assert.equal( quoteDiplomatDefense({ assignments, selectedLeader: 'other', weapon: worthless[0], opposingDefense, }), null, ); assert.equal( quoteDiplomatDefense({ assignments, selectedLeader: 'other', weapon: worthless[0], defense: snooper, opposingDefense: snooper, }), null, ); assert.equal( quoteDiplomatDefense({ assignments: [{ ...assignments[0], faceUp: false }], selectedLeader: 'other', weapon: worthless[0], opposingDefense: snooper, }), null, ); assert.ok( quoteDiplomatDefense({ assignments: [{ ...assignments[0], faceUp: false }], selectedLeader: 'trainer', weapon: worthless[0], opposingDefense: snooper, }), ); assert.equal( quoteDiplomatDefense({ assignments: [{ ...assignments[0], captured: true }], selectedLeader: 'trainer', weapon: worthless[0], opposingDefense: snooper, }), null, ); }); void test('native face-up and selected Diplomats copy a Snooper after reveal in Basic and Advanced without rewriting sealed plans', () => { for (const advanced of [false, true]) for (const hide of [false, true]) for (const slot of ['weapon', 'defense'] as const) { const game = diplomatDefenseGame(hide, advanced), card = game.players[0].hand[0].id; const pending = revealDiplomatPlans(game, slot), plans = structuredClone(pending.battle!.plans); assert.equal(pending.decision?.kind, 'diplomatDefense'); reject(pending, 'd', { type: 'decision', event: pending.decision.event, card, }); reject(pending, 'a', { type: 'traitorCall', call: false }); reject(pending, 'a', { type: 'decision', event: 'stale', card }); const copied = choose(pending, card); assert.deepEqual(copied.battle!.plans, plans); assert.equal( copied.players[0].hand.find((c) => c.id === card)!.kind, 'worthless', ); const done = finish(copied), leader = done.players[0].leaders.find( (l) => l.id === (hide ? 'emperor-0' : 'emperor-1'), )!; assert.equal(leader.dead, false); assert.ok(!done.players[0].hand.some((c) => c.id === card)); assert.equal(done.discard.filter((c) => c.id === card).length, 1); reject(done, 'a', { type: 'decision', event: pending.decision.event, card, }); } }); void test('declining the visible copy leaves ordinary weapon deaths and card custody unchanged', () => { const game = diplomatDefenseGame(), card = game.players[0].hand[0].id; const pending = revealDiplomatPlans(game); const done = finish(choose(pending, null)); assert.equal( done.players[0].leaders.find((l) => l.id === 'emperor-1')!.dead, true, ); assert.equal(done.players[0].forces['arrakeen:10'] ?? 0, 0); assert.equal(done.discard.filter((c) => c.id === card).length, 1); }); void test('two committed Worthless cards offer one choice and only the chosen winner card is mandatory discard', () => { let game = diplomatDefenseGame(); const first = game.players[0].hand[0], second = takeBattleCard(game, 'a', 'worthless'); game = applyAction(game, 'a', { type: 'battlePlan', dial: 0, leader: 'emperor-1', weapon: first.id, defense: second.id, }); game = applyAction(game, 'd', { type: 'battlePlan', dial: 0, leader: 'guild-1', weapon: game.players[1].hand.find((c) => c.kind === 'poison')!.id, defense: game.players[1].hand.find((c) => c.kind === 'snooper')!.id, }); assert.equal(game.decision?.kind, 'diplomatDefense'); assert.deepEqual( new Set(game.decision.cards), new Set([first.id, second.id]), ); const done = finish(choose(game, second.id)); assert.equal(done.discard.filter((c) => c.id === second.id).length, 1); assert.ok(done.players[0].hand.some((c) => c.id === first.id)); assert.equal(done.decision?.kind, 'battleCards'); assert.ok(done.decision.cards.includes(first.id)); assert.ok(!done.decision.cards.includes(second.id)); }); void test('an actual defense or absent opposing defense creates no Diplomat choice', () => { for (const ownDefense of [false, true]) { let game = diplomatDefenseGame(); const defense = ownDefense ? takeBattleCard(game, 'a', 'snooper').id : undefined; game = applyAction(game, 'a', { type: 'battlePlan', dial: 0, leader: 'emperor-1', weapon: game.players[0].hand[0].id, defense, }); game = applyAction(game, 'd', { type: 'battlePlan', dial: 0, leader: 'guild-1', defense: ownDefense ? game.players[1].hand.find((c) => c.kind === 'snooper')!.id : undefined, }); assert.notEqual(game.decision?.kind, 'diplomatDefense'); finish(game); } }); void test('copying a Shield preserves the existing Lasgun explosion instead of making a survival loop', () => { let game = diplomatDefenseGame(true); const card = game.players[0].hand[0].id, laser = takeBattleCard(game, 'd', 'lasgun'), shield = takeBattleCard(game, 'd', 'shield'); // The staged opponent only needs its played pair; return other physical cards to the deck. game.deck.push( ...game.players[1].hand.filter( (c) => ![laser.id, shield.id].includes(c.id), ), ); game.players[1].hand = game.players[1].hand.filter((c) => [laser.id, shield.id].includes(c.id), ); game = applyAction(game, 'a', { type: 'battlePlan', dial: 0, leader: 'emperor-0', defense: card, }); game = applyAction(game, 'd', { type: 'battlePlan', dial: 0, leader: 'guild-1', weapon: laser.id, defense: shield.id, }); const done = finish(choose(game, card)); for (const p of done.players) assert.equal(p.tanks, 5); assert.equal( done.players[0].leaders.find((l) => l.id === 'emperor-0')!.dead, true, ); assert.equal( done.players[1].leaders.find((l) => l.id === 'guild-1')!.dead, true, ); assert.equal(done.discard.filter((c) => c.id === card).length, 1); }); void test('copied defense role supports captured Medic or Prana while the physical Worthless still supports Warmaster', () => { const card = baseDeck().find((c) => c.kind === 'worthless')!; for (const [skill, kind] of [ ['killer-medic', 'snooper'], ['prana-bindu-adept', 'shield'], ['warmaster', 'snooper'], ] as const) { const bonus = leaderSkillBattleBonus({ assignments: [ { skill, leader: 'captive', faceUp: false, captured: true }, ], selectedLeader: { id: 'captive', kind: 'disc' }, weapon: undefined, defense: card, effectiveDefense: { id: card.id, name: 'Copied defense', kind }, skilledLeaderSurvives: true, }); assert.equal(bonus.bonus, 3, skill); } }); void test('old unmarked revealed battles retain their original continuation without inventing a copy', () => { const unsealed = diplomatDefenseGame(); delete unsealed.battle!.diplomatDefenseVersion; const game = revealDiplomatPlans(unsealed); assert.notEqual(game.decision?.kind, 'diplomatDefense'); assert.equal( viewGame(JSON.parse(JSON.stringify(game)), 'a').battle!.revealed, true, ); const done = finish(game); assert.equal( done.players[0].leaders.find((l) => l.id === 'emperor-1')!.dead, true, ); }); void test('a copied printed role keeps Shield Snooper hybrid, defensive Weirding Way and Chemistry distinct', () => { const worthless = baseDeck().filter((c) => c.kind === 'worthless'); const ix = ixBattleCards(); const hybrid = ix.find((c) => c.kind === 'shieldSnooper')!, weirding = ix.find((c) => c.kind === 'weirdingWay')!, chemistry = ix.find((c) => c.kind === 'chemistry')!, poisonTooth = ix.find((c) => c.kind === 'poisonTooth')!; const deck = baseDeck(); const projectile = deck.find((c) => c.kind === 'projectile')!, lasgun = deck.find((c) => c.kind === 'lasgun')!, poison = deck.find((c) => c.kind === 'poison')!, snooper = deck.find((c) => c.kind === 'snooper')!; const assignments = [ { skill: 'diplomat' as const, leader: 'trainer', faceUp: true, captured: false, }, ]; const copy = (opposingDefense: Card, opposingWeapon?: Card) => { const quote = quoteDiplomatDefense({ assignments, selectedLeader: 'other', weapon: worthless[0], opposingDefense, opposingWeapon, }); assert.ok(quote, `${opposingDefense.name} should supply a copied role`); const copied = copiedDiplomatDefense(quote, worthless[0].id); assert.equal(copied.id, worthless[0].id); return copied; }; const hybridCopy = copy(hybrid, lasgun); assert.equal(hybridCopy.kind, 'shieldSnooper'); assert.deepEqual(defenseTypes(hybridCopy), ['shield', 'snooper']); assert.ok(isShield(hybridCopy)); assert.ok(battleWeaponsExplode(lasgun, undefined, undefined, hybridCopy)); // Defensive Weirding Way keeps its original paired-weapon prerequisite. assert.equal( quoteDiplomatDefense({ assignments, selectedLeader: 'other', weapon: worthless[0], opposingDefense: weirding, }), null, ); const weirdingCopy = copy(weirding, projectile); assert.equal(weirdingCopy.kind, 'weirdingWay'); assert.equal(isShield(weirdingCopy), false); assert.equal(weaponKills(projectile, weirdingCopy), false); assert.equal(battleWeaponsExplode(lasgun, undefined, undefined, weirdingCopy), false); // Copying the used role does not copy its own-slot prerequisite onto this plan. assert.ok( quoteDiplomatDefense({ assignments, selectedLeader: 'other', weapon: worthless[0], opposingDefense: weirding, opposingWeapon: worthless[1], }), ); const chemistryCopy = copy(chemistry, poisonTooth); assert.equal(chemistryCopy.kind, 'chemistry'); assert.equal(weaponKills(poisonTooth, chemistryCopy), false); assert.equal(weaponKills(poisonTooth, hybridCopy), true); assert.equal(weaponKills(poison, chemistryCopy), false); assert.equal(weaponKills(projectile, chemistryCopy), true); const snooperCopy = copy(snooper, poison); assert.equal(snooperCopy.kind, 'snooper'); assert.equal(weaponKills(poison, snooperCopy), false); assert.equal(weaponKills(poisonTooth, snooperCopy), true); }); void test('a sealed Portable Snooper supplies only its original poison-defense role', () => { const portable = richeseCards().find((c) => c.effect === 'portableSnooper')!; const worthless = baseDeck().filter((c) => c.kind === 'worthless'); const deck = baseDeck(); const poison = deck.find((c) => c.kind === 'poison')!, projectile = deck.find((c) => c.kind === 'projectile')!, shield = deck.find((c) => c.kind === 'shield')!; const assignments = [ { skill: 'diplomat' as const, leader: 'trainer', faceUp: true, captured: false, }, ]; const quote = quoteDiplomatDefense({ assignments, selectedLeader: 'other', weapon: worthless[0], defense: worthless[1], opposingDefense: portable, })!; assert.equal(quote.kind, 'snooper'); const copied = copiedDiplomatDefense(quote, worthless[1].id); assert.equal(copied.kind, 'snooper'); assert.equal(copied.id, worthless[1].id); assert.equal(isShield(copied), false); assert.equal(weaponKills(poison, copied), false); assert.equal(weaponKills(projectile, copied), true); for (const forged of [ { ...portable, name: 'Snooper' }, { ...portable, kind: 'snooper' as const }, { ...portable, id: 'richese-portable-snooper-copy' }, { ...portable, effect: undefined }, ]) assert.equal( quoteDiplomatDefense({ assignments, selectedLeader: 'other', weapon: worthless[0], opposingDefense: forged, }), null, forged.id, ); const ix = ixBattleCards(); for (const unsupported of [ poison, projectile, worthless[0], ix.find((c) => c.kind === 'poisonTooth')!, ix.find((c) => c.kind === 'artillery')!, ix.find((c) => c.kind === 'poisonBlade')!, ]) assert.equal( quoteDiplomatDefense({ assignments, selectedLeader: 'other', weapon: worthless[0], opposingDefense: unsupported, }), null, unsupported.id, ); // A forged face cannot inherit the role by claiming a printed name alone. for (const forged of [ { ...ix.find((c) => c.kind === 'weirdingWay')!, kind: 'shield' as const }, { ...ix.find((c) => c.kind === 'chemistry')!, kind: 'snooper' as const }, { ...shield, kind: 'shieldSnooper' as const }, { ...shield, id: 'ix-shield', kind: 'shieldSnooper' as const }, ]) assert.equal( quoteDiplomatDefense({ assignments, selectedLeader: 'other', weapon: worthless[0], opposingDefense: forged, opposingWeapon: projectile, }), null, forged.id, ); }); void test('an older battle admits only its original base Shield and Snooper sources', () => { const worthless = baseDeck().filter((c) => c.kind === 'worthless'); const deck = baseDeck(); const shield = deck.find((c) => c.kind === 'shield')!, snooper = deck.find((c) => c.kind === 'snooper')!, projectile = deck.find((c) => c.kind === 'projectile')!; const ix = ixBattleCards(); const ixStandard = ixStandardCards(); const portable = richeseCards().find((c) => c.effect === 'portableSnooper')!; const assignments = [ { skill: 'diplomat' as const, leader: 'trainer', faceUp: true, captured: false, }, ]; const legacy = (opposingDefense: Card, opposingWeapon?: Card) => quoteDiplomatDefense({ assignments, selectedLeader: 'other', weapon: worthless[0], opposingDefense, opposingWeapon, expandedDefenses: false, }); assert.equal(legacy(shield)?.kind, 'shield'); assert.equal(legacy(snooper, projectile)?.kind, 'snooper'); for (const unavailable of [ ix.find((c) => c.kind === 'shieldSnooper')!, ix.find((c) => c.kind === 'weirdingWay')!, ix.find((c) => c.kind === 'chemistry')!, ixStandard.find((c) => c.id === 'ix-shield')!, ixStandard.find((c) => c.id === 'ix-snooper')!, portable, ]) assert.equal( legacy(unavailable, unavailable.kind === 'weirdingWay' ? projectile : undefined), null, unavailable.id, ); for (const unavailable of [ ix.find((c) => c.kind === 'shieldSnooper')!, ix.find((c) => c.kind === 'weirdingWay')!, ix.find((c) => c.kind === 'chemistry')!, portable, ]) assert.ok( quoteDiplomatDefense({ assignments, selectedLeader: 'other', weapon: worthless[0], opposingDefense: unavailable, opposingWeapon: unavailable.kind === 'weirdingWay' ? projectile : undefined, }), unavailable.id, ); }); void test('an already-open base-only battle finishes without a retroactive hybrid copy or missing-receipt deadlock', () => { const initialized = completedAdvancedNativeSkillsGame({ family: 'ixians', requestedSkill: 'diplomat', skillOwner: 'emperor', opponents: ['guild'], }); let game = stageAdvancedNativeSkillBattle(initialized, 'emperor', 'guild'); const worthless = takeBattleCard(game, 'emperor', 'worthless'); const weapon = takeBattleCard(game, 'guild', 'poisonBlade'); const defense = takeBattleCard(game, 'guild', 'shieldSnooper'); game = openAdvancedNativeSkillBattle(game, 'emperor', 'guild', true); // An in-flight battle created before printed-defense capability was enabled. game.battle!.diplomatDefenseVersion = 1; game = applyAction(game, 'emperor', { type: 'battlePlan', dial: 0, leader: 'emperor-0', defense: worthless.id }); game = applyAction(game, 'guild', { type: 'battlePlan', dial: 0, leader: 'guild-1', weapon: weapon.id, defense: defense.id }); assert.equal(game.decision, null); assert.equal(game.battle!.diplomatDefense, undefined); game = applyAction(game, 'emperor', { type: 'traitorCall', call: false }); game = applyAction(game, 'guild', { type: 'traitorCall', call: false }); assert.equal(game.players.find(player => player.id === 'emperor')!.leaders.find(leader => leader.id === 'emperor-0')!.dead, true); });