Files
Dune/tests/fixture-moritani-atomics-modules.ts
Quintine bb9d292c4c Compose native Ecaz Betrayal and Moritani Atomics
Retain original selected modules, typed native returns, advisors and hidden No-Field recall. Preserve a full unspent Guild/Sapho opening, paid ordered arrivals, capped provisional No-Field destruction and native World Sneak withdrawal. Bind desert Aftermath to its original Moritani Cunning placement.

Validation: frozen 16-file115/115, types/lint/build, eight finite original-action and GameTable SSR programmes, four minimal legal policies. Document provisional overlaps and corrected genuine recipes. No stored-game change or deployment; full combinations, comprehensive assurance and deployed acceptance remain required.
2026-10-18 11:21:28 +11:00

239 lines
18 KiB
TypeScript

import assert from 'node:assert/strict';
import { viewGame, type Action, type Game } from '../game/engine';
import type { FactionId } from '../game/catalog';
import { nativeShipmentSources } from '../game/homeworld-options';
import { createIndependentCardModulesFixture, type IndependentCardModulesOptions, type IndependentCardModulesStep } from './fixture-independent-card-modules';
import { nextPairedChoamSkillsStep } from './fixture-paired-choam-nexus-skills';
import { applyClassicDiscoveryNexusStep, withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
export type AtomicsModulesStep = IndependentCardModulesStep;
export type AtomicsModulesOptions = Pick<IndependentCardModulesOptions, 'advanced' | 'skills' | 'homeworlds' | 'tech' | 'strongholds' | 'expansions'>;
export const atomicsModulesPlayer = (game: Game, actor: string) => {
const player = game.players.find(player => player.id === actor); assert.ok(player); return player;
};
export const atomicsModulesClean = (game: Game) => !game.response && !game.phaseOpening && !game.decision &&
!game.truthtrance && !game.pendingTreacheryDiscard && !game.pendingGuildBetrayal &&
!game.pendingNexusIxianReplacement && !game.pendingNexusIxianBetrayal && !game.pendingNexusHarkonnenBetrayal;
export function actAtomicsModules(game: Game, actor: string, action: Action, actions?: AtomicsModulesStep[]): Game {
return applyClassicDiscoveryNexusStep(game, { actor, action }, actions);
}
function nextAtomicsModules(game: Game): AtomicsModulesStep | 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.response && !game.phaseOpening && !game.pendingTreacheryDiscard) {
const decision = game.decision;
if (decision?.kind === 'moritaniTerror' || decision?.kind === 'ecazAmbassador') return null;
if (decision?.kind === 'moritaniPlacement' || decision?.kind === 'ecazPlacement')
return { actor: decision.player, action: { type: 'decision', decline: true } };
if (decision?.kind === 'guildTiming') return { actor: decision.player, action: { type: 'decision', take: false } };
if (decision?.kind === 'advisor') return { actor: decision.player,
action: { type: 'decision', accept: game.turn === 1 && decision.destination === 'carthag:11', amount: 1, accompany: true } };
if (decision?.kind === 'intrusion') return { actor: decision.player, action: { type: 'decision', accept: false } };
if (decision?.kind === 'homeworldDefense') return { actor: decision.player,
action: { type: 'decision', event: decision.event, use: false } };
if (decision?.kind === 'homeworldRevivalDeployment' || decision?.kind === 'caladanReinforcement' || decision?.kind === 'grummanCollection')
return { actor: decision.player, action: { type: 'decision', event: decision.event, decline: true } };
if (decision?.kind === 'richeseUnbid') return { actor: decision.player,
action: { type: 'decision', event: game.richeseBidding!.event, keep: false } };
if (decision?.kind === 'techToken') return { actor: decision.player, action: { type: 'decision', token: decision.choices[0] } };
}
return nextPairedChoamSkillsStep(game);
}
export function advanceAtomicsModules(game: Game, until: (state: Game) => boolean,
actions: AtomicsModulesStep[] = []): Game {
for (let count = 0; count < 2400; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The actual Atomics programme ended before its control');
const step = nextAtomicsModules(game); assert.ok(step, `Choose the original ${game.decision?.kind ?? 'sealed plan'}`);
game = actAtomicsModules(game, step.actor, step.action, actions);
}
throw Error('The original module Atomics programme did not reach its requested boundary');
}
function orderOriginalBlow(game: Game, worm = false): void {
const front: Game['spiceDeck'] = [];
if (worm) {
const index = game.spiceDeck.findIndex(card => 'worm' in card && !card.greatMaker && !card.suppressed && !card.thumper);
assert.ok(index >= 0); front.push(game.spiceDeck.splice(index, 1)[0]);
}
for (let count = 0; count < (game.advanced ? 2 : 1); count++) {
const index = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery && card.territory !== 'imperial_basin');
assert.ok(index >= 0); front.push(game.spiceDeck.splice(index, 1)[0]);
}
game.spiceDeck.unshift(...front);
}
/** Actual bank lots fill the owner's printed four slots, at one spice each.
* No desired identity is put in a hand and no wallet is reassigned. */
function buyOriginalHand(game: Game, actor: string, actions: AtomicsModulesStep[]) {
const bids: Array<{ before: Game; step: AtomicsModulesStep }> = [];
for (let count = 0; count < 650; count++) {
if (atomicsModulesPlayer(game, actor).hand.length === 4) return { game, bids };
let step: AtomicsModulesStep | null = null;
if (atomicsModulesClean(game) && game.auction && !game.richeseAuction &&
game.auction.active === actor && game.auction.bid === 0) {
step = { actor, action: { type: 'bid', amount: 1, allyPayment: 0 } };
bids.push({ before: structuredClone(game), step: structuredClone(step) });
}
step ??= nextAtomicsModules(game); assert.ok(step, 'The original bank auction must expose its current bid');
game = actAtomicsModules(game, step.actor, step.action, actions);
}
throw Error('The original bank lots did not fill the actual hand');
}
function shipNative(game: Game, actor: string, territory: string, sector: number, amount: number, elite: number,
actions: AtomicsModulesStep[]) {
const sources = game.homeworlds ? nativeShipmentSources(viewGame(game, actor), amount, elite) : undefined;
assert.ok(!game.homeworlds || sources);
const before = structuredClone(game), step: AtomicsModulesStep = { actor, action: { type: 'ship', territory, sector,
amount, elite, allyPayment: 0, ...(sources ? { homeworldSources: sources } : {}) } };
game = actAtomicsModules(game, step.actor, step.action, actions);
return { before, step, game };
}
/** Original native setup, paid hand-filling auctions, real placements, a worm
* alliance and closing draw. The paired route additionally earns its one BG
* advisor through an original first-turn Guild shipment. */
function atomicsModulesProgramme(options: AtomicsModulesOptions, paired: boolean) {
const factions: FactionId[] = paired
? ['moritani', 'ecaz', 'guild', 'emperor', 'beneGesserit'] : ['moritani', 'richese', 'guild', 'emperor'];
const source = withClassicDiscoveryNexusLottery(() => createIndependentCardModulesFixture({ ...options,
expansions: options.expansions ?? (paired ? ['ecaz'] : ['ecaz', 'choam']), factions,
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 = actAtomicsModules(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceAtomicsModules(game, until, actions); };
orderOriginalBlow(game);
reach(state => state.phase === 2 && atomicsModulesClean(state));
const first = buyOriginalHand(game, 'moritani', actions); game = first.game;
const purchases = [...first.bids];
let firstShipment: { before: Game; step: AtomicsModulesStep; after: Game } | null = null;
if (paired) {
reach(state => state.phase === 5 && state.active === 'guild' && atomicsModulesClean(state));
const advisorsBefore = atomicsModulesPlayer(game, 'beneGesserit').forces['carthag:11'] ?? 0;
const shipment = shipNative(game, 'guild', 'carthag', 11, 1, 0, actions); game = shipment.game;
reach(atomicsModulesClean);
firstShipment = { before: shipment.before, step: shipment.step, after: structuredClone(game) };
assert.equal(atomicsModulesPlayer(game, 'guild').forces['carthag:11'], 1);
assert.equal(atomicsModulesPlayer(game, 'beneGesserit').forces['carthag:11'], advisorsBefore + 1);
assert.ok(atomicsModulesPlayer(game, 'beneGesserit').advisors?.carthag);
}
reach(state => state.turn === 1 && !state.response && !state.phaseOpening && state.decision?.kind === 'moritaniPlacement');
const atomics = game.moritaniTerror!.tokens.find(token => token.kind === 'atomics' && token.status === 'available'); assert.ok(atomics);
const beforePlacement = structuredClone(game);
act('moritani', { type: 'decision', token: atomics.id, territory: 'carthag' });
reach(state => state.turn === 2 && state.phase === 1 && atomicsModulesClean(state));
orderOriginalBlow(game, true);
reach(state => state.nexus && !state.spiceWindow && !state.spiceResolution && atomicsModulesClean(state));
const ally = paired ? 'ecaz' : 'guild', holder = paired ? 'emperor' : 'richese';
const beforeAlliance = structuredClone(game);
act('moritani', { type: 'alliance', target: ally }); act(ally, { type: 'alliance', target: 'moritani' });
reach(state => state.nexusCards?.phase?.stage === 'drawing' && atomicsModulesClean(state));
const cards = game.nexusCards!.cards!, index = cards.deck.indexOf('tleilaxu'); assert.ok(index >= 0);
cards.deck.unshift(cards.deck.splice(index, 1)[0]);
const beforeDraw = structuredClone(game), drawStep: AtomicsModulesStep = { actor: holder,
action: { type: 'nexusCardChoice', turn: game.turn, card: cards.hands[holder], choice: 'draw', ownRedraws: 0 } };
act(drawStep.actor, drawStep.action);
const afterDraw = structuredClone(game);
reach(state => state.phase === 2 && atomicsModulesClean(state));
const second = buyOriginalHand(game, ally, actions); game = second.game; purchases.push(...second.bids);
// Ecaz places after Revival, before Shipment; the first-turn opportunity
// preceded the original Guild accompaniment. Use the later earned opening
// after the real worm alliance and bank purchases, while Atomics is already
// on the board, rather than skipping the first Mentat in search of Ecaz.
let ambassador: string | null = null;
if (paired) {
reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'ecazPlacement');
const token = game.ecazAmbassadors!.tokens.find(token => token.effect === 'ecaz' && token.zone === 'supply'); assert.ok(token);
ambassador = token.id;
act('ecaz', { type: 'decision', token: token.id, territory: 'carthag' });
}
staging.push('Only unopened original Spice and Nexus order is selected. Six paid one-spice bank bids fill the actual Moritani and activation-ally hands; no card, force, wallet or token is staged.');
return { source, game, afterSetup, actions, staging, firstShipment, beforePlacement, atomics: atomics.id,
ambassador, ally, holder, beforeAlliance, draw: { before: beforeDraw, step: drawStep, after: afterDraw }, purchases };
}
/** Concealed native Richese 3-marker uses its real paid shipment and Guild
* acknowledgement. Atomics has not yet revealed/materialized or killed it. */
export function createNoFieldAtomicsModulesFixture(options: AtomicsModulesOptions = {}) {
const programme = atomicsModulesProgramme(options, false), actions = programme.actions;
let game = advanceAtomicsModules(programme.game, state => state.phase === 5 && state.active === 'richese' && atomicsModulesClean(state), actions);
const richese = atomicsModulesPlayer(game, 'richese'), token = richese.noField!.tokens.find(token => token.value === 3); assert.ok(token);
const beforeEntry = structuredClone(game), entry: AtomicsModulesStep = { actor: 'richese', action: { type: 'ship',
noField: token.id, event: richese.noFieldEvent, territory: 'carthag', sector: 11, allyPayment: 0 } };
game = actAtomicsModules(game, entry.actor, entry.action, actions);
game = advanceAtomicsModules(game, state => !state.phaseOpening && !state.response && state.decision?.kind === 'moritaniTerror', actions);
assert.ok(atomicsModulesPlayer(game, 'richese').noField!.deployed);
return { ...programme, game, actions, beforeEntry, entry, noField: token.id };
}
/** Paired native Ambassador/Terror arrival queue with a real ordinary Guild
* counter, real BG advisor and the Emperor's typed paid incoming army. */
export function createPairedAtomicsModulesFixture(options: AtomicsModulesOptions = {}) {
const programme = atomicsModulesProgramme({ ...options, advanced: true }, true), actions = programme.actions;
let game = advanceAtomicsModules(programme.game, state => state.phase === 5 && state.active === 'emperor' && atomicsModulesClean(state), actions);
const arrival = shipNative(game, 'emperor', 'carthag', 11, 3, 2, actions); game = arrival.game;
game = advanceAtomicsModules(game, state => !state.phaseOpening && !state.response && !!state.pendingArrivalOverlap, actions);
return { ...programme, game, actions, beforeEntry: arrival.before, entry: arrival.step };
}
/** The live native arrival order chooses which original child is first. After
* destruction, its surviving parent resumes once, without a second invoice. */
export function finishAtomicsModulesReveal(game: Game, actions: AtomicsModulesStep[] = []) {
const seen: string[] = [];
let beforeActivation: Game | null = null;
for (let count = 0; count < 200; count++) {
if (game.moritaniAtomics && !game.pendingArrivalOverlap && !game.pendingAmbassador && !game.pendingTerrorEntry && atomicsModulesClean(game)) {
assert.ok(beforeActivation); return { game, beforeActivation, seen };
}
let step: AtomicsModulesStep | null = null;
if (!game.response && !game.phaseOpening && game.decision?.kind === 'ecazAmbassador') {
seen.push('ambassador'); step = { actor: game.decision.player,
action: { type: 'decision', event: game.pendingAmbassador!.event, decline: true } };
}
if (!game.response && !game.phaseOpening && game.decision?.kind === 'moritaniTerror') {
assert.equal(game.pendingTerrorEntry!.stage, 'offer');
beforeActivation = structuredClone(game); seen.push('atomics');
step = { actor: game.decision.player, action: { type: 'decision', reveal: true } };
}
step ??= nextAtomicsModules(game); assert.ok(step, 'The original arrival child needs its owned control');
game = actAtomicsModules(game, step.actor, step.action, actions);
}
throw Error('The original Atomics reveal did not settle its native arrival queue');
}
export function openLaterAtomicsModulesNexus(game: Game, actions: AtomicsModulesStep[] = []): Game {
const nextTurn = game.turn + 1;
game = advanceAtomicsModules(game, state => state.turn === nextTurn && state.phase === 1 && atomicsModulesClean(state), actions);
orderOriginalBlow(game, true);
return advanceAtomicsModules(game, state => state.nexus && !state.spiceWindow && !state.spiceResolution && atomicsModulesClean(state), actions);
}
/** A later real Sneak Attack placement, followed by a permitted paid shipment
* to a different territory and ordinary movement into Aftermath. No clocks,
* counters, resources, movement flags or pending Terror frames are assigned. */
export function createAtomicsAftermathSneakContinuation(game: Game, actions: AtomicsModulesStep[] = []) {
const activationTurn = game.turn;
game = advanceAtomicsModules(game, state => state.turn === activationTurn && !state.response && !state.phaseOpening && state.decision?.kind === 'moritaniPlacement', actions);
const token = game.moritaniTerror!.tokens.find(token => token.kind === 'sneakAttack' && token.status === 'available'); assert.ok(token);
game = actAtomicsModules(game, 'moritani', { type: 'decision', token: token.id, territory: 'carthag' }, actions);
game = advanceAtomicsModules(game, state => state.turn === activationTurn + 1 && state.phase === 1 && atomicsModulesClean(state), actions);
orderOriginalBlow(game);
game = advanceAtomicsModules(game, state => state.phase === 5 && state.active === 'emperor' && atomicsModulesClean(state), actions);
const blockedSources = game.homeworlds ? nativeShipmentSources(viewGame(game, 'emperor'), 1, 0) : undefined;
assert.ok(!game.homeworlds || blockedSources);
const beforeShipment = structuredClone(game), blockedShipment: Action = { type: 'ship', territory: 'carthag', sector: 11,
amount: 1, elite: 0, allyPayment: 0, ...(blockedSources ? { homeworldSources: blockedSources } : {}) };
const arrival = shipNative(game, 'emperor', 'imperial_basin', 11, 1, 0, actions); game = arrival.game;
game = advanceAtomicsModules(game, atomicsModulesClean, actions);
const beforeMove = structuredClone(game), movement: AtomicsModulesStep = { actor: 'emperor', action: { type: 'move',
from: 'imperial_basin:11', amount: 1, territory: 'carthag', sector: 11 } };
game = actAtomicsModules(game, movement.actor, movement.action, actions);
game = advanceAtomicsModules(game, state => !state.response && !state.phaseOpening && state.decision?.kind === 'moritaniTerror', actions);
game = actAtomicsModules(game, 'moritani', { type: 'decision', reveal: true }, actions);
game = advanceAtomicsModules(game, state => !state.response && !state.phaseOpening && state.pendingTerrorEntry?.stage === 'sneakAttack', actions);
const beforeSneak = structuredClone(game);
game = actAtomicsModules(game, 'moritani', { type: 'decision', amount: 1 }, actions);
game = advanceAtomicsModules(game, atomicsModulesClean, actions);
return { game, actions, beforeShipment, blockedShipment, beforeMove, movement, beforeSneak, token: token.id };
}