341 lines
18 KiB
TypeScript
341 lines
18 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import { applyAction, createGame, initializeEcazOccupyGameForAudit, joinGame, newPlayer,
|
|
viewGame, type Action, type Game, type Player } from '../game/engine';
|
|
import { botActions } from '../game/bots';
|
|
import { TERRITORIES, location as locationKey, splitLocation } from '../game/board';
|
|
import { finishToMovement, advanceToNextStorm } from './fixture-advanced-source';
|
|
import type { FactionId } from '../game/catalog';
|
|
import { treacheryDeck } from '../game/cards';
|
|
import { ecazTreacheryCards } from '../game/ecaz-cards';
|
|
import { traitorDeck } from '../game/traitors';
|
|
import { withoutEcazLoyalty } from '../game/ecaz-loyalty';
|
|
|
|
export type EcazOccupyFixtureFaction = Exclude<FactionId, 'richese'>;
|
|
export type EcazOccupyFixtureOptions = {
|
|
initial?: Game;
|
|
/** Independent physical three-card variant, chosen before the original deal. */
|
|
ecazTreachery?: boolean;
|
|
ecazForces?: number;
|
|
allyFaction?: EcazOccupyFixtureFaction;
|
|
order?: 'ecaz-first' | 'ally-first' | 'opponent-first';
|
|
opponentFaction?: EcazOccupyFixtureFaction;
|
|
expansions?: Game['expansions'];
|
|
roster?: EcazOccupyFixtureFaction[];
|
|
allyForces?: number;
|
|
allyElite?: number;
|
|
opponentForces?: number;
|
|
opponentElite?: number;
|
|
/** Seed only a still-pending original Face Dancer shuffle, never a later draw. */
|
|
faceDancerFaction?: EcazOccupyFixtureFaction;
|
|
advisorAlly?: boolean;
|
|
};
|
|
export type EcazOccupyFixture = {
|
|
game: Game; initial: Game; afterSetup: Game; ecaz: string; ally: string; opponent: string;
|
|
territory: string; location: string; ecazForces: number;
|
|
};
|
|
|
|
function step(game: Game): Game {
|
|
if (game.response) return applyAction(game,
|
|
game.players.find(p => !game.response!.passed.includes(p.id))!.id, { type: 'passResponse' });
|
|
if (game.phaseOpening) return applyAction(game,
|
|
game.players.find(p => !game.phaseOpening!.passed.includes(p.id))!.id, { type: 'ready' });
|
|
if (game.phase === 3 && game.auction && !game.decision)
|
|
return applyAction(game, game.auction.active, { type: 'passBid' });
|
|
if (game.decision?.kind === 'wormRide')
|
|
return applyAction(game, game.decision.player, { type: 'decision', accept: false });
|
|
if (game.decision && ['advisor', 'intrusion', 'advisorBattle'].includes(game.decision.kind))
|
|
return applyAction(game, game.decision.player, { type: 'decision', accept: false });
|
|
if (game.decision?.kind === 'guildShipment')
|
|
return applyAction(game, game.decision.player, { type: 'decision', allow: true });
|
|
for (const player of game.players) {
|
|
const view = viewGame(game, player.id);
|
|
view.players.find(p => p.id === player.id)!.bot = 'Easy';
|
|
const actions = botActions(view);
|
|
const action = actions.find(a => a.type === 'ready') ?? actions[0];
|
|
if (action) return applyAction(game, player.id, action);
|
|
}
|
|
throw new Error(`No native fixture action at ${game.status}/${game.turn}/${game.phase}/${game.decision?.kind}`);
|
|
}
|
|
|
|
/** Complete existing native offers. Entropy is scoped to the final first-draw
|
|
* action and forwards the Ix starting-card remainder shuffle unchanged. */
|
|
export function finishEcazOccupySetup(state: Game, faceDancerFaction?: EcazOccupyFixtureFaction): Game {
|
|
let game = structuredClone(state);
|
|
for (let attempt = 0; attempt < 200 && game.status === 'setup'; attempt++) {
|
|
const tleilaxu = game.players.find(p => p.faction === 'tleilaxu');
|
|
const pending = viewGame(game, game.players[0].id).setupPending;
|
|
const finalForces = game.setupStage === 'forces' && !game.players.some(p => p.faction === 'ixians') &&
|
|
pending.length === 1 && (game.players.find(p => p.id === pending[0])!.faction === 'beneGesserit' ||
|
|
game.players.find(p => p.id === pending[0])!.faction === 'ecaz' &&
|
|
!game.players.some(p => p.faction === 'beneGesserit'));
|
|
if (!faceDancerFaction || !tleilaxu || tleilaxu.faceDancers ||
|
|
!(game.decision?.kind === 'ixSetup' || finalForces)) {
|
|
game = step(game);
|
|
continue;
|
|
}
|
|
const held = new Set(game.players.flatMap(p => p.traitors));
|
|
const source = withoutEcazLoyalty(traitorDeck(game.players, game.expansions.includes('ix')),
|
|
game.ecazLoyalty).filter(id => !held.has(id));
|
|
const owner = game.players.find(p => p.faction === faceDancerFaction);
|
|
assert.ok(owner);
|
|
const target = owner.leaders.filter(l => source.includes(l.id)).sort((a, b) => b.strength - a.strength)[0];
|
|
assert.ok(target, 'A real leader of the selected faction remains in the first native Face Dancer source.');
|
|
const targetIndex = source.indexOf(target.id);
|
|
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
|
|
const originalRandom = crypto.getRandomValues.bind(crypto);
|
|
let prefixCalls = game.decision?.kind === 'ixSetup' ? Math.max(0, game.ixSetupCards!.length - 2) : 0;
|
|
let index = source.length - 1;
|
|
crypto.getRandomValues = <T extends ArrayBufferView | null>(array: T): T => {
|
|
assert.ok(array instanceof Uint32Array && array.length === 1);
|
|
if (prefixCalls > 0) { prefixCalls--; originalRandom(array); return array; }
|
|
if (index <= 0) { originalRandom(array); return array; }
|
|
array[0] = index === targetIndex ? 0 : 0xffffffff;
|
|
index--;
|
|
return array;
|
|
};
|
|
try { game = step(game); }
|
|
finally {
|
|
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
|
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
|
}
|
|
assert.equal(game.status, 'playing', 'Seed only an actually pending first Face Dancer draw.');
|
|
assert.ok(game.players.find(p => p.id === tleilaxu.id)!.faceDancers!.some(c => c.leader === target.id));
|
|
}
|
|
assert.equal(game.status, 'playing', 'Original native setup must finish without a redeal.');
|
|
return game;
|
|
}
|
|
|
|
export function settleEcazOccupyArrival(state: Game, accompany = false): Game {
|
|
let game = state;
|
|
for (let attempt = 0; attempt < 50 && (game.response || game.decision); attempt++) {
|
|
if (accompany && !game.response && game.decision?.kind === 'advisor')
|
|
game = applyAction(game, game.decision.player,
|
|
{ type: 'decision', accept: true, accompany: true, amount: 1 });
|
|
else game = step(game);
|
|
}
|
|
assert.ok(!game.response && !game.decision, 'Native shipment reactions must complete.');
|
|
return game;
|
|
}
|
|
|
|
/** Conserved physical board staging AFTER genuine faction setup. No faction,
|
|
* alliance, phase, plan, force identity or battle receipt is manufactured. */
|
|
function returnBoardToReserves(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 = {};
|
|
// The Spice Bank supplies a labelled test budget, not alliance credit.
|
|
player.spice = 30;
|
|
}
|
|
}
|
|
function orderBlow(game: Game, worm: boolean, position: number): void {
|
|
const index = game.spiceDeck.findIndex((card, i) => i >= position &&
|
|
(worm ? 'worm' in card && !card.greatMaker && !card.suppressed :
|
|
'territory' in card && card.territory !== 'the_great_flat'));
|
|
assert.ok(index >= position, 'The actual unplayed Spice Card must remain available.');
|
|
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]);
|
|
}
|
|
|
|
/** Conserved physical card exchange after real setup; retains the native draw
|
|
* and every original identity. Used only to expose a named rule case. */
|
|
export function stageEcazOccupyCard(game: Game, owner: string,
|
|
predicate: (card: Player['hand'][number]) => boolean): string {
|
|
const player = game.players.find(p => p.id === owner)!;
|
|
const held = player.hand.find(predicate);
|
|
if (held) return held.id;
|
|
const index = game.deck.findIndex(predicate);
|
|
let card: Player['hand'][number];
|
|
if (index >= 0) {
|
|
card = game.deck.splice(index, 1)[0];
|
|
if (player.hand.length >= 4) game.deck.push(player.hand.shift()!);
|
|
} else {
|
|
const donor = game.players.find(p => p.id !== owner && p.hand.some(predicate));
|
|
assert.ok(donor, 'A conserved original physical card must supply this rule case.');
|
|
const donorIndex = donor.hand.findIndex(predicate);
|
|
card = donor.hand[donorIndex];
|
|
// Keep previously staged battle cards when there is still hand capacity.
|
|
// A donor's replacement comes from the original deck, just as a direct
|
|
// staging draw does; only a full hand must fund it with a held card.
|
|
const replacement = player.hand.length >= 4 ? player.hand.shift() : game.deck.shift();
|
|
assert.ok(replacement, 'A conserved physical face must preserve the donor hand.');
|
|
donor.hand[donorIndex] = replacement;
|
|
}
|
|
player.hand.push(card);
|
|
return card.id;
|
|
}
|
|
|
|
/** Genuine fresh E3 faction setup, a natural worm Nexus, reciprocal alliance,
|
|
* accepted shared shipment and actual chooseBattle. Returns BEFORE lead choice.
|
|
* A supplied fresh setup retains its original deal and printed seat positions. */
|
|
export function createEcazOccupySetup(options: EcazOccupyFixtureOptions = {}): Game {
|
|
const allyFaction = options.allyFaction ?? 'fremen';
|
|
const opponentFaction = options.opponentFaction ?? (allyFaction === 'guild' ? 'emperor' : 'guild');
|
|
assert.notEqual(allyFaction, opponentFaction);
|
|
let setup: Game;
|
|
if (options.initial) {
|
|
setup = structuredClone(options.initial);
|
|
assert.equal(setup.status, 'setup', 'Captured input must be its original admitted fresh setup.');
|
|
assert.equal(setup.ecazOccupyPreview, true);
|
|
assert.equal(setup.advanced, true);
|
|
if (options.expansions) assert.deepEqual(setup.expansions, options.expansions);
|
|
assert.ok(setup.expansions.includes('ecaz') && new Set(setup.expansions).size === setup.expansions.length);
|
|
assert.equal(setup.turn, 1, 'Captured Occupy input is never a started-game retrofit.');
|
|
if (options.ecazTreachery !== undefined)
|
|
assert.equal(setup.ecazTreachery === true, options.ecazTreachery);
|
|
assert.ok(!setup.homeworlds && !setup.nexusCards &&
|
|
!setup.leaderSkills && !setup.strongholdCards && !setup.techTokens &&
|
|
!setup.discoveryEnabled && !setup.discoveries && !setup.discoveryStash &&
|
|
!setup.greatMaker && !setup.moritaniAssassinatePreview,
|
|
'Captured Occupy inputs admit only the selected original Advanced family and independent card variant.');
|
|
assert.deepEqual([...setup.deck, ...setup.discard, ...(setup.ixSetupCards ?? []),
|
|
...setup.players.flatMap(p => p.hand)].map(card => card.id).sort(),
|
|
[...treacheryDeck(setup.expansions), ...(setup.ecazTreachery ? ecazTreacheryCards() : [])]
|
|
.map(card => card.id).sort(),
|
|
'The original captured deal must retain every selected physical card exactly once.');
|
|
} else {
|
|
const roster: EcazOccupyFixtureFaction[] = options.roster ?? ['ecaz', allyFaction, opponentFaction];
|
|
const expansions = options.expansions ?? ['ecaz',
|
|
...(roster.some(f => f === 'ixians' || f === 'tleilaxu') ? ['ix'] as const : []),
|
|
...(roster.includes('choam') ? ['choam'] as const : [])];
|
|
const desired: EcazOccupyFixtureFaction[] = options.order === 'ally-first' ? [allyFaction, opponentFaction, 'ecaz'] :
|
|
options.order === 'opponent-first' || opponentFaction === 'beneGesserit' ?
|
|
[opponentFaction, 'ecaz', allyFaction] : ['ecaz', allyFaction, opponentFaction];
|
|
desired.push(...roster.filter(f => !desired.includes(f)));
|
|
const joinOrder = roster.length > 4 ? desired : roster;
|
|
setup = createGame('ECAZOCCUPY', newPlayer(joinOrder[0], joinOrder[0], joinOrder[0]), true, expansions);
|
|
for (const faction of joinOrder.slice(1)) joinGame(setup, newPlayer(faction, faction, faction));
|
|
const firstCircle = Math.min(3, 7 - desired.length);
|
|
// Printed circles preserve the source order; all initial forces are
|
|
// subsequently returned through their actual physical reserves.
|
|
for (let i = desired.length - 1; i >= 0; i--)
|
|
if (viewGame(setup, desired[i]).playerPositions[desired[i]] !== i + firstCircle)
|
|
setup = applyAction(setup, desired[i], { type: 'seatPosition', position: i + firstCircle });
|
|
for (const player of setup.players) setup = applyAction(setup, player.id, { type: 'ready' });
|
|
// Label the independent variant in this fresh audit configuration only,
|
|
// immediately before its one original setup/deal; never retrofit a capture.
|
|
if (options.ecazTreachery) setup.ecazTreachery = true;
|
|
setup = initializeEcazOccupyGameForAudit(setup);
|
|
}
|
|
assert.ok(setup.players.some(p => p.faction === 'ecaz') &&
|
|
setup.players.some(p => p.faction === allyFaction) &&
|
|
setup.players.some(p => p.faction === opponentFaction),
|
|
'The original source roster must contain the three actual factions for this rule case.');
|
|
return setup;
|
|
}
|
|
|
|
export function ecazOccupyFixture(options: EcazOccupyFixtureOptions = {}): EcazOccupyFixture {
|
|
const allyFaction = options.allyFaction ?? 'fremen';
|
|
const opponentFaction = options.opponentFaction ?? (allyFaction === 'guild' ? 'emperor' : 'guild');
|
|
const ecazForces = options.ecazForces ?? 3;
|
|
assert.ok(Number.isInteger(ecazForces) && ecazForces >= 1 && ecazForces <= 5);
|
|
const setup = createEcazOccupySetup(options);
|
|
const initial = structuredClone(setup);
|
|
const ecaz = setup.players.find(p => p.faction === 'ecaz')!.id;
|
|
const ally = setup.players.find(p => p.faction === allyFaction)!.id;
|
|
const opponent = setup.players.find(p => p.faction === opponentFaction)!.id;
|
|
let game = finishEcazOccupySetup(setup, options.faceDancerFaction);
|
|
const afterSetup = structuredClone(game);
|
|
returnBoardToReserves(game);
|
|
orderBlow(game, false, 0);
|
|
orderBlow(game, false, 1);
|
|
game = finishToMovement(game);
|
|
while (game.phase === 5) game = game.response || game.phaseOpening || game.decision
|
|
? step(game) : applyAction(game, game.active!, { type: 'endMovement' });
|
|
game = advanceToNextStorm(game);
|
|
orderBlow(game, true, 0);
|
|
orderBlow(game, false, 1);
|
|
orderBlow(game, false, 2);
|
|
let allied = false;
|
|
for (let attempt = 0; attempt < 500; attempt++) {
|
|
if (!allied && game.phase === 1 && game.nexus && !game.spiceWindow &&
|
|
!game.spiceResolution && !game.phaseOpening && !game.response && !game.decision) {
|
|
game = applyAction(game, ecaz, { type: 'alliance', target: ally });
|
|
game = applyAction(game, ally, { type: 'alliance', target: ecaz });
|
|
allied = true;
|
|
}
|
|
if (game.phase === 5 && !game.phaseOpening && !game.response && !game.decision) break;
|
|
game = step(game);
|
|
}
|
|
assert.ok(allied && game.phase === 5, 'Real Nexus formation must reach native Shipment and Movement.');
|
|
assert.equal(game.players.find(p => p.id === ecaz)!.ally, ally);
|
|
const territory = 'the_great_flat';
|
|
const sector = TERRITORIES.find(t => t.id === territory)!.sectors.find(s => s !== game.storm)!;
|
|
assert.ok(sector !== undefined);
|
|
const location = locationKey(territory, sector);
|
|
stageEcazOccupyCard(game, opponent, card => card.effect === 'karama');
|
|
while (game.phase === 5) {
|
|
if (game.response || game.phaseOpening || game.decision) {
|
|
game = step(game);
|
|
continue;
|
|
}
|
|
const actor = game.active!;
|
|
if (![ecaz, ally, opponent].includes(actor)) {
|
|
game = applyAction(game, actor, { type: 'endMovement' });
|
|
continue;
|
|
}
|
|
const player = game.players.find(p => p.id === actor)!;
|
|
const amount = actor === ecaz ? ecazForces :
|
|
actor === ally ? options.allyForces ?? 4 : options.opponentForces ?? 8;
|
|
const elite = actor === ally ? options.allyElite ?? (player.elites ? 2 : 0) :
|
|
actor === opponent ? options.opponentElite ?? 0 : 0;
|
|
const action: Action = { type: 'ship', territory, sector, amount, ...(elite ? { elite } : {}) };
|
|
game = settleEcazOccupyArrival(applyAction(game, actor, action), !!options.advisorAlly && actor === ecaz);
|
|
if (actor === ally && options.advisorAlly) {
|
|
assert.equal(player.faction, 'beneGesserit');
|
|
assert.ok(game.players.find(p => p.id === ally)!.advisors?.[territory]);
|
|
}
|
|
game = applyAction(game, actor, { type: 'endMovement' });
|
|
}
|
|
while (game.phaseOpening || game.response || game.decision) game = step(game);
|
|
assert.equal(game.phase, 6);
|
|
const choices = viewGame(game, game.active!).battleChoices;
|
|
const choice = choices.find(b => b.territory === territory)!;
|
|
assert.ok(choice, 'The real three-army territory must quote a native battle.');
|
|
const chooser = choice.chooser;
|
|
game = applyAction(game, chooser, { type: 'chooseBattle', territory,
|
|
target: choice.attacker === chooser ? choice.defender : choice.attacker });
|
|
if (!options.advisorAlly) assert.equal(game.decision?.kind, 'ecazBattleLead');
|
|
return { game, initial, afterSetup, ecaz, ally, opponent, territory, location, ecazForces };
|
|
}
|
|
|
|
/** Resolve only existing native counter windows, never create one. */
|
|
export function allowEcazOccupyResponses(state: Game): Game {
|
|
let game = state;
|
|
for (let attempt = 0; attempt < 100 && game.response; attempt++)
|
|
game = applyAction(game, game.players.find(p => !game.response!.passed.includes(p.id))!.id,
|
|
{ type: 'passResponse' });
|
|
assert.equal(game.response, null);
|
|
return game;
|
|
}
|
|
export function chooseEcazOccupyLead(fixture: EcazOccupyFixture, lead: 'ecaz' | 'ally'): Game {
|
|
const decision = fixture.game.decision;
|
|
assert.ok(decision?.kind === 'ecazBattleLead', 'Only the native Ecaz lead decision may select this plan owner.');
|
|
return applyAction(fixture.game, fixture.ecaz,
|
|
{ type: 'decision', event: decision.event, lead: lead === 'ecaz' ? fixture.ecaz : fixture.ally });
|
|
}
|
|
export function cancelEcazOccupy(fixture: EcazOccupyFixture, lead: 'ecaz' | 'ally', bg = false): Game {
|
|
let game = fixture.game;
|
|
const card = stageEcazOccupyCard(game, fixture.opponent,
|
|
c => bg ? c.kind === 'worthless' : c.effect === 'karama');
|
|
game = chooseEcazOccupyLead({ ...fixture, game }, lead);
|
|
assert.equal(game.response?.kind, 'ecazOccupy');
|
|
return applyAction(game, fixture.opponent, { type: 'card', mode: 'cancel', card });
|
|
}
|
|
export function openEcazOccupyPlans(state: Game): Game {
|
|
let game = allowEcazOccupyResponses(state);
|
|
for (let attempt = 0; attempt < 20 && game.battle?.preparation; attempt++) {
|
|
game = applyAction(game, game.battle.preparation.owner, { type: 'declineBattlePower' });
|
|
game = allowEcazOccupyResponses(game);
|
|
}
|
|
assert.ok(game.battle && !game.battle.preparation && !game.decision);
|
|
return game;
|
|
}
|
|
export function ecazOccupyTerritoryForces(game: Game, player: string, territory: string): number {
|
|
return Object.entries(game.players.find(p => p.id === player)!.forces)
|
|
.filter(([key]) => splitLocation(key).territory === territory).reduce((sum, [, n]) => sum + n, 0);
|
|
}
|