Files
Dune/tests/ecaz-poison-income.test.ts
T
Quintine 28ba1618ba Implement Homeworld mechanics through Caladan reinforcement checkpoint
Connect typed Homeworld custody, transport, battle, income, revival and Caladan reinforcement flows with private multiplayer recovery and controls. Preserve the optimized test workflow and keep unfinished rules modes gated. Verified with 3682 offline tests, typecheck, lint, production build and 40 HTTP tests; existing saved games remain unchanged.
2026-09-10 08:12:26 +10:00

227 lines
6.8 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { baseDeck, ixDeck, type Card } from '../game/cards';
import { newPlayer } from '../game/engine';
import { createHomeworldCustody } from '../game/homeworld-custody';
import { homeworldContext } from '../game/homeworld-game';
import { RICHESE_CARD_DEFINITIONS } from '../game/richese-cards';
import { resolveBattleWeapons } from '../game/effective-weapons';
import {
EcazPoisonIncomeError,
quoteEcazPoisonIncome,
type EcazPoisonDiscard,
} from '../game/ecaz-poison-income';
function fixture(reserves = 7, advanced = false) {
const players = [
newPlayer('ecaz', 'Ecaz', 'ecaz'),
newPlayer('emperor', 'Emperor', 'emperor'),
];
players[0].reserves = reserves;
const context = { advanced, players };
return {
...context,
homeworlds: { custody: createHomeworldCustody(homeworldContext(context)) },
};
}
const ordinary = [...baseDeck(), ...ixDeck()];
const find = (kind: Card['kind']) =>
ordinary.find((card) => card.kind === kind)!;
const poison = find('poison');
void test('every printed ordinary poison, Poison Blade and Tooth pays once per physical discard', () => {
const cards = ordinary.filter((card) =>
['poison', 'poisonBlade', 'poisonTooth'].includes(card.kind),
);
assert.equal(cards.length, 7);
for (const advanced of [false, true]) {
const context = fixture(7, advanced);
assert.deepEqual(
quoteEcazPoisonIncome(
context,
cards.map((card) => ({ card })),
),
{
player: 'ecaz',
amount: 21,
count: 7,
},
);
for (const card of cards)
assert.deepEqual(
quoteEcazPoisonIncome(context, [{ card, battleSlot: 'weapon' }]),
{
player: 'ecaz',
amount: 3,
count: 1,
},
);
}
});
void test('Chemistry earns only for its actual poison weapon role, not its potential role', () => {
const card = find('chemistry');
for (const battleSlot of [undefined, 'defense', 'leader'] as const)
assert.equal(
quoteEcazPoisonIncome(fixture(), [{ card, battleSlot }]),
null,
);
assert.deepEqual(
quoteEcazPoisonIncome(fixture(), [{ card, battleSlot: 'weapon' }]),
{
player: 'ecaz',
amount: 3,
count: 1,
},
);
});
void test('defenses, projectiles, laser, Artillery and named Richese specials pay no poison income', () => {
const cards = [
...ordinary.filter(
(card) =>
!['poison', 'poisonBlade', 'poisonTooth', 'chemistry'].includes(
card.kind,
),
),
...RICHESE_CARD_DEFINITIONS.map((definition) => definition.card),
];
for (const card of cards)
assert.equal(quoteEcazPoisonIncome(fixture(), [{ card }]), null, card.name);
const residual = RICHESE_CARD_DEFINITIONS.find(
(d) => d.card.effect === 'residualPoison',
)!.card;
assert.equal(
quoteEcazPoisonIncome(fixture(), [
{ card: { ...residual, kind: 'poison' } },
]),
null,
);
});
void test('the exact current native threshold controls receipt in both game modes', () => {
for (const advanced of [false, true])
for (const reserves of [0, 6, 7, 20])
assert.equal(
quoteEcazPoisonIncome(fixture(reserves, advanced), [{ card: poison }])
?.amount ?? 0,
reserves >= 7 ? 3 : 0,
);
for (const homeworlds of [undefined, null, { custody: null }])
assert.equal(
quoteEcazPoisonIncome({ ...fixture(), homeworlds }, [{ card: poison }]),
null,
);
});
void test('foreign presence supplies no native population and receives no award', () => {
const context = fixture(6);
context.players[1].reserves -= 3;
context.homeworlds.custody.visitors['homeworld:ecaz'] = {
emperor: { normal: 3, elite: 0 },
};
assert.equal(quoteEcazPoisonIncome(context, [{ card: poison }]), null);
});
void test('quotation reads no hands, spice, battle plans or card-owner identity and survives JSON', () => {
const context = fixture();
const restored = JSON.parse(JSON.stringify(context)) as typeof context;
const before = JSON.stringify(restored);
for (const player of context.players) {
Object.defineProperty(player, 'hand', {
get: () => {
throw new Error('private hand read');
},
});
Object.defineProperty(player, 'spice', {
get: () => {
throw new Error('private spice read');
},
});
}
const entries = Object.freeze([
Object.freeze({ card: Object.freeze({ ...poison }) }),
]);
assert.deepEqual(
quoteEcazPoisonIncome(context, entries),
quoteEcazPoisonIncome(restored, entries),
);
assert.equal(JSON.stringify(restored), before);
});
void test('duplicate physical discards and malformed slots reject without mutation', () => {
const context = fixture();
const before = JSON.stringify(context);
for (const entries of [
[{ card: poison }, { card: poison }],
[{ card: poison, battleSlot: 'possible-weapon' }],
[{ card: { ...poison, id: '' } }],
])
assert.throws(
() => quoteEcazPoisonIncome(context, entries as EcazPoisonDiscard[]),
EcazPoisonIncomeError,
);
assert.equal(JSON.stringify(context), before);
assert.equal(quoteEcazPoisonIncome(context, []), null);
});
void test('Mirror disposal uses the retained actual copied weapon, not a guessed physical kind', () => {
const card = RICHESE_CARD_DEFINITIONS.find(
(d) => d.card.effect === 'mirrorWeapon',
)!.card;
assert.equal(quoteEcazPoisonIncome(fixture(), [{ card }]), null);
assert.throws(
() => quoteEcazPoisonIncome(fixture(), [{ card, battleSlot: 'weapon' }]),
/retained validated/,
);
assert.equal(
quoteEcazPoisonIncome(fixture(6), [{ card, battleSlot: 'weapon' }]),
null,
);
for (const kind of [
'poison',
'poisonBlade',
'poisonTooth',
'chemistry',
'lasgun',
'artillery',
'projectile',
] as const) {
const resolved = resolveBattleWeapons({
attacker: { weapon: card },
defender: { weapon: find(kind), defense: find('snooper') },
});
assert.equal(resolved.error, null);
assert.equal(
quoteEcazPoisonIncome(fixture(), [
{ card, battleSlot: 'weapon', effectiveWeapon: resolved.attacker },
])?.amount ?? 0,
['poison', 'poisonBlade', 'poisonTooth', 'chemistry'].includes(kind)
? 3
: 0,
kind,
);
}
const empty = resolveBattleWeapons({
attacker: { weapon: card },
defender: {},
}).attacker;
assert.equal(
quoteEcazPoisonIncome(fixture(), [
{ card, battleSlot: 'weapon', effectiveWeapon: empty },
]),
null,
);
assert.throws(
() =>
quoteEcazPoisonIncome(fixture(), [
{
card,
battleSlot: 'weapon',
effectiveWeapon: { ...empty, physicalId: 'another' },
},
]),
EcazPoisonIncomeError,
);
});