Connect native E3 World Skills and independent cards

This commit is contained in:
Quintine committed 2026-10-17 23:38:13 +11:00
1 parent 01985af36b
commit bc37cd38fe
18 files changed
+553 -92

No files matched your search

+118 -1
View File
@@ -1,11 +1,14 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { Game } from '../game/engine';
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,
@@ -410,3 +413,117 @@ for (const lead of ['ecaz', 'ally'] as const) {
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);
});
+12 -3
View File
@@ -13,6 +13,7 @@ import {
import { REINFORCEMENTS_CARD } from '../game/reinforcements';
import type { SukRescueOption } from '../game/suk-graduate';
import { quoteBattleResolution, type ResolutionCombatant } from '../game/battle-resolution-quote';
import { homeworldGameIntegrity } from '../game/homeworld-game';
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
import { chooseEcazOccupyLead, finishEcazOccupySetup, stageEcazOccupyCard } from './fixture-ecaz-occupy';
import { createHomeworldEcazOccupyFixture } from './fixture-homeworld-ecaz-occupy';
@@ -32,6 +33,8 @@ export type EcazIndependentSkillsOptions = {
skills?: boolean;
/** The independent physical three-card variant; `false` is the printed no-card control. */
independent?: boolean;
/** Original native World setup, selected before start; off by default. */
homeworlds?: boolean;
/** Original optional modules, both off by default; Strongholds require Advanced. */
tech?: boolean;
strongholds?: boolean;
@@ -143,6 +146,7 @@ export function stageEcazIndependentSkillsCard(game: Game, actor: string,
/** The admitted original card source (thirty-six with the independent variant),
* all14 skills and ordinary counters. */
export function assertEcazIndependentSkillsCustody(game: Game): void {
if (game.homeworlds) homeworldGameIntegrity(game);
if (game.leaderSkills) validateLeaderSkills(game.leaderSkills, game.players);
const sum = (values: Record<string, number>) => Object.values(values).reduce((a, b) => a + b, 0);
for (const player of game.players) {
@@ -263,7 +267,7 @@ export function createEcazIndependentSkillsFixture(
...LEADER_SKILL_CARDS.map(card => card.id).filter(id => id !== 'suk-graduate')];
const draws = skills ? permutationDraws(LEADER_SKILL_CARDS.map(card => card.id), order) : [];
const shared = entropy(() => createHomeworldEcazOccupyFixture({
advanced, skills, independent, homeworlds: false,
advanced, skills, independent, homeworlds: options.homeworlds ?? false,
tech: options.tech ?? false, strongholds: options.strongholds ?? false,
...(options.paired ? { allyFaction: 'moritani' as const } : {}),
...(skills ? { finishSetup: (initial: Game) => trainEcazSeat(initial, picks) } : {}),
@@ -295,7 +299,6 @@ export function createEcazIndependentSkillsFixture(
state => !!state.battle && ecazIndependentSkillsClean(state) && !state.battle.preparation &&
state.battle.preLeader?.closed !== false, actions, policy);
const afterLead = structuredClone(game);
const beforePlans = structuredClone(game);
const cardUser = lead === 'ecaz' ? shared.ecaz : shared.ally;
const cardIds = cardKinds.map(kind => stageEcazIndependentSkillsCard(game, cardUser,
ECAZ_INDEPENDENT_SKILLS_CARD_IDS[kind], staging));
@@ -318,12 +321,18 @@ export function createEcazIndependentSkillsFixture(
conserveTraitor(game, shared.opponent, traitor, staging);
callers.push(shared.opponent);
}
const feeSources = cardKinds.includes('reinforcements') && game.homeworlds
? viewGame(game, cardUser).battle?.reinforcements?.homeworldSources?.[0] : undefined;
if (cardKinds.includes('reinforcements') && game.homeworlds)
assert.ok(feeSources, 'Choose an actual offered native World source for the card user’s fee');
const plans: EcazIndependentSkillsStep[] = [
{ actor: cardUser, action: { type: 'battlePlan', dial: profile.fixedEcazDial + variable,
support, allyPayment: 0, leader: planLeader, ...own } },
support, allyPayment: 0, leader: planLeader, ...own,
...(feeSources ? { reinforcementsSources: structuredClone(feeSources) } : {}) } },
{ actor: shared.opponent, action: { type: 'battlePlan', dial: 0, support: 0,
leader: defender, weapon: null, defense: null } },
];
const beforePlans = structuredClone(game);
for (const plan of plans) {
actions.push(structuredClone(plan));
game = stepEcazIndependentSkills(game, plan);
+11 -2
View File
@@ -3,6 +3,7 @@ import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, ty
import { botActions } from '../game/bots';
import { territory } from '../game/board';
import { finishEcazOccupySetup, type EcazOccupyFixture } from './fixture-ecaz-occupy';
import { nativeShipmentSources } from '../game/homeworld-options';
export type HomeworldEcazStep = { actor: string; action: Action };
export type HomeworldEcazOccupyFixture = EcazOccupyFixture & {
@@ -58,6 +59,14 @@ export function createHomeworldEcazOccupyFixture(options: {
actions.push({ actor, action: structuredClone(action) });
game = applyAction(game, actor, action);
};
const ship = (actor: string, action: Action) => {
if (homeworlds) {
const sources = nativeShipmentSources(viewGame(game, actor), Number(action.amount), Number(action.elite ?? 0));
assert.ok(sources, 'The original native World pools must fund the typed shipment');
action.homeworldSources = sources;
}
act(actor, action);
};
if (homeworlds) act('ecaz', { type: 'homeworlds', enabled: true });
if (options.tech) act('ecaz', { type: 'techTokens', enabled: true });
if (options.strongholds) act('ecaz', { type: 'strongholdCards', enabled: true });
@@ -92,7 +101,7 @@ export function createHomeworldEcazOccupyFixture(options: {
if (!homeworldEcazClean(game)) { step(); continue; }
const actor = game.active!;
if (strongholdTerritory && actor === 'ecaz' && !game.players.find(player => player.id === actor)!.shipped) {
act(actor, { type: 'ship', territory: strongholdTerritory,
ship(actor, { type: 'ship', territory: strongholdTerritory,
sector: territory(strongholdTerritory).sectors[0], amount: 4, elite: 0, allyPayment: 0, smuggler: false });
reach(homeworldEcazClean);
}
@@ -118,7 +127,7 @@ export function createHomeworldEcazOccupyFixture(options: {
if (!homeworldEcazClean(game)) { step(); continue; }
const actor = game.active!;
if (!shipped.has(actor) && !(strongholdTerritory && actor === 'ecaz')) {
act(actor, { type: 'ship', territory: battleTerritory, sector,
ship(actor, { type: 'ship', territory: battleTerritory, sector,
amount: actor === 'guild' ? 2 : 4,
elite: actor === ally && ally === 'emperor' && (advanced || homeworlds) ? 2 : 0, allyPayment: 0 });
shipped.add(actor);
+64 -45
View File
@@ -8,7 +8,7 @@ import { botActions } from '../game/bots';
import type { Difficulty } from '../game/bot-profiles';
import { ecazTreacheryCards } from '../game/ecaz-cards';
import { withoutEcazLoyalty } from '../game/ecaz-loyalty';
import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards';
import type { LeaderSkillId } from '../game/leader-skill-cards';
import { validateLeaderSkills } from '../game/leader-skills';
import { traitorDeck } from '../game/traitors';
import {
@@ -19,6 +19,9 @@ import { REINFORCEMENTS_CARD } from '../game/reinforcements';
import { quoteIndependentSkillsBattle } from './fixture-independent-skills-battle';
import { holdMoritaniStrongholdsTraitor } from './fixture-moritani-strongholds';
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
import { skillOfferRolls } from './fixture-independent-card-modules';
import { homeworldGameIntegrity } from '../game/homeworld-game';
import { nativeShipmentSources } from '../game/homeworld-options';
export type MoritaniIndependentSkillsStep = { actor: string; action: Action };
/** 'guild' keeps the classic Heighliner opponent; 'harkonnen' is the native
@@ -31,6 +34,8 @@ export type MoritaniIndependentSkillsOptions = {
skills?: boolean;
/** The independent three-card Ecaz Treachery variant of printed p. 18. */
cards?: boolean;
/** Original native World custody, selected before the ordinary lobby start. */
homeworlds?: boolean;
/** Original optional modules, both off by default; Strongholds require Advanced. */
tech?: boolean;
strongholds?: boolean;
@@ -65,6 +70,8 @@ export type MoritaniIndependentSkillsFixture = {
beforeSplit: Game;
split: MoritaniIndependentSkillsStep;
afterSplit: Game;
/** A paid native-pool shipment before the original opposing board move. */
shipment: { before: Game; step: MoritaniIndependentSkillsStep; after: Game } | null;
entry: MoritaniIndependentSkillsStep;
afterEntry: Game;
beforeBattle: Game;
@@ -122,21 +129,6 @@ function entropy<T>(run: () => T, rolls: readonly number[] = []): T {
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
/** Only the original fourteen-card lottery is solved; later native draws keep
* a fixed maximum value, and every roll is restored afterwards. */
function skillRolls(chosen: readonly LeaderSkillId[]): number[] {
const source = LEADER_SKILL_CARDS.map((card) => card.id);
const rest = source.filter((id) => !chosen.includes(id));
const desired = chosen.flatMap((id) => [id, rest.shift()!]).concat(rest);
const work = [...source], rolls: number[] = [];
for (let index = work.length - 1; index > 0; index--) {
const from = work.indexOf(desired[index]);
assert.ok(from >= 0 && from <= index);
rolls.push(Math.floor((from + 0.5) * 0x100000000 / (index + 1)));
[work[index], work[from]] = [work[from], work[index]];
}
return rolls;
}
export function stepMoritaniIndependentSkills(game: Game, step: MoritaniIndependentSkillsStep): Game {
return entropy(() => applyAction(game, step.actor, step.action));
}
@@ -168,6 +160,10 @@ export function nextMoritaniIndependentSkillsStep(
event: decision.event, hide: policy.hide?.[decision.player] ?? false } };
if (decision?.kind === 'mentatQuestion')
return { actor: decision.player, action: { type: 'decision', event: decision.event, decline: true } };
if (decision?.kind === 'homeworldDefense')
return { actor: decision.player, action: { type: 'decision', event: decision.event, use: false } };
if (decision?.kind === 'homeworldRevivalDeployment')
return { actor: decision.player, action: { type: 'decision', event: decision.event, decline: true } };
if (decision?.kind === 'rihani')
return { actor: decision.player, action: { type: 'decision', event: decision.event, draw: false } };
if (decision?.kind === 'battleLosses')
@@ -212,6 +208,7 @@ const counterTotal = (forces: Record<string, number>) =>
/** Conserved physical custody: counters, the real 33/36-card inventory, all
* fourteen skills and every Traitor identity, including Assassinate removal. */
export function assertMoritaniIndependentSkillsCustody(game: Game): void {
if (game.homeworlds) homeworldGameIntegrity(game);
if (game.leaderSkills) validateLeaderSkills(game.leaderSkills, game.players);
const removed = (game.moritaniAssassinate?.opportunities ?? [])
.filter((receipt) => receipt.stage === 'replaced').map((receipt) => receipt.card!);
@@ -266,10 +263,13 @@ function board(opponent: MoritaniIndependentSkillsOpponent | 'ecaz') {
export function createMoritaniIndependentSkillsFixture(
options: MoritaniIndependentSkillsOptions = {},
): MoritaniIndependentSkillsFixture {
const advanced = (options.rules ?? 'advanced') === 'advanced';
const skills = options.skills ?? false;
const cards = options.cards ?? false;
const opponentFaction = options.paired ? 'ecaz' : (options.opponent ?? 'guild');
const advanced = (options.rules ?? (options.initial?.advanced === false ? 'basic' : 'advanced')) === 'advanced';
const skills = options.skills || !!options.initial?.lobbyOptions?.leaderSkills || !!options.initial?.leaderSkills;
const cards = options.cards || !!options.initial?.lobbyOptions?.ecazTreachery || !!options.initial?.ecazTreachery;
const worlds = options.homeworlds || !!options.initial?.homeworlds;
const paired = options.paired || !!options.initial?.players.some((seat) => seat.faction === 'ecaz');
const opponentFaction = paired ? 'ecaz' : (options.opponent ??
(options.initial?.players.some((seat) => seat.faction === 'harkonnen') ? 'harkonnen' : 'guild'));
assert.ok(!skills || opponentFaction !== 'harkonnen',
'Leader Skills keep the Harkonnen native roster out of this composition.');
const plan = board(opponentFaction);
@@ -292,12 +292,13 @@ export function createMoritaniIndependentSkillsFixture(
assert.equal(game.advanced, advanced);
assert.deepEqual(game.expansions, ['ecaz']);
assert.equal(game.status, 'lobby', 'Continue only a fresh lobby of this exact composition.');
assert.ok(!game.homeworlds && !game.nexusCards && !game.discoveryEnabled,
'Only the original E3/classic composition and optional Tech/Strongholds are accepted.');
assert.ok(!game.techTokens || options.tech,
'A preselected original Tech module needs tech:true.');
assert.ok(!game.strongholdCards || options.strongholds,
'A preselected original Stronghold module needs strongholds:true.');
assert.ok(!game.nexusCards && !game.discoveryEnabled,
'Only the original E3/classic composition and optional World/Tech/Stronghold modules are accepted.');
assert.deepEqual(game.players.map((seat) => seat.faction).sort(), ['moritani', opponentFaction, 'emperor'].sort(),
'Preserve the original native three-faction roster.');
const moritani = game.players.find((seat) => seat.faction === 'moritani')!.id;
const opponent = game.players.find((seat) => seat.faction === opponentFaction)!.id;
const third = game.players.find((seat) => seat.faction === 'emperor')!.id;
const actions: MoritaniIndependentSkillsStep[] = [], staging: string[] = [];
const act = (actor: string, action: Action) => {
actions.push({ actor, action: structuredClone(action) });
@@ -305,40 +306,44 @@ export function createMoritaniIndependentSkillsFixture(
return game;
};
const initial = structuredClone(game);
if (skills) act('moritani', { type: 'moduleOption', module: 'leaderSkills', enabled: true });
if (cards) act('moritani', { type: 'moduleOption', module: 'ecazTreachery', enabled: true });
if (options.tech && !game.techTokens) act('moritani', { type: 'techTokens', enabled: true });
if (options.strongholds && !game.strongholdCards) act('moritani', { type: 'strongholdCards', enabled: true });
if (worlds && !game.homeworlds) act(game.host, { type: 'homeworlds', enabled: true });
if (skills && !game.lobbyOptions?.leaderSkills && !game.leaderSkills)
act(game.host, { type: 'moduleOption', module: 'leaderSkills', enabled: true });
if (cards && !game.lobbyOptions?.ecazTreachery && !game.ecazTreachery)
act(game.host, { type: 'moduleOption', module: 'ecazTreachery', enabled: true });
if (options.tech && !game.techTokens) act(game.host, { type: 'techTokens', enabled: true });
if (options.strongholds && !game.strongholdCards) act(game.host, { type: 'strongholdCards', enabled: true });
for (const player of game.players) if (!player.ready) act(player.id, { type: 'ready' });
const start: MoritaniIndependentSkillsStep = { actor: 'moritani',
const start: MoritaniIndependentSkillsStep = { actor: game.host,
action: { type: 'start', ...(advanced ? { advancedPreview: true as const } : {}) } };
actions.push(structuredClone(start));
game = entropy(() => applyAction(game, start.actor, start.action),
skills ? skillRolls([chosen.moritani, chosen[opponentFaction], chosen.emperor]) : []);
skills ? skillOfferRolls(game, chosen) : []);
staging.push('Normal lobby start through the real module options; the Advanced opt-in is the ordinary lobby action, never a development profile.');
if (skills) staging.push('Solved only the original fourteen-card Leader Skill lottery with real Fisher-Yates draws; offers, deck and later native shuffles are untouched.');
const offered = structuredClone(game);
for (let count = 0; game.status === 'setup' && count < 200; count++) {
if (game.setupStage === 'leaderSkills') {
const actor = Object.keys(game.leaderSkills!.offers)[0];
const faction = moritaniIndependentSkillsPlayer(game, actor).faction;
const offer = game.leaderSkills!.offers[actor];
const view = viewGame(game, actor).leaderSkills!;
const leader = moritaniIndependentSkillsPlayer(game, actor).leaders
.filter((disc) => disc.id === trainedLeader[actor] &&
.filter((disc) => disc.id === trainedLeader[faction] &&
view.eligibleLeaders.some((eligible) => eligible.id === disc.id))[0];
assert.ok(leader, 'The requested original leader must be eligible for this offer');
assert.ok(offer.cards.includes(chosen[actor]), 'The requested original skill must be in the real offer');
act(actor, { type: 'leaderSkill', event: offer.event, skill: chosen[actor], leader: leader.id });
assert.ok(offer.cards.includes(chosen[faction]), 'The requested original skill must be in the real offer');
act(actor, { type: 'leaderSkill', event: offer.event, skill: chosen[faction], leader: leader.id });
continue;
}
if (game.setupStage === 'forces' && opponentFaction === 'ecaz' &&
viewGame(game, 'ecaz').setupPending.includes('ecaz')) {
act('ecaz', { type: 'ecazSetup', placements: { [plan.from]: 6 } });
viewGame(game, opponent).setupPending.includes(opponent)) {
act(opponent, { type: 'ecazSetup', placements: { [plan.from]: 6 } });
continue;
}
if (game.setupStage === 'traitors' || game.players.some((player) => player.traitorChoices.length)) {
const player = game.players.find((seat) => seat.traitorChoices.length)!;
const keep = player.id === 'moritani'
const keep = player.id === moritani
? (player.traitorChoices.find((id) => id.startsWith(`${opponentFaction}-`)) ?? player.traitorChoices[0])
: player.traitorChoices[0];
act(player.id, { type: 'traitor', leader: keep });
@@ -354,9 +359,6 @@ export function createMoritaniIndependentSkillsFixture(
act(step.actor, step.action);
}
assert.equal(game.status, 'playing');
const moritani = 'moritani';
const opponent = game.players.find((seat) => seat.faction === opponentFaction)!.id;
const third = game.players.find((seat) => seat.faction === 'emperor')!.id;
assert.equal(moritaniIndependentSkillsPlayer(game, moritani).reserves, 14);
assert.equal(!!game.moritaniAssassinate, advanced,
'The printed Advanced assassination producer follows the real rules mode alone.');
@@ -390,6 +392,22 @@ export function createMoritaniIndependentSkillsFixture(
const afterSplit = structuredClone(game);
game = advanceMoritaniIndependentSkills(game,
(state) => state.phase === 5 && state.active === opponent && moritaniIndependentSkillsClean(state), actions);
let shipment: MoritaniIndependentSkillsFixture['shipment'] = null;
if (worlds) {
const before = structuredClone(game);
const sources = nativeShipmentSources(viewGame(game, opponent), 1, 0);
assert.ok(sources, 'The opponent ships its own real native World normal counter.');
const step: MoritaniIndependentSkillsStep = { actor: opponent, action: {
type: 'ship', territory: plan.battle, sector: plan.sectors[1], amount: 1, elite: 0,
allyPayment: 0, homeworldSources: sources } };
game = stepMoritaniIndependentSkills(game, step);
actions.push(structuredClone(step));
game = advanceMoritaniIndependentSkills(game,
(state) => state.phase === 5 && state.active === opponent && !state.pendingShipment &&
!state.pendingHomeworldShipment && moritaniIndependentSkillsClean(state), actions);
shipment = { before, step, after: structuredClone(game) };
staging.push('The opponent paid for a real normal-counter shipment from its original native World before moving its original starting army.');
}
const entrySector = plan.sectors[1];
assert.notEqual(game.storm, entrySector, 'The real opponent entry must not enter the storm sector.');
const entry: MoritaniIndependentSkillsStep = { actor: opponent, action: {
@@ -417,10 +435,11 @@ export function createMoritaniIndependentSkillsFixture(
const winnerWeapon = conserveCard(game, opponent, (card) => card.kind === 'worthless', staging);
const plans: MoritaniIndependentSkillsStep[] = [
{ actor: moritani, action: { type: 'battlePlan', dial: 2, support: 0, leader: loser,
...(reinforcements && harass ? { weapon: REINFORCEMENTS_CARD, defense: HARASS_WITHDRAW_CARD } : {}) } },
...(reinforcements && harass ? { weapon: REINFORCEMENTS_CARD, defense: HARASS_WITHDRAW_CARD,
...(worlds ? { reinforcementsSources: { 'homeworld:moritani': { normal: 3, elite: 0 } } } : {}) } : {}) } },
{ actor: opponent, action: { type: 'battlePlan',
dial: options.paired ? (cards ? 4 : 2) : (cards ? 1 : 0),
support: options.paired && advanced ? (cards ? 4 : 2) : 0,
dial: paired ? (cards ? 4 : 2) : (cards ? 1 : 0),
support: paired && advanced ? (cards ? 4 : 2) : 0,
leader: winner, weapon: winnerWeapon.id } },
];
const window = structuredClone(game);
@@ -436,7 +455,7 @@ export function createMoritaniIndependentSkillsFixture(
assert.ok(!cards || footer, 'The committed Reinforcements fee plus Harass return must be a real physical choice');
const returns = footer ? defaultHarassWithdrawAllocation(footer.context, footer.dial, footer.support) : null;
const fixture: MoritaniIndependentSkillsFixture = {
initial, offered, afterSetup, beforeSplit, split, afterSplit, entry, afterEntry, beforeBattle,
initial, offered, afterSetup, beforeSplit, split, afterSplit, shipment, entry, afterEntry, beforeBattle,
battleStep, game: window, plans, revealed, returns, returnsContext: footer?.context ?? null,
pending: structuredClone(revealed), moritani, opponent, third, territory: plan.battle,
first: `${plan.battle}:${plan.sectors[0]}`, second: `${plan.battle}:${plan.sectors[1]}`,
@@ -1,9 +1,11 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
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,
@@ -28,6 +30,9 @@ const owned = (fixture: MoritaniIndependentSkillsFixture, game: Game, identity:
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 });
@@ -40,7 +45,6 @@ void test('Advanced standalone Moritani keeps the printed Assassinate power thro
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);
@@ -449,7 +453,6 @@ void test('paired Advanced Skills with original phase modules pays the printed E
const f = createMoritaniIndependentSkillsFixture({ paired: true, rules: 'advanced', skills: true,
tech: true, strongholds: true });
assert.equal(f.pending.lastBattleContext!.winner, f.opponent);
assert.equal(f.initial.nexusCards, undefined);
assert.equal(f.returns, null);
assert.equal(f.cards.reinforcements, null);
assert.equal(f.cards.harass, null);
@@ -476,3 +479,120 @@ void test('paired Advanced Skills with original phase modules pays the printed E
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);
});