Files
Dune/tests/fixture-e3-independent-nexus-occupy.ts
T
Quintine 8793e23d6b 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.
2026-10-18 10:59:57 +11:00

252 lines
16 KiB
TypeScript

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,
});
}