Files
Dune/tests/ecaz-independent-skills-runtime.test.ts
T
Quintine fde44baab5 Compose standalone E3 Skills and independent cards with native power defaults
Restore fresh Ecaz coalition and Advanced Moritani initialization; retain printed native continuations and selected-family card eligibility. Validate Mentat fallback hands against explicitly selected independent cards. Frozen25-file274/274, types/lint/build and genuine runtime/mobile entry proofs pass. No save retrofit or deployment.
2026-10-17 20:36:01 +11:00

245 lines
15 KiB
TypeScript
Raw 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 assert from 'node:assert/strict';
import test from 'node:test';
import 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 {
assertEcazIndependentSkillsCustody,
createEcazIndependentSkillsFixture, ecazIndependentSkillsPlayer as player,
quoteEcazIndependentSkillsBattle, settleEcazIndependentSkillsBattle,
} 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());
}
for (const advanced of [false, true]) {
void test(`original independent cards with all14 ${advanced ? 'Advanced' : 'Basic'} reach the printed lead choice, and the trained Ecaz Suk rescues only its own committed losses`, () => {
const f = createEcazIndependentSkillsFixture({ advanced, lead: 'ecaz' });
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);
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);
});
}