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.
71 lines
4.4 KiB
TypeScript
71 lines
4.4 KiB
TypeScript
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};
|
|
}
|