264 lines
12 KiB
TypeScript
264 lines
12 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
|
import {
|
|
advancePairedE3Nexus, createPairedE3NexusSetup, pairedE3NexusClean,
|
|
pairedE3NexusInventory, pairedE3NexusPlayer, pairedE3NexusReload,
|
|
quietPairedE3NexusChoice, stepPairedE3Nexus,
|
|
type PairedE3NexusOptions,
|
|
} from './fixture-paired-e3-nexus-modules';
|
|
|
|
export type BureaucratE3FundingStep = { actor: string; action: Action };
|
|
|
|
/** Original six/ five-seat paired E3 roster: both natives, three classics. */
|
|
export const BUREAUCRAT_E3_ROSTER = ['ecaz', 'moritani', 'guild', 'atreides', 'emperor'] as const;
|
|
/** The single classic seat trained with the native Bureaucrat skill. */
|
|
export const BUREAUCRAT_E3_TRAINER = 'atreides';
|
|
|
|
export type BureaucratE3FundingOptions = Pick<PairedE3NexusOptions,
|
|
'advanced' | 'nexus' | 'families' | 'tech' | 'strongholds'>;
|
|
export interface BureaucratE3FundingBase {
|
|
actions: BureaucratE3FundingStep[];
|
|
staging: string[];
|
|
/** Turn-2 real worm alliance window, independent of optional Nexus Cards. */
|
|
worm: Game;
|
|
/** Turn-2 Bidding boundary with the requested real mutual alliance formed. */
|
|
game: Game;
|
|
alliance: Game;
|
|
payer: string;
|
|
donor: string;
|
|
guild: string;
|
|
emperor: string;
|
|
trainer: string;
|
|
}
|
|
|
|
function frontSpice(
|
|
game: Game,
|
|
match: (card: Game['spiceDeck'][number]) => boolean,
|
|
position: number,
|
|
): void {
|
|
const at = game.spiceDeck.findIndex((c, i) => i >= position && match(c));
|
|
assert.ok(at >= position, 'An original unplayed Spice Card must remain for staging.');
|
|
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(at, 1)[0]);
|
|
}
|
|
|
|
/** Only counter-card custody is controlled here: swap an original unplayed
|
|
* card without adding a card, hand space, wallet, army, receipt or turn. */
|
|
function holdE3Card(
|
|
game: Game,
|
|
actor: string,
|
|
match: (card: Game['deck'][number]) => boolean,
|
|
staging: string[],
|
|
): string {
|
|
const owner = pairedE3NexusPlayer(game, actor);
|
|
const held = owner.hand.find(match);
|
|
if (held) return held.id;
|
|
const index = game.deck.findIndex(match);
|
|
assert.ok(index >= 0 && owner.hand.length > 0);
|
|
const outgoing = owner.hand[0];
|
|
const incoming = game.deck[index];
|
|
owner.hand[0] = incoming;
|
|
game.deck[index] = outgoing;
|
|
staging.push(
|
|
`Native response window: swap original unplayed ${incoming.id} with held ${outgoing.id} for ${actor}; retain physical cards and hand size.`,
|
|
);
|
|
return incoming.id;
|
|
}
|
|
|
|
/** Genuine paired E3 setup with skills, a real turn-2 worm alliance window and a
|
|
* mutual alliance between the payer and the requested donor. Never assigns
|
|
* wallets, armies, phase, assignment or alliance state. */
|
|
export function createBureaucratE3FundingBase(
|
|
{ donor = 'moritani', ...options }: BureaucratE3FundingOptions & { donor?: string } = {},
|
|
): BureaucratE3FundingBase {
|
|
const advanced = options.advanced ?? false;
|
|
const setup = createPairedE3NexusSetup({ ...options, advanced, roster: [...BUREAUCRAT_E3_ROSTER], skills: true });
|
|
assert.ok(setup.afterSetup.leaderSkills!.assignments.some(
|
|
(a) => a.owner === BUREAUCRAT_E3_TRAINER && a.skill === 'bureaucrat' && a.leader,
|
|
));
|
|
assert.deepEqual(setup.afterSetup.players.map((p) => p.faction), [...BUREAUCRAT_E3_ROSTER]);
|
|
const actions: BureaucratE3FundingStep[] = [];
|
|
const staging: string[] = [];
|
|
let game = pairedE3NexusReload(setup.afterSetup);
|
|
const act = (actor: string, action: Action): void => {
|
|
actions.push({ actor, action });
|
|
game = stepPairedE3Nexus(game, { actor, action });
|
|
};
|
|
// Reach turn 2's real Spice blow, then stage only original unplayed Spice
|
|
// Cards so the Great Maker reveals a worm and a Nexus forms.
|
|
game = advancePairedE3Nexus(game, (s) => s.turn === 2 && s.phase === 1 && pairedE3NexusClean(s), actions, quietPairedE3NexusChoice);
|
|
if (advanced) for (let i = 1; i <= 2; i++) frontSpice(game, (c) => 'territory' in c && !c.discovery, i);
|
|
frontSpice(game, (c) => 'worm' in c && !c.greatMaker && !c.suppressed, 0);
|
|
staging.push('Reorder only original still-unplayed Spice Cards so the turn-2 Great Maker reveals a worm.');
|
|
game = advancePairedE3Nexus(game, (s) => !!s.nexus && !s.spiceWindow && !s.spiceResolution && pairedE3NexusClean(s), actions, quietPairedE3NexusChoice);
|
|
const worm = pairedE3NexusReload(game);
|
|
assert.ok(pairedE3NexusPlayer(worm, 'ecaz').ally === null);
|
|
act('ecaz', { type: 'alliance', target: donor });
|
|
act(donor, { type: 'alliance', target: 'ecaz' });
|
|
assert.equal(pairedE3NexusPlayer(game, 'ecaz').ally, donor);
|
|
assert.equal(pairedE3NexusPlayer(game, donor).ally, 'ecaz');
|
|
const alliance = pairedE3NexusReload(game);
|
|
game = advancePairedE3Nexus(game, (s) => s.turn === 2 && s.phase === 3 && pairedE3NexusClean(s), actions, quietPairedE3NexusChoice);
|
|
pairedE3NexusInventory(game);
|
|
return {
|
|
actions, staging, worm, game, alliance,
|
|
payer: 'ecaz', donor, guild: 'guild', emperor: 'emperor', trainer: BUREAUCRAT_E3_TRAINER,
|
|
};
|
|
}
|
|
|
|
/** Pass the original income/counter windows without manufacturing a receipt. */
|
|
export function allowE3Responses(state: Game): Game {
|
|
let game = state;
|
|
for (let n = 0; game.response && n < 40; n++) {
|
|
const actor = game.players.find((p) => {
|
|
const controls = viewGame(game, p.id).responseControls;
|
|
return controls && !controls.hasPassed && controls.cancelCards.length > 0;
|
|
});
|
|
assert.ok(actor, 'An original unpassed counter holder must remain');
|
|
game = applyAction(game, actor.id, { type: 'passResponse' });
|
|
}
|
|
assert.equal(game.response, null);
|
|
return game;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Ordinary auction whose original payee is the Emperor.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface BureaucratE3AuctionFixture extends BureaucratE3FundingBase {
|
|
beforePledge: Game;
|
|
beforeAction: Game;
|
|
card: string;
|
|
amount: number;
|
|
payerShare: number;
|
|
donorShare: number;
|
|
action: Action;
|
|
payee: string;
|
|
counterCard?: string;
|
|
physical: string[];
|
|
}
|
|
|
|
/** Only original unplayed-card custody is controlled here. */
|
|
export function bureaucratE3PhysicalCards(game: Game): string[] {
|
|
const auction = game.auction;
|
|
const remaining = auction
|
|
? auction.cards.slice(auction.index + Number(game.currentAuctionSale?.origin === 'normal'))
|
|
: [];
|
|
return [...game.deck, ...game.discard, ...game.players.flatMap((p) => p.hand), ...remaining]
|
|
.map((card) => card.id).sort();
|
|
}
|
|
|
|
export function createBureaucratE3AuctionFixture({
|
|
payerShare = 5, donorShare = 3, donor = 'moritani', counter = false, ...options
|
|
}: BureaucratE3FundingOptions & {
|
|
payerShare?: number; donorShare?: number; donor?: string; counter?: boolean;
|
|
} = {}): BureaucratE3AuctionFixture {
|
|
const base = createBureaucratE3FundingBase({ ...options, donor });
|
|
const actions = [...base.actions];
|
|
const staging = [...base.staging];
|
|
let game = pairedE3NexusReload(base.game);
|
|
// Spend the original passes until the payer's genuine bid is next.
|
|
for (let n = 0; game.auction!.active !== base.payer && n < game.players.length; n++) {
|
|
const action: Action = { type: 'passBid' };
|
|
actions.push({ actor: game.auction!.active, action });
|
|
game = applyAction(game, game.auction!.active, action);
|
|
}
|
|
assert.equal(game.auction!.active, base.payer);
|
|
const physical = bureaucratE3PhysicalCards(game);
|
|
const counterCard = counter ? holdE3Card(game, base.trainer, (card) => card.effect === 'karama', staging) : undefined;
|
|
const beforePledge = pairedE3NexusReload(game);
|
|
const pledge: Action = { type: 'pledgeAid', amount: donorShare };
|
|
actions.push({ actor: donor, action: pledge });
|
|
game = applyAction(game, donor, pledge);
|
|
assert.equal(game.aid[donor].amount, donorShare);
|
|
const beforeAction = pairedE3NexusReload(game);
|
|
const card = game.auction!.cards[game.auction!.index].id;
|
|
const amount = payerShare + donorShare;
|
|
const action: Action = { type: 'bid', amount, allyPayment: donorShare };
|
|
return { ...base, actions, staging, game, physical, counterCard,
|
|
beforePledge, beforeAction, card, amount, payerShare, donorShare, action, payee: base.emperor };
|
|
}
|
|
|
|
/** Commit the declared bid. Passes the remaining original bidders. */
|
|
export function payBureaucratE3Auction(fixture: BureaucratE3AuctionFixture, state = fixture.game): Game {
|
|
const index = state.auction!.index;
|
|
let game = applyAction(state, fixture.payer, fixture.action);
|
|
for (let n = 0; !game.currentAuctionSale && game.auction?.index === index && n < game.players.length + 2; n++) {
|
|
if (game.decision?.kind === 'auctionPayment') {
|
|
game = applyAction(game, fixture.payer, { type: 'decision', karama: false });
|
|
break;
|
|
}
|
|
assert.ok(!game.decision && !game.response);
|
|
game = applyAction(game, game.auction!.active, { type: 'passBid' });
|
|
}
|
|
return game;
|
|
}
|
|
|
|
export function chooseBureaucratE3Payment(game: Game, redirect = true): Game {
|
|
assert.equal(game.decision?.kind, 'bureaucratPayment');
|
|
return applyAction(game, game.decision!.player, { type: 'decision', event: game.bureaucratPaymentEvent, redirect });
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Ordinary shipment whose original payee is the Guild.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface BureaucratE3ShipmentFixture extends BureaucratE3FundingBase {
|
|
beforePledge: Game;
|
|
beforeAction: Game;
|
|
target: string;
|
|
action: Action;
|
|
primary: number;
|
|
donorAmount: number;
|
|
amount: number;
|
|
payee: string;
|
|
cost: number;
|
|
counterCard?: string;
|
|
physical: string[];
|
|
}
|
|
|
|
export function createBureaucratE3ShipmentFixture({
|
|
primary = 5, partner = 'moritani', pledge = 3, target = 'habbanya_ridge_sietch', counter = false, ...options
|
|
}: BureaucratE3FundingOptions & {
|
|
primary?: number; partner?: string; pledge?: number;
|
|
target?: 'habbanya_ridge_sietch' | 'habbanya_erg'; counter?: boolean;
|
|
} = {}): BureaucratE3ShipmentFixture {
|
|
const base = createBureaucratE3FundingBase({ ...options, donor: partner });
|
|
const actions = [...base.actions];
|
|
const staging = [...base.staging];
|
|
let game = pairedE3NexusReload(base.game);
|
|
game = advancePairedE3Nexus(game, (s) => s.turn === 2 && s.phase === 5 && s.active === base.payer && pairedE3NexusClean(s), actions, quietPairedE3NexusChoice);
|
|
assert.equal(game.turn, 2);
|
|
assert.ok(game.players.every((p) => !Object.keys(p.forces).some((key) => key.startsWith(target))));
|
|
const physical = bureaucratE3PhysicalCards(game);
|
|
const counterCard = counter ? holdE3Card(game, base.trainer, (card) => card.effect === 'karama', staging) : undefined;
|
|
const beforePledge = pairedE3NexusReload(game);
|
|
const aid: Action = { type: 'pledgeAid', amount: pledge };
|
|
actions.push({ actor: partner, action: aid });
|
|
game = applyAction(game, partner, aid);
|
|
assert.equal(game.aid[partner].amount, pledge);
|
|
const beforeAction = pairedE3NexusReload(game);
|
|
const cost = primary + pledge;
|
|
const amount = cost / (target === 'habbanya_erg' ? 2 : 1);
|
|
assert.ok(Number.isInteger(amount) && amount > 0, 'The declared shares pay the exact original shipment cost');
|
|
const action: Action = { type: 'ship', territory: target, sector: 17, amount, elite: 0, allyPayment: pledge };
|
|
return { ...base, actions, staging, game, physical, counterCard,
|
|
beforePledge, beforeAction, target, action, primary, donorAmount: pledge, amount, cost, payee: base.guild };
|
|
}
|
|
|
|
/** Declare once. In Advanced the original Guild interception commits the ship. */
|
|
export function declareBureaucratE3Shipment(
|
|
fixture: BureaucratE3ShipmentFixture,
|
|
): { game: Game; declared: Game; paid: Game; actions: BureaucratE3FundingStep[] } {
|
|
const actions: BureaucratE3FundingStep[] = [];
|
|
let game = applyAction(pairedE3NexusReload(fixture.beforeAction), fixture.payer, fixture.action);
|
|
actions.push({ actor: fixture.payer, action: fixture.action });
|
|
const declared = pairedE3NexusReload(game);
|
|
if (game.decision?.kind === 'guildShipment') {
|
|
const actor = game.decision.player;
|
|
const action: Action = { type: 'decision', allow: true };
|
|
actions.push({ actor, action });
|
|
game = applyAction(game, actor, action);
|
|
}
|
|
return { game, declared, paid: pairedE3NexusReload(game), actions };
|
|
}
|