Connect classic Homeworld Banker final Bank income and ruled rounding

This commit is contained in:
Quintine committed 2026-10-14 13:10:33 +11:00
1 parent 2efa69d10f
commit 373badd560
27 files changed
+1624 -280

No files matched your search

+1 -6
View File
@@ -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);
+469
View File
@@ -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;
}
+314
View File
@@ -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,
);
});
+399
View File
@@ -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);
});
+65
View File
@@ -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);
}
});
+1 -6
View File
@@ -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);
+1 -50
View File
@@ -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');
+15 -72
View File
@@ -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', () => {
+2 -7
View File
@@ -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();