Files
Dune/tests/fixture-homeworld-banker-income.ts

470 lines
26 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeLeaderSkillsGameForAudit, joinGame, newPlayer,
viewGame, type Action, type Game, type Player,
} from '../game/engine';
import { territory } from '../game/board';
import type { FactionId } from '../game/catalog';
import { homeworldForceGroups } from '../game/homeworld-custody';
import { homeworldContext } from '../game/homeworld-game';
import type { OccupiedPercentageReceipt } from '../game/homeworld-occupied-percentage';
import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
import { quoteStableHomeworldOccupation } from '../game/homeworld-stable-occupation';
import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards';
import { assertClassicHomeworldSkillsCustody } from './fixture-homeworld-classic-skills';
import { nextSpiceBankerIncomeNativeStep, type SpiceBankerIncomeNativeStep } from './fixture-spice-banker-income';
export type HomeworldBankerStep = SpiceBankerIncomeNativeStep;
export type HomeworldBankerOptions = {
/** Real Bene Gesserit invasion of the emptied Junction before the invoice. */
occupied?: boolean;
/** The odd eleven-spice invoice onto the genuinely vacated Tuek's Sietch. */
stronghold?: boolean;
/** Declared primary payer share; the pledged ally funds the remaining invoice. */
payerShare?: number;
};
export type HomeworldBankerFixture = {
/** Admitted fresh setup before any setup choice: the original stock census. */
initial: Game;
/** Genuine all-fourteen Skills setup completed by real setup selections. */
setup: Game;
/** After the real four-spice prep invoice reached the native Guild. */
prepaid: Game;
/** After the native Guild's own fifteen-force tariff left Junction empty. */
junctionEmpty: Game;
/** After the Guild's real movement vacated Tuek's Sietch. */
vacated: Game;
/** After the real foreign invasion of Junction, or null when unoccupied. */
occupied: Game | null;
/** After the real reciprocal alliance at the later worm nexus. */
alliance: Game;
/** The payer's original Movement turn, before the ally's real pledge. */
beforePledge: Game;
/** After the ally's real pledge; the invoice itself is not yet declared. */
beforeAction: Game;
game: Game;
payer: string;
ally: string;
payee: string;
occupier: string | null;
/** Spice Banker owner and its living native trainer leader. */
trainer: string;
leader: string;
/** Separate Bureaucrat owner. */
bureaucrat: string;
prepPayer: string;
/** Original declared invoice contributions in primary/ally order. */
contributions: { payer: string; amount: number }[];
payerShare: number;
allyShare: number;
gross: number;
action: Action;
target: string;
sector: number;
/** Adjacent desert sector the Guild’s real movement used to vacate Tuek’s Sietch. */
vacateSector: number;
prepReceipt: OccupiedPercentageReceipt;
/** Original Karama custody staged for a cancellation path. */
held: string[];
physical: string[];
staging: string[];
actions: HomeworldBankerStep[];
};
/** Original seats in join order: the player circles follow the storm, so this
* order decides which faction acts first in each real phase sequence. */
const SEATS: readonly FactionId[] = ['atreides', 'guild', 'beneGesserit', 'emperor', 'harkonnen', 'fremen'];
/** One original skill per real offer; the first is the Banker, the last Bureaucrat. */
const REQUESTED_SKILLS: readonly LeaderSkillId[] = ['spice-banker', 'mentat', 'smuggler',
'swordmaster-of-ginaz', 'master-of-assassins', 'bureaucrat'];
export const homeworldBankerPlayer = (game: Game, id: string): Player => {
const player = game.players.find(candidate => candidate.id === id);
assert.ok(player, `Missing original seat ${id}`);
return player;
};
export const homeworldBankerClean = (game: Game) => !game.phaseOpening && !game.response &&
!game.decision && !game.pendingTreacheryDiscard;
export const homeworldBankerWallets = (game: Game) =>
Object.fromEntries(game.players.map(player => [player.id, player.spice]));
export const homeworldBankerCards = (game: Game) =>
[...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand)].map(card => card.id).sort();
export const homeworldBankerGroup = (game: Game, actor: string): string => {
const groups = homeworldForceGroups(homeworldContext(game), game.homeworlds!.custody!);
const group = groups.find(candidate => candidate.native === actor && !candidate.secondary);
assert.ok(group, `No original homeworld custody group for ${actor}`);
return group.id;
};
export const homeworldBankerSector = (game: Game, target: string): number => {
const sector = territory(target).sectors.find(candidate => candidate !== game.storm);
assert.ok(sector !== undefined, `The ${target} shipment needs a printed sector outside the current storm`);
return sector;
};
/** Genuine ordinary reserve shipment out of a faction's own Homeworld reserves
* into an empty printed board location. The withdrawal keeps its default
* own-home allocation; no force, wallet, phase or receipt is assigned. */
export function homeworldBankerShipment(game: Game, actor: string, target: string,
amount: number, allyPayment = 0): Action {
assert.ok(game.players.every(player => !Object.keys(player.forces).some(key => key.startsWith(`${target}:`))),
`The ${target} target must be genuinely empty before the real shipment`);
assert.ok(homeworldBankerPlayer(game, actor).reserves >= amount,
`${actor} must hold ${amount} Homeworld reserve counters for this shipment`);
return { type: 'ship', territory: target, sector: homeworldBankerSector(game, target), amount, elite: 0, allyPayment };
}
function byFactionId(game: Game, faction: FactionId): string {
const player = game.players.find(candidate => candidate.faction === faction);
assert.ok(player, `Missing original ${faction} seat`);
return player.id;
}
/** Only conserved, original unplayed Spice order is controlled. */
function orderSpice(game: Game, kinds: ('land' | 'worm')[]): void {
for (const [position, kind] of kinds.entries()) {
const index = game.spiceDeck.findIndex((card, i) => i >= position &&
(kind === 'worm' ? 'worm' in card : 'territory' in card));
assert.ok(index >= position, `The original unplayed Spice deck has no ${kind} card left.`);
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]);
}
}
/** A physical custody swap retains both cards and the original hand size. */
function holdKarama(game: Game, actor: string, staging: string[]): string {
const player = homeworldBankerPlayer(game, actor);
const held = player.hand.find(card => card.effect === 'karama');
if (held) return held.id;
const index = game.deck.findIndex(card => card.effect === 'karama');
assert.ok(index >= 0 && player.hand.length, 'A genuine original Karama must remain for this cancellation path.');
const card = game.deck[index], outgoing = player.hand[0];
game.deck[index] = outgoing;
player.hand[0] = card;
staging.push(`Conserved original Karama custody: ${card.id} from the remaining deck to ${actor}'s hand, ${outgoing.id} to the deck; hand size unchanged.`);
return card.id;
}
/** Actual Fisher-Yates draws for the original skill deck, not an assignment. */
function permutationDraws<T>(source: readonly T[], desired: readonly T[]): number[] {
const current = [...source], draws: number[] = [];
for (let i = current.length - 1; i > 0; i--) {
const j = current.indexOf(desired[i]);
assert.ok(j >= 0 && j <= i);
draws.push(Math.floor(((j + 0.5) / (i + 1)) * 4294967296));
[current[i], current[j]] = [current[j], current[i]];
}
assert.deepEqual(current, desired);
return draws;
}
function originalLottery<T>(draws: readonly number[], run: () => T): T {
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
const native = crypto.getRandomValues.bind(crypto);
let index = 0;
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
if (array instanceof Uint32Array && array.length === 1) array[0] = draws[index++] ?? 0xffffffff;
else Reflect.apply(native, crypto, [array]);
return array;
};
try { return run(); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
/** Deterministic original entropy for one fixture programme. The native code
* still draws and shuffles every component, but the Advanced storm card and the
* later phase order stay reproducible instead of varying with host entropy. */
export function withHomeworldBankerEntropy<T>(run: () => T): T {
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
const native = crypto.getRandomValues.bind(crypto);
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
if (array instanceof Uint32Array) array.fill(0xffffffff);
else Reflect.apply(native, crypto, [array]);
return array;
};
try { return run(); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
/** Fresh classic Advanced Homeworld admission through the ordinary Skills
* initializer, which also creates mandatory original occupation state. Only the
* original all-fourteen skill shuffle is controlled; decks, hands, offers,
* traitors and native custody stay original, with no occupation retrofit. */
function admitHomeworldBankerGame(): Game {
let game = createGame('HOMEWORLDBANKER', newPlayer(SEATS[0], 'Banker', SEATS[0]), true);
for (const faction of SEATS.slice(1)) joinGame(game, newPlayer(faction, faction, faction));
game = applyAction(game, game.host, { type: 'homeworlds', enabled: true });
for (const player of game.players) if (!player.ready) game = applyAction(game, player.id, { type: 'ready' });
const canonical = LEADER_SKILL_CARDS.map(card => card.id);
const rest = canonical.filter(id => !REQUESTED_SKILLS.includes(id));
const desired = REQUESTED_SKILLS.flatMap(id => [id, rest.shift()!]).concat(rest);
return originalLottery(permutationDraws(canonical, desired), () => initializeLeaderSkillsGameForAudit(game));
}
/** Genuine setup selections: each requested skill is chosen from its real
* private offer, and traitors and the Fremen star placement are real actions. */
function completeHomeworldBankerSetup(state: Game, actions: HomeworldBankerStep[]): Game {
let game = state;
for (let step = 0; game.status === 'setup' && step < 200; step++) {
let next: HomeworldBankerStep | null;
if (game.setupStage === 'leaderSkills') {
const actor = Object.keys(game.leaderSkills!.offers)[0];
const offer = game.leaderSkills!.offers[actor];
const view = viewGame(game, actor).leaderSkills!;
const skill = REQUESTED_SKILLS[game.players.findIndex(player => player.id === actor)];
assert.ok(skill, `No requested skill is mapped to the real offer seat ${actor}.`);
assert.ok(offer.cards.includes(skill) && !view.unavailableSkills?.[skill],
`The original offer for ${actor} must include the requested ${skill}.`);
const leaders = view.eligibleLeaders
.map(eligible => homeworldBankerPlayer(game, actor).leaders.find(disc => disc.id === eligible.id)!)
.sort((a, b) => b.strength - a.strength);
assert.ok(leaders[0]);
next = { actor, action: { type: 'leaderSkill', event: offer.event, skill, leader: leaders[0].id } };
} else if (game.setupStage === 'traitors' && !game.decision) {
const player = game.players.find(candidate => candidate.traitorChoices.length);
assert.ok(player);
next = { actor: player.id, action: { type: 'traitor', leader: player.traitorChoices[0] } };
} else if (game.setupStage === 'forces' && homeworldBankerPlayer(game, byFactionId(game, 'fremen')).reserves === 20) {
const sector = territory('sietch_tabr').sectors[0];
next = { actor: byFactionId(game, 'fremen'),
action: { type: 'fremenSetup', placements: { [`sietch_tabr:${sector}`]: 10 } } };
} else next = nextSpiceBankerIncomeNativeStep(game);
assert.ok(next, `The original setup stalled at ${game.setupStage}/${game.decision?.kind}`);
actions.push(structuredClone(next));
game = applyAction(game, next.actor, next.action);
}
assert.equal(game.status, 'playing');
return game;
}
/** The homeworld decisions first, then the shared Banker native stepper; a
* required human Battle Plan still stops the programme instead of inventing one. */
function nextHomeworldBankerStep(game: Game): HomeworldBankerStep | null {
const decision = game.decision;
if (decision?.kind === 'guildTiming')
return { actor: decision.player, action: { type: 'decision', take: true } };
if (decision?.kind === 'homeworldShipmentGuild')
return { actor: decision.player, action: { type: 'decision', event: decision.event, allow: true } };
if (decision?.kind === 'homeworldOccupiedIncome') {
const offer = viewGame(game, decision.player).homeworldOccupiedIncome!;
return { actor: decision.player, action: { type: 'decision', event: decision.event,
world: decision.world, ownAmount: offer.maxOwnAmount } };
}
if (decision?.kind === 'homeworldOccupiedPercentage') {
const offer = viewGame(game, decision.player).homeworldOccupiedPercentage!;
return { actor: decision.player, action: { type: 'decision', event: decision.event,
ownAmount: offer.maxOwnAmount } };
}
return nextSpiceBankerIncomeNativeStep(game);
}
export function advanceHomeworldBanker(state: Game, until: (game: Game) => boolean,
actions?: HomeworldBankerStep[]): Game {
return withHomeworldBankerEntropy(() => advanceHomeworldBankerUnder(state, until, actions));
}
function advanceHomeworldBankerUnder(state: Game, until: (game: Game) => boolean,
actions?: HomeworldBankerStep[]): Game {
let game = state;
for (let step = 0; !until(game) && step < 2000; step++) {
assert.notEqual(game.status, 'finished', 'The native Homeworld programme ended early.');
const next = nextHomeworldBankerStep(game);
assert.ok(next, `A real human Battle Plan or Suk choice is required at ${game.turn}/${game.phase}`);
actions?.push(structuredClone(next));
game = applyAction(game, next.actor, next.action);
}
assert.ok(until(game), 'The native Homeworld programme did not reach its requested boundary.');
return game;
}
/** Real classic Homeworld Banker runtime: genuine setup, the native Guild's own
* emptied Junction, an optional real invasion, a real worm/alliance and one
* declared foreign invoice. Only the controlled skill shuffle, the original
* unplayed Spice order and later conserved Karama custody are staged. */
export function createHomeworldBankerFixture(options: HomeworldBankerOptions = {}): HomeworldBankerFixture {
const occupied = options.occupied ?? false;
const stronghold = options.stronghold ?? false;
const gross = stronghold ? 11 : 8;
const payerShare = options.payerShare ?? 5;
const allyShare = gross - payerShare;
assert.ok(payerShare >= 1 && allyShare >= 1, 'The invoice needs a positive primary and pledged share.');
const staging: string[] = [], actions: HomeworldBankerStep[] = [];
let game = admitHomeworldBankerGame();
const initial = structuredClone(game);
staging.push('Fresh classic Advanced Homeworld lobby admitted once through the real Spice Banker initializer; only the original all-fourteen skill shuffle is controlled.');
game = completeHomeworldBankerSetup(game, actions);
const setup = structuredClone(game);
assertClassicHomeworldSkillsCustody(setup);
const payee = byFactionId(game, 'guild'), prepPayer = byFactionId(game, 'atreides');
const payer = byFactionId(game, 'emperor'), ally = byFactionId(game, 'harkonnen');
const occupier = occupied ? byFactionId(game, 'beneGesserit') : null;
const banker = setup.leaderSkills!.assignments.find(assignment => assignment.skill === 'spice-banker');
const bureaucrat = setup.leaderSkills!.assignments.find(assignment => assignment.skill === 'bureaucrat');
assert.ok(banker && bureaucrat);
assert.equal(banker.owner, prepPayer, 'The Banker trainer takes the first real offer.');
assert.equal(bureaucrat.owner, byFactionId(game, 'fremen'), 'A separate nonpayer owns Bureaucrat.');
// One real four-spice shipment income funds the native Guild's own tariff.
orderSpice(game, ['land', 'land']);
game = advanceHomeworldBanker(game, state => state.turn === 1 && state.phase === 5 &&
state.active === prepPayer && homeworldBankerClean(state), actions);
const guildOpening = homeworldBankerPlayer(game, payee).spice;
const prep = homeworldBankerShipment(game, prepPayer, 'polar_sink', 2);
actions.push({ actor: prepPayer, action: structuredClone(prep) });
game = advanceHomeworldBanker(applyAction(game, prepPayer, prep), homeworldBankerClean, actions);
assert.equal(homeworldBankerPlayer(game, payee).spice, guildOpening + 4,
'One real four-spice shipment invoice reaches the native Guild at high population.');
const prepaid = structuredClone(game);
const prepReceipt = prepaid.homeworldOccupiedPercentageLedger!.receipts.at(-1)!;
assert.equal(prepReceipt.source.kind, 'guild-shipping');
assert.equal(prepReceipt.source.native, payee);
assert.equal(prepReceipt.source.amount, 4, 'The prep invoice receipt keeps its original paid amount.');
assert.equal(prepReceipt.bankRetained, 0, 'A high-population invoice leaves no Bank remainder.');
// A later real worm opens the nexus, and a real reciprocal alliance funds it.
game = advanceHomeworldBanker(game, state => state.turn === 2 && state.phase === 1 &&
homeworldBankerClean(state) && !state.nexus && !state.spiceWindow && !state.spiceResolution, actions);
orderSpice(game, ['worm', 'land', 'land']);
game = advanceHomeworldBanker(game, state => !!state.nexus && !state.spiceWindow &&
!state.spiceResolution && homeworldBankerClean(state), actions);
for (const [actor, target] of [[payer, ally], [ally, payer]] as const) {
const alliance: HomeworldBankerStep = { actor, action: { type: 'alliance', target } };
actions.push(alliance);
game = applyAction(game, actor, alliance.action);
}
assert.equal(homeworldBankerPlayer(game, payer).ally, ally);
assert.equal(homeworldBankerPlayer(game, ally).ally, payer);
const alliance = structuredClone(game);
// The native Guild's own later fifteen-force tariff empties Junction.
game = advanceHomeworldBanker(game, state => state.turn === 2 && state.phase === 5 &&
state.active === payee && homeworldBankerClean(state), actions);
const guildBeforeTariff = homeworldBankerPlayer(game, payee).spice;
const tariff = homeworldBankerShipment(game, payee, 'habbanya_ridge_sietch', 15);
actions.push({ actor: payee, action: structuredClone(tariff) });
game = advanceHomeworldBanker(applyAction(game, payee, tariff), homeworldBankerClean, actions);
assert.equal(homeworldBankerPlayer(game, payee).spice, guildBeforeTariff - 8, 'The native tariff is paid once.');
assert.equal(homeworldBankerPlayer(game, payee).reserves, 0, 'Junction is physically empty.');
const junctionEmpty = structuredClone(game);
assert.equal(quoteStableHomeworldOccupation(game, 'junction').entitlement, null);
// Real movement retains the original Tuek's Sietch five in an adjacent desert.
const tuekKey = `tueks_sietch:${territory('tueks_sietch').sectors[0]}`;
const tuek = homeworldBankerPlayer(game, payee).forces[tuekKey] ?? 0;
assert.equal(tuek, 5, 'The classic Guild start keeps five forces at Tuek’s Sietch.');
const vacateSector = territory('pasty_mesa').sectors.find(sector =>
territory('tueks_sietch').sectors.includes(sector) && sector !== game.storm);
assert.ok(vacateSector !== undefined);
const vacate: Action = { type: 'move', from: tuekKey, amount: tuek, elite: 0,
territory: 'pasty_mesa', sector: vacateSector };
actions.push({ actor: payee, action: structuredClone(vacate) });
game = advanceHomeworldBanker(applyAction(game, payee, vacate), homeworldBankerClean, actions);
assert.ok(game.players.every(player => !Object.keys(player.forces).some(key => key.startsWith('tueks_sietch'))),
'The real movement genuinely vacates Tuek’s Sietch.');
assert.equal(homeworldBankerPlayer(game, payee).forces[`pasty_mesa:${vacateSector}`], tuek);
const vacated = structuredClone(game);
// The real foreign homeworld invasion of the emptied Junction.
let occupiedState: Game | null = null;
if (occupier) {
game = advanceHomeworldBanker(game, state => state.turn === 2 && state.phase === 5 &&
state.active === occupier && homeworldBankerClean(state), actions);
const invasion = homeworldShipmentChoice(viewGame(game, occupier), 'homeworld:guild',
{ [homeworldBankerGroup(game, occupier)]: { normal: 1, elite: 0 } });
assert.ok(invasion.action, invasion.blocked ?? undefined);
actions.push({ actor: occupier, action: structuredClone(invasion.action) });
game = advanceHomeworldBanker(applyAction(game, occupier, invasion.action), homeworldBankerClean, actions);
const authority = quoteStableHomeworldOccupation(game, 'junction');
assert.equal(authority.blocked, null);
assert.equal(authority.entitlement?.occupier, occupier);
assert.ok(game.homeworldOccupationHistory!.sources.some(source =>
source.event === authority.entitlement!.qualification && source.cause !== 'setup'));
occupiedState = structuredClone(game);
}
// The payer's own later Movement turn, in a fresh phase: the ally pledges and
// then the real invoice, so no earlier Banker grant shares this phase stamp.
game = advanceHomeworldBanker(game, state => state.turn === 3 && state.phase === 5 &&
state.active === payer && homeworldBankerClean(state), actions);
const beforePledge = structuredClone(game);
const pledge: HomeworldBankerStep = { actor: ally, action: { type: 'pledgeAid', amount: allyShare } };
actions.push(pledge);
game = applyAction(game, ally, pledge.action);
const beforeAction = structuredClone(game);
const target = stronghold ? 'tueks_sietch' : 'arsunt';
const sector = homeworldBankerSector(game, target);
const action = homeworldBankerShipment(game, payer, target, stronghold ? gross : gross / 2, allyShare);
assertClassicHomeworldSkillsCustody(game);
return { initial, setup, prepaid, junctionEmpty, vacated, occupied: occupiedState, alliance,
beforePledge, beforeAction, game, payer, ally, payee, occupier, trainer: banker.owner,
leader: banker.leader, bureaucrat: bureaucrat.owner, prepPayer,
contributions: [{ payer, amount: payerShare }, { payer: ally, amount: allyShare }],
payerShare, allyShare, gross, action, target, sector, vacateSector, prepReceipt, held: [],
physical: homeworldBankerCards(game), staging, actions };
}
/** The original declaration debits the payer and the pledged ally once. */
export function declareHomeworldBankerInvoice(f: HomeworldBankerFixture, state = f.game): Game {
let game = applyAction(state, f.payer, f.action);
if (game.decision?.kind === 'guildShipment')
game = applyAction(game, game.decision.player, { type: 'decision', allow: true });
return game;
}
/** Walk only the real persisted pass queue. */
export function allowHomeworldBankerIncome(state: Game): Game {
let game = state;
for (let step = 0; game.response && step < 60; step++) {
const actor = game.players.find(player => !game.response!.passed.includes(player.id));
assert.ok(actor, 'The original response queue must keep a legal responder');
game = applyAction(game, actor.id, { type: 'passResponse' });
}
assert.equal(game.response, null);
return game;
}
export function chooseHomeworldBankerBureaucrat(game: Game, redirect: boolean): Game {
assert.equal(game.decision?.kind, 'bureaucratPayment');
return applyAction(game, game.decision!.player,
{ type: 'decision', event: game.bureaucratPaymentEvent, redirect });
}
export function allocateHomeworldBankerPercentage(game: Game, ownAmount?: number): Game {
assert.equal(game.decision?.kind, 'homeworldOccupiedPercentage');
const offer = viewGame(game, game.decision!.player).homeworldOccupiedPercentage!;
return allowHomeworldBankerIncome(applyAction(game, game.decision!.player,
{ type: 'decision', event: offer.event, ownAmount: ownAmount ?? offer.maxOwnAmount }));
}
/** Consume the real Bene Gesserit advisor follow-up that an off-planet arrival
* opens; it is part of the shipment, not of the invoice's own settlement. */
export function resolveHomeworldBankerFollowups(state: Game): Game {
let game = state;
for (let step = 0; game.decision?.kind === 'advisor' && step < 8; step++)
game = advanceHomeworldBanker(game, current => current.decision?.kind !== 'advisor');
return game;
}
/** Original responses, the optional private Bureaucrat choice and then the real
* occupied percentage allocation by the occupant. */
export function settleHomeworldBankerInvoice(f: HomeworldBankerFixture,
state = f.game, redirect = false): Game {
let game = allowHomeworldBankerIncome(state);
if (game.decision?.kind === 'bureaucratPayment') game = chooseHomeworldBankerBureaucrat(game, redirect);
if (game.decision?.kind === 'homeworldOccupiedPercentage') {
assert.equal(game.decision.player, f.occupier, 'Only the real occupant allocates this percentage.');
game = allocateHomeworldBankerPercentage(game);
}
return resolveHomeworldBankerFollowups(game);
}
/** The original cancellation response, with the Banker owner's Karama held. Any
* advisor follow-up that the real arrival opened resolves through the stepper. */
export function cancelHomeworldBankerIncome(f: HomeworldBankerFixture, state: Game): Game {
assert.equal(state.response?.kind, 'guildIncome');
const card = f.held.at(-1);
assert.ok(card, 'A conserved original Karama must be held before cancelling.');
assert.ok(viewGame(state, f.trainer).responseControls?.cancelCards.includes(card));
const canceled = applyAction(state, f.trainer, { type: 'card', mode: 'cancel', card });
return canceled.decision?.kind === 'homeworldOccupiedPercentage' ? canceled
: resolveHomeworldBankerFollowups(canceled);
}
/** Conserved original Karama custody for either cancellation path. */
export function holdHomeworldBankerKarama(f: HomeworldBankerFixture): string {
const card = holdKarama(f.game, f.trainer, f.staging);
f.held.push(card);
assert.deepEqual(homeworldBankerCards(f.game), f.physical);
assertClassicHomeworldSkillsCustody(f.game);
return card;
}