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

+91 -33
View File
@@ -492,7 +492,7 @@ import { quoteRicheseBetrayalInvoice, requiredRicheseBetrayalResponders, richese
import { saleOptions, quoteSale, type ChoamMarket } from './choam-market';
import { highKaitainDiscardsAvailable, quoteKaitainDiscards, homeworldWorthlessSaleBlock } from './homeworld-card-economy';
import { biddingEndError, biddingEndQuiet, biddingEndPubliclyEmpty, type BiddingEnd } from './bidding-end';
import { quoteHomeworldPaymentIncome, quoteGuildPaymentRounding } from './homeworld-payment-income';
import { quoteHomeworldPaymentIncome, quoteHomeworldBankPayment } from './homeworld-payment-income';
import { quoteEcazPoisonIncome, EcazPoisonIncomeError, type EcazPoisonDiscard } from './ecaz-poison-income';
import { resolveBattleWeapons } from './effective-weapons';
import { choamGholaEvent, choamGholaSkillOfferEvent, choamGholaSkillSignature, choamMarketGholaError, choamSaleGholaTiming, type ChoamMarketGhola } from './choam-market-ghola';
@@ -8318,11 +8318,13 @@ function bankerIncomeContext(g: Game): BankerIncomeContext {
}
function bankerIncomeProfile(g: Game): boolean {
const classicNexus = classicNexusLeaderSkillsProfile(g);
const homeworlds = !!g.homeworlds && !g.expansions.length && !g.nexusCards &&
!g.techTokens && !g.strongholdCards && homeworldLeaderSkillsProfile(g);
return g.players.length >= 2 && g.players.length <= 6 &&
new Set(g.players.map(p => p.faction)).size === g.players.length &&
((!g.expansions.length && g.players.every(p => FACTIONS.some(f => f.id === p.faction && f.expansion === 'base'))) ||
nativeExpansionLeaderSkillsProfile(g)) &&
(!g.nexusCards || classicNexus) && !g.homeworlds &&
(!g.nexusCards || classicNexus) && (!g.homeworlds || homeworlds) &&
(!g.techTokens || classicTechLeaderSkillsProfile(g) || nativeTechLeaderSkillsProfile(g) || strongholdLeaderSkillsProfile(g) || classicNexus) &&
(!g.strongholdCards || strongholdLeaderSkillsProfile(g) || classicNexus) && !g.discoveryEnabled && !g.discoveries && !g.discoveryStash &&
!g.greatMaker && !g.ecazTreachery && !g.semutaPreview &&
@@ -8398,10 +8400,27 @@ function bankerIncomePayment(g: Game, kind: BankerIncomeAuthority['kind'],
payment.signature = bankerIncomePaymentSignature(payment);
return payment;
}
type BureaucratSettlement = { amount: 0 } | { amount: 2; payer: string };
function firstBureaucratSettlement(source: BureaucratPaymentSource, owner: string,
amount: 0 | 2): BureaucratSettlement {
if (amount === 0) return { amount: 0 };
let payer: string | undefined;
if (source.contributions) {
for (const leg of source.contributions) {
if (leg.amount < 5 || leg.payer === owner || leg.payer === source.payee) continue;
if (leg.payer === source.payer) { payer = leg.payer; break; }
if (payer === undefined) payer = leg.payer;
}
} else if (source.amount >= 5 && source.payer !== owner && source.payer !== source.payee) {
payer = source.payer;
}
requireRule(payer !== undefined, 'The original payment has no eligible Bureaucrat contribution.');
return { amount: 2, payer: payer! };
}
function awardBankerIncome(g: Game, payment: SpiceBankerIncomePayment | undefined,
canceled = false, resolvedBattle = false): void {
canceled = false, resolvedBattle = false, finalBankLegs?: BankerIncomeAuthority['bankLegs']): void {
if (!payment) return;
const bankLegs = payment.legs.filter(leg => leg.recipient === 'bank' || canceled)
const bankLegs = finalBankLegs ?? payment.legs.filter(leg => leg.recipient === 'bank' || canceled)
.map(({payer, amount}) => ({payer, amount}));
if (!bankLegs.length) return;
const assignment = g.leaderSkills?.assignments.find(a => a.skill === 'spice-banker');
@@ -8422,6 +8441,21 @@ function awardBankerIncome(g: Game, payment: SpiceBankerIncomePayment | undefine
{faction: owner.faction, name: 'Spice Banker'});
}
}
/** Original invoice legs stay signed and private. Only its actually retained
* Bank portion, including the same payer's selected diversion, earns income. */
function awardFinalFactionBankerIncome(g: Game, payment: SpiceBankerIncomePayment | undefined,
bankRetained: number, settlement?: BureaucratSettlement): void {
if (!payment) return;
const playerLegs: { payer: string; amount: number }[] = [];
const bankLegs: BankerIncomeAuthority['bankLegs'] = [];
for (const leg of payment.legs) {
if (leg.recipient === 'bank') bankLegs.push({ payer: leg.payer, amount: leg.amount });
else playerLegs.push({ payer: leg.payer, amount: leg.amount });
}
const diversion = settlement?.amount === 2 ? { payer: settlement.payer, amount: 2 as const } : undefined;
bankLegs.push(...homeworldRule(() => quoteHomeworldBankPayment(playerLegs, bankRetained, diversion)));
awardBankerIncome(g, payment, false, false, bankLegs);
}
function collectBankerIncome(g: Game): void {
if (!g.spiceBankerIncomePreview) return;
const context = bankerIncomeContext(g), authority = {event: crypto.randomUUID(), turn: g.turn, phase: 8 as const};
@@ -8434,14 +8468,17 @@ function collectBankerIncome(g: Game): void {
{faction: owner.faction, name: 'Spice Banker'});
}
}
function settleBankerShipmentIncome(g: Game, p: Player, cost: number, allyPayment: number): void {
function settleBankerShipmentIncome(g: Game, p: Player, cost: number, allyPayment: number,
recipients?: { own: boolean; ally: boolean }): void {
if (!g.spiceBankerIncomePreview) return;
const guild = byFaction(g, 'guild');
const bankOnly = !guild || g.karamaShipping?.player === p.id;
const payment = bankerIncomePayment(g, 'shipment', [
{payer: p.id, amount: cost - allyPayment, recipient: bankOnly || p.id === guild?.id ? 'bank' : 'player'},
{payer: p.id, amount: cost - allyPayment,
recipient: recipients ? recipients.own ? 'player' : 'bank' : bankOnly || p.id === guild?.id ? 'bank' : 'player'},
...(allyPayment ? [{payer: p.ally!, amount: allyPayment,
recipient: bankOnly || p.ally === guild?.id ? 'bank' as const : 'player' as const}] : []),
recipient: recipients ? recipients.ally ? 'player' as const : 'bank' as const :
bankOnly || p.ally === guild?.id ? 'bank' as const : 'player' as const}] : []),
]);
if (g.response?.kind === 'guildIncome') g.response.spiceBankerIncomePayment = payment;
else awardBankerIncome(g, payment);
@@ -8608,6 +8645,7 @@ function actBureaucratPayment(g: Game,action:Action) {
requireRule(action.event === pending.source.event && typeof action.redirect === 'boolean' &&
Object.keys(action).every(key => ['type','event','redirect'].includes(key)), 'Choose whether to redirect this exact Bureaucrat payment.');
const redirected = action.redirect ? 2 : 0;
const settlement = firstBureaucratSettlement(pending.source, pending.owner, redirected);
if (redirected) {
const use:BureaucratPaymentUse = {event:pending.source.event,owner:pending.owner,turn:g.turn,phase:g.phase,signature:''};
use.signature = bureaucratPaymentSignature(use);
@@ -8616,7 +8654,7 @@ function actBureaucratPayment(g: Game,action:Action) {
}
delete g.bureaucratPayments!.pending;delete g.bureaucratPaymentEvent;
g.response = pending.resume.response;g.decision = pending.resume.decision;
if (pending.continuation.kind === 'response') finishResponse(g,false,redirected);
if (pending.continuation.kind === 'response') finishResponse(g,false,settlement);
else if (pending.continuation.kind === 'auction') commitAuctionContinuation(g,pending.continuation.quote,redirected);
else if (pending.continuation.kind === 'guildBetrayal') {
// The native adapter already credited the full fee to the holder; the ruled
@@ -9576,7 +9614,7 @@ function guildBetrayalAuthority(g: Game, continuation: GuildBetrayalContinuation
'This Guild Betrayal profile supports native physical reserve shipments only.');
validatePhysicalShipment(g, shipment);
validateShipmentArrival(g, shipment);
checkShipmentIncomeRounding(g, getPlayer(g, shipment.player), shipment.cost, shipment.allyPayment);
checkShipmentIncomeFunding(g, getPlayer(g, shipment.player), shipment.cost, shipment.allyPayment);
checkShipmentPromises(g, getPlayer(g, shipment.player), shipment);
shipper = shipment.player; price = shipment.cost; allyPayment = shipment.allyPayment;
const p = getPlayer(g, shipper);
@@ -9609,7 +9647,7 @@ function guildBetrayalAuthority(g: Game, continuation: GuildBetrayalContinuation
shipper = shipment.player; price = quote.cost; allyPayment = shipment.allyPayment;
originalReceiver = getPlayer(g, shipper).ally !== guild.id && allyPayment > 0 ? guild.id : null;
const income = getPlayer(g, shipper).ally !== guild.id ? allyPayment : 0;
if (income > 0) validateGuildPaymentRounding(g, guild.id, income, [income]);
if (income > 0) validateGuildPaymentContributions(g, income, [income]);
break;
}
case 'junction': {
@@ -10049,6 +10087,12 @@ export function initializeSpiceBankerIncomeGameForAudit(state: Game): Game {
!state.spiceBankerIncomePreview && state.spiceBankerIncome === undefined,
'Spice Banker income requires a fresh classic or supported native skill lobby without unsupported optional modules.');
requireFreshSetup(state, state.expansions.length > 0);
if (state.homeworlds) {
const initialized = initializeHomeworldOccupationGameForAudit(state, true);
initialized.spiceBankerIncomePreview = true;
initialized.spiceBankerIncome = bankerIncomeRule(() => createBankerIncomeState(bankerIncomeContext(initialized)));
return initialized;
}
const g = structuredClone(state);
g.leaderSkills = createLeaderSkills(random);
g.spiceBankerIncomePreview = true;
@@ -16453,7 +16497,8 @@ function occupiedPercentageIntegrity(g: Game) {
g.pendingHomeworldOccupiedBonus === undefined, 'Occupied source receipts require their original fresh profile.');
return;
}
requireRule(!g.spiceBankerIncomePreview, 'Occupied percentage and deferred Banker routing require a separate source-composition profile.');
requireRule(!g.spiceBankerIncomePreview || bankerIncomeProfile(g),
'Occupied percentage and deferred Banker routing require their supported classic Homeworld source profile.');
if (!g.homeworldOccupiedPercentageLedger) {
requireRule(!g.pendingHomeworldOccupiedPercentage && !g.pendingHomeworldOccupiedBonus,
'The original occupied percentage ledger is missing.');
@@ -16605,7 +16650,7 @@ function currentFactionPayment(g: Game) {
requireRule(Array.isArray(response.guildContributions) &&
response.guildPaymentProof === guildPaymentSignature(g, response.owner, gross, response.guildContributions),
'The saved Guild payment no longer matches its original contributor receipt.');
validateGuildPaymentRounding(g, response.owner, gross, response.guildContributions);
validateGuildPaymentContributions(g, gross, response.guildContributions);
}
const payment = homeworldRule(() => quoteHomeworldPaymentIncome(g, response.owner, kind, gross));
const stable = g.homeworldOccupationPreview
@@ -16613,14 +16658,12 @@ function currentFactionPayment(g: Game) {
return { owner: response.owner, kind, ...payment,
...(stable?.entitlement || stable?.blocked ? { income: Math.ceil(gross / 2), bank: 0 } : {}) };
}
function validateGuildPaymentRounding(g: Game, owner: string, gross: number, contributions: number[]) {
function validateGuildPaymentContributions(g: Game, gross: number, contributions: number[]) {
if (!g.homeworlds?.custody) return;
const rounding = homeworldRule(() => quoteGuildPaymentRounding(gross, contributions));
const payment = homeworldRule(() => quoteHomeworldPaymentIncome(g, owner, 'shipment', gross));
const junction = g.homeworldOccupationPreview
? homeworldRule(() => quoteStableHomeworldOccupation(g, 'junction')) : null;
requireRule((!payment.low && !junction?.entitlement && !junction?.blocked) || rounding.unambiguous,
'Low Junction rounding for two odd allied contributions awaits a ruling. Choose a different payment split.');
requireRule(Number.isSafeInteger(gross) && gross >= 0 && Array.isArray(contributions) &&
contributions.length <= 2 && contributions.every(amount => Number.isSafeInteger(amount) && amount >= 0) &&
contributions.reduce((sum, amount) => sum + amount, 0) === gross,
'Guild contributions must preserve the original eligible whole-invoice payment.');
}
function shipmentIncomeContributions(g: Game, p: Player, cost: number, allyPayment: number) {
const guild = byFaction(g, 'guild');
@@ -16628,19 +16671,19 @@ function shipmentIncomeContributions(g: Game, p: Player, cost: number, allyPayme
return [p.id === guild.id ? 0 : cost - allyPayment,
p.ally === guild.id ? 0 : allyPayment].filter(amount => amount > 0);
}
function checkShipmentIncomeRounding(g: Game, p: Player, cost: number, allyPayment: number) {
function checkShipmentIncomeFunding(g: Game, p: Player, cost: number, allyPayment: number) {
const guild = byFaction(g, 'guild');
if (!guild) return;
const amounts = shipmentIncomeContributions(g, p, cost, allyPayment);
checkBureaucratContributors(g,g.karamaShipping?.player === p.id ? undefined : guild.id,[{payer:p.id,amount:p.id === guild.id ? 0 : cost-allyPayment},
...(allyPayment ? [{payer:p.ally!,amount:p.ally === guild.id ? 0 : allyPayment}] : [])],allyPayment > 0);
validateGuildPaymentRounding(g, guild.id, amounts.reduce((sum, amount) => sum + amount, 0), amounts);
validateGuildPaymentContributions(g, amounts.reduce((sum, amount) => sum + amount, 0), amounts);
}
function guildPaymentResponse(g: Game, owner: string, amounts: number[],payer?:string,
named?: { payer: string; amount: number }[]): ResponseWindow {
const contributions = amounts.filter(amount => amount > 0);
const amount = contributions.reduce((sum, value) => sum + value, 0);
validateGuildPaymentRounding(g, owner, amount, contributions);
validateGuildPaymentContributions(g, amount, contributions);
return { kind: 'guildIncome', owner, amount, passed: [],
...(g.homeworldOccupationPreview ? { occupiedPayment: { event: crypto.randomUUID(),
contributions: named ?? [{ payer: payer!, amount }] } } : {}),
@@ -24096,7 +24139,8 @@ function completeChoamGamont(
? `${owner.name} sent one of ${target.name}’s forces to reserves with Trip to Gamont.`
: `${owner.name} used Trip to Gamont to reveal ${target.name}’s No-Field. No force was present to return; the card is still used.`);
}
function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number) {
function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettlement) {
const bureaucratDiversion = settlement?.amount;
karamaConversionIntegrity(g);
currentFactionPayment(g);
const response = g.response!;
@@ -24157,9 +24201,15 @@ function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number)
const suffix: OccupiedPercentageSuffix = response.source === 'ambassador' ? { kind: 'ambassadorBonus' } :
response.kind === 'emperorIncome' ? { kind: 'auctionBonus' } : response.kind === 'richesePurchaseIncome'
? { kind: 'richesePurchase', resume: g.pendingRichesePurchaseIncome!.resume } : { kind: 'none', decision: g.decision };
if (response.kind === 'emperorIncome') awardBankerIncome(g, g.currentAuctionSale?.spiceBankerIncomePayment, canceled);
if (response.kind === 'guildIncome') awardBankerIncome(g, response.spiceBankerIncomePayment, canceled);
const incomePayment = response.kind === 'emperorIncome'
? g.currentAuctionSale?.spiceBankerIncomePayment : response.spiceBankerIncomePayment;
const pending = settleOccupiedPayment(g, response, gross, canceled, suffix, bureaucratDiversion ?? 0);
if (incomePayment) {
const receipt = g.homeworldOccupiedPercentageLedger!.receipts.at(-1);
requireRule(receipt?.source.native === response.owner && receipt.source.amount === gross,
'The final Bank payment lost its original Homeworld income receipt.');
awardFinalFactionBankerIncome(g, incomePayment, receipt!.bankRetained, settlement);
}
if (!pending) runOccupiedPercentageSuffix(g, suffix);
return;
}
@@ -25124,7 +25174,8 @@ function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number)
}
} else if (response.kind === 'guildIncome') {
const payment = !canceled ? creditFactionPayment(g, response.owner, 'shipment', response.amount!-(bureaucratDiversion ?? 0)) : null;
awardBankerIncome(g, response.spiceBankerIncomePayment, canceled);
awardFinalFactionBankerIncome(g, response.spiceBankerIncomePayment,
canceled ? response.amount! : payment!.bank, settlement);
log(
g,
canceled
@@ -25177,6 +25228,8 @@ function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number)
const owner = getPlayer(g, response.owner);
const amount = g.currentAuctionSale ? auctionEmperorIncomeAmount(g.currentAuctionSale) : g.auction!.bid;
const payment = creditFactionPayment(g, owner.id, 'treachery', amount-(bureaucratDiversion ?? 0));
awardFinalFactionBankerIncome(g, g.currentAuctionSale?.spiceBankerIncomePayment,
payment.bank, settlement);
log(
g,
`${owner.name} received ${payment.income} spice from another faction’s ${amount}-spice paid auction bid.${payment.bank ? ` Low-population Kaitain leaves ${payment.bank} spice in the bank.` : ''}`,
@@ -25343,7 +25396,7 @@ function quoteJunctionDeclaration(g: Game, p: Player, declaration: JunctionTrans
const ordinaryIncome = !!arrival && nativeDeparture && p.faction !== 'fremen';
const contributions = [ordinaryIncome ? quote.cost - allyPayment : 0,
p.ally === option.owner ? 0 : allyPayment];
validateGuildPaymentRounding(g, option.owner,
validateGuildPaymentContributions(g,
contributions.reduce((sum, amount) => sum + amount, 0), contributions);
return {option, rate, quote, arrival, nativeDeparture, promise, sourceLock, advisors, allyPayment, destination};
}
@@ -25386,6 +25439,8 @@ function commitJunctionTransport(g: Game, p: Player, declaration: JunctionTransp
[ordinaryIncome ? quote.cost - allyPayment : 0, p.ally === option.owner ? 0 : allyPayment], undefined,
[...(ordinaryIncome ? [{ payer: p.id, amount: quote.cost - allyPayment }] : []),
...(p.ally !== option.owner && allyPayment ? [{ payer: p.ally!, amount: allyPayment }] : [])]);
if (!settlement?.recipient) settleBankerShipmentIncome(g, p, quote.cost, allyPayment,
{ own: ordinaryIncome && p.id !== option.owner, ally: p.ally !== option.owner });
const sourceName = quote.originKind === 'arrakis' ? territory(quote.origin).name :
quote.sources.map((s) => combatLocationName(g, s.key)).join(' and ');
log(g, `${p.name} accepted ${getPlayer(g, option.owner).name}’s Junction ${rate}-price offer and transported ${quote.amount} physical forces (${quote.elite} special) from ${sourceName} to ${arrival ? `${territory(arrival.territory).name}, sector ${arrival.sector}` : combatLocationName(g, destination)} for ${quote.cost} spice (${quote.cost - allyPayment} own, ${allyPayment} pledged). The shipment is used; movement remains available.`,
@@ -25501,7 +25556,10 @@ function commitHomeworldShipment(g: Game, shipment: PendingHomeworldShipment, se
const guild = byFaction(g, 'guild');
const income = guild && p.ally !== guild.id ? shipment.allyPayment : 0;
if (!settlement?.recipient && guild && income > 0)
g.response = guildPaymentResponse(g, guild.id, [income], p.ally!);
g.response = guildPaymentResponse(g, guild.id, [income], undefined,
[{ payer: p.ally!, amount: income }]);
if (!settlement?.recipient) settleBankerShipmentIncome(g, p, quote.cost, shipment.allyPayment,
{ own: false, ally: !!guild && p.ally !== guild.id });
finishShipmentPromises(g, p, null);
log(g, `${p.name} shipped ${quote.amount} physical forces (${quote.elite} special) from ${quote.sourceNames.join(' and ')} to ${combatLocationName(g, shipment.destination)} for ${quote.cost} spice (${quote.cost - shipment.allyPayment} own, ${shipment.allyPayment} pledged). This uses their shipment; movement remains available.${shipment.guildSecretEvent ? ' Guild Secret Ally is spent for this half-price shipment; the payment goes to the bank.' : ''}`,
{faction: shipment.guildSecretEvent ? 'guild' : p.faction, name: shipment.guildSecretEvent ? 'Secret Ally Homeworld shipment' : 'Homeworld shipment'});
@@ -25845,7 +25903,7 @@ function finishGuildRateResponse(g: Game, response: ResponseWindow, canceled: bo
return;
}
shipment.cost = fullCost;
checkShipmentIncomeRounding(g, shipper, fullCost, shipment.allyPayment);
checkShipmentIncomeFunding(g, shipper, fullCost, shipment.allyPayment);
commitShipment(g, shipment);
delete g.guildRateBlocked;
log(g, `Karama removed Guild's half-price rate. ${shipper.name} paid the full ${fullCost} spice for the unchanged shipment.`);
@@ -25976,7 +26034,7 @@ function validateGuildTransportForCommit(
to === 'reserves' ? 'reserves' : territory(to).type, amount, halfRate),
'The declared Guild transport price no longer matches its rate.');
contribution(g, p, cost, allyPayment);
checkShipmentIncomeRounding(g, p, cost, allyPayment);
checkShipmentIncomeFunding(g, p, cost, allyPayment);
checkShipmentPromises(g, p, fromReserves ? { territory: to, amount } : null);
}
function commitGuildTransport(
@@ -26062,7 +26120,7 @@ function finishGuildTransportRateResponse(g: Game, response: ResponseWindow, can
function offerShipment(g: Game, shipment: PendingShipment) {
validatePhysicalShipment(g, shipment);
validateShipmentArrival(g, shipment);
checkShipmentIncomeRounding(g, getPlayer(g, shipment.player), shipment.cost, shipment.allyPayment);
checkShipmentIncomeFunding(g, getPlayer(g, shipment.player), shipment.cost, shipment.allyPayment);
const p = getPlayer(g, shipment.player),
id = p.id;
const { territory: to, sector: s } = shipment;
@@ -31678,7 +31736,7 @@ function applyActionInner(
// This cancellation window bypasses offerShipment, so reject known
// unsupported arrival combinations before saving its private intent.
validateShipmentArrival(g, shipment);
checkShipmentIncomeRounding(g, p, cost, allyPayment);
checkShipmentIncomeFunding(g, p, cost, allyPayment);
if (richesePair) recordRichesePairShipment(g, shipment);
g.pendingShipment = shipment;
g.response = { kind: 'richeseNoField', owner: p.id, passed: [],
@@ -31787,7 +31845,7 @@ function applyActionInner(
'Not enough spice.',
);
const allyPayment = contribution(g, p, cost, action.allyPayment);
checkShipmentIncomeRounding(g, p, cost, allyPayment);
checkShipmentIncomeFunding(g, p, cost, allyPayment);
const frame: GuildTransportOrder = {
turn: g.turn, player: id, origin, group, eliteGroup, to, sector,
amount: n, elite, cost, allyPayment, advisors,
+1 -8
View File
@@ -1,6 +1,6 @@
import { HomeworldCustodyError } from './homeworld-custody';
import type { HomeworldId } from './homeworld-cards';
import { quoteGuildPaymentRounding, quoteHomeworldPaymentIncome } from './homeworld-payment-income';
import { quoteHomeworldPaymentIncome } from './homeworld-payment-income';
import {
quoteStableHomeworldOccupation,
type StableHomeworldOccupationContext,
@@ -243,13 +243,6 @@ export function quoteOccupiedPercentageSource(
const payment = source.kind === 'emperor-treachery' || source.kind === 'guild-shipping'
? quoteHomeworldPaymentIncome(game, source.native, source.kind === 'guild-shipping' ? 'shipment' : 'treachery', source.amount)
: null;
// Keep the existing two-odd-contributor ruling gate. Exclusion happens before
// comparing rounding; no new transaction-versus-contribution policy is invented.
if (source.kind === 'guild-shipping' && (payment?.low || stable.entitlement || stable.blocked)) {
const eligible = source.payment.contributions.filter(leg => leg.payer !== source.native && leg.amount > 0).map(leg => leg.amount);
requireReceipt(quoteGuildPaymentRounding(source.amount + (source.payment.bankDiversion ?? 0), eligible).unambiguous,
'multiple-contributor shipping rounding awaits its original ruling.');
}
const candidate = Math.floor(source.amount / 2);
const blocked = candidate > 0 ? stable.blocked : null;
const entitlement = stable.entitlement ? Object.freeze({ ...stable.entitlement }) : null;
+88 -33
View File
@@ -15,10 +15,9 @@ export type HomeworldPaymentIncomeQuote = {
low: boolean;
};
/** Split one already eligible payment. Contributor exclusions, payment-unit
* classification and timing belong to the caller. This does not resolve how
* multiple contributors define a payment for rounding purposes.
* This quotes physical low population only, not occupation entitlements. */
/** Split one whole eligible invoice, including all eligible contributors.
* Contributor exclusions, payment-unit classification and timing belong to the
* caller. This quotes physical low population only, not occupation entitlements. */
export function quoteHomeworldPaymentIncome(
context: HomeworldPaymentIncomeContext,
ownerId: string,
@@ -51,37 +50,93 @@ export function quoteHomeworldPaymentIncome(
return { gross, income, bank: gross - income, low };
}
/** Compare the unresolved transaction and contributor rounding interpretations.
* The caller may accept agreement; divergence requires an explicit ruling. */
export function quoteGuildPaymentRounding(
gross: number,
contributions: number[],
): { transaction: number; contribution: number; unambiguous: boolean } {
if (
!Number.isSafeInteger(gross) ||
gross < 0 ||
!Array.isArray(contributions) ||
contributions.length > 2 ||
!Array.from(contributions).every(
(amount) => Number.isSafeInteger(amount) && amount >= 0,
/** Attribute the frozen final Bank remainder of one eligible player invoice.
* Contributions retain declared primary/ally order. The caller excludes direct
* Bank and self-payee legs and selects the first Bureaucrat-eligible payer;
* bankRetained excludes that payer's flat diversion. */
export function quoteHomeworldBankPayment(
contributions: readonly { payer: string; amount: number }[],
bankRetained: number,
diversion?: { payer: string; amount: 2 },
): { payer: string; amount: number }[] {
if (!Array.isArray(contributions))
throw new HomeworldCustodyError(
'Homeworld Bank payment requires original payer contributions.',
);
const payers = new Set<string>();
let total = 0;
for (const leg of contributions) {
if (
!leg ||
typeof leg.payer !== 'string' ||
leg.payer.trim().length === 0 ||
payers.has(leg.payer) ||
!Number.isSafeInteger(leg.amount) ||
leg.amount <= 0
)
throw new HomeworldCustodyError(
'Homeworld Bank payment requires distinct payers with positive safe original contributions.',
);
payers.add(leg.payer);
total += leg.amount;
if (!Number.isSafeInteger(total))
throw new HomeworldCustodyError(
'Homeworld Bank payment contributions exceed a safe invoice total.',
);
}
let diversionIndex = -1;
if (diversion !== undefined) {
if (
!diversion ||
diversion.amount !== 2 ||
typeof diversion.payer !== 'string'
)
throw new HomeworldCustodyError(
'Homeworld Bank diversion requires an original payer and exactly two spice.',
);
diversionIndex = contributions.findIndex(
(leg) => leg.payer === diversion.payer,
);
if (diversionIndex < 0 || contributions[diversionIndex].amount < 5)
throw new HomeworldCustodyError(
'Homeworld Bank diversion requires a selected original contribution of at least five spice.',
);
}
const remainingTotal = total - (diversionIndex < 0 ? 0 : 2);
if (
!Number.isSafeInteger(bankRetained) ||
(bankRetained !== 0 &&
bankRetained !== remainingTotal &&
bankRetained !== Math.floor(remainingTotal / 2) &&
bankRetained !== Math.ceil(remainingTotal / 2))
)
throw new HomeworldCustodyError(
'Guild rounding requires a nonnegative safe payment and at most two nonnegative safe contributions.',
'Homeworld Bank remainder must match zero, full or whole-invoice half income.',
);
const total = contributions.reduce((sum, amount) => sum + amount, 0);
if (!Number.isSafeInteger(total) || total !== gross)
throw new HomeworldCustodyError(
'Guild contributions must sum to the original eligible payment.',
);
const transaction = Math.ceil(gross / 2);
const contribution = contributions.reduce(
(sum, amount) => sum + Math.ceil(amount / 2),
0,
);
return {
transaction,
contribution,
unambiguous: transaction === contribution,
};
const partial = bankRetained > 0 && bankRetained < remainingTotal;
let remainder = 0;
if (partial) {
let base = 0;
for (let i = 0; i < contributions.length; i++) {
const remaining = contributions[i].amount - (i === diversionIndex ? 2 : 0);
base += Math.floor(remaining / 2);
}
remainder = bankRetained - base;
}
const bankLegs: { payer: string; amount: number }[] = [];
for (let i = 0; i < contributions.length; i++) {
const leg = contributions[i];
const remaining = leg.amount - (i === diversionIndex ? 2 : 0);
let amount = partial
? Math.floor(remaining / 2)
: bankRetained === remainingTotal ? remaining : 0;
if (remainder > 0) {
const extra = Math.min(remainder, remaining - amount);
amount += extra;
remainder -= extra;
}
if (i === diversionIndex) amount += 2;
if (amount > 0) bankLegs.push({ payer: leg.payer, amount });
}
return bankLegs;
}
+1 -22
View File
@@ -1,25 +1,10 @@
import { nexusGuildSecretAllyQuote } from './nexus-guild-secret-ally-options';
import type { Action, GameView } from './engine';
import { quoteGuildPaymentRounding } from './homeworld-payment-income';
import { reserveShipmentCost, guildShipmentCost } from './shipment-price';
import { territory } from './board';
import { nexusRicheseQuote } from './nexus-richese-options';
/** Own funding and public native population only; no donor balance or hidden card access. */
export function homeworldShipmentPaymentBlock(g: GameView, cost: number, allyPayment: number): string | null {
const guild = g.players.find(p => p.faction === 'guild');
const me = g.players.find(p => p.id === g.me)!;
if (!guild || g.karamaShipping?.player === g.me ||
!g.homeworlds?.worlds?.some(world => world.card === 'junction' && world.side === 'low')) return null;
const contributions = [me.id === guild.id ? 0 : cost - allyPayment,
me.ally === guild.id ? 0 : allyPayment];
return quoteGuildPaymentRounding(contributions[0] + contributions[1], contributions).unambiguous
? null : 'Low Junction rounding for two odd allied contributions awaits a ruling. Choose a different payment split.';
}
export function botHomeworldShipmentPaymentAllowed(g: GameView, action: Action): boolean {
if (!['ship', 'guildShip'].includes(action.type)) return true;
const me = g.players.find(p => p.id === g.me)!;
const to = String(action.territory);
const amount = action.noField ? 1 : action.forces ? Object.values(action.forces as Record<string, number>).reduce((sum, n) => sum + n, 0) : Number(action.amount);
if (action.revealedToken !== undefined) {
@@ -30,11 +15,5 @@ export function botHomeworldShipmentPaymentAllowed(g: GameView, action: Action):
const guildSource = action.nexus !== undefined && action.nexus === g.nexusGuildSecretAlly?.event;
const nexusQuote = action.nexus === undefined ? null : guildSource ? nexusGuildSecretAllyQuote(g, to, amount) : nexusRicheseQuote(g, to, amount);
if (action.nexus !== undefined && (!nexusQuote || (!guildSource && (action.nexus !== g.nexusRichese?.event || action.type !== 'ship')) || action.noField !== undefined)) return false;
if (!g.homeworlds?.worlds?.some(world => world.card === 'junction' && world.side === 'low')) return true;
const cost = nexusQuote ? nexusQuote.cost : action.type === 'ship'
? reserveShipmentCost({ faction: me.faction, halfRate: me.faction === 'guild' ||
g.players.some(p => p.faction === 'guild' && p.id === me.ally) || g.karamaShipping?.player === me.id }, territory(to).type, amount)
: guildShipmentCost(to === 'reserves' ? 'reserves' : territory(to).type, amount);
const share = action.allyPayment === undefined ? Math.max(0, cost - (me.spice ?? 0)) : Number(action.allyPayment);
return !homeworldShipmentPaymentBlock(g, cost, share);
return true;
}
+3 -1
View File
@@ -492,7 +492,9 @@ function homeworldLeaderSkillModulesSupported(game: LeaderSkillProfile): boolean
game.kullPreview || game.nexusKullPreview || game.guildBetrayalPreview ||
game.richeseBetrayalPreview || game.nexusIxianReplacementPreview ||
game.nexusIxianBetrayalPreview || game.nexusHarkonnenBetrayalPreview ||
game.spiceBankerIncomePreview) return false;
(game.spiceBankerIncomePreview && (game.expansions.length || game.nexusCards ||
game.discoveryEnabled || game.discoveries || game.discoveryStash || game.greatMaker ||
game.techTokens || game.strongholdCards))) return false;
return true;
}
+9 -8
View File
@@ -3950,7 +3950,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'While Tupile has at least eleven native forces, CHOAM cannot discard Worthless cards for spice through either ordinary sale price or a CHOAM Ambassador. Non-Worthless duplicate sales, allied trades and the Advanced CHOAM Karama cash-in retain their own rules. If a Ghola revival raises Tupile before a declared sale settles, that Worthless card stays in hand and earns no spice.',
'At the end of Bidding, high-population Kaitain lets Emperor pay two spice per selected Treachery Card to discard it. This does not play the card or begin another auction. Emperor and CHOAM share the closing opportunity, so sales, allied trades and paid discards can occur in either order. Current hands, spice and native population determine each action. Finish when ready; a new action clears readiness.',
'In supported payment cases, low Kaitain halves Emperor’s Treachery payment income and low Junction halves eligible Guild shipping income, rounded up. The payer still pays the full cost. For example, a five-spice auction payment gives low-population Emperor three spice and leaves two in the bank. The income opportunity shows the original payment, current receipt and bank remainder. A population change before collection updates the split; ordinary Karama can still prevent the separate faction income.',
'Guild’s own shipment contribution goes to the bank before any low-Junction reduction. Splits with two odd contributions from other factions remain unavailable while their rounding rule is unresolved; choose another funding split. Single-contributor payments and splits where both rounding interpretations agree remain supported. Occupier income is still unfinished.',
'Guild’s own shipment contribution goes to the Bank before any low-Junction reduction. The user-ruled low receipt rounds the whole eligible invoice once, including two odd contributions. Original payer costs do not change. Fresh occupation previews retain the printed occupier share; ordinary native income cancellation banks only its native portion and does not cancel the protected occupied share.',
'With at least seven native forces on Giedi Prime, Harkonnen automatically gains two bank spice after collecting positive desert spice during Spice Collection, once for the whole phase. Several deposits still earn only two. Shared Ecaz spice qualifies when Harkonnen actually receives a positive allocation; a zero share, stronghold income or technology income does not qualify. Refreshing preserves the completed bonus.',
'With fewer than eight native forces on Grumman, Moritani can reveal Terror only when at least three forces enter together. Count each incoming physical counter once, including advisors, and a concealed No-Field as one regardless of its hidden value. Forces already at the destination do not count. High Grumman’s separate Collection choice can add a token to an existing stronghold stack or return an unrevealed placed token to hidden supply for four bank spice, once per Collection.',
'While Ecaz has at least seven native reserves, it automatically receives three bank spice for each discarded poison weapon, including its own. Poison Blade and Poison Tooth qualify; Chemistry qualifies only when used as a weapon. Poison defenses and Residual Poison do not qualify. Ecaz sees its income in a private Homeworld record; private discarded faces stay private.',
@@ -4071,7 +4071,7 @@ export const RULE_TOPICS: RuleTopic[] = [
area: 'Documentation',
status: 'Partial',
detail:
'All 26 faces, global rules and source discrepancies are documented. Imperial shipment and movement controls explain sources, costs and unavailable actions. Card-economy notes explain shared closing actions, private poison income, winner-discard ordering and low-population payment splits. The authorized Advanced sole/last-departure cutover and original occupied economic/Bidding ownership are explicit. Divergent allied rounding, Basic occupation lifecycle and remaining effects stay separate; full interaction guidance remains in progress.',
'All 26 faces, global rules and source discrepancies are documented. Imperial shipment and movement controls explain sources, costs and unavailable actions. Card-economy notes explain shared closing actions, private poison income, winner-discard ordering and low-population payment splits. The authorized Advanced sole/last-departure cutover, original occupied economic/Bidding ownership and user-ruled whole-invoice rounding are explicit. Remaining occupation variants and interactions stay separate; full guidance remains in progress.',
evidence: [
'docs/HOMEWORLD_COMPONENT_AUDIT.md',
'docs/HOMEWORLD_RULES.md',
@@ -4408,7 +4408,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Wait for a legal positive shipment fee after any applicable native stop and rate responses. The original source, tariff and physical forces must already be legal and fully funded. Stopped, declined, invalid or zero-cost shipments cannot award spice or spend this Nexus; ordinary free Fremen reinforcement and free Guild Ambassador transport stay distinct.',
'Use takes the full original charged payment, not merely eligible Guild income or its reduced half. The original payer and authorized ally contribution pay once, but Guild, bank and any occupied-Junction receiver get none of that invoice. This is replacement of one fee, not an extra award or second charge.',
'You may take your own payment, but you must afford it before receiving it back. A wholly self-funded payment is paid and returned for net zero spice; future receipt cannot finance the declaration. An ally’s pledge has already debited that donor into escrow: the original escrow is consumed once, not charged to the donor again. Taking the fee does not change the payer’s own remainder or donor split. Your live voluntary-spend promises and reservations still apply.',
'The printed occupied-Junction override delivers the entire fee: a four-spice payment gives the holder four, not two while an occupier gets two. It does not decide ordinary low-Junction contribution rounding, occupation entitlement after departure/replacement/contest or retained penalties. Non-overridden settlement keeps its native restrictions; a hypothetical Nexus play cannot bypass an unresolved original invoice.',
'The printed occupied-Junction override delivers the entire fee: a four-spice payment gives the holder four, not two while an occupier gets two. It does not decide occupation entitlement after departure/replacement/contest or retained penalties. Ordinary low-Junction rounding now follows the separately ruled whole eligible invoice once. Non-overridden settlement preserves its native permissions; hypothetical Nexus play supplies no new ordinary invoice permission.',
'Supported original producers are ordinary physical reserve shipment onto Arrakis, native Guild cross-planet/return transport, existing native Homeworld shipment and an accepted Junction-sponsored transport. Keep the same tariff, physical elite/advisor group, selected native Homeworld reserve sources, source/destination custody and sponsor/offer. No new foreign Homeworld route or Secret Ally return permission is granted.',
'The original native shipment completes once: pay the bound fee, move the exact physical forces, mark that shipment used and finish applicable native arrival and response decisions. Ordinary movement afterward remains available under the shipper’s own rules. Betrayal does not grant another shipment, move, rate or sponsorship.',
'Every publicly possible unallied non-Guild seat with held-Nexus presence receives the same event and shipper, including an eligible shipper. A secret irrelevant face still uses Pass; only a privately eligible Guild-card holder sees Use and their own unavailable reason. This neutral acknowledgement is an application privacy protocol, not publisher timing authority, blanket Karama immunity or a new general response priority.',
@@ -4423,7 +4423,7 @@ export const RULE_TOPICS: RuleTopic[] = [
{ area: 'Implementation', status: 'Partial', detail: 'Bounded reserve, native Guild transport, Homeworld and Junction producers bind the full funded invoice and original typed continuation with independent pending/completed ownership. Own refund, donor escrow and occupied-Junction replacement retain once-only native payment/delivery; wider producers remain excluded.', evidence: ['game/nexus-guild-betrayal.ts', 'game/engine.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'The bounded contract separates uniform acknowledgement from private Use and own blocked reason, with event-only actions, unchanged inspector entitlements and no new fee/source disclosure. Broader presentation and acceptance remain open.', evidence: ['components/nexus-guild-betrayal.tsx', 'components/game-table.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'All four profiles use only canonical projected legal Use/Pass choices while the payment gate suspends underlying actions. Full strategy and calibration remain unfinished.', evidence: ['game/bot-nexus-guild-betrayal.ts', 'game/bots.ts'] },
{ area: 'Documentation', status: 'Partial', detail: 'Printed full-fee and own-payment clauses, escrow funding, explicit occupied-Junction override, four native adapters, privacy protocol and unchanged open occupation/rounding/priority boundaries are recorded. Full-family acceptance remains open.', evidence: ['docs/NEXUS_GUILD_RULES.md', 'docs/NEXUS_CARD_RULES.md', 'docs/HOMEWORLD_PAYMENT_INCOME_RULES.md', 'docs/RULE_DECISIONS.md'] },
{ area: 'Documentation', status: 'Partial', detail: 'Printed full-fee and own-payment clauses, escrow funding, explicit occupied-Junction override, four native adapters and privacy protocol are recorded. Ordinary whole-invoice rounding follows the separate user ruling; unresolved occupation/priority boundaries and full-family acceptance remain open.', evidence: ['docs/NEXUS_GUILD_RULES.md', 'docs/NEXUS_CARD_RULES.md', 'docs/HOMEWORLD_PAYMENT_INCOME_RULES.md', 'docs/RULE_DECISIONS.md'] },
{ area: 'Verification', status: 'Partial', detail: '147 selected offline cases and 6,114 full offline checks cover four native adapters, funded own refunds, original escrow, full low-Junction override, face-neutral acknowledgement, four legal bot profiles, actual handlers, authenticated restart/races and independent native-grant/orphan rejection. Eight real backed-up CLI entries and 390-pixel controls prove own/other/escrow/Guild-return/Homeworld/Junction Use, all-pass, free Fremen and refresh with exact saved force/card/spice custody. Local HTTP 48/55 has five 503s and two timeouts; broader and deployed acceptance remain open.', evidence: ['tests/nexus-guild-betrayal.test.ts', 'tests/nexus-guild-betrayal-engine.test.ts', 'tests/nexus-guild-betrayal-controls.test.tsx', 'tests/bot-nexus-guild-betrayal.test.ts', 'tests/nexus-guild-betrayal-recovery.test.ts', 'docs/NEXUS_GUILD_RULES.md'] },
],
},
@@ -4752,10 +4752,11 @@ export const RULE_TOPICS: RuleTopic[] = [
steps: [
'This separate fresh profile admits classic Basic/Advanced, supported Basic native families, bounded Advanced Ixian/Tleilaxu/CHOAM/Richese plus classic seats with required decks, or separate non-Harkonnen Advanced Moritani. All fourteen skills and native setup remain. Richese cache self-payments and authorized Advanced donor legs count only when actually paid to bank; allowed Emperor or seller income does not. Basic receipts and unrelated module/public gates remain.',
'The explicit banker-income Nexus composition admits fresh classic Basic/Advanced all14/all12 setup, optionally original Tech at three or more seats and Advanced Strongholds. Actual bank payment four gains one deferred spice; player-recipient four and a separate Tech payout do not. Original Mentat collects automatically. Homeworlds, Discovery, expansion-family Nexus and Betrayal overlays remain outside this income entry; no starting Nexus hand or played-game conversion is added.',
'The separate fresh banker-income --homeworlds composition admits classic Basic/Advanced original occupation and all fourteen Skills, without Nexus, Discovery, Tech, Strongholds or expansion overlays. It uses actual final native/occupied receipt Bank amounts. Bureaucrat diversion combines only with the same payer’s other Bank portions of the same original invoice. Low native income rounds the whole eligible invoice once; extra Bank rounding units and the first eligible diversion contributor are attributed primary-payer-first internally, without funding disclosure. Original occupied shares remain protected and Mentat collects automatically.',
'The printed normal band is mandatory once per phase: when another player makes a payment of at least four spice to the Spice Bank, gain one spice and place it in front of the shield. It is an additional bank grant, not a reduction of the payer’s payment or another player’s income.',
'Only an actual completed positive bank payment qualifies. A payment to a player, zero/free/waived cost, self-payment, invalid declaration or unfinished possible payment supplies no income. Several smaller payments do not combine to reach four.',
'Use the original actual bank portion after recipient and counter settlement, not a quoted fee or guessed wallet change. Paid auctions, funded shipments, paid revival and eligible resolved battle support keep their native continuation once. Canceled Emperor, Guild or Tleilaxu revival income can leave a real bank payment. Tleilaxu self-revival pays the Bank; other-player revival normally pays Tleilaxu. Its separate free-revival bank award is not payer debit: three paid plus that one reward never becomes four paid to the Bank. A stopped shipment supplies no payment.',
'Actual payer contribution legs stay distinct. Two different players paying two each do not become one player paying four. Banker’s separate one-to-three battle payment does not combine with two support to manufacture a qualifying payment. These split-source conventions are explicit conservative local compositions, not a publisher split-payment example.',
'Actual payer contribution legs stay distinct. Two different players paying two each do not become one player paying four. The same payer’s Bureaucrat diversion and other final Bank portions of one original invoice do combine; unrelated invoices never do. Banker’s separate one-to-three battle payment does not combine with two support to manufacture a qualifying payment. Source conventions and user rulings are explicit, not a publisher split-payment example.',
'A new grant requires an actual available living native trainer. A captive grants no normal income. Hidden unselected skills do not supply their normal band; in the trainer’s own battle, its actual selected surviving leader supplies the appropriate eligibility after reveal/resolution, not private pre-reveal timing.',
'One physical Banker card has one use per turn and phase. Repeated payments, refresh, death, return or same-phase reassignment cannot reset that use; the next phase or turn permits a new grant.',
'The front-shield pile is not spendable spice. It cannot fund the triggering payment, a later bid, shipment, revival, support or lower-band Banker commitment before collection. Human and all four AI policies keep using their actual available balance.',
@@ -4766,11 +4767,11 @@ export const RULE_TOPICS: RuleTopic[] = [
],
related: ['leader-skills', 'battle', 'bidding', 'revival', 'implementation-checklist'],
checklist: [
{ area: 'Implementation', status: 'Implemented', detail: 'Actual final bank payer legs, one physical Banker phase grant and original-owner Mentat collection connect through fresh classic and supported native skill profiles, including separate bounded Advanced Moritani assassination. Tleilaxu paid revival remains distinct from its free award; free Terror placement is not a new payment.', evidence: ['game/engine.ts', 'game/spice-banker-income.ts'] },
{ area: 'Implementation', status: 'Implemented', detail: 'Actual final Bank payer legs, one physical Banker phase grant and original-owner Mentat collection connect through fresh classic and supported native profiles, including the separate classic Homeworld occupation entry. Native/occupied receipts supply actual retained Bank amounts; user-ruled diversion and whole-invoice attribution preserve payer costs. Paid revival remains distinct from free awards; free Terror is not a payment.', evidence: ['game/engine.ts', 'game/spice-banker-income.ts', 'game/homeworld-payment-income.ts', 'tests/homeworld-banker-income.test.ts', 'tests/homeworld-banker-transport.test.ts', 'tests/homeworld-bank-payment.test.ts'] },
{ area: 'Player controls', status: 'Implemented', detail: 'A full-width read-only public front-shield notice explains unavailable spice and automatic collection; genuine original payment and final Collection controls work at 390px through refresh. No new income action or confirmation.', evidence: ['components/spice-banker-income.tsx', 'components/game-table.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'All four minimal legal policies preserve identical available-budget actions with deferred income present or absent. Original wallets exclude front-shield currency; strategy/calibration remain deferred.', evidence: ['game/bots.ts', 'game/spice-banker.ts'] },
{ area: 'Documentation', status: 'Partial', detail: 'Printed gain/threshold/one-payment/phase/collection, original payment separation, provisional earned custody and per-payer split composition are explicit without universal rulings.', evidence: ['docs/LEADER_SKILLS_RULES.md', 'docs/SPICE_BANKER_RUNTIME.md', 'docs/RULE_DECISIONS.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Original classic checkpoint: types/lint,6,487 offline and140 focused cases, eighteen source programs and two six-seat CLI/phone rooms. Native integration: affected110, final native/guide26, twelve Banker cases plus four legal post-income policies, native original CLI and390px deferred10-to-Mentat11. Tleilaxu paid/free/self/canceled3-or4 boundaries are exercised. Comprehensive assurance follows all rules; no deployed/full-module claim.', evidence: ['tests/fixture-spice-banker-income.ts', 'tests/spice-banker-income.test.ts', 'tests/spice-banker-income-runtime.test.ts', 'tests/fixture-native-banker-income.ts', 'tests/native-banker-income.test.ts', 'tests/spice-banker-income-recovery.test.ts', 'tests/prototype-room.test.ts'] },
{ area: 'Documentation', status: 'Partial', detail: 'Printed threshold/phase/collection, original payment separation, provisional earned custody and per-payer composition are explicit. The user-ruled same-payer diversion, primary-first private attribution and whole-invoice low rounding are documented without universal publisher claims.', evidence: ['docs/LEADER_SKILLS_RULES.md', 'docs/SPICE_BANKER_RUNTIME.md', 'docs/HOMEWORLD_PAYMENT_INCOME_RULES.md', 'docs/RULE_DECISIONS.md'] },
{ area: 'Verification', status: 'Partial', detail: 'The fresh Homeworld income entry passes292/292 in the frozen related18-file union, types/lint and build. Actual original world-to-world and Junction return Bank tariffs, low Kaitain auction income, isolated HWBANKQA CLI, private390px diversion and native wallet13-to-Mentat14/front0 are exercised. Existing classic/native and Tleilaxu paid/free/self/canceled boundaries remain source-bound evidence. Comprehensive assurance and deployed/full-module acceptance stay open.', evidence: ['tests/fixture-homeworld-banker-income.ts', 'tests/homeworld-banker-income.test.ts', 'tests/homeworld-banker-transport.test.ts', 'tests/homeworld-bank-payment.test.ts', 'tests/spice-banker-income.test.ts', 'tests/spice-banker-income-runtime.test.ts', 'tests/native-banker-income.test.ts', 'tests/prototype-room.test.ts', 'docs/SPICE_BANKER_RUNTIME.md'] },
],
},
{