713 lines
24 KiB
TypeScript
713 lines
24 KiB
TypeScript
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,
|
||
]);
|
||
}
|
||
});
|