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

-5
View File
@@ -52,7 +52,6 @@ import { atomicsAllianceChangeBlocked } from '@/game/moritani-atomics';
import { GuildHomeworldShipment } from './guild-homeworld-shipment';
import { JunctionTransport } from './junction-transport';
import { BiddingEnd } from './bidding-end';
import { homeworldShipmentPaymentBlock } from '@/game/homeworld-payment-options';
import {
HomeworldTable,
NativeShipmentChoice,
@@ -683,10 +682,6 @@ export function GameTable({
selectedAllyPayment >= 0 &&
selectedAllyPayment <= shipmentCost;
if (shipmentCost !== null) {
if (validShipmentShare) {
const blocked = homeworldShipmentPaymentBlock(g, shipmentCost, selectedAllyPayment);
if (blocked) shipmentProblems.push(blocked);
}
if (!validShipmentShare)
shipmentProblems.push(
`Ally payment must be a whole number from 0 to ${shipmentCost}.`,
+8 -2
View File
@@ -124,8 +124,14 @@ native3 + occupied3. The new receipt records `bankDiversion:2` while retaining
original `payment.paid:8` and each full actual payer contribution; its allocatable
`source.amount` is6. Decline splits8 into4+4. Karama before Bureaucrat instead
banks the native4, preserves the occupied4 and spends no Bureaucrat use.
The original two-odd Guild contributor rounding guard remains separate:6+2
is admitted, whereas5+3/3+5/5+5 reject before costs at low/occupied Junction.
The 14 October user ruling supersedes the former two-odd fence: low native income
rounds the whole remaining eligible invoice once. Both5+3 and3+5 are admitted.
The internal diversion payer is the first individually eligible contribution:
declared payer first, ally second, never pooled. Final Bank parts of that same
payer/invoice combine for Banker; no funding or eligibility identity is disclosed.
The separate fresh `banker-income --homeworlds` entry retains these original
receipts and automatically collects earned Bank income at native Mentat. See
[current accounting and proof](SPICE_BANKER_RUNTIME.md#classic-homeworld-final-bank-income--14-october-2026).
The new consumers are `tests/bureaucrat-occupied-guild.test.ts` and
`tests/bureaucrat-occupied-emperor.test.ts`, with original-army fixtures of the
+21
View File
@@ -29,6 +29,27 @@ optional skill bands, combinations and public expansion starts remain open.
Rules implementation continues; comprehensive assurance and administration
remain deferred until all rules are implemented.
**Classic Homeworld normal Banker and whole-invoice accounting — 14 October.**
Fresh `banker-income --homeworlds` reuses original classic Basic/Advanced
occupation/all14 setup and income-v1, without Nexus/Discovery/Tech/Strongholds
or native expansion overlays. The user selected same-payer/same-invoice Bank
combination, first eligible Bureaucrat contributor primary-first internally,
and low native whole-invoice rounding with primary-first extra Bank units.
Original payer shares and private prompts remain unchanged. The two-odd
declaration/UI/AI fence is removed; actual final native/occupied receipts,
not wallet deltas or full canceled occupied gross, authorize grants.
Native5+3 without tax banks3/1 and earns none; tax2 banks4/1 and earns one.
Occupied cancellation preserves occupier4 and banks only native4 as3/1.
Actual world-to-world4 and sponsored board-return4 bank fees qualify; low
Kaitain’s later eight-spice native auction yields Emperor4/Bank4.
Actual390px **Redirect2** leaves wallet11/front1; ordinary city Collection
makes13, last native **Ready** enters Mentat and makes14/front0 with one credit.
Browser errors empty, no root overflow. Isolated actual `HWBANKQA v8` CLI
retains original setup/custody. Related18-file union292/292, types/lint and
app build pass with existing warnings. [Accounting contract and proof](SPICE_BANKER_RUNTIME.md#classic-homeworld-final-bank-income--14-october-2026).
No existing save conversion, complete-mode, broad-assurance or deployed claim.
Recommended options are chosen without another clarification question.
**Ruled Grumman removal and Cunning addition — 14 October.**
The user selected supply-only Cunning, HMS exclusion, preserved Grumman
addition prerequisite and unrevealed return to hidden supply. Native removal
+6 -6
View File
@@ -105,17 +105,17 @@ stale permission must not remove the card or pay income. Keep the market and
its parent continuation recoverable. This requirement is independent of the
older unresolved Kull and Worthless-cancellation interpretation questions.
## Deferred payment splitting
## Payment splitting: ruled whole invoice
E3 p.15 explicitly splits a five-spice Treachery payment into three for Emperor
and two for Kaitain's occupier, even if that occupier bought the card. It does
not give an allied-contribution example. The inspected reverse-face record
assigns rounded-up half of shipment receipts to low Junction. Neither evidence
establishes separate rounding per contributor versus once per purchase or
shipment. Preserve payment identity, actual payer contributions and income
source separately before integrating those effects; do not infer an answer
from existing integer arithmetic. Occupation lifecycle questions remain in
[Homeworld rules](HOMEWORLD_RULES.md).
establishes separate rounding per contributor versus once per invoice.
The 14 October user ruling now selects whole-invoice native rounding and
primary-first internal attribution of extra Bank rounding units. Preserve
original payment identity and full payer shares; never pool different payers
or unrelated invoices. [Current source and runtime](HOMEWORLD_PAYMENT_INCOME_RULES.md#supported-receipts-and-ruled-whole-invoice-rounding).
Runtime verification should cover Basic/Advanced and module-off parity,
threshold transitions, exact card custody, private view/AI boundaries,
+1 -1
View File
@@ -58,7 +58,7 @@ The same fresh `homeworld-occupation` entry connects these original producers:
| Printed source | Original consumer and ownership |
| --- | --- |
| Kaitain | Eligible paid Treachery receipt: native rounded-up half, occupied rounded-down half; buyer payment occurs once. |
| Junction | Eligible other-player shipping receipt after original Guild-funded exclusions and routing; the existing two-odd-contributor guard remains. |
| Junction | Eligible other-player shipping receipt after original Guild-funded exclusions and routing; the 14 October user ruling rounds the whole invoice once and admits two odd contributors. |
| Richese income | Rounded-down half of actual seller net received, not the gross sale or contribution routed to Emperor. |
| Southern Hemisphere | One completed actual native Collection aggregate, including settled shared and occupied-bank credits; round once and redistribute the already credited amount. |
| Caladan | Native and occupier inspect the original admitted lot, never the occupier’s ally. Canceling native inspection hides its face from native while the separately admitted printed share survives. |
+12 -11
View File
@@ -15,17 +15,17 @@ The E3 Kaitain FAQ's five-spice purchase produces Emperor three and occupier two
Preserve the November FAQ's contribution routing before applying Junction's cut: non-Guild contributions eligible for shipment income go to Guild; Guild's own contribution goes to the bank. Preserve the existing independent-Karama bank-only route. The later funding answer replaces the earlier transfer-to-shipper instruction. This source adjudication and its Guild-as-shipper application are already documented in [Guild shipment payments](GUILD_SHIPMENT_PAYMENTS.md#publisher-contract-and-precedence), with the [November FAQ, p.2](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf#page=2). No new contributor-rounding ruling was retrieved in this audit.
## Supported receipts and pending contributor-rounding question
## Supported receipts and ruled whole-invoice rounding
The coordinating implementation keeps these operations separate:
1. Validate the transaction's actual authorized contributions. Before a low-Junction shipment declaration or cost commits, reject a split whose two positive eligible contributions are both odd: the two plausible rounding interpretations differ. This is an unsupported-case guard, not a printed prohibition. Complete admitted purchases or shipments and debit each contribution once under the transaction's own source rule.
1. Validate the transaction’s actual authorized contributions. Complete admitted purchases or shipments and debit each contribution once under the transaction’s own source rule. Two eligible odd contributions are admitted under the 14 October user ruling; the former comparative fence is removed.
2. Determine the amount that the existing ordinary income rule would deliver to the eligible faction. This `gross` is the faction's eligible receipt, not necessarily the total cost.
3. If ordinary faction-income cancellation succeeds, credit zero and leave that eligible amount in the bank. Homeworld immunity does not immunize the separate ordinary income advantage.
4. Otherwise evaluate the native world's current low condition at income settlement. For supported low receipts, credit `ceil(gross / 2)` and leave the remainder in the bank. This aggregate arithmetic agrees with per-contributor rounding only where the contributor guard admits the transaction; it does not establish a rule for blocked cases. Without the low condition, credit `gross`.
5. Keep the original gross transaction receipt and the original positive eligible contribution amounts immutable through responses and restore. These source amounts remain server-private. Recompute the current low-state preview; do not overwrite the original amount with an earlier preview, merge away payer evidence or halve an already reduced amount. Threshold changes must not let a previously high receipt bypass the unresolved-case check when it settles low.
4. Otherwise evaluate the native world’s current low condition at income settlement. Credit `ceil(gross / 2)` once for the whole eligible invoice and leave the remainder in the Bank. Without the low condition, credit `gross`. Payer costs and contribution routing do not change.
5. Keep original gross and positive eligible contributions immutable and server-private through responses. Recompute current low-state previews without repricing the purchase, merging payer evidence or halving an already reduced amount.
**Contributor rounding remains unanswered.** The coordinating agent has sent a second user question: for low Guild income on a shipment funded by two non-Guild contributions of one spice each, is the income one spice (round the total) or two spice (round each contribution)? Neither interpretation is selected for cases where they differ. Do not duplicate the question, treat elapsed time as an answer, or present the pending aggregate hypothesis as a verified rule. Single positive eligible contributions are unaffected by this distinction; with two contributors, the alternatives coincide unless both are odd. Do not pool unrelated purchases or shipments either. The [payment source gap](HOMEWORLD_CARD_ECONOMY_RULES.md#deferred-payment-splitting) remains open. The separate occupation-lifecycle question is also pending.
**14 October user ruling:** round the whole eligible invoice once, not each contribution. For final Bank attribution, floor each remaining contribution’s half, then assign extra Bank rounding units primary payer first and ally second internally. Bureaucrat’s flat two is attributed to the first individually eligible contribution in that same order; it combines only with that payer’s other actual Bank portions of this original invoice. No funding/eligibility identity is exposed and unrelated invoices or different payers never pool. These are selected user interpretations, not new publisher examples.
**Current population at actual income settlement is also a source composition.** Continuous threshold wording supports applying the condition when the faction receives spice. The publisher supplies no worked example of Ghola changing the native population during an already-open payment response. The implementation must label this composition honestly and test it; it must not reprice the buyer's completed purchase or shipment when the recipient's population changes.
@@ -71,18 +71,19 @@ history remain distinct. Native income Karama sends only its native portion to
the Bank; the printed occupied portion survives under Homeworld immunity and may
be immediately shared with the reciprocal ally. This is explicit source
composition, not a dedicated retrieved worked cancellation FAQ. The payer’s
completed debit and delivery never repeat. Existing two-odd contributor ambiguity,
independent-Karama bank routing and Guild Nexus full-fee overrides remain separate.
No past receipt is back-awarded and no overlay combination is admitted here.
completed debit and delivery never repeat. Whole-invoice rounding is now ruled;
independent-Karama Bank routing and Guild Nexus full-fee overrides remain distinct.
No past receipt is back-awarded. The separate fresh classic Homeworld Banker entry
is documented in [the current runtime](SPICE_BANKER_RUNTIME.md#classic-homeworld-final-bank-income--14-october-2026).
### Guild Nexus Betrayal is a full-fee exception
The [original Guild Nexus face and bounded payment contract](NEXUS_GUILD_RULES.md#nexus-guild-betrayal) expressly take one whole shipment payment, including one's own, and override occupied Junction income. For an accepted use, preserve the original **charged total**, not only this document's eligible `gross` or `ceil(gross / 2)`: the holder receives all of that fee once, and ordinary Guild/bank/occupier settlement for this invoice is suppressed. A four-spice fee yields four to the holder, not two plus two to an occupier. The original payer and authorized escrow still fund it once before any own-payment refund; future receipt is not upfront funding.
This exception does not resolve ordinary two-odd-contributor rounding, occupation owner/expiry or retained penalties. All-pass/no-use settlement retains its native routing and guards; no hypothetical card play may admit an otherwise unresolved ordinary invoice. Free or stopped transport has no positive fee to redirect. The explicit fresh classic/Guild/Nexus profile uses genuinely seeded Homeworld custody and existing legal shipment/Junction adapters; it grants no new route or sponsorship. Its [direct bounded local verification](NEXUS_GUILD_RULES.md#verification-and-release-boundary) includes full occupied low-Junction fees, independently of this document's historical unoccupied-income evidence and broader Homeworld gates.
This exception does not decide occupation owner/expiry or retained penalties. All-pass/no-use settlement retains original routing with the separately ruled whole-invoice rounding; hypothetical Nexus play supplies no new ordinary permission. Free or stopped transport has no positive fee to redirect. The explicit fresh classic/Guild/Nexus profile uses genuine Homeworld custody and existing shipment/Junction adapters, not a new route or sponsorship. Its [bounded local verification](NEXUS_GUILD_RULES.md#verification-and-release-boundary) remains distinct from the new Homeworld Banker entry, which excludes Nexus.
## Required verification before a runtime claim
Meaningful regressions cover odd/even and zero receipts, rejection of two eligible odd contributions before declaration or payment, coincident 2 + 1 rounding, both contributor identities, protected original contribution evidence, module-off parity, current 4-to-5 and 5-to-4 changes across legal Ghola interruptions, cancellation without refund, all listed Emperor sources, all admitted shipment adapters and exact parent restoration. Save/recovery tests should prove no repeat debit, card acquisition, force move or faction credit, and reject altered gross/source/owner binding without persistence. Public previews may show the current numeric split but must not expose the purchased card's private face.
Meaningful regressions cover odd/even and zero receipts, whole-invoice two-odd contributions, both original contributor identities, final per-payer Bank thresholds, cancellation without refund and preserved occupied shares. Existing threshold-change, source-binding and native routing regressions remain relevant. Comprehensive recovery, privacy and full-family acceptance follow all rules; targeted payment evidence is not a substitute.
This documentation pass reviewed source links, referenced local targets, the limited adapter wiring and whitespace only. The contributor guard and unpublished development code are not a completed runtime checkpoint. Runtime tests, full checks and release evidence belong to the coordinating implementation checkpoint; no such result is claimed here.
The 9 September source audit is historical. Current native consumers, actual CLI/phone proof and the frozen292-case related union are recorded in [the current runtime](HOMEWORLD_PAYMENT_INCOME_RUNTIME.md) and [Banker contract](SPICE_BANKER_RUNTIME.md#classic-homeworld-final-bank-income--14-october-2026), not full-module certification.
+5 -5
View File
@@ -1,6 +1,6 @@
# Homeworld payment income runtime
Development checkpoint, 9 September 2026. Low Kaitain and low Junction now reduce supported native faction payment receipts while preserving the payer's original cost. Occupation and divergent allied-contributor rounding remain unfinished. The [source contract](HOMEWORLD_PAYMENT_INCOME_RULES.md) distinguishes printed rules from timing composition and records the pending user question. No Homeworld, Advanced or expansion start gate is opened by this checkpoint.
The 14 October 2026 user ruling resolves allied rounding: low native income is `ceil(total eligible invoice / 2)` once, not a sum of rounded contributor legs. Original payer costs and eligibility stay unchanged. Fresh classic `banker-income --homeworlds` additionally connects actual final Bank portions to original Mentat; occupation receipts remain authoritative. See [the current Banker contract](SPICE_BANKER_RUNTIME.md#classic-homeworld-final-bank-income--14-october-2026). This is not complete Homeworld or deployed acceptance.
## Calculation and settlement
@@ -12,11 +12,11 @@ Ordinary Karama can prevent the separate Emperor or Guild income advantage. The
Emperor adapters include ordinary paid auctions, the existing Richese cache self-purchase destination, Richese's advanced three-spice special purchase and successful Richese Ambassador paid draws. Emperor buying for itself creates no self-income. Richese's own seller receipts retain their destination. Guild adapters preserve ordinary and legacy shipment, native return, Homeworld shipment and Junction-sponsored route classifications. Free transport and unrelated bank rewards create no new payment income.
## Unanswered contributor rounding
## Ruled whole-invoice rounding — 14 October 2026
For two eligible odd contributions, rounding the combined payment and rounding each contributor separately differ. A one-spice plus one-spice non-Guild payment would produce either one or two spice for low Guild. The user question is pending; neither result has been adopted for that case.
Two eligible odd contributions are now admitted; the former comparative fence and UI/AI exclusion are removed. Five plus three yields native4 and Bank4 when unoccupied and low. Attribution floors each remaining contribution’s half and assigns any extra Bank rounding unit to the declared primary payer first, then the ally. This is internal only: no payer, funding, eligibility or rounding identity is newly projected.
The engine rejects that unsupported split before a low-Junction shipment declaration or cost commits. This is a temporary scope guard, not a printed prohibition. Single positive eligible contributions and two-contributor splits on which both interpretations agree remain supported. The aggregate quote implements their common result; it must not be cited as resolving blocked cases. Original contribution evidence accompanies the receipt for later validation, including population-sensitive settlement. Player shipment controls and all four AI profiles use the same public funding guard; blocked candidates are excluded before declaration.
When Bureaucrat redirects two, choose the first individually eligible contribution (declared payer first, ally second), subtract two before the native/occupied calculation, and combine that payer’s two with their own other final Bank portions of this invoice. Never pool payers or unrelated invoices. Thus low unoccupied5+3 becomes native3, retained Bank3, diversion2 and final payer Bank legs4/1; it earns one deferred Banker spice. Occupied cancellation instead banks only the native share;5+3 then leaves Bank legs3/1 and earns none. These accounting choices are user rulings, not publisher worked examples.
## Saved receipts and private information
@@ -43,6 +43,6 @@ HTTP test rooms. No saved game was reset.
Desktop and phone browser checks exercised a five-spice bid yielding three to low-population Emperor and two to the bank. Keyboard allowance and refresh of all three seats passed. The coordinating agent inspected the resulting views and reported no page overflow or browser errors. These are bounded payment-flow checks, not complete Homeworld-game acceptance.
Focused entry point: `npm test -- homeworld-payment-income`. The relevant suites are [pure rules](../tests/homeworld-payment-income.test.ts), [engine flows](../tests/homeworld-payment-income-engine.test.ts) and [production SQLite recovery](../tests/homeworld-payment-income-recovery.test.ts). Their coverage includes source routing, unchanged payer cost, native threshold crossings, allowed/canceled receipt recovery, divergence rejection before commitment, proof validation and private projection. The implementation-status checkpoint records the same scoped evidence; complete expansion games and occupation behavior are not certified.
Focused current evidence includes [whole-invoice quotes](../tests/homeworld-payment-income.test.ts), [Bank attribution](../tests/homeworld-bank-payment.test.ts), [native Banker consumers](../tests/homeworld-banker-income.test.ts), [actual off-planet transport](../tests/homeworld-banker-transport.test.ts) and [existing engine flows](../tests/homeworld-payment-income-engine.test.ts). The 9 September recovery/full-suite counts above are historical evidence; comprehensive assurance is deferred until all rules are implemented. The current frozen related18-file union passes292/292, types/lint and app build pass; actual390px Bureaucrat and native Mentat controls are recorded in the linked Banker contract.
This documentation/reference pass changes no database or automation. The removed automation remains removed. Occupation retention, occupied percentage recipients and Collection income, remaining Homeworld effects and complete expansion acceptance retain their existing release gates.
+40
View File
@@ -1,5 +1,45 @@
# Dune implementation status
## 14 October 2026 — classic Homeworld final Bank income and ruled accounting
The user selected whole-invoice low Guild/Emperor rounding, first eligible
Bureaucrat contribution primary-first internally, and same-payer/same-invoice
combination of redirected2 with other actual final Bank portions. No distinct
payers/invoices pool and no funding or eligibility identity is newly projected.
Recommended options are selected without further clarification questions.
BEFORE fresh Homeworld normal Banker admission rejected; original occupied
5+3 rejected the comparative rounding guard before costs. AFTER the separate
classic `banker-income --homeworlds` path reuses actual occupation/all14 setup,
admits original odd funding and preserves original price, escrow, force sources
and recipient continuations. The old comparison export, table warning and AI
exclusion are removed without aliases. Expansions, Nexus, Discovery, Tech,
Strongholds and other overlays stay excluded from this new income branch.
Final receipts supply actual retained Bank amounts. Low unoccupied5+3 gives
3/1 and no grant; tax2 gives4/1 and one unavailable spice. Occupied allowed
income banks0 (tax variant only2); cancellation banks native4, not full8,
and preserves occupied4. Genuine odd11, first-eligible fallback, later same-
phase payment and replay rejection retain physical native stock and full debits.
Actual world-to-world4 and half-rate sponsored board-return4 are Bank fees,
not Guild income. Actual Kaitain capital4 after original eleven-force shipment
supports a later Guild eight-spice Treachery purchase: native4/Bank4/front1.
Actual isolated `HWBANKQA v8` CLI reaches native setup/income-v1. Actual390px
Bureaucrat **Redirect2** settles4/1, wallet11/front1; native city Collection
makes13, and the last original **Ready for next phase** enters Mentat and makes
14/front0, credit1 once. Browser errors empty; no root overflow. A text wait
for title-case Mentat timed out because the actual heading is uppercase;
fresh accessibility/server evidence proves the click succeeded without replay.
Private local proof: `/tmp/dune-homeworld-banker-ui-proof.json`.
Frozen related18-file union292/292, types/lint and production app build pass.
Build retains module-registration, chunk-size and unknown-route warnings.
Pure accounting21 cases and new native14 cases also passed earlier; no
comprehensive recovery/privacy/custody campaign or deployed acceptance is claimed.
Fixtures, docs/checklist and obsolete rounding-rejection tests are migrated.
The user PDF and all existing saves remain untouched.
## 14 October 2026 — ruled Grumman hidden return and Cunning addition
Four user decisions resolve the earlier Moritani boundaries: Cunning enhances
+15
View File
@@ -4,6 +4,21 @@ Updated 14 October 2026. This is the authoritative navigation index for existing
## User decisions — 14 October 2026
- **Recommended-option priority:** choose an available recommended option and
continue without further clarification questions. Record its interpretation
and scope; no new external privilege or destructive-action authorization.
- **Banker / Bureaucrat:** combine the redirected two with the same original
payer's other final Bank portions of that invoice for the four-spice threshold.
Do not pool distinct payers or distinct invoices.
- **Bureaucrat allied attribution:** use the first eligible contribution:
declared primary payer first, then ally if necessary. Determine it internally;
preserve the existing gross-only prompt and funding privacy.
- **Low-Homeworld funded income:** round the whole Guild/Emperor invoice once.
Allocate any extra Bank rounding unit primary-payer first, then ally,
internally without disclosing funding shares. Full payer debits remain
unchanged. This resolves the previous two-odd-contribution fence.
[Connected final Bank runtime and evidence](SPICE_BANKER_RUNTIME.md#classic-homeworld-final-bank-income--14-october-2026).
- **Moritani Cunning / relocation:** supply placement only. Ordinary relocation
remains available, but Cunning does not broaden its destinations.
- **Moritani Cunning / HMS:** keep the Hidden Mobile Stronghold itself excluded.
+51
View File
@@ -1,5 +1,56 @@
# Spice Banker battle commitment and normal income prototypes
## Classic Homeworld final Bank income — 14 October 2026
Fresh classic Basic/Advanced `banker-income --homeworlds` composes original
occupation setup and all14 Skills with income-v1. This branch excludes Nexus,
Discovery, Tech, Strongholds and native expansion/Betrayal overlays. Other
existing Banker entries remain unchanged; no public activation or played-game
conversion is added.
Three user rulings define accounting: combine only the same payer’s final Bank
portions of one original invoice; attribute Bureaucrat’s two to the first
individually eligible contribution (declared payer, then ally); round low native
income on the whole remaining eligible invoice once. Floor each remaining
contribution’s half and assign extra Bank rounding units primary-first internally.
No funding or eligibility identity is exposed. The former two-odd declaration,
UI and AI fence is removed, without changing original prices or payer shares.
Original native or occupied-percentage receipts supply the actual retained
Bank amount; original payment carriers preserve full payer evidence. Allowed
unoccupied5+3 gives native4/Bank4, final Bank legs3/1 and no grant. Diverting2
gives native3/Bank3 plus diversion2, final legs4/1 and one deferred spice.
Occupied allowed income banks none (or just diversion2); occupied cancellation
preserves occupier4 and banks native4 as3/1, not the full8, so grants none.
Odd11 and first-eligible fallback cases retain real payer shares and stock.
Native world-to-world4 and half-rate Junction board-return4 each remain Bank
fees, grant one unavailable spice and do not manufacture Guild income.
Actual low Kaitain after11 native normal counters leave has4 capital counters;
the later actual Guild eight-spice Treachery purchase gives Emperor4, Bank4,
and one deferred Banker grant.
Actual390px Bureaucrat **Redirect2** settles final Bank legs4/1, available
Atreides wallet11/front1. Ordinary city Collection separately makes wallet13;
the real last **Ready for next phase** enters Mentat and makes14/front0, with
one collection credit1. Browser errors empty; no root overflow. Private local
proof: `/tmp/dune-homeworld-banker-ui-proof.json`. The isolated actual CLI
`HWBANKQA v8` reaches original setup with income-v1 and occupation custody.
Existing games and databases are untouched.
Frozen related18-file union292/292, types/lint and app build pass. Build retains
the existing module-registration, chunk-size and route-classification warnings.
Evidence: `tests/fixture-homeworld-banker-income.ts`,
`tests/homeworld-banker-income.test.ts`,
`tests/homeworld-banker-transport.test.ts` and
`tests/homeworld-bank-payment.test.ts`. Comprehensive assurance, wider module
combinations, public starts and deployed acceptance remain open.
```sh
node --import tsx tools/start-prototype.ts --profile banker-income --homeworlds \
--db /private/local.sqlite --room ROOMCODE --version 7 \
--out /private/new-homeworld-banker-entry
```
## Classic Nexus income composition — 13 October 2026
`banker-income --nexus-cards` now connects the existing mandatory normal band
+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'] },
],
},
{
+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();
+2 -2
View File
@@ -107,8 +107,8 @@ export function startPrototypeRoom(
throw new Error('--discoveries requires the leader-skills or nexus profile.');
if (options.leaderSkills && profile !== 'semuta' && profile !== 'homeworld-occupation' && profile !== 'guild-betrayal')
throw new Error('--leader-skills requires the semuta, homeworld-occupation or guild-betrayal profile.');
if (options.homeworlds && profile !== 'semuta')
throw new Error('--homeworlds requires the semuta profile.');
if (options.homeworlds && profile !== 'semuta' && profile !== 'banker-income')
throw new Error('--homeworlds requires the semuta or banker-income profile.');
if (options.ecazTreachery) initial.ecazTreachery = true;
if ((profile === 'nexus' || options.nexusCards) && !initial.nexusCards)
initial.nexusCards = { cards: null, phase: null };
+3 -2
View File
@@ -40,6 +40,7 @@ async function main() {
'\nStronghold-factions additionally admits standalone Advanced Ecaz OR Moritani with classic opponents and the exact ecaz deck, six Stronghold Cards with optional unused Tech Tokens at 3–6 seats. Original setup and actual end-Mentat claims remain. Ecaz holds shared-control cards and only the actual selected holder-plan benefits. Native Moritani retains original assassination with printed bounty and one private Mentat replacement; Harkonnen is excluded there. An E3 pair, E1/E2/E3 mixtures and other modules remain separate.' +
'\nLeader-skills and banker-income preserve canonical unused Tech Tokens selected in fresh three-through-six-seat classic or supported native Basic/Advanced lobbies. Original selected decks, Richese cache, all fourteen skills, printed native Tech owners, phase-end income and mandatory original-winner reward before Face Dance remain. Banker income remains a separate opt-in. Mixed E3 families, other modules and played-game conversion remain excluded.',
'\nBanker-income --nexus-cards composes the existing deferred normal income with fresh classic Basic/Advanced Nexus and all14 Leader Skills, optionally preserving original Tech3+ and Advanced Strongholds2+. Original bank payer legs, once-per-phase use and native Mentat collection remain unchanged. No Homeworlds, Discovery, expansion-family Nexus or Betrayal overlay is admitted by this entry; ordinary skill starts still require the separate income opt-in.',
'\nBanker-income --homeworlds composes fresh classic Basic/Advanced original Homeworld occupation and all14 Skills without Nexus, Discovery, Tech, Strongholds or expansion/Betrayal overlays. Only actual final Bank portions qualify, including the same payer’s Bureaucrat diversion. Allied income rounds once for the whole eligible invoice; private rounding/diversion attribution preserves full payer debits and occupied player shares. Original Mentat collects automatically; no existing game is converted.',
'\nLeader-skills and banker-income also preserve canonical unused Stronghold Cards in fresh Advanced classic or supported native two-through-six-seat lobbies. Optional Tech Tokens requires three-through-six seats. All14 original skills, decks/cache and actual end-Mentat Stronghold assignment remain. Basic Strongholds, mixed E3 families, other overlays, public activation and save conversion stay excluded.',
'\nThese skill/module entries additionally preserve standalone Ecaz OR Moritani ecaz33/all14 profiles. Tech is Basic/Advanced three-through-six seats; Strongholds is Advanced two-through-six. Native Ecaz five-disc/Duke assignment and Advanced Harkonnen exclusion, plus original Advanced Moritani skill-first assassination/normal-call forfeiture, remain. E3 pairs, mixed families, allied Occupy skills and other overlays are not opened.',
'\nWithout Skills, factions preserves unused original Tech Tokens in fresh 3–6-seat Basic/Advanced selected E1/E2 native or standalone Ecaz OR Moritani lobbies with classic opponents; stronghold-factions composes them with Advanced Stronghold Cards. Original physical inventories, first-Storm owners, selected Ecaz lead rewards, even-force Basic Occupy and non-Harkonnen Advanced Moritani assassination remain. Skills, unrelated overlays, paired/mixed E3 rosters and public starts remain separate.',
@@ -71,8 +72,8 @@ async function main() {
throw new Error('--discoveries requires --profile leader-skills or nexus.');
if (values['leader-skills'] && values.profile !== 'semuta' && values.profile !== 'homeworld-occupation' && values.profile !== 'guild-betrayal')
throw new Error('--leader-skills requires --profile semuta, homeworld-occupation or guild-betrayal.');
if (values.homeworlds && values.profile !== 'semuta')
throw new Error('--homeworlds requires --profile semuta.');
if (values.homeworlds && values.profile !== 'semuta' && values.profile !== 'banker-income')
throw new Error('--homeworlds requires --profile semuta or banker-income.');
if (
!isPrototypeProfile(values.profile) ||
!values.db ||