Files
Dune/tests/ecaz-poison-income-engine.test.ts

713 lines
24 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction,
createGame,
newPlayer,
normalizeAutomaticGame,
viewGame,
RuleError,
type Game,
} from '../game/engine';
import { baseDeck, treacheryDeck, spiceDeck, type Card } from '../game/cards';
import { richeseCards } from '../game/richese-cards';
import { cashInCards } from '../game/choam-karama';
import { createHomeworldCustody } from '../game/homeworld-custody';
import { createTerrorState } from '../game/moritani-terror';
import {
homeworldContext,
homeworldGameIntegrity,
} from '../game/homeworld-game';
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
const player = (g: Game, id: string) => g.players.find((p) => p.id === id)!;
function hold(g: Game, id: string, match: (card: Card) => boolean) {
const at = g.deck.findIndex(match);
assert.ok(at >= 0);
const card = g.deck.splice(at, 1)[0];
player(g, id).hand.push(card);
return card;
}
/** Explicit Ecaz module scenario; no complete Ecaz expansion setup claim.
* Cards move from the real deck and all native/board/Tanks counters conserve. */
function fixture(reserves = 7, battle = false) {
const g = createGame(
'ECAZPOISON',
newPlayer(
'owner',
battle ? 'Guild' : 'Emperor',
battle ? 'guild' : 'emperor',
),
false,
);
g.players.push(
newPlayer('other', 'Atreides', 'atreides'),
newPlayer('ec', 'Ecaz', 'ecaz'),
);
Object.assign(g, {
status: 'playing',
phase: battle ? 6 : 2,
turn: 2,
storm: 18,
active: battle ? 'owner' : null,
order: ['owner', 'other', 'ec'],
deck: battle ? treacheryDeck(['ix']) : baseDeck(),
spiceDeck: spiceDeck(),
phaseOpening: null,
response: null,
decision: null,
ready: [],
});
for (const p of g.players)
Object.assign(p, {
spice: 20,
reserves: 20,
tanks: 0,
forces: {},
hand: [],
traitors: [],
traitorChoices: [],
});
if (!battle)
player(g, 'owner').elites = {
reserves: 5,
tanks: 0,
forces: {},
revived: 0,
};
player(g, 'ec').reserves = reserves;
player(g, 'ec').forces = { 'polar_sink:0': 20 - reserves };
if (battle) {
player(g, 'owner').reserves = 14;
player(g, 'owner').forces = { 'arrakeen:10': 6 };
player(g, 'other').reserves = 18;
player(g, 'other').forces = { 'arrakeen:10': 2 };
}
g.homeworlds = { custody: createHomeworldCustody(homeworldContext(g)) };
return g;
}
function inventory(g: Game, expected: readonly string[]) {
homeworldGameIntegrity(g);
for (const p of g.players)
assert.equal(
p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0),
20,
);
const ids = [...g.deck, ...g.discard, ...g.players.flatMap((p) => p.hand)]
.map((card) => card.id)
.sort();
assert.deepEqual(ids, [...expected].sort());
assert.equal(new Set(ids).size, ids.length);
}
const baseIds = baseDeck().map((card) => card.id);
function endBidding(g: Game) {
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
assert.equal(g.phase, 3);
for (let n = 0; g.auction && n < 40; n++)
g = applyAction(g, g.auction.active, { type: 'passBid' });
assert.ok(g.biddingEnd);
return g;
}
function dispose(g: Game, ids: string[]) {
return applyAction(g, 'owner', {
type: 'biddingEnd',
event: g.biddingEnd!.event,
mode: 'discard',
cards: ids,
});
}
function readyBattle(
g: Game,
weapon?: string,
defense?: string,
enemyWeapon?: string,
enemyDefense?: string,
expectedDecision = 'battleCards',
) {
g = applyAction(g, 'owner', {
type: 'chooseBattle',
territory: 'arrakeen',
target: 'other',
});
for (let n = 0; n < 30; n++) {
if (g.response) {
const id = g.players.find((p) => !g.response!.passed.includes(p.id))!.id;
g = applyAction(g, id, { type: 'passResponse' });
} else if (g.decision?.kind === 'fullPlanOffer')
g = applyAction(g, g.decision.player, {
type: 'decision',
decline: true,
});
else if (g.battle?.preparation)
g = applyAction(g, g.battle.preparation.owner, {
type: 'declineBattlePower',
});
else break;
}
assert.equal(g.decision, null, JSON.stringify(g.decision));
const leader = [...player(g, 'owner').leaders].sort(
(a, b) => b.strength - a.strength,
)[0].id;
const enemy = [...player(g, 'other').leaders].sort(
(a, b) => a.strength - b.strength,
)[0].id;
g = applyAction(g, 'owner', {
type: 'battlePlan',
dial: 3,
leader,
weapon,
defense,
});
g = applyAction(g, 'other', {
type: 'battlePlan',
dial: 0,
leader: enemy,
...(enemyWeapon ? { weapon: enemyWeapon } : {}),
...(enemyDefense ? { defense: enemyDefense } : {}),
});
if (g.decision?.kind === 'poisonTooth')
g = applyAction(g, g.decision.player, { type: 'decision', activate: true });
g = applyAction(g, 'owner', { type: 'traitorCall', call: false });
g = applyAction(g, 'other', { type: 'traitorCall', call: false });
assert.equal(g.decision?.kind, expectedDecision);
assert.equal(g.decision?.player, 'owner');
return g;
}
void test('actual Kaitain batch pays Ecaz once per physical poison discard while Emperor pays the full cost first', () => {
let g = fixture();
const first = hold(g, 'owner', (card) => card.kind === 'poison');
const second = hold(g, 'owner', (card) => card.kind === 'poison');
hold(g, 'owner', (card) => card.kind === 'shield');
g = dispose(endBidding(g), [first.id, second.id]);
assert.equal(player(g, 'owner').spice, 16);
assert.equal(player(g, 'ec').spice, 26);
assert.equal(
viewGame(g, 'ec').ecazPoisonIncome.reduce(
(sum, income) => sum + income.amount,
0,
),
6,
);
assert.equal(
viewGame(g, 'ec').ecazPoisonIncome.reduce(
(sum, income) => sum + income.count,
0,
),
2,
);
assert.deepEqual(viewGame(g, 'other').ecazPoisonIncome, []);
assert.deepEqual(viewGame(g, 'owner').ecazPoisonIncome, []);
assert.deepEqual(normalizeAutomaticGame(reload(g)), g);
inventory(g, baseIds);
});
void test('current low Ecaz earns nothing; a real Ghola return reaching seven enables only later poison discards', () => {
let g = fixture(6);
player(g, 'ec').forces['polar_sink:0']--;
player(g, 'ec').tanks = 1;
const first = hold(g, 'owner', (card) => card.kind === 'poison');
const second = hold(g, 'owner', (card) => card.kind === 'poison');
hold(g, 'owner', (card) => card.kind === 'shield');
const ghola = hold(g, 'ec', (card) => card.effect === 'ghola');
g = dispose(endBidding(g), [first.id]);
assert.equal(player(g, 'ec').spice, 20);
assert.deepEqual(viewGame(g, 'ec').ecazPoisonIncome, []);
g = applyAction(reload(g), 'ec', {
type: 'card',
card: ghola.id,
amount: 1,
elite: 0,
});
assert.equal(player(g, 'ec').reserves, 7);
g = dispose(g, [second.id]);
assert.equal(player(g, 'ec').spice, 23);
assert.equal(viewGame(g, 'ec').ecazPoisonIncome.length, 1);
inventory(g, baseIds);
});
void test('hidden Kaitain poison versus non-poison disposal leaves an uninvolved observer’s complete view equivalent', () => {
let g = fixture();
const poison = hold(g, 'owner', (card) => card.kind === 'poison');
const shield = hold(g, 'owner', (card) => card.kind === 'shield');
hold(g, 'owner', (card) => card.kind === 'worthless');
g = endBidding(g);
const poisonResult = dispose(reload(g), [poison.id]);
const plainResult = dispose(reload(g), [shield.id]);
assert.equal(player(poisonResult, 'ec').spice, 23);
assert.equal(player(plainResult, 'ec').spice, 20);
assert.deepEqual(
viewGame(poisonResult, 'other'),
viewGame(plainResult, 'other'),
);
assert.deepEqual(poisonResult.log, plainResult.log);
assert.equal(
viewGame(poisonResult, 'other').players.find((p) => p.id === 'ec')!.spice,
undefined,
);
inventory(poisonResult, baseIds);
inventory(plainResult, baseIds);
});
void test('an unused Chemistry discarded from a hand does not earn poison income', () => {
let g = fixture();
const chemistry = treacheryDeck(['ix']).find(
(card) => card.kind === 'chemistry',
)!;
g.deck.push(chemistry);
hold(g, 'owner', (card) => card.id === chemistry.id);
hold(g, 'owner', (card) => card.kind === 'shield');
g = dispose(endBidding(g), [chemistry.id]);
assert.equal(player(g, 'ec').spice, 20);
assert.deepEqual(viewGame(g, 'ec').ecazPoisonIncome, []);
inventory(g, [...baseIds, chemistry.id]);
});
void test('Chemistry pays only when discarded after its actual winning weapon role, including refreshed delayed cleanup', () => {
for (const slot of ['weapon', 'defense'] as const) {
let g = fixture(7, true);
const chemistry = hold(g, 'owner', (card) => card.kind === 'chemistry');
const complement = hold(
g,
'owner',
(card) => card.kind === (slot === 'weapon' ? 'shield' : 'projectile'),
);
g = readyBattle(
g,
slot === 'weapon' ? chemistry.id : complement.id,
slot === 'defense' ? chemistry.id : complement.id,
);
assert.equal(
player(g, 'ec').spice,
20,
'Keeping a revealed winning card does not discard it.',
);
assert.equal(
g.lastBattleContext!.cardRoles!.owner[chemistry.id].battleSlot,
slot,
);
const saved = reload(g);
g = applyAction(saved, 'owner', {
type: 'decision',
discard: [chemistry.id],
});
assert.equal(player(g, 'ec').spice, slot === 'weapon' ? 23 : 20);
assert.equal(
g.discard.filter((card) => card.id === chemistry.id).length,
1,
);
assert.ok(
player(g, 'owner').hand.some((card) => card.id === complement.id),
);
assert.deepEqual(normalizeAutomaticGame(reload(g)), g);
inventory(
g,
treacheryDeck(['ix']).map((card) => card.id),
);
}
});
void test('Mirror remains gated at actual plan selection and cannot award income without a committed battle role', () => {
const g = fixture(7, true);
const mirror = richeseCards().find((card) => card.effect === 'mirrorWeapon')!;
g.deck.push(mirror);
hold(g, 'owner', (card) => card.id === mirror.id);
const defense = hold(g, 'owner', (card) => card.kind === 'snooper');
const poison = hold(g, 'other', (card) => card.kind === 'poison');
const before = reload(g);
assert.throws(() => readyBattle(g, mirror.id, defense.id, poison.id), RuleError);
assert.deepEqual(g, before);
assert.equal(player(g, 'ec').spice, 20);
assert.deepEqual(viewGame(g, 'ec').ecazPoisonIncome, []);
inventory(g, [...treacheryDeck(['ix']).map((card) => card.id), mirror.id]);
});
void test('mandatory losing Chemistry disposal uses the original battle slot before the hand is cleared', () => {
for (const slot of ['weapon', 'defense'] as const) {
let g = fixture(7, true);
const weapon = hold(g, 'owner', (card) => card.kind === 'projectile');
const shield = hold(g, 'owner', (card) => card.kind === 'shield');
const chemistry = hold(g, 'other', (card) => card.kind === 'chemistry');
const complement = hold(
g,
'other',
(card) => card.kind === (slot === 'weapon' ? 'snooper' : 'projectile'),
);
g = readyBattle(
g,
weapon.id,
shield.id,
slot === 'weapon' ? chemistry.id : complement.id,
slot === 'defense' ? chemistry.id : complement.id,
);
assert.equal(player(g, 'ec').spice, slot === 'weapon' ? 23 : 20);
assert.equal(
g.discard.filter((card) => card.id === chemistry.id).length,
1,
);
assert.equal(player(g, 'other').hand.length, 0);
g = applyAction(reload(g), 'owner', { type: 'decision', discard: [] });
assert.equal(player(g, 'ec').spice, slot === 'weapon' ? 23 : 20);
inventory(
g,
treacheryDeck(['ix']).map((card) => card.id),
);
}
});
void test('saved delayed Chemistry cleanup rejects a corrupted defense-to-weapon role before minting Ecaz income', () => {
let g = fixture(7, true);
const chemistry = hold(g, 'owner', (card) => card.kind === 'chemistry');
const weapon = hold(g, 'owner', (card) => card.kind === 'projectile');
g = readyBattle(g, weapon.id, chemistry.id);
g.lastBattleContext!.cardRoles!.owner[chemistry.id].battleSlot = 'weapon';
const before = reload(g);
assert.throws(() => viewGame(g, 'ec'));
assert.throws(() =>
applyAction(g, 'owner', { type: 'decision', discard: [chemistry.id] }),
);
assert.deepEqual(g, before);
});
void test('native Ecaz earns losing poison income at seven before winner losses lower population, then earns none for its own later discard', () => {
let g = fixture(7, true);
const ecaz = player(g, 'ec');
const visitor = player(g, 'owner');
visitor.reserves = 18;
visitor.forces = {};
g.homeworlds!.custody!.visitors['homeworld:ecaz'] = {
owner: { normal: 2, elite: 0 },
};
g.active = 'ec';
g.order = ['ec', 'owner', 'other'];
const nativePoison = hold(g, 'ec', (card) => card.kind === 'poison');
const nativeDefense = hold(g, 'ec', (card) => card.kind === 'snooper');
const invadingPoison = hold(g, 'owner', (card) => card.kind === 'poison');
const invadingDefense = hold(g, 'owner', (card) => card.kind === 'snooper');
const nativeLeader = [...ecaz.leaders].sort(
(a, b) => b.strength - a.strength,
)[0].id;
const invadingLeader = [...visitor.leaders].sort(
(a, b) => a.strength - b.strength,
)[0].id;
g = applyAction(g, 'ec', {
type: 'chooseBattle',
territory: 'homeworld:ecaz',
target: 'owner',
});
for (let n = 0; g.battle?.preparation && n < 20; n++)
g = applyAction(g, g.battle.preparation.owner, {
type: 'declineBattlePower',
});
assert.equal(g.decision, null, JSON.stringify(g.decision));
g = applyAction(g, 'ec', {
type: 'battlePlan',
dial: 1,
leader: nativeLeader,
weapon: nativePoison.id,
defense: nativeDefense.id,
});
g = applyAction(g, 'owner', {
type: 'battlePlan',
dial: 0,
leader: invadingLeader,
weapon: invadingPoison.id,
defense: invadingDefense.id,
});
g = applyAction(g, 'ec', { type: 'traitorCall', call: false });
// Only the native faction may call a traitor in a Homeworld battle.
assert.equal(g.decision?.kind, 'battleCards');
assert.equal(player(g, 'ec').reserves, 6);
assert.equal(player(g, 'ec').tanks, 1);
assert.equal(player(g, 'ec').spice, 23);
assert.equal(
g.discard.filter((card) => card.id === invadingPoison.id).length,
1,
);
assert.equal(g.decision?.kind, 'battleCards');
assert.equal(g.decision?.player, 'ec');
assert.ok(player(g, 'ec').hand.some((card) => card.id === nativePoison.id));
assert.equal(
viewGame(g, 'ec').ecazPoisonIncome.reduce(
(sum, income) => sum + income.amount,
0,
),
3,
);
g = applyAction(reload(g), 'ec', {
type: 'decision',
discard: [nativePoison.id],
});
assert.equal(player(g, 'ec').spice, 23);
assert.equal(
g.discard.filter((card) => card.id === nativePoison.id).length,
1,
);
assert.equal(
viewGame(g, 'ec').ecazPoisonIncome.reduce(
(sum, income) => sum + income.count,
0,
),
1,
);
inventory(
g,
treacheryDeck(['ix']).map((card) => card.id),
);
});
void test('Moritani delayed Chemistry cleanup preserves its actual weapon or defense role and pays only if discarded', () => {
for (const slot of ['weapon', 'defense'] as const)
for (const keepChemistry of [false, true]) {
let g = fixture(7, true);
g.advanced = true;
const moritani = newPlayer('mo', 'Moritani', 'moritani');
moritani.spice = 20;
g.players.push(moritani);
g.moritaniTerror = createTerrorState(() => 0);
g.order.push('mo');
player(g, 'other').ally = 'mo';
moritani.ally = 'other';
g.homeworlds = { custody: createHomeworldCustody(homeworldContext(g)) };
const weapon = hold(g, 'owner', (card) => card.kind === 'projectile');
const defense = hold(g, 'owner', (card) => card.kind === 'shield');
const chemistry = hold(g, 'other', (card) => card.kind === 'chemistry');
const complement = hold(
g,
'other',
(card) => card.kind === (slot === 'weapon' ? 'snooper' : 'projectile'),
);
g = readyBattle(
g,
weapon.id,
defense.id,
slot === 'weapon' ? chemistry.id : complement.id,
slot === 'defense' ? chemistry.id : complement.id,
);
assert.equal(player(g, 'ec').spice, 20);
assert.ok(
player(g, 'other').hand.some((card) => card.id === chemistry.id),
);
g = applyAction(g, 'owner', { type: 'decision', discard: [] });
assert.equal(g.decision?.kind, 'moritaniRetention');
assert.equal(g.decision?.player, 'other');
assert.equal(
g.lastBattleContext!.cardRoles!.other[chemistry.id].battleSlot,
slot,
);
g = applyAction(reload(g), 'other', {
type: 'decision',
keep: keepChemistry ? chemistry.id : null,
});
assert.equal(
player(g, 'ec').spice,
slot === 'weapon' && !keepChemistry ? 23 : 20,
);
assert.equal(
g.discard.filter((card) => card.id === chemistry.id).length,
keepChemistry ? 0 : 1,
);
assert.equal(
player(g, 'other').hand.some((card) => card.id === chemistry.id),
keepChemistry,
);
assert.deepEqual(normalizeAutomaticGame(reload(g)), g);
inventory(
g,
treacheryDeck(['ix']).map((card) => card.id),
);
}
});
void test('winning used Poison Tooth is discarded after native Ecaz losses lower seven to six, while losing poison pays before those losses', () => {
let g = fixture(7, true);
const ecaz = player(g, 'ec');
const visitor = player(g, 'owner');
visitor.reserves = 18;
visitor.forces = {};
g.homeworlds!.custody!.visitors['homeworld:ecaz'] = {
owner: { normal: 2, elite: 0 },
};
g.active = 'ec';
g.order = ['ec', 'owner', 'other'];
const tooth = hold(g, 'ec', (card) => card.kind === 'poisonTooth');
const nativeDefense = hold(g, 'ec', (card) => card.kind === 'snooper');
const invadingPoison = hold(g, 'owner', (card) => card.kind === 'poison');
const invadingDefense = hold(g, 'owner', (card) => card.kind === 'snooper');
const nativeLeader = [...ecaz.leaders].sort(
(a, b) => b.strength - a.strength,
)[0];
const invadingLeader = [...visitor.leaders].sort(
(a, b) => a.strength - b.strength,
)[0];
g = applyAction(g, 'ec', {
type: 'chooseBattle',
territory: 'homeworld:ecaz',
target: 'owner',
});
for (let n = 0; g.battle?.preparation && n < 20; n++)
g = applyAction(g, g.battle.preparation.owner, {
type: 'declineBattlePower',
});
g = applyAction(g, 'ec', {
type: 'battlePlan',
dial: 1,
leader: nativeLeader.id,
weapon: tooth.id,
defense: nativeDefense.id,
});
g = applyAction(g, 'owner', {
type: 'battlePlan',
dial: 0,
leader: invadingLeader.id,
weapon: invadingPoison.id,
defense: invadingDefense.id,
});
assert.equal(g.decision?.kind, 'poisonTooth');
assert.equal(g.decision?.player, 'ec');
assert.equal(player(g, 'ec').reserves, 7);
assert.equal(player(g, 'ec').spice, 20);
g = applyAction(reload(g), 'ec', { type: 'decision', activate: true });
g = applyAction(reload(g), 'ec', { type: 'traitorCall', call: false });
assert.equal(g.lastBattleContext!.winner, 'ec');
assert.equal(player(g, 'ec').reserves, 6);
assert.equal(player(g, 'ec').tanks, 1);
assert.equal(g.discard.filter((card) => card.id === tooth.id).length, 1);
assert.equal(
g.discard.filter((card) => card.id === invadingPoison.id).length,
1,
);
const income = viewGame(g, 'ec').ecazPoisonIncome;
assert.equal(
income.reduce((sum, entry) => sum + entry.amount, 0),
3,
);
assert.equal(
income.reduce((sum, entry) => sum + entry.count, 0),
1,
);
assert.equal(g.decision?.kind, 'battleCards');
assert.equal(g.decision?.player, 'ec');
const afterBattleSpice = player(g, 'ec').spice;
g = applyAction(reload(g), 'ec', { type: 'decision', discard: [] });
assert.equal(player(g, 'ec').spice, afterBattleSpice);
assert.deepEqual(normalizeAutomaticGame(reload(g)), g);
inventory(
g,
treacheryDeck(['ix']).map((card) => card.id),
);
});
void test('used winning Poison Tooth stays reserved through real Ixian substitution and is discarded exactly once afterward', () => {
for (const substitute of [false, true]) {
let g = fixture(7, true);
g.advanced = true;
const ix = newPlayer('owner', 'Ixians', 'ixians');
Object.assign(ix, {
spice: 20,
reserves: 12,
forces: { 'arrakeen:10': 8 },
elites: {
reserves: 5,
tanks: 0,
forces: { 'arrakeen:10': 2 },
revived: 0,
},
});
g.players[0] = ix;
g.homeworlds = { custody: createHomeworldCustody(homeworldContext(g)) };
const tooth = hold(g, 'owner', (card) => card.kind === 'poisonTooth');
const shield = hold(g, 'owner', (card) => card.kind === 'shield');
const distrans = richeseCards().find((card) => card.effect === 'distrans')!;
g.deck.push(distrans);
hold(g, 'owner', (card) => card.id === distrans.id);
g = readyBattle(
g,
tooth.id,
shield.id,
undefined,
undefined,
'battleLosses',
);
assert.equal(player(g, 'ec').spice, 20);
if (g.decision?.kind !== 'battleLosses')
throw new Error('Missing typed Ixian casualty choice');
const choice = g.decision.options.findIndex((loss) => loss.elite === 1);
assert.ok(choice >= 0);
assert.ok(player(g, 'owner').hand.some((card) => card.id === tooth.id));
assert.equal(g.discard.filter((card) => card.id === tooth.id).length, 0);
g = applyAction(reload(g), 'owner', { type: 'decision', choice });
assert.equal(g.decision?.kind, 'ixSubstitution');
assert.deepEqual(g.pendingWinnerDiscards!.cards, [tooth.id]);
for (const corrupt of [
(saved: Game) => {
saved.pendingWinnerDiscards = null;
},
(saved: Game) => {
delete saved.pendingWinnerDiscards;
},
(saved: Game) => {
delete saved.lastBattleContext!.winnerDiscards;
},
]) {
const broken = reload(g);
corrupt(broken);
const before = reload(broken);
assert.throws(() => viewGame(broken, 'owner'), /winner|winning|discard/i);
assert.throws(
() => normalizeAutomaticGame(broken),
/winner|winning|discard/i,
);
assert.throws(
() => applyAction(broken, 'owner', { type: 'decision', decline: true }),
/winner|winning|discard/i,
);
assert.deepEqual(broken, before);
}
assert.ok(player(g, 'owner').hand.some((card) => card.id === tooth.id));
assert.equal(player(g, 'ec').spice, 20);
assert.deepEqual(g.pendingWinnerDiscards!.optional, [shield.id]);
for (const reserved of [tooth.id, shield.id]) {
assert.ok(
!cashInCards(g, player(g, 'owner')).some(
(card) => card.id === reserved,
),
'Both mandatory and optional winner cards remain reserved through casualties.',
);
const before = reload(g);
assert.throws(
() =>
applyAction(g, 'owner', {
type: 'card',
card: distrans.id,
target: 'other',
give: reserved,
}),
/committed|discard|reserved/i,
);
assert.deepEqual(g, before);
}
g = substitute
? applyAction(reload(g), 'owner', {
type: 'decision',
sources: { 'arrakeen:10': 1 },
recover: { 'arrakeen:10': 1 },
})
: applyAction(reload(g), 'owner', { type: 'decision', decline: true });
assert.equal(g.pendingWinnerDiscards, null);
assert.equal(g.pendingIxSubstitution, null);
assert.equal(g.discard.filter((card) => card.id === tooth.id).length, 1);
assert.equal(player(g, 'ec').spice, 23);
assert.equal(g.decision?.kind, 'battleCards');
assert.ok(!player(g, 'owner').hand.some((card) => card.id === tooth.id));
assert.equal(player(g, 'owner').elites!.tanks, substitute ? 0 : 1);
assert.deepEqual(normalizeAutomaticGame(reload(g)), g);
g = applyAction(g, 'owner', { type: 'decision', discard: [] });
assert.equal(player(g, 'ec').spice, 23);
inventory(g, [
...treacheryDeck(['ix']).map((card) => card.id),
distrans.id,
]);
}
});