Files
Dune/tests/effective-weapons.test.ts
T

372 lines
12 KiB
TypeScript

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<typeof resolveBattleWeapons>[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<typeof resolveBattleWeapons>[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/,
);
}
});