diff --git a/components/bureaucrat-payment.tsx b/components/bureaucrat-payment.tsx index 1ac36969..cddcdb18 100644 --- a/components/bureaucrat-payment.tsx +++ b/components/bureaucrat-payment.tsx @@ -30,9 +30,11 @@ export function BureaucratPayment({

Bureaucrat payment

{payer ? <>The payment from {payer} to {payee} : <>The qualifying allied-funded payment to {payee}}{' '} - is {pending.amount} spice. You may redirect {pending.redirect} spice to the Bank: {payee} receives{' '} - {redirectAmount} spice and the Bank receives {pending.redirect}, while{' '} - {payer ? `${payer} still pays the full amount` : 'each contributor still pays their full original share'}. + is {pending.amount} spice. You may redirect {pending.redirect} spice to the Bank.{' '} + {pending.homeworldAllocation + ? <>{redirectAmount} spice then follows the original Homeworld income allocation, including any occupied share. + : <>{payee} receives {redirectAmount} spice and the Bank receives {pending.redirect}.}{' '} + {payer ? `${payer} still pays the full amount` : 'Each contributor still pays their full original share'}.

{pending.kind === 'bribe' && (

diff --git a/docs/BUREAUCRAT_PAYMENTS.md b/docs/BUREAUCRAT_PAYMENTS.md index b8f699d3..cfdf5ac9 100644 --- a/docs/BUREAUCRAT_PAYMENTS.md +++ b/docs/BUREAUCRAT_PAYMENTS.md @@ -113,14 +113,37 @@ Advanced source, physical p.22, does not address Bureaucrat); the earlier runtime behavior was the same ordering but had been recorded only as an implementation choice. -The ruling is recorded but not yet exercisable: the occupied-percentage preview -is fenced from Leader Skills (`initializeHomeworldOccupationGameForAudit` -requires no skills, and `homeworldOccupiedIncomeIntegrity` requires -`!g.leaderSkills` whenever the preview is active). Plain Homeworlds + Leader -Skills allied auction/shipment income is connected now, without a split -recipient. Exercising the ruled ordering requires deliberately widening the -occupation preview to admit Leader Skills, which is a separate composition -change, not a silent extension. +The original occupied-percentage preview now composes with all fourteen Leader +Skills in fresh classic Homeworld games. Use `--profile homeworld-occupation +--leader-skills`; the original setup, occupation history and allocation ledger +remain authoritative. Expansions, Nexus Cards, Tech, Strongholds, Discovery and +other overlays remain outside this new entry. Existing games are not converted. + +Actual Guild and Emperor receipts demonstrate gross8 → Bureaucrat Bank2 + +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 new consumers are `tests/bureaucrat-occupied-guild.test.ts` and +`tests/bureaucrat-occupied-emperor.test.ts`, with original-army fixtures of the +same family names. The native Guild path uses paid Emperor shipping to fund +the Guild emptying Junction, then a real Harkonnen invasion. The Emperor path +uses an actual paid auction to fund the original twenty-counter departure, +then a real Guild invasion of empty Kaitain. No force, cash or occupation +history is assigned. Basic current-sole receipts are exercised; Advanced fresh +entry is exercised, not an Advanced retention/full-game certification. + +Actual390px controls redirect the Emperor invoice, then the Guild occupier +keeps3 through the original allocation control, then the original Harkonnen +bonus allowance advances to lot1. E5→8/H5→0/G16→13→16, one use, original +paid8 and5+3 evidence intact. Artifact: `/tmp/dune-occupied-bureaucrat-3231.json`. +Fresh AX observation failed after a Seat selection; fresh state confirmed the +selection and no gameplay action was replayed. Recorded browser errors are empty. +The caption now says the remainder follows the original Homeworld allocation, +rather than falsely claiming it all reaches the native recipient. **Guild Nexus Betrayal receipt.** Betrayal redirects the full original charged shipment fee to the Nexus holder, while Bureaucrat says the recipient receives diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index de675b74..33ec63ba 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -29,6 +29,26 @@ optional skill bands, combinations and public expansion starts remain open. Rules implementation continues; comprehensive assurance and administration remain deferred until all rules are implemented. +**Actual occupied Homeworld / Skills composition — 13 October.** +The earlier marked-done split-recipient task had only proved plain Homeworld +funding; it did not finish the ruled occupied allocation. That missing composition +is now connected through original classic occupation+all14 Skills entry and CLI +`--profile homeworld-occupation --leader-skills`. Paid receipts preserve original +amount/shares and carry optional Bank diversion2; allocation uses gross−2. +Native Basic empty-Junction and empty-Kaitain invasions prove actual occupied +gross8→Bank2/native3/occupied3, decline4+4, and cancellation native0/occupied4 +without a Bureaucrat use. No armies, wallets or qualification histories are +assigned. Original low-Junction two-odd funding guards remain;6+2 is legal. +Actual390px Emperor redirect→Guild allocation→original bonus allowance reaches +lot1 at E8/H0/G16/A10, one use; evidence +`/tmp/dune-occupied-bureaucrat-3231.json`. Browser errors empty; one transient +AX failure after Seat change was recovered without replay. +Both Basic/Advanced fresh entry and actual prototype dispatch are exercised; +occupied payment paths here are Basic, not retention/full-game acceptance. +Final110/110 across seven affected unit files, types/lint and app build pass; +existing build warnings remain. No deployed or complete-mode claim follows; +broader rules, administration and final assurance remain due. + **Bureaucrat / Guild Nexus Betrayal receipt — 13 October.** Per the user ruling, a Betrayal receipt now carries Bureaucrat: the holder receives the fee minus two. A new bounded composition diff --git a/docs/HOMEWORLD_OCCUPATION_RULES.md b/docs/HOMEWORLD_OCCUPATION_RULES.md index 2e27fcfb..77b1c9bb 100644 --- a/docs/HOMEWORLD_OCCUPATION_RULES.md +++ b/docs/HOMEWORLD_OCCUPATION_RULES.md @@ -82,6 +82,16 @@ Original receipt-free occupation captures may admit a new source lazily; no past percentage is back-awarded. A separate fresh marker now connects [occupied Tupile slots/cleanup and low-query restoration](HOMEWORLD_TUPILE_INTELLIGENCE_RUNTIME.md#occupied-tupile-slots-and-original-cleanup--5-october-2026). Public starts, played-game conversion, other modules and exceptional Duke custody remain separate. + +Fresh classic occupation now accepts explicit Leader Skills through +`--profile homeworld-occupation --leader-skills`. Original all14 setup and +occupation-ledger custody remain; other optional modules and expansions are +not added by this composition. The user-ruled Bureaucrat diversion is gross-first: +an original paid8 remains8 in payer evidence, but two goes to Bank before the +remaining6 splits3 native/3 occupied. Native Karama cancellation still leaves +the occupied portion; a canceled receipt never spends Bureaucrat. +See [actual receipt evidence](BUREAUCRAT_PAYMENTS.md#user-rulings--13-october-2026). + See [percentage receipts](../game/homeworld-occupied-percentage.ts), [Bidding authority](../game/homeworld-occupied-bidding.ts) and [original consumer programs](../tests/homeworld-occupied-producers-engine.test.ts). diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 06c9f429..74d6f5ff 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,48 @@ # Dune implementation status +## 13 October 2026 — actual occupied income and Leader Skills + +Corrected the previous task completion claim:9b83b61 proved plain Homeworld +allied receipts, not the ruled occupied split. The original occupation entry now +accepts a second explicit Skills argument for classic rosters only, reusing +native Skills initialization and original occupation ledgers. CLI +`--profile homeworld-occupation --leader-skills` reaches this same entry; +an isolated in-memory prototype dispatch proves persisted setup with real offers +and the original ledger. No existing room or database is converted/reset. + +Redirected occupation sources preserve full original paid amounts/contributions +with optional `bankDiversion:2`; allocatable source amount is gross−2. The old +implementation instead shaved one payer share and rejected multiple shares. +Historical undiverted receipt shape remains unchanged. Original Guild rounding +is checked against full original shares, not rewritten post-diversion shares. +The two-odd low/occupied Junction boundary remains guarded before costs. + +Two native Basic fixtures use real paid funding, departures, invasions and +worm alliances: Emperor shipping funds Guild's original15-reserve departure +before Harkonnen invades Junction; a real ten-spice lot funds Emperor's +twenty-counter departure before Guild invades empty Kaitain. Source history, +armies and wallets are never assigned. Guild6+2 and Emperor5+3 gross8 redirect +to Bank2/native3/occupied3; decline splits4+4. Original Karama cancels native +income only, leaves occupied4 and creates no Bureaucrat offer/use. + +Actual390px GameTable proof starts at the original paid Emperor receipt, +redirects as Atreides, keeps3 as Guild occupier, and allows the original Harkonnen +bonus: lot1, E8/H0/G16/A10, one use, original paid8 and5+3 contributions intact. +Artifact `/tmp/dune-occupied-bureaucrat-3231.json`. A fresh AX observation failed +after accepted Seat selection; recovered state confirms selection with no +gameplay action replay. Recorded browser errors are empty. Caption now describes +the remaining Homeworld allocation instead of promising native gross−2 cash. +Parent actual Guild program separately proves paid8/shares6+2/remaining6, +native3/occupied3 and one use with the original delivered forces. + +Focused84/84 across four unit files and types/lint pass. Both modes' fresh entry +are exercised; actual occupied scenarios are Basic. These are bounded rule/control +proofs, not Advanced retention, deployed, saved-game or complete-mode acceptance. +Final frozen union110/110 across seven affected unit files, types/lint and app +build pass with unchanged warnings. Real CLI help lists the new explicit +occupation+Skills option; only owned QA host/tab/scaffold were removed. + + ## 13 October 2026 — Guild Nexus Betrayal Bureaucrat receipt The user ruled that a Betrayal receipt still carries Bureaucrat: the holder diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 5ea41b2f..8c3a8d33 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -400,7 +400,7 @@ dedicated retrieved worked FAQs. [Contract and remaining limits](HOMEWORLD_OCCUP | Smuggler shipping | Optional free companion is included in the actual total; apply Guild rounding to the reduced priced count. Ordinary Fremen reserves are on planet. The officially permitted owned No-Field plus a separate free force is now prototyped at the unchanged one-marker price. Legacy unstamped ordinary declarations keep their quoted full price. | [Ordinary contract](SMUGGLER_SHIPMENT.md), [No-Field combination](SMUGGLER_NO_FIELD.md) | | Sandmaster movement | One optional board spice per distinct entered territory; source-only repositioning earns none. Per-movement Hajr reset is a documented grammatical inference. Canceled moves earn nothing. Native Fremen worm rides and physical Fremen Ambassador relocations collect optionally at the destination before arrival reactions, without an invented intermediate route. Owned one-pile-per-territory allocation now covers ground, direct destinations and native HMS relocation; sector allocation remains provisional and concealed No-Field movement stays separate. | [Ground/direct/HMS relocation](SANDMASTER_MOVEMENT.md), [worm rides](SANDMASTER_WORM.md) | | Diplomat defense | A committed Worthless copies the opponent's canonical printed defensive role: base/Ix Shield/Snooper, hybrid, defensive Weirding/Chemistry or sealed Portable. Named combat distinctions remain. Copying the used role, not activation or own-slot prerequisites, is a visibly qualified first version. Reveal-before-traitor timing and mandatory disposal remain implementation interpretations; existing base-only battle windows are not reopened. Late/mirror/numeric modifications remain separate. | [Source and connected prototype](DIPLOMAT_DEFENSE.md) | -| Bureaucrat payments | Once per phase, optionally divert two when one third-party payer pays at least five; payer cost is unchanged. Different below-five allied shares never pool. Supported classic/E1/E2/E3 no-Homeworlds and Homeworlds+Leader Skills ordinary, Richese-source and Guild auctions/shipment funding, including a fully qualifying ally, offer one gross-receipt redirection; private shares and selected qualifying payer remain withheld. Owner/payee contributions cannot qualify but do not mask a different eligible payer. Bribes trigger at transfer to the shield, not later collection. Existing Guild/Emperor/revival income follows Karama allowance as an outcome-based inference; canceled gross creates no offer/use. Richese uses its original seller-credit callback, not an invented income cancellation. By user ruling, the two comes off the gross before any Homeworld occupation split (that split-recipient composition stays fenced from Leader Skills), and a Guild Nexus Betrayal receipt still carries Bureaucrat so the holder receives the fee minus two; the bounded classic Nexus plus Leader Skills Betrayal composition now connects that offer from the invoice's own legs. Remaining payment families need further integration. | [Source and connected boundary](BUREAUCRAT_PAYMENTS.md) | +| Bureaucrat payments | Once per phase, divert two from one actual third-party payer contribution of at least five; never pool below-five shares or change full payer costs. Trainer/payee contributions cannot qualify but do not hide another eligible payer; private shares and qualifying identity stay withheld. Bribes qualify at shield transfer, not collection; gifts remain excluded by the 11 October ruling. Original native Karama income cancellation precedes Bureaucrat as an outcome-based inference. User ruled gross-first diversion before occupied Homeworld allocation: fresh classic occupation+Skills now connects gross8→Bank2/native3/occupied3 with original paid8 and contributions preserved; cancellation instead leaves occupied4/native0 and no Bureaucrat use. Original two-odd Guild rounding remains guarded. User also ruled Betrayal holder receipt minus2, connected in the bounded classic Nexus+Skills Betrayal composition. Other combinations remain unfinished. | [Source and connected boundary](BUREAUCRAT_PAYMENTS.md) | | Leader battle effects | Rihani normal peek and reshuffle precede a separate committed optional draw; ordering is an explicit printed-order inference. Unused excludes actual calls, not mere disclosure. Mentat/Bureaucrat change scores; Sandmaster adds board spice. Owned multi-pile Smuggler debits and Sandmaster victory placement are connected under provisional allocation policies; modifier and exceptional Face Dancer composition remain separate. | [Source contract and prototype](LEADER_BATTLE_EFFECTS.md), [Smuggler allocation](SMUGGLER_BATTLE.md) | | Source and readiness | Separate published rules, explicit implementation choices and unfinished interpretations; Advanced/expansion gates stay closed. | [Base/Advanced audit](BASE_ADVANCED_READINESS_20260907.md), [checklist](../game/reference.ts) | | Setup and Fremen movement | Preserve source-ordered setup and the distinct declared-movement cancellation; do not recreate fixed historical gaps. | [Setup contract](ADVANCED_SETUP_TEST_SEAM.md), [Fremen Karama](FREMEN_MOVEMENT_KARAMA_RULES.md) | diff --git a/game/bureaucrat-payment.ts b/game/bureaucrat-payment.ts index 92b71d4a..b64160cb 100644 --- a/game/bureaucrat-payment.ts +++ b/game/bureaucrat-payment.ts @@ -31,6 +31,7 @@ export type BureaucratPaymentView = { amount: number; kind: BureaucratPaymentKind; redirect: 2; + homeworldAllocation?: true; }; usedThisPhase: boolean; }; diff --git a/game/engine.ts b/game/engine.ts index 3e68e1f7..e2604b28 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -8596,7 +8596,9 @@ function projectedBureaucrat(g: Game,viewer: string): BureaucratPaymentView { ![pending.owner,pending.source.payer,pending.source.payee,pending.resume.response?.recipient].includes(viewer); return {pending:pending && !privateRevival ? {event:pending.source.event,owner:pending.owner, payer:pending.source.privateFunding ? null : pending.source.payer,payee:pending.source.payee, - amount:pending.source.amount,kind:pending.source.kind,redirect:2} : null, + amount:pending.source.amount,kind:pending.source.kind,redirect:2, + ...(g.homeworlds && (pending.source.kind === 'auction' || pending.source.kind === 'shipment') + ? { homeworldAllocation: true as const } : {})} : null, usedThisPhase:bureaucratUsed(g.bureaucratPayments?.used ?? [],g.turn,g.phase)}; } function actBureaucratPayment(g: Game,action:Action) { @@ -8951,7 +8953,7 @@ export function initializeHomeworldGameForAudit(state: Game): Game { return initializeSetupGameForAudit(state, true); } /** Fresh occupation prototype; ambiguity remains source-gated rather than assigned a controller. */ -export function initializeHomeworldOccupationGameForAudit(state: Game): Game { +export function initializeHomeworldOccupationGameForAudit(state: Game, leaderSkills = false): Game { requireRule(state.homeworlds?.custody === null && !state.homeworldOccupationPreview && !state.homeworldOccupationHistory && !state.nexusCards && !state.leaderSkills && !state.techTokens && !state.strongholdCards && !state.discoveryEnabled && @@ -8964,7 +8966,10 @@ export function initializeHomeworldOccupationGameForAudit(state: Game): Game { 'Occupation requires a fresh Homeworld lobby with original selected faction decks and no other modules.'); requireFreshBaseRuntime(state); requireFreshFactionInventory(state); - const g = initializeSetupGameForAudit(state, true, false, false, false, false, false, true); + requireRule(!leaderSkills || !state.expansions.length && homeworldLeaderSkillsProfile(state), + 'Occupation with Leader Skills requires original classic Homeworlds without other modules.'); + const g = leaderSkills ? initializeLeaderSkillsGameForAudit(state) + : initializeSetupGameForAudit(state, true, false, false, false, false, false, true); g.homeworldOccupationPreview = true; g.pendingHomeworldOccupiedIncome = null; g.homeworldOccupiedIncomeHistory = []; @@ -16493,7 +16498,8 @@ function admitOccupiedPercentage(g: Game, source: OccupiedPercentageSource, alre g.decision = owner ? { kind: 'homeworldOccupiedPercentage', player: owner, event: source.event } : null; return true; } -function occupiedPaymentSource(g: Game, response: ResponseWindow, gross: number, canceled: boolean): OccupiedPercentageSource { +function occupiedPaymentSource(g: Game, response: ResponseWindow, gross: number, canceled: boolean, + diversion = 0): OccupiedPercentageSource { const kind = response.kind === 'guildIncome' ? 'guild-shipping' : 'emperor-treachery', sale = g.currentAuctionSale; const buyer = response.source === 'ambassador' ? g.pendingAmbassador!.purchaseReceipt!.buyer : response.kind === 'richesePurchaseIncome' ? g.pendingRichesePurchaseIncome!.buyer : sale?.winner; @@ -16503,19 +16509,22 @@ function occupiedPaymentSource(g: Game, response: ResponseWindow, gross: number, sale.occupiedContributions!; } const paid = contributions.reduce((sum, leg) => sum + leg.amount, 0); - if (paid !== gross) { + requireRule(diversion === 0 || diversion === 2 && !canceled && paid === gross + diversion, + 'The occupied receipt lost its original Bureaucrat diversion.'); + if (!diversion && paid !== gross) { requireRule(contributions.length === 1 && paid >= gross, 'The routed payment lost its eligible paid contributions.'); contributions = [{ ...contributions[0], amount: gross }]; } const id = response.occupiedPayment?.event ?? (response.source === 'ambassador' ? g.pendingAmbassador!.event : response.kind === 'richesePurchaseIncome' ? g.pendingRichesePurchaseIncome!.event! : sale!.occupiedEvent!); return { kind, event: crypto.randomUUID(), native: response.owner, amount: gross, - payment: { id, binding: occupiedProducerProof(g, kind, response), paid: gross, contributions, recipient: response.owner }, + payment: { id, binding: occupiedProducerProof(g, kind, response), paid: diversion ? paid : gross, + contributions, recipient: response.owner, ...(diversion ? { bankDiversion: 2 as const } : {}) }, nativeIncome: canceled ? 'karama-canceled' : 'received' }; } function settleOccupiedPayment(g: Game, response: ResponseWindow, gross: number, canceled: boolean, - suffix: OccupiedPercentageSuffix): boolean { - return !!g.homeworldOccupationPreview && admitOccupiedPercentage(g, occupiedPaymentSource(g, response, gross, canceled), 0, suffix); + suffix: OccupiedPercentageSuffix, diversion = 0): boolean { + return !!g.homeworldOccupationPreview && admitOccupiedPercentage(g, occupiedPaymentSource(g, response, gross, canceled, diversion), 0, suffix); } function decideOccupiedPercentage(g: Game, p: Player, action: Action) { occupiedPercentageIntegrity(g); @@ -19081,7 +19090,8 @@ function homeworldOccupiedIncomeIntegrity(g: Game) { return; } requireRule(g.homeworldOccupationPreview === true && g.homeworlds && !g.nexusCards && - !g.leaderSkills && !g.techTokens && !g.strongholdCards && !g.discoveryEnabled && + (!g.leaderSkills || !g.expansions.length && homeworldLeaderSkillsProfile(g)) && + !g.techTokens && !g.strongholdCards && !g.discoveryEnabled && Array.isArray(history) && pending !== undefined, 'The fresh occupation profile lost its original Homeworld module or income ledger.'); const turns = new Set(); @@ -24110,7 +24120,7 @@ function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number) ? { 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 pending = settleOccupiedPayment(g, response, gross, canceled, suffix); + const pending = settleOccupiedPayment(g, response, gross, canceled, suffix, bureaucratDiversion ?? 0); if (!pending) runOccupiedPercentageSuffix(g, suffix); return; } diff --git a/game/homeworld-occupied-percentage.ts b/game/homeworld-occupied-percentage.ts index eb60b941..132c3dd5 100644 --- a/game/homeworld-occupied-percentage.ts +++ b/game/homeworld-occupied-percentage.ts @@ -19,6 +19,8 @@ export type OccupiedPercentagePayment = Readonly<{ paid: number; contributions: readonly OccupiedPercentageContribution[]; recipient: string; + /** Ruled Bureaucrat diversion; original paid amounts and payer shares remain intact. */ + bankDiversion?: 2; }>; export type OccupiedPercentageSource = Readonly<{ event: string; @@ -127,10 +129,14 @@ function freezeSource(source: OccupiedPercentageSource, game: OccupiedPercentage }) }); } const richese = source.kind === 'richese-income'; + const diverted = !!source.payment && Object.hasOwn(source.payment, 'bankDiversion'); requireReceipt(keys(source, richese ? ['kind', 'event', 'native', 'amount', 'payment'] : ['kind', 'event', 'native', 'amount', 'payment', 'nativeIncome']) && keys(source.payment, richese ? ['id', 'binding', 'paid', 'contributions', 'recipient', 'received'] : - ['id', 'binding', 'paid', 'contributions', 'recipient']) && text(source.payment.id) && text(source.payment.binding) && + diverted ? ['id', 'binding', 'paid', 'contributions', 'recipient', 'bankDiversion'] : + ['id', 'binding', 'paid', 'contributions', 'recipient']) && + (!diverted || !richese && source.payment.bankDiversion === 2 && source.nativeIncome === 'received') && + text(source.payment.id) && text(source.payment.binding) && whole(source.payment.paid) && source.payment.recipient === source.native && Array.isArray(source.payment.contributions), 'payment lost its original receipt, actual native recipient or paid provenance.'); const seen: Record = Object.create(null); @@ -144,7 +150,8 @@ function freezeSource(source: OccupiedPercentageSource, game: OccupiedPercentage requireReceipt(total(contributions.map(leg => leg.amount)) === source.payment.paid, 'original payer contributions do not conserve the paid amount.'); const payment = { id: source.payment.id, binding: source.payment.binding, paid: source.payment.paid, - contributions: Object.freeze(contributions), recipient: source.payment.recipient }; + contributions: Object.freeze(contributions), recipient: source.payment.recipient, + ...(diverted ? { bankDiversion: 2 as const } : {}) }; if (source.kind === 'richese-income') { requireReceipt(whole(source.payment.received) && source.payment.received <= source.payment.paid && source.amount === source.payment.received, 'Richese percentage requires actual net received income, not the gross sale.'); @@ -152,7 +159,8 @@ function freezeSource(source: OccupiedPercentageSource, game: OccupiedPercentage } requireReceipt(source.nativeIncome === 'received' || source.nativeIncome === 'karama-canceled', 'invalid native income outcome.'); const eligible = contributions.filter(leg => source.kind !== 'guild-shipping' || leg.payer !== source.native); - requireReceipt(total(eligible.map(leg => leg.amount)) === source.amount, 'eligible receipt includes excluded or unpaid spice.'); + requireReceipt(total(eligible.map(leg => leg.amount)) === source.amount + (diverted ? 2 : 0), + 'eligible receipt includes excluded or unpaid spice.'); return Object.freeze({ ...base, kind: source.kind, payment: Object.freeze(payment), nativeIncome: source.nativeIncome }); } function amounts(source: OccupiedPercentageSource, nativeLow: boolean, occupied: boolean) { @@ -239,7 +247,7 @@ export function quoteOccupiedPercentageSource( // 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, eligible).unambiguous, + 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); diff --git a/game/reference.ts b/game/reference.ts index 3ed1dd02..41697ecc 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -4802,7 +4802,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'Spice Banker can commit one through three own spice with its Battle Plan, separate from force support. Only the surviving selected skilled disc gains that strength. The amount stays private until authorized plan inspection or public reveal. It is paid on win or loss, including leader death, except a sole traitor-call winner spends nothing. Normal deferred income connects only in the separate fresh classic or supported native banker-income profiles, including the bounded Advanced Moritani roster; ordinary/default starts are unchanged.', 'Diplomat may use one committed Worthless as the opponent’s canonical printed defensive role when you played no actual defense: Shield, Snooper, Shield Snooper, defensive Weirding Way or Chemistry, or sealed Portable Snooper. Hybrid protection, Weirding’s non-Shield behavior and Chemistry’s Poison Tooth exception remain distinct. Copying the used role without its activation or own-slot prerequisite is the explicit first-version interpretation. Choose or decline after reveal, before traitors; the Worthless must be discarded even if you win. Already-open base-only battles keep that source window. A native Diplomat can protect its own selected disc; a captive grants no upper band. A surviving trained loser may retreat selected undialed physical counters up to printed/copied disc strength to one empty adjacent non-stronghold before losses. Late/mirror/numeric modifications remain separate.', 'Normal Mentat questions use the user-approved uniform private card-show step before posture and faction powers in supported Leader Skills games. The opponent shows the named held weapon or chooses another held card, even for an exact match or sole eligible card. Weirding Way is a nameable default weapon; Chemistry is not. Cards stay held and need not enter a Battle Plan. New battles activate the question without an extra opt-in; already-opened unversioned battles retain their original flow. Empty hands skip without an answer. Private history records an observation, not a later hand guarantee.', - 'Bureaucrat may redirect two spice when one other player pays at least five to a player other than the skill owner, once per phase. The payer still pays in full; the recipient receives two less. Declining keeps the use available for a later payment. Paid bribes qualify when transferred, with their remaining share unavailable until Mentat Pause. Supported auctions, Guild income, Richese sales and paid Tleilaxu revival have connected paths; a separate free-revival award does not make a smaller price qualify. Allied payer shares below five each, such as three plus three, never pool to qualify. Supported classic, E1, E2 and E3 games without Homeworlds additionally connect qualifying allied-funded ordinary auctions, Richese cache/Advanced Black Market and shipments, including fully ally-funded five: one optional two-spice diversion from the gross recipient receipt, unchanged full contributor costs. Supported Homeworlds plus Leader Skills games connect the same allied-funded auction and Guild-shipment income. Original Guild/Emperor income cancellation resolves first; Richese proceeds use their original direct seller-credit callback without an invented cancellation window. Basic still has no Black Market and free Karama purchases remain normal-only. A trainer or payee contribution cannot qualify, but does not hide an eligible other payer. The prompt shows public gross and recipient, not private shares or which payer qualified. By user ruling the diversion comes off the gross before any Homeworld occupation split, but that split-recipient composition remains fenced from Leader Skills. By user ruling a Guild Nexus Betrayal receipt still carries Bureaucrat: the holder receives the fee minus two, and the bounded classic Nexus plus Leader Skills Betrayal composition now connects that offer from the invoice’s own payment legs. By user ruling, Emperor gifts are not payments: no Bureaucrat prompt/use or threshold disclosure, and exact gift amounts remain private. Other payment-family/module interactions remain unfinished.', + 'Bureaucrat may redirect two spice when one other player pays at least five to a player other than the skill owner, once per phase. Payer costs stay unchanged; declining preserves later use. Bribes qualify at transfer and remain unavailable until Mentat Pause. Supported ordinary/Richese auctions, Guild shipments and paid Tleilaxu revival have connected paths; a separate free-revival award cannot make a smaller price qualify. Allied payer shares never pool to reach five. Trainer or recipient contributions cannot qualify but do not hide an eligible other payer. Public prompts show gross and recipient, not private shares or which payer qualified. Supported classic and native E1/E2/E3 profiles and Homeworld Skills retain original costs and callbacks. Native Guild/Emperor/revival Karama cancellation resolves before Bureaucrat; Richese retains direct seller credit with no invented cancellation window. Basic has no Black Market; free Karama purchases remain normal-only. By user ruling Bureaucrat takes two off gross before occupied Homeworld allocation. Fresh classic occupation plus Skills now connects this: eight becomes two to Bank, three native and three occupied, with full original payer contributions retained. Decline gives four plus four; native income cancellation preserves the separate occupied share and creates no Bureaucrat offer. The low/occupied Guild two-odd contributor rounding boundary is unchanged. By user ruling Guild Nexus Betrayal also carries Bureaucrat on its holder receipt in the bounded classic Nexus plus Skills composition; the holder receives the fee minus two. Emperor gifts remain excluded by user ruling, without threshold disclosure. Other profiles, overlays and full-mode acceptance remain unfinished.', 'Smuggler records all existing positive battle-territory piles when plans reveal, capped in total by the selected leader’s unmodified strength. A surviving controller collects even when losing or no forces remain. A partial collection across several piles asks that owner for exact sector amounts; all/none and single-pile collection stay automatic. This allocation policy is provisional. Pending spice cannot fund the plan. Collection precedes Sandmaster addition; a drained pile receives none. Modified strength and other combined-module limits remain guarded. Older saved battles retain their recorded behavior.', 'In classic and supported E1/E2 native Tech composition, actual Smuggler prices and normal Banker qualifying fees remain distinct from phase-end Tech income. Tech piles cannot fund their triggering action; Banker front-shield grants still wait for actual Mentat. Winning physical Suk rescue and Sandmaster board spice precede original played-card cleanup and mandatory winner token reward. One token transfers automatically, several require a selection, and no token skips without an invented decline. A later Face Dancer retains the original winner’s cards, bounty and Tech reward, kills the matching trainer without another bounty and replaces only actual survivors from real sources. Native Tleilaxu-only free revival excludes Axlotl; CHOAM initial income is not Charity. Richese’s free companion does not change its marker price or create the concealed counters early.', 'Physical Fremen Ambassador relocation now offers optional Sandmaster destination collection before arrival reactions, without an invented route or ordinary movement use. Same-territory repositioning earns none; concealed relocation and other special movement still retain their own limits. Normal Banker income has separate fresh classic or supported native source-to-Mentat previews with provisional original-earned-faction custody, not general activation. Diplomat retreat retains its documented conservative outcome order. Native Advanced foreign gholas/Face Dancers, skill-first Moritani assassination and ordinary-disc Ecaz skills follow their bounded contracts; shared-Duke assignment, combined Occupy skills and other combined modules remain unfinished. This prototype does not enable public module starts.', diff --git a/tests/bureaucrat-homeworld-funding.test.ts b/tests/bureaucrat-homeworld-funding.test.ts index 2eadae3c..90864062 100644 --- a/tests/bureaucrat-homeworld-funding.test.ts +++ b/tests/bureaucrat-homeworld-funding.test.ts @@ -194,16 +194,16 @@ for (const advanced of [false, true]) void test(`${advanced ? 'Advanced' : 'Basi } }); -void test('occupied-percentage entry remains explicitly fenced from an actual trained Homeworld Bureaucrat game', () => { +void test('played Homeworld skills rooms cannot be converted and incomplete occupation saves reject immutably', () => { const f = fixture({ kind: 'shipment' }); assert.equal(f.game.homeworldOccupationPreview, undefined); - assert.throws(() => initializeHomeworldOccupationGameForAudit(f.game), - { message: 'Occupation requires a fresh Homeworld lobby with original selected faction decks and no other modules.' }); + const original = structuredClone(f.game); + assert.throws(() => initializeHomeworldOccupationGameForAudit(f.game)); + assert.deepEqual(f.game, original); // Negative malformed-save input only: never claimed as a reachable occupation // fixture. No wallet, army, occupation history or skill custody is fabricated. const rejectedSave = structuredClone(f.game); rejectedSave.homeworldOccupationPreview = true; const before = structuredClone(rejectedSave); - assert.throws(() => applyAction(rejectedSave, f.payer, f.action), - { message: 'The fresh occupation profile lost its original Homeworld module or income ledger.' }); + assert.throws(() => applyAction(rejectedSave, f.payer, f.action)); assert.deepEqual(rejectedSave, before); }); diff --git a/tests/bureaucrat-occupied-emperor.test.ts b/tests/bureaucrat-occupied-emperor.test.ts new file mode 100644 index 00000000..8f0152bd --- /dev/null +++ b/tests/bureaucrat-occupied-emperor.test.ts @@ -0,0 +1,189 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { applyAction, viewGame, type Action, type Game } from '../game/engine'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { quoteStableHomeworldOccupation } from '../game/homeworld-stable-occupation'; +import { homeworldFundingCards } from './fixture-bureaucrat-homeworld-funding'; +import { assertClassicHomeworldSkillsCustody } from './fixture-homeworld-classic-skills'; +import { + advanceOccupiedEmperor, allocateOccupiedEmperor as allocate, + allowOccupiedEmperorResponses as allow, chooseOccupiedEmperor as choose, + createOccupiedEmperorFixture as fixture, occupiedEmperorClean, + occupiedEmperorPlayer as player, occupiedEmperorWallets as wallets, + payOccupiedEmperor as pay, type OccupiedEmperorFixture, +} from './fixture-bureaucrat-occupied-emperor'; + +function reject(game: Game, actor: string, action: Action): void { + const before = structuredClone(game); + assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before); +} + +/** Full debit evidence precedes either skill or occupation allocation. */ +function assertPaid(f: OccupiedEmperorFixture, paid: Game): void { + const expected = wallets(f.beforePledge); + expected[f.payer] -= f.payerShare; expected[f.donor] -= f.donorShare; + assert.deepEqual(wallets(paid), expected); + assert.equal(paid.currentAuctionSale!.amount, f.gross); + assert.deepEqual(paid.currentAuctionSale!.occupiedContributions, + [{ payer: f.payer, amount: f.payerShare }, ...(f.donorShare ? [{ payer: f.donor, amount: f.donorShare }] : [])]); + assert.equal(paid.aid[f.donor]?.amount ?? 0, 0); +} + +function assertOutcome(f: OccupiedEmperorFixture, allocation: Game, done: Game, + diverted: 0 | 2, canceled = false, ownAmount?: number): void { + const pending = allocation.pendingHomeworldOccupiedPercentage!; + const source = pending.source; + assert.ok(source.kind === 'emperor-treachery'); + const grossAfterBank = f.gross - diverted; + const native = canceled ? 0 : Math.ceil(grossAfterBank / 2), occupied = Math.floor(grossAfterBank / 2); + const own = ownAmount ?? occupied; + assert.equal(source.amount, grossAfterBank); assert.equal(source.payment.paid, f.gross); + assert.deepEqual(source.payment.contributions, + [{ payer: f.payer, amount: f.payerShare }, ...(f.donorShare ? [{ payer: f.donor, amount: f.donorShare }] : [])]); + if (diverted) assert.equal(source.payment.bankDiversion, 2); + else assert.equal(Object.hasOwn(source.payment, 'bankDiversion'), false); + assert.equal(source.nativeIncome, canceled ? 'karama-canceled' : 'received'); + const expected = wallets(f.beforePledge); + expected[f.payer] -= f.payerShare; expected[f.donor] -= f.donorShare; + expected[f.payee] += native; expected[f.occupier] += own; + const receipt = done.homeworldOccupiedPercentageLedger!.receipts.find(r => r.source.event === source.event)!; + if (occupied - own) expected[receipt.entitlement!.ally!] += occupied - own; + assert.deepEqual(wallets(done), expected); + assert.equal(receipt.nativeRetained, native); assert.equal(receipt.occupiedAmount, occupied); + assert.equal(receipt.status, 'settled'); assert.equal(receipt.ownAmount, own); + assert.deepEqual(receipt.credits, [{ player: f.occupier, amount: own }, + ...(occupied - own ? [{ player: receipt.entitlement!.ally!, amount: occupied - own }] : [])]); + assert.equal(Object.values(wallets(f.beforePledge)).reduce((a, b) => a + b, 0) - + Object.values(wallets(done)).reduce((a, b) => a + b, 0), canceled ? Math.ceil(f.gross / 2) : diverted); + assert.equal(done.bureaucratPayments?.used.length ?? 0, diverted ? 1 : 0); + assert.deepEqual(player(done, f.payer).hand.map(c => c.id), + [...player(f.beforeAction, f.payer).hand.map(c => c.id), f.lot, f.bonus]); + assert.equal(done.auction!.index, f.beforeAction.auction!.index + 1); + assert.equal(done.currentAuctionSale, null); assert.equal(done.pendingHomeworldOccupiedPercentage, null); + assert.deepEqual(homeworldFundingCards(done), f.physical); + assert.deepEqual(done.players.map(p => ({ id: p.id, forces: p.forces, reserves: p.reserves, tanks: p.tanks, elites: p.elites })), + f.beforePledge.players.map(p => ({ id: p.id, forces: p.forces, reserves: p.reserves, tanks: p.tanks, elites: p.elites }))); + assert.deepEqual(done.homeworlds!.custody, f.beforePledge.homeworlds!.custody); + assertClassicHomeworldSkillsCustody(done); +} + +void test('Basic native paid auction empties original Kaitain with a real twenty-counter shipment and qualifies a one-counter Guild invasion', () => { + const f = fixture(); + assert.equal(f.setup.homeworldOccupationPreview, true); + assert.equal(f.setup.leaderSkills!.deck.length + f.setup.leaderSkills!.assignments.length, 14); + assert.equal(f.setup.leaderSkills!.assignments.find(a => a.skill === 'bureaucrat')!.owner, f.trainer); + assert.equal(player(f.fundedEmperor, f.payee).spice, 20); + assert.equal(player(f.emptyKaitain, f.payee).spice, 0); + assert.equal(player(f.emptyKaitain, f.payee).reserves, 0); + assert.equal(Object.values(player(f.emptyKaitain, f.payee).forces).reduce((a, b) => a + b, 0), 20); + assert.equal(f.occupied.homeworlds!.custody!.visitors['homeworld:emperor'][f.occupier].normal, 1); + const authority = quoteStableHomeworldOccupation(f.occupied, 'kaitain'); + assert.equal(authority.blocked, null); assert.equal(authority.entitlement!.occupier, f.occupier); + assert.equal(player(f.alliance, f.payer).ally, f.donor); + assert.equal(player(f.alliance, f.donor).ally, f.payer); + for (const boundary of [f.setup, f.emptyKaitain, f.occupied, f.beforeAction]) assertClassicHomeworldSkillsCustody(boundary); +}); + +for (const redirect of [true, false]) void test(`occupied gross8 ${redirect ? 'diverts before splitting to Emperor3/Guild3/Bank2' : 'declines to Emperor4/Guild4'} and delivers the original lot/bonus once`, () => { + const f = fixture(), paid = pay(f); assertPaid(f, paid); + const pending = allow(paid); + assert.equal(pending.decision?.kind, 'bureaucratPayment'); assert.equal(pending.decision!.player, f.trainer); + const allocation = choose(pending, redirect); + assert.equal(allocation.decision?.kind, 'homeworldOccupiedPercentage'); + assert.equal(allocation.decision!.player, f.occupier); + assert.equal(player(allocation, f.payee).spice - player(f.beforePledge, f.payee).spice, redirect ? 3 : 4); + assert.equal(player(allocation, f.occupier).spice, player(f.beforePledge, f.occupier).spice - 3); + const done = allocate(allocation); assertOutcome(f, allocation, done, redirect ? 2 : 0); + reject(done, f.trainer, { type: 'decision', event: pending.bureaucratPaymentEvent, redirect: true }); + reject(done, f.occupier, { type: 'decision', event: allocation.pendingHomeworldOccupiedPercentage!.source.event, ownAmount: redirect ? 3 : 4 }); +}); + +void test('original occupied sharing suffix legally credits the reciprocal ally directly after gross-minus-two', () => { + const f = fixture(), allocation = choose(allow(pay(f)), true); + assert.equal(viewGame(allocation, f.occupier).homeworldOccupiedPercentage!.ally, f.payer); + const done = allocate(allocation, 1); assertOutcome(f, allocation, done, 2, false, 1); + reject(allocation, f.payer, { type: 'decision', event: allocation.pendingHomeworldOccupiedPercentage!.source.event, ownAmount: 1 }); + reject(allocation, f.occupier, { type: 'decision', event: 'stale-occupied-source', ownAmount: 1 }); +}); + +void test('actual 3+5 qualifies on the donor; actual 4+4 cannot pool two sub-five debits', () => { + const eligible = fixture({ payerShare: 3, donorShare: 5 }), paid = pay(eligible); assertPaid(eligible, paid); + const pending = allow(paid); assert.equal(pending.decision?.kind, 'bureaucratPayment'); + const allocation = choose(pending, true); assertOutcome(eligible, allocation, allocate(allocation), 2); + const pooled = fixture({ payerShare: 4, donorShare: 4 }), pooledPaid = pay(pooled); assertPaid(pooled, pooledPaid); + const direct = allow(pooledPaid); assert.equal(direct.decision?.kind, 'homeworldOccupiedPercentage'); + assertOutcome(pooled, direct, allocate(direct), 0); +}); + +for (const donor of ['atreides', 'emperor'] as const) void test(`occupied funding excludes ${donor === 'atreides' ? 'trainer' : 'native recipient'} share without masking the independent qualified buyer`, () => { + const excluded = fixture({ donor, payerShare: 3, donorShare: 5 }), paid = pay(excluded); assertPaid(excluded, paid); + const allocation = allow(paid); assert.equal(allocation.decision?.kind, 'homeworldOccupiedPercentage'); + assertOutcome(excluded, allocation, allocate(allocation), 0); + const eligible = fixture({ donor, payerShare: 5, donorShare: 5 }), eligiblePaid = pay(eligible); assertPaid(eligible, eligiblePaid); + const pending = allow(eligiblePaid); assert.equal(pending.decision?.kind, 'bureaucratPayment'); + const redirected = choose(pending, true); assertOutcome(eligible, redirected, allocate(redirected), 2); +}); + +void test('native Karama cancellation preserves occupied4, retains full paid8 and never offers or spends Bureaucrat', () => { + const f = fixture(), paid = pay(f); assertPaid(f, paid); + assert.equal(paid.response?.kind, 'emperorIncome'); + assert.ok(viewGame(paid, f.trainer).responseControls!.cancelCards.includes(f.karama)); + const allocation = allow(applyAction(paid, f.trainer, { type: 'card', mode: 'cancel', card: f.karama })); + assert.equal(allocation.decision?.kind, 'homeworldOccupiedPercentage'); + const done = allocate(allocation); assertOutcome(f, allocation, done, 0, true); + assert.equal(done.discard.filter(c => c.id === f.karama).length, 1); + assert.ok(!player(done, f.trainer).hand.some(c => c.id === f.karama)); + reject(done, f.trainer, { type: 'card', mode: 'cancel', card: f.karama }); +}); + +void test('occupied gross8 invoice keeps private payer evidence private, rejects stale/wrong actors, and all four minimal owner bots settle real money', () => { + const f = fixture(), pending = allow(pay(f)); + for (const viewer of pending.players) { + const invoice = viewGame(pending, viewer.id).bureaucrat.pending!; + assert.equal(invoice.amount, 8); assert.equal(invoice.payer, null); assert.equal(invoice.payee, f.payee); + for (const key of ['contributions', 'shares', 'qualifyingPayer', 'privateFunding', 'signature', 'source']) + assert.equal(key in invoice, false); + } + reject(pending, f.payer, { type: 'decision', event: pending.bureaucratPaymentEvent, redirect: true }); + reject(pending, f.trainer, { type: 'decision', event: 'stale-bureaucrat-source', redirect: true }); + reject(pending, f.donor, { type: 'pledgeAid', amount: 1 }); + for (const difficulty of DIFFICULTIES) { + const view = viewGame(pending, f.trainer); view.players.find(p => p.id === f.trainer)!.bot = difficulty; + const actions = botActions(view); assert.equal(actions.length, 1); + const allocation = applyAction(pending, f.trainer, actions[0]); + assert.equal(allocation.decision?.kind, 'homeworldOccupiedPercentage'); + assertOutcome(f, allocation, allocate(allocation), 2); + } +}); + +void test('redirect spends only one phase use; decline leaves it for the next actual funded lot and each physical bonus occurs once', () => { + for (const firstRedirect of [true, false]) { + const f = fixture(), firstPending = allow(pay(f)), firstAllocation = choose(firstPending, firstRedirect); + const first = allocate(firstAllocation); assertOutcome(f, firstAllocation, first, firstRedirect ? 2 : 0); + let next = advanceOccupiedEmperor(first, g => !!g.auction && g.auction.active === f.payer && occupiedEmperorClean(g)); + const beforePledge = structuredClone(next); + next = applyAction(next, f.donor, { type: 'pledgeAid', amount: 5 }); + const second: OccupiedEmperorFixture = { ...f, game: next, beforePledge, beforeAction: structuredClone(next), + payerShare: 0, donorShare: 5, gross: 5, action: { type: 'bid', amount: 5, allyPayment: 5 }, + lot: next.auction!.cards[next.auction!.index].id, bonus: next.deck[0].id }; + const paid = pay(second); assertPaid(second, paid); + let allocation = allow(paid); + if (firstRedirect) assert.equal(allocation.decision?.kind, 'homeworldOccupiedPercentage'); + else { + assert.equal(allocation.decision?.kind, 'bureaucratPayment'); + reject(allocation, f.trainer, { type: 'decision', event: firstPending.bureaucratPaymentEvent, redirect: true }); + allocation = choose(allocation, true); + } + const done = allocate(allocation); + const expected = wallets(beforePledge); expected[f.donor] -= 5; + expected[f.payee] += firstRedirect ? 3 : 2; expected[f.occupier] += firstRedirect ? 2 : 1; + assert.deepEqual(wallets(done), expected); + assert.equal(done.bureaucratPayments!.used.length, 1); + assert.deepEqual(player(done, f.payer).hand.map(c => c.id), + [...player(first, f.payer).hand.map(c => c.id), second.lot, second.bonus]); + assert.equal(done.auction!.index, first.auction!.index + 1); + assert.deepEqual(homeworldFundingCards(done), f.physical); + assertClassicHomeworldSkillsCustody(done); + } +}); diff --git a/tests/bureaucrat-occupied-guild.test.ts b/tests/bureaucrat-occupied-guild.test.ts new file mode 100644 index 00000000..f66b03e9 --- /dev/null +++ b/tests/bureaucrat-occupied-guild.test.ts @@ -0,0 +1,271 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { applyAction, viewGame, type Action, type Game } from '../game/engine'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { quoteStableHomeworldOccupation } from '../game/homeworld-stable-occupation'; +import type { OccupiedPercentageReceipt } from '../game/homeworld-occupied-percentage'; +import { assertClassicHomeworldSkillsCustody } from './fixture-homeworld-classic-skills'; +import { + allocateOccupiedGuild, allowOccupiedGuildIncome, cancelOccupiedGuildIncome, chooseOccupiedGuild, + createOccupiedGuildFixture, declareOccupiedGuild, occupiedGuildArmies, occupiedGuildCards, + occupiedGuildClean, occupiedGuildPlayer, occupiedGuildWallets, advanceOccupiedGuild, + type OccupiedGuildFixture, +} from './fixture-bureaucrat-occupied-guild'; + +const wallets = occupiedGuildWallets; +const armies = occupiedGuildArmies; + +/** The frozen receipt keeps its original producer proof; read it only after the + * discriminant is checked. */ +function guildShippingSource(receipt: OccupiedPercentageReceipt) { + const source = receipt.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): void { + const before = structuredClone(game); + assert.throws(() => applyAction(game, actor, action)); + assert.deepEqual(game, before); +} + +/** Exact wallet conservation: the payer and the pledged ally pay their original + * shares once, the native keeps its half, the occupier takes the other half and + * only the ruled diversion (or the canceled native half) leaves for the Bank. */ +function outcome(f: OccupiedGuildFixture, done: Game, diverted: number, canceled = false): void { + const expected = wallets(f.beforePledge); + expected[f.payer] -= f.payerShare; + expected[f.donor] -= f.donorShare; + const amount = canceled ? f.gross : f.gross - diverted; + if (!canceled) expected[f.payee] += Math.ceil(amount / 2); + expected[f.occupier] += Math.floor(amount / 2); + assert.deepEqual(wallets(done), expected); + const before = Object.values(wallets(f.beforePledge)).reduce((a, b) => a + b, 0); + const after = Object.values(wallets(done)).reduce((a, b) => a + b, 0); + assert.equal(before - after, canceled ? Math.floor(amount / 2) : diverted, + 'Only the ruled diversion or the canceled native half leaves the seated wallets for the Bank'); + assert.equal(done.aid[f.donor]?.amount ?? 0, 0); + assert.deepEqual(occupiedGuildCards(done), f.physical); + assertClassicHomeworldSkillsCustody(done); +} + +void test('native Guild occupation redirects two of a foreign shipping payment to the Bank after the full payer and ally debits', () => { + const f = createOccupiedGuildFixture(); + assert.equal(f.payerShare + f.donorShare, 8); + assert.equal(quoteStableHomeworldOccupation(f.junctionEmpty, 'junction').entitlement, null); + const authority = quoteStableHomeworldOccupation(f.occupied, 'junction'); + assert.equal(authority.blocked, null); + assert.equal(authority.entitlement?.occupier, f.occupier); + assert.equal(authority.entitlement?.native, f.payee); + assert.deepEqual(f.occupied.homeworlds!.custody!.visitors['homeworld:guild'], + { harkonnen: { normal: 1, elite: 0 } }); + assert.equal(occupiedGuildPlayer(f.junctionEmpty, f.payee).reserves, 0); + const declared = declareOccupiedGuild(f); + assert.equal(declared.response?.kind, 'guildIncome'); + assert.equal(declared.response?.amount, f.gross); + assert.equal(occupiedGuildPlayer(declared, f.payer).spice, + occupiedGuildPlayer(f.beforePledge, f.payer).spice - f.payerShare); + assert.equal(occupiedGuildPlayer(declared, f.donor).spice, + occupiedGuildPlayer(f.beforePledge, f.donor).spice - f.donorShare); + assert.equal(occupiedGuildPlayer(declared, f.payee).spice, occupiedGuildPlayer(f.beforePledge, f.payee).spice); + assert.deepEqual(occupiedGuildPlayer(declared, f.payer).forces, { + ...occupiedGuildPlayer(f.beforePledge, f.payer).forces, + [`${f.territory}:${f.sector}`]: f.gross / 2, + }); + assert.equal(occupiedGuildPlayer(declared, f.payer).reserves, + occupiedGuildPlayer(f.beforePledge, f.payer).reserves - f.gross / 2); + assert.equal(occupiedGuildPlayer(declared, f.payer).shipped, true); + for (const other of declared.players.filter(player => player.id !== f.payer)) { + assert.deepEqual(other.forces, occupiedGuildPlayer(f.beforePledge, other.id).forces); + assert.equal(other.reserves, occupiedGuildPlayer(f.beforePledge, other.id).reserves); + } + const pending = allowOccupiedGuildIncome(declared); + assert.equal(pending.decision?.kind, 'bureaucratPayment'); + assert.equal(pending.decision?.player, f.trainer); + for (const viewer of pending.players) { + const view = viewGame(pending, viewer.id), invoice = view.bureaucrat.pending!; + assert.equal(invoice.payer, null); + assert.equal(invoice.payee, f.payee); + assert.equal(invoice.owner, f.trainer); + assert.equal(invoice.amount, f.gross); + assert.equal(invoice.homeworldAllocation, true); + assert.equal(view.bureaucrat.usedThisPhase, false); + 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`); + assert.equal('homeworldOccupiedPercentageLedger' in view, false); + } + reject(pending, f.donor, { type: 'decision', event: pending.bureaucratPaymentEvent, redirect: true }); + reject(pending, f.trainer, { type: 'decision', event: 'expired-bureaucrat-event', redirect: true }); + reject(pending, f.payer, { type: 'endMovement' }); + const redirected = chooseOccupiedGuild(pending, true); + assert.equal(redirected.decision?.kind, 'homeworldOccupiedPercentage'); + assert.equal(redirected.decision?.player, f.occupier); + assert.equal(occupiedGuildPlayer(redirected, f.payee).spice, + occupiedGuildPlayer(f.beforePledge, f.payee).spice + 3); + const offer = viewGame(redirected, f.occupier).homeworldOccupiedPercentage!; + assert.equal(offer.world, 'homeworld:guild'); + assert.equal(offer.name, 'Junction'); + assert.equal(offer.owner, f.occupier); + assert.equal(offer.ally, f.donor); + assert.equal(offer.amount, 3); + assert.equal(offer.minOwnAmount, 0); + assert.equal(offer.maxOwnAmount, 3); + assert.equal(offer.blocked, null); + const done = allocateOccupiedGuild(redirected); + const receipt = done.homeworldOccupiedPercentageLedger!.receipts.at(-1)!; + const source = guildShippingSource(receipt); + assert.equal(source.nativeIncome, 'received'); + assert.equal(source.native, f.payee); + assert.equal(source.amount, 6); + assert.equal(source.payment.recipient, f.payee); + assert.ok(source.payment.id.length > 0); + assert.ok(source.payment.binding.length > 0); + assert.equal(source.payment.paid, 8, 'the receipt keeps the original paid amount'); + assert.equal(source.payment.bankDiversion, 2); + assert.deepEqual(source.payment.contributions, [ + { payer: f.payer, amount: 6 }, { payer: f.donor, amount: 2 }, + ]); + assert.equal(receipt.status, 'settled'); + assert.equal(receipt.nativeRetained, 3); + assert.equal(receipt.occupiedAmount, 3); + assert.equal(receipt.bankRetained, 0); + assert.equal(receipt.ownAmount, 3); + assert.deepEqual(receipt.credits, [{ player: f.occupier, amount: 3 }]); + assert.equal(receipt.entitlement?.occupier, f.occupier); + assert.equal(receipt.entitlement?.ally, f.donor); + assert.equal(occupiedGuildPlayer(done, f.payer).forces[`${f.territory}:${f.sector}`], f.gross / 2); + assert.equal(occupiedGuildPlayer(done, f.payer).reserves, + occupiedGuildPlayer(f.beforePledge, f.payer).reserves - f.gross / 2); + assert.equal(occupiedGuildPlayer(done, f.payer).shipped, true); + assert.deepEqual(armies(done), armies(declared), + 'The ruled diversion never rewrites the committed force shipment'); + assert.equal(done.bureaucratPayments?.used.length, 1); + assert.equal(viewGame(done, f.trainer).bureaucrat.usedThisPhase, true); + outcome(f, done, 2); + assert.notEqual(applyAction(done, done.active!, { type: 'endMovement' }).active, f.payer); +}); + +void test('the same occupied shipping payment declined keeps four with the native Guild and four with the occupier', () => { + const f = createOccupiedGuildFixture(); + const decline = allocateOccupiedGuild(chooseOccupiedGuild(allowOccupiedGuildIncome(declareOccupiedGuild(f)), false)); + const receipt = decline.homeworldOccupiedPercentageLedger!.receipts.at(-1)!; + assert.equal(receipt.source.amount, 8); + assert.equal(receipt.nativeRetained, 4); + assert.equal(receipt.occupiedAmount, 4); + assert.equal(receipt.bankRetained, 0); + assert.equal(receipt.ownAmount, 4); + assert.deepEqual(receipt.credits, [{ player: f.occupier, amount: 4 }]); + assert.equal(decline.bureaucratPayments?.used.length ?? 0, 0); + assert.equal(viewGame(decline, f.trainer).bureaucrat.usedThisPhase, false); + outcome(f, decline, 0); +}); + +void test('an original Karama cancellation removes the offer and banks the native half while the occupied half still settles', () => { + const f = createOccupiedGuildFixture(); + const paid = declareOccupiedGuild(f); + const canceled = cancelOccupiedGuildIncome(f, paid); + assert.equal(canceled.response, null); + assert.equal(canceled.decision?.kind, 'homeworldOccupiedPercentage'); + assert.equal(canceled.bureaucratPayments?.pending, undefined); + assert.equal(canceled.discard.filter(card => card.id === f.karama).length, 1); + reject(canceled, f.payee, { type: 'card', mode: 'cancel', card: f.karama }); + const done = allocateOccupiedGuild(canceled); + const receipt = done.homeworldOccupiedPercentageLedger!.receipts.at(-1)!; + const source = guildShippingSource(receipt); + assert.equal(source.nativeIncome, 'karama-canceled'); + assert.equal(source.amount, 8); + assert.equal(source.payment.paid, 8); + assert.equal(source.payment.bankDiversion, undefined); + assert.deepEqual(source.payment.contributions, [ + { payer: f.payer, amount: 6 }, { payer: f.donor, amount: 2 }, + ]); + assert.equal(receipt.nativeRetained, 0); + assert.equal(receipt.occupiedAmount, 4); + assert.equal(receipt.bankRetained, 4); + assert.deepEqual(receipt.credits, [{ player: f.occupier, amount: 4 }]); + assert.equal(done.bureaucratPayments?.used.length ?? 0, 0); + assert.equal(occupiedGuildPlayer(done, f.payee).spice, occupiedGuildPlayer(f.beforePledge, f.payee).spice); + 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); + } + const subFive = createOccupiedGuildFixture({ payerShare: 2, donorShare: 2 }); + const settled = allocateOccupiedGuild(allowOccupiedGuildIncome(declareOccupiedGuild(subFive))); + assert.equal(settled.bureaucratPayments?.used.length ?? 0, 0); + assert.equal(settled.homeworldOccupiedPercentageLedger!.receipts.at(-1)!.occupiedAmount, 2); + assert.equal(settled.homeworldOccupiedPercentageLedger!.receipts.at(-1)!.nativeRetained, 2); + outcome(subFive, settled, 0); +}); + +void test('a single payer and a fully pledged ally both settle the occupied Junction payment once', () => { + const single = createOccupiedGuildFixture({ payerShare: 8, donorShare: 0 }); + const singleDone = allocateOccupiedGuild(chooseOccupiedGuild( + allowOccupiedGuildIncome(declareOccupiedGuild(single)), true)); + assert.equal(singleDone.bureaucratPayments?.used.length, 1); + assert.equal(singleDone.homeworldOccupiedPercentageLedger!.receipts.at(-1)!.source.amount, 6); + outcome(single, singleDone, 2); + const pledged = createOccupiedGuildFixture({ + payer: 'emperor', donor: 'harkonnen', occupier: 'harkonnen', payerShare: 0, donorShare: 8, + }); + const declared = declareOccupiedGuild(pledged); + assert.equal(occupiedGuildPlayer(declared, pledged.payer).spice, + occupiedGuildPlayer(pledged.beforePledge, pledged.payer).spice, + 'The payer contributes nothing of its own spice'); + const pledgedDone = allocateOccupiedGuild(chooseOccupiedGuild( + allowOccupiedGuildIncome(declared), true)); + assert.equal(pledgedDone.bureaucratPayments?.used.length, 1); + outcome(pledged, pledgedDone, 2); +}); + +void test('the Bureaucrat use is once per phase and a later genuine payment in a new phase renews it', () => { + for (const redirect of [false, true]) { + const f = createOccupiedGuildFixture(); + const settled = allocateOccupiedGuild(chooseOccupiedGuild( + allowOccupiedGuildIncome(declareOccupiedGuild(f)), redirect)); + const following = applyAction(settled, f.payer, { type: 'bribe', target: f.payee, amount: 5 }); + let used: Game; + if (redirect) { + assert.notEqual(following.decision?.kind, 'bureaucratPayment'); + assert.equal(occupiedGuildPlayer(following, f.payee).bribes, + occupiedGuildPlayer(settled, f.payee).bribes + 5); + used = following; + } else { + assert.equal(following.decision?.kind, 'bureaucratPayment'); + used = chooseOccupiedGuild(following, true); + assert.equal(occupiedGuildPlayer(used, f.payee).bribes, + occupiedGuildPlayer(settled, f.payee).bribes + 3); + } + assert.equal(used.bureaucratPayments!.used.length, 1); + const ended = applyAction(used, f.payer, { type: 'endMovement' }); + const next = advanceOccupiedGuild(ended, g => g.turn === 3 && g.phase === 1 && occupiedGuildClean(g)); + assert.equal(viewGame(next, f.trainer).bureaucrat.usedThisPhase, false); + const renewed = chooseOccupiedGuild(applyAction(next, f.payer, + { type: 'bribe', target: f.payee, amount: 5 }), true); + assert.equal(renewed.bureaucratPayments!.used.length, 2); + assert.deepEqual(renewed.bureaucratPayments!.used.map(use => [use.turn, use.phase]), [[2, 5], [3, 1]]); + assert.equal(viewGame(renewed, f.trainer).bureaucrat.usedThisPhase, true); + assertClassicHomeworldSkillsCustody(renewed); + } +}); + +void test('every owner bot resolves the occupied Junction offer and the native payment stays in real custody', () => { + const f = createOccupiedGuildFixture(); + const pending = allowOccupiedGuildIncome(declareOccupiedGuild(f)); + for (const bot of DIFFICULTIES) { + const view = viewGame(pending, f.trainer); + view.players.find(p => p.id === f.trainer)!.bot = bot; + const actions = botActions(view); + assert.equal(actions.length, 1, `${bot} has exactly one legal Bureaucrat choice`); + const done = allocateOccupiedGuild(applyAction(pending, f.trainer, actions[0])); + assert.equal(done.bureaucratPayments?.used.length, 1); + assert.equal(done.homeworldOccupationPreview, true); + assert.equal(done.homeworlds!.custody!.visitors['homeworld:guild'][f.occupier].normal, 1); + outcome(f, done, 2); + } +}); diff --git a/tests/fixture-bureaucrat-occupied-emperor.ts b/tests/fixture-bureaucrat-occupied-emperor.ts new file mode 100644 index 00000000..8a45bfa5 --- /dev/null +++ b/tests/fixture-bureaucrat-occupied-emperor.ts @@ -0,0 +1,185 @@ +import assert from 'node:assert/strict'; +import { applyAction, viewGame, type Action, type Game } from '../game/engine'; +import { territory } from '../game/board'; +import { homeworldShipmentChoice } from '../game/homeworld-shipment-options'; +import { quoteStableHomeworldOccupation } from '../game/homeworld-stable-occupation'; +import { + assertClassicHomeworldSkillsCustody, classicHomeworldSkillsPlayer, + createClassicHomeworldSkillsSetup, nextClassicHomeworldSkillsStep, + type ClassicHomeworldSkillsStep, +} from './fixture-homeworld-classic-skills'; +import { homeworldFundingCards } from './fixture-bureaucrat-homeworld-funding'; + +export const occupiedEmperorPlayer = classicHomeworldSkillsPlayer; +export const occupiedEmperorWallets = (game: Game) => Object.fromEntries(game.players.map(p => [p.id, p.spice])); +export const occupiedEmperorClean = (game: Game) => !game.phaseOpening && !game.response && !game.decision && !game.pendingTreacheryDiscard; + +/** 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); + 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): string { + const player = occupiedEmperorPlayer(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); + const card = game.deck[index]; + game.deck[index] = player.hand[0]; player.hand[0] = card; + return card.id; +} + +export function nextOccupiedEmperorStep(game: Game): ClassicHomeworldSkillsStep { + const decision = game.decision; + if (game.biddingEnd && occupiedEmperorClean(game)) { + const actor = game.biddingEnd.owners.find(id => !game.biddingEnd!.ready.includes(id)); assert.ok(actor); + return { actor, action: { type: 'biddingEnd', event: game.biddingEnd.event, mode: 'ready' } }; + } + 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 } }; + } + const step = nextClassicHomeworldSkillsStep(game); + assert.ok(step, 'The native scenario never requires a fabricated battle plan'); + return step; +} + +export function advanceOccupiedEmperor(state: Game, until: (game: Game) => boolean, + actions?: ClassicHomeworldSkillsStep[]): Game { + let game = state; + for (let n = 0; !until(game) && n < 1600; n++) { + assert.notEqual(game.status, 'finished'); + const step = nextOccupiedEmperorStep(game); + actions?.push(structuredClone(step)); game = applyAction(game, step.actor, step.action); + } + assert.ok(until(game), 'Native occupation programme reaches the requested boundary'); + return game; +} + +export function allowOccupiedEmperorResponses(state: Game): Game { + let game = state; + for (let n = 0; game.response && n < 40; n++) { + const actor = game.players.find(p => !game.response!.passed.includes(p.id)); assert.ok(actor); + game = applyAction(game, actor.id, { type: 'passResponse' }); + } + assert.equal(game.response, null); return game; +} + +export type OccupiedEmperorFixture = { + game: Game; setup: Game; fundedEmperor: Game; emptyKaitain: Game; occupied: Game; alliance: Game; + beforePledge: Game; beforeAction: Game; + payer: string; donor: string; payee: string; trainer: string; occupier: string; + payerShare: number; donorShare: number; gross: number; action: Action; + lot: string; bonus: string; karama: string; physical: string[]; actions: ClassicHomeworldSkillsStep[]; +}; + +/** Actual Basic setup -> paid ten-spice lot -> twenty-counter paid shipment -> + * Guild's real bribes through Mentat -> worm/alliance -> empty Kaitain invasion + * -> later funded auction. No wallet, force, phase, alliance or authority edits. */ +export function createOccupiedEmperorFixture(options: { + payerShare?: number; donorShare?: number; donor?: 'guild' | 'atreides' | 'emperor'; +} = {}): OccupiedEmperorFixture { + const { payerShare = 5, donorShare = 3, donor = 'guild' } = options; + const payer = 'harkonnen', payee = 'emperor', trainer = 'atreides', occupier = 'guild'; + const native = createClassicHomeworldSkillsSetup({ occupation: true }); + const setup = structuredClone(native.afterSetup), actions = [...native.actions]; + let game = structuredClone(setup); + const act = (actor: string, action: Action) => { + actions.push({ actor, action: structuredClone(action) }); game = applyAction(game, actor, action); + }; + const reach = (until: (state: Game) => boolean) => { game = advanceOccupiedEmperor(game, until, actions); }; + orderSpice(game, ['land']); + reach(g => g.phase === 3 && !!g.auction && occupiedEmperorClean(g)); + holdKarama(game, payer); + reach(g => g.auction!.active === payer && occupiedEmperorClean(g)); + const firstLot = game.auction!.cards[game.auction!.index].id; + act(payer, { type: 'bid', amount: 10 }); + reach(g => g.decision?.kind === 'auctionPayment'); + act(payer, { type: 'decision', karama: false }); + reach(g => occupiedEmperorClean(g) && occupiedEmperorPlayer(g, payer).hand.some(c => c.id === firstLot)); + assert.equal(occupiedEmperorPlayer(game, payee).spice, 20); + const fundedEmperor = structuredClone(game); + reach(g => g.phase === 5 && g.active === payee && occupiedEmperorClean(g)); + const emperor = occupiedEmperorPlayer(game, payee), elite = emperor.elites?.reserves ?? 0; + assert.equal(emperor.reserves, 20); + assert.equal(emperor.spice, 20, 'The original Kaitain discard opportunity is declined, retaining shipment funding'); + const destination = 'habbanya_ridge_sietch'; + assert.ok(game.players.every(p => !Object.keys(p.forces).some(key => key.startsWith(`${destination}:`)))); + act(payee, { type: 'ship', territory: destination, sector: territory(destination).sectors[0], amount: 20, elite, + homeworldSources: { 'homeworld:emperor': { normal: 20 - elite, elite } } }); + reach(g => g.phase === 5 && occupiedEmperorClean(g)); + assert.equal(occupiedEmperorPlayer(game, payee).reserves, 0); + assert.equal(occupiedEmperorPlayer(game, payee).forces[`${destination}:${territory(destination).sectors[0]}`], 20); + const emptyKaitain = structuredClone(game); + // Real transport income pays these bribes; the native Mentat transfers them. + act(occupier, { type: 'bribe', target: payer, amount: 5 }); + reach(occupiedEmperorClean); + act(occupier, { type: 'bribe', target: payee, amount: 5 }); + reach(occupiedEmperorClean); + reach(g => g.turn === 2 && g.phase === 1 && occupiedEmperorClean(g)); + orderSpice(game, ['worm', 'land']); + reach(g => !!g.nexus && !g.spiceWindow && !g.spiceResolution && occupiedEmperorClean(g)); + act(payer, { type: 'alliance', target: donor }); + act(donor, { type: 'alliance', target: payer }); + assert.equal(occupiedEmperorPlayer(game, payer).ally, donor); + assert.equal(occupiedEmperorPlayer(game, donor).ally, payer); + const alliance = structuredClone(game); + reach(g => g.turn === 2 && g.phase === 5 && g.active === occupier && occupiedEmperorClean(g)); + const invasion = homeworldShipmentChoice(viewGame(game, occupier), 'homeworld:emperor', + { 'homeworld:guild': { normal: 1, elite: 0 } }); + assert.ok(invasion.action, invasion.blocked ?? undefined); + act(occupier, invasion.action); + reach(occupiedEmperorClean); + const authority = quoteStableHomeworldOccupation(game, 'kaitain'); + 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')); + const occupied = structuredClone(game); + orderSpice(game, ['land', 'land']); + reach(g => g.turn === 3 && g.phase === 3 && !!g.auction && occupiedEmperorClean(g)); + holdKarama(game, payer); + const karama = holdKarama(game, trainer); + reach(g => g.auction!.active === payer && occupiedEmperorClean(g)); + const beforePledge = structuredClone(game); + if (donorShare) act(donor, { type: 'pledgeAid', amount: donorShare }); + const beforeAction = structuredClone(game), gross = payerShare + donorShare; + assert.ok(occupiedEmperorPlayer(game, payer).spice >= payerShare); + assertClassicHomeworldSkillsCustody(game); + return { game, setup, fundedEmperor, emptyKaitain, occupied, alliance, beforePledge, beforeAction, + payer, donor, payee, trainer, occupier, payerShare, donorShare, gross, + action: { type: 'bid', amount: gross, allyPayment: donorShare }, + lot: game.auction!.cards[game.auction!.index].id, bonus: game.deck[0].id, + karama, physical: homeworldFundingCards(game), actions }; +} + +export function payOccupiedEmperor(f: OccupiedEmperorFixture, state = f.game): Game { + let game = applyAction(state, f.payer, f.action); + game = advanceOccupiedEmperor(game, g => g.decision?.kind === 'auctionPayment'); + return applyAction(game, f.payer, { type: 'decision', karama: false }); +} +export function chooseOccupiedEmperor(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 allocateOccupiedEmperor(game: Game, ownAmount?: number): Game { + assert.equal(game.decision?.kind, 'homeworldOccupiedPercentage'); + const offer = viewGame(game, game.decision!.player).homeworldOccupiedPercentage!; + return allowOccupiedEmperorResponses(applyAction(game, game.decision!.player, + { type: 'decision', event: offer.event, ownAmount: ownAmount ?? offer.maxOwnAmount })); +} diff --git a/tests/fixture-bureaucrat-occupied-guild.ts b/tests/fixture-bureaucrat-occupied-guild.ts new file mode 100644 index 00000000..dbbd0e4f --- /dev/null +++ b/tests/fixture-bureaucrat-occupied-guild.ts @@ -0,0 +1,216 @@ +import assert from 'node:assert/strict'; +import { applyAction, viewGame, type Action, type Game } from '../game/engine'; +import { territory } from '../game/board'; +import { homeworldShipmentChoice } from '../game/homeworld-shipment-options'; +import { quoteStableHomeworldOccupation } from '../game/homeworld-stable-occupation'; +import { + assertClassicHomeworldSkillsCustody, classicHomeworldSkillsPlayer, + createClassicHomeworldSkillsSetup, nextClassicHomeworldSkillsStep, + type ClassicHomeworldSkillsStep, +} from './fixture-homeworld-classic-skills'; +import { homeworldFundingCards } from './fixture-bureaucrat-homeworld-funding'; + +export const occupiedGuildPlayer = classicHomeworldSkillsPlayer; +export const occupiedGuildWallets = (game: Game) => + Object.fromEntries(game.players.map(p => [p.id, p.spice])); +export const occupiedGuildArmies = (game: Game) => game.players.map(p => ({ + id: p.id, forces: p.forces, reserves: p.reserves, tanks: p.tanks, elites: p.elites, +})); +export const occupiedGuildClean = (game: Game) => + !game.phaseOpening && !game.response && !game.decision && !game.pendingTreacheryDiscard; +export const occupiedGuildCards = homeworldFundingCards; + +/** 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); + 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): string { + const player = occupiedGuildPlayer(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); + const card = game.deck[index]; + game.deck[index] = player.hand[0]; + player.hand[0] = card; + return card.id; +} + +export function nextOccupiedGuildStep(game: Game): ClassicHomeworldSkillsStep { + const decision = game.decision; + if (decision?.kind === 'homeworldShipmentGuild') return { actor: decision.player, + action: { type: 'decision', event: decision.event, allow: true } }; + if (decision?.kind === 'guildShipment') return { actor: decision.player, + action: { type: 'decision', 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 } }; + } + const step = nextClassicHomeworldSkillsStep(game); + assert.ok(step, 'The native scenario never requires a fabricated battle plan'); + return step; +} + +export function advanceOccupiedGuild(state: Game, until: (game: Game) => boolean, + actions?: ClassicHomeworldSkillsStep[]): Game { + let game = state; + for (let n = 0; !until(game) && n < 1600; n++) { + assert.notEqual(game.status, 'finished'); + const step = nextOccupiedGuildStep(game); + actions?.push(structuredClone(step)); + game = applyAction(game, step.actor, step.action); + } + assert.ok(until(game), 'Native occupation programme reaches the requested boundary'); + return game; +} + +export function allowOccupiedGuildResponses(state: Game): Game { + let game = state; + for (let n = 0; game.response && n < 60; n++) { + const actor = game.players.find(p => !game.response!.passed.includes(p.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 type OccupiedGuildFixture = { + game: Game; setup: Game; junctionEmpty: Game; occupied: Game; alliance: Game; + beforePledge: Game; beforeAction: Game; + payer: string; donor: string; payee: string; trainer: string; occupier: string; + payerShare: number; donorShare: number; gross: number; action: Action; + territory: string; sector: number; karama: string; physical: string[]; + staging: string[]; actions: ClassicHomeworldSkillsStep[]; +}; + +/** Actual classic occupation + Skills setup: the native Guild empties Junction + * through its own reserve shipment, a foreign faction invades Junction through + * a real world shipment, and the later real alliance funds the real paid + * shipment. No wallet, force, custody, history, phase or authority was assigned; + * only original unplayed Spice order and one conserved card swap are staged. */ +export function createOccupiedGuildFixture(options: { + payer?: string; donor?: string; occupier?: string; prep?: string; + payerShare?: number; donorShare?: number; +} = {}): OccupiedGuildFixture { + const payer = options.payer ?? 'harkonnen'; + const donor = options.donor ?? 'emperor'; + const occupier = options.occupier ?? 'harkonnen'; + const prep = options.prep ?? 'emperor'; + const { payerShare = 6, donorShare = 2 } = options; + const payee = 'guild', trainer = 'atreides'; + const native = createClassicHomeworldSkillsSetup({ occupation: true }); + const setup = structuredClone(native.afterSetup), actions = [...native.actions]; + const staging = [...native.staging]; + let game = structuredClone(setup); + const act = (actor: string, action: Action) => { + actions.push({ actor, action: structuredClone(action) }); + game = applyAction(game, actor, action); + }; + const reach = (until: (state: Game) => boolean) => { + game = advanceOccupiedGuild(game, until, actions); + }; + orderSpice(game, ['land']); + reach(g => g.turn === 1 && g.phase === 5 && g.active === prep && occupiedGuildClean(g)); + const opening = occupiedGuildPlayer(game, payee).spice; + // One real four-spice shipment income funds the native Guild's later tariff. + act(prep, { type: 'ship', territory: 'sietch_tabr', sector: territory('sietch_tabr').sectors[0], + amount: 4, elite: 0, homeworldSources: { [`homeworld:${occupiedGuildPlayer(game, prep).faction}`]: + { normal: 4, elite: 0 } } }); + reach(occupiedGuildClean); + assert.equal(occupiedGuildPlayer(game, payee).spice, opening + 4, + 'The four-spice shipment income reaches the native Guild'); + // The Guild's own reserve shipment empties Junction. + reach(g => g.phase === 5 && g.active === payee && occupiedGuildClean(g)); + act(payee, { type: 'ship', territory: 'habbanya_ridge_sietch', + sector: territory('habbanya_ridge_sietch').sectors[0], amount: 15, elite: 0, + homeworldSources: { 'homeworld:guild': { normal: 15, elite: 0 } } }); + reach(occupiedGuildClean); + assert.equal(occupiedGuildPlayer(game, payee).reserves, 0, 'Junction is physically empty'); + const junctionEmpty = structuredClone(game); + assert.equal(quoteStableHomeworldOccupation(game, 'junction').entitlement, null); + // The real foreign homeworld invasion, then the real reciprocal alliance. + reach(g => g.phase === 5 && g.active === occupier && occupiedGuildClean(g)); + const invasion = homeworldShipmentChoice(viewGame(game, occupier), 'homeworld:guild', + { [`homeworld:${occupiedGuildPlayer(game, occupier).faction}`]: { normal: 1, elite: 0 } }); + assert.ok(invasion.action, invasion.blocked ?? undefined); + act(occupier, invasion.action); + reach(occupiedGuildClean); + 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')); + const occupied = structuredClone(game); + reach(g => g.turn === 2 && g.phase === 1 && occupiedGuildClean(g)); + orderSpice(game, ['worm', 'land']); + reach(g => !!g.nexus && !g.spiceWindow && !g.spiceResolution && occupiedGuildClean(g)); + act(payer, { type: 'alliance', target: donor }); + act(donor, { type: 'alliance', target: payer }); + assert.equal(occupiedGuildPlayer(game, payer).ally, donor); + assert.equal(occupiedGuildPlayer(game, donor).ally, payer); + const alliance = structuredClone(game); + // The later real paid shipment to an empty Arrakis territory. + reach(g => g.turn === 2 && g.phase === 5 && g.active === payer && occupiedGuildClean(g)); + const karama = holdKarama(game, trainer); + const beforePledge = structuredClone(game); + if (donorShare) act(donor, { type: 'pledgeAid', amount: donorShare }); + const beforeAction = structuredClone(game), gross = payerShare + donorShare; + const target = 'arsunt', sector = territory(target).sectors[0]; + assert.ok(occupiedGuildPlayer(game, payer).spice >= payerShare); + assert.ok(game.players.every(p => !Object.keys(p.forces).some(key => key.startsWith(`${target}:`)))); + const action: Action = { type: 'ship', territory: target, sector, amount: gross / 2, + elite: 0, allyPayment: donorShare }; + assertClassicHomeworldSkillsCustody(game); + assert.deepEqual(occupiedGuildCards(game), homeworldFundingCards(setup)); + return { game, setup, junctionEmpty, occupied, alliance, beforePledge, beforeAction, + payer, donor, payee, trainer, occupier, payerShare, donorShare, gross, action, + territory: target, sector, karama, physical: homeworldFundingCards(game), staging, actions }; +} + +/** The original declaration debits the payer and the pledged ally, then opens + * only the real income window. */ +export function declareOccupiedGuild(f: OccupiedGuildFixture, 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 allowOccupiedGuildIncome(state: Game): Game { + return allowOccupiedGuildResponses(state); +} + +export function cancelOccupiedGuildIncome(f: OccupiedGuildFixture, state: Game): Game { + assert.equal(state.response?.kind, 'guildIncome'); + assert.ok(viewGame(state, f.trainer).responseControls?.cancelCards.includes(f.karama)); + return applyAction(state, f.trainer, { type: 'card', mode: 'cancel', card: f.karama }); +} + +export function chooseOccupiedGuild(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 allocateOccupiedGuild(game: Game, ownAmount?: number): Game { + assert.equal(game.decision?.kind, 'homeworldOccupiedPercentage'); + const offer = viewGame(game, game.decision!.player).homeworldOccupiedPercentage!; + return allowOccupiedGuildResponses(applyAction(game, game.decision!.player, + { type: 'decision', event: offer.event, ownAmount: ownAmount ?? offer.maxOwnAmount })); +} diff --git a/tests/fixture-homeworld-classic-skills.ts b/tests/fixture-homeworld-classic-skills.ts index c1adcce4..d28c7bff 100644 --- a/tests/fixture-homeworld-classic-skills.ts +++ b/tests/fixture-homeworld-classic-skills.ts @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import { applyAction, createGame, initializeLeaderSkillsGameForAudit, joinGame, newPlayer, viewGame, type Action, type Game, type Player } from '../game/engine'; +import { applyAction, createGame, initializeHomeworldOccupationGameForAudit, initializeLeaderSkillsGameForAudit, joinGame, newPlayer, viewGame, type Action, type Game, type Player } from '../game/engine'; import { botActions } from '../game/bots'; import type { Difficulty } from '../game/bot-profiles'; import { territory } from '../game/board'; @@ -93,6 +93,7 @@ export function createClassicHomeworldSkillsSetup(options: { roster?: readonly FactionId[]; initial?: Game; skill?: LeaderSkillId; + occupation?: boolean; } = {}): ClassicHomeworldSkillsSetup { const roster = options.roster ?? ['emperor', 'guild', 'atreides', 'harkonnen']; let game = options.initial ? structuredClone(options.initial) @@ -128,7 +129,8 @@ export function createClassicHomeworldSkillsSetup(options: { const deckOrder = [...prefix, ...cards.map(c => c.id).filter(id => !prefix.includes(id))]; if (game.status === 'lobby') game = originalLottery( [...permutationDraws(canonical, desired), ...permutationDraws(cards.map(c => c.id), deckOrder)], - () => initializeLeaderSkillsGameForAudit(game)); + () => options.occupation ? initializeHomeworldOccupationGameForAudit(game, true) + : initializeLeaderSkillsGameForAudit(game)); let offered: Game | null = null; for (let count = 0; game.status === 'setup' && count < 200; count++) { let next: ClassicHomeworldSkillsStep; diff --git a/tools/prototype-room.ts b/tools/prototype-room.ts index b34724b3..36108bee 100644 --- a/tools/prototype-room.ts +++ b/tools/prototype-room.ts @@ -55,7 +55,7 @@ export type PrototypeOptions = { nexusCards?: boolean; /** Explicit classic Discovery/Skills composition, not a public activation. */ discoveries?: boolean; - /** Explicit original native skills only with the Semuta profile. */ + /** Explicit original skills with Semuta or classic Homeworld occupation. */ leaderSkills?: boolean; /** Original Richese/CHOAM and classic Homeworld occupation with Semuta only. */ homeworlds?: boolean; @@ -104,8 +104,8 @@ export function startPrototypeRoom( throw new Error('--nexus-cards requires the leader-skills or semuta profile.'); if (options.discoveries && profile !== 'leader-skills' && profile !== 'nexus') throw new Error('--discoveries requires the leader-skills or nexus profile.'); - if (options.leaderSkills && profile !== 'semuta') - throw new Error('--leader-skills requires the semuta profile.'); + if (options.leaderSkills && profile !== 'semuta' && profile !== 'homeworld-occupation') + throw new Error('--leader-skills requires the semuta or homeworld-occupation profile.'); if (options.homeworlds && profile !== 'semuta') throw new Error('--homeworlds requires the semuta profile.'); if (options.ecazTreachery) initial.ecazTreachery = true; @@ -120,7 +120,7 @@ export function startPrototypeRoom( ? initializeHomeworldOccupationGameForAudit({ ...initial, homeworlds: initial.homeworlds ?? { custody: null }, - }) + }, options.leaderSkills) : profile === 'stronghold-factions' ? initializeStrongholdFactionsGameForAudit(initial) : profile === 'ecaz-treachery' diff --git a/tools/start-prototype.ts b/tools/start-prototype.ts index 38b85cd7..36231681 100644 --- a/tools/start-prototype.ts +++ b/tools/start-prototype.ts @@ -33,6 +33,7 @@ async function main() { '\n--profile semuta --homeworlds uses original fresh Basic/Advanced Richese with CHOAM and classic factions, exact CHOAM deck, at2–6 seats. Native occupation setup/history remain original. Paid Box, Distrans, definite Truthtrance, ordinary specials, Kaitain, Tupile cleanup and legal CHOAM market sales offer used cards before their original suffix. Final Ornithopter disposal keeps completed original groups; finite Once Around and clean unstarted movement Sapho disposal keeps the reordered queue/funding. Accepted losing-card, mandatory winning-card and selected winner disposal remain separate fresh events, preserving paid deployments, casualties and original rewards without replaying combat. Clean Residual Poison keeps one native random death without bounty; early Sapho aggressor and remaining Battle chooser costs keep original participants and finite priority. Native printed CHOAM costs pause before their effect; printed Karama canceling these powers or native Guild/Emperor/Richese income is separately offered before denial. Printed CHOAM opening-income/Inflation cancellation and normal-lot Karama purchase/free-payment costs also pause before their original denial, spent attempt or zero-spice acquisition. Retained fuel/witness and credited sale spice remain unchanged. No repeated move/return/storm/payout/refund is introduced. Kull, Hajr/prior-flight integration, unresolved cyclic/partial/Guild/late Sapho timing, readied/inspected Poison costs, Advanced Harkonnen captive disclosure, other producers/modules, retrofit and public starts remain guarded.', '\nThe same Homeworld Semuta entry also offers printed Karama canceling native Advanced BG extra charity and genuine allied Emperor gift/extra revival. The consumed charity claim, original ally, paid deployment/casualties, wallets and unawarded revival remain committed. Wealthy Basic BG keeps its ordinary-charity guard; no new benefit or profile is granted.', '\nPrinted native Homeworld capture/Auditor/force-income Karama cancellation costs also offer their card. Accepted combat, paid support, leader death and original casualty/cleanup remain committed; the canceled reward never samples, transfers, inspects, refunds or credits. Earlier allowed income stays intact before a separately canceled audit.', + '\n--profile homeworld-occupation --leader-skills composes original all14 Leader Skills with the classic2–6-seat occupied Homeworld income preview. Native setup, reserve custody, occupation history and owner allocation remain original. Bureaucrat diverts two from gross before the occupied percentage split while preserving the full original payer shares. Nexus Cards, expansions, Tech, Strongholds, Discovery, other overlays and conversion of played games remain excluded.', '\nEcaz-occupy (--profile ecaz-occupy) opts only a fresh ready 2–6-seat lobby with native Ecaz and classic/Ixian/Tleilaxu/CHOAM/Richese opponents (optional native Moritani). Advanced uses distinct selected ecaz/ix/choam decks; Basic uses the exact ecaz deck with classic/optional Moritani opponents. Ordinary33/47/35 cards retain real original offers/HMS/Auditor/Face Dancers and Richese cache. Explicit --ecaz-treachery independently adds the three physical variant cards, making36/50/38 (Advanced only). Basic combined battles require an even Ecaz force count; the preserved publisher odd-force casualty-rounding conflict rejects before any lead choice. Native Richese ordinary and marker-only pools compose with selected lead plans; original own mixed No-Field dialing, whole-plan disclosure and optional overlays remain guarded.' + '\nLeader-skills retains existing classic and Basic native entries and adds native Basic Richese/CHOAM. Advanced Ixians, Tleilaxu, CHOAM and/or Richese plus classic rosters use distinct required family decks and all fourteen skills, with original cache, Face Dancers, Auditor exclusion and typed aftermath. Foreign revival grants no new skill. Separate non-Harkonnen Advanced Moritani uses the ecaz deck and original skill-first assassination preview. Native Ecaz supports its five ordinary discs, original Ambassador/Duke custody and ecaz deck with classic opponents in Basic/Advanced; Advanced Harkonnen retains its existing Duke capture exclusion. Normal Mentat questions use a uniform private card-show step in supported Leader Skills games. Banker-income additionally supports native Richese and Ecaz, never ordinary seller/allowed Emperor income or a bank grant as a payer cost. Basic auction recipients remain unchanged. Public activation, exhausted-cache counts, native Ixian Technology on Richese lots, own mixed No-Field, captured replacement, shared-Duke assignment, combined Occupy skills and other pending module/rulings remain guarded.', '\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.' +