Files
Dune/tests/moritani-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

344 lines
21 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { HARASS_WITHDRAW_CARD } from '../game/harass-withdraw';
import { REINFORCEMENTS_CARD } from '../game/reinforcements';
import {
advanceMoritaniIndependentSkills,
assertMoritaniIndependentSkillsCustody,
chooseMoritaniIndependentSkillsAssassination,
createMoritaniIndependentSkillsFixture,
finishMoritaniIndependentSkillsBattle,
moritaniIndependentSkillsClean,
moritaniIndependentSkillsPlayer,
quoteMoritaniIndependentSkillsBattle,
resolveMoritaniIndependentSkillsBattle,
stepMoritaniIndependentSkills,
type MoritaniIndependentSkillsFixture,
} from './fixture-moritani-independent-skills';
const player = moritaniIndependentSkillsPlayer;
const reject = (game: Game, actor: string, action: Action) => {
const before = structuredClone(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before);
};
const owned = (fixture: MoritaniIndependentSkillsFixture, game: Game, identity: string) =>
player(game, fixture.opponent).leaders.find((leader) => leader.id === identity)!;
const choiceOf = (game: Game, viewer: string) =>
viewGame(game, viewer).moritaniAssassinate!.pending!;
void test('Advanced standalone Moritani keeps the printed Assassinate power through the real lobby, the real loss and one held unused Traitor', () => {
const f = createMoritaniIndependentSkillsFixture({ rules: 'advanced', cards: true, skills: true });
const moritani = player(f.afterSetup, f.moritani), guild = player(f.afterSetup, f.opponent);
// The producer is the ordinary fresh roster, never the separate audit profile.
assert.equal(f.afterSetup.moritaniAssassinatePreview, true);
assert.equal(f.afterSetup.moritaniAssassinate!.owner, f.moritani);
assert.deepEqual(f.afterSetup.moritaniAssassinate!.opportunities, []);
assert.equal(f.afterSetup.moritaniAssassinate!.normalTraitorCall, false);
assert.deepEqual(f.afterSetup.moritaniAssassinateCallEvents, []);
assert.deepEqual(f.afterSetup.expansions, ['ecaz']);
assert.equal(f.afterSetup.ecazTreachery, true);
assert.equal(f.afterSetup.homeworlds, undefined);
assert.equal(f.afterSetup.leaderSkills!.assignments.length, 3);
assert.equal(moritani.spice, 12);
assert.equal(moritani.reserves, 14);
assert.equal(moritani.tanks, 0);
assert.deepEqual(moritani.forces, { [f.first]: 6 });
assert.equal(guild.reserves, 15);
assert.deepEqual(guild.forces, { 'tueks_sietch:5': 5 });
assertMoritaniIndependentSkillsCustody(f.afterSetup);
// The real revealed battle: Moritani's own physical Reinforcements/Harass loss.
const battle = f.revealed.battle!;
assert.equal(battle.territory, f.territory);
assert.equal(battle.plans[f.moritani].weapon, REINFORCEMENTS_CARD);
assert.equal(battle.plans[f.moritani].defense, HARASS_WITHDRAW_CARD);
assert.equal(battle.plans[f.moritani].leader, f.loserLeader);
assert.equal(battle.plans[f.opponent].leader, f.winnerLeader);
assert.equal(f.returnsContext!.locations[f.first].normal, 4);
assert.equal(f.returnsContext!.locations[f.second].normal, 2);
assert.deepEqual(f.returns, { [f.first]: { normal: 2, elite: 0 } });
assert.equal(f.pending.lastBattleContext!.winner, f.opponent);
assert.equal(f.pending.lastBattleContext!.result, 'normal');
assert.equal(f.pending.lastBattleContext!.territory, f.territory);
// Real fee, real casualties and real returns, before the owner's choice.
const held = player(f.pending, f.moritani), rival = player(f.pending, f.opponent);
assert.equal(held.spice, 12);
assert.equal(held.reserves, 13);
assert.equal(held.tanks, 7);
assert.deepEqual(held.forces, {});
assert.equal(rival.spice, 5);
assert.equal(rival.reserves, 15);
assert.equal(rival.tanks, 2);
assert.deepEqual(rival.forces, { [f.second]: 3 });
assert.deepEqual(held.traitors, [f.traitorTarget]);
assert.equal(owned(f, f.pending, f.traitorTarget).dead, false);
assert.equal(owned(f, f.pending, f.winnerLeader).dead, false);
// The owner's real, private choice: one conserved unused Traitor identity.
assert.equal(f.pending.decision?.kind, 'moritaniAssassinate');
assert.equal(f.pending.decision?.player, f.moritani);
const decision = f.pending.decision;
if (decision?.kind !== 'moritaniAssassinate') throw new Error('Expected the original Assassinate choice.');
const event = decision.event;
const choice = choiceOf(f.pending, f.moritani);
assert.equal(choice.opponent, f.opponent);
assert.equal(choice.territory, f.territory);
assert.equal(choice.blocked, null);
assert.deepEqual(choice.cards, [{ card: f.traitorTarget,
name: owned(f, f.pending, f.traitorTarget).name,
bounty: owned(f, f.pending, f.traitorTarget).strength, dead: false }]);
assert.equal(choice.cards.some((card) => card.card === f.winnerLeader), false);
assert.deepEqual(choiceOf(f.pending, f.opponent).cards, []);
assert.deepEqual(choiceOf(f.pending, f.third).cards, []);
reject(f.pending, f.opponent, { type: 'decision', event, card: f.traitorTarget });
reject(f.pending, f.moritani, { type: 'decision', event: 'stale', card: f.traitorTarget });
reject(f.pending, f.moritani, { type: 'decision', event, card: f.winnerLeader });
reject(f.pending, f.moritani, { type: 'decision', event, card: 'guild-2' });
reject(f.pending, f.moritani, { type: 'decision', event, decline: true, card: f.traitorTarget });
const spent = chooseMoritaniIndependentSkillsAssassination(f.pending, f.traitorTarget);
const receipt = spent.moritaniAssassinate!.opportunities.at(-1)!;
const target = owned(f, spent, f.traitorTarget);
assert.equal(target.dead, true);
assert.equal(target.deaths, 1);
assert.equal(target.usedAt, undefined);
assert.equal(receipt.stage, 'revealed');
assert.equal(receipt.card, f.traitorTarget);
assert.equal(receipt.opposingLeader, f.winnerLeader);
assert.equal(receipt.bounty, owned(f, f.pending, f.traitorTarget).strength);
assert.equal(receipt.replacement, null);
assert.equal(player(spent, f.moritani).spice, 12 + receipt.bounty);
assert.equal(player(spent, f.opponent).spice, 5);
// Ordered leader-skill custody: the dead leader's card returns once.
assert.equal(spent.leaderSkills!.deck.filter((id) => id === 'killer-medic').length, 1);
assert.deepEqual(spent.leaderSkills!.assignments, [
{ owner: f.moritani, leader: 'moritani-0', skill: f.chosen.moritani },
{ owner: f.third, leader: 'emperor-0', skill: f.chosen.emperor },
]);
assert.deepEqual(player(spent, f.moritani).traitors, [f.traitorTarget]);
// The original winner-card cleanup is preserved, not erased by the choice.
assert.equal(spent.decision?.kind, 'battleCards');
assert.equal(spent.decision?.player, f.opponent);
assert.equal(player(spent, f.opponent).hand.some((card) => card.id === f.cards.winnerWeapon), true);
assert.equal(spent.discard.filter((card) => card.id === REINFORCEMENTS_CARD).length, 1);
assert.equal(spent.discard.filter((card) => card.id === HARASS_WITHDRAW_CARD).length, 1);
assert.equal(spent.discard.filter((card) => card.id === f.cards.winnerWeapon).length, 0);
assertMoritaniIndependentSkillsCustody(spent);
const settled = finishMoritaniIndependentSkillsBattle(spent, { winnerDiscard: 'played' });
assert.equal(player(settled, f.opponent).hand.some((card) => card.id === f.cards.winnerWeapon), false);
assert.equal(settled.discard.filter((card) => card.id === f.cards.winnerWeapon).length, 1);
const replacement = settled.moritaniAssassinate!.opportunities.at(-1)!;
assert.equal(replacement.stage, 'replaced');
assert.ok(replacement.replacement && replacement.replacement !== f.traitorTarget);
assert.deepEqual(player(settled, f.moritani).traitors, [replacement.replacement]);
assert.equal(settled.traitorReserve!.includes(replacement.replacement!), false);
assert.equal(settled.players.flatMap((seat) => seat.traitors).includes(f.traitorTarget), false);
assert.equal(player(settled, f.moritani).spice, 12 + replacement.bounty);
assert.equal(settled.leaderSkills!.deck.filter((id) => id === 'killer-medic').length, 1);
assert.equal(settled.traitorReserve!.length, f.pending.traitorReserve!.length - 1);
assert.equal(settled.phase, 8);
assert.equal(viewGame(settled, f.opponent).moritaniAssassinate!.history.at(-1)!.card, f.traitorTarget);
assert.equal('replacement' in viewGame(settled, f.opponent).moritaniAssassinate!.history.at(-1)!, false);
assertMoritaniIndependentSkillsCustody(settled);
// Real quote of the revealed plans, including the committed specials.
const quote = quoteMoritaniIndependentSkillsBattle(f);
assert.equal(quote.result, 'normal');
assert.equal(quote.winner, f.opponent);
assert.deepEqual(quote.reinforcements, [{ player: f.moritani, card: REINFORCEMENTS_CARD,
normal: 3, elite: 0 }]);
assert.equal(quote.harassWithdraw!.length, 1);
assert.equal(quote.harassWithdraw![0].player, f.moritani);
assert.deepEqual(quote.harassWithdraw![0].returned, { normal: 2, elite: 0 });
assert.deepEqual(quote.harassWithdraw![0].remaining, {
normal: 4, normalFixedHalf: false, elite: 0, eliteStrength: 2, freeSupport: false });
assert.deepEqual(quote.harassWithdraw![0].locations, { [f.first]: { normal: 2, elite: 0 } });
});
void test('Advanced standalone Moritani keeps the printed power without Leader Skills, and a real decline changes nothing', () => {
const f = createMoritaniIndependentSkillsFixture({ rules: 'advanced', cards: true });
assert.equal(f.afterSetup.leaderSkills, undefined);
assert.equal(f.afterSetup.moritaniAssassinatePreview, true);
assert.equal(f.pending.decision?.kind, 'moritaniAssassinate');
const choice = choiceOf(f.pending, f.moritani);
assert.deepEqual(choice.cards.map((card) => card.card), [f.traitorTarget]);
assert.equal(choice.cards[0].bounty, owned(f, f.pending, f.traitorTarget).strength);
const spent = chooseMoritaniIndependentSkillsAssassination(f.pending, f.traitorTarget);
const target = owned(f, spent, f.traitorTarget);
assert.equal(target.dead, true);
assert.equal(player(spent, f.moritani).spice, 12 + spent.moritaniAssassinate!.opportunities.at(-1)!.bounty);
const settled = finishMoritaniIndependentSkillsBattle(spent, { winnerDiscard: 'played' });
assert.equal(settled.moritaniAssassinate!.opportunities.at(-1)!.stage, 'replaced');
assert.equal(settled.leaderSkills, undefined);
assert.equal(settled.discard.filter((card) => card.id === f.cards.winnerWeapon).length, 1);
assertMoritaniIndependentSkillsCustody(settled);
// A second identical lobby: declining is a real, state-preserving choice.
const declined = createMoritaniIndependentSkillsFixture({ rules: 'advanced', cards: true });
const before = structuredClone(declined.pending);
const after = chooseMoritaniIndependentSkillsAssassination(declined.pending, null);
const kept = owned(declined, after, declined.traitorTarget);
assert.equal(kept.dead, false);
assert.equal(kept.deaths, 0);
assert.equal(after.moritaniAssassinate!.opportunities.at(-1)!.stage, 'declined');
assert.equal(player(after, declined.moritani).spice, player(before, declined.moritani).spice);
assert.deepEqual(player(after, declined.moritani).traitors, [declined.traitorTarget]);
assert.deepEqual(player(after, declined.moritani).leaders, player(before, declined.moritani).leaders);
const ended = finishMoritaniIndependentSkillsBattle(after, { winnerDiscard: 'played' });
assert.deepEqual(ended.moritaniAssassinate!.opportunities, after.moritaniAssassinate!.opportunities);
assert.equal(ended.traitorReserve!.length, declined.pending.traitorReserve!.length);
assert.equal(viewGame(ended, declined.moritani).moritaniAssassinate!.history.length, 0);
assertMoritaniIndependentSkillsCustody(ended);
});
void test('a no-card no-Skills normal start still produces the Advanced assassination, including the native Harkonnen roster', () => {
const f = createMoritaniIndependentSkillsFixture({ rules: 'advanced', cards: false, opponent: 'harkonnen' });
const harkonnen = player(f.afterSetup, f.opponent);
// The plain 33-card classic inventory: no independent card is staged or played.
assert.equal(f.afterSetup.ecazTreachery, undefined);
assert.equal(f.cards.reinforcements, null);
assert.equal(f.cards.harass, null);
const cards = [...f.afterSetup.deck, ...f.afterSetup.discard,
...f.afterSetup.players.flatMap((seat) => seat.hand)];
assert.equal(cards.length, 33);
assert.equal(f.returns, null);
// The Harkonnen native roster: four dealt Traitor Cards and a real capture.
assert.equal(harkonnen.traitors.length, 4);
assert.equal(f.pending.decision?.kind, 'moritaniAssassinate');
const assassination = chooseMoritaniIndependentSkillsAssassination(f.pending, f.traitorTarget);
assert.equal(owned(f, assassination, f.traitorTarget).dead, true);
assert.equal(assassination.moritaniAssassinate!.opportunities.at(-1)!.bounty,
owned(f, f.pending, f.traitorTarget).strength);
const offer = resolveMoritaniIndependentSkillsBattle(f, assassination, { capture: { accept: true, captured: 'keep' } });
const settled = finishMoritaniIndependentSkillsBattle(offer,
{ winnerDiscard: 'played', capture: { accept: true, captured: 'keep' } });
const captives = settled.players.flatMap((seat) => seat.leaders)
.filter((leader) => leader.capturedBy === f.opponent);
assert.equal(captives.length, 1);
assert.equal(captives[0].faction, 'moritani');
assert.equal(captives[0].dead, false);
assert.equal(settled.moritaniAssassinate!.opportunities.at(-1)!.stage, 'replaced');
assert.equal(player(settled, f.moritani).tanks, 6);
assert.equal(settled.discard.filter((card) => card.id === f.cards.winnerWeapon).length, 1);
assertMoritaniIndependentSkillsCustody(settled);
});
void test('Basic independent Moritani resolves the same real Reinforcements/Harass loss without the Advanced assassination', () => {
const f = createMoritaniIndependentSkillsFixture({ rules: 'basic', cards: true, skills: true });
assert.equal(f.afterSetup.moritaniAssassinate, undefined);
assert.equal(f.afterSetup.moritaniAssassinatePreview, undefined);
assert.equal(f.afterSetup.leaderSkills!.assignments.length, 3);
const battle = f.revealed.battle!;
assert.equal(battle.plans[f.moritani].weapon, REINFORCEMENTS_CARD);
assert.equal(battle.plans[f.moritani].defense, HARASS_WITHDRAW_CARD);
assert.deepEqual(f.returns, { [f.first]: { normal: 4, elite: 0 } });
assert.ok(moritaniIndependentSkillsClean(f.pending));
assert.equal(f.pending.phase, 7);
assert.equal(f.pending.lastBattleContext!.result, 'normal');
assert.equal(f.pending.lastBattleContext!.winner, f.opponent);
assert.equal(f.pending.moritaniAssassinate, undefined);
const held = player(f.pending, f.moritani), rival = player(f.pending, f.opponent);
assert.equal(held.spice, 12);
assert.equal(held.reserves, 15);
assert.equal(held.tanks, 5);
assert.deepEqual(held.forces, {});
assert.deepEqual(held.traitors, [f.traitorTarget]);
assert.equal(rival.tanks, 1);
assert.deepEqual(rival.forces, { [f.second]: 4 });
assert.equal(owned(f, f.pending, f.traitorTarget).dead, false);
assert.equal(owned(f, f.pending, f.winnerLeader).dead, false);
assert.equal(f.pending.discard.filter((card) => card.id === REINFORCEMENTS_CARD).length, 1);
assert.equal(f.pending.discard.filter((card) => card.id === HARASS_WITHDRAW_CARD).length, 1);
assert.equal(f.pending.discard.filter((card) => card.id === f.cards.winnerWeapon).length, 1);
const settings = structuredClone(f.pending.leaderSkills);
const settled = finishMoritaniIndependentSkillsBattle(f.pending);
assert.deepEqual(settled.leaderSkills, settings);
assert.deepEqual(player(settled, f.moritani).traitors, [f.traitorTarget]);
assertMoritaniIndependentSkillsCustody(settled);
const plain = createMoritaniIndependentSkillsFixture({ rules: 'basic', cards: false, opponent: 'harkonnen' });
assert.equal(plain.afterSetup.moritaniAssassinate, undefined);
assert.equal(plain.pending.phase, 7);
assert.equal(plain.pending.moritaniAssassinate, undefined);
assert.deepEqual(player(plain.pending, plain.moritani).traitors, [plain.traitorTarget]);
assert.equal(player(plain.pending, plain.moritani).tanks, 6);
assert.equal(owned(plain, plain.pending, plain.traitorTarget).dead, false);
assertMoritaniIndependentSkillsCustody(plain.pending);
const variant = createMoritaniIndependentSkillsFixture({ rules: 'basic', cards: true });
assert.equal(variant.afterSetup.moritaniAssassinate, undefined);
assert.equal(variant.afterSetup.ecazTreachery, true);
assert.equal(variant.pending.phase, 7);
assert.equal(variant.pending.moritaniAssassinate, undefined);
assert.equal(variant.pending.lastBattleContext!.winner, variant.opponent);
assert.deepEqual(player(variant.pending, variant.moritani).traitors, [variant.traitorTarget]);
assert.equal(owned(variant, variant.pending, variant.traitorTarget).dead, false);
assert.equal(variant.pending.discard.filter((card) => card.id === REINFORCEMENTS_CARD).length, 1);
assert.equal(variant.pending.discard.filter((card) => card.id === HARASS_WITHDRAW_CARD).length, 1);
assertMoritaniIndependentSkillsCustody(variant.pending);
});
void test('a real normal Traitor call forfeits the printed power for the game and the used faction is never offered twice', () => {
const called = createMoritaniIndependentSkillsFixture({ rules: 'advanced', cards: true, traitor: 'winner' });
assert.equal(called.heldTraitor, called.winnerLeader);
assert.deepEqual(choiceOf(called.pending, called.moritani).cards, []);
const played = resolveMoritaniIndependentSkillsBattle(called, called.revealed,
{ traitorCalls: [called.moritani], winnerDiscard: 'played' });
assert.equal(played.lastBattleContext!.result, 'traitor');
assert.equal(played.lastBattleContext!.winner, called.moritani);
assert.equal(played.moritaniAssassinate!.normalTraitorCall, true);
assert.deepEqual(played.moritaniAssassinate!.opportunities, []);
assert.equal(played.moritaniAssassinateCallEvents!.includes(played.lastBattleContext!.event), true);
assert.equal(player(played, called.moritani).spice, 12 + owned(called, played, called.winnerLeader).strength);
assert.deepEqual(player(played, called.moritani).revealedTraitors, [called.winnerLeader]);
assert.ok((viewGame(played, called.moritani).moritaniAssassinate!.blocked ?? '').includes('forfeited'));
assert.equal(played.decision?.kind, undefined);
assertMoritaniIndependentSkillsCustody(played);
// The original once-per-faction rule survives a second real battle.
const f = createMoritaniIndependentSkillsFixture({ rules: 'advanced', cards: true });
const spent = chooseMoritaniIndependentSkillsAssassination(f.pending, f.traitorTarget);
let game = finishMoritaniIndependentSkillsBattle(spent, { winnerDiscard: 'played' });
assert.equal(game.turn, 1);
const actions: { actor: string; action: Action }[] = [];
game = advanceMoritaniIndependentSkills(game,
(state) => state.phase === 5 && state.active === f.moritani && moritaniIndependentSkillsClean(state), actions);
assert.equal(player(game, f.moritani).spice, 15);
game = stepMoritaniIndependentSkills(game,
{ actor: f.moritani, action: { type: 'ship', territory: f.territory, sector: Number(f.second.split(':')[1]),
amount: 4, elite: 0, allyPayment: 0 } });
game = advanceMoritaniIndependentSkills(game,
(state) => state.phase === 5 && state.active === f.moritani && moritaniIndependentSkillsClean(state) && !state.pendingShipment,
actions);
assert.deepEqual(player(game, f.moritani).forces, { [f.second]: 4 });
game = stepMoritaniIndependentSkills(game, { actor: f.moritani, action: { type: 'endMovement' } });
game = advanceMoritaniIndependentSkills(game,
(state) => state.phase === 6 && !state.battle && moritaniIndependentSkillsClean(state), actions);
const offered = viewGame(game, game.active!).battleChoices.find((entry) => entry.territory === f.territory);
assert.ok(offered);
game = stepMoritaniIndependentSkills(game, { actor: offered!.chooser, action: { type: 'chooseBattle',
territory: f.territory, target: offered!.attacker === offered!.chooser ? offered!.defender : offered!.attacker } });
game = advanceMoritaniIndependentSkills(game,
(state) => !!state.battle && !state.battle.revealed && moritaniIndependentSkillsClean(state), actions);
game = stepMoritaniIndependentSkills(game, { actor: f.moritani, action: {
type: 'battlePlan', dial: 2, support: 0, leader: f.loserLeader } });
game = stepMoritaniIndependentSkills(game, { actor: f.opponent, action: {
type: 'battlePlan', dial: 0, support: 0, leader: f.winnerLeader } });
const second = resolveMoritaniIndependentSkillsBattle(f, game, { winnerDiscard: 'played' });
assert.equal(second.lastBattleContext!.result, 'normal');
assert.equal(second.lastBattleContext!.winner, f.opponent);
assert.equal(second.moritaniAssassinate!.opportunities.length, 1);
assert.equal(second.moritaniAssassinate!.opportunities[0].stage, 'replaced');
assert.notEqual(second.decision?.kind, 'moritaniAssassinate');
assert.equal(viewGame(second, f.moritani).moritaniAssassinate!.pending, null);
assert.equal(viewGame(second, f.moritani).moritaniAssassinate!.history.length, 1);
assert.equal(player(second, f.moritani).spice, 7);
assert.equal(owned(f, second, f.traitorTarget).dead, true);
assertMoritaniIndependentSkillsCustody(second);
});