Connect paired E3 Skills without Nexus and retain native arrival independence

Reuse original paired roster validation with plain all14 native setup and optional independent cards. Preserve real coalition, trained death and Mentat paths; extend the existing paid Ambassador/Terror queue to paired Skills and card-only factions. Frozen29-file323/323, types/lint/build, native programmes and390px entry pass. No save conversion or deployment.
This commit is contained in:
Quintine committed 2026-10-17 21:12:35 +11:00
1 parent fde44baab5
commit 17adffa6a7
20 files changed
+573 -93

No files matched your search

+3 -25
View File
@@ -353,31 +353,9 @@ void test('original Enemy of My Enemy and ally retention exclude a consumed nati
assert.deepEqual(normalizeAutomaticGame(reload(game)), game);
});
void test('new native admission preserves public, Harkonnen assassination, foreign family/module and Advanced Atreides Suk assignment', () => {
const make = (faction: 'atreides' | 'harkonnen' | 'ixians' | 'ecaz' = 'atreides', expansions: Game['expansions'] = ['ecaz']) => {
const game = createGame('MORIGATE', newPlayer('original-m', 'Moritani', 'moritani'), true, expansions);
joinGame(game, newPlayer('original-other', faction, faction));
return game;
};
reject(make(), 'original-m', { type: 'start' });
for (const game of [make('harkonnen'), make('ixians', ['ecaz', 'ix']), make('ecaz'), make('atreides', ['ecaz', 'choam'])]) {
game.players.forEach(p => { p.ready = true; });
const before = reload(game);
assert.throws(() => initializeLeaderSkillsGameForAudit(game));
assert.deepEqual(game, before);
}
// Native Discovery Leader Skills are now admitted for this fresh Moritani
// lobby; the foreign-family and combined-expansion lobbies still reject.
const discoveryLobby = make();
discoveryLobby.players.forEach(p => { p.ready = true; });
discoveryLobby.discoveryEnabled = true;
const discoveryBefore = reload(discoveryLobby);
assert.ok(initializeLeaderSkillsGameForAudit(discoveryLobby).leaderSkills);
assert.deepEqual(discoveryLobby, discoveryBefore, 'The lobby is never mutated.');
// Basic Moritani keeps its original Harkonnen-compatible skills profile; it
// does not acquire the Advanced-only assassination preview.
const basicLobby = make('harkonnen');
basicLobby.advanced = false;
void test('Basic native Harkonnen keeps four Traitors and Advanced Atreides can assign an offered Suk Graduate', () => {
const basicLobby = createGame('MORIGATE', newPlayer('original-m', 'Moritani', 'moritani'), false, ['ecaz']);
joinGame(basicLobby, newPlayer('original-other', 'Harkonnen', 'harkonnen'));
basicLobby.players.forEach(p => { p.ready = true; });
const basic = completeAdvancedNativeSkillsSetup(initializeLeaderSkillsGameForAudit(basicLobby), { family: 'moritani' });
assert.equal(basic.advanced, false);
@@ -9,6 +9,7 @@ import {
assertEcazIndependentSkillsCustody,
createEcazIndependentSkillsFixture, ecazIndependentSkillsPlayer as player,
quoteEcazIndependentSkillsBattle, settleEcazIndependentSkillsBattle,
type EcazIndependentSkillsFixture,
} from './fixture-ecaz-independent-skills';
const at = (game: Game, id: string, key: string) => player(game, id).forces[key] ?? 0;
@@ -23,6 +24,26 @@ function physicalSource(game: Game, count: number): void {
...(game.ecazTreachery ? ecazTreacheryCards() : [])].map(card => card.id).sort());
}
/** Both native starting armies stay physical outside the genuinely shipped battle. */
function pairedArmies(f: EcazIndependentSkillsFixture, end: Game): void {
for (const game of [f.afterSetup, f.game, end]) {
assert.equal(at(game, f.ecaz, 'imperial_basin:9'), 6);
assert.equal(at(game, f.ally, 'polar_sink:0'), 6);
}
assert.equal(at(f.game, f.ally, f.location), 4);
assert.equal(player(f.game, f.ally).reserves, player(f.afterSetup, f.ally).reserves - 4,
'Moritani ships four of its own reserves, not its original six or Imperial elites');
}
function disposedLeadCards(f: EcazIndependentSkillsFixture, end: Game): void {
assert.equal(end.lastBattleContext?.winnerDiscards?.completed, true);
assert.deepEqual(end.lastBattleContext?.winnerDiscards?.cards.slice().sort(), f.cardIds.slice().sort());
for (const id of f.cardIds) {
assert.equal(player(end, f.cardUser).hand.some(card => card.id === id), false);
assert.equal(end.discard.filter(card => card.id === id).length, 1);
}
}
for (const advanced of [false, true]) {
void test(`original independent cards with all14 ${advanced ? 'Advanced' : 'Basic'} reach the printed lead choice, and the trained Ecaz Suk rescues only its own committed losses`, () => {
const f = createEcazIndependentSkillsFixture({ advanced, lead: 'ecaz' });
@@ -242,3 +263,99 @@ for (const advanced of [false, true]) {
});
}
for (const advanced of [false, true]) {
void test(`native paired no-card all14 ${advanced ? 'Advanced Moritani lead keeps Ecaz losses separate' : 'Basic Ecaz lead rescues its fixed commitment'} through coalition cleanup`, () => {
const f = createEcazIndependentSkillsFixture({
paired: true, advanced, independent: false, cards: [], lead: advanced ? 'ally' : 'ecaz',
});
physicalSource(f.initial, 33);
physicalSource(f.game, 33);
const quote = quoteEcazIndependentSkillsBattle(f);
assert.deepEqual(quote.fixedLosses, [{ owner: f.ecaz, normal: 2, elite: 0 }]);
assert.equal(quote.casualties?.owner, f.ally);
assert.equal(quote.reinforcements, undefined);
assert.equal(quote.harassWithdraw, undefined);
const end = settleEcazIndependentSkillsBattle(f);
assert.equal(end.phase, 7);
assert.equal(end.lastBattleContext?.winner, f.cardUser);
assert.equal(end.lastBattleContext?.result, 'normal');
assert.equal(end.lastBattleContext?.sukRescue?.completed, advanced ? undefined : true);
assert.equal(change(player(f.game, f.ecaz).tanks, player(end, f.ecaz).tanks), advanced ? 2 : 0);
assert.equal(change(player(f.game, f.ecaz).reserves, player(end, f.ecaz).reserves), advanced ? 0 : 1);
assert.equal(at(end, f.ecaz, f.location), advanced ? 2 : 3);
assert.equal(change(player(f.game, f.ally).tanks, player(end, f.ally).tanks), 2);
assert.equal(change(player(f.game, f.ally).reserves, player(end, f.ally).reserves), 0);
assert.equal(at(end, f.ally, f.location), 2);
assert.equal(change(player(f.game, f.opponent).tanks, player(end, f.opponent).tanks), 2);
assert.equal(at(end, f.opponent, f.location), 0);
pairedArmies(f, end);
assertEcazIndependentSkillsCustody(end);
});
}
void test('native paired Ecaz lead pays its own fee, returns its undialed fixed army and rescues only its committed losses', () => {
const f = createEcazIndependentSkillsFixture({ paired: true });
physicalSource(f.initial, 36);
const quote = quoteEcazIndependentSkillsBattle(f);
assert.deepEqual(quote.reinforcements,
[{ player: f.ecaz, card: REINFORCEMENTS_CARD, normal: 3, elite: 0 }]);
assert.deepEqual(quote.harassWithdraw![0].returned, { normal: 2, elite: 0 });
assert.deepEqual(quote.harassWithdraw![0].locations, { [f.location]: { normal: 2, elite: 0 } });
assert.equal(quote.casualties?.owner, f.ally);
const rescue = settleEcazIndependentSkillsBattle(f, { stop: 'rescue' });
assert.ok(rescue.decision?.kind === 'sukRescue');
assert.equal(rescue.decision.player, f.ecaz);
assert.equal(rescue.decision.mode, 'skilled');
assert.deepEqual(rescue.decision.options.filter(option => option.kept),
[{ normal: 1, elite: 0, kept: { key: f.location, kind: 'normal' } },
{ normal: 2, elite: 0, kept: { key: f.location, kind: 'normal' } }]);
// Fee is already in Ecaz's Tanks; its undialed pair is already home. Neither
// the fee nor Moritani's two casualties enter this actual rescue offer.
assert.equal(change(player(f.game, f.ecaz).tanks, player(rescue, f.ecaz).tanks), 3);
assert.equal(change(player(f.game, f.ecaz).reserves, player(rescue, f.ecaz).reserves), -1);
assert.equal(at(rescue, f.ecaz, f.location), 2, 'only the committed pair awaits the rescue choice');
assert.equal(change(player(f.game, f.ally).tanks, player(rescue, f.ally).tanks), 2);
const end = settleEcazIndependentSkillsBattle(f, { state: rescue });
assert.equal(end.phase, 7);
assert.equal(end.lastBattleContext?.winner, f.ecaz);
assert.equal(end.lastBattleContext?.sukRescue?.completed, true);
assert.equal(change(player(f.game, f.ecaz).tanks, player(end, f.ecaz).tanks), 3);
assert.equal(change(player(f.game, f.ecaz).reserves, player(end, f.ecaz).reserves), 0);
assert.equal(at(end, f.ecaz, f.location), 1);
assert.equal(change(player(f.game, f.ally).tanks, player(end, f.ally).tanks), 2);
assert.equal(change(player(f.game, f.ally).reserves, player(end, f.ally).reserves), 0);
assert.equal(at(end, f.ally, f.location), 2);
assert.equal(change(player(f.game, f.opponent).tanks, player(end, f.opponent).tanks), 2);
disposedLeadCards(f, end);
pairedArmies(f, end);
physicalSource(end, 36);
assertEcazIndependentSkillsCustody(end);
});
void test('native paired Basic Moritani lead pays and withdraws its normal variable army without merging Ecaz fixed losses', () => {
const f = createEcazIndependentSkillsFixture({ paired: true, advanced: false, lead: 'ally' });
const quote = quoteEcazIndependentSkillsBattle(f);
assert.deepEqual(quote.reinforcements,
[{ player: f.ally, card: REINFORCEMENTS_CARD, normal: 3, elite: 0 }]);
assert.deepEqual(quote.harassWithdraw![0].returned, { normal: 2, elite: 0 });
assert.deepEqual(quote.harassWithdraw![0].locations, { [f.location]: { normal: 2, elite: 0 } });
assert.deepEqual(quote.fixedLosses, [{ owner: f.ecaz, normal: 2, elite: 0 }]);
assert.equal(quote.sukGraduate, undefined);
const end = settleEcazIndependentSkillsBattle(f);
assert.equal(end.phase, 7);
assert.equal(end.lastBattleContext?.winner, f.ally);
assert.equal(end.lastBattleContext?.sukRescue, undefined);
assert.equal(change(player(f.game, f.ally).reserves, player(end, f.ally).reserves), -1);
assert.equal(change(player(f.game, f.ally).tanks, player(end, f.ally).tanks), 5);
assert.equal(at(end, f.ally, f.location), 0);
assert.equal(change(player(f.game, f.ecaz).reserves, player(end, f.ecaz).reserves), 0);
assert.equal(change(player(f.game, f.ecaz).tanks, player(end, f.ecaz).tanks), 2);
assert.equal(at(end, f.ecaz, f.location), 2);
assert.equal(player(end, f.ecaz).leaders.find(leader => leader.id === f.trainer)!.dead, false);
assert.equal(change(player(f.game, f.opponent).tanks, player(end, f.opponent).tanks), 2);
disposedLeadCards(f, end);
pairedArmies(f, end);
physicalSource(end, 36);
assertEcazIndependentSkillsCustody(end);
});
+5 -2
View File
@@ -18,14 +18,16 @@ import { chooseEcazOccupyLead, finishEcazOccupySetup, stageEcazOccupyCard } from
import { createHomeworldEcazOccupyFixture } from './fixture-homeworld-ecaz-occupy';
/** Printed E3 shared rule (docs/ECAZ_TREACHERY_RULES.md): the three-card variant
* is independent of faction selection and other variants, so a standalone Ecaz
* seat with classic opponents keeps it; all14 Leader Skills is the separate
* is independent of faction selection and other variants, so standalone Ecaz
* and the native Ecaz/Moritani pair keep it; all14 Leader Skills is the separate
* optional module documented in docs/LEADER_SKILLS_RULES.md. Card timings and the
* fee-before-return order stay provisional as recorded there, not new rulings. */
export type EcazIndependentSkillsStep = { actor: string; action: Action };
export type EcazIndependentSkillsCard = 'reinforcements' | 'harass';
export type EcazIndependentSkillsOptions = {
advanced?: boolean;
/** Native Ecaz/Moritani seats; the default keeps the standalone Emperor ally. */
paired?: boolean;
/** All14 skills; Basic and Advanced are both genuine native bands. */
skills?: boolean;
/** The independent physical three-card variant; `false` is the printed no-card control. */
@@ -255,6 +257,7 @@ export function createEcazIndependentSkillsFixture(
const draws = skills ? permutationDraws(LEADER_SKILL_CARDS.map(card => card.id), order) : [];
const shared = entropy(() => createHomeworldEcazOccupyFixture({
advanced, skills, independent, homeworlds: false,
...(options.paired ? { allyFaction: 'moritani' as const } : {}),
...(skills ? { finishSetup: (initial: Game) => trainEcazSeat(initial, picks) } : {}),
}), draws);
if (skills) {
+12 -6
View File
@@ -15,6 +15,8 @@ export function nextHomeworldEcazStep(game: Game): HomeworldEcazStep | null {
action: { type: 'passResponse' } };
if (game.decision?.kind === 'ecazPlacement')
return { actor: game.decision.player, action: { type: 'decision', decline: true } };
if (game.decision?.kind === 'moritaniPlacement')
return { actor: game.decision.player, action: { type: 'decision', decline: true } };
if (!game.phaseOpening && !game.decision && game.phase === 0 && game.stormPending === null) {
const actor = game.stormDialers.find(id => game.stormDials[id] === undefined);
if (actor) return { actor, action: { type: 'stormDial', amount: game.turn === 1 ? 0 : 1 } };
@@ -36,6 +38,7 @@ export function nextHomeworldEcazStep(game: Game): HomeworldEcazStep | null {
* phases, alliances, World custody and offered skills follow the real actions. */
export function createHomeworldEcazOccupyFixture(options: {
advanced?: boolean; skills?: boolean; independent?: boolean;
allyFaction?: 'emperor' | 'moritani';
/** No-World control uses the same genuine alliance/Arrakis route. */
homeworlds?: boolean;
/** Select only actual offered setup choices; the captured initial stays untouched. */
@@ -43,8 +46,9 @@ export function createHomeworldEcazOccupyFixture(options: {
} = {}): HomeworldEcazOccupyFixture {
const advanced = options.advanced ?? true;
const homeworlds = options.homeworlds ?? true;
const ally = options.allyFaction ?? 'emperor';
let game = createGame('WORLDECAZOCCUPY', newPlayer('ecaz', 'Ecaz', 'ecaz'), advanced, ['ecaz']);
joinGame(game, newPlayer('emperor', 'Emperor', 'emperor'));
joinGame(game, newPlayer(ally, ally === 'emperor' ? 'Emperor' : 'Moritani', ally));
joinGame(game, newPlayer('guild', 'Guild', 'guild'));
const actions: HomeworldEcazStep[] = [];
const act = (actor: string, action: Action) => {
@@ -86,11 +90,13 @@ export function createHomeworldEcazOccupyFixture(options: {
orderBlow(false, 1);
if (advanced) orderBlow(false, 2);
reach(g => g.phase === 1 && g.nexus && !g.spiceWindow && !g.spiceResolution && homeworldEcazClean(g));
act('ecaz', { type: 'alliance', target: 'emperor' });
act('emperor', { type: 'alliance', target: 'ecaz' });
act('ecaz', { type: 'alliance', target: ally });
act(ally, { type: 'alliance', target: 'ecaz' });
reach(g => g.phase === 5 && homeworldEcazClean(g));
const battleTerritory = ['arrakeen', 'sietch_tabr', 'habbanya_ridge_sietch']
.find(id => territory(id).sectors[0] !== game.storm)!;
.find(id => territory(id).sectors[0] !== game.storm &&
!game.players.some(player => Object.entries(player.forces).some(([key, amount]) =>
amount > 0 && key.startsWith(`${id}:`))))!;
const sector = territory(battleTerritory).sectors[0];
const location = `${battleTerritory}:${sector}`;
const shipped = new Set<string>();
@@ -100,7 +106,7 @@ export function createHomeworldEcazOccupyFixture(options: {
if (!shipped.has(actor)) {
act(actor, { type: 'ship', territory: battleTerritory, sector,
amount: actor === 'guild' ? 2 : 4,
elite: actor === 'emperor' && (advanced || homeworlds) ? 2 : 0, allyPayment: 0 });
elite: actor === ally && ally === 'emperor' && (advanced || homeworlds) ? 2 : 0, allyPayment: 0 });
shipped.add(actor);
reach(homeworldEcazClean);
}
@@ -112,6 +118,6 @@ export function createHomeworldEcazOccupyFixture(options: {
act(choice.chooser, { type: 'chooseBattle', territory: battleTerritory,
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker });
if (!options.skills) assert.equal(game.decision?.kind, 'ecazBattleLead');
return { game, initial, afterSetup, actions, ecaz: 'ecaz', ally: 'emperor', opponent: 'guild',
return { game, initial, afterSetup, actions, ecaz: 'ecaz', ally, opponent: 'guild',
territory: battleTerritory, location, ecazForces: 4 };
}
+33 -10
View File
@@ -7,6 +7,7 @@ import { treacheryDeck, type Card } from '../game/cards';
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 { validateLeaderSkills } from '../game/leader-skills';
import { traitorDeck } from '../game/traitors';
@@ -31,6 +32,9 @@ export type MoritaniIndependentSkillsOptions = {
/** The independent three-card Ecaz Treachery variant of printed p. 18. */
cards?: boolean;
opponent?: MoritaniIndependentSkillsOpponent;
/** Native Ecaz replaces the classic opponent; both factions keep printed setup.
* `opponent` remains the standalone-only Guild/Harkonnen selector. */
paired?: boolean;
/** Conserved Traitor identity Moritani really holds: the unused target disc
* (default) or the winning committed disc, for a real normal Traitor call. */
traitor?: 'target' | 'winner';
@@ -174,7 +178,7 @@ export function nextMoritaniIndependentSkillsStep(
if (decision?.kind === 'capturedLeader')
return { actor: decision.player,
action: { type: 'decision', mode: policy.capture?.captured ?? 'keep' } };
if (decision?.kind === 'moritaniPlacement')
if (decision?.kind === 'moritaniPlacement' || decision?.kind === 'ecazPlacement')
return { actor: decision.player, action: { type: 'decision', decline: true } };
if (decision?.kind === 'moritaniSpecialKarama')
return { actor: decision.player, action: { type: 'decision', event: decision.event, decline: true } };
@@ -222,7 +226,8 @@ export function assertMoritaniIndependentSkillsCustody(game: Game): void {
.map((card) => card.id).sort(),
'Every physical Treachery Card must stay in one known zone.');
assert.deepEqual([...(game.traitorReserve ?? []), ...game.players.flatMap((player) => player.traitors), ...removed].sort(),
traitorDeck(game.players).sort(), 'Every physical Traitor Card must stay in one known zone.');
withoutEcazLoyalty(traitorDeck(game.players), game.ecazLoyalty).sort(),
'Every circulating or assassinated Traitor stays physical; Loyalty remains outside the deck.');
}
/** Conserved physical card custody; no purchase, adoption or minted identity. */
function conserveCard(game: Game, actor: string, match: (card: Card) => boolean, staging: string[]): Card {
@@ -244,25 +249,27 @@ function conserveCard(game: Game, actor: string, match: (card: Card) => boolean,
staging.push(`Conserved original ${card.id}: ${source.label} → ${actor} hand; not a purchase or native deal`);
return card;
}
function board(opponent: MoritaniIndependentSkillsOpponent) {
function board(opponent: MoritaniIndependentSkillsOpponent | 'ecaz') {
if (opponent === 'ecaz')
return { battle: 'false_wall_east', from: 'imperial_basin:9', amount: 6, sectors: [8, 9] };
return opponent === 'guild'
? { battle: 'pasty_mesa', from: 'tueks_sietch:5', amount: 5, sectors: [5, 6] }
: { battle: 'imperial_basin', from: 'carthag:11', amount: 10, sectors: [11, 10] };
}
/** Genuine normal lobby start (no audit profile seam) for a standalone
* Moritani/classic independent three-card app with optional all-fourteen skills. */
/** Genuine normal lobby start (no audit profile seam) for a standalone or native
* paired E3/classic composition with optional all-fourteen skills and cards. */
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.opponent ?? 'guild';
assert.ok(!skills || opponentFaction === 'guild',
const opponentFaction = options.paired ? 'ecaz' : (options.opponent ?? 'guild');
assert.ok(!skills || opponentFaction !== 'harkonnen',
'Leader Skills keep the Harkonnen native roster out of this composition.');
const plan = board(opponentFaction);
const target = opponentFaction === 'guild' ? 'guild-1' : 'harkonnen-1';
const winner = opponentFaction === 'guild' ? 'guild-0' : 'harkonnen-0';
const target = `${opponentFaction}-1`;
const winner = `${opponentFaction}-0`;
const loser = 'moritani-4';
const heldIdentity = (options.traitor ?? 'target') === 'winner' ? winner : target;
const chosen: Record<string, LeaderSkillId> = {
@@ -314,6 +321,11 @@ export function createMoritaniIndependentSkillsFixture(
act(actor, { type: 'leaderSkill', event: offer.event, skill: chosen[actor], leader: leader.id });
continue;
}
if (game.setupStage === 'forces' && opponentFaction === 'ecaz' &&
viewGame(game, 'ecaz').setupPending.includes('ecaz')) {
act('ecaz', { 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'
@@ -339,6 +351,15 @@ export function createMoritaniIndependentSkillsFixture(
assert.equal(!!game.moritaniAssassinate, advanced,
'The printed Advanced assassination producer follows the real rules mode alone.');
if (skills) assert.equal(game.leaderSkills!.assignments.find((entry) => entry.owner === opponent)!.leader, target);
if (opponentFaction === 'ecaz') {
const ecaz = moritaniIndependentSkillsPlayer(game, opponent);
assert.equal(ecaz.reserves, 14);
assert.deepEqual(ecaz.forces, { [plan.from]: 6 });
assert.notEqual(game.ecazLoyalty?.card, target,
'The trained unused native target must have a circulating physical Traitor.');
assert.notEqual(game.ecazLoyalty?.card, heldIdentity,
'Never exchange the face-up Loyalty card into a hand.');
}
holdMoritaniStrongholdsTraitor(game, moritani, heldIdentity, staging);
const afterSetup = structuredClone(game);
game = advanceMoritaniIndependentSkills(game,
@@ -387,7 +408,9 @@ export function createMoritaniIndependentSkillsFixture(
const plans: MoritaniIndependentSkillsStep[] = [
{ actor: moritani, action: { type: 'battlePlan', dial: 2, support: 0, leader: loser,
...(reinforcements && harass ? { weapon: REINFORCEMENTS_CARD, defense: HARASS_WITHDRAW_CARD } : {}) } },
{ actor: opponent, action: { type: 'battlePlan', dial: cards ? 1 : 0, support: 0,
{ actor: opponent, action: { type: 'battlePlan',
dial: options.paired ? (cards ? 4 : 2) : (cards ? 1 : 0),
support: options.paired && advanced ? (cards ? 4 : 2) : 0,
leader: winner, weapon: winnerWeapon.id } },
];
const window = structuredClone(game);
@@ -341,3 +341,116 @@ void test('a real normal Traitor call forfeits the printed power for the game an
assertMoritaniIndependentSkillsCustody(second);
});
void test('Advanced paired Skills/cards loss returns selected sectors, then assassinates one unused native Ecaz disc before Mentat replacement', () => {
const f = createMoritaniIndependentSkillsFixture({ paired: true, rules: 'advanced', skills: true, cards: 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);
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 without independent cards still pays the printed Ecaz assassination bounty', () => {
const f = createMoritaniIndependentSkillsFixture({ paired: true, rules: 'advanced', skills: 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 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);
});
+1 -24
View File
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, createGame, initializeLeaderSkillsGameForAudit, joinGame, newPlayer, normalizeAutomaticGame, viewGame, type Game } from '../game/engine';
import { applyAction, normalizeAutomaticGame, viewGame, type Game } from '../game/engine';
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
import { planetologistMoveDraft } from '../components/planetologist-movement';
import { TERRITORIES, distance, location, splitLocation } from '../game/board';
@@ -33,29 +33,6 @@ void test('every canonical skill can be dealt and publicly assigned to Basic Mor
}
});
void test('Moritani skill starts retain immutable Ecaz and optional-module rejection', () => {
// Native Discovery Leader Skills are now admitted for this fresh lobby.
const discovery = createGame('MORIGATE', newPlayer('m', 'Moritani', 'moritani'), false, ['ecaz']);
joinGame(discovery, newPlayer('a', 'Atreides', 'atreides'));
discovery.players.forEach(p => { p.ready = true; });
discovery.discoveryEnabled = true;
assert.ok(initializeLeaderSkillsGameForAudit(discovery).leaderSkills);
for (const change of [
(g: Game) => { g.players[1] = newPlayer('a', 'Ecaz', 'ecaz'); },
(g: Game) => { g.expansions.push('choam'); },
(g: Game) => { g.discoveryEnabled = true; g.expansions.push('choam'); },
(g: Game) => { g.discoveryEnabled = true; g.players[1] = newPlayer('a', 'Ecaz', 'ecaz'); },
]) {
const lobby = createGame('MORIGATE', newPlayer('m', 'Moritani', 'moritani'), false, ['ecaz']);
joinGame(lobby, newPlayer('a', 'Atreides', 'atreides'));
lobby.players.forEach(p => { p.ready = true; });
change(lobby);
const before = JSON.stringify(lobby);
assert.throws(() => initializeLeaderSkillsGameForAudit(lobby));
assert.equal(JSON.stringify(lobby), before);
}
});
void test('all four profiles legally choose from the same private Moritani setup offer', () => {
const game = initializedMoritaniSkillsOffers({ requestedSkill: 'planetologist' });
for (const difficulty of DIFFICULTIES) {
+101
View File
@@ -0,0 +1,101 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { createEcazIndependentSkillsFixture } from './fixture-ecaz-independent-skills';
import { homeworldEcazClean, nextHomeworldEcazStep } from './fixture-homeworld-ecaz-occupy';
/** Branch from the genuine original setup snapshot, not its later battle.
* Only unopened land-card order is controlled. Native token declarations,
* both response windows, phase readiness and the paid shipment are actual actions. */
function pairedArrival(advanced: boolean, skills: boolean, independent: boolean) {
const fixture = createEcazIndependentSkillsFixture({ paired: true, advanced, skills,
independent, cards: independent ? ['reinforcements', 'harass'] : [] });
let game = structuredClone(fixture.afterSetup);
const act = (actor: string, action: Action) => { game = applyAction(game, actor, action); };
const step = () => {
if (homeworldEcazClean(game) && game.phase === 5)
act(game.active!, { type: 'endMovement' });
else {
const next = nextHomeworldEcazStep(game);
assert.ok(next, `Native paired continuation at ${game.turn}/${game.phase}/${game.decision?.kind}`);
act(next.actor, next.action);
}
};
const reach = (until: (state: Game) => boolean) => {
for (let n = 0; n < 450 && !until(game); n++) step();
assert.ok(until(game));
};
for (let index = 0; index < (advanced ? 4 : 2); index++) {
const at = game.spiceDeck.findIndex((card, position) => position >= index &&
'territory' in card && !card.discovery);
assert.ok(at >= index);
game.spiceDeck.splice(index, 0, game.spiceDeck.splice(at, 1)[0]);
}
reach(state => state.turn === 1 && !state.response && state.decision?.kind === 'ecazPlacement');
const ambassador = game.ecazAmbassadors!.tokens.find(token => token.effect === 'ecaz' && token.zone === 'supply')!.id;
act(fixture.ecaz, { type: 'decision', token: ambassador, territory: 'arrakeen' });
reach(state => !state.response && state.ecazAmbassadors!.tokens.find(token => token.id === ambassador)!.zone === 'placed');
reach(state => state.turn === 1 && !state.response && state.decision?.kind === 'moritaniPlacement');
const terror = game.moritaniTerror!.tokens.find(token => token.kind === 'robbery' && token.status === 'available')!.id;
act(fixture.ally, { type: 'decision', token: terror, territory: 'arrakeen' });
reach(state => state.turn === 2 && state.phase === 5 && state.active === fixture.opponent && homeworldEcazClean(state));
const before = structuredClone(game);
const shipment: Action = { type: 'ship', territory: 'arrakeen', sector: 10, amount: 1, elite: 0, allyPayment: 0 };
act(fixture.opponent, shipment);
reach(state => !!state.pendingArrivalOverlap);
return { fixture, game, before, shipment, ambassador, terror };
}
for (const { advanced, skills, independent } of [
{ advanced: false, skills: true, independent: false },
{ advanced: true, skills: true, independent: true },
{ advanced: false, skills: false, independent: true },
])
void test(`${advanced ? 'Advanced' : 'Basic'} paired ${skills ? 'Skills' : 'native factions'} ${independent ? 'with independent cards' : 'without extra cards'} settles both earned reactions without re-paying shipment`, () => {
const f = pairedArrival(advanced, skills, independent), { ecaz, ally: moritani, opponent: guild } = f.fixture;
let game = f.game;
const player = (state: Game, id: string) => state.players.find(seat => seat.id === id)!;
const before = player(f.before, guild), moritaniBefore = player(f.before, moritani).spice;
const first = game.pendingArrivalOverlap!.first;
const seen: string[] = [];
for (let n = 0; n < 40 && (game.pendingArrivalOverlap || game.pendingAmbassador ||
game.pendingTerrorEntry || !homeworldEcazClean(game)); n++) {
let actor: string, action: Action;
if (game.response) {
const next = nextHomeworldEcazStep(game)!;
actor = next.actor; action = next.action;
} else if (game.decision?.kind === 'ecazAmbassador') {
seen.push('ambassador'); actor = ecaz;
action = { type: 'decision', event: game.pendingAmbassador!.event,
trigger: true, choice: 'duke', beneficiary: ecaz };
} else if (game.decision?.kind === 'moritaniTerror') {
const entry = viewGame(game, moritani).terrorEntry!;
assert.ok('stage' in entry);
seen.push(`terror:${entry.stage}`); actor = moritani;
action = entry.stage === 'offer' ? { type: 'decision', reveal: true }
: { type: 'decision', choice: 'spice' };
} else throw Error(`Unexpected arrival continuation ${game.decision?.kind}`);
game = applyAction(game, actor, action);
}
assert.deepEqual(seen, first === 'ambassador'
? ['ambassador', 'terror:offer', 'terror:robbery']
: ['terror:offer', 'terror:robbery', 'ambassador']);
const robbery = Math.ceil((before.spice - 1) / 2);
assert.equal(player(game, guild).spice, before.spice - 1 - robbery);
assert.equal(player(game, moritani).spice, moritaniBefore + robbery);
assert.equal(player(game, guild).reserves, before.reserves - 1);
assert.equal(player(game, guild).forces['arrakeen:10'], 1);
assert.equal(game.dukeVidal!.controller, ecaz);
assert.equal(game.dukeVidal!.leader.strength, 6);
assert.ok(!game.leaderSkills?.assignments.some(assignment => assignment.leader === 'duke-vidal'));
assert.equal(game.moritaniTerror!.tokens.find(token => token.id === f.terror)!.status, 'removed');
assert.equal(game.ecazAmbassadors!.tokens.find(token => token.id === f.ambassador)!.zone, 'supply');
assert.equal(viewGame(game, guild).arrivalOverlap, null);
assert.equal(game.active, guild);
const settled = structuredClone(game);
assert.throws(() => applyAction(game, guild, f.shipment));
assert.deepEqual(game, settled);
game = applyAction(game, guild, { type: 'endMovement' });
assert.equal(player(game, guild).reserves, before.reserves - 1);
assert.equal(player(game, guild).forces['arrakeen:10'], 1);
});