Files
Dune/tests/fixture-no-field-full-plan-occupy.ts

179 lines
9.4 KiB
TypeScript

import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeEcazOccupyGameForAudit, joinGame, newPlayer,
viewGame, type Action, type Game,
} from '../game/engine';
import type { NoFieldValue } from '../game/richese-no-field';
import { defaultHarassWithdrawAllocation } from '../game/harass-withdraw';
import { stageEcazOccupyCard } from './fixture-ecaz-occupy';
import { holdOccupyCardsTraitor } from './fixture-ecaz-occupy-cards';
import {
ecazOccupyRicheseFixture, finishOccupyRichese, nextOccupyRichese, occupyRicheseSeat,
type EcazOccupyRicheseFixture,
} from './fixture-ecaz-occupy-richese';
export type NoFieldFullPlanOccupyOptions = {
initial?: Game;
lead?: 'ecaz' | 'ally';
marker?: NoFieldValue;
richeseReserves?: number;
ordinary?: number;
prevented?: boolean;
utilities?: boolean;
opposingTraitor?: boolean;
variableDial?: number;
support?: number;
};
export type NoFieldFullPlanOccupyFixture = EcazOccupyRicheseFixture & {
source: EcazOccupyRicheseFixture;
beforeLead: Game;
prescience: Game;
owner: string;
atreides: string;
inspectionCard: string;
preventionCard: string | null;
ordinary: number;
marker: NoFieldValue;
materializes: number;
fixedEcazDial: number;
variableDial: number;
support: number;
targetPlan: Action;
atreidesPlan: Action;
utilities: boolean;
opposingTraitor: boolean;
};
/** Select the independent physical three-card source before the one original
* initialization. A captured setup is instead continued without redealing. */
export function createNoFieldFullPlanOccupySetup(): Game {
const roster = ['ecaz', 'richese', 'atreides', 'choam'] as const;
let game = createGame('NOFIELDFULLPLANOCCUPY', newPlayer(roster[0], roster[0], roster[0]), true, ['ecaz', 'choam']);
for (const faction of roster.slice(1)) joinGame(game, newPlayer(faction, faction, faction));
for (let i = roster.length - 1; i >= 0; i--)
if (viewGame(game, roster[i]).playerPositions[roster[i]] !== i + 1)
game = applyAction(game, roster[i], { type: 'seatPosition', position: i + 1 });
for (const faction of roster) game = applyAction(game, faction, { type: 'ready' });
game.ecazTreachery = true;
return initializeEcazOccupyGameForAudit(game);
}
/** The driver never consumes the boundary that the actual control must offer. */
export function advanceNoFieldFullPlanOccupy(state: Game, boundary: 'prescience' | 'offer'): Game {
let game = state;
for (let attempt = 0; attempt < 160; attempt++) {
const prepared = !game.battle?.preLeader || game.battle.preLeader.closed;
if (!game.response && !game.phaseOpening && prepared &&
(boundary === 'prescience'
? game.battle?.preparation?.kind === 'prescience'
: game.decision?.kind === 'fullPlanOffer')) return game;
assert.notEqual(game.decision?.kind, 'fullPlanOffer', 'Do not auto-decline a real full-plan offer.');
assert.notEqual(!game.response && !game.phaseOpening && prepared && game.battle?.preparation?.kind, 'prescience',
'Ordinary Prescience requires an explicit choice before advancing to the special offer.');
game = nextOccupyRichese(game);
}
throw Error(`Native coalition continuation did not reach ${boundary}.`);
}
/** The shared fixture performs genuine setup, Nexus alliance, native token
* shipment and chooseBattle. Only labeled conserved card/Traitor staging follows. */
export function noFieldFullPlanOccupyFixture(options: NoFieldFullPlanOccupyOptions = {}): NoFieldFullPlanOccupyFixture {
const marker = options.marker ?? 3;
const ordinary = options.ordinary ?? 2;
assert.ok(Number.isSafeInteger(ordinary) && ordinary >= 0);
const source = ecazOccupyRicheseFixture({
initial: options.initial ?? (options.utilities ? createNoFieldFullPlanOccupySetup() : undefined),
opponentFaction: 'atreides', marker, mixedRicheseForces: ordinary,
richeseReserves: options.richeseReserves ?? 6,
});
if (options.utilities) assert.equal(source.initial.ecazTreachery, true);
const owner = options.lead === 'ecaz' ? source.ecaz : source.richese;
const atreides = source.opponent;
const inspectionCard = occupyRicheseSeat(source.game, atreides).hand.find(card => card.effect === 'karama')!.id;
// Return, do not replace, unselected original cards. Keep the actual Karama
// already staged by the shared source; all subsequent draws use that same deck.
for (const player of source.game.players) {
const returned = player.hand.filter(card => card.id !== inspectionCard);
source.game.deck.push(...returned);
player.hand = player.hand.filter(card => card.id === inspectionCard);
}
source.staging.push('Return all unselected original held cards to the remaining native deck; retain the original Atreides inspection Karama and stage only the selected real battle faces.');
const preventionCard = options.prevented
? stageEcazOccupyCard(source.game, 'choam', card => card.effect === 'karama' && card.id !== inspectionCard)
: null;
if (preventionCard) source.staging.push(`Conserve distinct original CHOAM Karama ${preventionCard} for the actual Occupy response.`);
const weapon = stageEcazOccupyCard(source.game, owner, card => options.utilities
? card.id === 'ecaz-reinforcements' : card.kind === 'projectile');
const defense = stageEcazOccupyCard(source.game, owner, card => options.utilities
? card.id === 'ecaz-harass-withdraw' : card.kind === 'shield');
const enemyDefense = stageEcazOccupyCard(source.game, atreides,
card => card.kind === 'shield' && card.id !== defense);
const physicalTraitors = new Set([...(source.game.traitorReserve ?? []),
...source.game.players.flatMap(player => player.traitors)]);
const leader = occupyRicheseSeat(source.game, owner).leaders
.filter(face => !face.dead && !face.usedAt && (!options.opposingTraitor || physicalTraitors.has(face.id)))
.sort((a, b) => b.strength - a.strength)[0];
const enemyLeader = occupyRicheseSeat(source.game, atreides).leaders
.filter(face => !face.dead && !face.usedAt).sort((a, b) => a.strength - b.strength)[0];
assert.ok(leader && enemyLeader);
if (options.opposingTraitor) {
holdOccupyCardsTraitor(source.game, atreides, leader.id);
source.staging.push(`Exchange an original held Traitor Card for actual ${leader.id} in the Atreides hand.`);
}
const beforeLead = structuredClone(source.game);
assert.equal(source.game.decision?.kind, 'ecazBattleLead');
let game = applyAction(source.game, source.ecaz, { type: 'decision', event: source.game.decision!.event, lead: owner });
if (preventionCard) {
assert.equal(game.response?.kind, 'ecazOccupy');
game = applyAction(game, 'choam', { type: 'card', mode: 'cancel', card: preventionCard });
}
const prescience = advanceNoFieldFullPlanOccupy(game, 'prescience');
const profile = viewGame(prescience, owner).battle!.ecazOccupy!.profile!;
assert.ok(profile);
const materializes = Math.min(marker, occupyRicheseSeat(prescience, source.richese).reserves);
const variableDial = options.variableDial ?? Math.min(3, profile.forces.normal);
const support = options.support ?? variableDial;
return {
...source, source, beforeLead, game: prescience, prescience, owner, atreides,
inspectionCard, preventionCard, ordinary, marker, materializes,
fixedEcazDial: profile.fixedEcazDial, variableDial, support,
targetPlan: { type: 'battlePlan', dial: profile.fixedEcazDial + variableDial,
support, leader: leader.id, weapon, defense },
atreidesPlan: { type: 'battlePlan', dial: 0, support: 0, leader: enemyLeader.id, defense: enemyDefense },
utilities: options.utilities ?? false, opposingTraitor: options.opposingTraitor ?? false,
};
}
export function offerNoFieldFullPlanOccupy(fixture: NoFieldFullPlanOccupyFixture, state = fixture.prescience): Game {
assert.equal(state.battle?.preparation?.kind, 'prescience');
return advanceNoFieldFullPlanOccupy(applyAction(state, fixture.atreides, { type: 'declineBattlePower' }), 'offer');
}
export function inspectNoFieldFullPlanOccupy(fixture: NoFieldFullPlanOccupyFixture,
state = offerNoFieldFullPlanOccupy(fixture)): Game {
return applyAction(state, fixture.atreides,
{ type: 'card', mode: 'special', card: fixture.inspectionCard, target: fixture.owner });
}
/** Real post-reveal allocation and declaration windows, including an optional
* actual opposing Traitor call; never assigns an outcome or clears a decision. */
export function finishNoFieldFullPlanOccupy(fixture: NoFieldFullPlanOccupyFixture, state: Game): Game {
let game = state;
for (let attempt = 0; attempt < 100; attempt++) {
if (game.response || game.phaseOpening) { game = nextOccupyRichese(game); continue; }
if (game.decision?.kind === 'harassWithdraw') {
const decision = game.decision;
const allocation = viewGame(game, decision.player).battle?.harassAllocation;
assert.ok(allocation);
game = applyAction(game, decision.player, { type: 'decision', event: decision.event,
returns: defaultHarassWithdrawAllocation(allocation.context, allocation.dial, allocation.support) });
continue;
}
const battle = viewGame(game, fixture.owner).battle;
const voter = !game.decision && battle?.traitorVoters.find(id => !battle.traitorSubmitted.includes(id));
if (!voter) return finishOccupyRichese(game);
game = applyAction(game, voter,
{ type: 'traitorCall', call: fixture.opposingTraitor && voter === fixture.atreides });
}
throw Error('Native inspected coalition declarations did not finish.');
}