Connect classic Homeworld Banker final Bank income and ruled rounding
This commit is contained in:
1 parent
2efa69d10f
commit
373badd560
27 files changed
+1624
-280
No files matched your search
@@ -189,12 +189,7 @@ void test('an original Karama cancellation removes the offer and banks the nativ
|
||||
outcome(f, done, 0, true);
|
||||
});
|
||||
|
||||
void test('occupied Junction refuses two odd contributions and sub-five splits obtain no offer', () => {
|
||||
for (const [payerShare, donorShare] of [[5, 3], [3, 5], [5, 5]]) {
|
||||
const rejected = createOccupiedGuildFixture({ payerShare, donorShare, donor: 'atreides' });
|
||||
assert.equal(rejected.gross, payerShare + donorShare);
|
||||
reject(rejected.game, rejected.payer, rejected.action);
|
||||
}
|
||||
void test('sub-five occupied Junction payer shares do not create a Bureaucrat offer', () => {
|
||||
const subFive = createOccupiedGuildFixture({ payerShare: 2, donorShare: 2 });
|
||||
const settled = allocateOccupiedGuild(allowOccupiedGuildIncome(declareOccupiedGuild(subFive)));
|
||||
assert.equal(settled.bureaucratPayments?.used.length ?? 0, 0);
|
||||
|
||||
@@ -0,0 +1,469 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction, createGame, initializeSpiceBankerIncomeGameForAudit, 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 Banker profile allows only the
|
||||
* original native payment fields, so the withdrawal keeps its default own-home
|
||||
* allocation and no force, wallet, phase or receipt was 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 real Banker
|
||||
* initializer. Only the original all-fourteen skill shuffle is controlled; the
|
||||
* decks, hands, offers, traitors and native custody stay original. */
|
||||
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), () => initializeSpiceBankerIncomeGameForAudit(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;
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { HomeworldCustodyError } from '../game/homeworld-custody';
|
||||
import { quoteHomeworldBankPayment } from '../game/homeworld-payment-income';
|
||||
|
||||
void test('whole-invoice low Bank halves attribute odd-odd rounding to the declared primary payer', () => {
|
||||
assert.deepEqual(
|
||||
quoteHomeworldBankPayment(
|
||||
[{ payer: 'primary', amount: 5 }, { payer: 'ally', amount: 3 }],
|
||||
4,
|
||||
),
|
||||
[{ payer: 'primary', amount: 3 }, { payer: 'ally', amount: 1 }],
|
||||
);
|
||||
assert.deepEqual(
|
||||
quoteHomeworldBankPayment(
|
||||
[{ payer: 'primary', amount: 7 }, { payer: 'ally', amount: 1 }],
|
||||
4,
|
||||
),
|
||||
[{ payer: 'primary', amount: 4 }],
|
||||
);
|
||||
assert.deepEqual(
|
||||
quoteHomeworldBankPayment(
|
||||
[{ payer: 'ally', amount: 3 }, { payer: 'primary', amount: 5 }],
|
||||
4,
|
||||
),
|
||||
[{ payer: 'ally', amount: 2 }, { payer: 'primary', amount: 2 }],
|
||||
);
|
||||
});
|
||||
|
||||
void test('native Bank zero and canceled full income preserve the actual original payer legs', () => {
|
||||
const contributions = [
|
||||
{ payer: 'primary', amount: 5 },
|
||||
{ payer: 'ally', amount: 3 },
|
||||
];
|
||||
assert.deepEqual(quoteHomeworldBankPayment(contributions, 0), []);
|
||||
assert.deepEqual(quoteHomeworldBankPayment(contributions, 8), contributions);
|
||||
assert.deepEqual(quoteHomeworldBankPayment([], 0), []);
|
||||
assert.deepEqual(
|
||||
quoteHomeworldBankPayment([{ payer: 'primary', amount: 1 }], 0),
|
||||
[],
|
||||
);
|
||||
assert.deepEqual(
|
||||
quoteHomeworldBankPayment([{ payer: 'primary', amount: 1 }], 1),
|
||||
[{ payer: 'primary', amount: 1 }],
|
||||
);
|
||||
});
|
||||
|
||||
void test('occupied cancellation uses the frozen ceil Bank remainder rather than the ordinary low floor', () => {
|
||||
const contributions = [
|
||||
{ payer: 'primary', amount: 5 },
|
||||
{ payer: 'ally', amount: 4 },
|
||||
];
|
||||
assert.deepEqual(
|
||||
quoteHomeworldBankPayment(contributions, 4),
|
||||
[{ payer: 'primary', amount: 2 }, { payer: 'ally', amount: 2 }],
|
||||
);
|
||||
assert.deepEqual(
|
||||
quoteHomeworldBankPayment(contributions, 5),
|
||||
[{ payer: 'primary', amount: 3 }, { payer: 'ally', amount: 2 }],
|
||||
);
|
||||
assert.deepEqual(quoteHomeworldBankPayment(contributions, 0), []);
|
||||
});
|
||||
|
||||
void test('Bureaucrat combines its diversion with the same payer final Bank half, never another payer', () => {
|
||||
const bankLegs = quoteHomeworldBankPayment(
|
||||
[{ payer: 'primary', amount: 5 }, { payer: 'ally', amount: 3 }],
|
||||
3,
|
||||
{ payer: 'primary', amount: 2 },
|
||||
);
|
||||
assert.deepEqual(bankLegs, [
|
||||
{ payer: 'primary', amount: 4 },
|
||||
{ payer: 'ally', amount: 1 },
|
||||
]);
|
||||
assert.deepEqual(
|
||||
bankLegs.filter((leg) => leg.amount >= 4).map((leg) => leg.payer),
|
||||
['primary'],
|
||||
);
|
||||
});
|
||||
|
||||
void test('two qualifying Bureaucrat contributions use the selected first eligible payer in original order', () => {
|
||||
const firstFive = quoteHomeworldBankPayment(
|
||||
[{ payer: 'primary', amount: 5 }, { payer: 'ally', amount: 6 }],
|
||||
4,
|
||||
{ payer: 'primary', amount: 2 },
|
||||
);
|
||||
assert.deepEqual(firstFive, [
|
||||
{ payer: 'primary', amount: 3 },
|
||||
{ payer: 'ally', amount: 3 },
|
||||
]);
|
||||
assert.deepEqual(firstFive.filter((leg) => leg.amount >= 4), []);
|
||||
const firstSix = quoteHomeworldBankPayment(
|
||||
[{ payer: 'primary', amount: 6 }, { payer: 'ally', amount: 5 }],
|
||||
4,
|
||||
{ payer: 'primary', amount: 2 },
|
||||
);
|
||||
assert.deepEqual(firstSix, [
|
||||
{ payer: 'primary', amount: 4 },
|
||||
{ payer: 'ally', amount: 2 },
|
||||
]);
|
||||
assert.deepEqual(
|
||||
firstSix.filter((leg) => leg.amount >= 4).map((leg) => leg.payer),
|
||||
['primary'],
|
||||
);
|
||||
});
|
||||
|
||||
void test('Bureaucrat can select the ally when the declared primary contribution is below five', () => {
|
||||
assert.deepEqual(
|
||||
quoteHomeworldBankPayment(
|
||||
[{ payer: 'primary', amount: 4 }, { payer: 'ally', amount: 5 }],
|
||||
3,
|
||||
{ payer: 'ally', amount: 2 },
|
||||
),
|
||||
[{ payer: 'primary', amount: 2 }, { payer: 'ally', amount: 3 }],
|
||||
);
|
||||
});
|
||||
|
||||
void test('diversion is excluded before zero, full, floor and occupied ceil Bank allocation', () => {
|
||||
const contributions = [
|
||||
{ payer: 'primary', amount: 5 },
|
||||
{ payer: 'ally', amount: 4 },
|
||||
];
|
||||
const diversion = { payer: 'primary', amount: 2 } as const;
|
||||
assert.deepEqual(
|
||||
quoteHomeworldBankPayment(contributions, 0, diversion),
|
||||
[{ payer: 'primary', amount: 2 }],
|
||||
);
|
||||
assert.deepEqual(
|
||||
quoteHomeworldBankPayment(contributions, 7, diversion),
|
||||
[{ payer: 'primary', amount: 5 }, { payer: 'ally', amount: 4 }],
|
||||
);
|
||||
assert.deepEqual(
|
||||
quoteHomeworldBankPayment(contributions, 3, diversion),
|
||||
[{ payer: 'primary', amount: 3 }, { payer: 'ally', amount: 2 }],
|
||||
);
|
||||
assert.deepEqual(
|
||||
quoteHomeworldBankPayment(contributions, 4, diversion),
|
||||
[{ payer: 'primary', amount: 4 }, { payer: 'ally', amount: 2 }],
|
||||
);
|
||||
});
|
||||
|
||||
void test('distinct payer and distinct invoice Bank payments are not pooled into a Banker threshold', () => {
|
||||
const funded = quoteHomeworldBankPayment(
|
||||
[{ payer: 'primary', amount: 5 }, { payer: 'ally', amount: 3 }],
|
||||
4,
|
||||
);
|
||||
assert.deepEqual(funded, [
|
||||
{ payer: 'primary', amount: 3 },
|
||||
{ payer: 'ally', amount: 1 },
|
||||
]);
|
||||
assert.deepEqual(funded.filter((leg) => leg.amount >= 4), []);
|
||||
for (let invoice = 0; invoice < 2; invoice++) {
|
||||
const bankLegs = quoteHomeworldBankPayment(
|
||||
[{ payer: 'primary', amount: 5 }],
|
||||
2,
|
||||
);
|
||||
assert.deepEqual(bankLegs, [{ payer: 'primary', amount: 2 }]);
|
||||
assert.deepEqual(bankLegs.filter((leg) => leg.amount >= 4), []);
|
||||
}
|
||||
});
|
||||
|
||||
void test('all valid final Bank modes conserve each original contribution across odd and even funded payments', () => {
|
||||
for (let primary = 1; primary <= 11; primary++)
|
||||
for (let ally = 1; ally <= 11; ally++) {
|
||||
const contributions = [
|
||||
{ payer: 'primary', amount: primary },
|
||||
{ payer: 'ally', amount: ally },
|
||||
];
|
||||
const diversions = [
|
||||
undefined,
|
||||
...(primary >= 5 ? [{ payer: 'primary', amount: 2 } as const] : []),
|
||||
...(ally >= 5 ? [{ payer: 'ally', amount: 2 } as const] : []),
|
||||
];
|
||||
for (const diversion of diversions) {
|
||||
const remaining = primary + ally - (diversion ? 2 : 0);
|
||||
for (const bank of [0, remaining, Math.floor(remaining / 2), Math.ceil(remaining / 2)]) {
|
||||
const legs = quoteHomeworldBankPayment(contributions, bank, diversion);
|
||||
assert.equal(
|
||||
legs.reduce((sum, leg) => sum + leg.amount, 0),
|
||||
bank + (diversion ? 2 : 0),
|
||||
);
|
||||
for (const leg of legs) {
|
||||
const original = contributions.find((paid) => paid.payer === leg.payer)!;
|
||||
assert.ok(Number.isSafeInteger(leg.amount) && leg.amount > 0);
|
||||
assert.ok(leg.amount <= original.amount);
|
||||
assert.equal(legs.filter((paid) => paid.payer === leg.payer).length, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('Bank attribution preserves exact maximal safe payments and their half rounding', () => {
|
||||
const contributions = [{ payer: 'primary', amount: Number.MAX_SAFE_INTEGER }];
|
||||
assert.deepEqual(
|
||||
quoteHomeworldBankPayment(contributions, 4_503_599_627_370_495),
|
||||
[{ payer: 'primary', amount: 4_503_599_627_370_495 }],
|
||||
);
|
||||
assert.deepEqual(
|
||||
quoteHomeworldBankPayment(contributions, 4_503_599_627_370_496),
|
||||
[{ payer: 'primary', amount: 4_503_599_627_370_496 }],
|
||||
);
|
||||
assert.deepEqual(
|
||||
quoteHomeworldBankPayment(contributions, 4_503_599_627_370_494, { payer: 'primary', amount: 2 }),
|
||||
[{ payer: 'primary', amount: 4_503_599_627_370_496 }],
|
||||
);
|
||||
assert.deepEqual(
|
||||
quoteHomeworldBankPayment(contributions, Number.MAX_SAFE_INTEGER),
|
||||
contributions,
|
||||
);
|
||||
});
|
||||
|
||||
void test('Bank allocation survives JSON and frozen inputs without changing original payments or diversion', () => {
|
||||
const contributions = Object.freeze([
|
||||
Object.freeze({ payer: 'primary', amount: 5 }),
|
||||
Object.freeze({ payer: 'ally', amount: 3 }),
|
||||
]);
|
||||
const diversion = Object.freeze({ payer: 'primary', amount: 2 } as const);
|
||||
const before = JSON.stringify({ contributions, diversion });
|
||||
const legs = quoteHomeworldBankPayment(contributions, 3, diversion);
|
||||
assert.deepEqual(legs, [
|
||||
{ payer: 'primary', amount: 4 },
|
||||
{ payer: 'ally', amount: 1 },
|
||||
]);
|
||||
assert.deepEqual(
|
||||
quoteHomeworldBankPayment(JSON.parse(JSON.stringify(contributions)), 3, JSON.parse(JSON.stringify(diversion))),
|
||||
legs,
|
||||
);
|
||||
legs[0].amount = 1;
|
||||
assert.equal(JSON.stringify({ contributions, diversion }), before);
|
||||
});
|
||||
|
||||
void test('invalid original payer attribution, contribution amounts and frozen Bank modes throw typed errors without mutation', () => {
|
||||
const valid = [{ payer: 'primary', amount: 5 }, { payer: 'ally', amount: 3 }];
|
||||
const invalid: [unknown, unknown][] = [
|
||||
[undefined, 0],
|
||||
[null, 0],
|
||||
[{}, 0],
|
||||
['primary', 0],
|
||||
[Array(1), 0],
|
||||
[[null], 0],
|
||||
[[{}], 0],
|
||||
[[{ payer: '', amount: 5 }], 2],
|
||||
[[{ payer: ' ', amount: 5 }], 2],
|
||||
[[{ payer: 1, amount: 5 }], 2],
|
||||
[[{ payer: 'primary', amount: 0 }], 0],
|
||||
[[{ payer: 'primary', amount: -1 }], 0],
|
||||
[[{ payer: 'primary', amount: NaN }], 0],
|
||||
[[{ payer: 'primary', amount: Infinity }], 0],
|
||||
[[{ payer: 'primary', amount: 1.5 }], 0],
|
||||
[[{ payer: 'primary', amount: '5' }], 2],
|
||||
[[{ payer: 'primary', amount: Number.MAX_SAFE_INTEGER + 1 }], 0],
|
||||
[[{ payer: 'primary', amount: Number.MAX_SAFE_INTEGER }, { payer: 'ally', amount: 1 }], 0],
|
||||
[[{ payer: 'primary', amount: 5 }, { payer: 'primary', amount: 3 }], 4],
|
||||
[valid, -1],
|
||||
[valid, 1.5],
|
||||
[valid, NaN],
|
||||
[valid, Infinity],
|
||||
[valid, '4'],
|
||||
[valid, Number.MAX_SAFE_INTEGER + 1],
|
||||
[valid, 3],
|
||||
[valid, 9],
|
||||
[[], 1],
|
||||
];
|
||||
for (const [contributions, bank] of invalid) {
|
||||
const before = JSON.stringify(contributions);
|
||||
assert.throws(
|
||||
() => quoteHomeworldBankPayment(
|
||||
contributions as Parameters<typeof quoteHomeworldBankPayment>[0],
|
||||
bank as number,
|
||||
),
|
||||
HomeworldCustodyError,
|
||||
);
|
||||
assert.equal(JSON.stringify(contributions), before);
|
||||
}
|
||||
});
|
||||
|
||||
void test('invalid Bureaucrat selection or diversion amount rejects without changing the invoice', () => {
|
||||
const contributions = Object.freeze([
|
||||
Object.freeze({ payer: 'primary', amount: 4 }),
|
||||
Object.freeze({ payer: 'ally', amount: 5 }),
|
||||
]);
|
||||
const invalid: unknown[] = [
|
||||
null,
|
||||
{},
|
||||
{ payer: 'absent', amount: 2 },
|
||||
{ payer: 'primary', amount: 2 },
|
||||
{ payer: 'ally', amount: 0 },
|
||||
{ payer: 'ally', amount: 1 },
|
||||
{ payer: 'ally', amount: 3 },
|
||||
{ payer: 'ally', amount: 2.5 },
|
||||
{ payer: 'ally', amount: '2' },
|
||||
{ payer: 1, amount: 2 },
|
||||
];
|
||||
for (const diversion of invalid) {
|
||||
const before = JSON.stringify({ contributions, diversion });
|
||||
assert.throws(
|
||||
() => quoteHomeworldBankPayment(
|
||||
contributions,
|
||||
3,
|
||||
diversion as Parameters<typeof quoteHomeworldBankPayment>[2],
|
||||
),
|
||||
HomeworldCustodyError,
|
||||
);
|
||||
assert.equal(JSON.stringify({ contributions, diversion }), before);
|
||||
}
|
||||
assert.throws(
|
||||
() => quoteHomeworldBankPayment(contributions, 5, { payer: 'ally', amount: 2 }),
|
||||
HomeworldCustodyError,
|
||||
);
|
||||
assert.throws(
|
||||
() => quoteHomeworldBankPayment([], 0, { payer: 'ally', amount: 2 }),
|
||||
HomeworldCustodyError,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,399 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { quoteHomeworldPaymentIncome } from '../game/homeworld-payment-income';
|
||||
import type { OccupiedPercentageReceipt } from '../game/homeworld-occupied-percentage';
|
||||
import { quoteStableHomeworldOccupation } from '../game/homeworld-stable-occupation';
|
||||
import { assertClassicHomeworldSkillsCustody } from './fixture-homeworld-classic-skills';
|
||||
import {
|
||||
advanceHomeworldBanker, allocateHomeworldBankerPercentage, allowHomeworldBankerIncome,
|
||||
cancelHomeworldBankerIncome, chooseHomeworldBankerBureaucrat, createHomeworldBankerFixture,
|
||||
declareHomeworldBankerInvoice, holdHomeworldBankerKarama, homeworldBankerCards,
|
||||
homeworldBankerClean, homeworldBankerPlayer, homeworldBankerShipment, homeworldBankerWallets,
|
||||
settleHomeworldBankerInvoice, type HomeworldBankerFixture,
|
||||
} from './fixture-homeworld-banker-income';
|
||||
|
||||
function addedSources(before: Game, after: Game) {
|
||||
const sources = after.spiceBankerIncome!.sources;
|
||||
return sources.slice(before.spiceBankerIncome!.sources.length);
|
||||
}
|
||||
function addedLegs(before: Game, after: Game) {
|
||||
return addedSources(before, after).flatMap(row => row.source.bankLegs.map(leg => ({ ...leg })));
|
||||
}
|
||||
function addedGrants(before: Game, after: Game) {
|
||||
return addedSources(before, after).filter(row => row.grant !== null);
|
||||
}
|
||||
function deferred(game: Game, owner: string) {
|
||||
return viewGame(game, owner).spiceBankerIncome!.deferred.find(row => row.owner === owner)?.amount ?? 0;
|
||||
}
|
||||
function receipt(game: Game) {
|
||||
return game.homeworldOccupiedPercentageLedger!.receipts.at(-1)!;
|
||||
}
|
||||
/** The frozen receipt keeps its original producer proof; read it only after the
|
||||
* discriminant is checked. */
|
||||
function guildShippingSource(row: OccupiedPercentageReceipt) {
|
||||
const source = row.source;
|
||||
if (source.kind !== 'guild-shipping') throw new Error('Expected the original Guild shipping source.');
|
||||
return source;
|
||||
}
|
||||
function reject(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, actor, action));
|
||||
assert.deepEqual(game, before, 'A rejected action must leave the immutable state unchanged.');
|
||||
}
|
||||
/** The payer and the pledged ally pay their original shares exactly once; only
|
||||
* the receipt's bank/settled shares leave or re-enter seated wallets. */
|
||||
function expectWallets(f: HomeworldBankerFixture, done: Game, nativeShare: number, occupiedShare = 0) {
|
||||
const expected = homeworldBankerWallets(f.beforePledge);
|
||||
expected[f.payer] -= f.payerShare;
|
||||
expected[f.ally] -= f.allyShare;
|
||||
if (nativeShare) expected[f.payee] += nativeShare;
|
||||
if (occupiedShare) expected[f.occupier!] += occupiedShare;
|
||||
assert.deepEqual(homeworldBankerWallets(done), expected);
|
||||
assert.equal(done.aid[f.ally]?.amount ?? 0, 0, 'The pledge is spent once.');
|
||||
assert.deepEqual(homeworldBankerCards(done), f.physical);
|
||||
assertClassicHomeworldSkillsCustody(done);
|
||||
}
|
||||
|
||||
void test('fresh classic Homeworld Banker admission keeps the original board, fourteen skills and separate owners', () => {
|
||||
const f = createHomeworldBankerFixture();
|
||||
assert.equal(f.initial.spiceBankerIncomePreview, true);
|
||||
assert.equal(f.initial.homeworldOccupationPreview, true);
|
||||
assert.ok(f.initial.spiceBankerIncome, 'The fresh Homeworld Banker game owns the real income ledger.');
|
||||
assert.equal(f.initial.status, 'setup');
|
||||
assert.equal(f.setup.status, 'playing');
|
||||
const banker = f.setup.leaderSkills!.assignments.find(assignment => assignment.skill === 'spice-banker')!;
|
||||
assert.equal(banker.owner, f.trainer);
|
||||
assert.equal(banker.leader, f.leader);
|
||||
assert.equal(homeworldBankerPlayer(f.setup, f.trainer).leaders.find(leader => leader.id === f.leader)!.dead, false);
|
||||
assert.notEqual(f.bureaucrat, f.trainer);
|
||||
assert.notEqual(f.bureaucrat, f.payer);
|
||||
assert.notEqual(f.bureaucrat, f.payee);
|
||||
assert.notEqual(f.trainer, f.payee);
|
||||
assert.equal(homeworldBankerPlayer(f.setup, f.bureaucrat).faction, 'fremen');
|
||||
assert.equal(homeworldBankerPlayer(f.setup, f.payee).reserves, 15, 'Junction starts with the native fifteen.');
|
||||
assert.equal(homeworldBankerPlayer(f.setup, f.payee).forces['tueks_sietch:5'], 5);
|
||||
assert.equal(homeworldBankerPlayer(f.setup, f.bureaucrat).forces['sietch_tabr:14'], 10,
|
||||
'The Fremen star placement is a real setup action.');
|
||||
assert.ok(f.setup.players.every(player => !Object.keys(player.forces).some(key => key.startsWith('polar_sink'))));
|
||||
assert.deepEqual(homeworldBankerCards(f.setup), homeworldBankerCards(f.initial), 'No physical card was dealt or lost.');
|
||||
assertClassicHomeworldSkillsCustody(f.setup);
|
||||
});
|
||||
|
||||
void test('high Junction keeps the whole prep invoice, then the native tariff banks its own gross once', () => {
|
||||
const f = createHomeworldBankerFixture();
|
||||
assert.equal(f.prepReceipt.source.kind, 'guild-shipping');
|
||||
assert.equal(f.prepReceipt.source.native, f.payee);
|
||||
assert.equal(f.prepReceipt.source.amount, 4);
|
||||
assert.equal(f.prepReceipt.nativeLow, false);
|
||||
assert.equal(f.prepReceipt.nativeRetained, 4);
|
||||
assert.equal(f.prepReceipt.occupiedAmount, 0);
|
||||
assert.equal(f.prepReceipt.bankRetained, 0, 'A high-population invoice leaves no Bank remainder.');
|
||||
assert.equal(f.prepReceipt.status, 'settled');
|
||||
assert.equal(f.prepaid.spiceBankerIncome!.sources.length, 0, 'No Bank payment existed before the native tariff.');
|
||||
// The Guild's own fifteen-force tariff is a real Bank payment of its gross cost.
|
||||
const tariff = f.junctionEmpty.spiceBankerIncome!.sources.at(-1)!;
|
||||
assert.deepEqual(tariff.source.bankLegs, [{ payer: f.payee, amount: 8 }]);
|
||||
assert.equal(tariff.source.kind, 'shipment');
|
||||
assert.equal(tariff.grant?.owner, f.trainer);
|
||||
assert.equal(homeworldBankerPlayer(f.junctionEmpty, f.payee).forces['habbanya_ridge_sietch:17'], 15);
|
||||
// Junction is genuinely low, and the vacated stronghold is a real move.
|
||||
assert.equal(quoteHomeworldPaymentIncome(f.junctionEmpty, f.payee, 'shipment', 8).low, true);
|
||||
assert.equal(homeworldBankerPlayer(f.junctionEmpty, f.payee).reserves, 0);
|
||||
assert.ok(f.actions.some(step => step.action.type === 'move' && step.actor === f.payee),
|
||||
'The real Guild movement that vacates Tuek’s Sietch stays in the trace.');
|
||||
assert.equal(homeworldBankerPlayer(f.vacated, f.payee).forces[`pasty_mesa:${f.vacateSector}`], 5);
|
||||
assert.ok(f.vacated.players.every(player =>
|
||||
!Object.keys(player.forces).some(key => key.startsWith('tueks_sietch'))));
|
||||
});
|
||||
|
||||
void test('the low unoccupied eight-spice invoice pays each original leg once and stays below the Bank boundary', () => {
|
||||
const f = createHomeworldBankerFixture();
|
||||
const declared = declareHomeworldBankerInvoice(f);
|
||||
assert.equal(homeworldBankerPlayer(declared, f.payer).spice,
|
||||
homeworldBankerPlayer(f.beforePledge, f.payer).spice - f.payerShare);
|
||||
assert.equal(homeworldBankerPlayer(declared, f.ally).spice,
|
||||
homeworldBankerPlayer(f.beforePledge, f.ally).spice - f.allyShare);
|
||||
assert.equal(declared.decision?.kind, 'bureaucratPayment',
|
||||
'The real income window resolves through its original pass queue into the private Bureaucrat offer.');
|
||||
assert.equal(homeworldBankerPlayer(declared, f.payee).spice, homeworldBankerPlayer(f.beforePledge, f.payee).spice);
|
||||
const done = settleHomeworldBankerInvoice(f, declared);
|
||||
const row = receipt(done), source = guildShippingSource(row);
|
||||
assert.equal(source.amount, f.gross);
|
||||
assert.equal(source.nativeIncome, 'received');
|
||||
assert.deepEqual(source.payment.contributions, f.contributions);
|
||||
assert.equal(row.nativeLow, true);
|
||||
assert.equal(row.nativeRetained, 4);
|
||||
assert.equal(row.occupiedAmount, 0);
|
||||
assert.equal(row.bankRetained, 4);
|
||||
assert.equal(row.entitlement, null, 'No occupation exists, so no percentage allocation opens.');
|
||||
assert.equal(row.status, 'settled');
|
||||
// The final Bank legs keep distinct payers and no leg reaches four spice.
|
||||
assert.deepEqual(addedLegs(f.game, done), [{ payer: f.payer, amount: 3 }, { payer: f.ally, amount: 1 }]);
|
||||
assert.equal(addedGrants(f.game, done).length, 0, 'A three-spice primary leg never qualifies.');
|
||||
assert.equal(deferred(done, f.trainer), deferred(f.game, f.trainer));
|
||||
expectWallets(f, done, 4);
|
||||
assert.equal(done.bureaucratPayments?.used.length ?? 0, 0);
|
||||
assert.equal(viewGame(done, f.bureaucrat).bureaucrat.usedThisPhase, false);
|
||||
assert.equal(addedSources(f.game, done).length, 1, 'The invoice records its own single receipt.');
|
||||
reject(declared, f.payer, f.action);
|
||||
});
|
||||
|
||||
void test('the private Bureaucrat diversion combines with the same payer’s remainder and pays at the real Mentat', () => {
|
||||
const f = createHomeworldBankerFixture();
|
||||
const declared = declareHomeworldBankerInvoice(f);
|
||||
const pending = allowHomeworldBankerIncome(declared);
|
||||
assert.equal(pending.decision?.kind, 'bureaucratPayment');
|
||||
assert.equal(pending.decision?.player, f.bureaucrat);
|
||||
for (const viewer of pending.players) {
|
||||
const view = viewGame(pending, viewer.id), invoice = view.bureaucrat.pending!;
|
||||
assert.equal(invoice.payer, null, 'Allied funding stays private.');
|
||||
assert.equal(invoice.payee, f.payee);
|
||||
assert.equal(invoice.owner, f.bureaucrat);
|
||||
assert.equal(invoice.amount, f.gross);
|
||||
assert.equal(invoice.homeworldAllocation, true);
|
||||
for (const field of ['contributions', 'shares', 'qualifyingPayer', 'signature', 'source', 'privateFunding'])
|
||||
assert.equal(field in invoice, false, `${field} is private funding evidence, not a public invoice`);
|
||||
}
|
||||
reject(pending, f.payer, { type: 'decision', event: pending.bureaucratPaymentEvent, redirect: true });
|
||||
reject(pending, f.bureaucrat, { type: 'decision', event: 'expired-bureaucrat-event', redirect: true });
|
||||
const redirected = chooseHomeworldBankerBureaucrat(pending, true);
|
||||
assert.equal(redirected.bureaucratPayments?.used.length, 1);
|
||||
assert.equal(viewGame(redirected, f.bureaucrat).bureaucrat.usedThisPhase, true);
|
||||
assert.notEqual(redirected.decision?.kind, 'homeworldOccupiedPercentage',
|
||||
'An unoccupied world opens no percentage allocation.');
|
||||
const done = settleHomeworldBankerInvoice(f, declared, true);
|
||||
const row = receipt(done), source = guildShippingSource(row);
|
||||
assert.equal(source.amount, f.gross - 2);
|
||||
assert.equal(source.payment.paid, f.gross);
|
||||
assert.equal(source.payment.bankDiversion, 2);
|
||||
assert.deepEqual(source.payment.contributions, f.contributions);
|
||||
assert.equal(row.nativeRetained, 3);
|
||||
assert.equal(row.occupiedAmount, 0);
|
||||
assert.equal(row.bankRetained, 3);
|
||||
assert.deepEqual(addedLegs(f.game, done), [{ payer: f.payer, amount: 4 }, { payer: f.ally, amount: 1 }],
|
||||
'The original two spice join that payer’s own Bank remainder, and never the ally’s leg.');
|
||||
const grants = addedGrants(f.game, done);
|
||||
assert.equal(grants.length, 1);
|
||||
assert.equal(grants[0].grant?.owner, f.trainer);
|
||||
assert.equal(deferred(done, f.trainer), deferred(f.game, f.trainer) + 1);
|
||||
assert.equal(homeworldBankerPlayer(done, f.trainer).spice,
|
||||
homeworldBankerPlayer(f.beforePledge, f.trainer).spice, 'The grant stays in front of the shield.');
|
||||
expectWallets(f, done, 3);
|
||||
assert.equal(done.bureaucratPayments?.used.length, 1);
|
||||
// Only the real Mentat Pause collects the deferred spice.
|
||||
const atCollection = advanceHomeworldBanker(done, state => state.phase === 7 && homeworldBankerClean(state));
|
||||
const beforeMentat = homeworldBankerPlayer(atCollection, f.trainer).spice;
|
||||
const mentat = advanceHomeworldBanker(atCollection, state => state.phase === 8);
|
||||
assert.equal(homeworldBankerPlayer(mentat, f.trainer).spice, beforeMentat + 1);
|
||||
assert.deepEqual(mentat.spiceBankerIncome!.collections.at(-1)?.credits, [{ owner: f.trainer, amount: 1 }]);
|
||||
assert.deepEqual(viewGame(mentat, f.trainer).spiceBankerIncome!.deferred, []);
|
||||
assertClassicHomeworldSkillsCustody(mentat);
|
||||
});
|
||||
|
||||
void test('a Karama cancellation banks the whole low invoice and only the five-spice primary leg qualifies', () => {
|
||||
const f = createHomeworldBankerFixture();
|
||||
const karama = holdHomeworldBankerKarama(f);
|
||||
const declared = declareHomeworldBankerInvoice(f);
|
||||
assert.equal(declared.response?.kind, 'guildIncome');
|
||||
reject(declared, f.payee, { type: 'card', mode: 'cancel', card: karama });
|
||||
reject(declared, f.ally, { type: 'card', mode: 'cancel', card: karama });
|
||||
const done = cancelHomeworldBankerIncome(f, declared);
|
||||
assert.equal(done.response, null);
|
||||
assert.equal(done.decision, null);
|
||||
assert.equal(done.discard.filter(card => card.id === karama).length, 1);
|
||||
assert.equal(done.bureaucratPayments?.pending, undefined);
|
||||
const row = receipt(done), source = guildShippingSource(row);
|
||||
assert.equal(source.nativeIncome, 'karama-canceled');
|
||||
assert.equal(source.amount, f.gross);
|
||||
assert.equal(row.nativeRetained, 0);
|
||||
assert.equal(row.occupiedAmount, 0);
|
||||
assert.equal(row.bankRetained, f.gross, 'Canceled ordinary income leaves all remaining cash in the Bank.');
|
||||
assert.deepEqual(addedLegs(f.game, done), f.contributions);
|
||||
const grants = addedGrants(f.game, done);
|
||||
assert.equal(grants.length, 1);
|
||||
assert.equal(deferred(done, f.trainer), deferred(f.game, f.trainer) + 1);
|
||||
expectWallets(f, done, 0);
|
||||
assert.equal(homeworldBankerPlayer(done, f.payee).spice, homeworldBankerPlayer(f.beforePledge, f.payee).spice,
|
||||
'A canceled income credits the native nothing.');
|
||||
});
|
||||
|
||||
void test('an occupied low Junction pays only the occupier’s allocated share as player income', () => {
|
||||
const f = createHomeworldBankerFixture({ occupied: true });
|
||||
assert.ok(f.occupied);
|
||||
const authority = quoteStableHomeworldOccupation(f.occupied!, 'junction');
|
||||
assert.equal(authority.blocked, null);
|
||||
assert.equal(authority.entitlement?.occupier, f.occupier);
|
||||
assert.deepEqual(f.occupied!.homeworlds!.custody!.visitors['homeworld:guild'], { [f.occupier!]: { normal: 1, elite: 0 } });
|
||||
const done = settleHomeworldBankerInvoice(f, declareHomeworldBankerInvoice(f));
|
||||
const row = receipt(done);
|
||||
assert.equal(row.source.amount, f.gross);
|
||||
assert.equal(row.nativeRetained, 4);
|
||||
assert.equal(row.occupiedAmount, 4);
|
||||
assert.equal(row.bankRetained, 0);
|
||||
assert.equal(row.ownAmount, 4);
|
||||
assert.deepEqual(row.credits, [{ player: f.occupier, amount: 4 }]);
|
||||
assert.equal(row.entitlement?.occupier, f.occupier);
|
||||
assert.deepEqual(addedSources(f.game, done), [],
|
||||
'Occupier cash is player income; an occupied invoice leaves the Bank nothing to award.');
|
||||
assert.equal(deferred(done, f.trainer), deferred(f.game, f.trainer));
|
||||
expectWallets(f, done, 4, 4);
|
||||
assert.equal(done.bureaucratPayments?.used.length ?? 0, 0);
|
||||
});
|
||||
|
||||
void test('an occupied invoice with a private diversion banks exactly the diversion and grants nothing', () => {
|
||||
const f = createHomeworldBankerFixture({ occupied: true });
|
||||
const declared = declareHomeworldBankerInvoice(f);
|
||||
const pending = allowHomeworldBankerIncome(declared);
|
||||
assert.equal(pending.decision?.kind, 'bureaucratPayment');
|
||||
const redirected = chooseHomeworldBankerBureaucrat(pending, true);
|
||||
assert.equal(redirected.decision?.kind, 'homeworldOccupiedPercentage');
|
||||
assert.equal(redirected.decision?.player, f.occupier);
|
||||
const offer = viewGame(redirected, f.occupier!).homeworldOccupiedPercentage!;
|
||||
assert.equal(offer.world, 'homeworld:guild');
|
||||
assert.equal(offer.amount, 3);
|
||||
assert.equal(offer.ally, null);
|
||||
reject(redirected, f.payee, { type: 'decision', event: offer.event, ownAmount: offer.maxOwnAmount });
|
||||
reject(redirected, f.occupier!, { type: 'decision', event: 'stale-percentage-event', ownAmount: 0 });
|
||||
reject(redirected, f.occupier!, { type: 'decision', event: offer.event, ownAmount: 0 });
|
||||
const done = allocateHomeworldBankerPercentage(redirected);
|
||||
const row = receipt(done), source = guildShippingSource(row);
|
||||
assert.equal(source.amount, f.gross - 2);
|
||||
assert.equal(source.payment.bankDiversion, 2);
|
||||
assert.equal(row.nativeRetained, 3);
|
||||
assert.equal(row.occupiedAmount, 3);
|
||||
assert.equal(row.bankRetained, 0, 'The diversion is taken before the occupied split.');
|
||||
assert.deepEqual(row.credits, [{ player: f.occupier, amount: 3 }]);
|
||||
assert.deepEqual(addedLegs(f.game, done), [{ payer: f.payer, amount: 2 }],
|
||||
'Only the two-spice diversion reaches the Bank, and never pooled with the ally.');
|
||||
assert.equal(addedGrants(f.game, done).length, 0, 'Two spice alone never reaches the printed boundary.');
|
||||
assert.equal(deferred(done, f.trainer), deferred(f.game, f.trainer));
|
||||
expectWallets(f, done, 3, 3);
|
||||
});
|
||||
|
||||
void test('an occupied cancellation banks the native half only and leaves three/one below the boundary', () => {
|
||||
const f = createHomeworldBankerFixture({ occupied: true });
|
||||
holdHomeworldBankerKarama(f);
|
||||
const declared = declareHomeworldBankerInvoice(f);
|
||||
const canceled = cancelHomeworldBankerIncome(f, declared);
|
||||
assert.equal(canceled.response, null);
|
||||
assert.equal(canceled.bureaucratPayments?.pending, undefined);
|
||||
assert.equal(canceled.decision?.kind, 'homeworldOccupiedPercentage');
|
||||
assert.equal(canceled.decision?.player, f.occupier);
|
||||
const done = allocateHomeworldBankerPercentage(canceled);
|
||||
const row = receipt(done), source = guildShippingSource(row);
|
||||
assert.equal(source.nativeIncome, 'karama-canceled');
|
||||
assert.equal(source.amount, f.gross);
|
||||
assert.equal(row.nativeRetained, 0);
|
||||
assert.equal(row.occupiedAmount, 4, 'The canceled native half does not cancel the printed occupied share.');
|
||||
assert.equal(row.bankRetained, 4);
|
||||
assert.deepEqual(row.credits, [{ player: f.occupier, amount: 4 }]);
|
||||
assert.deepEqual(addedLegs(f.game, done), [{ payer: f.payer, amount: 3 }, { payer: f.ally, amount: 1 }]);
|
||||
assert.equal(addedGrants(f.game, done).length, 0, 'A final three-spice leg never qualifies.');
|
||||
assert.equal(homeworldBankerPlayer(done, f.payee).spice, homeworldBankerPlayer(f.beforePledge, f.payee).spice);
|
||||
expectWallets(f, done, 0, 4);
|
||||
});
|
||||
|
||||
for (const [payerShare, expected] of [
|
||||
[5, [{ payer: 'payer', amount: 3 }, { payer: 'ally', amount: 3 }]],
|
||||
[6, [{ payer: 'payer', amount: 4 }, { payer: 'ally', amount: 2 }]],
|
||||
] as const) {
|
||||
void test(`the odd eleven-spice stronghold invoice orders its extra Bank unit on the declared primary first (${payerShare}+${11 - payerShare})`, () => {
|
||||
const f = createHomeworldBankerFixture({ occupied: true, stronghold: true, payerShare });
|
||||
assert.equal(f.gross, 11);
|
||||
assert.equal(f.allyShare, 11 - payerShare);
|
||||
assert.equal(f.target, 'tueks_sietch');
|
||||
const reserves = homeworldBankerPlayer(f.game, f.payer).reserves;
|
||||
const karama = holdHomeworldBankerKarama(f);
|
||||
const declared = declareHomeworldBankerInvoice(f);
|
||||
const canceled = cancelHomeworldBankerIncome(f, declared);
|
||||
assert.equal(canceled.decision?.kind, 'homeworldOccupiedPercentage');
|
||||
const done = allocateHomeworldBankerPercentage(canceled);
|
||||
const row = receipt(done), source = guildShippingSource(row);
|
||||
assert.equal(source.amount, 11);
|
||||
assert.equal(source.nativeIncome, 'karama-canceled');
|
||||
assert.equal(row.nativeRetained, 0);
|
||||
assert.equal(row.occupiedAmount, 5);
|
||||
assert.equal(row.bankRetained, 6);
|
||||
assert.deepEqual(addedLegs(f.game, done), expected.map(leg => ({
|
||||
payer: leg.payer === 'payer' ? f.payer : f.ally, amount: leg.amount,
|
||||
})));
|
||||
const grants = addedGrants(f.game, done);
|
||||
assert.equal(grants.length, payerShare === 5 ? 0 : 1,
|
||||
'Only a final four-spice primary leg reaches the printed boundary.');
|
||||
assert.equal(homeworldBankerPlayer(done, f.payer).reserves, reserves - 11);
|
||||
assert.equal(homeworldBankerPlayer(done, f.payer).forces['tueks_sietch:5'], 11);
|
||||
assert.equal(done.discard.filter(card => card.id === karama).length, 1);
|
||||
expectWallets(f, done, 0, 5);
|
||||
});
|
||||
}
|
||||
|
||||
void test('the stronghold diversion selects the first eligible contribution in declared order', () => {
|
||||
const f = createHomeworldBankerFixture({ occupied: true, stronghold: true, payerShare: 5 });
|
||||
assert.deepEqual(f.contributions, [{ payer: f.payer, amount: 5 }, { payer: f.ally, amount: 6 }]);
|
||||
const declared = declareHomeworldBankerInvoice(f);
|
||||
const pending = allowHomeworldBankerIncome(declared);
|
||||
assert.equal(pending.decision?.kind, 'bureaucratPayment');
|
||||
const done = settleHomeworldBankerInvoice(f, declared, true);
|
||||
const row = receipt(done), source = guildShippingSource(row);
|
||||
assert.equal(source.amount, 9);
|
||||
assert.equal(source.payment.bankDiversion, 2);
|
||||
assert.equal(row.occupiedAmount, 4);
|
||||
assert.deepEqual(addedLegs(f.game, done), [{ payer: f.payer, amount: 2 }],
|
||||
'The first eligible contribution is the declared primary payer, not the larger ally.');
|
||||
assert.equal(addedGrants(f.game, done).length, 0);
|
||||
expectWallets(f, done, 5, 4);
|
||||
});
|
||||
|
||||
void test('a below-five primary leaves the private diversion to the ally contribution', () => {
|
||||
const f = createHomeworldBankerFixture({ occupied: true, stronghold: true, payerShare: 4 });
|
||||
const declared = declareHomeworldBankerInvoice(f);
|
||||
const pending = allowHomeworldBankerIncome(declared);
|
||||
assert.equal(pending.decision?.kind, 'bureaucratPayment');
|
||||
const done = settleHomeworldBankerInvoice(f, declared, true);
|
||||
const row = receipt(done);
|
||||
assert.equal(row.source.amount, 9);
|
||||
assert.equal(row.occupiedAmount, 4);
|
||||
assert.deepEqual(addedLegs(f.game, done), [{ payer: f.ally, amount: 2 }],
|
||||
'The first contribution of at least five spice is the ally’s.');
|
||||
assert.equal(addedGrants(f.game, done).length, 0);
|
||||
expectWallets(f, done, 5, 4);
|
||||
});
|
||||
|
||||
void test('a second qualifying Bank payment in the same phase adds no further grant', () => {
|
||||
const f = createHomeworldBankerFixture({ payerShare: 6 });
|
||||
const karama = holdHomeworldBankerKarama(f);
|
||||
const declared = declareHomeworldBankerInvoice(f);
|
||||
const canceled = cancelHomeworldBankerIncome(f, declared);
|
||||
assert.equal(addedGrants(f.game, canceled).length, 1);
|
||||
const granted = deferred(canceled, f.trainer);
|
||||
const second = advanceHomeworldBanker(canceled, state => state.turn === 3 && state.phase === 5 &&
|
||||
state.active === f.ally && homeworldBankerClean(state));
|
||||
const payment = homeworldBankerShipment(second, f.ally, 'tueks_sietch', 8);
|
||||
const settled = advanceHomeworldBanker(applyAction(second, f.ally, payment), homeworldBankerClean);
|
||||
const added = addedSources(canceled, settled);
|
||||
assert.equal(added.length, 1, 'The second real payment still records its own Bank legs.');
|
||||
assert.deepEqual(added[0].source.bankLegs, [{ payer: f.ally, amount: 4 }]);
|
||||
assert.equal(added[0].grant, null, 'The physical skill already earned this phase.');
|
||||
assert.equal(deferred(settled, f.trainer), granted);
|
||||
assert.equal(viewGame(settled, f.trainer).spiceBankerIncome!.usedThisPhase.includes(f.trainer), true);
|
||||
assert.equal(canceled.discard.filter(card => card.id === karama).length, 1);
|
||||
});
|
||||
|
||||
void test('a below-threshold Bank remainder creates no Banker payment at all', () => {
|
||||
const f = createHomeworldBankerFixture();
|
||||
const done = settleHomeworldBankerInvoice(f, declareHomeworldBankerInvoice(f));
|
||||
const second = advanceHomeworldBanker(done, state => state.turn === 3 && state.phase === 5 &&
|
||||
state.active === f.ally && homeworldBankerClean(state));
|
||||
const payment = homeworldBankerShipment(second, f.ally, 'tueks_sietch', 2);
|
||||
const settled = advanceHomeworldBanker(applyAction(second, f.ally, payment), homeworldBankerClean);
|
||||
const added = addedSources(done, settled);
|
||||
assert.deepEqual(added.flatMap(row => row.source.bankLegs), [{ payer: f.ally, amount: 1 }],
|
||||
'The true one-spice Bank remainder is recorded without any grant.');
|
||||
assert.equal(added.every(row => row.grant === null), true);
|
||||
assert.equal(deferred(settled, f.trainer), deferred(done, f.trainer));
|
||||
const row = receipt(settled);
|
||||
assert.equal(row.source.amount, 2);
|
||||
assert.equal(row.nativeRetained, 1);
|
||||
assert.equal(row.bankRetained, 1);
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame } from '../game/engine';
|
||||
import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
|
||||
import { junctionTransportChoice } from '../game/junction-transport-options';
|
||||
import {
|
||||
createHomeworldBankerFixture, homeworldBankerGroup, homeworldBankerPlayer,
|
||||
advanceHomeworldBanker, homeworldBankerClean, settleHomeworldBankerInvoice, declareHomeworldBankerInvoice,
|
||||
} from './fixture-homeworld-banker-income';
|
||||
|
||||
void test('world-to-world native fees remain Bank payments and fund only deferred Banker income', () => {
|
||||
const f = createHomeworldBankerFixture();
|
||||
const before = f.beforePledge;
|
||||
const actor = homeworldBankerPlayer(before,f.payer);
|
||||
const choice = homeworldShipmentChoice(viewGame(before,f.payer),'homeworld:guild',
|
||||
{[homeworldBankerGroup(before,f.payer)]:{normal:4,elite:0}});
|
||||
assert.ok(choice.action,choice.blocked ?? undefined);
|
||||
const done = advanceHomeworldBanker(applyAction(before,f.payer,choice.action),homeworldBankerClean);
|
||||
assert.equal(homeworldBankerPlayer(done,f.payer).spice,actor.spice-4);
|
||||
assert.equal(homeworldBankerPlayer(done,f.payer).reserves,actor.reserves-4);
|
||||
assert.equal(done.homeworlds!.custody!.visitors['homeworld:guild'][f.payer].normal,4);
|
||||
assert.deepEqual(done.spiceBankerIncome!.sources.at(-1)!.source.bankLegs,[{payer:f.payer,amount:4}]);
|
||||
assert.deepEqual(viewGame(done,f.trainer).spiceBankerIncome!.deferred,[{owner:f.trainer,amount:1}]);
|
||||
assert.equal(homeworldBankerPlayer(done,f.trainer).spice,homeworldBankerPlayer(before,f.trainer).spice);
|
||||
});
|
||||
|
||||
void test('Junction-sponsored board return charges the real Bank tariff without manufacturing Guild income', () => {
|
||||
const f = createHomeworldBankerFixture();
|
||||
let game = advanceHomeworldBanker(f.setup,g=>g.turn===1&&g.phase===5&&g.active==='harkonnen'&&homeworldBankerClean(g));
|
||||
const offer = viewGame(game,'guild').junctionTransport!;
|
||||
assert.equal(offer.canOffer,true);
|
||||
game = applyAction(game,'guild',{type:'offerJunctionTransport',event:offer.offerEvent,rate:'half'});
|
||||
const before = game, actor = homeworldBankerPlayer(game,'harkonnen');
|
||||
const source = Object.keys(actor.forces).find(key=>key.startsWith('carthag:'))!;
|
||||
const choice = junctionTransportChoice(viewGame(game,'harkonnen'),'homeworld:harkonnen',
|
||||
{[source]:{normal:8,elite:0}});
|
||||
assert.ok(choice.action,choice.blocked ?? undefined);
|
||||
assert.equal(choice.cost,4);
|
||||
const done = applyAction(game,'harkonnen',choice.action);
|
||||
assert.equal(homeworldBankerPlayer(done,'harkonnen').spice,actor.spice-4);
|
||||
assert.equal(homeworldBankerPlayer(done,'harkonnen').reserves,actor.reserves+8);
|
||||
assert.equal(homeworldBankerPlayer(done,'harkonnen').forces[source],actor.forces[source]-8);
|
||||
assert.equal(homeworldBankerPlayer(done,'guild').spice,homeworldBankerPlayer(before,'guild').spice);
|
||||
assert.deepEqual(done.spiceBankerIncome!.sources.at(-1)!.source.bankLegs,[{payer:'harkonnen',amount:4}]);
|
||||
assert.deepEqual(viewGame(done,f.trainer).spiceBankerIncome!.deferred,[{owner:f.trainer,amount:1}]);
|
||||
assert.equal(homeworldBankerPlayer(done,f.trainer).spice,homeworldBankerPlayer(before,f.trainer).spice);
|
||||
});
|
||||
|
||||
void test('the first eligible declared contribution controls the combined low-world Bank threshold without private choice', () => {
|
||||
for (const payerShare of [5,6]) {
|
||||
const f = createHomeworldBankerFixture({stronghold:true,payerShare});
|
||||
const done = settleHomeworldBankerInvoice(f,declareHomeworldBankerInvoice(f),true);
|
||||
const expected = payerShare===5
|
||||
? [{payer:f.payer,amount:3},{payer:f.ally,amount:3}]
|
||||
: [{payer:f.payer,amount:4},{payer:f.ally,amount:2}];
|
||||
const receipt = done.spiceBankerIncome!.sources.at(-1)!;
|
||||
assert.deepEqual(receipt.source.bankLegs,expected);
|
||||
assert.equal(receipt.grant?.owner ?? null,payerShare===5?null:f.trainer);
|
||||
assert.equal(homeworldBankerPlayer(done,f.payer).spice,
|
||||
homeworldBankerPlayer(f.beforePledge,f.payer).spice-f.payerShare);
|
||||
assert.equal(homeworldBankerPlayer(done,f.ally).spice,
|
||||
homeworldBankerPlayer(f.beforePledge,f.ally).spice-f.allyShare);
|
||||
assert.equal(done.homeworldOccupiedPercentageLedger!.receipts.at(-1)!.source.amount,9);
|
||||
}
|
||||
});
|
||||
@@ -133,15 +133,10 @@ void test('Junction excludes actual Guild funding before rounding; Guild-only pa
|
||||
}
|
||||
});
|
||||
|
||||
void test('Guild unresolved two odd contributions remain blocked, while a conserved even/odd original payment is reachable', () => {
|
||||
void test('Guild percentage allocation retains whole original payer receipts', () => {
|
||||
const { game } = occupiedIncomeFixture('junction', false, false);
|
||||
const original = actualSource('guild-shipping', 4);
|
||||
if (original.kind !== 'guild-shipping') throw new Error('Fixture source kind');
|
||||
const twoOdd: OccupiedPercentageSource = { ...original, payment: { ...original.payment,
|
||||
contributions: [{ payer: 'owner', amount: 1 }, { payer: 'competitor', amount: 3 }] } };
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => quoteOccupiedPercentageSource(game, twoOdd, createOccupiedPercentageState()), HomeworldCustodyError);
|
||||
assert.deepEqual(game, before);
|
||||
const supported: OccupiedPercentageSource = { ...original, amount: 5, payment: { ...original.payment, paid: 5,
|
||||
contributions: [{ payer: 'owner', amount: 2 }, { payer: 'competitor', amount: 3 }] } };
|
||||
const initial = balances(game);
|
||||
|
||||
@@ -490,29 +490,13 @@ void test('real Richese Ambassador purchase applies low Kaitain income after rel
|
||||
}
|
||||
});
|
||||
|
||||
void test('low Junction rejects ambiguous one-plus-one funding before interception and accepts a single two-spice contribution', () => {
|
||||
void test('a fully pledged low-Junction shipment retains the original two-spice debit and whole-invoice native income', () => {
|
||||
let g = movement(fixture(), 'a');
|
||||
population(g, 'g', 4);
|
||||
own(g, 'a').ally = 'e';
|
||||
own(g, 'e').ally = 'a';
|
||||
const karama = hold(g, 'e', 'karama');
|
||||
g = applyAction(g, 'e', { type: 'pledgeAid', amount: 2 });
|
||||
const before = reload(g);
|
||||
assert.throws(
|
||||
() =>
|
||||
applyAction(g, 'a', {
|
||||
type: 'ship',
|
||||
territory: 'carthag',
|
||||
sector: 11,
|
||||
amount: 2,
|
||||
allyPayment: 1,
|
||||
}),
|
||||
/rounding.*ruling/,
|
||||
);
|
||||
assert.deepEqual(g, before);
|
||||
assert.equal(g.pendingShipment ?? null, null);
|
||||
assert.equal(g.decision, null);
|
||||
assert.equal(g.aid.e.amount, 2);
|
||||
g = applyAction(g, 'a', {
|
||||
type: 'ship',
|
||||
territory: 'carthag',
|
||||
@@ -533,39 +517,6 @@ void test('low Junction rejects ambiguous one-plus-one funding before intercepti
|
||||
assert.equal(own(done, 'g').spice, 21);
|
||||
});
|
||||
|
||||
void test('all AI profiles omit shipment candidates with unresolved low-Junction allied rounding', () => {
|
||||
let g = movement(fixture(), 'a');
|
||||
population(g, 'g', 4);
|
||||
own(g, 'a').ally = 'e';
|
||||
own(g, 'e').ally = 'a';
|
||||
own(g, 'a').spice = 1;
|
||||
g = applyAction(g, 'e', { type: 'pledgeAid', amount: 1 });
|
||||
const blocked: { level: string; action: unknown }[] = [];
|
||||
for (const level of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
|
||||
const view = viewGame(reload(g), 'a');
|
||||
view.players.find((p) => p.id === 'a')!.bot = level;
|
||||
const actions = botActions(view);
|
||||
assert.ok(actions.length > 0, `${level} must retain a legal fallback.`);
|
||||
for (const action of actions.filter((a) => a.type === 'ship')) {
|
||||
try {
|
||||
applyAction(reload(g), 'a', action);
|
||||
} catch (error) {
|
||||
if (/rounding.*ruling/.test(String(error)))
|
||||
blocked.push({ level, action });
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.deepEqual(
|
||||
Object.fromEntries(
|
||||
[...new Set(blocked.map((entry) => entry.level))].map((level) => [
|
||||
level,
|
||||
blocked.filter((entry) => entry.level === level).length,
|
||||
]),
|
||||
),
|
||||
{},
|
||||
'Every profile must omit the unresolved funding split.',
|
||||
);
|
||||
});
|
||||
|
||||
void test('Guild payment contributor receipts reject partial deletion or changed gross and remain absent from private projections', () => {
|
||||
let g = movement(fixture(), 'a');
|
||||
|
||||
@@ -6,10 +6,7 @@ import {
|
||||
HomeworldCustodyError,
|
||||
} from '../game/homeworld-custody';
|
||||
import { homeworldContext } from '../game/homeworld-game';
|
||||
import {
|
||||
quoteGuildPaymentRounding,
|
||||
quoteHomeworldPaymentIncome,
|
||||
} from '../game/homeworld-payment-income';
|
||||
import { quoteHomeworldPaymentIncome } from '../game/homeworld-payment-income';
|
||||
|
||||
function fixture(
|
||||
faction: 'guild' | 'emperor',
|
||||
@@ -72,74 +69,20 @@ void test('odd, even, zero and maximal safe single payment conserve spice', () =
|
||||
}
|
||||
});
|
||||
|
||||
void test('Guild rounding comparisons agree for single payer, even-even and mixed parity contributions', () => {
|
||||
for (const [gross, contributions, income] of [
|
||||
[0, [], 0],
|
||||
[0, [0, 0], 0],
|
||||
[1, [1], 1],
|
||||
[5, [5], 3],
|
||||
[5, [0, 5], 3],
|
||||
[6, [2, 4], 3],
|
||||
[5, [2, 3], 3],
|
||||
[Number.MAX_SAFE_INTEGER, [Number.MAX_SAFE_INTEGER], 4_503_599_627_370_496],
|
||||
] as [number, number[], number][]) {
|
||||
const before = JSON.stringify(contributions);
|
||||
Object.freeze(contributions);
|
||||
assert.deepEqual(quoteGuildPaymentRounding(gross, contributions), {
|
||||
transaction: income,
|
||||
contribution: income,
|
||||
unambiguous: true,
|
||||
});
|
||||
assert.equal(JSON.stringify(contributions), before);
|
||||
}
|
||||
});
|
||||
|
||||
void test('Guild rounding exposes odd-odd disagreement without selecting either interpretation', () => {
|
||||
assert.deepEqual(quoteGuildPaymentRounding(2, [1, 1]), {
|
||||
transaction: 1,
|
||||
contribution: 2,
|
||||
unambiguous: false,
|
||||
});
|
||||
const restored = JSON.parse('[3,3]') as number[];
|
||||
assert.deepEqual(quoteGuildPaymentRounding(6, restored), {
|
||||
transaction: 3,
|
||||
contribution: 4,
|
||||
unambiguous: false,
|
||||
});
|
||||
assert.deepEqual(restored, [3, 3]);
|
||||
});
|
||||
|
||||
void test('Guild rounding rejects malformed amounts, overflow, sparse arrays and mismatched payment totals', () => {
|
||||
const invalid: [unknown, unknown][] = [
|
||||
[-1, []],
|
||||
[NaN, []],
|
||||
[Infinity, []],
|
||||
[0.5, [0.5]],
|
||||
[Number.MAX_SAFE_INTEGER + 1, [Number.MAX_SAFE_INTEGER, 1]],
|
||||
[0, undefined],
|
||||
[0, null],
|
||||
[0, {}],
|
||||
[1, '1'],
|
||||
[0, [0, 0, 0]],
|
||||
[0, [-1, 1]],
|
||||
[0, [NaN]],
|
||||
[1, [Infinity]],
|
||||
[1, [0.5, 0.5]],
|
||||
[1, ['1']],
|
||||
[0, Array(1)],
|
||||
[1, []],
|
||||
[3, [1, 1]],
|
||||
[Number.MAX_SAFE_INTEGER, [Number.MAX_SAFE_INTEGER, 1]],
|
||||
];
|
||||
for (const [gross, contributions] of invalid) {
|
||||
const before = JSON.stringify(contributions);
|
||||
assert.throws(
|
||||
() =>
|
||||
quoteGuildPaymentRounding(gross as number, contributions as number[]),
|
||||
HomeworldCustodyError,
|
||||
);
|
||||
assert.equal(JSON.stringify(contributions), before);
|
||||
}
|
||||
void test('low Guild and Emperor round a funded odd-odd invoice once, not each contributor', () => {
|
||||
for (const advanced of [false, true])
|
||||
for (const [faction, kind] of [
|
||||
['guild', 'shipment'],
|
||||
['emperor', 'treachery'],
|
||||
] as const) {
|
||||
const context = fixture(faction, 4, advanced);
|
||||
const contributions = [5, 3];
|
||||
const gross = contributions.reduce((sum, amount) => sum + amount, 0);
|
||||
assert.deepEqual(
|
||||
quoteHomeworldPaymentIncome(context, 'native', kind, gross),
|
||||
{ gross: 8, income: 4, bank: 4, low: true },
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
void test('typed Kaitain natives count while Salusa allocation and visiting armies do not', () => {
|
||||
|
||||
@@ -879,16 +879,11 @@ void test('Banker income rejects incompatible profiles and public activation wit
|
||||
game => { game.players.splice(1); },
|
||||
game => { game.players.push(...Array.from({ length: 4 }, (_, index) => newPlayer(`extra${index}`, `Extra ${index}`, 'guild'))); },
|
||||
];
|
||||
const modules = ['homeworlds'].map(type => {
|
||||
const game = applyAction(native, native.host, { type, enabled: true });
|
||||
for (const player of game.players) player.ready = true;
|
||||
return game;
|
||||
});
|
||||
for (const game of [...changes.map(change => {
|
||||
for (const game of changes.map(change => {
|
||||
const game = structuredClone(native);
|
||||
change(game);
|
||||
return game;
|
||||
}), ...modules]) {
|
||||
})) {
|
||||
db.prepare('UPDATE rooms SET state=? WHERE code=?').run(JSON.stringify(game), native.code);
|
||||
const rooms = db.prepare('SELECT * FROM rooms ORDER BY code').all();
|
||||
const credentials = db.prepare('SELECT * FROM seats').all();
|
||||
|
||||
Reference in new issue
Block a user