530 lines
32 KiB
TypeScript
530 lines
32 KiB
TypeScript
import assert from 'node:assert/strict';
|
||
import test from 'node:test';
|
||
import { applyAction, type Game } from '../game/engine';
|
||
import { treacheryDeck } from '../game/cards';
|
||
import { ecazTreacheryCards } from '../game/ecaz-cards';
|
||
import { HARASS_WITHDRAW_CARD } from '../game/harass-withdraw';
|
||
import { REINFORCEMENTS_CARD } from '../game/reinforcements';
|
||
import { ownedTech } from '../game/tech-tokens';
|
||
import { homeworldContext } from '../game/homeworld-game';
|
||
import { homeworldForceGroups } from '../game/homeworld-custody';
|
||
import { DUKE_VIDAL_ID } from '../game/duke-vidal';
|
||
import {
|
||
assertEcazIndependentSkillsCustody,
|
||
createEcazIndependentSkillsFixture, ecazIndependentSkillsPlayer as player,
|
||
quoteEcazIndependentSkillsBattle, settleEcazIndependentSkillsBattle,
|
||
type EcazIndependentSkillsFixture,
|
||
} from './fixture-ecaz-independent-skills';
|
||
|
||
const at = (game: Game, id: string, key: string) => player(game, id).forces[key] ?? 0;
|
||
const change = (before: number, after: number) => after - before;
|
||
const cards = (game: Game) => [...game.deck, ...game.discard, ...(game.ixSetupCards ?? []),
|
||
...(game.auction?.cards.slice(game.auction.index) ?? []), ...game.players.flatMap(seat => seat.hand)];
|
||
/** The admitted physical source is the selected original families exactly once. */
|
||
function physicalSource(game: Game, count: number): void {
|
||
const source = cards(game);
|
||
assert.equal(source.length, count);
|
||
assert.deepEqual(source.map(card => card.id).sort(), [...treacheryDeck(game.expansions),
|
||
...(game.ecazTreachery ? ecazTreacheryCards() : [])].map(card => card.id).sort());
|
||
}
|
||
|
||
/** Both native starting armies stay physical outside the genuinely shipped battle. */
|
||
function pairedArmies(f: EcazIndependentSkillsFixture, end: Game): void {
|
||
for (const game of [f.afterSetup, f.game, end]) {
|
||
assert.equal(at(game, f.ecaz, 'imperial_basin:9'), 6);
|
||
assert.equal(at(game, f.ally, 'polar_sink:0'), 6);
|
||
}
|
||
assert.equal(at(f.game, f.ally, f.location), 4);
|
||
assert.equal(player(f.game, f.ally).reserves, player(f.afterSetup, f.ally).reserves - 4,
|
||
'Moritani ships four of its own reserves, not its original six or Imperial elites');
|
||
}
|
||
|
||
function disposedLeadCards(f: EcazIndependentSkillsFixture, end: Game): void {
|
||
assert.equal(end.lastBattleContext?.winnerDiscards?.completed, true);
|
||
assert.deepEqual(end.lastBattleContext?.winnerDiscards?.cards.slice().sort(), f.cardIds.slice().sort());
|
||
for (const id of f.cardIds) {
|
||
assert.equal(player(end, f.cardUser).hand.some(card => card.id === id), false);
|
||
assert.equal(end.discard.filter(card => card.id === id).length, 1);
|
||
}
|
||
}
|
||
|
||
for (const advanced of [false, true]) {
|
||
void test(`original independent cards with all14 ${advanced ? 'Advanced' : 'Basic Tech'} reach the printed lead choice, and the trained Ecaz Suk rescues only its own committed losses`, () => {
|
||
const f = createEcazIndependentSkillsFixture({ advanced, lead: 'ecaz', tech: !advanced });
|
||
physicalSource(f.initial, 36);
|
||
physicalSource(f.game, 36);
|
||
assert.equal(f.beforeLead.decision?.kind, 'ecazBattleLead');
|
||
assert.deepEqual(f.beforeLead.decision?.kind === 'ecazBattleLead' ? f.beforeLead.decision.choices
|
||
: [], [f.ecaz, f.ally]);
|
||
assert.equal(player(f.beforeLead, f.ecaz).ally, f.ally);
|
||
assert.equal(player(f.beforeLead, f.ally).ally, f.ecaz);
|
||
assert.equal(f.trainer, 'ecaz-0');
|
||
assert.equal(f.game.leaderSkills!.assignments.find(a => a.owner === f.ecaz)!.skill, 'suk-graduate');
|
||
assert.equal(f.game.phase, 6);
|
||
// Both printed cards occupy either slot but stay in the lead's own plan.
|
||
const plan = f.plans[0].action;
|
||
assert.ok(plan.type === 'battlePlan');
|
||
assert.equal(plan.weapon, REINFORCEMENTS_CARD);
|
||
assert.equal(plan.defense, HARASS_WITHDRAW_CARD);
|
||
assert.equal(f.fixedEcazDial, 2, 'ceil(E/2) of Ecaz’s four committed counters is free');
|
||
assert.equal(f.planDial, f.fixedEcazDial + 2, 'the plan dial adds the ally’s two variable counters');
|
||
const quote = quoteEcazIndependentSkillsBattle(f);
|
||
assert.deepEqual(quote.reinforcements,
|
||
[{ player: f.ecaz, card: REINFORCEMENTS_CARD, normal: 3, elite: 0 }]);
|
||
assert.deepEqual(quote.harassWithdraw![0].returned, { normal: 2, elite: 0 });
|
||
assert.equal(quote.harassWithdraw![0].remaining.normal, 2);
|
||
assert.equal(quote.harassWithdraw![0].remaining.elite, 0);
|
||
assert.equal(quote.harassWithdraw![0].remaining.freeSupport, true,
|
||
'the mandatory fixed Ecaz counters are fully supported by the coalition plan');
|
||
assert.deepEqual(quote.harassWithdraw![0].locations, { [f.location]: { normal: 2, elite: 0 } });
|
||
assert.deepEqual(quote.fixedLosses, [{ owner: f.ecaz, normal: 2, elite: 0 }]);
|
||
assert.equal(quote.casualties?.owner, f.ally);
|
||
assert.deepEqual(quote.sukGraduate, { leader: 'ecaz-0', mode: 'skilled' });
|
||
assert.ok(quote.scores);
|
||
assert.equal(quote.scores[quote.winner === f.game.battle!.attacker ? 'attacker' : 'defender'],
|
||
player(f.game, f.ecaz).leaders.find(leader => leader.id === 'ecaz-0')!.strength +
|
||
f.planDial + 2, 'the card’s +2 stays a separate score modifier');
|
||
// The real rescue offer is the skilled band of Ecaz's own committed losses.
|
||
const rescue = settleEcazIndependentSkillsBattle(f, { stop: 'rescue' });
|
||
assert.equal(rescue.decision?.kind, 'sukRescue');
|
||
if (rescue.decision?.kind !== 'sukRescue') throw Error('Expected the original Suk rescue window');
|
||
assert.equal(rescue.decision.mode, 'skilled');
|
||
assert.equal(rescue.decision.player, f.ecaz);
|
||
assert.deepEqual(rescue.decision.options.filter(option => option.kept),
|
||
[{ normal: 1, elite: 0, kept: { key: f.location, kind: 'normal' } },
|
||
{ normal: 2, elite: 0, kept: { key: f.location, kind: 'normal' } }]);
|
||
const end = settleEcazIndependentSkillsBattle(f, { state: rescue });
|
||
assert.equal(end.phase, 7, 'the original battle continues to native Spice Collection');
|
||
assert.equal(end.lastBattleContext?.winner, f.ecaz);
|
||
assert.equal(end.lastBattleContext?.result, 'normal');
|
||
assert.equal(end.lastBattleContext?.sukRescue?.completed, true);
|
||
assert.equal(end.pendingSukRescue, null);
|
||
// Ecaz: three fee counters in the Tanks, two returned undialed, one rescued
|
||
// counter kept in the sector and one back in reserves.
|
||
assert.equal(change(player(f.game, f.ecaz).reserves, player(end, f.ecaz).reserves), 0);
|
||
assert.equal(change(player(f.game, f.ecaz).tanks, player(end, f.ecaz).tanks), 3);
|
||
assert.equal(at(f.game, f.ecaz, f.location), 4);
|
||
assert.equal(at(end, f.ecaz, f.location), 1);
|
||
assert.equal(at(end, f.ecaz, 'imperial_basin:9'), at(f.game, f.ecaz, 'imperial_basin:9'));
|
||
// The ally's own variable army is lossy and never rescued by Ecaz's skill.
|
||
assert.equal(change(player(f.game, f.ally).reserves, player(end, f.ally).reserves), 0);
|
||
assert.equal(change(player(f.game, f.ally).tanks, player(end, f.ally).tanks), 2);
|
||
assert.equal(at(end, f.ally, f.location), 2);
|
||
// The defeated defender loses its whole army and never touches the fee.
|
||
assert.equal(change(player(f.game, f.opponent).tanks, player(end, f.opponent).tanks), 2);
|
||
assert.equal(at(end, f.opponent, f.location), 0);
|
||
assert.equal(change(player(f.game, f.opponent).reserves, player(end, f.opponent).reserves), 0);
|
||
if (!advanced) {
|
||
const tokens = ownedTech(f.game.techTokens, f.opponent);
|
||
assert.equal(tokens.length, 1, 'the actual defeated Guild carries one original token');
|
||
assert.equal(end.techTokens![tokens[0]].owner, f.ecaz,
|
||
'original winner Tech cleanup credits the selected Ecaz plan, after its own card fee and rescue');
|
||
disposedLeadCards(f, end);
|
||
}
|
||
assertEcazIndependentSkillsCustody(end);
|
||
});
|
||
}
|
||
|
||
for (const card of ['reinforcements', 'harass'] as const) {
|
||
void test(`${card === 'reinforcements' ? 'Reinforcements alone pays three own reserves before the rescue' : 'Harass & Withdraw alone returns only the card user’s undialed counters'}, never the fixed Ecaz contribution`, () => {
|
||
const f = createEcazIndependentSkillsFixture({ cards: [card] });
|
||
physicalSource(f.game, 36);
|
||
const quote = quoteEcazIndependentSkillsBattle(f);
|
||
if (card === 'reinforcements') {
|
||
assert.deepEqual(quote.reinforcements,
|
||
[{ player: f.ecaz, card: REINFORCEMENTS_CARD, normal: 3, elite: 0 }]);
|
||
assert.equal(quote.harassWithdraw, undefined);
|
||
} else {
|
||
assert.equal(quote.reinforcements, undefined);
|
||
assert.deepEqual(quote.harassWithdraw![0].returned, { normal: 2, elite: 0 });
|
||
assert.equal(quote.harassWithdraw![0].remaining.freeSupport, true);
|
||
}
|
||
const end = settleEcazIndependentSkillsBattle(f);
|
||
assert.equal(end.phase, 7);
|
||
assert.equal(end.lastBattleContext?.winner, f.ecaz);
|
||
assert.equal(change(player(f.game, f.ecaz).tanks, player(end, f.ecaz).tanks),
|
||
card === 'reinforcements' ? 3 : 0, 'only the Reinforcements fee reaches the Tanks');
|
||
assert.equal(change(player(f.game, f.ecaz).reserves, player(end, f.ecaz).reserves),
|
||
card === 'reinforcements' ? -2 : 3);
|
||
assert.equal(at(end, f.ecaz, f.location), card === 'reinforcements' ? 3 : 1);
|
||
assert.equal(change(player(f.game, f.ally).tanks, player(end, f.ally).tanks), 2);
|
||
assert.equal(at(end, f.ally, f.location), 2);
|
||
assertEcazIndependentSkillsCustody(end);
|
||
});
|
||
}
|
||
|
||
void test('an ally-led coalition spends the ally’s own fee and army and never rescues Ecaz’s fixed commitment', () => {
|
||
const f = createEcazIndependentSkillsFixture({ lead: 'ally' });
|
||
physicalSource(f.game, 36);
|
||
assert.equal(f.plans[0].actor, f.ally);
|
||
const quote = quoteEcazIndependentSkillsBattle(f);
|
||
assert.deepEqual(quote.reinforcements,
|
||
[{ player: f.ally, card: REINFORCEMENTS_CARD, normal: 3, elite: 0 }],
|
||
'the card user pays the printed reserve cost, not the coalition partner');
|
||
assert.deepEqual(quote.harassWithdraw![0].returned, { normal: 0, elite: 2 });
|
||
assert.deepEqual(quote.harassWithdraw![0].locations, { [f.location]: { normal: 0, elite: 2 } });
|
||
assert.deepEqual(quote.fixedLosses, [{ owner: f.ecaz, normal: 2, elite: 0 }]);
|
||
assert.equal(quote.sukGraduate, undefined, 'the trained Ecaz disc is not the winning plan’s leader');
|
||
const end = settleEcazIndependentSkillsBattle(f);
|
||
assert.equal(end.phase, 7);
|
||
assert.equal(end.lastBattleContext?.winner, f.ally);
|
||
assert.equal(end.lastBattleContext?.sukRescue, undefined);
|
||
assert.equal(f.trainer, 'ecaz-0');
|
||
assert.equal(player(end, f.ecaz).leaders.find(leader => leader.id === f.trainer)!.dead, false);
|
||
// Ecaz's fixed commitment dies unrescued while its own card custody is untouched.
|
||
assert.equal(change(player(f.game, f.ecaz).reserves, player(end, f.ecaz).reserves), 0);
|
||
assert.equal(change(player(f.game, f.ecaz).tanks, player(end, f.ecaz).tanks), 2);
|
||
assert.equal(at(f.game, f.ecaz, f.location), 4);
|
||
assert.equal(at(end, f.ecaz, f.location), 2);
|
||
// The ally pays three reserve fee counters plus its own two commitment losses.
|
||
assert.equal(change(player(f.game, f.ally).reserves, player(end, f.ally).reserves), -1);
|
||
assert.equal(change(player(f.game, f.ally).tanks, player(end, f.ally).tanks), 5);
|
||
assert.equal(at(end, f.ally, f.location), 0);
|
||
assert.equal(change(player(f.game, f.opponent).tanks, player(end, f.opponent).tanks), 2);
|
||
assertEcazIndependentSkillsCustody(end);
|
||
});
|
||
|
||
void test('a revealed skill keeps the skilled leader out of the plan and rescues exactly one committed counter with no kept counter', () => {
|
||
const f = createEcazIndependentSkillsFixture({ hide: false });
|
||
physicalSource(f.game, 36);
|
||
const plan = f.plans[0];
|
||
assert.ok(plan.action.type === 'battlePlan' && plan.action.leader !== f.trainer,
|
||
'the face-up skilled leader cannot be in the battle plan');
|
||
const quote = quoteEcazIndependentSkillsBattle(f);
|
||
assert.deepEqual(quote.sukGraduate, { leader: 'ecaz-0', mode: 'normal' });
|
||
const end = settleEcazIndependentSkillsBattle(f);
|
||
assert.equal(end.phase, 7);
|
||
assert.equal(end.pendingSukRescue, null, 'the single normal-band option settles automatically');
|
||
assert.equal(end.lastBattleContext?.sukRescue?.completed, true);
|
||
// Three fee counters plus the one unrescued committed counter reach the Tanks;
|
||
// the normal band never keeps a counter in the battle sector.
|
||
assert.equal(change(player(f.game, f.ecaz).tanks, player(end, f.ecaz).tanks), 4);
|
||
assert.equal(change(player(f.game, f.ecaz).reserves, player(end, f.ecaz).reserves), 0);
|
||
assert.equal(at(end, f.ecaz, f.location), 0);
|
||
assert.equal(change(player(f.game, f.ally).tanks, player(end, f.ally).tanks), 2);
|
||
assertEcazIndependentSkillsCustody(end);
|
||
});
|
||
|
||
void test('an opposing successful Traitor cancels Harass but preserves the card-user reserve fee', () => {
|
||
const f = createEcazIndependentSkillsFixture({ traitor: true, advanced: true });
|
||
assert.equal(f.traitor, f.trainer);
|
||
assert.ok(player(f.game, f.opponent).traitors.includes(f.traitor!));
|
||
const end = settleEcazIndependentSkillsBattle(f);
|
||
assert.equal(end.phase, 7);
|
||
assert.equal(end.lastBattleContext?.result, 'traitor');
|
||
assert.equal(end.lastBattleContext?.winner, f.opponent);
|
||
assert.equal(end.lastBattleContext?.sukRescue, undefined);
|
||
assert.equal(change(player(f.game, f.ecaz).reserves, player(end, f.ecaz).reserves), -3);
|
||
assert.equal(change(player(f.game, f.ecaz).tanks, player(end, f.ecaz).tanks), 7);
|
||
assert.equal(at(end, f.ecaz, f.location), 0);
|
||
assert.equal(change(player(f.game, f.ally).tanks, player(end, f.ally).tanks), 4);
|
||
assert.equal(at(end, f.ally, f.location), 0);
|
||
assert.equal(at(end, f.opponent, f.location), 2);
|
||
assertEcazIndependentSkillsCustody(end);
|
||
});
|
||
|
||
for (const advanced of [false, true]) {
|
||
void test(`the printed ${advanced ? 'Advanced' : 'Basic'} Occupy needs no Skills: the no-Skills independent control settles the same real coalition with both cards`, () => {
|
||
const f = createEcazIndependentSkillsFixture({ advanced, skills: false });
|
||
assert.equal(f.trainer, null);
|
||
assert.equal(f.game.leaderSkills, undefined);
|
||
physicalSource(f.game, 36);
|
||
assert.equal(f.beforeLead.decision?.kind, 'ecazBattleLead');
|
||
const quote = quoteEcazIndependentSkillsBattle(f);
|
||
assert.deepEqual(quote.reinforcements,
|
||
[{ player: f.ecaz, card: REINFORCEMENTS_CARD, normal: 3, elite: 0 }]);
|
||
assert.deepEqual(quote.harassWithdraw![0].returned, { normal: 2, elite: 0 });
|
||
assert.equal(quote.sukGraduate, undefined);
|
||
const end = settleEcazIndependentSkillsBattle(f);
|
||
assert.equal(end.phase, 7);
|
||
assert.equal(end.lastBattleContext?.winner, f.ecaz);
|
||
assert.equal(end.lastBattleContext?.sukRescue, undefined);
|
||
// Fee first, then the return, then the mandatory commitment losses.
|
||
assert.equal(change(player(f.game, f.ecaz).tanks, player(end, f.ecaz).tanks), 5);
|
||
assert.equal(change(player(f.game, f.ecaz).reserves, player(end, f.ecaz).reserves), -1);
|
||
assert.equal(at(end, f.ecaz, f.location), 0);
|
||
assert.equal(change(player(f.game, f.ally).tanks, player(end, f.ally).tanks), 2);
|
||
assert.equal(at(end, f.ally, f.location), 2);
|
||
assert.equal(change(player(f.game, f.opponent).tanks, player(end, f.opponent).tanks), 2);
|
||
assertEcazIndependentSkillsCustody(end);
|
||
});
|
||
}
|
||
|
||
for (const advanced of [false, true]) {
|
||
void test(`the real no-card, no-Skills ${advanced ? 'Advanced' : 'Basic'} coalition still exposes its lead choice and settles to Collection`, () => {
|
||
const f = createEcazIndependentSkillsFixture({ advanced, skills: false, independent: false, cards: [] });
|
||
assert.equal(f.initial.ecazTreachery, undefined);
|
||
assert.equal(f.game.leaderSkills, undefined);
|
||
assert.deepEqual(f.cardIds, []);
|
||
physicalSource(f.initial, 33);
|
||
physicalSource(f.game, 33);
|
||
assert.equal(f.beforeLead.decision?.kind, 'ecazBattleLead');
|
||
const end = settleEcazIndependentSkillsBattle(f);
|
||
assert.equal(end.phase, 7);
|
||
assert.equal(end.lastBattleContext?.winner, f.ecaz);
|
||
assert.equal(end.lastBattleContext?.result, 'normal');
|
||
assert.equal(change(player(f.game, f.ecaz).tanks, player(end, f.ecaz).tanks), 2);
|
||
assert.equal(change(player(f.game, f.ecaz).reserves, player(end, f.ecaz).reserves), 0);
|
||
assert.equal(at(end, f.ecaz, f.location), 2);
|
||
assert.equal(change(player(f.game, f.ally).tanks, player(end, f.ally).tanks), 2);
|
||
assert.equal(at(end, f.ally, f.location), 2);
|
||
assert.equal(change(player(f.game, f.opponent).tanks, player(end, f.opponent).tanks), 2);
|
||
assert.equal(at(end, f.opponent, f.location), 0);
|
||
assertEcazIndependentSkillsCustody(end);
|
||
});
|
||
}
|
||
|
||
|
||
for (const advanced of [false, true]) {
|
||
void test(`native paired no-card all14 ${advanced ? 'Advanced Moritani lead keeps Ecaz losses separate' : 'Basic Ecaz lead rescues its fixed commitment'} through coalition cleanup`, () => {
|
||
const f = createEcazIndependentSkillsFixture({
|
||
paired: true, advanced, independent: false, cards: [], lead: advanced ? 'ally' : 'ecaz',
|
||
});
|
||
physicalSource(f.initial, 33);
|
||
physicalSource(f.game, 33);
|
||
const quote = quoteEcazIndependentSkillsBattle(f);
|
||
assert.deepEqual(quote.fixedLosses, [{ owner: f.ecaz, normal: 2, elite: 0 }]);
|
||
assert.equal(quote.casualties?.owner, f.ally);
|
||
assert.equal(quote.reinforcements, undefined);
|
||
assert.equal(quote.harassWithdraw, undefined);
|
||
const end = settleEcazIndependentSkillsBattle(f);
|
||
assert.equal(end.phase, 7);
|
||
assert.equal(end.lastBattleContext?.winner, f.cardUser);
|
||
assert.equal(end.lastBattleContext?.result, 'normal');
|
||
assert.equal(end.lastBattleContext?.sukRescue?.completed, advanced ? undefined : true);
|
||
assert.equal(change(player(f.game, f.ecaz).tanks, player(end, f.ecaz).tanks), advanced ? 2 : 0);
|
||
assert.equal(change(player(f.game, f.ecaz).reserves, player(end, f.ecaz).reserves), advanced ? 0 : 1);
|
||
assert.equal(at(end, f.ecaz, f.location), advanced ? 2 : 3);
|
||
assert.equal(change(player(f.game, f.ally).tanks, player(end, f.ally).tanks), 2);
|
||
assert.equal(change(player(f.game, f.ally).reserves, player(end, f.ally).reserves), 0);
|
||
assert.equal(at(end, f.ally, f.location), 2);
|
||
assert.equal(change(player(f.game, f.opponent).tanks, player(end, f.opponent).tanks), 2);
|
||
assert.equal(at(end, f.opponent, f.location), 0);
|
||
pairedArmies(f, end);
|
||
assertEcazIndependentSkillsCustody(end);
|
||
});
|
||
}
|
||
|
||
void test('native paired Ecaz lead pays its own fee, returns its undialed fixed army and rescues only its committed losses', () => {
|
||
const f = createEcazIndependentSkillsFixture({ paired: true });
|
||
physicalSource(f.initial, 36);
|
||
const quote = quoteEcazIndependentSkillsBattle(f);
|
||
assert.deepEqual(quote.reinforcements,
|
||
[{ player: f.ecaz, card: REINFORCEMENTS_CARD, normal: 3, elite: 0 }]);
|
||
assert.deepEqual(quote.harassWithdraw![0].returned, { normal: 2, elite: 0 });
|
||
assert.deepEqual(quote.harassWithdraw![0].locations, { [f.location]: { normal: 2, elite: 0 } });
|
||
assert.equal(quote.casualties?.owner, f.ally);
|
||
const rescue = settleEcazIndependentSkillsBattle(f, { stop: 'rescue' });
|
||
assert.ok(rescue.decision?.kind === 'sukRescue');
|
||
assert.equal(rescue.decision.player, f.ecaz);
|
||
assert.equal(rescue.decision.mode, 'skilled');
|
||
assert.deepEqual(rescue.decision.options.filter(option => option.kept),
|
||
[{ normal: 1, elite: 0, kept: { key: f.location, kind: 'normal' } },
|
||
{ normal: 2, elite: 0, kept: { key: f.location, kind: 'normal' } }]);
|
||
// Fee is already in Ecaz's Tanks; its undialed pair is already home. Neither
|
||
// the fee nor Moritani's two casualties enter this actual rescue offer.
|
||
assert.equal(change(player(f.game, f.ecaz).tanks, player(rescue, f.ecaz).tanks), 3);
|
||
assert.equal(change(player(f.game, f.ecaz).reserves, player(rescue, f.ecaz).reserves), -1);
|
||
assert.equal(at(rescue, f.ecaz, f.location), 2, 'only the committed pair awaits the rescue choice');
|
||
assert.equal(change(player(f.game, f.ally).tanks, player(rescue, f.ally).tanks), 2);
|
||
const end = settleEcazIndependentSkillsBattle(f, { state: rescue });
|
||
assert.equal(end.phase, 7);
|
||
assert.equal(end.lastBattleContext?.winner, f.ecaz);
|
||
assert.equal(end.lastBattleContext?.sukRescue?.completed, true);
|
||
assert.equal(change(player(f.game, f.ecaz).tanks, player(end, f.ecaz).tanks), 3);
|
||
assert.equal(change(player(f.game, f.ecaz).reserves, player(end, f.ecaz).reserves), 0);
|
||
assert.equal(at(end, f.ecaz, f.location), 1);
|
||
assert.equal(change(player(f.game, f.ally).tanks, player(end, f.ally).tanks), 2);
|
||
assert.equal(change(player(f.game, f.ally).reserves, player(end, f.ally).reserves), 0);
|
||
assert.equal(at(end, f.ally, f.location), 2);
|
||
assert.equal(change(player(f.game, f.opponent).tanks, player(end, f.opponent).tanks), 2);
|
||
disposedLeadCards(f, end);
|
||
pairedArmies(f, end);
|
||
physicalSource(end, 36);
|
||
assertEcazIndependentSkillsCustody(end);
|
||
});
|
||
|
||
void test('native paired Basic Tech Moritani lead pays and withdraws its normal variable army without merging Ecaz fixed losses', () => {
|
||
const f = createEcazIndependentSkillsFixture({ paired: true, advanced: false, lead: 'ally', tech: true });
|
||
const quote = quoteEcazIndependentSkillsBattle(f);
|
||
assert.deepEqual(quote.reinforcements,
|
||
[{ player: f.ally, card: REINFORCEMENTS_CARD, normal: 3, elite: 0 }]);
|
||
assert.deepEqual(quote.harassWithdraw![0].returned, { normal: 2, elite: 0 });
|
||
assert.deepEqual(quote.harassWithdraw![0].locations, { [f.location]: { normal: 2, elite: 0 } });
|
||
assert.deepEqual(quote.fixedLosses, [{ owner: f.ecaz, normal: 2, elite: 0 }]);
|
||
assert.equal(quote.sukGraduate, undefined);
|
||
const end = settleEcazIndependentSkillsBattle(f);
|
||
assert.equal(end.phase, 7);
|
||
assert.equal(end.lastBattleContext?.winner, f.ally);
|
||
assert.equal(end.lastBattleContext?.sukRescue, undefined);
|
||
assert.equal(change(player(f.game, f.ally).reserves, player(end, f.ally).reserves), -1);
|
||
assert.equal(change(player(f.game, f.ally).tanks, player(end, f.ally).tanks), 5);
|
||
assert.equal(at(end, f.ally, f.location), 0);
|
||
assert.equal(change(player(f.game, f.ecaz).reserves, player(end, f.ecaz).reserves), 0);
|
||
assert.equal(change(player(f.game, f.ecaz).tanks, player(end, f.ecaz).tanks), 2);
|
||
assert.equal(at(end, f.ecaz, f.location), 2);
|
||
assert.equal(player(end, f.ecaz).leaders.find(leader => leader.id === f.trainer)!.dead, false);
|
||
assert.equal(change(player(f.game, f.opponent).tanks, player(end, f.opponent).tanks), 2);
|
||
const tokens = ownedTech(f.game.techTokens, f.opponent);
|
||
assert.equal(tokens.length, 1);
|
||
assert.equal(end.techTokens![tokens[0]].owner, f.ally,
|
||
'the selected Moritani winner, not its Ecaz coalition partner, inherits the defeated Guild token');
|
||
disposedLeadCards(f, end);
|
||
pairedArmies(f, end);
|
||
physicalSource(end, 36);
|
||
assertEcazIndependentSkillsCustody(end);
|
||
});
|
||
|
||
for (const lead of ['ecaz', 'ally'] as const) {
|
||
void test(`earned Arrakeen supports only its Ecaz holder's selected plan: paired ${lead === 'ecaz' ? 'holder' : 'Moritani'} lead keeps card fees, losses and Tech separate`, () => {
|
||
const f = createEcazIndependentSkillsFixture({ paired: true, tech: true, strongholds: true, lead });
|
||
const claim = f.afterFirstMentat!;
|
||
assert.equal(claim.strongholdCards!.claimedTurn, 1);
|
||
assert.equal(claim.strongholdCards!.owners.arrakeen, f.ecaz);
|
||
assert.equal(at(claim, f.ecaz, f.location), 4);
|
||
assert.equal(f.actions.filter(step => step.actor === f.ecaz && step.action.type === 'ship').length, 1,
|
||
'the same four paid turn-one counters remain through the claim and real worm alliance');
|
||
const quote = quoteEcazIndependentSkillsBattle(f);
|
||
const payment = quote.payments.find(payment => payment.player === f.cardUser)!;
|
||
assert.equal(payment.bankSupport, lead === 'ecaz' ? 2 : 0);
|
||
assert.equal(payment.ownPayment, lead === 'ecaz' ? 0 : 2,
|
||
'Moritani does not inherit the Ecaz-held card when Ecaz selects its ally to lead');
|
||
assert.deepEqual(quote.reinforcements,
|
||
[{ player: f.cardUser, card: REINFORCEMENTS_CARD, normal: 3, elite: 0 }]);
|
||
assert.deepEqual(quote.harassWithdraw![0].returned, { normal: 2, elite: 0 });
|
||
assert.deepEqual(quote.fixedLosses, [{ owner: f.ecaz, normal: 2, elite: 0 }]);
|
||
assert.equal(quote.casualties?.owner, f.ally);
|
||
const paid = settleEcazIndependentSkillsBattle(f, { stop: 'rescue' });
|
||
assert.equal(player(paid, f.cardUser).spice, player(f.game, f.cardUser).spice - payment.ownPayment,
|
||
'support settles before the Ecaz rescue and the separate Collection income');
|
||
const end = settleEcazIndependentSkillsBattle(f, { state: paid });
|
||
assert.equal(end.phase, 7);
|
||
assert.equal(end.lastBattleContext?.winner, f.cardUser);
|
||
assert.equal(change(player(f.game, f.ecaz).tanks, player(end, f.ecaz).tanks), lead === 'ecaz' ? 3 : 2);
|
||
assert.equal(change(player(f.game, f.ally).tanks, player(end, f.ally).tanks), lead === 'ecaz' ? 2 : 5);
|
||
assert.equal(end.lastBattleContext?.sukRescue?.completed, lead === 'ecaz' ? true : undefined);
|
||
const lostTokens = ownedTech(f.game.techTokens, f.opponent);
|
||
assert.equal(lostTokens.length, 1);
|
||
assert.equal(end.techTokens![lostTokens[0]].owner, f.cardUser);
|
||
disposedLeadCards(f, end);
|
||
pairedArmies(f, end);
|
||
physicalSource(end, 36);
|
||
assert.deepEqual(end.ecazLoyalty, f.afterSetup.ecazLoyalty);
|
||
assertEcazIndependentSkillsCustody(end);
|
||
});
|
||
}
|
||
|
||
void test('native World coalition pays Ecaz’s source-labelled fee before committed-only Suk rescue and settles earned Arrakeen and Tech', () => {
|
||
const f = createEcazIndependentSkillsFixture({
|
||
homeworlds: true, paired: true, tech: true, strongholds: true,
|
||
});
|
||
const native = (game: Game) => homeworldForceGroups(homeworldContext(game), game.homeworlds!.custody!)
|
||
.find(home => home.native === f.ecaz)!.forces[f.ecaz];
|
||
const plan = f.plans[0];
|
||
assert.deepEqual(plan.action.reinforcementsSources,
|
||
{ 'homeworld:ecaz': { normal: 3, elite: 0 } });
|
||
const before = structuredClone(f.beforePlans);
|
||
assert.throws(() => applyAction(f.beforePlans, f.ecaz, {
|
||
...plan.action, reinforcementsSources: { 'homeworld:moritani': { normal: 3, elite: 0 } },
|
||
}), 'A coalition partner’s native World cannot fund the Ecaz card user’s fee');
|
||
assert.deepEqual(f.beforePlans, before);
|
||
const shipment = f.actions.find(step => step.actor === f.ecaz && step.action.type === 'ship')!;
|
||
assert.deepEqual(shipment.action.homeworldSources,
|
||
{ 'homeworld:ecaz': { normal: 4, elite: 0 } });
|
||
assert.equal(native(f.afterFirstMentat!).normal, native(f.afterSetup).normal - 4,
|
||
'The four real paid native counters hold Arrakeen through END Mentat');
|
||
assert.equal(f.afterFirstMentat!.strongholdCards!.owners.arrakeen, f.ecaz);
|
||
const quote = quoteEcazIndependentSkillsBattle(f);
|
||
const payment = quote.payments.find(item => item.player === f.ecaz)!;
|
||
assert.equal(payment.bankSupport, 2);
|
||
assert.equal(payment.ownPayment, 0, 'The earned Arrakeen card actually supports the selected lead');
|
||
assert.deepEqual(quote.harassWithdraw![0].returned, { normal: 2, elite: 0 });
|
||
const rescue = settleEcazIndependentSkillsBattle(f, { stop: 'rescue' });
|
||
assert.ok(rescue.decision?.kind === 'sukRescue');
|
||
assert.equal(rescue.decision.player, f.ecaz);
|
||
assert.deepEqual(rescue.decision.options.filter(option => option.kept),
|
||
[{ normal: 1, elite: 0, kept: { key: f.location, kind: 'normal' } },
|
||
{ normal: 2, elite: 0, kept: { key: f.location, kind: 'normal' } }]);
|
||
assert.equal(player(rescue, f.ecaz).tanks - player(f.game, f.ecaz).tanks, 3,
|
||
'The source-labelled fee is already in Tanks and never joins the two committed rescue losses');
|
||
assert.equal(native(rescue).normal, native(f.game).normal - 1,
|
||
'Three native fee counters leave before the two undialed counters return to the same World');
|
||
assert.equal(player(rescue, f.ally).tanks - player(f.game, f.ally).tanks, 2,
|
||
'Moritani’s variable army losses cannot enter Ecaz’s rescue');
|
||
assert.equal(player(rescue, f.ecaz).spice, player(f.game, f.ecaz).spice,
|
||
'Held-card bank support settles before Collection, without charging Ecaz');
|
||
const end = settleEcazIndependentSkillsBattle(f, { state: rescue });
|
||
assert.equal(end.phase, 7);
|
||
assert.equal(end.lastBattleContext?.winner, f.ecaz);
|
||
assert.equal(end.lastBattleContext?.sukRescue?.completed, true);
|
||
assert.deepEqual(end.lastBattleContext?.sukRescue?.physical, {
|
||
player: f.ecaz, territory: f.territory, source: 'arrakis',
|
||
saved: { normal: 2, elite: 0 }, kept: { key: f.location, kind: 'normal' },
|
||
destinations: { 'homeworld:ecaz': { normal: 1, elite: 0 } }, tanks: { normal: 0, elite: 0 },
|
||
});
|
||
assert.equal(native(end).normal, native(f.game).normal);
|
||
assert.equal(at(end, f.ecaz, f.location), 1);
|
||
assert.equal(player(end, f.ecaz).tanks - player(f.game, f.ecaz).tanks, 3);
|
||
const enemyTokens = ownedTech(f.game.techTokens, f.opponent);
|
||
assert.equal(enemyTokens.length, 1);
|
||
assert.equal(end.techTokens![enemyTokens[0]].owner, f.ecaz);
|
||
disposedLeadCards(f, end);
|
||
pairedArmies(f, end);
|
||
physicalSource(end, 36);
|
||
assertEcazIndependentSkillsCustody(end);
|
||
});
|
||
|
||
void test('paired plain World all14 keeps the native Moritani lead and separate Ecaz fixed army through battle continuation', () => {
|
||
const f = createEcazIndependentSkillsFixture({
|
||
homeworlds: true, paired: true, independent: false, cards: [], lead: 'ally',
|
||
});
|
||
assert.equal(f.beforeLead.decision?.kind, 'ecazBattleLead');
|
||
assert.equal(f.plans[0].actor, f.ally);
|
||
assert.equal(player(f.beforeLead, f.ecaz).ally, f.ally);
|
||
const quote = quoteEcazIndependentSkillsBattle(f);
|
||
assert.deepEqual(quote.fixedLosses, [{ owner: f.ecaz, normal: 2, elite: 0 }]);
|
||
assert.equal(quote.casualties?.owner, f.ally);
|
||
assert.equal(quote.sukGraduate, undefined,
|
||
'Ecaz’s native skill cannot rescue its fixed army while Moritani leads');
|
||
assert.ok(f.game.leaderSkills!.assignments.every(assignment => assignment.leader !== DUKE_VIDAL_ID));
|
||
const end = settleEcazIndependentSkillsBattle(f);
|
||
assert.equal(end.phase, 7);
|
||
assert.equal(end.lastBattleContext?.winner, f.ally);
|
||
assert.equal(end.lastBattleContext?.sukRescue, undefined);
|
||
for (const actor of [f.ecaz, f.ally]) {
|
||
assert.equal(player(end, actor).tanks - player(f.game, actor).tanks, 2);
|
||
assert.equal(player(end, actor).reserves, player(f.game, actor).reserves);
|
||
assert.equal(at(end, actor, f.location), 2);
|
||
}
|
||
pairedArmies(f, end);
|
||
physicalSource(end, 33);
|
||
assertEcazIndependentSkillsCustody(end);
|
||
});
|
||
|
||
void test('no-Skills World independent cards retain the native Emperor-led coalition and typed Sardaukar withdrawal', () => {
|
||
const f = createEcazIndependentSkillsFixture({ homeworlds: true, skills: false, lead: 'ally' });
|
||
assert.equal(f.beforeLead.decision?.kind, 'ecazBattleLead');
|
||
assert.equal(f.plans[0].actor, f.ally);
|
||
const sources = f.game.battle!.plans[f.cardUser].reinforcementsSources!;
|
||
assert.ok(Object.keys(sources).every(world => world === 'homeworld:emperor' || world === 'homeworld:emperor:salusa'));
|
||
assert.equal(Object.values(sources).reduce((sum, forces) => sum + forces.normal, 0), 3);
|
||
const quote = quoteEcazIndependentSkillsBattle(f);
|
||
assert.deepEqual(quote.harassWithdraw![0].returned, { normal: 0, elite: 2 });
|
||
assert.deepEqual(quote.harassWithdraw![0].locations, { [f.location]: { normal: 0, elite: 2 } });
|
||
assert.equal(quote.sukGraduate, undefined);
|
||
const end = settleEcazIndependentSkillsBattle(f);
|
||
assert.equal(end.phase, 7);
|
||
assert.equal(end.lastBattleContext?.winner, f.ally);
|
||
assert.equal(end.lastBattleContext?.sukRescue, undefined);
|
||
assert.equal(player(end, f.ally).reserves - player(f.game, f.ally).reserves, -1);
|
||
assert.equal(player(end, f.ally).tanks - player(f.game, f.ally).tanks, 5,
|
||
'Three own native fee counters and two variable commitment losses remain distinct from Ecaz');
|
||
assert.equal(player(end, f.ally).elites!.reserves - player(f.game, f.ally).elites!.reserves, 2);
|
||
assert.equal(player(end, f.ally).elites!.tanks, player(f.game, f.ally).elites!.tanks);
|
||
assert.equal(player(end, f.ecaz).tanks - player(f.game, f.ecaz).tanks, 2);
|
||
assert.equal(at(end, f.ecaz, f.location), 2);
|
||
disposedLeadCards(f, end);
|
||
physicalSource(end, 36);
|
||
assertEcazIndependentSkillsCustody(end);
|
||
});
|