179 lines
9.4 KiB
TypeScript
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.');
|
|
}
|