Apply the Advanced retained-fighter target cutover through shared quotes and controls. Preserve committed foreign shipments while handling free-packet shipping, Polar matching, actual placement and original arrival once.
242 lines
13 KiB
TypeScript
242 lines
13 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import {
|
|
applyAction, createGame, initializeFactionExpansionsGameForAudit, joinGame,
|
|
newPlayer, type Action, type Game,
|
|
} from '../game/engine';
|
|
import { nextRicheseSkillsTechNativeStep } from './fixture-richese-skills-tech';
|
|
|
|
export type BgAmbassadorArrivalStep = { actor: string; action: Action };
|
|
export type BgAmbassadorArrivalFixture = {
|
|
initial: Game;
|
|
afterSetup: Game;
|
|
beforePlacement: Game;
|
|
beforeShipment: Game;
|
|
game: Game;
|
|
actions: BgAmbassadorArrivalStep[];
|
|
owner: string;
|
|
advisor: string;
|
|
entrant: string;
|
|
territory: string;
|
|
location: string;
|
|
token: string;
|
|
fighters: boolean;
|
|
shipmentAction: Action;
|
|
movementAction: Action;
|
|
staging: string[];
|
|
};
|
|
|
|
export function bgAmbassadorArrivalPlayer(game: Game, actor: string) {
|
|
const player = game.players.find(player => player.id === actor);
|
|
assert.ok(player, `Missing original BG arrival seat ${actor}`);
|
|
return player;
|
|
}
|
|
const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision && !game.pendingTreacheryDiscard;
|
|
|
|
/** Native entropy only: original shuffles/deals retain their physical inventories.
|
|
* No hand, force, wallet, stance, turn, receipt or decision is assigned. */
|
|
function entropy<T>(run: () => T): T {
|
|
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
|
|
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
|
|
assert.ok(array);
|
|
new Uint8Array(array.buffer, array.byteOffset, array.byteLength).fill(255);
|
|
return array;
|
|
};
|
|
try { return run(); }
|
|
finally {
|
|
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
|
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
|
}
|
|
}
|
|
|
|
/** Original no-spend/no-call driver, including the native owned BG controls. */
|
|
export function nextBgAmbassadorArrivalStep(game: Game): BgAmbassadorArrivalStep {
|
|
if (!game.phaseOpening && !game.response) {
|
|
const bg = game.players.find(player => player.faction === 'beneGesserit')!;
|
|
const ecaz = game.players.find(player => player.faction === 'ecaz')!;
|
|
if (game.status === 'setup' && game.setupStage === 'prediction')
|
|
return { actor: bg.id, action: { type: 'predict', faction: 'emperor', turn: 10 } };
|
|
if (game.status === 'setup' && game.setupStage === 'forces') {
|
|
if (ecaz.reserves === 20)
|
|
return { actor: ecaz.id, action: { type: 'ecazSetup', placements: { 'imperial_basin:10': 6 } } };
|
|
if (!bg.advisorSetup)
|
|
return { actor: bg.id, action: { type: 'advisorSetup', territory: 'imperial_basin', sector: 10 } };
|
|
}
|
|
const decision = game.decision;
|
|
if (decision?.kind === 'ecazPlacement')
|
|
return { actor: decision.player, action: { type: 'decision', decline: true } };
|
|
if (decision?.kind === 'advisor' || decision?.kind === 'intrusion' || decision?.kind === 'advisorBattle')
|
|
return { actor: decision.player, action: { type: 'decision', accept: false } };
|
|
if (decision?.kind === 'ecazAmbassador') {
|
|
assert.ok(game.pendingAmbassador);
|
|
return { actor: decision.player, action: { type: 'decision', event: game.pendingAmbassador.event, decline: true } };
|
|
}
|
|
}
|
|
const step = nextRicheseSkillsTechNativeStep(game);
|
|
if (step.action.type === 'stormDial') step.action.amount = 0;
|
|
return step;
|
|
}
|
|
|
|
export function applyBgAmbassadorArrivalStep(
|
|
game: Game, step: BgAmbassadorArrivalStep, actions?: BgAmbassadorArrivalStep[],
|
|
): Game {
|
|
actions?.push(structuredClone(step));
|
|
return entropy(() => applyAction(game, step.actor, step.action));
|
|
}
|
|
|
|
function advance(game: Game, until: (state: Game) => boolean, actions: BgAmbassadorArrivalStep[]): Game {
|
|
for (let n = 0; n < 2400; n++) {
|
|
if (until(game)) return game;
|
|
assert.notEqual(game.status, 'finished', 'Original BG arrival must remain in its native game');
|
|
game = applyBgAmbassadorArrivalStep(game, nextBgAmbassadorArrivalStep(game), actions);
|
|
}
|
|
throw Error('Original native BG arrival did not reach the requested boundary');
|
|
}
|
|
|
|
/** Allow response/acknowledgement windows, including a committed Spiritual
|
|
* arrival's automatic conversion. Never choose Intrusion, accompaniment or an
|
|
* Ambassador offer; tests stop at those original owned decisions. */
|
|
export function settleBgAmbassadorArrivalControls(game: Game, actions: BgAmbassadorArrivalStep[] = []): Game {
|
|
return advance(game, state => !state.response && !state.phaseOpening && !state.pendingTreacheryDiscard, actions);
|
|
}
|
|
|
|
/** Controlled already-declared legacy control, not fresh accompaniment permission.
|
|
* Keep the earned shipment, parent, forces, cards and wallets; replace only its
|
|
* current advisor chooser with the old recorded response shape, without the new
|
|
* Spiritual source. This isolates the retained callback protocol. */
|
|
export function recordControlledLegacyBgAdvisorResponse(state: Game): Game {
|
|
const decision = state.decision;
|
|
assert.ok(decision?.kind === 'advisor' && decision.destination);
|
|
assert.ok(!state.response && !state.pendingSpiritualAdvisor);
|
|
const game = structuredClone(state);
|
|
game.decision = null;
|
|
game.response = {
|
|
kind: 'advisor', owner: decision.player, location: decision.destination,
|
|
amount: 1, passed: [],
|
|
...(decision.ambassadorEvent === undefined ? {} : {
|
|
advisorResume: 'ambassador' as const, advisorAmbassadorEvent: decision.ambassadorEvent,
|
|
}),
|
|
};
|
|
assert.equal(game.response.spiritualAdvisorSource, undefined);
|
|
return game;
|
|
}
|
|
|
|
function orderLandBlows(game: Game, staging: string[]) {
|
|
for (let at = 0; at < 2; at++) {
|
|
const index = game.spiceDeck.findIndex((card, index) => index >= at && 'territory' in card &&
|
|
!['imperial_basin', 'carthag'].includes(card.territory));
|
|
assert.ok(index >= at, 'Two original undealt land blows must remain');
|
|
const card = game.spiceDeck.splice(index, 1)[0];
|
|
game.spiceDeck.splice(at, 0, card);
|
|
assert.ok('territory' in card);
|
|
staging.push(`Turn ${game.turn}: original undealt ${card.territory} land blow moved to slot ${at + 1}.`);
|
|
}
|
|
}
|
|
|
|
/** Original Advanced Ecaz/BG/Emperor setup and paid entry, stopped BEFORE ship.
|
|
* In the fighter variant the original Imperial Basin advisor actually moves to
|
|
* empty Carthag on turn one, becoming a fighter by the native automatic rule.
|
|
* Ecaz leaves turn-one placement unused, then marks Carthag on turn two: no
|
|
* preceding BG Ambassador trigger or fabricated fighter group is needed.
|
|
* The only controlled values are producer entropy, a conserved undealt Karama
|
|
* starting-deal slot and unplayed Spice order. Supplied fresh lobbies retain
|
|
* their original seat identities; no HTTP authentication claim is made. */
|
|
export function createBgAmbassadorArrivalFixture(options: { initial?: Game; fighters?: boolean } = {}): BgAmbassadorArrivalFixture {
|
|
let game = options.initial ? structuredClone(options.initial)
|
|
: createGame('BGAMBASSADORARRIVAL', newPlayer('bg', 'Bene Gesserit', 'beneGesserit'), true, ['ecaz']);
|
|
if (!options.initial) {
|
|
joinGame(game, newPlayer('ecaz', 'Ecaz', 'ecaz'));
|
|
joinGame(game, newPlayer('emperor', 'Emperor', 'emperor'));
|
|
}
|
|
assert.equal(game.status, 'lobby', 'Only an original undealt lobby can enter');
|
|
assert.equal(game.advanced, true);
|
|
assert.deepEqual(game.expansions, ['ecaz']);
|
|
assert.equal(game.players.length, 3);
|
|
const owner = game.players.find(player => player.faction === 'ecaz')?.id;
|
|
const advisor = game.players.find(player => player.faction === 'beneGesserit')?.id;
|
|
const entrant = game.players.find(player => player.faction === 'emperor')?.id;
|
|
assert.ok(owner && advisor && entrant, 'Original seats must be Ecaz, Bene Gesserit and Emperor');
|
|
const initial = structuredClone(game), actions: BgAmbassadorArrivalStep[] = [], staging: string[] = [];
|
|
for (const player of game.players)
|
|
if (!player.ready) game = applyBgAmbassadorArrivalStep(game, { actor: player.id, action: { type: 'ready' } }, actions);
|
|
game = entropy(() => initializeFactionExpansionsGameForAudit(game));
|
|
// Give Ecaz a real cancellation opportunity through its ORIGINAL starting
|
|
// deal, not a card transfer into an already dealt hand.
|
|
const dealSlot = game.players.findIndex(player => player.id === owner);
|
|
const karamaIndex = game.deck.findIndex(card => card.effect === 'karama');
|
|
assert.ok(karamaIndex >= 0 && game.players.every(player => !player.hand.length));
|
|
const karama = game.deck[karamaIndex].id;
|
|
[game.deck[dealSlot], game.deck[karamaIndex]] = [game.deck[karamaIndex], game.deck[dealSlot]];
|
|
staging.push(`Original undealt ${karama} swapped into Ecaz's starting-deal slot ${dealSlot + 1}; no dealt hand is changed.`);
|
|
game = advance(game, state => state.status === 'playing', actions);
|
|
const afterSetup = structuredClone(game);
|
|
assert.ok(bgAmbassadorArrivalPlayer(game, owner).hand.some(card => card.id === karama));
|
|
assert.equal(bgAmbassadorArrivalPlayer(game, advisor).forces['imperial_basin:10'], 1);
|
|
assert.ok(bgAmbassadorArrivalPlayer(game, advisor).advisors?.imperial_basin);
|
|
game = advance(game, state => state.phase === 1 && clean(state), actions);
|
|
orderLandBlows(game, staging);
|
|
const fighters = options.fighters ?? false;
|
|
if (fighters) {
|
|
game = advance(game, state => state.phase === 5 && state.active === advisor && clean(state), actions);
|
|
game = applyBgAmbassadorArrivalStep(game, { actor: advisor, action: {
|
|
type: 'move', from: 'imperial_basin:10', territory: 'carthag', sector: 11, amount: 1,
|
|
} }, actions);
|
|
game = settleBgAmbassadorArrivalControls(game, actions);
|
|
assert.ok(clean(game));
|
|
assert.equal(bgAmbassadorArrivalPlayer(game, advisor).forces['carthag:11'], 1);
|
|
assert.equal(bgAmbassadorArrivalPlayer(game, advisor).advisors?.carthag, undefined);
|
|
game = advance(game, state => state.turn === 2 && state.phase === 1 && clean(state), actions);
|
|
orderLandBlows(game, staging);
|
|
}
|
|
game = advance(game, state => state.phase === 4 && state.decision?.kind === 'ecazPlacement', actions);
|
|
const beforePlacement = structuredClone(game);
|
|
const token = game.ecazAmbassadors!.tokens.find(token => token.effect === 'ecaz')!;
|
|
assert.equal(token.zone, 'supply');
|
|
const placementWallet = bgAmbassadorArrivalPlayer(game, owner).spice;
|
|
game = applyBgAmbassadorArrivalStep(game, { actor: owner, action: {
|
|
type: 'decision', token: token.id, territory: 'carthag',
|
|
} }, actions);
|
|
game = settleBgAmbassadorArrivalControls(game, actions);
|
|
assert.equal(bgAmbassadorArrivalPlayer(game, owner).spice, placementWallet - 1);
|
|
assert.equal(game.ecazAmbassadors!.tokens.find(candidate => candidate.id === token.id)!.location, 'carthag');
|
|
assert.equal(game.decision?.kind, 'ecazPlacement');
|
|
game = applyBgAmbassadorArrivalStep(game, { actor: owner, action: { type: 'decision', decline: true } }, actions);
|
|
game = advance(game, state => state.phase === 5 && state.active === entrant && clean(state), actions);
|
|
assert.equal(bgAmbassadorArrivalPlayer(game, advisor).forces['carthag:11'] ?? 0, fighters ? 1 : 0);
|
|
assert.ok(!game.pendingAmbassador && !game.pendingArrivalReaction?.length);
|
|
const beforeShipment = structuredClone(game);
|
|
return {
|
|
initial, afterSetup, beforePlacement, beforeShipment, game, actions, owner, advisor, entrant,
|
|
territory: 'carthag', location: 'carthag:11', token: token.id, fighters, staging,
|
|
shipmentAction: { type: 'ship', territory: 'carthag', sector: 11, amount: 1, elite: 0, allyPayment: 0 },
|
|
movementAction: { type: 'move', from: 'carthag:11', territory: 'arsunt', sector: 11, amount: 1 },
|
|
};
|
|
}
|
|
|
|
/** First bad transition is intentionally outside the original builder. */
|
|
export function beginBgAmbassadorArrival(fixture: BgAmbassadorArrivalFixture): Game {
|
|
return settleBgAmbassadorArrivalControls(applyBgAmbassadorArrivalStep(fixture.game, {
|
|
actor: fixture.entrant, action: fixture.shipmentAction,
|
|
}));
|
|
}
|
|
|
|
export function bgAmbassadorAccompanimentAction(accept: boolean, accompany = true): Action {
|
|
return accept ? { type: 'decision', accept: true, accompany, amount: 1 }
|
|
: { type: 'decision', accept: false };
|
|
}
|
|
export function bgAmbassadorIntrusionAction(accept: boolean): Action {
|
|
return { type: 'decision', accept };
|
|
}
|
|
export function bgAmbassadorOfferAction(game: Game, trigger = false): Action {
|
|
const entry = game.pendingAmbassador;
|
|
assert.ok(entry);
|
|
return trigger ? { type: 'decision', event: entry.event, trigger: true, beneficiary: entry.owner, choice: 'duke' }
|
|
: { type: 'decision', event: entry.event, decline: true };
|
|
}
|
|
export function finishBgAmbassadorArrival(game: Game, trigger = false): Game {
|
|
const entry = game.pendingAmbassador;
|
|
assert.ok(entry);
|
|
return settleBgAmbassadorArrivalControls(applyBgAmbassadorArrivalStep(game, {
|
|
actor: entry.owner, action: bgAmbassadorOfferAction(game, trigger),
|
|
}));
|
|
}
|