Files
Dune/tests/fixture-ecaz-occupy-richese.ts
Quintine 1f3e71fe03 Connect bare Basic native Ecaz coalitions
Admit fresh public Basic Ecaz starts with selected native E1/E2 families while retaining roster, inventory, seat and optional-profile guards. Keep actual coalition battle assertions. Include allied marker-only and mixed Richese presence, zero and capped materialization after both seals, and owner-specific casualties. Extend the explicitly provisional additive mixed-marker interpretation to Basic without new arithmetic or FAQ ruling.

Use the current physical force pool for battle-wheel half steps so Ecaz can commit Ixian Suboids; cancellation restores Ecaz-only whole steps. Preserve Basic free support, zero funding/income, native Ix substitution, lead-owned cards and original Collection.

Verify related Basic/Advanced rules, types/lint/build, nine actual-control/rendered-table programmes after genuine public Basic setup, explicit controlled board/budget/card staging and four existing profiles with legal lead/plan/native-substitution proposals. Full modes, wider optional configurations, comprehensive assurance and deployed acceptance remain unfinished. No strategic calibration, saved-room operation, reset, restart or deployment.
2026-10-24 13:54:50 +11:00

210 lines
11 KiB
TypeScript

import assert from 'node:assert/strict';
import { applyAction, createGame, initializeEcazOccupyGameForAudit, joinGame, newPlayer,
viewGame, type Action, type Game } from '../game/engine';
import { territory } from '../game/board';
import type { FactionId } from '../game/catalog';
import type { NoFieldValue } from '../game/richese-no-field';
import { finishEcazOccupySetup, stageEcazOccupyCard } from './fixture-ecaz-occupy';
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
export type EcazOccupyRicheseOptions = {
allyFaction?: 'richese' | 'ixians' | 'choam' | 'guild';
advanced?: boolean;
publicStart?: boolean;
opponentFaction?: 'richese' | 'choam' | 'atreides' | 'guild';
marker?: NoFieldValue;
richeseReserves?: number;
mixedRicheseForces?: number;
ecazForces?: number;
omitPreview?: boolean;
/** Original admitted fresh setup with its real authenticated seat identities. */
initial?: Game;
};
export type EcazOccupyRicheseFixture = {
initial: Game; afterSetup: Game; beforeBattle: Game; game: Game;
ecaz: string; ally: string; opponent: string; richese: string;
territory: string; location: string; ecazForces: number; materialized: number | null;
staging: string[];
};
export const occupyRicheseSeat = (game: Game, actor: string) => {
const player = game.players.find(p => p.id === actor);
assert.ok(player);
return player;
};
const clean = (game: Game) => !game.response && !game.phaseOpening && !game.decision;
export function nextOccupyRichese(game: Game): Game {
if (!game.response && !game.phaseOpening && game.decision?.kind === 'richeseAllyOpportunity')
return applyAction(game, game.decision.player, { type: 'decision', decline: true });
if (!game.response && !game.phaseOpening && game.decision?.kind === 'faceDance')
return applyAction(game, game.decision.player, { type: 'decision', reveal: false });
const next = nextRicheseStrongholdsNativeStep(game);
assert.ok(next, 'Native continuation must not manufacture a sealed plan.');
return applyAction(game, next.actor, next.action);
}
function advance(game: Game, predicate: (game: Game) => boolean): Game {
for (let attempt = 0; attempt < 1600; attempt++) {
if (predicate(game)) return game;
game = nextOccupyRichese(game);
}
throw Error('Native Occupy/Richese continuation did not reach its boundary.');
}
function orderBlow(game: Game, worm: boolean, position: number): void {
const at = game.spiceDeck.findIndex((card, i) => i >= position && (worm
? 'worm' in card && !card.greatMaker && !card.suppressed
: 'territory' in card && card.territory !== 'carthag'));
assert.ok(at >= position);
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(at, 1)[0]);
}
function reserveBoard(game: Game): void {
for (const player of game.players) {
player.reserves += Object.values(player.forces).reduce((sum, n) => sum + n, 0);
player.forces = {};
if (player.elites) {
player.elites.reserves += Object.values(player.elites.forces).reduce((sum, n) => sum + n, 0);
player.elites.forces = {};
}
if (player.advisors) player.advisors = {};
player.spice = 30;
}
}
function place(game: Game, owner: string, location: string, count: number): void {
const player = occupyRicheseSeat(game, owner);
assert.ok(Number.isSafeInteger(count) && count >= 0 && count <= player.reserves);
player.reserves -= count;
if (count) player.forces[location] = (player.forces[location] ?? 0) + count;
}
/** Real fresh selected Basic/Advanced family deal and natural Nexus alliance. Explicitly
* controlled conserved board/bank/card staging follows original setup; only
* native shipment creates the concealed marker, and actual chooseBattle admits
* the coalition. No phase, faction, alliance, plan or marker is assigned. */
export function ecazOccupyRicheseFixture(options: EcazOccupyRicheseOptions = {}): EcazOccupyRicheseFixture {
const advanced = options.advanced ?? true;
const allyFaction = options.allyFaction ?? 'richese';
const opponentFaction = options.opponentFaction ?? 'guild';
assert.notEqual(allyFaction, opponentFaction);
const roster: FactionId[] = allyFaction === 'ixians' && opponentFaction === 'choam'
? ['ecaz', 'ixians', 'choam', 'richese', 'moritani', 'tleilaxu']
: [...new Set<FactionId>(['ecaz', allyFaction, opponentFaction, 'richese', 'choam'])];
const expansions: Game['expansions'] = ['ecaz', 'choam', ...(roster.includes('ixians') ? ['ix'] as const : [])];
let game: Game;
if (options.initial) {
game = structuredClone(options.initial);
assert.equal(game.status, 'setup');
assert.equal(game.turn, 1);
assert.equal(game.advanced, advanced);
assert.equal(game.ecazOccupyPreview, true);
assert.deepEqual(game.players.map(p => p.faction), roster);
assert.deepEqual(game.expansions, expansions);
} else {
game = createGame('ECAZOCCUPYRICHESE', newPlayer(roster[0], roster[0], roster[0]), advanced, expansions);
for (const faction of roster.slice(1)) joinGame(game, newPlayer(faction, faction, faction));
for (let i = roster.length - 1; i >= 0; i--) {
const position = i + 1;
if (viewGame(game, roster[i]).playerPositions[roster[i]] !== position)
game = applyAction(game, roster[i], { type: 'seatPosition', position });
}
for (const player of game.players) game = applyAction(game, player.id, { type: 'ready' });
game = options.publicStart
? applyAction(game, game.host, { type: 'start', ...(advanced ? { advancedPreview: true } : {}) })
: initializeEcazOccupyGameForAudit(game);
}
const initial = structuredClone(game);
game = finishEcazOccupySetup(game);
const afterSetup = structuredClone(game);
const factionSeat = (faction: FactionId) => {
const player = game.players.find(p => p.faction === faction);
assert.ok(player);
return player.id;
};
const ecaz = factionSeat('ecaz'), ally = factionSeat(allyFaction);
const opponent = factionSeat(opponentFaction), richese = factionSeat('richese');
reserveBoard(game);
const staging = ['After original faction setup: return each physical board counter to its own reserves and stage thirty-spice bank balances.'];
orderBlow(game, false, 0); orderBlow(game, false, 1);
game = advance(game, g => g.turn === 2 && g.phase === 0 && clean(g));
orderBlow(game, true, 0); orderBlow(game, false, 1); orderBlow(game, false, 2);
game = advance(game, g => g.phase === 1 && !!g.nexus && !g.spiceWindow && !g.spiceResolution && clean(g));
game = applyAction(game, ecaz, { type: 'alliance', target: ally });
game = applyAction(game, ally, { type: 'alliance', target: ecaz });
game = advance(game, g => g.phase === 5 && clean(g));
reserveBoard(game);
const battleTerritory = 'carthag';
const sector = territory(battleTerritory).sectors[0];
const location = `${battleTerritory}:${sector}`;
const ecazForces = options.ecazForces ?? 5;
for (const token of game.ecazAmbassadors?.tokens ?? []) {
if (token.zone !== 'placed' || token.location !== battleTerritory) continue;
token.zone = 'supply';
token.location = null;
staging.push('Return the actual Carthag Ambassador to its existing supply for this clean controlled battle position.');
}
place(game, ecaz, location, ecazForces);
if (ally !== richese || options.marker === undefined) place(game, ally, location, 4);
if (opponent !== richese || options.marker === undefined) place(game, opponent, location, 8);
const mixed = options.mixedRicheseForces ?? 0;
if (options.marker !== undefined) {
assert.ok(ally === richese || opponent === richese);
const reserves = options.richeseReserves ?? 20;
assert.ok(Number.isSafeInteger(reserves) && reserves >= 0 && reserves + mixed <= 20);
place(game, richese, 'polar_sink:0', occupyRicheseSeat(game, richese).reserves - reserves - mixed);
place(game, richese, location, mixed);
staging.push(`Conserve Richese counters: ${mixed} ordinary battle counters, ${reserves} reserves, remainder in Polar Sink; native No-Field ${options.marker} shipment remains concealed.`);
}
staging.push(`Conserve ${ecazForces} Ecaz counters and ordinary allied/opposing counters in Carthag; original Nexus alliance is unchanged.`);
stageEcazOccupyCard(game, opponent, card => card.effect === 'karama');
let shipped = false;
while (game.phase === 5) {
if (!clean(game)) { game = nextOccupyRichese(game); continue; }
const actor = game.active!;
if (actor === richese && options.marker !== undefined && !shipped) {
const player = occupyRicheseSeat(game, richese);
const token = player.noField!.tokens.find(t => t.value === options.marker)!;
assert.ok(token);
const action: Action = { type: 'ship', territory: battleTerritory, sector,
noField: token.id, event: player.noFieldEvent };
game = applyAction(game, richese, action);
game = advance(game, clean);
shipped = true;
}
game = applyAction(game, actor, { type: 'endMovement' });
}
game = advance(game, clean);
if (options.omitPreview) {
delete game.ecazOccupyPreview;
staging.push('Before actual battle admission: omit the audit label; keep the native original setup inventory and rule composition unchanged.');
}
const beforeBattle = structuredClone(game);
const choice = viewGame(game, game.active!).battleChoices.find(b => b.territory === battleTerritory);
assert.ok(choice, 'Concealed zero remains actual native battle presence.');
game = applyAction(game, choice.chooser, { type: 'chooseBattle', territory: battleTerritory,
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker });
return { initial, afterSetup, beforeBattle, game, ecaz, ally, opponent, richese,
territory: battleTerritory, location, ecazForces,
materialized: options.marker === undefined ? null : Math.min(options.marker, options.richeseReserves ?? 20), staging };
}
export function openOccupyRichese(fixture: EcazOccupyRicheseFixture, lead: 'ecaz' | 'ally', cancel = false): Game {
let game = fixture.game;
assert.equal(game.decision?.kind, 'ecazBattleLead');
game = applyAction(game, fixture.ecaz, { type: 'decision', event: game.decision!.event,
lead: lead === 'ecaz' ? fixture.ecaz : fixture.ally });
if (cancel) {
assert.equal(game.response?.kind, 'ecazOccupy');
const card = occupyRicheseSeat(game, fixture.opponent).hand.find(c => c.effect === 'karama')!;
game = applyAction(game, fixture.opponent, { type: 'card', mode: 'cancel', card: card.id });
}
for (let attempt = 0; attempt < 100; attempt++) {
if (game.battle && !game.battle.preparation && (!game.battle.preLeader || game.battle.preLeader.closed) && clean(game)) return game;
game = nextOccupyRichese(game);
}
throw Error('Original Richese preleader and Occupy responses did not finish.');
}
export function finishOccupyRichese(state: Game): Game {
let game = state;
for (let attempt = 0; attempt < 200; attempt++) {
if (!game.battle && clean(game) && !game.pendingTreacheryDiscard) return game;
game = nextOccupyRichese(game);
}
throw Error('Native Occupy/Richese reveal aftermath did not finish.');
}