Compose selected E3 independent cards with Nexus rules

Selected Ecaz/Moritani decks with independent cards and Nexus now retain native Occupy, Advanced assassination, arrival ordering and shared Duke consumers, including original optional modules. Keep the trained E3/Harkonnen capture boundary and remaining individual Nexus effect limits explicit.

Validation: frozen 17-file 168/168, types/lint/build, seven finite original-action and table-SSR programmes, plus four minimal legal Cunning/expiry policies. Fixture corrections preserve the printed alliance draw gate and real paid placement affordability. No stored-game or production change; comprehensive and deployed acceptance remain open.
This commit is contained in:
Quintine committed 2026-10-18 10:59:57 +11:00
1 parent 8422cf1f2d
commit 8793e23d6b
21 files changed
+1374 -24

No files matched your search

@@ -0,0 +1,33 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame } from '../game/engine';
import { applyClassicDiscoveryNexusStep } from './fixture-discovery-classic-nexus';
import { createE3IndependentArrival, advanceE3IndependentArrival } from './fixture-e3-independent-nexus-arrival';
for (const modules of [false,true]) void test(`original selected E3 independent Nexus ${modules ? 'all14/World/phase' : 'Basic'} entry preserves both native arrival offers in Storm order`, () => {
const fixture = createE3IndependentArrival({advanced:modules, expansions:modules ? ['ecaz','ix','choam'] : ['ecaz'],
skills:modules, homeworlds:modules, tech:modules, strongholds:modules});
let game = fixture.game;
const original = structuredClone(game.pendingArrivalOverlap!);
assert.equal(original.entrant, 'guild');
assert.equal(original.amount, 2);
assert.equal(original.stage, 'first');
assert.equal(original.first, game.order.indexOf('ecaz') < game.order.indexOf('moritani') ? 'ambassador' : 'terror');
assert.equal(game.players.find(player=>player.id==='guild')!.forces['arrakeen:10'], 2);
assert.equal(game.ecazAmbassadors!.tokens.find(token=>token.id===fixture.ambassador)!.location, 'arrakeen');
assert.equal(game.moritaniTerror!.tokens.find(token=>token.id===fixture.terror)!.location, 'arrakeen');
for (const stage of ['first','second'] as const) {
assert.equal(game.pendingArrivalOverlap!.stage, stage);
const decision = game.decision!;
assert.ok(decision.kind === 'ecazAmbassador' || decision.kind === 'moritaniTerror');
assert.ok(viewGame(game, decision.player).arrivalOverlap);
game = applyClassicDiscoveryNexusStep(game, {actor:decision.player, action:{type:'decision',
...(decision.kind === 'ecazAmbassador' ? {event:game.pendingAmbassador!.event} : {}), decline:true}}, fixture.actions);
}
assert.ok(!game.pendingArrivalOverlap && !game.pendingAmbassador && !game.pendingTerrorEntry);
game = advanceE3IndependentArrival(game, state=>!state.phaseOpening && !state.response && !state.decision && !state.pendingGuildBetrayal);
assert.equal(game.phase, 5);
assert.equal(game.players.find(player=>player.id==='guild')!.shipped, true);
assert.equal(game.players.find(player=>player.id==='guild')!.forces['arrakeen:10'], 2);
assert.equal(game.nexusCards!.cards!.deck.length, 12, 'Without a real alliance there was no invented Nexus draw');
});
+146
View File
@@ -0,0 +1,146 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Game } from '../game/engine';
import { treacheryDeck } from '../game/cards';
import { ecazTreacheryCards } from '../game/ecaz-cards';
import { NEXUS_FACTIONS } from '../game/nexus-cards';
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
import { REINFORCEMENTS_CARD } from '../game/reinforcements';
import { RECRUITS_CARD_ID, recruitsPlayAction } from '../game/recruits';
import {
actE3IndependentNexusCard, advanceE3IndependentNexusCards, chooseE3IndependentAssassinate,
createE3IndependentNexusCardBattle, e3IndependentNexusCardPlayer as player,
e3IndependentNexusCardsClean as clean, revealE3IndependentNexusCardPlans,
type E3IndependentNexusCardOptions,
} from './fixture-e3-independent-nexus-cards';
/** Printed selected stocks only; final save/seat assurance is a later gate. */
function originalStocks(game: Game): void {
assert.deepEqual([...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand),
...(game.auction?.cards.slice(game.auction.index + Number(!!game.currentAuctionSale)) ?? [])].map(card => card.id).sort(),
[...treacheryDeck(game.expansions), ...ecazTreacheryCards()].map(card => card.id).sort());
const cards = game.nexusCards!.cards!;
assert.deepEqual([...cards.deck, ...cards.discard, ...Object.values(cards.hands).filter(card => card !== null)].sort(), [...NEXUS_FACTIONS].sort());
if (game.leaderSkills) assert.deepEqual([...game.leaderSkills.deck,
...Object.values(game.leaderSkills.offers).flatMap(offer => offer.cards),
...game.leaderSkills.assignments.map(assignment => assignment.skill)].sort(), LEADER_SKILL_CARDS.map(card => card.id).sort());
}
function originalLoss(game: Game): void {
assert.equal(game.lastBattleContext!.territory, 'pasty_mesa');
assert.equal(game.lastBattleContext!.winner, 'guild');
assert.equal(game.lastBattleContext!.result, 'normal');
assert.equal(player(game, 'moritani').reserves, 11);
assert.equal(player(game, 'moritani').tanks, 9, 'The real reserve fee and the six original committed counters reached the Tanks');
assert.deepEqual(player(game, 'moritani').forces, {});
assert.equal(game.discard.filter(card => card.id === REINFORCEMENTS_CARD).length, 1);
assert.ok(player(game, 'moritani').hand.some(card => card.id === RECRUITS_CARD_ID));
assert.equal(game.nexusCards!.cards!.hands.moritani, 'tleilaxu');
}
function reviveOriginalE3Loss(game: Game): Game {
game = advanceE3IndependentNexusCards(game, state => state.turn === 3 && state.phase === 4 && clean(state));
const spice = player(game, 'moritani').spice;
assert.equal(player(game, 'moritani').tanks, 9);
const play = recruitsPlayAction(viewGame(game, 'moritani').recruitsPreview); assert.ok(play);
game = actE3IndependentNexusCard(game, 'moritani', play);
assert.equal(viewGame(game, 'moritani').revival.freeRemaining, 4);
assert.equal(viewGame(game, 'moritani').revival.limit, 7);
game = actE3IndependentNexusCard(game, 'moritani', { type: 'revive', amount: 4, elite: 0 });
game = advanceE3IndependentNexusCards(game, clean);
assert.equal(player(game, 'moritani').reserves, 15);
assert.equal(player(game, 'moritani').tanks, 5);
assert.equal(player(game, 'moritani').revived, 4);
assert.equal(player(game, 'moritani').freeForcesRevived, 4);
assert.equal(player(game, 'moritani').spice, spice);
assert.equal(game.discard.filter(card => card.id === RECRUITS_CARD_ID).length, 1);
assert.equal(game.nexusCards!.cards!.hands.moritani, 'tleilaxu', 'Ordinary Recruits returns do not spend a held Secret Ally');
if (game.homeworlds) {
const world = viewGame(game, 'moritani').homeworlds!.worlds!.find(world => world.id === 'homeworld:moritani'); assert.ok(world);
assert.deepEqual(world.forces.moritani, { normal: 15, elite: 0 });
}
originalStocks(game);
return advanceE3IndependentNexusCards(game, state => state.phase === 5 && clean(state));
}
const advancedCases: Array<E3IndependentNexusCardOptions & { label: string }> = [
{ label: 'original E3' },
{ label: 'all14/World/phase', skills: true, homeworlds: true, tech: true, strongholds: true },
{ label: 'selected E3+Ix+CHOAM all14/World/phase', expansions: ['ecaz', 'ix', 'choam'], skills: true, homeworlds: true, tech: true, strongholds: true },
];
for (const sample of advancedCases) void test(`Advanced ${sample.label} native Moritani loss spends genuine Reinforcements, assassinates its original unused Traitor and revives real casualties with Recruits`, () => {
const fixture = createE3IndependentNexusCardBattle({ ...sample, advanced: true });
assert.equal(fixture.afterSetup.ecazTreachery, true);
assert.equal(fixture.afterSetup.moritaniAssassinatePreview, true);
assert.equal(fixture.afterSetup.moritaniAssassinate!.owner, 'moritani');
assert.equal(fixture.afterSetup.moritaniAssassinate!.normalTraitorCall, false);
assert.deepEqual(fixture.afterSetup.moritaniAssassinate!.opportunities, []);
assert.deepEqual(player(fixture.afterSetup, 'moritani').traitors, [fixture.target]);
assert.deepEqual(player(fixture.afterSetup, 'moritani').forces, { 'pasty_mesa:5': 6 });
assert.ok(fixture.actions.some(step => step.actor === 'moritani' && step.action.type === 'traitor' && step.action.leader === fixture.target));
assert.equal(fixture.actions.filter(step => step.actor === 'moritani' && step.action.type === 'bid' && step.action.amount === 1).length, 2);
assert.ok(player(fixture.afterPurchases, 'moritani').hand.some(card => card.id === REINFORCEMENTS_CARD));
assert.ok(player(fixture.afterPurchases, 'moritani').hand.some(card => card.id === RECRUITS_CARD_ID));
assert.equal(fixture.draw.before.nexusCards!.cards!.hands.moritani, null);
assert.equal(fixture.draw.after.nexusCards!.cards!.hands.moritani, 'tleilaxu');
assert.equal(player(fixture.draw.after, 'guild').ally, 'emperor');
assert.equal(player(fixture.draw.after, 'emperor').ally, 'guild');
assert.equal(player(fixture.beforeMove, 'guild').forces['tueks_sietch:5'], 5);
assert.equal(fixture.move.action.type, 'move');
assert.equal(fixture.game.battle!.plans.moritani, undefined);
assert.equal(viewGame(fixture.game, 'moritani').battle!.reinforcements!.blocked, null);
originalStocks(fixture.game);
let game = revealE3IndependentNexusCardPlans(fixture.game, fixture.plans);
assert.equal(game.battle!.plans.moritani.weapon, REINFORCEMENTS_CARD);
assert.equal(game.battle!.plans.moritani.leader, 'moritani-4');
assert.equal(game.battle!.plans.guild.leader, 'guild-0');
game = advanceE3IndependentNexusCards(game, state => !state.phaseOpening && !state.response && state.decision?.kind === 'moritaniAssassinate');
originalLoss(game);
const choice = viewGame(game, 'moritani').moritaniAssassinate!.pending!;
assert.equal(choice.blocked, null);
assert.equal(choice.opponent, 'guild');
assert.ok(choice.cards.some(card => card.card === fixture.target));
assert.equal(choice.cards.some(card => card.card === 'guild-0'), false, 'The committed winning disc stays outside the unused-Traitor choice');
const beforeSpice = player(game, 'moritani').spice, target = player(game, 'guild').leaders.find(leader => leader.id === fixture.target)!;
const bounty = target.strength;
assert.equal(target.dead, false);
if (sample.skills) assert.ok(game.leaderSkills!.assignments.some(assignment => assignment.owner === 'guild' && assignment.leader === fixture.target));
assert.ok(game.decision?.kind === 'moritaniAssassinate');
const event = game.decision.event;
assert.throws(() => applyAction(game, 'guild', { type: 'decision', event, card: fixture.target }));
game = chooseE3IndependentAssassinate(game, fixture.target);
assert.equal(player(game, 'guild').leaders.find(leader => leader.id === fixture.target)!.dead, true);
assert.equal(player(game, 'guild').leaders.find(leader => leader.id === 'guild-0')!.dead, false);
assert.equal(player(game, 'moritani').spice, beforeSpice + bounty);
assert.equal(game.moritaniAssassinate!.opportunities.at(-1)!.card, fixture.target);
assert.equal(game.moritaniAssassinate!.opportunities.at(-1)!.opposingLeader, 'guild-0');
assert.equal(game.moritaniAssassinate!.opportunities.at(-1)!.stage, 'revealed');
if (sample.skills) {
assert.equal(game.leaderSkills!.assignments.some(assignment => assignment.leader === fixture.target), false);
assert.equal(game.leaderSkills!.deck.filter(skill => skill === 'killer-medic').length, 1);
}
game = reviveOriginalE3Loss(game);
const receipt = game.moritaniAssassinate!.opportunities.at(-1)!;
assert.equal(receipt.stage, 'replaced');
assert.ok(receipt.replacement && receipt.replacement !== fixture.target);
assert.equal(player(game, 'moritani').traitors.includes(fixture.target), false);
assert.ok(player(game, 'moritani').traitors.includes(receipt.replacement));
assert.equal(game.moritaniAssassinate!.normalTraitorCall, false);
assert.equal(game.turn, 3);
originalStocks(game);
});
void test('Basic all14/World Moritani with independent cards and genuine Nexus draw keeps Assassinate absent while Recruits returns its actual battle losses', () => {
const fixture = createE3IndependentNexusCardBattle({ advanced: false, skills: true, homeworlds: true, tech: true });
assert.ok(!fixture.afterSetup.moritaniAssassinatePreview && !fixture.afterSetup.moritaniAssassinate);
assert.equal(fixture.afterSetup.leaderSkills!.assignments.length, 3);
assert.equal(fixture.draw.after.nexusCards!.cards!.hands.moritani, 'tleilaxu');
let game = revealE3IndependentNexusCardPlans(fixture.game, fixture.plans);
game = advanceE3IndependentNexusCards(game, state => !state.battle && clean(state));
originalLoss(game);
assert.ok(!game.moritaniAssassinate);
assert.equal(player(game, 'guild').leaders.find(leader => leader.id === fixture.target)!.dead, false);
assert.deepEqual(player(game, 'moritani').traitors, [fixture.target]);
game = reviveOriginalE3Loss(game);
assert.equal(game.turn, 3);
assert.ok(!game.moritaniAssassinate);
});
+114
View File
@@ -0,0 +1,114 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { DUKE_VIDAL_ID } from '../game/duke-vidal';
import { quoteEcazDukeAcquisition } from '../game/ecaz-duke-acquisition';
import { nexusEcazDukeModeSupported } from '../game/nexus-ecaz-duke';
import { nexusEcazDukeAction } from '../game/nexus-ecaz-duke-options';
import { initialIxianBetrayalCursor, validateIxianBetrayalHistory } from '../game/nexus-ixian-betrayal';
import { createIndependentCardModulesFixture } from './fixture-independent-card-modules';
import {
createE3IndependentNexusDuke, createE3IndependentTwoSeatNexus, e3IndependentDukePlayer as player,
expireE3IndependentNexusDuke, useE3IndependentNexusDuke,
} from './fixture-e3-independent-nexus-duke';
for (const advanced of [false, true]) {
void test(`${advanced ? 'Advanced all14 World/phase' : 'Basic independent36'} Ecaz draws through a real Nexus, acquires the actual Duke and consumes him in one funded battle`, () => {
const f = createE3IndependentNexusDuke({ advanced, homeworlds: advanced, skills: advanced,
tech: advanced, strongholds: advanced });
assert.equal(f.afterSetup.ecazTreachery, true);
assert.equal(f.afterSetup.dukeVidal!.controller, null);
assert.equal(f.afterSetup.dukeVidal!.leader.id, DUKE_VIDAL_ID);
assert.equal(player(f.alliance, f.owner).ally, null);
assert.equal(player(f.alliance, f.others[0]).ally, f.others[1]);
assert.equal(player(f.alliance, f.others[1]).ally, f.others[0]);
assert.equal(f.beforeDraw.nexusCards!.cards!.hands[f.owner], null);
assert.equal(f.afterDraw.nexusCards!.cards!.hands[f.owner], 'ecaz');
assert.equal(f.game.nexusCards!.cards!.hands[f.owner], null);
assert.equal(f.game.nexusCards!.cards!.discard.filter(card => card === 'ecaz').length, 1);
assert.equal(f.game.dukeVidal!.controller, f.owner);
assert.equal(f.game.dukeVidal!.source, 'ecazNexus');
assert.equal(f.game.dukeVidal!.acquiredTurn, f.game.turn);
assert.deepEqual(f.game.players, f.beforeCunning.players,
'The shared-disc acquisition does not pay spice, move forces or add a sixth native disc.');
assert.ok(viewGame(f.game, f.owner).players.find(seat => seat.id === f.owner)!.leaders
.some(leader => leader.id === DUKE_VIDAL_ID));
for (const difficulty of DIFFICULTIES) {
const game = structuredClone(f.beforeCunning), own = viewGame(game, f.owner);
own.players.find(seat => seat.id === f.owner)!.bot = difficulty;
const action = botActions(own).find(candidate => candidate.type === 'nexusEcazDuke'); assert.ok(action, difficulty);
assert.equal(applyAction(game, f.owner, action).dukeVidal!.controller, f.owner);
}
const used = useE3IndependentNexusDuke(f);
assert.equal(player(used.beforePlans, f.owner).spice, player(used.beforeShipment, f.owner).spice - 1);
assert.equal(used.revealed.battle!.plans[f.owner].leader, DUKE_VIDAL_ID);
assert.equal(used.game.lastBattleContext!.winner, f.owner);
assert.equal(used.game.dukeVidal!.controller, null);
assert.equal(used.game.dukeVidal!.source, null);
assert.equal(used.game.dukeVidal!.leader.dead, false);
assert.equal(used.game.dukeVidal!.leader.usedAt, used.territory);
assert.equal(player(used.game, f.owner).tanks, player(used.beforePlans, f.owner).tanks + 1);
assert.equal(used.game.nexusEcazDukeHistory!.length, 1);
assert.equal(used.game.nexusCards!.cards!.discard.filter(card => card === 'ecaz').length, 1);
const unused = expireE3IndependentNexusDuke(f);
assert.equal(unused.game.turn, f.game.turn + 1);
assert.equal(unused.game.dukeVidal!.controller, null);
assert.equal(unused.game.dukeVidal!.source, null);
assert.equal(unused.game.dukeVidal!.leader.dead, false);
assert.equal(unused.game.dukeVidal!.leader.usedAt, undefined);
assert.equal(unused.game.nexusEcazDukeHistory!.length, 1);
assert.equal(unused.game.nexusCards!.cards!.discard.filter(card => card === 'ecaz').length, 1);
});
void test(`${advanced ? 'Advanced' : 'Basic'} two-seat original Nexus setup closes a real worm without inventing an alliance or closing draw`, () => {
const f = createE3IndependentTwoSeatNexus(advanced);
assert.equal(f.game.players.length, 2);
assert.equal(f.opened.nexus, true);
assert.ok(f.opened.players.every(seat => seat.ally === null));
assert.ok(f.game.players.every(seat => seat.ally === null));
assert.equal(f.game.nexus, false);
assert.equal(f.game.phase, 2);
assert.equal(f.game.nexusCards!.cards!.deck.length, 12);
assert.ok(Object.values(f.game.nexusCards!.cards!.hands).every(card => card === null));
assert.deepEqual(f.game.nexusCards!.cards, f.afterSetup.nexusCards!.cards);
assert.equal(f.actions.some(step => step.action.type === 'nexusCardChoice'), false);
assert.equal(nexusEcazDukeModeSupported(f.game), true, 'The selected module still accepts a normal two-seat table.');
assert.equal(nexusEcazDukeAction(viewGame(f.game, 'ecaz')), null);
assert.equal(f.game.dukeVidal!.controller, null);
});
}
void test('Advanced independent Ecaz/Harkonnen keeps the shared Duke acquisition table permission without Skills', () => {
const f = createE3IndependentNexusDuke({ advanced: true, factions: ['ecaz', 'guild', 'harkonnen'],
tech: true, homeworlds: true, strongholds: true });
const before = structuredClone(f.beforeCunning);
const acquisition = quoteEcazDukeAcquisition(f.beforeCunning, f.owner);
assert.equal(acquisition.controller, f.owner);
assert.deepEqual(f.beforeCunning, before, 'An ordinary acquisition quote does not itself commit an Ambassador or a second Duke.');
assert.equal(f.game.dukeVidal!.controller, f.owner);
assert.equal(f.game.nexusEcazDukeHistory!.length, 1);
});
for (const richese of [false, true]) {
void test(`original E3/Ix${richese ? '/CHOAM with Richese cache' : ''} stock feeds the native Ixian cancellation catalogue without changing legacy defaults`, () => {
const expansions = richese ? ['ecaz', 'ix', 'choam'] : ['ecaz', 'ix'];
const f = createIndependentCardModulesFixture({ advanced: false, expansions,
factions: richese ? ['ecaz', 'moritani', 'ixians', 'richese'] : ['ecaz', 'moritani', 'ixians', 'guild'],
independent: true, nexus: true, homeworlds: true, tech: true });
const game = f.game;
const physical = [...game.deck, ...game.discard, ...game.players.flatMap(seat => seat.hand),
...(game.ixSetupCards ?? []), ...(game.richeseCache ?? []), ...(game.richeseRemoved ?? [])];
assert.equal(physical.length, richese ? 60 : 50);
const cursor = initialIxianBetrayalCursor();
validateIxianBetrayalHistory([], cursor, physical, { expansions, ecazTreachery: true, richese });
assert.throws(() => validateIxianBetrayalHistory([], cursor, physical),
'An original Ix47 caller cannot silently gain the independent cards or an unselected cache.');
assert.throws(() => validateIxianBetrayalHistory([], cursor, physical,
{ expansions, ecazTreachery: false, richese }), 'Selected E3 admission here requires the explicit independent variant.');
});
}
+109
View File
@@ -0,0 +1,109 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { applyAction, viewGame } from '../game/engine';
import { botActions } from '../game/bots';
import { REINFORCEMENTS_CARD } from '../game/reinforcements';
import { HARASS_WITHDRAW_CARD } from '../game/harass-withdraw';
import {
createE3IndependentNexusOccupyFixture, e3IndependentNexusOccupyClean,
e3IndependentNexusOccupyPlayer as player, quoteE3IndependentNexusOccupy,
revealE3IndependentNexusOccupy, settleE3IndependentNexusOccupy,
} from './fixture-e3-independent-nexus-occupy';
for (const modules of [false, true]) for (const lead of ['ecaz', 'ally'] as const) {
void test(`original ${modules ? 'all-family World/all14/phase' : 'Ecaz'} independent Nexus ${lead} Occupy lead pays only its own fee and typed undialed return`, () => {
const f = createE3IndependentNexusOccupyFixture({ lead,
expansions: modules ? ['ix', 'choam', 'ecaz'] : ['ecaz'],
homeworlds: modules, skills: modules, tech: modules, strongholds: modules });
const beforeEcaz = player(f.game, 'ecaz'), beforeAlly = player(f.game, 'emperor'), beforeEnemy = player(f.game, 'guild');
const originalEcaz = Object.fromEntries(Object.entries(player(f.afterSetup, 'ecaz').forces)
.filter(([key]) => key.startsWith('imperial_basin:')));
assert.equal(Object.values(originalEcaz).reduce((sum, count) => sum + count, 0), 6);
assert.equal(f.shipments.length, 3);
assert.deepEqual(f.shipments.map(shipment => [shipment.step.actor, shipment.step.action.amount, shipment.step.action.elite])
.sort(([one], [two]) => String(one).localeCompare(String(two))),
[['ecaz', 5, 0], ['emperor', 5, 2], ['guild', 3, 0]]);
assert.equal(f.purchases.length, 2);
assert.ok(f.purchases.every(purchase => purchase.step.actor === f.cardUser && purchase.step.action.amount === 1));
assert.equal(f.beforeAlliance.turn, 2);
assert.equal(f.afterNexusDraw.nexusCards!.cards!.hands.guild, 'guild');
assert.equal(f.drawStep.action.type, 'nexusCardChoice');
assert.equal(player(f.afterNexusDraw, 'ecaz').ally, 'emperor');
assert.equal(player(f.afterNexusDraw, 'emperor').ally, 'ecaz');
assert.equal(player(f.afterNexusDraw, 'guild').ally, null);
assert.equal(f.beforeLead.decision?.kind, 'ecazBattleLead');
assert.equal(f.leadStep.action.lead, f.cardUser);
assert.deepEqual([f.profile.planOwner, f.profile.payer, f.profile.forceOwner], [f.cardUser, f.cardUser, 'emperor']);
assert.deepEqual([f.profile.ecazForces.normal, f.profile.ecazForces.elite], [5, 0]);
assert.deepEqual([f.profile.forces.normal, f.profile.forces.elite], [3, 2]);
assert.equal(f.profile.fixedEcazDial, 3);
assert.equal(f.plans[0].action.dial, 5);
assert.equal(f.support, modules ? 0 : 1, 'Actual high Salusa supports the real stars; ordinary Advanced uses one paid star');
assert.ok(player(f.game, f.cardUser).hand.some(card => card.id === REINFORCEMENTS_CARD));
assert.ok(player(f.game, f.cardUser).hand.some(card => card.id === HARASS_WITHDRAW_CARD));
if (modules) {
assert.ok(f.plans[0].action.reinforcementsSources);
assert.equal(f.afterFirstMentat.strongholdCards!.owners.arrakeen, 'ecaz');
}
const revealed = revealE3IndependentNexusOccupy(f.game, f.plans, f.actions);
const quote = quoteE3IndependentNexusOccupy(revealed, f.cardUser, f.returns);
assert.equal(quote.result, 'normal');
assert.equal(quote.winner, f.cardUser);
assert.deepEqual(quote.reinforcements, [{ player: f.cardUser, card: REINFORCEMENTS_CARD, normal: 3, elite: 0 }]);
assert.deepEqual(quote.fixedLosses, [{ owner: 'ecaz', normal: 3, elite: 0 }]);
assert.equal(quote.casualties!.owner, 'emperor');
assert.equal(quote.casualties!.dial, 2, 'The original fixed contribution and score bonus do not enter the variable physical dial');
assert.ok(quote.casualties!.options.some(loss => loss.normal === 0 && loss.elite === 1));
const withdrawal = quote.harassWithdraw!.find(entry => entry.player === f.cardUser)!;
assert.deepEqual(withdrawal.returned, lead === 'ecaz' ? { normal: 2, elite: 0 } : { normal: 3, elite: 1 });
assert.deepEqual(withdrawal.locations, f.returns);
assert.deepEqual([withdrawal.remaining.normal, withdrawal.remaining.elite], lead === 'ecaz' ? [3, 0] : [0, 1]);
const plan = revealed.battle!.plans[f.cardUser];
const leader = player(revealed, f.cardUser).leaders.find(leader => leader.id === plan.leader)!;
const side = revealed.battle!.attacker === f.cardUser ? 'attacker' : 'defender';
assert.equal(quote.scores![side], leader.strength + 5 + 2, 'Reinforcements contributes exactly two normal-score points');
const game = settleE3IndependentNexusOccupy(revealed, f.returns, f.actions);
assert.equal(game.phase, 7);
assert.equal(game.battle, null);
assert.ok(e3IndependentNexusOccupyClean(game));
assert.equal(game.lastBattleContext!.winner, f.cardUser);
assert.equal(game.lastBattleContext!.result, 'normal');
const ecaz = player(game, 'ecaz'), ally = player(game, 'emperor'), enemy = player(game, 'guild');
assert.deepEqual(Object.fromEntries(Object.entries(ecaz.forces).filter(([key]) => key.startsWith('imperial_basin:'))), originalEcaz);
assert.equal(ecaz.forces[f.location] ?? 0, lead === 'ecaz' ? 0 : 2);
assert.equal(ecaz.reserves, beforeEcaz.reserves + (lead === 'ecaz' ? -1 : 0));
assert.equal(ecaz.tanks, beforeEcaz.tanks + (lead === 'ecaz' ? 6 : 3));
assert.equal(ally.forces[f.location] ?? 0, lead === 'ecaz' ? 4 : 0);
assert.equal(ally.elites!.forces[f.location] ?? 0, lead === 'ecaz' ? 1 : 0);
assert.equal(ally.reserves, beforeAlly.reserves + (lead === 'ecaz' ? 0 : 1));
assert.equal(ally.elites!.reserves, beforeAlly.elites!.reserves + (lead === 'ecaz' ? 0 : 1));
assert.equal(ally.tanks, beforeAlly.tanks + (lead === 'ecaz' ? 1 : 4));
assert.equal(ally.elites!.tanks, beforeAlly.elites!.tanks + 1);
assert.equal(enemy.forces[f.location] ?? 0, 0);
assert.equal(enemy.tanks, beforeEnemy.tanks + 3);
assert.equal(enemy.reserves, beforeEnemy.reserves);
assert.equal(game.nexusCards!.cards!.hands.guild, 'guild', 'The original unspent closing-draw card remains held');
for (const card of [REINFORCEMENTS_CARD, HARASS_WITHDRAW_CARD]) {
assert.equal(game.discard.filter(physical => physical.id === card).length, 1);
assert.ok(!player(game, f.cardUser).hand.some(physical => physical.id === card));
}
});
}
void test('four minimal legal policies use original independent cards through the selected Occupy owner with real World sources', () => {
for (const lead of ['ecaz', 'ally'] as const) {
const f = createE3IndependentNexusOccupyFixture({ lead, homeworlds: true, expansions: ['ix', 'choam', 'ecaz'] });
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
const view = viewGame(f.game, f.cardUser);
view.players.find(player => player.id === f.cardUser)!.bot = difficulty;
const action = botActions(view).find(action => action.type === 'battlePlan' &&
Number(action.dial) > f.profile.fixedEcazDial &&
(action.weapon === REINFORCEMENTS_CARD || action.defense === REINFORCEMENTS_CARD));
assert.ok(action, `${lead} ${difficulty} must expose its own card with a positive variable commitment`);
assert.ok(action.reinforcementsSources, 'The selected payer declares an actual own native reserve source');
assert.ok(applyAction(f.game, f.cardUser, action).battle!.plans[f.cardUser]);
}
}
});
@@ -0,0 +1,70 @@
import assert from 'node:assert/strict';
import { viewGame, type Action, type Game } from '../game/engine';
import { nativeShipmentSources } from '../game/homeworld-options';
import { createSelectedIndependentEntry, type SelectedIndependentEntryOptions } from './fixture-selected-independent-nexus-entry';
import { applyClassicDiscoveryNexusStep, nextClassicDiscoveryNexusStep } from './fixture-discovery-classic-nexus';
export type E3IndependentArrivalStep = {actor:string; action:Action};
export function nextE3IndependentArrivalStep(game: Game): E3IndependentArrivalStep | null {
if (game.pendingGuildBetrayal) {
const frame = game.pendingGuildBetrayal;
const actor = frame.required.find(id => !frame.passed.includes(id)); assert.ok(actor);
return {actor, action:{type:'guildBetrayalPass', event:frame.invoice.event}};
}
if (!game.response && !game.phaseOpening && game.decision &&
['ecazPlacement','moritaniPlacement'].includes(game.decision.kind))
return {actor:game.decision.player, action:{type:'decision', decline:true}};
const step = nextClassicDiscoveryNexusStep(game);
if (step?.action.type === 'stormDial') step.action.amount = game.turn === 1 ? 0 : 1;
return step;
}
export function advanceE3IndependentArrival(game: Game, until: (game:Game) => boolean,
actions: E3IndependentArrivalStep[] = []): Game {
for (let count = 0; count < 900; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished');
const step = nextE3IndependentArrivalStep(game); assert.ok(step, `Choose the original ${game.decision?.kind} window`);
game = applyClassicDiscoveryNexusStep(game, step, actions);
}
throw Error('Original E3 entry did not reach its requested continuation');
}
const clean = (game:Game) => !game.phaseOpening && !game.response && !game.decision && !game.pendingGuildBetrayal;
/** Native tokens are placed through the original paid/response opportunities.
* Only unopened land-card positions are chosen; no token, army or wallet is moved by a fixture. */
export function createE3IndependentArrival(options: Omit<SelectedIndependentEntryOptions, 'factions'>) {
const source = createSelectedIndependentEntry({...options, factions:['ecaz','moritani','guild']});
let game = source.game;
const actions = [...source.actions];
const act = (actor:string, action:Action) => { game = applyClassicDiscoveryNexusStep(game, {actor,action}, actions); };
const reach = (until:(game:Game)=>boolean) => { game = advanceE3IndependentArrival(game, until, actions); };
const land = () => {
const chosen: Game['spiceDeck'] = [];
for (let count = 0; count < (game.advanced ? 2 : 1); count++) {
const index = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery);
assert.ok(index >= 0); chosen.push(game.spiceDeck.splice(index,1)[0]);
}
game.spiceDeck.unshift(...chosen);
};
land();
reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'ecazPlacement');
const ambassador = game.ecazAmbassadors!.tokens.find(token => token.effect === 'ecaz' && token.zone === 'supply');
assert.ok(ambassador);
const beforePlacement = structuredClone(game);
act('ecaz', {type:'decision', token:ambassador.id, territory:'arrakeen'});
reach(state => state.ecazAmbassadors!.tokens.some(token => token.id === ambassador.id && token.location === 'arrakeen') &&
!state.response && !state.phaseOpening);
reach(state => state.phase === 8 && !state.response && !state.phaseOpening && state.decision?.kind === 'moritaniPlacement');
const terror = game.moritaniTerror!.tokens.find(token => token.kind === 'robbery' && token.location === null);
assert.ok(terror);
act('moritani', {type:'decision', token:terror.id, territory:'arrakeen'});
reach(state => state.turn === 2 && state.phase === 0);
land();
reach(state => state.phase === 5 && state.active === 'guild' && clean(state));
const homeworldSources = game.homeworlds ? nativeShipmentSources(viewGame(game,'guild'),2,0) : undefined;
assert.ok(!game.homeworlds || homeworldSources);
const beforeShipment = structuredClone(game);
act('guild', {type:'ship', territory:'arrakeen', sector:10, amount:2, elite:0,
...(homeworldSources ? {homeworldSources} : {})});
reach(state => !!state.pendingArrivalOverlap && !state.response && !state.phaseOpening);
return {source, beforePlacement, beforeShipment, game, actions, ambassador:ambassador.id, terror:terror.id};
}
+250
View File
@@ -0,0 +1,250 @@
import assert from 'node:assert/strict';
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { leaders, spiceDeck, treacheryDeck } from '../game/cards';
import { ecazTreacheryCards } from '../game/ecaz-cards';
import { NEXUS_FACTIONS } from '../game/nexus-cards';
import { CHEAP_HERO_TRAITOR } from '../game/traitors';
import { REINFORCEMENTS_CARD } from '../game/reinforcements';
import { RECRUITS_CARD_ID } from '../game/recruits';
import { skillOfferRolls, type IndependentCardModulesStep } from './fixture-independent-card-modules';
import { nextMoritaniNexusStep } from './fixture-e3-nexus-moritani';
export type E3IndependentNexusCardStep = IndependentCardModulesStep;
export type E3IndependentNexusCardOptions = {
advanced?: boolean;
skills?: boolean;
homeworlds?: boolean;
tech?: boolean;
strongholds?: boolean;
expansions?: Game['expansions'];
};
export const e3IndependentNexusCardPlayer = (game: Game, actor: string) => {
const player = game.players.find(player => player.id === actor); assert.ok(player); return player;
};
export const e3IndependentNexusCardsClean = (game: Game) => !game.response && !game.phaseOpening && !game.decision &&
!game.truthtrance && !game.pendingTreacheryDiscard && !game.pendingGuildBetrayal &&
!game.pendingNexusIxianBetrayal && !game.pendingNexusHarkonnenBetrayal;
function originalLottery<T>(rolls: readonly number[], operation: () => T): T {
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues'), native = crypto.getRandomValues.bind(crypto);
let cursor = 0;
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
if (array instanceof Uint32Array && array.length === 1) array[0] = rolls[cursor++] ?? 0xffffffff;
else Reflect.apply(native, crypto, [array]);
return array;
};
try { return operation(); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
const unchangedShuffle = (count: number) => Array<number>(count).fill(0xffffffff);
/** Order only the original undealt Traitor lottery. The card is genuinely
* offered and chosen; no played Traitor hand or reserve is exchanged. */
function originalGuildTraitorRolls(game: Game, target: string): number[] {
const source = [...game.players.flatMap(player => leaders(player.faction).map(leader => leader.id)),
...(game.expansions.includes('ix') ? [CHEAP_HERO_TRAITOR] : [])];
const desired = [...source], at = desired.indexOf(target); assert.ok(at >= 0);
[desired[0], desired[at]] = [desired[at], desired[0]];
const working = [...source], rolls: number[] = [];
for (let index = working.length - 1; index > 0; index--) {
const from = working.indexOf(desired[index]); assert.ok(from >= 0 && from <= index);
rolls.push(Math.floor((from + 0.5) * 0x100000000 / (index + 1)));
[working[index], working[from]] = [working[from], working[index]];
}
return rolls;
}
export function actE3IndependentNexusCard(game: Game, actor: string, action: Action,
actions?: E3IndependentNexusCardStep[]): Game {
actions?.push({ actor, action: structuredClone(action) });
return originalLottery([], () => applyAction(game, actor, action));
}
function nextE3IndependentNexusCard(game: Game): E3IndependentNexusCardStep | null {
if (game.pendingGuildBetrayal) {
const pending = game.pendingGuildBetrayal, actor = pending.required.find(actor => !pending.passed.includes(actor)); assert.ok(actor);
return { actor, action: { type: 'guildBetrayalPass', event: pending.invoice.event } };
}
if (game.pendingNexusIxianReplacement) return { actor: game.pendingNexusIxianReplacement.buyer,
action: { type: 'nexusIxianReplacementPass', event: game.pendingNexusIxianReplacement.event } };
if (!game.phaseOpening && !game.response && !game.pendingTreacheryDiscard) {
const decision = game.decision;
if (decision?.kind === 'moritaniAssassinate') return null;
if (decision?.kind === 'techToken') return { actor: decision.player,
action: { type: 'decision', token: decision.choices[0] } };
if (decision?.kind === 'moritaniPlacement') return { actor: decision.player,
action: { type: 'decision', decline: true } };
if (decision?.kind === 'leaderSkillVisibility') return { actor: decision.player,
action: { type: 'leaderSkillVisibility', event: decision.event, hide: false } };
if (decision?.kind === 'mentatQuestion') return { actor: decision.player,
action: { type: 'decision', event: decision.event, decline: true } };
if (decision?.kind === 'battleCards') return { actor: decision.player,
action: { type: 'decision', discard: [...decision.cards] } };
}
return nextMoritaniNexusStep(game);
}
export function advanceE3IndependentNexusCards(game: Game, until: (state: Game) => boolean,
actions: E3IndependentNexusCardStep[] = []): Game {
for (let count = 0; count < 2500; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The original Moritani programme ended before its card control');
const step = nextE3IndependentNexusCard(game);
assert.ok(step, `Choose the original human ${game.decision?.kind ?? 'Battle Plan'} at ${game.turn}/${game.phase}`);
game = actE3IndependentNexusCard(game, step.actor, step.action, actions);
}
throw Error('The original E3 independent/Nexus programme did not reach its requested boundary');
}
function orderOriginalBlow(game: Game, worm = false): void {
const front: Game['spiceDeck'] = [];
if (worm) {
const at = game.spiceDeck.findIndex(card => 'worm' in card && !card.greatMaker && !card.suppressed && !card.thumper);
assert.ok(at >= 0); front.push(game.spiceDeck.splice(at, 1)[0]);
}
for (let count = 0; count < (game.advanced ? 2 : 1); count++) {
const at = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery &&
!['pasty_mesa', 'tueks_sietch'].includes(card.territory));
assert.ok(at >= 0); front.push(game.spiceDeck.splice(at, 1)[0]);
}
game.spiceDeck.unshift(...front);
}
/** Ordinary fresh lobby selections and original skill/Nexus/card/Traitor
* shuffles. The real six-counter Pasty Mesa start remains unchanged. */
export function createE3IndependentNexusCardSetup(options: E3IndependentNexusCardOptions = {}) {
let game = createGame('E3INDEPENDENTNEXUSCARDS', newPlayer('moritani', 'Moritani', 'moritani'),
options.advanced ?? true, options.expansions ?? ['ecaz']);
joinGame(game, newPlayer('guild', 'Guild', 'guild'));
joinGame(game, newPlayer('emperor', 'Emperor', 'emperor'));
const actions: E3IndependentNexusCardStep[] = [], staging: string[] = [];
const act = (actor: string, action: Action) => { game = actE3IndependentNexusCard(game, actor, action, actions); };
for (const selection of ['ecazTreachery', 'nexusCards', ...(options.skills ? ['leaderSkills'] : [])])
act(game.host, { type: 'moduleOption', module: selection, enabled: true });
for (const [type, enabled] of [['homeworlds', options.homeworlds], ['techTokens', options.tech], ['strongholdCards', options.strongholds]] as const)
if (enabled) act(game.host, { type, enabled: true });
for (const player of game.players) act(player.id, { type: 'ready' });
const initial = structuredClone(game), target = 'guild-1';
const training = { moritani: 'warmaster', guild: 'killer-medic', emperor: 'master-of-assassins' } as const;
const rolls = options.skills ? skillOfferRolls(game, training) : [];
rolls.push(...unchangedShuffle(NEXUS_FACTIONS.length - 1));
rolls.push(...unchangedShuffle(treacheryDeck(game.expansions).length + ecazTreacheryCards().length - 1));
rolls.push(...unchangedShuffle(spiceDeck(game.expansions.includes('ix')).length - 1));
if (!options.skills) rolls.push(...originalGuildTraitorRolls(game, target));
const start: E3IndependentNexusCardStep = { actor: game.host,
action: game.advanced ? { type: 'start', advancedPreview: true } : { type: 'start' } };
actions.push(structuredClone(start));
game = originalLottery(rolls, () => applyAction(game, start.actor, start.action));
const offered = structuredClone(game);
for (let count = 0; game.status === 'setup' && count < 300; count++) {
if (game.setupStage === 'leaderSkills' && !game.decision) {
const actor = Object.keys(game.leaderSkills!.offers)[0], own = viewGame(game, actor).leaderSkills!;
const skill = training[e3IndependentNexusCardPlayer(game, actor).faction as keyof typeof training];
const leader = own.eligibleLeaders.find(leader => leader.id === (actor === 'guild' ? target : `${actor}-0`));
assert.ok(leader && own.offer!.cards.includes(skill) && !own.unavailableSkills?.[skill]);
const step: E3IndependentNexusCardStep = { actor,
action: { type: 'leaderSkill', event: own.offer!.event, skill, leader: leader.id } };
actions.push(structuredClone(step));
const finalOffer = Object.keys(game.leaderSkills!.offers).length === 1;
game = originalLottery(finalOffer ? [...unchangedShuffle(game.leaderSkills!.deck.length), ...originalGuildTraitorRolls(game, target)] : [],
() => applyAction(game, step.actor, step.action));
continue;
}
if (game.setupStage === 'traitors' || game.players.some(player => player.traitorChoices.length)) {
const player = game.players.find(player => player.traitorChoices.length); assert.ok(player);
const card = player.id === 'moritani' ? player.traitorChoices.find(card => card === target) : player.traitorChoices[0];
assert.ok(card, 'The original shuffled Traitor offer must contain the selected target');
act(player.id, { type: 'traitor', leader: card });
continue;
}
if (game.decision?.kind === 'moritaniSetup') {
act(game.decision.player, { type: 'decision', territory: 'pasty_mesa', sector: 5 });
continue;
}
const step = nextE3IndependentNexusCard(game); assert.ok(step); act(step.actor, step.action);
}
assert.equal(game.status, 'playing');
assert.deepEqual(e3IndependentNexusCardPlayer(game, 'moritani').traitors, [target]);
assert.deepEqual(e3IndependentNexusCardPlayer(game, 'moritani').forces, { 'pasty_mesa:5': 6 });
staging.push('Select original unused skill/Nexus/Treachery/Spice/Traitor lotteries before their real deals; Moritani actually chooses the offered unused Guild Traitor.');
return { initial, offered, afterSetup: structuredClone(game), game, actions, staging, target };
}
function buyOriginalE3Independent(game: Game, card: string, actions: E3IndependentNexusCardStep[]): Game {
for (let count = 0; count < 500; count++) {
if (e3IndependentNexusCardPlayer(game, 'moritani').hand.some(held => held.id === card)) return game;
let step: E3IndependentNexusCardStep | null;
if (e3IndependentNexusCardsClean(game) && game.auction && game.auction.cards[game.auction.index]?.id === card) {
const auction = game.auction;
step = { actor: auction.active, action: auction.active === 'moritani' && auction.bidder !== 'moritani'
? { type: 'bid', amount: 1, allyPayment: 0 } : { type: 'passBid' } };
} else step = nextE3IndependentNexusCard(game);
assert.ok(step); game = actE3IndependentNexusCard(game, step.actor, step.action, actions);
}
throw Error('The original E3 auction did not deliver its physical independent card');
}
/** Genuine bank purchases and first Mentat, then original worm/alliance/closing
* draw. The held Tleilaxu Nexus remains a separate unused physical card. */
export function createE3IndependentNexusCardProgramme(options: E3IndependentNexusCardOptions = {}) {
const source = createE3IndependentNexusCardSetup(options), actions = source.actions;
let game = structuredClone(source.game);
for (const card of [RECRUITS_CARD_ID, REINFORCEMENTS_CARD]) {
const at = game.deck.findIndex(face => face.id === card); assert.ok(at >= 0);
game.deck.unshift(game.deck.splice(at, 1)[0]);
}
orderOriginalBlow(game);
game = buyOriginalE3Independent(game, REINFORCEMENTS_CARD, actions);
game = buyOriginalE3Independent(game, RECRUITS_CARD_ID, actions);
const afterPurchases = structuredClone(game);
game = advanceE3IndependentNexusCards(game, state => state.turn === 2 && state.phase === 1 && e3IndependentNexusCardsClean(state), actions);
orderOriginalBlow(game, true);
game = advanceE3IndependentNexusCards(game, state => state.nexus && !state.spiceWindow && !state.spiceResolution && e3IndependentNexusCardsClean(state), actions);
const beforeAlliance = structuredClone(game);
for (const [actor, target] of [['guild', 'emperor'], ['emperor', 'guild']])
game = actE3IndependentNexusCard(game, actor, { type: 'alliance', target }, actions);
game = advanceE3IndependentNexusCards(game, state => state.nexusCards?.phase?.stage === 'drawing' && e3IndependentNexusCardsClean(state), actions);
const deck = game.nexusCards!.cards!.deck, at = deck.indexOf('tleilaxu'); assert.ok(at >= 0);
deck.unshift(deck.splice(at, 1)[0]);
const beforeDraw = structuredClone(game), drawStep: E3IndependentNexusCardStep = { actor: 'moritani',
action: { type: 'nexusCardChoice', turn: game.turn, card: null, choice: 'draw', ownRedraws: 0 } };
game = actE3IndependentNexusCard(game, drawStep.actor, drawStep.action, actions);
const afterDraw = structuredClone(game);
game = advanceE3IndependentNexusCards(game, state => state.phase === 5 && state.active === 'guild' && e3IndependentNexusCardsClean(state), actions);
source.staging.push('Buy original Reinforcements and Recruits for one spice each, then draw the original unopened Tleilaxu Nexus after an actual worm and reciprocal Guild/Emperor alliance.');
return { ...source, game, actions, afterPurchases, beforeAlliance, draw: { before: beforeDraw, step: drawStep, after: afterDraw } };
}
/** Actual Guild starting-army movement into Moritani's original six-counter
* position. No staged army, invented seal, held-card swap or copied receipt. */
export function createE3IndependentNexusCardBattle(options: E3IndependentNexusCardOptions = {}) {
const source = createE3IndependentNexusCardProgramme(options), actions = source.actions;
let game = source.game;
const beforeMove = structuredClone(game), move: E3IndependentNexusCardStep = { actor: 'guild',
action: { type: 'move', from: 'tueks_sietch:5', amount: 5, territory: 'pasty_mesa', sector: 6 } };
game = actE3IndependentNexusCard(game, move.actor, move.action, actions);
game = advanceE3IndependentNexusCards(game, state => state.phase === 6 && !!state.active && e3IndependentNexusCardsClean(state), actions);
const choice = viewGame(game, game.active!).battleChoices.find(choice => choice.territory === 'pasty_mesa'); assert.ok(choice);
const choose: E3IndependentNexusCardStep = { actor: choice.chooser, action: { type: 'chooseBattle', territory: choice.territory,
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker } };
game = actE3IndependentNexusCard(game, choose.actor, choose.action, actions);
game = advanceE3IndependentNexusCards(game, state => !!state.battle && !state.battle.preparation &&
state.battle.preLeader?.closed !== false && e3IndependentNexusCardsClean(state), actions);
const sources = game.homeworlds ? viewGame(game, 'moritani').battle!.reinforcements!.homeworldSources?.[0] : undefined;
assert.ok(!game.homeworlds || sources);
const plans: E3IndependentNexusCardStep[] = [
{ actor: 'moritani', action: { type: 'battlePlan', dial: 0, support: 0, leader: 'moritani-4',
weapon: REINFORCEMENTS_CARD, ...(sources ? { reinforcementsSources: sources } : {}) } },
{ actor: 'guild', action: { type: 'battlePlan', dial: 1, support: 0, leader: 'guild-0' } },
];
return { ...source, game, actions, beforeMove, move, choose, plans };
}
export function revealE3IndependentNexusCardPlans(game: Game, plans: readonly E3IndependentNexusCardStep[], actions: E3IndependentNexusCardStep[] = []): Game {
for (const plan of plans) {
game = actE3IndependentNexusCard(game, plan.actor, plan.action, actions);
game = advanceE3IndependentNexusCards(game, state => !!state.battle && !state.battle.preparation &&
state.battle.preLeader?.closed !== false && e3IndependentNexusCardsClean(state), actions);
}
assert.ok(game.battle?.revealed); return game;
}
export function chooseE3IndependentAssassinate(game: Game, target: string | null, actions?: E3IndependentNexusCardStep[]): Game {
const decision = game.decision; assert.ok(decision?.kind === 'moritaniAssassinate');
return actE3IndependentNexusCard(game, decision.player, { type: 'decision', event: decision.event,
...(target === null ? { decline: true } : { card: target }) }, actions);
}
+219
View File
@@ -0,0 +1,219 @@
import assert from 'node:assert/strict';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { territory } from '../game/board';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { DUKE_VIDAL_ID } from '../game/duke-vidal';
import { nativeShipmentSources } from '../game/homeworld-options';
import { nexusEcazDukeAction } from '../game/nexus-ecaz-duke-options';
import {
createIndependentCardModulesFixture, type IndependentCardModulesOptions,
type IndependentCardModulesStep,
} from './fixture-independent-card-modules';
import { nextClassicDiscoveryNexusStep, withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
export type E3IndependentDukeOptions = Pick<IndependentCardModulesOptions,
'advanced' | 'expansions' | 'factions' | 'homeworlds' | 'skills' | 'tech' | 'strongholds'>;
export type E3IndependentDukeStep = IndependentCardModulesStep;
export const e3IndependentDukePlayer = (game: Game, actor: string) => {
const player = game.players.find(player => player.id === actor); assert.ok(player); return player;
};
export const e3IndependentDukeClean = (game: Game) => !game.response && !game.decision &&
!game.phaseOpening && !game.pendingTreacheryDiscard && !game.truthtrance &&
!game.pendingGuildBetrayal && !game.pendingNexusIxianBetrayal && !game.pendingNexusHarkonnenBetrayal;
export function actE3IndependentDuke(game: Game, actor: string, action: Action,
actions?: E3IndependentDukeStep[]): Game {
actions?.push({ actor, action: structuredClone(action) });
return withClassicDiscoveryNexusLottery(() => applyAction(game, actor, action));
}
export function nextE3IndependentDukeStep(game: Game): E3IndependentDukeStep | null {
if (game.pendingGuildBetrayal) {
const frame = game.pendingGuildBetrayal, actor = frame.required.find(id => !frame.passed.includes(id)); assert.ok(actor);
return { actor, action: { type: 'guildBetrayalPass', event: frame.invoice.event } };
}
if (game.pendingNexusIxianBetrayal && !game.response && !game.phaseOpening) {
const frame = game.pendingNexusIxianBetrayal, actor = frame.required.find(id => !frame.passed.includes(id)); assert.ok(actor);
return { actor, action: { type: 'nexusIxianBetrayalPass', event: frame.source.event } };
}
if (game.pendingNexusHarkonnenBetrayal) {
const frame = game.pendingNexusHarkonnenBetrayal, actor = frame.required.find(id => !frame.passed.includes(id)); assert.ok(actor);
return { actor, action: { type: 'nexusHarkonnenBetrayalPass', event: frame.source.event } };
}
if (!game.response && !game.phaseOpening && !game.pendingTreacheryDiscard) {
const decision = game.decision;
if (decision?.kind === 'nexusIxianReplacement') return { actor: decision.player,
action: { type: 'nexusIxianReplacementPass', event: decision.event } };
if (decision?.kind === 'homeworldShipmentGuild') return { actor: decision.player,
action: { type: 'decision', event: decision.event, allow: true } };
if (decision?.kind === 'leaderSkillVisibility') return { actor: decision.player,
action: { type: 'leaderSkillVisibility', event: decision.event, hide: false } };
if (decision?.kind === 'mentatQuestion' || decision?.kind === 'moritaniSpecialKarama')
return { actor: decision.player, action: { type: 'decision', event: decision.event, decline: true } };
if (decision?.kind === 'moritaniPlacement' || decision?.kind === 'ecazPlacement') return { actor: decision.player,
action: { type: 'decision', decline: true } };
if (decision?.kind === 'techToken') return { actor: decision.player,
action: { type: 'decision', token: decision.choices[0] } };
if (decision?.kind === 'advisor' || decision?.kind === 'intrusion' || decision?.kind === 'advisorBattle')
return { actor: decision.player, action: { type: 'decision', accept: false } };
}
return nextClassicDiscoveryNexusStep(game);
}
export function advanceE3IndependentDuke(game: Game, until: (state: Game) => boolean,
actions: E3IndependentDukeStep[] = []): Game {
for (let count = 0; count < 2200; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'Original Duke programme ended before the requested native boundary');
const step = nextE3IndependentDukeStep(game);
assert.ok(step, `Supply the original human plan at ${game.turn}/${game.phase}/${game.decision?.kind}`);
game = actE3IndependentDuke(game, step.actor, step.action, actions);
}
throw Error('Independent E3 Duke programme did not reach its finite original boundary');
}
/** Only still-unopened original Spice Cards are ordered; every draw remains native. */
function orderSpice(game: Game, worm = false): void {
const kinds = [...(worm ? ['worm'] : []), ...Array<string>(game.advanced ? 2 : 1).fill('land')];
for (const [position, kind] of kinds.entries()) {
const index = game.spiceDeck.findIndex((card, at) => at >= position && (kind === 'worm'
? 'worm' in card && !card.greatMaker && !card.suppressed : 'territory' in card && !card.discovery));
assert.ok(index >= position); game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]);
}
}
/** Actual setup, first turn, ordinary worm and closing draw. The printed draw
* requires an alliance, so this acquisition programme needs at least three
* seats: the other two real players ally, leaving Ecaz unallied.
* Only unopened original lotteries are selected; dealt hands, wallets, force
* locations, controller fields and historical records are never assigned. */
export function createE3IndependentNexusDuke(options: E3IndependentDukeOptions = {}) {
const factions = options.factions ?? ['ecaz', 'guild', 'emperor'];
assert.ok(factions.length >= 3, 'A closing draw needs an actual alliance and an unallied recipient.');
const source = withClassicDiscoveryNexusLottery(() => createIndependentCardModulesFixture({
...options, factions, advanced: options.advanced ?? true, expansions: options.expansions ?? ['ecaz'],
independent: true, nexus: true, tech: options.tech ?? factions.length >= 3,
training: { ecaz: 'swordmaster-of-ginaz', guild: 'smuggler', emperor: 'sandmaster',
ixians: 'planetologist', richese: 'warmaster', moritani: 'suk-graduate', atreides: 'bureaucrat' },
}));
const owner = source.game.players.find(player => player.faction === 'ecaz')?.id; assert.ok(owner);
const others = source.game.players.filter(player => player.id !== owner).map(player => player.id);
const actions = [...source.steps], staging: string[] = [];
const afterSetup = structuredClone(source.game);
let game = structuredClone(afterSetup);
orderSpice(game);
staging.push('Order only unopened first-turn land Spice Cards and the original second-turn worm; no played resources or phase clocks change.');
game = advanceE3IndependentDuke(game, state => state.turn === 2 && state.phase === 1 && e3IndependentDukeClean(state), actions);
const afterFirstTurn = structuredClone(game);
orderSpice(game, true);
game = advanceE3IndependentDuke(game, state => state.nexus && !state.spiceWindow &&
!state.spiceResolution && e3IndependentDukeClean(state), actions);
game = actE3IndependentDuke(game, others[0], { type: 'alliance', target: others[1] }, actions);
game = actE3IndependentDuke(game, others[1], { type: 'alliance', target: others[0] }, actions);
const alliance = structuredClone(game);
game = advanceE3IndependentDuke(game, state => state.nexusCards?.phase?.stage === 'drawing' &&
e3IndependentDukeClean(state), actions);
assert.ok(game.nexusCards!.phase!.eligible.includes(owner));
const cards = game.nexusCards!.cards!, at = cards.deck.indexOf('ecaz'); assert.ok(at >= 0);
cards.deck.unshift(cards.deck.splice(at, 1)[0]);
staging.push('Order the still-undrawn original Ecaz Nexus singleton before the real unallied closing draw.');
const beforeDraw = structuredClone(game);
const draw: E3IndependentDukeStep = { actor: owner, action: { type: 'nexusCardChoice', turn: game.turn,
card: cards.hands[owner], choice: 'draw', ownRedraws: 0 } };
game = actE3IndependentDuke(game, draw.actor, draw.action, actions);
const afterDraw = structuredClone(game);
game = advanceE3IndependentDuke(game, state => state.phase === 2 && e3IndependentDukeClean(state), actions);
const beforeCunning = structuredClone(game), action = nexusEcazDukeAction(viewGame(game, owner)); assert.ok(action);
const cunning: E3IndependentDukeStep = { actor: owner, action };
game = actE3IndependentDuke(game, owner, action, actions);
return { initial: source.initial, offered: source.offered, afterSetup, afterFirstTurn,
owner, others, actions, staging, alliance, beforeDraw, draw, afterDraw, beforeCunning, cunning, game };
}
export type E3IndependentDukeFixture = ReturnType<typeof createE3IndependentNexusDuke>;
/** Two seats may select the module but cannot leave an unallied recipient
* after forming an alliance. This actual worm closes without an alliance,
* so its twelve original Nexus cards stay undrawn. */
export function createE3IndependentTwoSeatNexus(advanced = false) {
const source = withClassicDiscoveryNexusLottery(() => createIndependentCardModulesFixture({
advanced, factions: ['ecaz', 'guild'], expansions: ['ecaz'], independent: true,
nexus: true, tech: false,
}));
const afterSetup = structuredClone(source.game), actions = [...source.steps];
let game = structuredClone(afterSetup);
orderSpice(game);
game = advanceE3IndependentDuke(game, state => state.turn === 2 && state.phase === 1 &&
e3IndependentDukeClean(state), actions);
orderSpice(game, true);
game = advanceE3IndependentDuke(game, state => state.nexus && !state.spiceWindow &&
!state.spiceResolution && e3IndependentDukeClean(state), actions);
const opened = structuredClone(game);
game = advanceE3IndependentDuke(game, state => state.turn === 2 && state.phase === 2 &&
!state.nexus && e3IndependentDukeClean(state), actions);
return { initial: source.initial, offered: source.offered, afterSetup, opened, game, actions };
}
/** Use the acquired original shared disc in a genuinely funded invasion of the
* Guild's original Tuek garrison; no battle position or defending hand is staged. */
export function useE3IndependentNexusDuke(fixture: E3IndependentDukeFixture) {
const actions: E3IndependentDukeStep[] = [], owner = fixture.owner;
let game = structuredClone(fixture.game);
const opponent = game.players.find(player => player.faction === 'guild')?.id; assert.ok(opponent);
game = advanceE3IndependentDuke(game, state => state.phase === 5 && state.active === owner &&
e3IndependentDukeClean(state), actions);
const place = territory('tueks_sietch'), sector = place.sectors[0];
assert.ok(!place.sectors.includes(game.storm));
const beforeShipment = structuredClone(game), sources = game.homeworlds
? nativeShipmentSources(viewGame(game, owner), 1, 0) : undefined;
assert.ok(!game.homeworlds || sources);
const shipment: E3IndependentDukeStep = { actor: owner, action: { type: 'ship', territory: place.id,
sector, amount: 1, elite: 0, allyPayment: 0, ...(sources ? { homeworldSources: sources } : {}) } };
game = actE3IndependentDuke(game, owner, shipment.action, actions);
game = advanceE3IndependentDuke(game, state => state.phase === 6 && !!state.active &&
e3IndependentDukeClean(state), actions);
const beforeBattle = structuredClone(game), choice = viewGame(game, game.active!).battleChoices.find(candidate =>
candidate.territory === place.id && [candidate.attacker, candidate.defender].includes(owner) &&
[candidate.attacker, candidate.defender].includes(opponent)); assert.ok(choice);
game = actE3IndependentDuke(game, choice.chooser, { type: 'chooseBattle', territory: place.id,
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker }, actions);
game = advanceE3IndependentDuke(game, state => !!state.battle && e3IndependentDukeClean(state) &&
!state.battle.preparation && state.battle.preLeader?.closed !== false, actions);
const beforePlans = structuredClone(game), trained = new Set(game.leaderSkills?.assignments.map(assignment => assignment.leader));
const enemy = e3IndependentDukePlayer(game, opponent).leaders.filter(leader => !leader.dead &&
!leader.usedAt && !trained.has(leader.id)).sort((a, b) => a.strength - b.strength)[0]; assert.ok(enemy);
for (const step of [
{ actor: owner, action: { type: 'battlePlan', leader: DUKE_VIDAL_ID, dial: 1, support: game.advanced ? 1 : 0 } },
{ actor: opponent, action: { type: 'battlePlan', leader: enemy.id, dial: 0, support: 0 } },
] satisfies E3IndependentDukeStep[]) {
game = actE3IndependentDuke(game, step.actor, step.action, actions);
game = advanceE3IndependentDuke(game, state => !!state.battle && e3IndependentDukeClean(state) &&
!state.battle.preparation && state.battle.preLeader?.closed !== false, actions);
}
const revealed = structuredClone(game);
assert.equal(revealed.battle!.plans[owner].leader, DUKE_VIDAL_ID);
game = advanceE3IndependentDuke(game, state => !state.battle && e3IndependentDukeClean(state), actions);
return { game, actions, opponent, territory: place.id, beforeShipment, shipment, beforeBattle, beforePlans, revealed };
}
export function expireE3IndependentNexusDuke(fixture: E3IndependentDukeFixture) {
const actions: E3IndependentDukeStep[] = [];
const game = advanceE3IndependentDuke(structuredClone(fixture.game), state =>
state.turn > fixture.game.turn && state.phase === 0 && e3IndependentDukeClean(state), actions);
return { game, actions };
}
/** Callable finite legality smoke for owner verification; this is not AI calibration. */
export function smokeE3IndependentNexusDuke(options: E3IndependentDukeOptions = {}) {
const fixture = createE3IndependentNexusDuke(options);
return DIFFICULTIES.map(difficulty => {
const game = structuredClone(fixture.beforeCunning), view = viewGame(game, fixture.owner);
view.players.find(player => player.id === fixture.owner)!.bot = difficulty;
const action = botActions(view).find(candidate => candidate.type === 'nexusEcazDuke'); assert.ok(action, difficulty);
const actions: E3IndependentDukeStep[] = [];
const acquired = actE3IndependentDuke(game, fixture.owner, action, actions);
const expired = advanceE3IndependentDuke(acquired, state => state.turn > game.turn &&
state.phase === 0 && e3IndependentDukeClean(state), actions);
assert.equal(expired.dukeVidal!.controller, null);
return { difficulty, actions: actions.length, turn: expired.turn, phase: expired.phase,
discarded: expired.nexusCards!.cards!.discard.filter(card => card === 'ecaz').length };
});
}
@@ -0,0 +1,251 @@
import assert from 'node:assert/strict';
import { viewGame, type Action, type Game } from '../game/engine';
import { territory } from '../game/board';
import { nativeShipmentSources } from '../game/homeworld-options';
import { REINFORCEMENTS_CARD } from '../game/reinforcements';
import { HARASS_WITHDRAW_CARD, type HarassWithdrawSelection } from '../game/harass-withdraw';
import { quoteBattleResolution, type ResolutionCombatant } from '../game/battle-resolution-quote';
import {
createIndependentCardModulesFixture, type IndependentCardModulesOptions,
type IndependentCardModulesStep,
} from './fixture-independent-card-modules';
import {
actSelectedIndependentNexusBattle, selectedIndependentNexusClean,
} from './fixture-selected-independent-nexus-battles';
import { nextClassicDiscoveryNexusStep } from './fixture-discovery-classic-nexus';
export type E3IndependentNexusOccupyStep = IndependentCardModulesStep;
export type E3IndependentNexusOccupyOptions = Pick<IndependentCardModulesOptions,
'expansions' | 'homeworlds' | 'skills' | 'tech' | 'strongholds'> & { lead?: 'ecaz' | 'ally' };
export const e3IndependentNexusOccupyClean = selectedIndependentNexusClean;
export const e3IndependentNexusOccupyPlayer = (game: Game, actor: string) => {
const player = game.players.find(player => player.id === actor); assert.ok(player); return player;
};
/** Quiet printed optional placement and original reaction windows; the actual
* coalition owner still chooses its lead, sealed plan and typed withdrawal. */
export function nextE3IndependentNexusOccupyStep(game: Game): E3IndependentNexusOccupyStep | null {
if (game.pendingGuildBetrayal) {
const frame = game.pendingGuildBetrayal;
const actor = frame.required.find(id => !frame.passed.includes(id)); assert.ok(actor);
return { actor, action: { type: 'guildBetrayalPass', event: frame.invoice.event } };
}
if (!game.response && !game.phaseOpening) {
if (game.pendingNexusIxianBetrayal) {
const frame = game.pendingNexusIxianBetrayal;
const actor = frame.required.find(id => !frame.passed.includes(id)); assert.ok(actor);
return { actor, action: { type: 'nexusIxianBetrayalPass', event: frame.source.event } };
}
const decision = game.decision;
if (decision?.kind === 'ecazPlacement' || decision?.kind === 'moritaniPlacement')
return { actor: decision.player, action: { type: 'decision', decline: true } };
if (decision && ['ecazBattleLead', 'harassWithdraw', 'battleLosses'].includes(decision.kind)) return null;
}
return nextClassicDiscoveryNexusStep(game);
}
export function actE3IndependentNexusOccupy(game: Game, step: E3IndependentNexusOccupyStep,
actions?: E3IndependentNexusOccupyStep[]): Game {
return actSelectedIndependentNexusBattle(game, step, actions);
}
export function advanceE3IndependentNexusOccupy(game: Game, until: (game: Game) => boolean,
actions: E3IndependentNexusOccupyStep[] = []): Game {
for (let count = 0; count < 2200; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'Original Occupy game ended before its requested card window');
const step = nextE3IndependentNexusOccupyStep(game);
assert.ok(step, `Choose the real plan/allocation at ${game.turn}/${game.phase}/${game.decision?.kind}`);
game = actE3IndependentNexusOccupy(game, step, actions);
}
throw Error('Original independent Nexus Occupy programme did not reach its boundary');
}
/** Select an unopened singleton, then buy it with the real native auction.
* The roster has no Ixian provider; selected family cards alone create no pool. */
function buyCard(game: Game, owner: string, desired: string, actions: E3IndependentNexusOccupyStep[], staging: string[]) {
assert.equal(game.phase, 2);
const at = game.deck.findIndex(card => card.id === desired); assert.ok(at >= 0);
game.deck.unshift(game.deck.splice(at, 1)[0]);
staging.push(`Position unopened ${desired} before the actual turn-${game.turn} ordinary auction; ${owner} acquires it only by its paid one-spice bid.`);
let bid: { before: Game; step: E3IndependentNexusOccupyStep } | undefined;
for (let count = 0; count < 280; count++) {
if (e3IndependentNexusOccupyPlayer(game, owner).hand.some(card => card.id === desired)) {
assert.ok(bid, 'The actual auction must have accepted its owner bid');
return { game, bid };
}
let step: E3IndependentNexusOccupyStep | null = null;
if (e3IndependentNexusOccupyClean(game) && game.auction?.active === owner &&
game.auction.cards[game.auction.index].id === desired && game.auction.bid === 0) {
step = { actor: owner, action: { type: 'bid', amount: 1, allyPayment: 0 } };
bid = { before: structuredClone(game), step: structuredClone(step) };
}
step ??= nextE3IndependentNexusOccupyStep(game); assert.ok(step);
game = actE3IndependentNexusOccupy(game, step, actions);
}
throw Error('Original Occupy auction did not deliver its selected physical card');
}
/** Original native Ecaz setup and first Mentat, two funded auctions, real worm
* alliance/closing Nexus draw, paid typed arrivals and actual lead selection.
* Only unopened physical deck positions are chosen; original six Imperial
* Basin counters, hands, purses, receipts and module owners are never assigned. */
export function createE3IndependentNexusOccupyFixture(options: E3IndependentNexusOccupyOptions = {}) {
const lead = options.lead ?? 'ecaz', cardUser = lead === 'ecaz' ? 'ecaz' : 'emperor';
const source = createIndependentCardModulesFixture({ ...options, advanced: true,
expansions: options.expansions ?? ['ecaz'], factions: ['ecaz', 'emperor', 'guild'],
independent: true, nexus: true, tech: options.tech ?? false });
let game = structuredClone(source.game);
const afterSetup = structuredClone(game), actions = [...source.steps], staging: string[] = [];
const act = (actor: string, action: Action) => {
game = actE3IndependentNexusOccupy(game, { actor, action }, actions);
};
const reach = (until: (game: Game) => boolean) => {
game = advanceE3IndependentNexusOccupy(game, until, actions);
};
const takeLand = () => {
const at = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery); assert.ok(at >= 0);
return game.spiceDeck.splice(at, 1)[0];
};
game.spiceDeck.unshift(takeLand(), takeLand());
staging.push('Order unopened first-turn land Spice Cards; later order one original ordinary worm before the second-turn Blow.');
reach(state => state.phase === 2 && e3IndependentNexusOccupyClean(state));
const firstPurchase = buyCard(game, cardUser, REINFORCEMENTS_CARD, actions, staging); game = firstPurchase.game;
const purchases = [firstPurchase.bid];
const kind = 'arrakeen', sector = territory(kind).sectors[0], location = `${kind}:${sector}`;
const shipments: { before: Game; step: E3IndependentNexusOccupyStep; after: Game }[] = [];
const ship = (actor: string, amount: number, elite: number) => {
const homeworldSources = game.homeworlds ? nativeShipmentSources(viewGame(game, actor), amount, elite) : undefined;
assert.ok(!game.homeworlds || homeworldSources);
const before = structuredClone(game), step: E3IndependentNexusOccupyStep = { actor, action: {
type: 'ship', territory: kind, sector, amount, elite, allyPayment: 0,
...(homeworldSources ? { homeworldSources } : {}),
} };
game = actE3IndependentNexusOccupy(game, step, actions);
reach(e3IndependentNexusOccupyClean);
shipments.push({ before, step, after: structuredClone(game) });
};
reach(state => state.phase === 5 && state.active === 'ecaz' && e3IndependentNexusOccupyClean(state));
ship('ecaz', 5, 0);
reach(state => state.turn === 2 && state.phase === 1 && e3IndependentNexusOccupyClean(state));
const afterFirstMentat = structuredClone(game);
const worm = game.spiceDeck.findIndex(card => 'worm' in card && !card.greatMaker && !card.suppressed);
assert.ok(worm >= 0); game.spiceDeck.unshift(game.spiceDeck.splice(worm, 1)[0]);
reach(state => state.nexus && !state.spiceWindow && !state.spiceResolution && e3IndependentNexusOccupyClean(state));
const beforeAlliance = structuredClone(game);
act('ecaz', { type: 'alliance', target: 'emperor' });
act('emperor', { type: 'alliance', target: 'ecaz' });
reach(state => state.nexusCards?.phase?.stage === 'drawing');
const cards = game.nexusCards!.cards!, next = cards.deck.indexOf('guild'); assert.ok(next >= 0);
cards.deck.unshift(cards.deck.splice(next, 1)[0]);
staging.push('Order the unopened Guild Nexus singleton; the unallied Guild receives its own card through the real closing draw while the coalition is already allied.');
const beforeNexusDraw = structuredClone(game), drawStep: E3IndependentNexusOccupyStep = { actor: 'guild', action: {
type: 'nexusCardChoice', turn: game.turn, card: cards.hands.guild, choice: 'draw', ownRedraws: 0,
} };
game = actE3IndependentNexusOccupy(game, drawStep, actions);
const afterNexusDraw = structuredClone(game);
reach(state => state.phase === 2 && e3IndependentNexusOccupyClean(state));
const secondPurchase = buyCard(game, cardUser, HARASS_WITHDRAW_CARD, actions, staging);
game = secondPurchase.game; purchases.push(secondPurchase.bid);
reach(state => state.phase === 5 && e3IndependentNexusOccupyClean(state));
const waiting = new Set(['emperor', 'guild']);
while (game.phase === 5) {
if (e3IndependentNexusOccupyClean(game) && waiting.has(game.active!)) {
const actor = game.active!; ship(actor, actor === 'emperor' ? 5 : 3, actor === 'emperor' ? 2 : 0);
waiting.delete(actor);
} else {
const step = nextE3IndependentNexusOccupyStep(game); assert.ok(step);
game = actE3IndependentNexusOccupy(game, step, actions);
}
}
assert.equal(waiting.size, 0);
reach(state => state.phase === 6 && e3IndependentNexusOccupyClean(state));
const beforeBattle = structuredClone(game), choice = viewGame(game, game.active!).battleChoices.find(choice => choice.territory === kind);
assert.ok(choice);
const battleStep: E3IndependentNexusOccupyStep = { actor: choice.chooser, action: { type: 'chooseBattle', territory: kind,
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker } };
game = actE3IndependentNexusOccupy(game, battleStep, actions);
reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'ecazBattleLead');
const beforeLead = structuredClone(game);
assert.ok(game.decision?.kind === 'ecazBattleLead');
const leadStep: E3IndependentNexusOccupyStep = { actor: 'ecaz', action: { type: 'decision', event: game.decision.event, lead: cardUser } };
game = actE3IndependentNexusOccupy(game, leadStep, actions);
reach(state => !!state.battle && !state.battle.preparation && state.battle.preLeader?.closed !== false && e3IndependentNexusOccupyClean(state));
const own = viewGame(game, cardUser).battle!, profile = own.ecazOccupy?.profile; assert.ok(profile);
const leaders = (actor: string) => e3IndependentNexusOccupyPlayer(game, actor).leaders.filter(leader => !leader.dead && !leader.usedAt &&
!game.leaderSkills?.assignments.some(assignment => assignment.leader === leader.id));
const leader = leaders(cardUser).sort((one, two) => two.strength - one.strength)[0]; assert.ok(leader);
const enemy = leaders('guild').sort((one, two) => one.strength - two.strength)[0]; assert.ok(enemy);
const support = profile.forces.eliteFreeSupport || profile.forces.freeSupport ? 0 : 1;
const feeSources = own.reinforcements!.homeworldSources?.[0];
const plans: E3IndependentNexusOccupyStep[] = [
{ actor: cardUser, action: { type: 'battlePlan', dial: profile.fixedEcazDial + 2, support, leader: leader.id,
weapon: REINFORCEMENTS_CARD, defense: HARASS_WITHDRAW_CARD, allyPayment: 0,
...(feeSources ? { reinforcementsSources: feeSources } : {}),
} },
{ actor: 'guild', action: { type: 'battlePlan', dial: 0, support: 0, leader: enemy.id } },
];
const returns: HarassWithdrawSelection = { [location]: cardUser === 'ecaz'
? { normal: 2, elite: 0 } : { normal: 3, elite: 1 } };
return { ...source, afterSetup, afterFirstMentat, beforeAlliance, beforeNexusDraw, drawStep, afterNexusDraw,
beforeBattle, battleStep, beforeLead, leadStep, game, lead, cardUser, kind, sector, location,
profile, support, plans, returns, actions, staging, purchases, shipments };
}
export function revealE3IndependentNexusOccupy(game: Game, plans: readonly E3IndependentNexusOccupyStep[],
actions: E3IndependentNexusOccupyStep[] = []): Game {
for (const step of plans) {
game = actE3IndependentNexusOccupy(game, step, actions);
game = advanceE3IndependentNexusOccupy(game, state => !state.response && !state.phaseOpening &&
(!state.decision || state.decision.kind === 'harassWithdraw') && !state.pendingGuildBetrayal &&
!state.battle?.preparation && state.battle?.preLeader?.closed !== false, actions);
}
assert.ok(game.battle?.revealed); return game;
}
export function settleE3IndependentNexusOccupy(game: Game, returns: HarassWithdrawSelection,
actions: E3IndependentNexusOccupyStep[] = []): Game {
for (let count = 0; count < 360; count++) {
if (game.phase === 7 && !game.battle && e3IndependentNexusOccupyClean(game)) return game;
let step: E3IndependentNexusOccupyStep | null = null;
if (!game.response && !game.phaseOpening && game.decision?.kind === 'harassWithdraw')
step = { actor: game.decision.player, action: { type: 'decision', event: game.decision.event, returns } };
if (!game.response && !game.phaseOpening && game.decision?.kind === 'battleLosses') {
const choice = game.decision.options.findIndex(loss => loss.normal === 0 && loss.elite === 1); assert.ok(choice >= 0);
step = { actor: game.decision.player, action: { type: 'decision', choice } };
}
step ??= nextE3IndependentNexusOccupyStep(game); assert.ok(step);
game = actE3IndependentNexusOccupy(game, step, actions);
}
throw Error('Original combined card costs/returns/losses did not finish into Collection');
}
/** Project the genuinely revealed normal-outcome plans with explicit original
* owner allocations; the programme subsequently declines every real Traitor. */
export function quoteE3IndependentNexusOccupy(game: Game, cardUser: string, returns: HarassWithdrawSelection) {
const battle = game.battle; assert.ok(battle?.revealed);
const own = viewGame(game, cardUser).battle!, profile = own.ecazOccupy?.profile; assert.ok(profile);
const combatant = (actor: string): ResolutionCombatant => {
const player = e3IndependentNexusOccupyPlayer(game, actor), view = viewGame(game, actor).battle!;
const plan = battle.plans[actor]; assert.ok(plan);
return { id: actor, faction: player.faction, spice: player.spice, hand: player.hand, plan,
leader: player.leaders.find(leader => leader.id === plan.leader), forces: view.ownForces!,
stronghold: view.strongholdEffects[actor],
leaderSkills: (game.leaderSkills?.assignments ?? []).filter(assignment => assignment.owner === actor)
.map(assignment => ({ skill: assignment.skill, leader: assignment.leader,
faceUp: battle.leaderSkillHidden?.[actor] !== true, captured: false })),
...(view.reinforcements ? { reinforcementsReserves: {
normal: player.reserves - (player.elites?.reserves ?? 0), elite: player.elites?.reserves ?? 0 } } : {}),
...(view.harassWithdraw ? { harassWithdraw: { blocked: null, forces: view.harassWithdraw.forces,
locations: view.harassWithdraw.locations,
...(view.harassWithdraw.occupy ? { occupy: view.harassWithdraw.occupy } : {}) }, harassSelection: returns } : {}),
};
};
return quoteBattleResolution({ advanced: game.advanced, turn: game.turn, territory: battle.territory,
aggressor: viewGame(game, game.host).battle!.aggressor, ecazOccupy: profile,
attacker: combatant(battle.attacker), defender: combatant(battle.defender),
voters: viewGame(game, game.host).battle!.traitorVoters.map(actor => ({ id: actor,
beneficiary: [profile.ecaz, profile.ally].includes(actor) ? profile.lead : actor,
called: false, traitors: e3IndependentNexusOccupyPlayer(game, actor).traitors })),
participants: game.players,
physicalCards: [...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand)],
pendingAuditorPresent: false, pendingRetentionPresent: false,
});
}
@@ -108,7 +108,7 @@ void test('selected physical inventories require all three independent faces and
assert.deepEqual(ctx, before);
});
void test('public selected families authorize native responders without admitting unselected or E3 seats', () => {
void test('public selected families authorize native responders without admitting unselected native seats', () => {
const ctx = fixture();
for (const expansions of [['choam'], ['ix', 'ix'], ['ix', 'ecaz'], []])
assert.throws(() => createIxianBetrayalSource({ ...ctx, expansions }, authority(),
@@ -125,6 +125,26 @@ void test('public selected families authorize native responders without admittin
assert.equal(ixianBetrayalEligible(allied.cards, allied.players, 'holder', selection(allied)), false);
});
for (const mixed of [false, true]) void test(`selected E3/${mixed ? 'Ix/CHOAM cache' : 'Ix'} native responders preserve original cancellation and physical cards`, () => {
const original = fixture(mixed);
const expansions = [...original.expansions!, 'ecaz'];
const players: NexusPlayer[] = [...original.players.map(player => player.id === 'observer'
? {...player, faction:'ecaz' as const} : player), {id:'moritani', faction:'moritani'}];
const ctx: IxianBetrayalContext = {...original, expansions, players,
cards:{...original.cards, hands:{...original.cards.hands, moritani:null}},
physicalCards:[...treacheryDeck(expansions), ...ecazTreacheryCards(), ...(mixed ? richeseCards() : [])]};
const before = structuredClone(ctx);
const source = createIxianBetrayalSource(ctx, authority(), 'original-selected-e3-native-pool');
assert.equal(source.eligible, 'holder');
assert.deepEqual(ixianBetrayalResponders(ctx.cards, players, selection(ctx)), source.required);
const closed = closeIxianBetrayalSource(source, 'use', 'holder', initialIxianBetrayalCursor());
validateIxianBetrayalHistory([closed.receipt], closed.cursor, ctx.physicalCards, selection(ctx));
assert.equal(ctx.physicalCards.length, mixed ? 60 : 50);
assert.deepEqual(ctx, before, 'Neither the native E3 presence nor cancellation changes the selected catalogue.');
assert.throws(() => createIxianBetrayalSource({...ctx, ecazTreachery:false}, authority(),
source.parent), IxianBetrayalError, 'This new selected E3 lane requires its real independent-card selection.');
});
void test('omitted selection preserves original Ix47 source and receipt grammar and rejects silent new card inventory', () => {
const selected = fixture(false), ctx = { ...selected, physicalCards: treacheryDeck(['ix']) };
delete ctx.expansions;
+23 -1
View File
@@ -11,6 +11,12 @@ const entries: SelectedIndependentEntryOptions[] = [
{expansions:['ix','choam'], factions:['atreides','guild']},
{expansions:['choam'], factions:['emperor','atreides'], advanced:true, homeworlds:true, strongholds:true},
{expansions:['ix'], factions:['tleilaxu','guild'], homeworlds:true},
{expansions:['ecaz'], factions:['ecaz','moritani']},
{expansions:['ecaz'], factions:['moritani','harkonnen'], advanced:true},
{expansions:['ecaz'], factions:['ecaz','moritani','guild'], advanced:true, skills:true, homeworlds:true, tech:true, strongholds:true},
{expansions:['ecaz','ix','choam'], factions:['ecaz','moritani','ixians','tleilaxu','choam','richese'], advanced:true, skills:true, homeworlds:true, tech:true, strongholds:true},
{expansions:['ecaz','ix','choam'], factions:['atreides','guild']},
{expansions:['ecaz','choam'], factions:['ecaz','emperor'], advanced:true, skills:true, strongholds:true},
];
for (const options of entries) void test(`normal independent Nexus entry ${options.expansions.join('+')}/${options.factions.join('+')} retains selected components`, () => {
const {lobby, setup, game} = createSelectedIndependentEntry(options);
@@ -28,12 +34,15 @@ for (const options of entries) void test(`normal independent Nexus entry ${optio
assert.equal(game.nexusCards!.cards!.deck.length, 12);
assert.ok(Object.values(game.nexusCards!.cards!.hands).every(card => card === null));
const ordinary = game.deck.length + game.discard.length + game.players.reduce((sum, player) => sum + player.hand.length, 0);
assert.equal(ordinary, options.expansions.includes('ix') ? 50 : 38);
assert.equal(ordinary, options.expansions.includes('ix') ? 50 : options.expansions.includes('choam') ? 38 : 36);
assert.equal(game.richeseCache?.length ?? 0, options.factions.includes('richese') ? 10 : 0);
assert.equal(!!game.nexusIxianReplacementPreview, !options.factions.includes('ixians'));
assert.equal(!!game.nexusIxianBetrayalPreview, options.factions.includes('ixians'));
assert.equal(!!game.nexusHarkonnenBetrayalPreview, options.factions.includes('harkonnen'));
assert.equal(!!game.guildBetrayalPreview, options.factions.includes('guild'));
assert.equal(!!game.ecazOccupyPreview, options.factions.includes('ecaz'));
assert.equal(!!game.moritaniAssassinatePreview, !!options.advanced && options.factions.includes('moritani'));
assert.equal(!!game.moritaniAssassinate, !!options.advanced && options.factions.includes('moritani'));
if (options.skills) assert.ok(['skillTreachery','leaderSkills'].includes(setup.setupStage!),
'The original Ixian starting-card choice may precede skill training');
if (setup.techTokens) {
@@ -44,6 +53,19 @@ for (const options of entries) void test(`normal independent Nexus entry ${optio
if (game.strongholdCards) assert.ok(Object.values(game.strongholdCards.owners).every(owner => owner === null));
});
void test('selected E3 core retains native assassination stamps and the existing trained capture boundary', () => {
const native = {advanced:true, expansions:['ecaz'], players:[{faction:'moritani'},{faction:'harkonnen'}], nexusCards:true, ecazTreachery:true};
assert.equal(selectedIndependentNexusProfile(native), true);
assert.equal(selectedIndependentNexusProfile({...native, moritaniAssassinatePreview:true}), true);
assert.equal(selectedIndependentNexusProfile({...native, leaderSkills:true}), false);
assert.equal(selectedIndependentNexusProfile({...native, advanced:false, leaderSkills:true}), true);
assert.equal(selectedIndependentNexusProfile({...native, advanced:false, moritaniAssassinatePreview:true}), false);
assert.equal(selectedIndependentNexusProfile({...native, players:[{faction:'ecaz'},{faction:'emperor'}], moritaniAssassinatePreview:true}), false);
assert.equal(selectedIndependentNexusProfile({...native, expansions:['ix']}), false);
assert.equal(selectedIndependentNexusProfile({...native, expansions:['ecaz','ix','choam','ecaz']}), false);
assert.equal(selectedIndependentNexusProfile({...native, expansions:['ecaz','sandtrout']}), false);
});
void test('selected core capability requires real independent cards and selected registered seats', () => {
const base = {advanced:true, expansions:['ix'], players:[{faction:'ixians'},{faction:'atreides'}], nexusCards:true, ecazTreachery:true};
assert.equal(selectedIndependentNexusProfile(base), true);