Connect native paired E3 Bureaucrat funding

This commit is contained in:
Quintine committed 2026-10-13 09:32:36 +11:00
1 parent b207dd0479
commit ac64adaa3f
8 files changed
+677 -8

No files matched your search

+306
View File
@@ -0,0 +1,306 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { advancePairedE3Nexus, quietPairedE3NexusChoice, pairedE3NexusClean } from './fixture-paired-e3-nexus-modules';
import {
allowE3Responses, bureaucratE3PhysicalCards, chooseBureaucratE3Payment,
createBureaucratE3AuctionFixture, createBureaucratE3ShipmentFixture,
declareBureaucratE3Shipment, payBureaucratE3Auction,
type BureaucratE3AuctionFixture, type BureaucratE3ShipmentFixture,
} from './fixture-bureaucrat-e3-funding';
const seat = (game: Game, id: string) => game.players.find((p) => p.id === id)!;
/** Real worm-alliance partners whose contributions cannot qualify. */
const ALLIANCE = { trainer: 'atreides', auctionPayee: 'emperor' } as const;
const counters = (game: Game) => game.players.map((p) => ({ id: p.id, forces: p.forces,
reserves: p.reserves, tanks: p.tanks, elites: p.elites }));
const hand = (game: Game, id: string) => seat(game, id).hand.map((card) => card.id);
function rejectUnchanged(game: Game, actor: string, action: Action): void {
const before = structuredClone(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before);
}
/** Full declared shares leave the payer wallet before any Bureaucrat choice. */
function assertAuctionCosts(f: BureaucratE3AuctionFixture, game: Game): void {
assert.equal(seat(game, f.payer).spice, seat(f.beforePledge, f.payer).spice - f.payerShare);
assert.equal(game.aid[f.donor]?.amount ?? 0, 0);
assert.deepEqual(bureaucratE3PhysicalCards(game), f.physical);
}
function assertAuctionOutcome(f: BureaucratE3AuctionFixture, done: Game, diverted: number): void {
assertAuctionCosts(f, done);
const donorLeg = f.donor === f.payee ? f.donorShare : 0;
assert.equal(seat(done, f.payee).spice,
seat(f.beforePledge, f.payee).spice - donorLeg + f.amount - diverted);
assert.equal(seat(done, f.donor).spice,
seat(f.beforePledge, f.donor).spice - f.donorShare + (f.donor === f.payee ? f.amount - diverted : 0));
assert.deepEqual(hand(done, f.payer), [...hand(f.beforeAction, f.payer), f.card]);
assert.equal(done.currentAuctionSale, null);
assert.equal(done.bureaucratPayments?.used.length ?? 0, diverted ? 1 : 0);
assert.equal(viewGame(done, f.trainer).bureaucrat.usedThisPhase, diverted === 2);
assert.equal(done.auction!.index, f.beforeAction.auction!.index + 1);
}
function assertShipmentCosts(f: BureaucratE3ShipmentFixture, game: Game): void {
assert.equal(seat(game, f.payer).spice, seat(f.beforePledge, f.payer).spice - f.primary);
assert.equal(seat(game, f.donor).spice, seat(f.beforePledge, f.donor).spice - f.donorAmount);
assert.equal(game.aid[f.donor]?.amount ?? 0, 0);
assert.equal(seat(game, f.payer).reserves, seat(f.beforePledge, f.payer).reserves - f.amount);
assert.deepEqual(seat(game, f.payer).forces,
{ ...seat(f.beforePledge, f.payer).forces, [`${f.target}:17`]: f.amount });
assert.equal(seat(game, f.payer).shipped, true);
for (const id of f.game.players.map((p) => p.id).filter((id) => id !== f.payer)) {
assert.deepEqual(seat(game, id).forces, seat(f.beforePledge, id).forces);
assert.equal(seat(game, id).reserves, seat(f.beforePledge, id).reserves);
}
assert.deepEqual(bureaucratE3PhysicalCards(game), f.physical);
}
for (const advanced of [false, true])
for (const [payerShare, donorShare] of [[5, 3], [3, 5], [5, 5], [0, 5]])
for (const redirect of [false, true]) {
void test(`${advanced ? 'Advanced' : 'Basic'} paired E3 native auction ${payerShare}+${donorShare}: ${redirect ? 'Bank two' : 'full Emperor income'}, one purchase and original next lot`, () => {
const f = createBureaucratE3AuctionFixture({ advanced, payerShare, donorShare });
assert.equal(seat(f.beforeAction, f.payer).spice, seat(f.beforePledge, f.payer).spice);
assert.equal(seat(f.beforeAction, f.donor).spice, seat(f.beforePledge, f.donor).spice - donorShare);
assert.equal(f.beforeAction.aid[f.donor].amount, donorShare);
assert.deepEqual(counters(f.beforeAction), counters(f.beforePledge));
const paid = payBureaucratE3Auction(f);
assert.equal(paid.currentAuctionSale?.winner, f.payer);
assert.equal(paid.currentAuctionSale?.amount, f.amount);
assert.equal(paid.currentAuctionSale?.seller, null);
assertAuctionCosts(f, paid);
assert.equal(seat(paid, f.donor).spice, seat(f.beforePledge, f.donor).spice - donorShare);
const pending = allowE3Responses(paid);
assert.equal(pending.decision?.kind, 'bureaucratPayment');
assert.equal(pending.decision?.player, f.trainer);
assert.deepEqual(counters(pending), counters(paid));
assert.deepEqual(hand(pending, f.payer), hand(paid, f.payer));
const done = allowE3Responses(chooseBureaucratE3Payment(pending, redirect));
assertAuctionOutcome(f, done, redirect ? 2 : 0);
assert.deepEqual(counters(done), counters(pending));
assert.equal(done.bureaucratPayments?.pending, undefined);
rejectUnchanged(done, f.trainer, { type: 'decision', event: pending.bureaucratPaymentEvent, redirect: true });
rejectUnchanged(done, f.payer, f.action);
});
}
for (const advanced of [false, true])
for (const [primary, pledge] of [[5, 3], [3, 5], [5, 5], [0, 5]])
for (const redirect of [false, true]) {
void test(`${advanced ? 'Advanced' : 'Basic'} paired E3 native shipment ${primary}+${pledge}: ${redirect ? 'Bank two' : 'full Guild income'}, army commitment and Movement continuation`, () => {
const f = createBureaucratE3ShipmentFixture({ advanced, primary, pledge });
assert.equal(f.game.turn, 2);
assert.equal(f.game.phase, 5);
assert.equal(f.game.active, f.payer);
assert.equal(seat(f.beforeAction, f.donor).spice, seat(f.beforePledge, f.donor).spice - pledge);
assert.equal(f.beforeAction.aid[f.donor].amount, pledge);
assert.deepEqual(counters(f.beforeAction), counters(f.beforePledge));
const { game: paid, declared } = declareBureaucratE3Shipment(f);
if (advanced) {
assert.equal(declared.decision?.kind, 'guildShipment');
assert.equal(declared.decision?.player, f.guild);
assert.deepEqual(counters(declared), counters(f.beforeAction));
assert.deepEqual(seat(declared, f.payer).hand, seat(f.beforeAction, f.payer).hand);
}
assertShipmentCosts(f, paid);
const pending = allowE3Responses(paid);
assert.equal(pending.decision?.kind, 'bureaucratPayment');
assert.equal(pending.decision?.player, f.trainer);
assert.deepEqual(counters(pending), counters(paid));
assert.deepEqual(hand(pending, f.payer), hand(paid, f.payer));
const done = allowE3Responses(chooseBureaucratE3Payment(pending, redirect));
assert.equal(seat(done, f.guild).spice, seat(f.beforePledge, f.guild).spice + f.amount - (redirect ? 2 : 0));
for (const id of [f.payer, f.donor, f.trainer, f.emperor]) {
assert.equal(seat(done, id).spice, seat(paid, id).spice);
assert.equal(seat(done, id).bribes, seat(paid, id).bribes);
}
assert.equal(done.bureaucratPayments?.used.length ?? 0, redirect ? 1 : 0);
assertShipmentCosts(f, done);
assert.deepEqual(counters(done), counters(paid));
assert.equal(done.phase, 5);
assert.equal(done.active, f.payer);
rejectUnchanged(done, f.trainer, { type: 'decision', event: pending.bureaucratPaymentEvent, redirect: true });
const ended = applyAction(structuredClone(done), f.payer, { type: 'endMovement' });
assert.notEqual(ended.active, f.payer);
assert.deepEqual(counters(ended), counters(done));
});
}
for (const advanced of [false, true]) {
void test(`${advanced ? 'Advanced' : 'Basic'} paired E3 native receipts never pool sub-five shares`, () => {
const auction = createBureaucratE3AuctionFixture({ advanced, payerShare: 3, donorShare: 3 });
const auctionDone = allowE3Responses(payBureaucratE3Auction(auction));
assert.notEqual(auctionDone.decision?.kind, 'bureaucratPayment');
assert.equal(auctionDone.bureaucratPayments?.used.length ?? 0, 0);
assert.equal(seat(auctionDone, auction.emperor).spice, seat(auction.beforePledge, auction.emperor).spice + 6);
assertAuctionOutcome(auction, auctionDone, 0);
const shipment = createBureaucratE3ShipmentFixture({ advanced, primary: 3, pledge: 3 });
const shipmentDone = allowE3Responses(declareBureaucratE3Shipment(shipment).paid);
assert.notEqual(shipmentDone.decision?.kind, 'bureaucratPayment');
assert.equal(shipmentDone.bureaucratPayments?.used.length ?? 0, 0);
assert.equal(seat(shipmentDone, shipment.guild).spice, seat(shipment.beforePledge, shipment.guild).spice + 6);
assertShipmentCosts(shipment, shipmentDone);
});
void test(`${advanced ? 'Advanced' : 'Basic'} original Emperor Karama cancels the gross paired auction income to Bank without a Bureaucrat use`, () => {
const f = createBureaucratE3AuctionFixture({ advanced, payerShare: 5, donorShare: 5, counter: true });
assert.ok(f.staging.some((line) => line.includes('swap original unplayed')));
const paid = payBureaucratE3Auction(f);
assert.equal(paid.response?.kind, 'emperorIncome');
assert.equal(paid.response?.owner, f.emperor);
assert.equal(viewGame(paid, f.trainer).responseControls?.cancelCards.includes(f.counterCard!), true);
assertAuctionCosts(f, paid);
const done = allowE3Responses(applyAction(paid, f.trainer, { type: 'card', card: f.counterCard!, mode: 'cancel' }));
assert.equal(seat(done, f.emperor).spice, seat(f.beforeAction, f.emperor).spice);
assert.deepEqual(hand(done, f.payer), [...hand(f.beforeAction, f.payer), f.card]);
assert.equal(done.discard.filter((card) => card.id === f.counterCard).length, 1);
assert.equal(done.players.some((p) => p.hand.some((card) => card.id === f.counterCard)), false);
assert.equal(done.bureaucratPayments?.used.length ?? 0, 0);
assert.equal(viewGame(done, f.trainer).bureaucrat.pending, null);
assert.equal(done.auction!.index, f.beforeAction.auction!.index + 1);
assertAuctionCosts(f, done);
});
void test(`${advanced ? 'Advanced' : 'Basic'} paired E3 pending invoices expose only the public gross and payee`, () => {
const primary = createBureaucratE3AuctionFixture({ advanced, payerShare: 5, donorShare: 3 });
const donor = createBureaucratE3AuctionFixture({ advanced, payerShare: 3, donorShare: 5 });
const left = allowE3Responses(payBureaucratE3Auction(primary));
const right = allowE3Responses(payBureaucratE3Auction(donor));
for (const viewer of [primary.trainer, primary.guild, primary.emperor]) {
const invoice = viewGame(left, viewer).bureaucrat.pending!;
const other = viewGame(right, viewer).bureaucrat.pending!;
assert.equal(invoice.payer, null);
assert.equal(invoice.payee, primary.emperor);
assert.equal(invoice.amount, 8);
assert.equal(invoice.kind, 'auction');
assert.equal(invoice.redirect, 2);
assert.equal(invoice.owner, primary.trainer);
assert.equal('contributions' in invoice, false);
assert.equal('signature' in invoice, false);
assert.equal(other.amount, 8);
assert.equal('contributions' in other, false);
}
const shipment = createBureaucratE3ShipmentFixture({ advanced, primary: 5, pledge: 5 });
const pending = allowE3Responses(declareBureaucratE3Shipment(shipment).paid);
for (const viewer of pending.players) {
const invoice = viewGame(pending, viewer.id).bureaucrat.pending!;
assert.equal(invoice.payer, null);
assert.equal(invoice.payee, shipment.guild);
assert.equal(invoice.amount, 10);
assert.equal(invoice.kind, 'shipment');
assert.equal(invoice.owner, shipment.trainer);
assert.equal('contributions' in invoice, false);
assert.equal('signature' in invoice, false);
assert.equal('bureaucratPayments' in viewGame(pending, viewer.id), false);
assert.equal('bureaucratUseEvents' in viewGame(pending, viewer.id), false);
}
});
void test(`${advanced ? 'Advanced' : 'Basic'} paired E3 trainer and payee contributions cannot qualify or mask a real payer`, () => {
for (const donor of [ALLIANCE.trainer, ALLIANCE.auctionPayee]) {
const allowed = createBureaucratE3AuctionFixture({ advanced, payerShare: 5, donorShare: 5, donor });
assert.equal(allowed.worm.phase, 1);
assert.equal(allowed.worm.nexus, true);
assert.ok(allowed.worm.players.every((p) => p.ally === null));
assert.equal(seat(allowed.alliance, allowed.payer).ally, allowed.donor);
assert.equal(seat(allowed.alliance, allowed.donor).ally, allowed.payer);
assert.equal(allowed.actions.filter((step) => step.action.type === 'alliance').length, 2);
const pending = allowE3Responses(payBureaucratE3Auction(allowed));
assert.equal(pending.decision?.kind, 'bureaucratPayment');
assertAuctionOutcome(allowed, allowE3Responses(chooseBureaucratE3Payment(pending, true)), 2);
const masked = createBureaucratE3AuctionFixture({ advanced, payerShare: 3, donorShare: 5, donor });
const done = allowE3Responses(payBureaucratE3Auction(masked));
assert.notEqual(done.decision?.kind, 'bureaucratPayment');
assert.equal(done.bureaucratPayments?.used.length ?? 0, 0);
}
{
const allowed = createBureaucratE3ShipmentFixture({ advanced, primary: 5, pledge: 5, partner: ALLIANCE.trainer });
const pending = allowE3Responses(declareBureaucratE3Shipment(allowed).paid);
assert.equal(pending.decision?.kind, 'bureaucratPayment');
assertShipmentCosts(allowed, allowE3Responses(chooseBureaucratE3Payment(pending, true)));
const masked = createBureaucratE3ShipmentFixture({ advanced, primary: 3, pledge: 5, partner: ALLIANCE.trainer });
const done = allowE3Responses(declareBureaucratE3Shipment(masked).paid);
assert.notEqual(done.decision?.kind, 'bureaucratPayment');
assert.equal(done.bureaucratPayments?.used.length ?? 0, 0);
}
});
void test(`${advanced ? 'Advanced' : 'Basic'} paired E3 pending choices reject cross-actor and stale input unchanged`, () => {
const auction = createBureaucratE3AuctionFixture({ advanced, payerShare: 5, donorShare: 5 });
const auctionPending = allowE3Responses(payBureaucratE3Auction(auction));
rejectUnchanged(auctionPending, auction.emperor, { type: 'decision', event: auctionPending.bureaucratPaymentEvent, redirect: true });
rejectUnchanged(auctionPending, auction.trainer, { type: 'decision', event: 'stale-auction-invoice', redirect: true });
rejectUnchanged(auctionPending, auction.payer, auction.action);
assertAuctionOutcome(auction, allowE3Responses(chooseBureaucratE3Payment(auctionPending)), 2);
const shipment = createBureaucratE3ShipmentFixture({ advanced, primary: 5, pledge: 5 });
const shipmentPending = allowE3Responses(declareBureaucratE3Shipment(shipment).paid);
rejectUnchanged(shipmentPending, shipment.donor, { type: 'decision', event: shipmentPending.bureaucratPaymentEvent, redirect: true });
rejectUnchanged(shipmentPending, shipment.trainer, { type: 'decision', event: 'stale-shipment-invoice', redirect: true });
rejectUnchanged(shipmentPending, shipment.payer, { type: 'endMovement' });
rejectUnchanged(shipmentPending, shipment.donor, { type: 'pledgeAid', amount: 1 });
assertShipmentCosts(shipment, allowE3Responses(chooseBureaucratE3Payment(shipmentPending)));
});
void test(`${advanced ? 'Advanced' : 'Basic'} four minimal owner bot choices settle the paired private-funded receipt and resume Movement`, () => {
for (const bot of DIFFICULTIES) {
const f = createBureaucratE3ShipmentFixture({ advanced, primary: 5, pledge: 5 });
const pending = allowE3Responses(declareBureaucratE3Shipment(f).paid);
const view = viewGame(pending, f.trainer);
view.players.find((p) => p.id === f.trainer)!.bot = bot;
const actions = botActions(view);
assert.equal(actions.length, 1);
const after = applyAction(structuredClone(pending), f.trainer, actions[0]);
assert.equal(seat(after, f.guild).spice, seat(f.beforePledge, f.guild).spice + f.amount - 2);
assert.equal(after.bureaucratPayments?.used.length, 1);
assert.deepEqual(counters(after), counters(pending));
for (const id of [f.payer, f.donor, f.trainer, f.emperor]) assert.equal(seat(after, id).spice, seat(pending, id).spice);
assert.equal(after.phase, 5);
assert.equal(after.active, f.payer);
const ended = applyAction(structuredClone(after), f.payer, { type: 'endMovement' });
assert.notEqual(ended.active, f.payer);
assert.deepEqual(counters(ended), counters(after));
rejectUnchanged(ended, f.trainer, actions[0]);
}
});
void test(`${advanced ? 'Advanced' : 'Basic'} one physical Bureaucrat use per native phase survives a decline and renews after Mentat`, () => {
for (const redirect of [false, true]) {
const f = createBureaucratE3ShipmentFixture({ advanced, primary: 5, pledge: 3 });
const pending = allowE3Responses(declareBureaucratE3Shipment(f).paid);
const settled = allowE3Responses(chooseBureaucratE3Payment(pending, redirect));
const following = applyAction(structuredClone(settled), f.emperor, { type: 'bribe', target: f.guild, amount: 5 });
assert.equal(seat(following, f.emperor).spice, 5);
assert.deepEqual(counters(following), counters(settled));
let used: Game;
if (redirect) {
assert.notEqual(following.decision?.kind, 'bureaucratPayment');
assert.equal(seat(following, f.guild).bribes, seat(settled, f.guild).bribes + 5);
used = following;
} else {
assert.equal(following.decision?.kind, 'bureaucratPayment');
rejectUnchanged(following, f.trainer, { type: 'decision', event: pending.bureaucratPaymentEvent, redirect: true });
used = allowE3Responses(chooseBureaucratE3Payment(following, true));
assert.equal(seat(used, f.guild).bribes, seat(settled, f.guild).bribes + 3);
assert.equal(seat(used, f.guild).spice, seat(settled, f.guild).spice);
}
assert.equal(used.bureaucratPayments?.used.length, 1);
const ended = applyAction(used, f.payer, { type: 'endMovement' });
const next = advancePairedE3Nexus(ended, (state) => state.turn === 3 && state.phase === 1 &&
pairedE3NexusClean(state), undefined, quietPairedE3NexusChoice);
assert.equal(viewGame(next, f.trainer).bureaucrat.usedThisPhase, false);
const payment = applyAction(next, f.emperor, { type: 'bribe', target: f.guild, amount: 5 });
assert.equal(payment.decision?.kind, 'bureaucratPayment');
const renewed = allowE3Responses(chooseBureaucratE3Payment(payment, true));
assert.equal(renewed.bureaucratPayments?.used.length, 2);
assert.deepEqual(renewed.bureaucratPayments!.used.map((use) => ({ turn: use.turn, phase: use.phase })),
[{ turn: 2, phase: 5 }, { turn: 3, phase: 1 }]);
assert.equal(viewGame(renewed, f.trainer).bureaucrat.usedThisPhase, true);
assert.deepEqual(counters(renewed), counters(next));
}
});
}
+255
View File
@@ -0,0 +1,255 @@
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,
} 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 interface BureaucratE3FundingBase {
actions: BureaucratE3FundingStep[];
staging: string[];
/** Turn-2 Spice blow Nexus window: real alliances may still form here. */
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 Nexus and a real
* mutual alliance between the payer and the requested donor. Never assigns
* wallets, armies, phase, assignment or alliance state. */
export function createBureaucratE3FundingBase(
{ advanced = false, donor = 'moritani' }: { advanced?: boolean; donor?: string } = {},
): BureaucratE3FundingBase {
const options = { roster: [...BUREAUCRAT_E3_ROSTER], advanced, skills: true };
const setup = createPairedE3NexusSetup(options);
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({
advanced = false, payerShare = 5, donorShare = 3, donor = 'moritani', counter = false,
}: {
advanced?: boolean; payerShare?: number; donorShare?: number; donor?: string; counter?: boolean;
} = {}): BureaucratE3AuctionFixture {
const base = createBureaucratE3FundingBase({ advanced, 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;
physical: string[];
}
export function createBureaucratE3ShipmentFixture({
advanced = false, primary = 5, partner = 'moritani', pledge = 3,
}: {
advanced?: boolean; primary?: number; partner?: string; pledge?: number;
} = {}): BureaucratE3ShipmentFixture {
const base = createBureaucratE3FundingBase({ advanced, 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);
const target = 'habbanya_ridge_sietch';
assert.ok(game.players.every((p) => !Object.keys(p.forces).some((key) => key.startsWith(target))));
const physical = bureaucratE3PhysicalCards(game);
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 amount = primary + pledge;
const action: Action = { type: 'ship', territory: target, sector: 17, amount, elite: 0, allyPayment: pledge };
return { ...base, actions, staging, game, physical,
beforePledge, beforeAction, target, action, primary, donorAmount: pledge, amount, 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 };
}