import test from 'node:test'; import assert from 'node:assert/strict'; import { baseDeck, ixBattleCards, ixStandardCards, type Card, } from '../game/cards'; import { richeseCards } from '../game/richese-cards'; import { quoteBattleWeaponRetention, resolveBattleWeapons, type BattleWeaponSide, type EffectiveBattleWeapon, } from '../game/effective-weapons'; const mirror = richeseCards().find((c) => c.effect === 'mirrorWeapon')!; const stone = richeseCards().find((c) => c.effect === 'stoneBurner')!; const portable = richeseCards().find((c) => c.effect === 'portableSnooper')!; const cards = [...baseDeck(), ...ixBattleCards(), ...ixStandardCards()]; const card = (kind: Card['kind']) => cards.find((c) => c.kind === kind)!; const sides = ['attacker', 'defender'] as const; function copied( side: BattleWeaponSide, weapon?: Card, defense?: Card, mirrorDefense?: Card, ) { const other: BattleWeaponSide = side === 'attacker' ? 'defender' : 'attacker'; const input = { [side]: { weapon: mirror, defense: mirrorDefense }, [other]: { weapon, defense }, } as Parameters[0]; const snapshot = structuredClone(input); const result = resolveBattleWeapons(input); assert.deepEqual(input, snapshot); assert.equal(result.error, null); assert.equal(result[side].physicalId, mirror.id); assert.equal(result[other].physicalId, weapon?.id ?? null); return { result, other }; } void test('Mirror copies every ordinary and Ix actual weapon kind in both orientations without changing physical identity', () => { const weapons = cards.filter((c) => [ 'projectile', 'poison', 'lasgun', 'poisonBlade', 'poisonTooth', 'artillery', 'weirdingWay', 'chemistry', ].includes(c.kind), ); for (const side of sides) for (const weapon of weapons) { const { result, other } = copied( side, weapon, weapon.kind === 'chemistry' ? card('shield') : undefined, ); assert.deepEqual(result[side], { physicalId: mirror.id, copiedFrom: weapon.id, kind: weapon.kind, choice: weapon.kind === 'poisonTooth' ? 'poisonTooth' : null, }); assert.equal(result[other].kind, weapon.kind); assert.equal(result[other].copiedFrom, null); } }); void test('Stone Burner and Poison Tooth copies require separate choices before the original owner in both orientations', () => { for (const side of sides) for (const weapon of [stone, card('poisonTooth')]) { const kind = weapon === stone ? 'stoneBurner' : 'poisonTooth'; const { result, other } = copied(side, weapon); assert.deepEqual(result.choiceOrder, [ { side, kind, physicalId: mirror.id, copiedFrom: weapon.id }, { side: other, kind, physicalId: weapon.id, copiedFrom: null }, ]); assert.equal(result[side].choice, kind); assert.equal(result[other].choice, kind); assert.equal(result[side].kind, kind); assert.notEqual(result[side], result[other]); result[side].choice = null; assert.equal(result[other].choice, kind); } }); void test('ordinary choices retain input order when no copy dependency exists and Artillery adds no choice', () => { const tooth = card('poisonTooth'); for (const [attacker, defender] of [ [tooth, stone], [stone, tooth], [tooth, { ...tooth, id: 'second-tooth' }], ] as const) { const r = resolveBattleWeapons({ attacker: { weapon: attacker }, defender: { weapon: defender }, }); assert.deepEqual(r.choiceOrder, [ { side: 'attacker', kind: attacker === stone ? 'stoneBurner' : 'poisonTooth', physicalId: attacker.id, copiedFrom: null, }, { side: 'defender', kind: defender === stone ? 'stoneBurner' : 'poisonTooth', physicalId: defender.id, copiedFrom: null, }, ]); } for (const side of sides) assert.deepEqual(copied(side, card('artillery')).result.choiceOrder, []); }); void test('empty slots, Worthless and Ecaz stand-ins yield no copied attack and no choice', () => { const nonWeapons: (Card | undefined)[] = [ undefined, card('worthless'), card('shield'), card('snooper'), card('hero'), portable, { id: 'ecaz-reinforcements', name: 'Reinforcements', kind: 'special', effect: 'reinforcements', }, { id: 'ecaz-harass-withdraw', name: 'Harass & Withdraw', kind: 'special', effect: 'harassWithdraw', }, { id: 'unknown-special', name: 'Other', kind: 'special', effect: 'weapon' }, ]; for (const side of sides) for (const weapon of nonWeapons) { const { result, other } = copied(side, weapon); assert.deepEqual(result[side], { physicalId: mirror.id, copiedFrom: null, kind: null, choice: null, }); assert.equal(result[other].kind, null); assert.deepEqual(result.choiceOrder, []); } }); void test('only the selected weapon slot is copied, including Chemistry and Weirding Way defense pairings', () => { for (const side of sides) for (const defense of [ card('chemistry'), card('weirdingWay'), portable, stone, ]) { const weapon = card('projectile'); const { result } = copied(side, weapon, defense); assert.equal(result[side].kind, 'projectile'); assert.equal(result[side].copiedFrom, weapon.id); assert.equal(copied(side, undefined, defense).result[side].kind, null); } }); void test('original Chemistry needs its selected defense but copied Chemistry never needs a defense on Mirror', () => { for (const side of sides) { assert.equal(copied(side, card('chemistry')).result[side].kind, null); assert.equal( copied(side, card('chemistry'), card('chemistry')).result[side].kind, null, ); assert.equal( copied(side, card('chemistry'), card('projectile')).result[side].kind, null, ); for (const defense of [ card('shield'), card('worthless'), card('weirdingWay'), portable, ]) { assert.equal( copied(side, card('chemistry'), defense).result[side].kind, 'chemistry', ); assert.equal( copied(side, card('chemistry'), defense, card('weirdingWay')).result[ side ].kind, 'chemistry', ); } } }); void test('duplicated Mirror custody is rejected without recursively copying or creating choices', () => { for (const input of [ { attacker: { weapon: mirror }, defender: { weapon: { ...mirror } } }, { attacker: { weapon: mirror, defense: { ...mirror } }, defender: { weapon: stone }, }, { attacker: { defense: mirror }, defender: { weapon: { ...mirror } } }, ]) { const snapshot = structuredClone(input), r = resolveBattleWeapons(input); assert.match(r.error!, /duplicated custody/); assert.equal(r.attacker.kind, null); assert.equal(r.defender.kind, null); assert.deepEqual(r.choiceOrder, []); assert.deepEqual(input, snapshot); } }); void test('forged Mirror IDs, names, kinds or effects cannot acquire a copy or ordinary attack', () => { const forgeries: Card[] = [ { ...mirror, id: 'forged' }, { ...mirror, name: 'Not Mirror' }, { ...mirror, kind: 'projectile' }, { ...mirror, effect: undefined }, { id: 'base-fake', name: 'Mirror Weapon', kind: 'special' }, { id: 'base-fake', name: 'Other', kind: 'special', effect: 'mirrorWeapon' }, ]; for (const side of sides) for (const forged of forgeries) for (const slot of ['weapon', 'defense'] as const) { const other: BattleWeaponSide = side === 'attacker' ? 'defender' : 'attacker'; const input = { [side]: { [slot]: forged }, [other]: { weapon: stone }, } as Parameters[0]; const snapshot = structuredClone(input), r = resolveBattleWeapons(input); assert.match(r.error!, /canonical physical identity/); assert.deepEqual(r.choiceOrder, []); assert.equal(r.attacker.kind, null); assert.equal(r.defender.kind, null); assert.deepEqual(input, snapshot); } }); void test('only canonical Stone Burner creates its effective kind and malformed Richese identities gain no attack', () => { for (const side of sides) for (const forged of [ { ...stone, id: 'forged' }, { ...stone, name: 'Other' }, { ...stone, effect: 'other' }, { ...stone, kind: 'projectile' as const }, { ...portable, kind: 'poison' as const }, ]) { const { result, other } = copied(side, forged); assert.equal(result[side].kind, null); assert.equal(result[other].kind, null); assert.deepEqual(result.choiceOrder, []); } }); void test('resolution accepts frozen physical inputs and returned descriptors never alias them', () => { const a = Object.freeze({ ...mirror }), d = Object.freeze({ ...stone }); const input = Object.freeze({ attacker: Object.freeze({ weapon: a }), defender: Object.freeze({ weapon: d }), }); const r = resolveBattleWeapons(input); assert.equal(r.error, null); r.attacker.kind = 'projectile'; r.defender.physicalId = 'changed-output'; assert.equal(a.kind, 'special'); assert.equal(d.id, stone.id); const blank: EffectiveBattleWeapon = { physicalId: null, copiedFrom: null, kind: null, choice: null, }; assert.deepEqual(resolveBattleWeapons({ attacker: {}, defender: {} }), { attacker: blank, defender: blank, choiceOrder: [], error: null, }); }); void test('physical winner retention ignores the copied attack, including activated Tooth and Artillery', () => { for (const weapon of [ card('poisonTooth'), card('artillery'), stone, card('projectile'), undefined, ]) { for (const side of sides) { const { result } = copied(side, weapon); assert.equal(result[side].physicalId, mirror.id); assert.equal(result[side].copiedFrom, weapon?.id ?? null); const winner = quoteBattleWeaponRetention({ card: mirror, outcome: 'winner', traitorDecided: false, toothUsed: true, }); assert.deepEqual(winner, { physicalId: mirror.id, mayRetain: true }); assert.deepEqual( quoteBattleWeaponRetention({ card: mirror, outcome: 'loser', traitorDecided: false, toothUsed: true, }), { physicalId: mirror.id, mayRetain: false }, ); } } }); void test('original physical Tooth and Artillery retain their existing mandatory disposal exceptions', () => { const quote = ( physical: Card, outcome: 'winner' | 'loser', toothUsed: boolean, traitorDecided = false, ) => quoteBattleWeaponRetention({ card: physical, outcome, toothUsed, traitorDecided, }); const tooth = card('poisonTooth'); const artillery = card('artillery'); assert.deepEqual(quote(tooth, 'winner', true), { physicalId: tooth.id, mayRetain: false, }); assert.equal(quote(tooth, 'winner', false).mayRetain, true); assert.equal(quote(artillery, 'winner', false).mayRetain, false); assert.equal(quote(artillery, 'winner', true, true).mayRetain, true); assert.equal(quote(tooth, 'winner', true, true).mayRetain, true); assert.equal(quote(tooth, 'loser', false, true).mayRetain, false); assert.equal(quote(card('projectile'), 'winner', false).mayRetain, true); assert.equal(quote(card('hero'), 'winner', false, true).mayRetain, false); }); void test('retention quote refuses forged Mirror identities instead of treating them as generic Specials', () => { for (const forged of [ { ...mirror, id: 'forged-mirror' }, { ...mirror, effect: undefined }, { ...mirror, kind: 'artillery' as const }, ]) { assert.throws( () => quoteBattleWeaponRetention({ card: forged, outcome: 'winner', traitorDecided: false, toothUsed: false, }), /canonical physical identity/, ); } });