Files
Dune/tests/moritani-independent-skills-runtime.test.ts
T
Quintine eff668b202 Support Moritani assassination of captured and foreign-ghola targets
Retain printed native identity and valid original custody while projecting only
uniform potential bounty before commitment. Resolve living/dead targets once,
clear selected capture/concealment and retain ordinary Ghola return custody.
Selected concealed-outcome disclosure is explicitly provisional.

Verified: 14 affected offline files100/100, types/lint and production build;
six actual choice/gameplay/GameTable SSR journeys and twelve profile paths.
Native capture source47 actions; actual paid Ghola prefix140 actions followed
by explicit conserved seven-action battle. No saved-room/D1/reset/restart or
deployment operation. Runtime-only driver fixes reused unchanged passed checks.
2026-10-24 19:03:03 +11:00

599 lines
38 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
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 {
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!;
const pool = (game: Game, actor: string) =>
homeworldForceGroups(homeworldContext(game), game.homeworlds!.custody!)
.find((home) => home.native === actor)!.forces[actor];
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.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,
potentialBounty: owned(f, f.pending, f.traitorTarget).strength }]);
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].potentialBounty, 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);
});
void test('Advanced paired Skills/cards with original Tech/Strongholds return selected sectors, then assassinate one unused native Ecaz disc before Mentat replacement', () => {
const f = createMoritaniIndependentSkillsFixture({
paired: true, rules: 'advanced', skills: true, cards: true, tech: true, strongholds: true,
});
const loyal = f.afterSetup.ecazLoyalty!.card!;
assert.notEqual(f.traitorTarget, loyal);
assert.notEqual(f.traitorTarget, f.winnerLeader);
assert.notEqual(f.traitorTarget, 'duke-vidal');
assert.equal(owned(f, f.pending, f.traitorTarget).usedAt, undefined);
assert.equal(f.afterEntry.players.find((seat) => seat.id === f.opponent)!.forces[f.second], 6);
assert.deepEqual(player(f.pending, f.moritani).traitors, [f.traitorTarget]);
assertMoritaniIndependentSkillsCustody(f.afterSetup);
// Pick the other real sector, not the fixture's default allocation. The fee
// consumes three of Moritani's own reserves before two undialed forces return.
const returns = { [f.second]: { normal: 2, elite: 0 } };
const offered = advanceMoritaniIndependentSkills(f.revealed,
(game) => game.decision?.kind === 'harassWithdraw');
const decision = offered.decision;
if (decision?.kind !== 'harassWithdraw') throw new Error('Expected the real sector-return choice.');
const returned = stepMoritaniIndependentSkills(offered, { actor: decision.player,
action: { type: 'decision', event: decision.event, returns } });
const pending = resolveMoritaniIndependentSkillsBattle(f, returned);
const quote = quoteMoritaniIndependentSkillsBattle({ ...f, returns });
assert.equal(quote.result, 'normal');
assert.equal(quote.winner, f.opponent);
assert.deepEqual(quote.scores, { attacker: 5, defender: 8 });
assert.deepEqual(quote.harassWithdraw![0].locations, returns);
assert.equal(quote.payments.find((payment) => payment.player === f.opponent)!.ownPayment, 4);
assert.equal(player(pending, f.opponent).spice, player(f.afterSetup, f.opponent).spice - 4);
assert.deepEqual(player(pending, f.opponent).forces, { [f.second]: 2 });
assert.equal(player(pending, f.moritani).reserves, player(f.afterSetup, f.moritani).reserves - 3 + 2);
assert.equal(player(pending, f.moritani).tanks, 7);
assert.deepEqual(player(pending, f.moritani).forces, {});
assert.equal(pending.lastBattleContext!.winner, f.opponent);
assert.deepEqual(choiceOf(pending, f.moritani).cards.map((card) => card.card), [f.traitorTarget]);
const skillBefore = pending.leaderSkills!;
const targetSkill = skillBefore.assignments.find((entry) => entry.leader === f.traitorTarget)!;
const spent = chooseMoritaniIndependentSkillsAssassination(pending, f.traitorTarget);
const target = owned(f, spent, f.traitorTarget);
const receipt = spent.moritaniAssassinate!.opportunities[0];
assert.equal(target.dead, true);
assert.equal(target.deaths, 1);
assert.equal(target.usedAt, undefined);
assert.equal(owned(f, spent, f.winnerLeader).dead, false);
assert.equal(receipt.opposingLeader, f.winnerLeader);
assert.equal(receipt.bounty, target.strength);
assert.equal(receipt.bounty, 4);
assert.equal(player(spent, f.moritani).spice, player(pending, f.moritani).spice + target.strength);
assert.deepEqual(spent.leaderSkills!.assignments,
skillBefore.assignments.filter((entry) => entry !== targetSkill));
assert.deepEqual(spent.leaderSkills!.deck, [...skillBefore.deck, targetSkill.skill]);
assert.equal(spent.decision?.kind, 'battleCards');
assert.equal(spent.decision?.player, f.opponent);
assert.equal(spent.discard.some((card) => card.id === f.cards.winnerWeapon), false);
assertMoritaniIndependentSkillsCustody(spent);
const beforeMentat = finishMoritaniIndependentSkillsBattle(spent, { winnerDiscard: 'played' },
(game) => game.phase === 7 && moritaniIndependentSkillsClean(game));
assert.equal(beforeMentat.discard.filter((card) => card.id === f.cards.winnerWeapon).length, 1);
const lostTokens = ownedTech(f.game.techTokens, f.moritani);
assert.equal(lostTokens.length, 1, 'the actual losing Moritani army entered with one original token');
assert.equal(beforeMentat.techTokens![lostTokens[0]].owner, f.opponent,
'Assassinate preserves the original winner cleanup and its automatic single-token transfer');
assert.equal(beforeMentat.strongholdCards!.claimedTurn, 0,
'no Stronghold Card is fabricated before the first genuine Mentat acquisition');
assert.equal(beforeMentat.moritaniAssassinate!.opportunities[0].stage, 'revealed');
assert.deepEqual(player(beforeMentat, f.moritani).traitors, [f.traitorTarget]);
const settled = finishMoritaniIndependentSkillsBattle(beforeMentat);
const replacement = settled.moritaniAssassinate!.opportunities[0];
assert.equal(settled.moritaniAssassinate!.opportunities.length, 1);
assert.equal(replacement.stage, 'replaced');
assert.ok(replacement.replacement && replacement.replacement !== f.traitorTarget);
assert.notEqual(replacement.replacement, loyal);
assert.deepEqual(player(settled, f.moritani).traitors, [replacement.replacement]);
assert.equal(settled.traitorReserve!.length, pending.traitorReserve!.length - 1);
assert.equal(settled.players.flatMap((seat) => seat.traitors).includes(f.traitorTarget), false);
assert.equal(settled.leaderSkills!.deck.filter((skill) => skill === targetSkill.skill).length, 1);
assert.deepEqual(settled.ecazLoyalty, f.afterSetup.ecazLoyalty);
assertMoritaniIndependentSkillsCustody(settled);
});
void test('Basic paired Skills/cards loss leaves the unused trained Ecaz disc and held Traitor intact through Mentat', () => {
const f = createMoritaniIndependentSkillsFixture({ paired: true, rules: 'basic', skills: true, cards: true });
assert.equal(f.pending.lastBattleContext!.winner, f.opponent);
assert.equal(f.pending.lastBattleContext!.result, 'normal');
assert.equal(f.pending.phase, 7);
assert.ok(moritaniIndependentSkillsClean(f.pending));
assert.equal(f.pending.moritaniAssassinate, undefined);
assert.deepEqual(f.returns, { [f.first]: { normal: 4, elite: 0 } });
assert.equal(player(f.pending, f.moritani).reserves, player(f.afterSetup, f.moritani).reserves - 3 + 4);
assert.equal(player(f.pending, f.moritani).tanks, 5);
const settled = finishMoritaniIndependentSkillsBattle(f.pending);
assert.equal(owned(f, settled, f.traitorTarget).dead, false);
assert.equal(owned(f, settled, f.traitorTarget).usedAt, undefined);
assert.deepEqual(settled.leaderSkills, f.pending.leaderSkills);
assert.deepEqual(player(settled, f.moritani).traitors, [f.traitorTarget]);
assert.equal(settled.discard.filter((card) => card.id === f.cards.winnerWeapon).length, 1);
assertMoritaniIndependentSkillsCustody(settled);
});
void test('paired Advanced Skills with original phase modules pays the printed Ecaz bounty and winner Tech without independent cards', () => {
const f = createMoritaniIndependentSkillsFixture({ paired: true, rules: 'advanced', skills: true,
tech: true, strongholds: true });
assert.equal(f.pending.lastBattleContext!.winner, f.opponent);
assert.equal(f.returns, null);
assert.equal(f.cards.reinforcements, null);
assert.equal(f.cards.harass, null);
assert.equal(player(f.pending, f.moritani).reserves, player(f.afterSetup, f.moritani).reserves);
assert.equal(player(f.pending, f.moritani).tanks, 6);
const physical = [...f.pending.deck, ...f.pending.discard, ...f.pending.players.flatMap((seat) => seat.hand)];
assert.equal(physical.length, 33);
assert.equal(physical.some((card) => card.id === REINFORCEMENTS_CARD || card.id === HARASS_WITHDRAW_CARD), false);
const defeatedTokens = Object.entries(f.pending.techTokens!).filter(([, token]) =>
token.owner === f.moritani).map(([id]) => id);
const spent = chooseMoritaniIndependentSkillsAssassination(f.pending, f.traitorTarget);
assert.equal(owned(f, spent, f.traitorTarget).dead, true);
assert.equal(spent.moritaniAssassinate!.opportunities[0].bounty, 4);
assert.equal(player(spent, f.moritani).spice, player(f.pending, f.moritani).spice + 4);
assert.equal(spent.leaderSkills!.assignments.some((entry) => entry.leader === f.traitorTarget), false);
assertMoritaniIndependentSkillsCustody(spent);
const settled = finishMoritaniIndependentSkillsBattle(spent, { winnerDiscard: 'played' });
assert.equal(settled.phase, 8, 'plain paired Skills keeps the original phase envelope without a card-module dependency');
assert.equal(settled.discard.filter(card => card.id === f.cards.winnerWeapon).length, 1);
assert.equal(settled.moritaniAssassinate!.opportunities.length, 1);
assert.equal(settled.moritaniAssassinate!.opportunities[0].stage, 'replaced');
assert.equal(settled.traitorReserve!.length, f.pending.traitorReserve!.length - 1);
assert.equal(Object.entries(settled.techTokens!).filter(([id, token]) =>
defeatedTokens.includes(id) && token.owner === f.opponent).length, 1);
assertMoritaniIndependentSkillsCustody(settled);
});
void test('Advanced native World cards pay their real normal source fee and assassinate a different unused trained disc before one Mentat replacement', () => {
let initial = createGame('WORLDASSASSINATE', newPlayer('native-owner', 'Moritani', 'moritani'), true, ['ecaz']);
joinGame(initial, newPlayer('native-rival', 'Guild', 'guild'));
joinGame(initial, newPlayer('native-court', 'Emperor', 'emperor'));
for (const action of [
{ type: 'homeworlds', enabled: true },
{ type: 'moduleOption', module: 'leaderSkills', enabled: true },
{ type: 'moduleOption', module: 'ecazTreachery', enabled: true },
{ type: 'techTokens', enabled: true },
{ type: 'strongholdCards', enabled: true },
] as const) initial = applyAction(initial, initial.host, action);
const f = createMoritaniIndependentSkillsFixture({ initial });
const shipment = f.shipment!;
assert.deepEqual(pool(shipment.after, f.opponent),
{ normal: pool(shipment.before, f.opponent).normal - 1, elite: 0 });
assert.equal(player(shipment.after, f.opponent).spice, player(shipment.before, f.opponent).spice - 1);
assert.equal(player(f.afterEntry, f.opponent).forces[f.second], 6);
const plan = f.plans.find((step) => step.actor === f.moritani)!;
if (plan.action.type !== 'battlePlan') throw new Error('Expected the real Moritani plan.');
reject(f.game, f.moritani, { ...plan.action,
reinforcementsSources: { 'homeworld:guild': { normal: 3, elite: 0 } } });
reject(f.game, f.moritani, { ...plan.action,
reinforcementsSources: { 'homeworld:moritani': { normal: 2, elite: 0 } } });
assert.equal(f.pending.lastBattleContext!.winner, f.opponent);
assert.equal(f.pending.lastBattleContext!.result, 'normal');
assert.deepEqual(pool(f.pending, f.moritani),
{ normal: pool(f.revealed, f.moritani).normal - 3 + 2, elite: 0 });
assert.equal(player(f.pending, f.moritani).tanks, 7);
assert.deepEqual(player(f.pending, f.moritani).forces, {});
assert.deepEqual(player(f.pending, f.opponent).forces, { [f.second]: 4 });
assert.deepEqual(f.returns, { [f.first]: { normal: 2, elite: 0 } });
const assignment = f.pending.leaderSkills!.assignments.find((entry) => entry.leader === f.traitorTarget)!;
assert.ok(assignment);
assert.notEqual(f.traitorTarget, f.winnerLeader);
assert.equal(owned(f, f.pending, f.traitorTarget).usedAt, undefined);
const spent = chooseMoritaniIndependentSkillsAssassination(f.pending, f.traitorTarget);
const target = owned(f, spent, f.traitorTarget);
assert.equal(target.dead, true);
assert.equal(target.deaths, 1);
assert.equal(target.usedAt, undefined);
assert.equal(owned(f, spent, f.winnerLeader).dead, false);
assert.deepEqual(spent.leaderSkills!.deck, [...f.pending.leaderSkills!.deck, assignment.skill]);
assert.equal(spent.leaderSkills!.assignments.some((entry) => entry.leader === target.id), false);
assert.equal(player(spent, f.moritani).spice, player(f.pending, f.moritani).spice + target.strength);
assert.deepEqual(player(spent, f.moritani).traitors, [f.traitorTarget]);
const beforeMentat = finishMoritaniIndependentSkillsBattle(spent, { winnerDiscard: 'played' },
(game) => game.phase === 7 && moritaniIndependentSkillsClean(game));
assert.equal(beforeMentat.moritaniAssassinate!.opportunities[0].stage, 'revealed');
const settled = finishMoritaniIndependentSkillsBattle(beforeMentat);
const receipt = settled.moritaniAssassinate!.opportunities[0];
assert.equal(receipt.stage, 'replaced');
assert.equal(receipt.bounty, target.strength);
assert.ok(receipt.replacement && receipt.replacement !== f.traitorTarget);
assert.deepEqual(player(settled, f.moritani).traitors, [receipt.replacement]);
assert.equal(settled.traitorReserve!.length, f.pending.traitorReserve!.length - 1);
assert.equal(settled.leaderSkills!.deck.filter((skill) => skill === assignment.skill).length, 1);
assert.deepEqual(pool(settled, f.moritani), pool(f.pending, f.moritani));
assertMoritaniIndependentSkillsCustody(settled);
});
void test('paired native Worlds keep Ecaz Loyalty and the shared Duke outside an unused trained-target assassination', () => {
const f = createMoritaniIndependentSkillsFixture({ paired: true, homeworlds: true,
rules: 'advanced', skills: true, cards: true, tech: true, strongholds: true });
const loyalty = f.afterSetup.ecazLoyalty!;
assert.notEqual(f.traitorTarget, loyalty.card);
assert.notEqual(f.traitorTarget, f.winnerLeader);
assert.notEqual(f.traitorTarget, 'duke-vidal');
assert.equal(f.pending.leaderSkills!.assignments.some((entry) => entry.leader === 'duke-vidal'), false);
assert.deepEqual(pool(f.shipment!.after, f.opponent),
{ normal: pool(f.shipment!.before, f.opponent).normal - 1, elite: 0 });
assert.equal(player(f.shipment!.after, f.opponent).spice,
player(f.shipment!.before, f.opponent).spice - 2);
assert.equal(player(f.afterEntry, f.opponent).forces[f.second], 7);
assert.deepEqual(player(f.pending, f.opponent).forces, { [f.second]: 3 });
assert.equal(player(f.pending, f.opponent).spice, player(f.game, f.opponent).spice - 4,
'support uses the real prebattle wallet after original optional phase income');
const assignment = f.pending.leaderSkills!.assignments.find((entry) => entry.leader === f.traitorTarget)!;
const spent = chooseMoritaniIndependentSkillsAssassination(f.pending, f.traitorTarget);
assert.equal(owned(f, spent, f.traitorTarget).usedAt, undefined);
assert.equal(owned(f, spent, f.traitorTarget).dead, true);
assert.equal(owned(f, spent, f.winnerLeader).dead, false);
assert.equal(spent.moritaniAssassinate!.opportunities[0].bounty, 4);
assert.equal(player(spent, f.moritani).spice, player(f.pending, f.moritani).spice + 4);
assert.deepEqual(spent.leaderSkills!.deck, [...f.pending.leaderSkills!.deck, assignment.skill]);
const settled = finishMoritaniIndependentSkillsBattle(spent, { winnerDiscard: 'played' });
const receipt = settled.moritaniAssassinate!.opportunities[0];
assert.equal(receipt.stage, 'replaced');
assert.notEqual(receipt.replacement, loyalty.card);
assert.equal(settled.traitorReserve!.length, f.pending.traitorReserve!.length - 1);
assert.equal(settled.leaderSkills!.deck.filter((skill) => skill === assignment.skill).length, 1);
assert.deepEqual(settled.ecazLoyalty, loyalty);
assert.deepEqual(settled.dukeVidal, f.afterSetup.dukeVidal);
assertMoritaniIndependentSkillsCustody(settled);
});
void test('Basic native Worlds return the typed Harass survivors but cannot assassinate the unused trained Ecaz target', () => {
const f = createMoritaniIndependentSkillsFixture({ paired: true, homeworlds: true,
rules: 'basic', skills: true, cards: true });
assert.equal(f.pending.lastBattleContext!.winner, f.opponent);
assert.equal(f.pending.lastBattleContext!.result, 'normal');
assert.deepEqual(f.returns, { [f.first]: { normal: 4, elite: 0 } });
assert.deepEqual(pool(f.pending, f.moritani),
{ normal: pool(f.revealed, f.moritani).normal - 3 + 4, elite: 0 });
assert.equal(player(f.pending, f.moritani).tanks, 5);
assert.deepEqual(player(f.pending, f.moritani).forces, {});
assert.deepEqual(player(f.pending, f.opponent).forces, { [f.second]: 3 });
assert.equal(f.pending.moritaniAssassinate, undefined);
reject(f.pending, f.moritani, { type: 'decision', card: f.traitorTarget });
const settled = finishMoritaniIndependentSkillsBattle(f.pending);
assert.equal(owned(f, settled, f.traitorTarget).dead, false);
assert.equal(owned(f, settled, f.traitorTarget).usedAt, undefined);
assert.deepEqual(settled.leaderSkills, f.pending.leaderSkills);
assert.deepEqual(player(settled, f.moritani).traitors, [f.traitorTarget]);
assert.deepEqual(pool(settled, f.moritani), pool(f.pending, f.moritani));
assertMoritaniIndependentSkillsCustody(settled);
});