diff --git a/components/game-table.tsx b/components/game-table.tsx index c25f7efa..eb820240 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -25,6 +25,8 @@ import { HomeworldRevivalDeployment } from './homeworld-revival-deployment'; import { CaladanReinforcement } from './caladan-reinforcement'; import { GrummanCollection } from './grumman-collection'; import { HomeworldOccupiedIncome } from './homeworld-occupied-income'; +import { HomeworldOccupiedPercentage } from './homeworld-occupied-percentage'; +import { HomeworldOccupiedBonus } from './homeworld-occupied-bonus'; import { TupileIntelligence } from './tupile-intelligence'; import { NexusCards } from './nexus-cards'; import { NexusTraitors } from './nexus-traitors'; @@ -1098,14 +1100,25 @@ export function GameTable({ {g.advanced && g.status !== 'lobby' && } {g.homeworldOccupationPreview && ( - Homeworld occupation development preview · continuous, uncontested - qualification only. Printed bank Collection, immediate ally sharing - and occupied native defenses are connected. Departure, replacement, - contested/repopulated histories, percentage income and other unfinished - occupied powers remain guarded. Not complete or certified Homeworld rules.{' '} + Homeworld occupation development preview · printed bank Collection, + percentage income, original Bidding effects, immediate ally sharing + and occupied native defenses are connected. Advanced occupation benefits + are retained until the occupier’s last force leaves; Basic lifecycle and + unresolved competing-owner cases remain guarded. Not complete or certified Homeworld rules.{' '} Preview rules and limits )} + {g.homeworldOccupiedPercentage?.blocked && (g.decision?.kind !== 'homeworldOccupiedPercentage' || g.decision.player !== g.me) && ( + + )} + {g.homeworldOccupiedBonus?.blocked && (g.decision?.kind !== 'homeworldOccupiedBonus' || g.decision.player !== g.me) && ( + + )} + {g.richeseBidding?.stage === 'cacheOffer' && g.richeseBidding.offerBlocked && !g.decision && ( + + {g.richeseBidding.offerBlocked} + + )} {g.nexusKullPreview ? (

CHOAM Nexus Cunning · Kull Wahad development preview. Any eligible @@ -2469,6 +2482,10 @@ export function GameTable({ ? 'Grumman Collection' : g.decision.kind === 'homeworldOccupiedIncome' ? 'Occupied Homeworld bank income' + : g.decision.kind === 'homeworldOccupiedPercentage' + ? 'Occupied Homeworld percentage income' + : g.decision.kind === 'homeworldOccupiedBonus' + ? 'Occupied Giedi Prime bonus card' : g.decision.kind === 'leaderSkillVisibility' ? 'Position your skilled leader' : g.decision.kind === 'mentatQuestion' @@ -2503,6 +2520,7 @@ export function GameTable({ 'richeseBlackMarket', 'richeseDeclaration', 'richeseCache', + 'richeseCacheTerms', 'richeseUnbid', ].includes(g.decision.kind) ? 'Richese auction decision' @@ -2737,6 +2755,7 @@ export function GameTable({ ) : g.decision.kind === 'richeseBlackMarket' || g.decision.kind === 'richeseDeclaration' || g.decision.kind === 'richeseCache' || + g.decision.kind === 'richeseCacheTerms' || g.decision.kind === 'richeseUnbid' ? ( ) : g.decision.kind === 'homeworldOccupiedIncome' ? ( + ) : g.decision.kind === 'homeworldOccupiedPercentage' ? ( + + ) : g.decision.kind === 'homeworldOccupiedBonus' ? ( + ) : g.decision.kind === 'ecazSpice' ? ( g.ecazSpice?.allocation ? (

{g.auction.card?.name ?? 'Unknown treachery'}

{g.auction.card && } + {g.auction.inspectionBlocked && ( + {g.auction.inspectionBlocked} + )} {!!g.ixTechnology?.known.length && (
Your inspected pool · order unknown diff --git a/components/homeworld-occupied-bonus.tsx b/components/homeworld-occupied-bonus.tsx new file mode 100644 index 00000000..b4683367 --- /dev/null +++ b/components/homeworld-occupied-bonus.tsx @@ -0,0 +1,66 @@ +'use client'; + +import { useState } from 'react'; +import type { Action, GameView } from '@/game/engine'; +import { FACTIONS } from '@/game/catalog'; +import { homeworldCard } from '@/game/homeworld-cards'; +import { HomeworldFace } from './homeworld-cards'; +import { Button } from './ui/button'; + +type Props = { game: GameView; act: (action: Action) => void; busy: boolean }; +type Offer = NonNullable; + +export function HomeworldOccupiedBonus({ game, act, busy }: Props) { + const offer = game.homeworldOccupiedBonus; + if (!offer) return null; + const authorized = game.decision?.kind === 'homeworldOccupiedBonus' && + game.decision.player === game.me && game.decision.event === offer.event && offer.owner === game.me; + if (!authorized && !offer.blocked) return null; + return ; +} + +function BonusChoice({ offer, act, busy }: Props & { offer: Offer }) { + const [recipient, setRecipient] = useState(offer.owner); + const selected = offer.recipients.find((player) => player.player === recipient) ?? offer.recipients[0]; + return ( +
+

Giedi Prime · occupied purchase bonus

+

+ {offer.ownerName} chooses who receives one real treachery card from the original purchase bonus. + Only the server-listed eligible recipients can receive it. The card stays private to its recipient. +

+

+ This printed Homeworld effect is separate from the ordinary cancelable Harkonnen advantage and cannot be canceled by Karama. + This is one card, not one card per recipient. +

+

+ Development preview · Advanced occupation benefits are retained until your last occupying force leaves. Basic lifecycle and unresolved competing-owner cases remain guarded. Not complete or certified Homeworld rules. +

+
+ Inspect Giedi Prime source card + +
+ {offer.blocked ? {offer.blocked} : !selected ? ( +

No eligible recipient is available.

+ ) : ( +
{ + event.preventDefault(); + if (!busy) act({ type: 'decision', event: offer.event, recipient: selected.player }); + }}> + + +
+ )} +
+ ); +} diff --git a/components/homeworld-occupied-percentage.tsx b/components/homeworld-occupied-percentage.tsx new file mode 100644 index 00000000..90f094e9 --- /dev/null +++ b/components/homeworld-occupied-percentage.tsx @@ -0,0 +1,79 @@ +'use client'; + +import { useState } from 'react'; +import type { Action, GameView } from '@/game/engine'; +import { homeworldCard } from '@/game/homeworld-cards'; +import { HomeworldFace } from './homeworld-cards'; +import { Button } from './ui/button'; +import { Input } from './ui/input'; + +type Props = { game: GameView; act: (action: Action) => void; busy: boolean }; +type Offer = NonNullable; + +export function HomeworldOccupiedPercentage({ game, act, busy }: Props) { + const offer = game.homeworldOccupiedPercentage; + if (!offer) return null; + const authorized = game.decision?.kind === 'homeworldOccupiedPercentage' && + game.decision.player === game.me && game.decision.event === offer.event && offer.owner === game.me; + if (!authorized && !offer.blocked) return null; + return ; +} + +function PercentageChoice({ offer, game, act, busy }: Props & { offer: Offer }) { + const [ownAmount, setOwnAmount] = useState(offer.maxOwnAmount); + const valid = Number.isSafeInteger(ownAmount) && ownAmount >= offer.minOwnAmount && ownAmount <= offer.maxOwnAmount; + const source = game.homeworlds?.worlds?.find((world) => world.id === offer.world); + const card = source ? homeworldCard(source.card) : undefined; + return ( +
+

{offer.name} · occupied percentage income

+

+ Allocate {offer.amount} spice from the actual completed payment or Collection income. + {offer.allyName ? ` Keep it or immediately share any portion with ${offer.allyName}.` : ' You have no reciprocal ally to share with.'} +

+

+ This is the actual occupied portion, not a printed Bank icon award or a bribe from your personal spice. + The printed Homeworld effect cannot be canceled by Karama; ordinary native advantages remain separate. +

+

+ Development preview · Advanced occupation benefits are retained until your last occupying force leaves. Basic lifecycle and unresolved competing-owner cases remain guarded. Not complete or certified Homeworld rules. +

+ {card && ( +
+ Inspect {offer.name} source card + +
+ )} + {offer.blocked ? {offer.blocked} : ( +
{ + event.preventDefault(); + if (!busy && valid) act({ type: 'decision', event: offer.event, ownAmount }); + }}> + {offer.ally && ( + <> + +
+ + +
+ + )} + + {valid + ? `Credits: ${offer.ownerName} ${ownAmount}${offer.allyName ? ` · ${offer.allyName} ${offer.amount - ownAmount}` : ''}. Total ${offer.amount}.` + : `Keep a whole number from ${offer.minOwnAmount} through ${offer.maxOwnAmount}.`} + + +
+ )} +
+ ); +} diff --git a/components/ix-technology.tsx b/components/ix-technology.tsx index 2182405d..0db90015 100644 --- a/components/ix-technology.tsx +++ b/components/ix-technology.tsx @@ -16,6 +16,12 @@ export function IxTechnology({ const kind = g.decision?.kind; const me = g.players.find((p) => p.id === g.me)!; const [position, setPosition] = useState('bottom'); + if ( + g.decision?.player !== g.me || + !['ixSetup', 'ixAuction', 'ixTechnology', 'ixAllyCard'].includes(kind ?? '') + ) return null; + if (kind === 'ixAuction' && g.ixTechnology?.blocked) + return {g.ixTechnology.blocked}; const cards = kind === 'ixSetup' ? g.ixTechnology?.setup @@ -57,7 +63,7 @@ export function IxTechnology({ {kind === 'ixSetup' ? 'Choose your starting card. The remaining cards are shuffled and dealt privately, one to each other faction; Harkonnen then draws its extra card.' : kind === 'ixAuction' - ? 'Privately choose one card to return to the deck. The remaining pool will be shuffled for auction.' + ? 'Privately choose one card from the original Ixian pool to return to the deck. The remaining pool will be shuffled for auction. This choice does not transfer unrelated Ixian Technology.' : 'Exchange one card from your hand for the upcoming card, before Atreides looks. You commit your offered card before learning what you receive. This advantage can be attempted once this round.'}{' '}

diff --git a/components/richese-auctions.tsx b/components/richese-auctions.tsx index 5a7a3c8d..1250517c 100644 --- a/components/richese-auctions.tsx +++ b/components/richese-auctions.tsx @@ -32,11 +32,12 @@ export function RicheseAuctionDecision({ game, act, busy }: Props) { !bidding || !me || decision?.player !== game.me || - bidding.owner !== game.me || + (decision.kind !== 'richeseCache' && bidding.owner !== game.me) || ![ 'richeseBlackMarket', 'richeseDeclaration', 'richeseCache', + 'richeseCacheTerms', 'richeseUnbid', ].includes(decision.kind) ) @@ -44,6 +45,40 @@ export function RicheseAuctionDecision({ game, act, busy }: Props) { const send = (fields: Omit) => { if (!busy) act({ type: 'decision', event: bidding.event, ...fields }); }; + if (decision.kind === 'richeseCacheTerms') { + const cacheMethod = method === 'normal' ? 'onceAround' : method; + return ( +
+

+ You remain the Richese seller. Choose the auction method and direction; + the occupied Homeworld controller then chooses only the physical cache card. +

+ {bidding.offerBlocked &&

{bidding.offerBlocked}

} + + + {cacheMethod === 'onceAround' && ( + <> + + + + )} + +
+ ); + } if (decision.kind === 'richeseDeclaration') return (
{bidding.offerBlocked ?? 'Waiting for the native Richese seller to set cache auction terms.'}; const cards = blackMarket ? (me.hand ?? []) : (bidding.cache ?? []); const selected = cards.find((card) => card.id === cardId) ?? cards[0]; const chosenMethod = - !blackMarket && method === 'normal' ? 'onceAround' : method; + cardOnly ? bidding.cacheTerms!.method : + !blackMarket && method === 'normal' ? 'onceAround' : method; return (
{blackMarket ? 'Offer a card from your hand without showing its face. Atreides may inspect it. An optional public claim may be truthful or a bluff. If no one pays spice, you keep the card.' - : 'Choose one card from your separate cache. Its face is revealed to everyone when offered. Cache cards do not count toward your hand limit.'} + : 'Choose one card from the original Richese cache. Its face is revealed to everyone when offered. Cache cards do not count toward a hand limit; Richese remains the seller.'}

+ {cardOnly && bidding.cacheTerms && ( +

+ Native Richese terms: {methodName[bidding.cacheTerms.method]} + {bidding.cacheTerms.method === 'onceAround' ? ` · ${bidding.cacheTerms.direction}` : ''}. + You choose the cache card only; the seller’s terms do not transfer. +

+ )} {bidding.offerBlocked && (

{bidding.offerBlocked} @@ -152,6 +198,8 @@ export function RicheseAuctionDecision({ game, act, busy }: Props) { )} + {!cardOnly && ( + <> + )} + )} {blackMarket && ( <> @@ -213,8 +263,7 @@ export function RicheseAuctionDecision({ game, act, busy }: Props) { onClick={() => send({ card: selected!.id, - method: chosenMethod, - direction, + ...(!cardOnly ? { method: chosenMethod, direction } : {}), ...(blackMarket && claim.trim() ? { claim } : {}), }) } @@ -312,6 +361,9 @@ export function RicheseAuctionLot({ game, act, busy }: Props) { ) : (

The Black Market card remains face down.

)} + {auction.inspectionBlocked && ( + {auction.inspectionBlocked} + )}

{name(auction.owner)} is selling. Karama cannot acquire this card.

diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index c6b79062..20c75c33 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -8,15 +8,56 @@ The user-supplied `UNOFFICIAL_Revised_Dune_Rulebook_v.2.3_web.pdf` is now an [authorized Advanced source of truth](RULE_DECISIONS.md#authorized-source-amendment--1-october-2026), including its identified unofficial rulings. The 44-page file has 2.3 metadata but a visibly retained 2.2 editorial heading; the index records its -SHA-256 and physical-page citations. Current Guild payment/source passages -were compared with the wave. Wider Advanced reconciliation remains open. +SHA-256 and physical-page citations. Guild payment/source passages and Advanced +Homeworld sole/last-departure occupation now have explicit source cutovers. +Wider Advanced reconciliation remains open. Adoption does not reset games, convert Basic, republish the local PDF or open mode gates. ## Current checkpoint and work -**Stable Homeworld occupation — bounded Development Verified:** +**Occupied Homeworld economics / Bidding — bounded Development Verified:** +original fresh2..6 Basic/Advanced setup and selected decks; Advanced uses the +authorized supplied p22 sole qualification retained until its last force leaves, +while Basic keeps its historical lifecycle guards. Actual Kaitain/Junction/ +Richese percentages and completed total Southern Collection retain original +invoices, one payer debit, immediate sharing and signed once-only continuations. +Caladan shares the admitted private face, Ix transfers its actual pool, Richese +keeps native terms/seller/invoice while the occupier chooses only the card, and +Giedi awards one original purchase bonus using actual receiving capacity. +Native cancellation remains distinct from printed occupied effects. + +Final247 affected cases/24 files, types/lint/build and all4 legal policies pass. +Human shipping exposed lost concurrent BG accompaniment; the signed suffix +now preserves it through allocation/JSON and actual subsequent advisor placement. +Wrong bonus recipients use normal RuleError; actual HTTP409 leaves version42. +Nondeterministic original-deal capacity/stock fixtures are conserved, not rerolled. + +Pre-advisor-repair50 genuine games on unchanged09cfdd19 tree:44 finish18,977 +accepted/19,149 attempts/491 JSON continuations; all50 total22,383/22,580/580. +Six remaining captures are four Basic occupation and two CHOAM source guards. +Five original Advanced captures finish3,135/3,136/83 at their original seeds; +resume restarts rather than reconstructs the random stream. After repair, +five fresh six-seat Advanced games finish4,339/4,358/114 on unchanged2ada8996 +tree, including19 recorded rejected candidates. No zero-rejection/full-mode claim. + +Original CLI HH7VHT2U v21/4H5CZDMA v18/6K5JFNPH v18 preserve actors and stock. +Real turn-two alliances precede labelled conserved positions. Human390px verifies +Kaitain1/1, one allied bonus despite full Harkonnen8, actual three-force shipping6 +and Junction1/2 with restored BG accompaniment. Completed Fremen Collection9 +retains5 and shares4 as1/3; HH7VHT2U **v72/Mentat** wallets are Emperor5/Guild17/ +Harkonnen11/Fremen10/Atreides13/BG9. Richese native counterclockwise terms, +original Ornithopter5 invoice and3/2 net split reach **v29**; sole Ix pool6→5 +reaches **v25**, with native Ix-known0 and Technology still native. +Refresh has no root390px overflow. [Exact source/program evidence](HOMEWORLD_OCCUPATION_RULES.md#original-runtime-and-human-evidence). + +Tupile capacity/cleanup, exceptional Duke, Basic/CHOAM rulings, other overlays, +public/save conversion and full/deployed assurance remain open. No reset or +current-garrison shortcut. Publication is not deployment; NAS prerequisites +and last observed liveab5c782 remain unchanged. + +**Prior checkpoint5e902e3: stable Homeworld occupation — bounded Development Verified:** fresh original2..6 Basic/Advanced setup/selected decks and Homeworlds alone; no played-game conversion or inferred current controller. Continuously present sole qualification, without competing/departed/contested/restored-high history, diff --git a/docs/HOMEWORLD_OCCUPATION_RULES.md b/docs/HOMEWORLD_OCCUPATION_RULES.md index f7c19cc2..aaf92f5c 100644 --- a/docs/HOMEWORLD_OCCUPATION_RULES.md +++ b/docs/HOMEWORLD_OCCUPATION_RULES.md @@ -2,7 +2,113 @@ Runtime follow-up, 10 September 2026: [Tupile intelligence and qualification history](HOMEWORLD_TUPILE_INTELLIGENCE_RUNTIME.md#occupation-observation-boundaries) implement confirmed observations while retaining the unresolved entitlement and expiry questions below. The source audit remains historical evidence. +## Occupied economics, Bidding and Advanced source cutover — 5 October 2026 + +The supplied `UNOFFICIAL_Revised_Dune_Rulebook_v.2.3_web.pdf`, physical +**page22**, is authorized for Advanced. Its occupation clause replaces the +publisher boundary qualification with **sole foreign presence**, then retains +that occupier’s advantages **until none of its forces remain**. This is the +supplied revision’s rule, not a newly located GF9 erratum or a Basic ruling. +An intervening foreign army, a later turn or native high population does not +expire that retained Advanced source. Departure clears it; a later sole arrival +creates a new epoch. Original ordered semantic snapshots prove these transitions; +turn-boundary foreign presence alongside natives cannot invent Advanced +qualification. Unobserved physical writes and missing history cannot mint an +entitlement. Basic retains the historical pending guards below. + +The same fresh `homeworld-occupation` entry connects these original producers: + +| Printed source | Original consumer and ownership | +| --- | --- | +| Kaitain | Eligible paid Treachery receipt: native rounded-up half, occupied rounded-down half; buyer payment occurs once. | +| Junction | Eligible other-player shipping receipt after original Guild-funded exclusions and routing; the existing two-odd-contributor guard remains. | +| Richese income | Rounded-down half of actual seller net received, not the gross sale or contribution routed to Emperor. | +| Southern Hemisphere | One completed actual native Collection aggregate, including settled shared and occupied-bank credits; round once and redistribute the already credited amount. | +| Caladan | Native and occupier inspect the original admitted lot, never the occupier’s ally. Canceling native inspection hides its face from native while the separately admitted printed share survives. | +| Ix | One controller chooses from the actual original pool; native setup and Technology remain native, without duplicate native pool knowledge. | +| Richese cache choice | Original seller chooses auction method/direction first; occupier chooses only the physical cache card. Seller, lot, invoice and unbid disposition stay native. | +| Giedi Prime | One original Harkonnen purchase bonus goes to occupier or reciprocal ally with actual receiver capacity and drawable stock, even if the buyer’s purchase filled its own hand. | + +Percentage income has a separate persisted source ledger, frozen eligible +payment/Collection proof and exact suspended continuation. Keep-all or immediate +ally allocation commits actual credits with its settled receipt, not a second +payer debit or static Bank icon grant. Ordinary native income cancellation leaves +its native portion in the Bank; the printed occupied portion remains separate. +This cancellation composition uses the printed Homeworld immunity and original +ordinary cancellation rules, not a dedicated retrieved worked FAQ. +Richese’s existing source-blocked canceled-auction count remains guarded: +occupied card selection is not authority to invent a sale after cancellation. + +Actual Collection percentage credits feed the original once-per-phase Giedi +spice hook. Private producer proofs and suspended invoices stay server-private. +Original receipt-free occupation captures may admit a new source lazily; no past +percentage is back-awarded. Public starts, played-game conversion, other modules, +occupied Tupile capacity/cleanup and exceptional Duke custody remain separate. +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). + +### Original runtime and human evidence + +Final **247 affected cases in24 files**, types/lint and build pass. Actual +programs retain original physical cards, paid invoices, typed forces, private +lots, native terms and JSON continuations; all four policies take legal source +choices. Controlled capacity/counter positions are labelled and conserved, +including original Karama already dealt during random setup. + +Two real consumer defects were reproduced and repaired. Invalid bonus recipients +now use the ordinary `RuleError` adapter; an actual authenticated request returns +409 without changing the room version. Guild income could overwrite a concurrent +BG accompaniment choice. The signed source suffix now retains it through the +percentage choice and JSON; the original-snapshot smoke and native regression +complete the subsequent actual advisor placement, not merely a copied decision. + +The pre-advisor-repair **50 genuine games** ran on unchanged tree +`09cfdd1957d9ae4c807bce3c7c43edd66fe72b5c239c29cbadf3acca4253de9a`: +**44 complete**,18,977 accepted/19,149 attempts/491 JSON continuations. +All50 total22,383 accepted/22,580 attempts/580 JSON continuations. +Six captures retain the four Basic occupation and two low-Tupile CHOAM guards; +this is not a zero-rejection or complete-mode certificate. +Five original Advanced captures finish **3,135 more accepted/3,136 attempts/ +83 JSON continuations** at original scenario seeds20263433/453/455/465/475. +Resume restarts those seeds, not their lost original random streams. +After the advisor repair, **five fresh six-seat Advanced games all finish** +4,339 accepted/4,358 attempts/114 JSON continuations on unchanged tree +`2ada89969c683b3ed263f39b9467f623205d83b765cb3639832ee10adf918aae`. +The19 rejected candidates remain recorded, including existing arrival guards. + +Three original backed-up CLI setups preserve the real actors, decks and starting +choices: **HH7VHT2U v21**, **4H5CZDMA v18** and **6K5JFNPH v18**. +Real second-turn Nexus actions earn the alliances before labelled conserved +positions. Refreshed390px controls have no root overflow: + +- HH7VHT2U pays Harkonnen5 once: Emperor10→13, Guild6→7 and Harkonnen12→8 + after the1/1 Kaitain share. Harkonnen’s actual hand fills8; Atreides chooses + its ally Fremen for the one real bonus, whose hand1→2. +- Its actual three-normal-force Kaitain shipment costs6: Emperor11→5, + Guild7→10, Atreides12→13 and Fremen3→5 after the1/2 Junction share. + The restored BG control sends its actual advisor into Hagga Basin afterward. +- Original Collection has Fremen5 plus two actually shared2-spice bank grants: + total9, retained5, occupied4 allocated Guild1/Harkonnen3. **v72/Mentat** + ends with Emperor5/Guild17/Harkonnen11/Fremen10/Atreides13/BG9. + The five bank receipts and percentage settle once before original readiness. +- 4H5CZDMA’s native seller chooses Once Around/counterclockwise; Guild chooses + only original Ornithopter. The actual5-spice Harkonnen purchase splits Richese + net as native3/occupied2, then Guild1/Harkonnen1: balances4→7/5→6/10→6. + A single real bonus reaches Atreides; **v29** preserves the next native lot. +- 6K5JFNPH’s Guild alone sees the actual six-card Ix pool and returns original + Snooper to the top. **v25** retains five auction cards/five Guild-known faces, + native Ix-known0 and its unrelated Technology decision still owned by Ix. + +Only dedicated QA records changed; no game reset, public activation or credential +disclosure. Reports/private receipts remain outside Git. These local receipts do +not certify full Homeworlds, deferred assurance or deployed acceptance. + + ## Stable continuous occupation prototype — 5 October 2026 +Historical checkpoint `5e902e3`; its narrower Advanced lifecycle and absent +percentage/Bidding paths are superseded by the source cutover above. + The fresh local `homeworld-occupation` entry uses original two-through-six-seat Basic/Advanced setup, selected faction decks, thirteen physical card facts and @@ -110,7 +216,7 @@ The following is an implementation design derived from those timing requirements A public qualification record needs no hand, Traitor, Face Dancer or sealed-plan data. Malformed initialized evidence must fail validation; a legacy save lacking history cannot be claimed to prove that no earlier qualifier existed. Resolving entitlement by scanning current armies alone would discard the distinction the publisher created. -## Questions the retrieved sources do not settle +## Basic source questions and historical publisher gaps | Concrete situation | Missing decision; do not silently substitute a policy | | --- | --- | diff --git a/docs/HOMEWORLD_PAYMENT_INCOME_RULES.md b/docs/HOMEWORLD_PAYMENT_INCOME_RULES.md index 3c50057c..25c3a2e6 100644 --- a/docs/HOMEWORLD_PAYMENT_INCOME_RULES.md +++ b/docs/HOMEWORLD_PAYMENT_INCOME_RULES.md @@ -2,6 +2,11 @@ Source-composition contract, 9 September 2026. This covers population-based reductions to existing Emperor and Guild payment receipts. It does not implement occupied income, decide competing Occupiers or certify a complete Homeworld module. [Occupation source boundary](HOMEWORLD_OCCUPATION_RULES.md). +Runtime follow-up, 5 October 2026: the fresh occupation profile now has a +[separate original percentage ledger](HOMEWORLD_OCCUPATION_RULES.md#occupied-economics-bidding-and-advanced-source-cutover--5-october-2026). +The unoccupied contract below remains historical authority for ordinary native +receipt reduction, not for canceling a printed occupied share. + ## Printed effects and source precedence The verified low faces say that native Emperor receives the rounded-up half of Treachery Card payments and native Guild receives the rounded-up half of shipping payments. Both are low at zero through four native forces. Kaitain population is its own physical native pool, excluding forces allocated to Salusa Secundus. Component provenance is recorded in the [Homeworld component audit](HOMEWORLD_COMPONENT_AUDIT.md); this pass did not re-download its historical reverse-image cache. @@ -55,9 +60,20 @@ Do not reduce unrelated Emperor/Guild bank awards, charity, revival income, ordi ## Occupied branch remains separate -The unoccupied calculation leaves the missing half in the bank. An occupied face can assign that portion to a qualifying occupier and may independently award printed bank spice during Collection. Do not convert the bank remainder into an occupier award until the lifecycle and actual recipient are resolved. Likewise, high native population alone cannot prove that a retained occupation penalty has expired. Any runtime scope guard must acknowledge this limitation rather than describing the population-only quote as complete occupation support. +The unoccupied calculation leaves the missing half in the Bank. The fresh +occupation profile instead assigns that rounded-down portion to its proved +occupier, independently of the printed Collection bank icons. Advanced uses the +authorized supplied p22 sole/last-departure lifecycle; Basic retains guarded +historical cases. Native repopulation does not expire a retained Advanced source. -Future integration must preserve the original eligible receipt, present force state and qualification history separately. Ordinary income cancellation, retained low effects, occupied percentage income and immediate ally sharing must compose from their own rules; the current unoccupied branch is not authority for a future occupied Karama outcome. +The original eligible paid receipt, present force state and original qualification +history remain distinct. Native income Karama sends only its native portion to +the Bank; the printed occupied portion survives under Homeworld immunity and may +be immediately shared with the reciprocal ally. This is explicit source +composition, not a dedicated retrieved worked cancellation FAQ. The payer’s +completed debit and delivery never repeat. Existing two-odd contributor ambiguity, +independent-Karama bank routing and Guild Nexus full-fee overrides remain separate. +No past receipt is back-awarded and no overlay combination is admitted here. ### Guild Nexus Betrayal is a full-fee exception diff --git a/docs/HOMEWORLD_RULES.md b/docs/HOMEWORLD_RULES.md index adfdbee0..b0249af1 100644 --- a/docs/HOMEWORLD_RULES.md +++ b/docs/HOMEWORLD_RULES.md @@ -4,14 +4,15 @@ Source audit, 9 September 2026. No runtime activation. The primary module source ### Stable occupation runtime follow-up — 5 October 2026 -Original fresh `homeworld-occupation` setup now connects continuously proved, -uncontested bank Collection/immediate ally sharing and the printed Wallach/ -Tleilax/Grumman/Salusa occupied protections. The immutable qualification history -remains distinct from present armies; departure, replacement, contested or -repopulated histories retain their pending guards. Percentage receipts and -other occupied powers remain separate. This does not open normal starts, -convert old saves or certify Homeworlds. See the -[exact supported source/runtime subset](HOMEWORLD_OCCUPATION_RULES.md#stable-continuous-occupation-prototype--5-october-2026). +Original fresh `homeworld-occupation` connects bank Collection/immediate sharing, +Kaitain/Junction/Richese percentage receipts, the completed total Southern +Collection, original Caladan/Ix/Richese/Giedi Bidding ownership and printed +Wallach/Tleilax/Grumman/Salusa protections. Advanced follows the authorized supplied +rulebook p22: qualify alone, retain until the last occupying force leaves. +Basic still guards its publisher-source lifecycle questions. Original history, +native population, invoices and private audiences remain separate. Tupile capacity, +exceptional Duke, other modules, normal starts and full acceptance stay open. +See the [source cutover and original consumers](HOMEWORLD_OCCUPATION_RULES.md#occupied-economics-bidding-and-advanced-source-cutover--5-october-2026). ## Module scope and component inventory diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 12e61286..42ecf250 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,50 @@ # Dune implementation status +## 5 October 2026 — occupied Homeworld economics, Bidding and Advanced retention + +The authorized supplied p22 resolves Advanced qualification/retention: sole +foreign presence, continuing until its last force leaves. Original snapshots +retain that epoch through contests/native revival/turn changes and replace it +only after departure plus a new sole source. Basic historical guards remain. + +Fresh Homeworlds alone connects actual Kaitain/Junction/Richese percentage +receipts, one completed Southern Collection total, immediate ally allocation, +Caladan shared private inspection, sole Ix pool control, Richese card-only choice +with native seller terms, and one Giedi bonus using real receiver space/stock. +Original paid invoices, hidden custody and source suffixes remain authoritative. +Native income/inspection cancellation does not silently cancel printed shares. + +Final247 affected cases/24files, types/lint/build and original programs/four legal +policies pass. Real wrong-recipient rejection needed the RuleError adapter; +actual HTTP409 leaves v42. Human Guild income exposed overwritten BG accompaniment: +the signed suffix now restores its original choice through JSON and subsequent +actual advisor placement. Conserved random-deal stock/capacity fixtures now stage +the exact tested boundary; no favorable seed, invented card or guard weakening. + +Pre-advisor-repair50 genuine games on unchanged +`09cfdd1957d9ae4c807bce3c7c43edd66fe72b5c239c29cbadf3acca4253de9a`: +44 complete18,977 accepted/19,149 attempts/491 JSON; all50 total22,383/22,580/580. +Four Basic occupation/two CHOAM captures retain existing rulings. +Five exact original Advanced captures finish3,135/3,136/83 at original scenario +seeds20263433/453/455/465/475; resume restarts the seed, not its lost stream. +After repair, allfive fresh six-seat Advanced games finish4,339/4,358/114 on +unchanged `2ada89969c683b3ed263f39b9467f623205d83b765cb3639832ee10adf918aae`; +19 rejected candidates remain recorded, not a zero-rejection certificate. + +Original backed-up CLI HH7VHT2U v21/4H5CZDMA v18/6K5JFNPH v18 preserve real +decks, choices and actors. Actual turn-two alliances precede labelled positions. +390px human controls exercise all four percentages, native Richese terms/card-only +selection, full-Harkonnen allied real bonus and sole Ix pool6→5/native-known0. +HH7VHT2U actual shipping6 shares Junction3 as1/2 without repeating forces or +losing BG; Collection9 retains5/shares4 as1/3 and reachesv72/Mentat with wallets +5/17/11/10/13/9. Richese v29 retains its actual Ornithopter5 invoice/3+2 net split; +Ix v25 leaves unrelated Technology native. Allthree refreshed roots fit390px. + +Only dedicated QA changes, no reset/public activation/credential disclosure. +Private proofs remain outside Git. Tupile capacity/cleanup, exceptional Duke, +pending Basic/CHOAM rulings, overlays and complete/deployed acceptance remain. +[Canonical source, original consumers and evidence](HOMEWORLD_OCCUPATION_RULES.md#original-runtime-and-human-evidence). + ## 5 October 2026 — stable occupied Homeworld bank income and protections Fresh original `homeworld-occupation` enters Basic/Advanced2..6 and selected diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 3ea5e250..8549d25c 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -151,11 +151,33 @@ reveal history and the log now name the sourced forfeiture. Exceptional leader custody and the independent private-step UX/public-preview gates are unchanged. See [the cutover and Advanced sequence](MORITANI_ASSASSINATE_LEADERS.md#authority-and-advanced-duration-cutover). +## Advanced Homeworld occupation source cutover — 5 October 2026 + +The authorized supplied revision, physical **page22**, qualifies an occupier +when its foreign forces are **alone** on the world and retains those benefits +until **none of its forces remain**. Original semantic snapshots therefore +retain Advanced ownership through later contests, native repopulation and turn +changes, then clear it on departure; a subsequent sole arrival creates a new +original epoch. Its sole-only qualification does not inherit the publisher’s +foreign-presence-at-boundary clause. This supersedes the historical Advanced +lifecycle guard, not Basic, a GF9 erratum or a current-garrison shortcut. +The supplied page omits the individual reverse effects; audited components and +GF9 immunity/payment authority continue to supply those details. + +Kaitain/Junction native cancellation remains separate from printed occupied +percentages. Caladan native inspection denial hides native’s face while the +separately admitted occupier share survives. Ix transfers the actual Bidding +pool, not setup/Technology; Richese transfers only cache-card selection, not +seller terms, invoice or authority to bypass a canceled-auction-count ruling. +One real Giedi bonus uses the recipient’s hand space; Southern rounds the +completed actual Collection total once. These source compositions are not +dedicated retrieved worked FAQs. [Contract and remaining limits](HOMEWORLD_OCCUPATION_RULES.md#occupied-economics-bidding-and-advanced-source-cutover--5-october-2026). + ## Recorded contracts and implementation boundaries | Topic | Decision to preserve | Detailed authority and evidence | | --- | --- | --- | -| Stable Homeworld occupation | Fresh original Homeworlds alone, original setup and qualification history. Admit only one continuously present qualifier without another foreign army, departure, competing qualification or restored native high threshold. Actual bank icons/immediate reciprocal-ally allocation and Wallach/Tleilax/Grumman/Salusa protections compose source-clear cases; no current-controller shortcut or expiry/replacement ruling. Zero-icon ambiguity cannot block unrelated currency awards or select combat strength. Paid percentages, remaining borrowed powers, pending rulings and public/save conversion stay separate. | [Source and bounded runtime evidence](HOMEWORLD_OCCUPATION_RULES.md#stable-continuous-occupation-prototype--5-october-2026) | +| Stable Homeworld occupation | Fresh original Homeworlds alone and source history. Advanced uses authorized p22 sole qualification retained until last-force departure; Basic keeps historical lifecycle guards. Actual bank/percentage income, immediate sharing, Caladan/Ix/Richese/Giedi original ownership and Wallach/Tleilax/Grumman/Salusa defenses are connected. No inferred current controller, past-income back-award, invented canceled-auction count, public/save conversion or unrelated overlay. Tupile capacity and exceptional Duke remain separate. | [Source cutover and original consumers](HOMEWORLD_OCCUPATION_RULES.md#occupied-economics-bidding-and-advanced-source-cutover--5-october-2026) | | Standalone E3 Nexus / Skills / original components | Fresh Ecaz OR Moritani/classic ecaz33/all14/all12, Basic/Advanced2..6, original Tech3+/Advanced Strongholds2+. Original Duke/assassination/Terror, chosen-plan Advanced Occupy and separate physical armies remain. Ecaz-led Suk rescues only its own fixed casualties; Diplomat uses the selected faction's own undialed commitment. Owner-pool skill composition is an explicit implementation inference, not a combined-skill FAQ. E3 pairs/Harkonnen Advanced/HW/previews/shared-Duke training/enhanced relocation and pending rulings remain guarded. | [Source and verified scope](NEXUS_CARD_RULES.md#standalone-e3-nexus-skills-and-original-components--5-october-2026), [coalition contract](ECAZ_OCCUPY_RULES.md#standalone-nexus--skills-coalition--5-october-2026) | | Harkonnen special-Karama forced return | Return automatically only when the combined hand equals the count owed. Keep actual drawn-card observations owner-private after settlement; legacy forced saves describe held-card history without inventing draw provenance. Existing provisional auction-payment recovery and mode gates remain. | [Contract and limits](HARKONNEN_EXCHANGE.md) | | Basic Moritani / Leader Skills | Ordinary Terror death returns the assigned skill once; own revival offers the existing private optional replacement. Sabotage does not take skills, alliances do not transfer assignments, and mandatory Planetologist/Diplomat disposal excludes those cards from ally retention. This is composition of existing contracts, not a new ruling or resolution of unfinished skill effects. Advanced and other roster/module combinations remain gated. | [Integration contract](MORITANI_LEADER_SKILLS.md) | diff --git a/game/admin-seat-ai-shape.ts b/game/admin-seat-ai-shape.ts index 36d1e847..fb824f0e 100644 --- a/game/admin-seat-ai-shape.ts +++ b/game/admin-seat-ai-shape.ts @@ -32,6 +32,8 @@ const decisions = { homeworldRevivalDeployment: true, grummanCollection: true, homeworldOccupiedIncome: true, + homeworldOccupiedPercentage: true, + homeworldOccupiedBonus: true, caladanReinforcement: true, choamAudit: true, nexusChoamInspection: true, @@ -40,6 +42,7 @@ const decisions = { richeseBlackMarket: true, richeseDeclaration: true, richeseCache: true, + richeseCacheTerms: true, richeseUnbid: true, richeseAllyShipment: true, richeseAllyOpportunity: true, diff --git a/game/auction-continuation-quote.ts b/game/auction-continuation-quote.ts index 268f12d3..609f2fe7 100644 --- a/game/auction-continuation-quote.ts +++ b/game/auction-continuation-quote.ts @@ -27,6 +27,8 @@ export type AuctionContinuationInput = { richeseAuction?: Game['richeseAuction']; richeseBidding?: Game['richeseBidding']; richeseCacheCount?: number; + /** Printed occupied bonus uses actual receiver capacity, not native buyer space. */ + occupiedBonusAvailable?: boolean; }; export type AuctionContinuationOperation = | { kind: 'sale'; free: boolean } @@ -394,7 +396,7 @@ function calculate( if ( stage === 'bonus' && winner.faction === 'harkonnen' && - handCount(winner) < 8 + (input.occupiedBonusAvailable || handCount(winner) < 8) ) return result({ kind: 'response', diff --git a/game/bots.ts b/game/bots.ts index 32042f4b..ce3e2e65 100644 --- a/game/bots.ts +++ b/game/bots.ts @@ -1137,6 +1137,52 @@ function policyActions(g: GameView): Action[] { const level = rank(g); if (!(me.bot ?? me.autopilot) || g.status === 'finished') return []; if (g.status === 'lobby') return me.ready ? [] : [{ type: 'ready' }]; + if (!g.decision && !g.response && g.richeseBidding?.stage === 'cacheOffer' && g.richeseBidding.offerBlocked) + return []; + // Resolve these original source choices before unrelated optional card plays. + // Private faces and eligible recipients come only from the personalized view. + if (g.decision?.kind === 'homeworldOccupiedPercentage') { + const offer = g.homeworldOccupiedPercentage; + return g.decision.player === me.id && offer && offer.owner === me.id && + offer.event === g.decision.event && !offer.blocked + ? [{ type: 'decision', event: offer.event, ownAmount: offer.maxOwnAmount }] + : []; + } + if (g.decision?.kind === 'homeworldOccupiedBonus') { + const offer = g.homeworldOccupiedBonus; + const recipient = offer?.recipients.find((player) => player.player === me.id) ?? offer?.recipients[0]; + return g.decision.player === me.id && offer && offer.owner === me.id && + offer.event === g.decision.event && !offer.blocked && recipient + ? [{ type: 'decision', event: offer.event, recipient: recipient.player }] + : []; + } + if (g.decision?.kind === 'ixAuction') { + if (g.decision.player !== me.id || g.ixTechnology?.blocked) return []; + const cards = [...(g.ixTechnology?.pool ?? [])].sort( + (a, b) => technologyCardValue(g, a) - technologyCardValue(g, b), + ); + return cards[0] ? [{ type: 'decision', card: cards[0].id, position: 'bottom' }] : []; + } + if (g.decision?.kind === 'richeseCacheTerms') { + const bidding = g.richeseBidding; + return g.decision.player === me.id && bidding?.owner === me.id && !bidding.offerBlocked + ? [{ type: 'decision', event: bidding.event, method: 'onceAround', direction: 'clockwise' }] + : []; + } + if (g.decision?.kind === 'richeseCache') { + const bidding = g.richeseBidding; + if (bidding && bidding.owner !== me.id && !bidding.cacheTerms) return []; + if (g.decision.player !== me.id || !bidding || bidding.offerBlocked) return []; + const cards = [...(bidding.cache ?? [])].sort( + (a, b) => technologyCardValue(g, a) - technologyCardValue(g, b), + ); + return cards[0] ? [{ + type: 'decision', event: bidding.event, card: cards[0].id, + ...(bidding.owner === me.id + ? { method: level === 0 ? 'silent' as const : 'onceAround' as const, direction: 'clockwise' } + : {}), + }] : []; + } if (g.decision?.kind === 'nullentropy') { if (g.decision.player !== me.id) return []; const search = g.nullentropy?.search; @@ -1811,22 +1857,6 @@ function policyActions(g: GameView): Action[] { position: level >= 2 ? 'last' : 'first', }, ]; - if (d.kind === 'richeseCache') { - const cards = [...(g.richeseBidding?.cache ?? [])].sort( - (a, b) => technologyCardValue(g, a) - technologyCardValue(g, b), - ); - return cards.length - ? [ - { - type: 'decision', - event: g.richeseBidding!.event, - card: cards[0].id, - method: level === 0 ? 'silent' : 'onceAround', - direction: 'clockwise', - }, - ] - : []; - } if (d.kind === 'richeseUnbid') return [ { @@ -2054,9 +2084,8 @@ function policyActions(g: GameView): Action[] { ? [{ type: 'decision', accept: true }] : [{ type: 'decision', decline: true }]; } - if (d.kind === 'ixSetup' || d.kind === 'ixAuction') { - const cards = - d.kind === 'ixSetup' ? g.ixTechnology?.setup : g.ixTechnology?.pool; + if (d.kind === 'ixSetup') { + const cards = g.ixTechnology?.setup; const sorted = [...(cards ?? [])].sort( (a, b) => technologyCardValue(g, b) - technologyCardValue(g, a), ); @@ -2064,8 +2093,7 @@ function policyActions(g: GameView): Action[] { return [ { type: 'decision', - card: (d.kind === 'ixSetup' ? sorted[0] : sorted.at(-1)!).id, - position: 'bottom', + card: sorted[0].id, }, ]; } diff --git a/game/engine.ts b/game/engine.ts index 96bc7184..dd474718 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -95,6 +95,9 @@ import { HOMEWORLD_CARDS } from './homeworld-cards'; import { lowGrummanRevealBlock, type HomeworldCollectionSource } from './homeworld-collection'; import { quoteOccupiedHomeworldVoice, quoteOccupiedHomeworldFaceDancer, quoteOccupiedHomeworldTerror, occupiedHomeworldSardaukarStatus } from './homeworld-occupied-defenses'; import { beginHomeworldOccupiedIncome, quoteHomeworldOccupiedIncomeChoice, homeworldOccupiedIncomeOffer, validateHomeworldOccupiedIncomeState, type HomeworldOccupiedIncomeState } from './homeworld-occupied-income'; +import { createOccupiedPercentageState, quoteOccupiedPercentageSource, allocateOccupiedPercentageReceipt, validateOccupiedPercentageState, type OccupiedPercentageState, type OccupiedPercentageSource } from './homeworld-occupied-percentage'; +import { quoteOccupiedBiddingAuthority, requireOccupiedBiddingController, requireOccupiedBiddingBonusRecipient } from './homeworld-occupied-bidding'; +import { quoteStableHomeworldOccupation } from './homeworld-stable-occupation'; import { quoteGrummanCollection, quoteGrummanCollectionAction } from './grumman-collection'; import { grummanCollectionSignature, validateGrummanCollection, type GrummanCollection } from './grumman-collection-return'; import { ambassadorPhaseAllowed } from './ambassador-phase'; @@ -757,6 +760,7 @@ export type Battle = { }; export type Auction = { peekKnown?: boolean; + occupiedEvent?: string; cards: Card[]; index: number; bid: number; @@ -776,6 +780,8 @@ export type Decision = | { kind: 'homeworldRevivalDeployment'; player: string; event: string } | { kind: 'grummanCollection'; player: string; event: string } | { kind: 'homeworldOccupiedIncome'; player: string; event: string; world: string } + | { kind: 'homeworldOccupiedPercentage'; player: string; event: string } + | { kind: 'homeworldOccupiedBonus'; player: string; event: string } | { kind: 'caladanReinforcement'; player: string; event: string } | { kind: 'choamAudit'; player: string; event: string } | { kind: 'nexusChoamInspection'; player: string; event: string } @@ -789,6 +795,7 @@ export type Decision = | { kind: 'richeseBlackMarket'; player: string } | { kind: 'richeseDeclaration'; player: string } | { kind: 'richeseCache'; player: string } + | { kind: 'richeseCacheTerms'; player: string } | { kind: 'richeseUnbid'; player: string } | { kind: 'richeseAllyShipment'; player: string; owner: string } | { kind: 'richeseAllyOpportunity'; player: string; recipient: string } @@ -961,6 +968,7 @@ export type ResponseWindow = { /** Original eligible contributor amounts; private routing evidence, not a new payment. */ guildContributions?: number[]; guildPaymentProof?: string; + occupiedPayment?: { event: string; contributions: { payer: string; amount: number }[] }; /** Bank-paid portion of a frozen Homeworld charity claim. */ charityHomeworld?: number; source?: 'ambassador'; @@ -1522,6 +1530,10 @@ export type Game = { normalCount: number | null; blackMarketSold: boolean; cacheCanceled: boolean; + cacheAuthority?: string; + cacheTerms?: { method: 'onceAround' | 'silent'; direction: 'clockwise' | 'counterclockwise' }; + cacheTermsEvent?: string; + cacheTermsProof?: string; opener?: number; } | null; richeseAuction?: RicheseAuction | null; @@ -1546,6 +1558,8 @@ export type Game = { }; ixRicheseTechnologyEvent?: string; currentAuctionSale?: { + occupiedEvent?: string; + occupiedContributions?: { payer: string; amount: number }[]; spiceBankerIncomePayment?: SpiceBankerIncomePayment; bureaucratPayment?: BureaucratPaymentSource; bureaucratPaymentEvent?: string; @@ -1775,8 +1789,8 @@ export type Game = { truthHistory?: TruthRecord[]; shipmentPromises?: ShipmentPromise[]; ixSetupCards?: Card[] | null; - ixAuction?: { count: number; cards: Card[] } | null; - ixAuctionKnown?: { turn: number; cards: Card[] } | null; + ixAuction?: { count: number; cards: Card[]; authority?: string } | null; + ixAuctionKnown?: { turn: number; cards: Card[]; controller?: string } | null; ixTechnologyTurn?: number; pendingIxTechnology?: { card: string } | null; pendingIxAlly?: { player: string; card: string; free: boolean } | null; @@ -1875,8 +1889,22 @@ export type Game = { homeworldOccupationHistory?: HomeworldOccupationHistory; /** Fresh development occupation entry only; never inferred for an older Homeworld save. */ homeworldOccupationPreview?: true; + homeworldAuctionInspection?: { lot: string; audience: string[]; native: string | null; occupation: string; occupiedKnown: boolean }; pendingHomeworldOccupiedIncome?: HomeworldOccupiedIncomeState | null; homeworldOccupiedIncomeHistory?: HomeworldOccupiedIncomeState[]; + homeworldOccupiedPercentageLedger?: OccupiedPercentageState; + pendingHomeworldOccupiedPercentage?: { + source: OccupiedPercentageSource; + proof: string; + suffix: OccupiedPercentageSuffix; + suffixSignature: string; + } | null; + pendingHomeworldOccupiedBonus?: { + event: string; + proof: string; + response: ResponseWindow; + } | null; + fremenCollectionCredits?: { turn: number; event: string; collected: { id: string; amount: number }[]; complete: boolean }; tupileIntelligence?: TupileIntelligenceState; pendingTech?: { player: string; loser: string; choices: TechId[] } | null; summonedBeforeBlow?: boolean; @@ -1920,6 +1948,8 @@ export type Game = { }; /** Purchase is already committed; only the Emperor's receipt remains pending. */ pendingRichesePurchaseIncome?: { + event?: string; + buyer?: string; owner: string; turn: number; phase: number; @@ -8493,6 +8523,9 @@ export function initializeHomeworldOccupationGameForAudit(state: Game): Game { g.homeworldOccupationPreview = true; g.pendingHomeworldOccupiedIncome = null; g.homeworldOccupiedIncomeHistory = []; + g.homeworldOccupiedPercentageLedger = createOccupiedPercentageState(); + g.pendingHomeworldOccupiedPercentage = null; + g.pendingHomeworldOccupiedBonus = null; return g; } /** Offline-only seam for the independent Nexus module; public starts remain gated. */ @@ -10551,12 +10584,22 @@ function richeseDecision( | 'richeseBlackMarket' | 'richeseDeclaration' | 'richeseCache' + | 'richeseCacheTerms' | 'richeseUnbid', ) { const round = g.richeseBidding!; round.event = crypto.randomUUID(); - g.decision = { kind, player: round.owner }; - g.active = round.owner; + const authority = (kind === 'richeseCache' || kind === 'richeseCacheTerms') && g.homeworldOccupationPreview + ? quoteOccupiedBiddingAuthority(g, 'richeseCache') : null; + const player = kind === 'richeseCache' ? authority?.controller ?? round.owner : round.owner; + if (authority) round.cacheAuthority = JSON.stringify(quoteStableHomeworldOccupation(g, 'richese')); + if (authority?.blocked) { + g.decision = null; + g.active = null; + return; + } + g.decision = { kind, player }; + g.active = player; } function beginRicheseBidding(g: Game) { const owner = byFaction(g, 'richese')!; @@ -10597,7 +10640,8 @@ function prepareRicheseNormal(g: Game) { } function offerRicheseCache(g: Game) { g.richeseBidding!.stage = 'cacheOffer'; - richeseDecision(g, 'richeseCache'); + const occupied = g.homeworldOccupationPreview && quoteOccupiedBiddingAuthority(g, 'richeseCache').occupied; + richeseDecision(g, occupied ? 'richeseCacheTerms' : 'richeseCache'); } function finishNormalBidding(g: Game) { const round = g.richeseBidding; @@ -10666,6 +10710,7 @@ function finishRicheseLot(g: Game) { } } g.richeseAuction = null; + if (g.homeworldOccupationPreview) g.homeworldAuctionInspection = undefined; g.richeseFunding = {}; g.richesePeekKnown = false; g.richeseClaim = null; @@ -10908,6 +10953,9 @@ function settleRicheseSoldLot(g: Game, quote: Extract c.id !== card.id); winner.hand.push(card); g.currentAuctionSale = { + ...(g.homeworldOccupationPreview ? { occupiedEvent: lot.event, + occupiedContributions: [{ payer: winner.id, amount: quote.ownPayment }, + ...(quote.allyPayment ? [{ payer: winner.ally!, amount: quote.allyPayment }] : [])] } : {}), ...(richeseContribution ? {richeseContribution} : {}), ...(incomePayment ? {spiceBankerIncomePayment:incomePayment} : {}), winner: winner.id, @@ -11065,10 +11113,14 @@ function decideRichese(g: Game, p: Player, decision: Decision, action: Action) { requireRule( g.phase === 3 && round && - round.owner === p.id && + (decision.kind === 'richeseCache' && g.homeworldOccupationPreview + ? homeworldRule(() => requireOccupiedBiddingController(g, 'richeseCache', p.id)).provider === round.owner : round.owner === p.id) && action.event === round.event, 'This Richese bidding decision has expired.', ); + if ((decision.kind === 'richeseCache' || decision.kind === 'richeseCacheTerms') && round.cacheAuthority) + requireRule(round.cacheAuthority === JSON.stringify(quoteStableHomeworldOccupation(g, 'richese')), + 'The original cache choice authority changed.'); if (decision.kind === 'richeseBlackMarket') { requireRule( round.stage === 'blackMarketOffer' && g.advanced, @@ -11089,12 +11141,28 @@ function decideRichese(g: Game, p: Player, decision: Decision, action: Action) { `${p.name} announced the Richese cache auction ${round.position}, before normal pool preparation. ${round.normalCount} normal lots are scheduled after the cache reduction${round.blackMarketSold ? ' and completed Black Market sale' : ''}.`, ); g.response = { kind: 'richeseAuction', owner: p.id, passed: [] }; + } else if (decision.kind === 'richeseCacheTerms') { + requireRule(round.stage === 'cacheOffer' && ['onceAround', 'silent'].includes(action.method as string) && + ['clockwise', 'counterclockwise'].includes(action.direction as string) && + Object.keys(action).sort().join(',') === 'direction,event,method,type', + 'The original seller must choose the cache auction terms.'); + round.cacheTerms = { method: action.method as 'onceAround' | 'silent', + direction: action.direction as 'clockwise' | 'counterclockwise' }; + round.cacheTermsEvent = round.event; + round.cacheTermsProof = JSON.stringify([round.cacheTermsEvent, round.turn, round.owner, round.cacheTerms, round.cacheAuthority]); + richeseDecision(g, 'richeseCache'); } else if (decision.kind === 'richeseCache') { requireRule( round.stage === 'cacheOffer', 'The cache offer is not available now.', ); - beginRicheseLot(g, 'cache', action); + if (p.id !== round.owner) { + requireRule(round.cacheTerms && Object.keys(action).sort().join(',') === 'card,event,type', + 'The occupier chooses only the original physical cache card, not the seller terms.'); + requireRule(round.cacheTermsProof === JSON.stringify([round.cacheTermsEvent, round.turn, round.owner, + round.cacheTerms, round.cacheAuthority]), 'The original seller terms changed before the physical cache choice.'); + beginRicheseLot(g, 'cache', { ...action, ...round.cacheTerms }); + } else beginRicheseLot(g, 'cache', action); } else if (decision.kind === 'richeseUnbid') { requireRule( g.richeseAuction?.source === 'cache' && @@ -11144,9 +11212,15 @@ function setAuction(g: Game, cards?: Card[]) { if (!cards && ixians && eligible.length && count) { g.auction = null; g.active = null; - g.ixAuction = { count, cards: [] }; + g.ixAuction = { count, cards: [], + ...(g.homeworldOccupationPreview ? { authority: JSON.stringify(quoteStableHomeworldOccupation(g, 'ix')) } : {}) }; g.ixAuctionKnown = null; g.response = { kind: 'ixAuction', owner: ixians.id, passed: [] }; + if (g.homeworldOccupationPreview && quoteOccupiedBiddingAuthority(g, 'ixAuction').occupied) { + const original = g.response; + g.response = null; + finishIxianNativeAttempt(g, original, false); + } return; } if (!cards) { @@ -11166,6 +11240,7 @@ function setAuction(g: Game, cards?: Card[]) { (id) => eligible.includes(id), )!; g.auction = { + ...(g.homeworldOccupationPreview ? { occupiedEvent: crypto.randomUUID() } : {}), cards, index: 0, bid: 0, @@ -11179,6 +11254,7 @@ function setAuction(g: Game, cards?: Card[]) { } function offerAuctionTechnology(g: Game) { g.auction!.peekKnown = false; + if (g.homeworldOccupationPreview) g.homeworldAuctionInspection = undefined; const ixians = byFaction(g, 'ixians'); if ( g.advanced && @@ -11198,10 +11274,23 @@ function auctionTechnologyQuote(run: () => T): T { throw error; } } +/** Card identity alone is not a lot: Technology can return a previously sold card. */ +function originalAuctionInspectionLot(g: Game) { + return g.richeseAuction?.event ?? JSON.stringify([g.auction!.occupiedEvent ?? g.turn, + g.auction!.index, g.auction!.cards[g.auction!.index].id]); +} +function openOccupiedAuctionInspection(g: Game) { + if (!g.homeworldOccupationPreview) return; + const authority = quoteOccupiedBiddingAuthority(g, 'atreidesInspection'); + const lot = originalAuctionInspectionLot(g); + g.homeworldAuctionInspection = { lot, audience: authority.blocked ? [] : [...authority.inspectionAudience], + native: authority.provider, occupation: JSON.stringify(quoteStableHomeworldOccupation(g, 'caladan')), occupiedKnown: false }; +} function offerAuctionPeek(g: Game) { const quote = auctionTechnologyQuote(() => quoteNormalAuctionPeek(g)); g.auction!.peekKnown = quote.peekKnown; g.response = quote.response; + openOccupiedAuctionInspection(g); } function aidFor(g: Game, p: Player) { return p.ally && g.aid[p.ally]?.recipient === p.id @@ -11733,6 +11822,9 @@ function settleAuction(g: Game, free = false, automatic = false, emperorNexus = recipient: paysEmperor ? 'player' as const : 'bank' as const}] : []), ]) : undefined; g.currentAuctionSale = { + ...(g.homeworldOccupationPreview ? { occupiedEvent: crypto.randomUUID(), + occupiedContributions: [{ payer: winner.id, amount: free ? 0 : a.bid - (a.allyPayment ?? 0) }, + ...(!free && a.allyPayment ? [{ payer: winner.ally!, amount: a.allyPayment }] : [])] } : {}), ...(incomePayment ? {spiceBankerIncomePayment: incomePayment} : {}), winner: winner.id, amount: a.bid, @@ -11781,6 +11873,9 @@ function currentAuctionContinuationQuote( physicalCards: physicalTreacheryCards(g), pendingIxAlly: g.pendingIxAlly, ixTechnologyTurn: g.ixTechnologyTurn, + ...(g.homeworldOccupationPreview ? { occupiedBonusAvailable: + (() => { const authority = quoteOccupiedBiddingAuthority(g, 'harkonnenBonus'); + return !!authority.blocked || authority.occupied && authority.bonusRecipients.length > 0; })() } : {}), richeseAuction: g.richeseAuction, richeseBidding: g.richeseBidding, richeseCacheCount: g.richeseCache?.length, @@ -11824,11 +11919,22 @@ function commitAuctionContinuation(g: Game, quote: AuctionContinuationQuote,bure `${seller.name} collected ${step.amount-(bureaucratDiversion ?? 0)} spice from the ${quote.sale.origin === 'cache' ? 'Richese cache' : 'Black Market'} sale.`, { faction: 'richese', name: 'Auction income' }, ); + if (g.homeworldOccupationPreview && g.currentAuctionSale?.occupiedEvent) { + const sale = g.currentAuctionSale!, received = step.amount - (bureaucratDiversion ?? 0); + const source: OccupiedPercentageSource = { kind: 'richese-income', event: crypto.randomUUID(), + native: seller.id, amount: received, payment: { id: sale.occupiedEvent!, + binding: occupiedProducerProof(g, 'richese-income'), paid: sale.amount, + contributions: sale.occupiedContributions!, recipient: seller.id, received } }; + if (admitOccupiedPercentage(g, source, received, { kind: 'auction', quote: { ...quote, steps: [] } })) return; + } } } const next = quote.next; - if (next.kind === 'response') g.response = {...next.response,...(next.response.kind === 'emperorIncome' && g.currentAuctionSale?.bureaucratPayment ? - {bureaucratPayment:g.currentAuctionSale.bureaucratPayment,bureaucratPaymentEvent:g.currentAuctionSale.bureaucratPaymentEvent} : {})}; + if (next.kind === 'response') { + g.response = {...next.response,...(next.response.kind === 'emperorIncome' && g.currentAuctionSale?.bureaucratPayment ? + {bureaucratPayment:g.currentAuctionSale.bureaucratPayment,bureaucratPaymentEvent:g.currentAuctionSale.bureaucratPaymentEvent} : {})}; + offerOccupiedBonus(g, g.response); + } else if (next.kind === 'richeseEnd') finishRicheseLot(g); else { g.currentAuctionSale = null; @@ -11840,7 +11946,7 @@ function commitAuctionContinuation(g: Game, quote: AuctionContinuationQuote,bure g.auction = next.auction; g.active = next.active; if (next.offer?.kind === 'ixTechnology') g.decision = next.offer; - else if (next.offer) g.response = next.offer; + else if (next.offer) offerAuctionPeek(g); } } } @@ -12382,16 +12488,21 @@ function currentIxAuctionDrawQuote( * It consumes the accepted attempt directly; no declaration is replayed. */ function finishIxianNativeAttempt(g: Game, response: ResponseWindow, canceled: boolean): void { if (response.kind === 'ixAuction') { - const request = currentIxAuctionDrawQuote(g, response, canceled)!; + const authority = g.homeworldOccupationPreview ? quoteOccupiedBiddingAuthority(g, 'ixAuction') : null; + requireRule(!authority?.blocked, authority?.blocked ?? 'The original Ixian pool is blocked.'); + const request = currentIxAuctionDrawQuote(g, response, canceled && !authority?.occupied)!; const pending = g.ixAuction!; + if (pending.authority) requireRule(pending.authority === JSON.stringify(quoteStableHomeworldOccupation(g, 'ix')), + 'The original undrawn Ixian source authority changed.'); const cards: Card[] = []; for (let i = 0; i < request.drawCount; i++) { const card = draw(g); if (card) cards.push(card); } - if (!canceled && cards.length > 1) { + if ((!canceled || authority?.occupied) && cards.length > 1) { pending.cards = cards; - g.decision = { kind: 'ixAuction', player: response.owner }; + if (authority) pending.authority = JSON.stringify(quoteStableHomeworldOccupation(g, 'ix')); + g.decision = { kind: 'ixAuction', player: authority?.controller ?? response.owner }; } else { g.ixAuction = null; setAuction(g, cards); @@ -12404,6 +12515,7 @@ function finishIxianNativeAttempt(g: Game, response: ResponseWindow, canceled: b g.pendingIxTechnology = cancellation.pendingIxTechnology; g.auction!.peekKnown = cancellation.peek.peekKnown; g.response = cancellation.peek.response; + openOccupiedAuctionInspection(g); log(g, 'Ixian auction substitution did not occur.'); return; } @@ -15644,7 +15756,9 @@ function ambassadorPurchaseBonus(g: Game) { const entry = g.pendingAmbassador!; const receipt = entry.purchaseReceipt!; const buyer = getPlayer(g, receipt.buyer); - if (buyer.faction === 'harkonnen' && buyer.hand.length < handLimit(buyer)) { + const authority = g.homeworldOccupationPreview ? quoteOccupiedBiddingAuthority(g, 'harkonnenBonus') : null; + if (buyer.faction === 'harkonnen' && (authority?.blocked || authority?.occupied + ? !!authority?.blocked || !!authority?.bonusRecipients.length : buyer.hand.length < handLimit(buyer))) { entry.stage = receipt.stage = 'bonus'; g.response = { kind: 'harkonnenBonus', @@ -15653,8 +15767,219 @@ function ambassadorPurchaseBonus(g: Game) { owner: buyer.id, passed: [], }; + offerOccupiedBonus(g, g.response); } else finishAmbassador(g); } +/** A printed bonus consumes the original paid purchase's suffix, never a new buy. */ +function offerOccupiedBonus(g: Game, response: ResponseWindow): boolean { + if (!g.homeworldOccupationPreview || response.kind !== 'harkonnenBonus') return false; + const authority = quoteOccupiedBiddingAuthority(g, 'harkonnenBonus'); + if (response.source !== 'ambassador' && !g.currentAuctionSale?.occupiedEvent) return false; + if (!authority.occupied && !authority.blocked) return false; + if (response.source === 'ambassador') validateAmbassadorPurchaseResponse(g, response); + else currentAuctionContinuationQuote(g, { kind: 'cancel', response }); + requireRule(!g.pendingHomeworldOccupiedBonus, 'The original bonus is already awaiting its receiver.'); + const event = crypto.randomUUID(); + const proof = JSON.stringify({ producer: occupiedProducerProof(g, 'bonus', response), + occupation: JSON.stringify(quoteStableHomeworldOccupation(g, 'giedi_prime')), + controller: authority.controller, recipients: authority.bonusRecipients }); + g.homeworldOccupiedPercentageLedger ??= createOccupiedPercentageState(); + g.pendingHomeworldOccupiedBonus = { event, proof, response: structuredClone(response) }; + g.response = null; + g.decision = authority.controller && authority.bonusRecipients.length + ? { kind: 'homeworldOccupiedBonus', player: authority.controller, event } : null; + if (!authority.blocked && !authority.bonusRecipients.length) { + g.pendingHomeworldOccupiedBonus = null; + if (response.source === 'ambassador') finishAmbassador(g); else nextAuction(g); + } + return true; +} +function decideOccupiedBonus(g: Game, p: Player, action: Action) { + const pending = g.pendingHomeworldOccupiedBonus; + requireRule(pending && action.event === pending.event && Object.keys(action).sort().join(',') === 'event,recipient,type', + 'Choose the original occupied purchase bonus recipient.'); + const authority = homeworldRule(() => requireOccupiedBiddingController(g, 'harkonnenBonus', p.id)); + homeworldRule(() => requireOccupiedBiddingBonusRecipient(g, action.recipient as string)); + const saved = JSON.parse(pending.proof) as { producer: string; controller: string; recipients: string[]; occupation: string }; + requireRule(saved.controller === authority.controller && + saved.occupation === JSON.stringify(quoteStableHomeworldOccupation(g, 'giedi_prime')) && + saved.producer === occupiedProducerProof(g, 'bonus', pending.response) && saved.recipients.includes(action.recipient as string), + 'The original purchase or reciprocal bonus recipient changed.'); + if (pending.response.source === 'ambassador') validateAmbassadorPurchaseResponse(g, pending.response); + else currentAuctionContinuationQuote(g, { kind: 'cancel', response: pending.response }); + const stock = [...g.deck, ...g.discard], physical = physicalTreacheryCards(g); + requireRule(stock.every(card => physical.filter(other => other.id === card.id).length === 1), + 'The original bonus draw stock has conflicting physical custody.'); + const card = draw(g); + requireRule(card, 'The original bonus requires one physical drawable card.'); + getPlayer(g, action.recipient as string).hand.push(card); + g.pendingHomeworldOccupiedBonus = null; g.decision = null; + log(g, `${getPlayer(g, action.recipient as string).name} received the one private occupied Giedi Prime purchase bonus.`, + { faction: 'harkonnen', name: 'Occupied purchase bonus' }); + if (pending.response.source === 'ambassador') finishAmbassador(g); else nextAuction(g); +} +function projectedOccupiedBonus(g: Game) { + const pending = g.pendingHomeworldOccupiedBonus; + if (!pending) return null; + const authority = quoteOccupiedBiddingAuthority(g, 'harkonnenBonus'); + const saved = JSON.parse(pending.proof) as { controller: string | null; recipients: string[]; occupation: string }; + const owner = saved.controller ? getPlayer(g, saved.controller) : null; + const blocked = authority.blocked ?? (saved.occupation !== JSON.stringify(quoteStableHomeworldOccupation(g, 'giedi_prime')) + ? 'The original occupied purchase bonus authority changed.' : null); + return { event: pending.event, owner: owner?.id ?? '', ownerName: owner?.name ?? '', + recipients: (blocked ? [] : authority.bonusRecipients.filter(id => saved.recipients.includes(id))) + .map(id => { const p = getPlayer(g, id); return { player: id, name: p.name, faction: p.faction }; }), + blocked }; +} +type OccupiedPercentageSuffix = + | { kind: 'none'; decision: Decision | null } + | { kind: 'auction'; quote: AuctionContinuationQuote } + | { kind: 'auctionBonus' } + | { kind: 'ambassadorBonus' } + | { kind: 'richesePurchase'; resume: NonNullable['resume'] }; +function occupiedProducerProof(g: Game, kind: OccupiedPercentageSource['kind'] | 'bonus', response?: ResponseWindow) { + if (kind === 'fremen-collection') return JSON.stringify(g.fremenCollectionCredits); + if (response?.source === 'ambassador') return JSON.stringify({ turn: g.turn, phase: g.phase, entry: g.pendingAmbassador }); + if (response?.kind === 'richesePurchaseIncome') return JSON.stringify({ turn: g.turn, phase: g.phase, purchase: g.pendingRichesePurchaseIncome }); + if (kind === 'guild-shipping') return JSON.stringify({ turn: g.turn, phase: g.phase, response, + movement: g.players.map(p => ({ id: p.id, shipped: p.shipped, forces: p.forces, elites: p.elites, noField: p.noField })), + homeworlds: g.homeworlds?.custody }); + return JSON.stringify({ turn: g.turn, phase: g.phase, sale: g.currentAuctionSale, + auction: g.auction, lot: g.richeseAuction, round: g.richeseBidding }); +} +function runOccupiedPercentageSuffix(g: Game, suffix: OccupiedPercentageSuffix) { + if (suffix.kind === 'auction') commitAuctionContinuation(g, suffix.quote, 0); + else if (suffix.kind === 'auctionBonus') auctionBonus(g); + else if (suffix.kind === 'ambassadorBonus') ambassadorPurchaseBonus(g); + else if (suffix.kind === 'richesePurchase') { + g.pendingRichesePurchaseIncome = null; + g.response = suffix.resume.response; g.decision = suffix.resume.decision; g.pendingKarama = suffix.resume.pendingKarama; + } else if (suffix.kind === 'none') { + g.decision = suffix.decision; + } +} +function occupiedPercentageIntegrity(g: Game) { + if (!g.homeworldOccupationPreview) { + requireRule(g.homeworldOccupiedPercentageLedger === undefined && g.pendingHomeworldOccupiedPercentage === undefined && + g.pendingHomeworldOccupiedBonus === undefined, 'Occupied source receipts require their original fresh profile.'); + return; + } + requireRule(!g.spiceBankerIncomePreview, 'Occupied percentage and deferred Banker routing require a separate source-composition profile.'); + if (!g.homeworldOccupiedPercentageLedger) { + requireRule(!g.pendingHomeworldOccupiedPercentage && !g.pendingHomeworldOccupiedBonus, + 'The original occupied percentage ledger is missing.'); + return; + } + homeworldRule(() => validateOccupiedPercentageState(g.homeworldOccupiedPercentageLedger!, g)); + const pending = g.pendingHomeworldOccupiedPercentage; + const unsettled = g.homeworldOccupiedPercentageLedger.receipts.filter(row => row.status !== 'settled'); + requireRule(unsettled.length === Number(!!pending) && (!pending || unsettled[0].source.event === pending.source.event && + JSON.stringify(unsettled[0].source) === JSON.stringify(pending.source)), 'The occupied source lost its exact unresolved receipt.'); + if (pending) requireRule(pending.suffixSignature === JSON.stringify(pending.suffix), + 'The exact occupied percentage suffix changed.'); +} +/** The original payer has already paid; reconcile native cash only once. */ +function admitOccupiedPercentage(g: Game, source: OccupiedPercentageSource, alreadyCredited: number, + suffix: OccupiedPercentageSuffix): boolean { + if (!g.homeworldOccupationPreview) return false; + occupiedPercentageIntegrity(g); + g.homeworldOccupiedPercentageLedger ??= createOccupiedPercentageState(); + requireRule(!g.pendingHomeworldOccupiedPercentage, 'Settle the original percentage allocation first.'); + const result = homeworldRule(() => quoteOccupiedPercentageSource(g, source, g.homeworldOccupiedPercentageLedger!)); + const native = getPlayer(g, source.native), balance = native.spice - alreadyCredited + result.receipt.nativeRetained; + requireRule(Number.isSafeInteger(balance) && balance >= 0, 'The original percentage cash cannot be reconciled.'); + native.spice = balance; g.homeworldOccupiedPercentageLedger = result.state; + log(g, `${native.name}'s original ${source.kind === 'fremen-collection' ? 'total Collection' : 'paid income'} retained ${result.receipt.nativeRetained} spice; ${result.receipt.occupiedAmount} spice belongs to the separate occupied portion and ${result.receipt.bankRetained} spice remains in the bank.${result.receipt.blocked ? ' The printed allocation is blocked by its unresolved original occupation source.' : ''}`, + { faction: native.faction, name: 'Original Homeworld income' }); + if (result.receipt.status === 'settled') return false; + g.pendingHomeworldOccupiedPercentage = { source: result.receipt.source, + proof: source.kind === 'fremen-collection' ? source.collection.binding : source.payment.binding, + suffix, suffixSignature: JSON.stringify(suffix) }; + const owner = result.receipt.entitlement?.occupier; + g.decision = owner ? { kind: 'homeworldOccupiedPercentage', player: owner, event: source.event } : null; + return true; +} +function occupiedPaymentSource(g: Game, response: ResponseWindow, gross: number, canceled: boolean): 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; + let contributions = response.occupiedPayment?.contributions ?? [{ payer: buyer!, amount: gross }]; + if (response.kind === 'emperorIncome' && response.source !== 'ambassador' && sale) { + contributions = sale.richeseContribution !== undefined ? [{ payer: sale.seller!, amount: gross }] : + sale.occupiedContributions!; + } + const paid = contributions.reduce((sum, leg) => sum + leg.amount, 0); + if (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 }, + 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); +} +function decideOccupiedPercentage(g: Game, p: Player, action: Action) { + occupiedPercentageIntegrity(g); + const pending = g.pendingHomeworldOccupiedPercentage!; + requireRule(pending && action.event === pending.source.event && Object.keys(action).sort().join(',') === 'event,ownAmount,type', + 'Choose the original occupied percentage event and allocation.'); + const response = pending.source.kind === 'fremen-collection' ? undefined : + JSON.parse(pending.source.payment.binding).response as ResponseWindow | undefined; + const originalResponse = response ?? (pending.suffix.kind === 'ambassadorBonus' ? { source: 'ambassador' } as ResponseWindow : + pending.suffix.kind === 'richesePurchase' ? { kind: 'richesePurchaseIncome' } as ResponseWindow : undefined); + requireRule(occupiedProducerProof(g, pending.source.kind, originalResponse) === pending.proof, + 'The original occupied percentage producer changed.'); + const result = homeworldRule(() => allocateOccupiedPercentageReceipt(g.homeworldOccupiedPercentageLedger!, g, + p.id, action.event as string, action.ownAmount as number, pending.source)); + for (const credit of result.credits) requireRule(Number.isSafeInteger(getPlayer(g, credit.player).spice + credit.amount), 'Occupied allocation exceeds a wallet.'); + g.homeworldOccupiedPercentageLedger = result.state; g.pendingHomeworldOccupiedPercentage = null; g.decision = null; + for (const credit of result.credits) { + getPlayer(g, credit.player).spice += credit.amount; + if (pending.source.kind === 'fremen-collection') creditGiediCollection(g, credit.player, { kind: 'homeworld', amount: credit.amount }); + log(g, `${getPlayer(g, credit.player).name} received ${credit.amount} spice from the original occupied percentage allocation.`, + { faction: getPlayer(g, credit.player).faction, name: 'Occupied percentage income' }); + } + runOccupiedPercentageSuffix(g, pending.suffix); +} +function projectedOccupiedPercentage(g: Game) { + const pending = g.pendingHomeworldOccupiedPercentage; + if (!pending) return null; + const row = g.homeworldOccupiedPercentageLedger!.receipts.find(row => row.source.event === pending.source.event)!; + const card = HOMEWORLD_CARDS.find(card => card.id === (row.entitlement?.card ?? + ({ 'emperor-treachery': 'kaitain', 'guild-shipping': 'junction', 'richese-income': 'richese', 'fremen-collection': 'southern_hemisphere' } as const)[row.source.kind]))!; + const owner = row.entitlement ? getPlayer(g, row.entitlement.occupier) : null; + const ally = row.entitlement?.ally ? getPlayer(g, row.entitlement.ally) : null; + const current = quoteStableHomeworldOccupation(g, card.id); + const blocked = row.blocked ?? current.blocked ?? + (JSON.stringify(current.entitlement) !== JSON.stringify(row.entitlement) ? 'The original occupied percentage authority changed.' : null); + return { event: row.source.event, world: `homeworld:${card.faction}`, name: card.name, owner: owner?.id ?? '', ownerName: owner?.name ?? '', + ally: ally?.id ?? null, allyName: ally?.name ?? null, amount: row.occupiedAmount, minOwnAmount: ally ? 0 : row.occupiedAmount, + maxOwnAmount: row.occupiedAmount, blocked }; +} +function recordFremenCollection(g: Game, player: string, id: string, amount: number) { + if (!g.homeworldOccupationPreview || getPlayer(g, player).faction !== 'fremen' || amount === 0) return; + const receipt = g.fremenCollectionCredits; + if (!receipt) return; + requireRule(receipt?.turn === g.turn && !receipt.complete && Number.isSafeInteger(amount) && amount > 0 && + !receipt.collected.some(leg => leg.id === id), 'The original total Fremen Collection credit changed.'); + receipt.collected.push({ id, amount }); +} +function completeOccupiedFremenCollection(g: Game) { + const receipt = g.fremenCollectionCredits, native = byFaction(g, 'fremen'); + if (!native || !receipt || receipt.complete || g.phase !== 7 || g.phaseOpening || g.decision || g.response || + g.truthtrance || g.pendingHomeworldOccupiedIncome || g.pendingTreacheryDiscard || g.pendingNullentropy || g.pendingKarama || + g.grummanCollection && g.grummanCollection.stage !== 'complete' || g.ecazCollection && g.ecazCollection.stage !== 'complete') return; + receipt.complete = true; + const amount = receipt.collected.reduce((sum, leg) => sum + leg.amount, 0); + admitOccupiedPercentage(g, { kind: 'fremen-collection', native: native.id, event: crypto.randomUUID(), amount, + collection: { id: receipt.event, binding: occupiedProducerProof(g, 'fremen-collection'), complete: true, collected: receipt.collected } }, + amount, { kind: 'none', decision: null }); +} /** The payer has already paid. Only credit the eligible faction share once. */ function creditFactionPayment(g: Game, ownerId: string, kind: 'shipment' | 'treachery', gross: number) { const payment = homeworldRule(() => quoteHomeworldPaymentIncome(g, ownerId, kind, gross)); @@ -15689,14 +16014,19 @@ function currentFactionPayment(g: Game) { 'The saved Guild payment no longer matches its original contributor receipt.'); validateGuildPaymentRounding(g, response.owner, gross, response.guildContributions); } - return { owner: response.owner, kind, - ...homeworldRule(() => quoteHomeworldPaymentIncome(g, response.owner, kind, gross)) }; + const payment = homeworldRule(() => quoteHomeworldPaymentIncome(g, response.owner, kind, gross)); + const stable = g.homeworldOccupationPreview + ? homeworldRule(() => quoteStableHomeworldOccupation(g, kind === 'shipment' ? 'junction' : 'kaitain')) : null; + return { owner: response.owner, kind, ...payment, + ...(stable?.entitlement || stable?.blocked ? { income: Math.ceil(gross / 2), bank: 0 } : {}) }; } function validateGuildPaymentRounding(g: Game, owner: string, gross: number, contributions: number[]) { if (!g.homeworlds?.custody) return; const rounding = homeworldRule(() => quoteGuildPaymentRounding(gross, contributions)); const payment = homeworldRule(() => quoteHomeworldPaymentIncome(g, owner, 'shipment', gross)); - requireRule(!payment.low || rounding.unambiguous, + const junction = g.homeworldOccupationPreview + ? homeworldRule(() => quoteStableHomeworldOccupation(g, 'junction')) : null; + requireRule((!payment.low && !junction?.entitlement && !junction?.blocked) || rounding.unambiguous, 'Low Junction rounding for two odd allied contributions awaits a ruling. Choose a different payment split.'); } function shipmentIncomeContributions(g: Game, p: Player, cost: number, allyPayment: number) { @@ -15713,11 +16043,14 @@ function checkShipmentIncomeRounding(g: Game, p: Player, cost: number, allyPayme ...(allyPayment ? [{payer:p.ally!,amount:p.ally === guild.id ? 0 : allyPayment}] : [])],allyPayment > 0); validateGuildPaymentRounding(g, guild.id, amounts.reduce((sum, amount) => sum + amount, 0), amounts); } -function guildPaymentResponse(g: Game, owner: string, amounts: number[],payer?:string): ResponseWindow { +function guildPaymentResponse(g: Game, owner: string, amounts: number[],payer?:string, + named?: { payer: string; amount: number }[]): ResponseWindow { const contributions = amounts.filter(amount => amount > 0); const amount = contributions.reduce((sum, value) => sum + value, 0); validateGuildPaymentRounding(g, owner, amount, contributions); return { kind: 'guildIncome', owner, amount, passed: [], + ...(g.homeworldOccupationPreview ? { occupiedPayment: { event: crypto.randomUUID(), + contributions: named ?? [{ payer: payer!, amount }] } } : {}), ...(contributions.length === 1 && payer ? stampBureaucratPayment(g,'shipment',payer,owner,amount) : {}), ...(g.homeworlds?.custody ? { guildContributions: contributions, guildPaymentProof: guildPaymentSignature(g, owner, amount, contributions) } : {}) }; @@ -17996,6 +18329,7 @@ function homeworldHistoryIntegrity(g: Game) { if (g.tupileIntelligence) homeworldRule(() => validateTupileIntelligenceState(g.tupileIntelligence!, g.players, g.turn)); } else requireRule(g.tupileIntelligence === undefined, 'Tupile intelligence requires its original Homeworld history.'); homeworldOccupiedIncomeIntegrity(g); + occupiedPercentageIntegrity(g); } function homeworldOccupiedIncomeIntegrity(g: Game) { const pending = g.pendingHomeworldOccupiedIncome, history = g.homeworldOccupiedIncomeHistory; @@ -18045,6 +18379,7 @@ function commitHomeworldOccupiedIncome(g: Game, actor: string, event: string, wo g.pendingHomeworldOccupiedIncome = result.state; for (const credit of result.credits) { getPlayer(g, credit.player).spice += credit.amount; + recordFremenCollection(g, credit.player, `${event}:${world}:${credit.player}`, credit.amount); creditGiediCollection(g, credit.player, { kind: 'homeworld', amount: credit.amount }); log(g, `${getPlayer(g, credit.player).name} received ${credit.amount} spice from ${world}'s occupied bank award.`, { faction: getPlayer(g, credit.player).faction, name: 'Occupied Homeworld Collection' }); @@ -18213,6 +18548,7 @@ function commitCollection( for (const receipt of discovery.receipts) { const p = getPlayer(g, receipt.player); p.spice = receipt.balance; + recordFremenCollection(g, p.id, `ordinary:${p.id}`, receipt.strongholds + receipt.collected); if (receipt.strongholds) log( g, @@ -18227,6 +18563,7 @@ function commitCollection( } for (const effect of discovery.effects) { const p = getPlayer(g, effect.player); + recordFremenCollection(g, p.id, `${effect.kind}:${effect.kind === 'orgiz' ? effect.location : 'bank'}:${p.id}`, effect.amount); if (effect.kind === 'cistern') log( g, @@ -18280,6 +18617,8 @@ function commitCollection( stageGrummanCollection(g); } function collect(g: Game) { + if (g.homeworldOccupationPreview && byFaction(g, 'fremen') && g.fremenCollectionCredits?.turn !== g.turn) + g.fremenCollectionCredits = { turn: g.turn, event: crypto.randomUUID(), collected: [], complete: false }; if (g.homeworldOccupationPreview && !g.pendingHomeworldOccupiedIncome && !g.homeworldOccupiedIncomeHistory!.some(receipt => receipt.turn === g.turn)) g.pendingHomeworldOccupiedIncome = homeworldRule(() => beginHomeworldOccupiedIncome(g, crypto.randomUUID())); @@ -18351,6 +18690,8 @@ function decideSharedSpice(g: Game, id: string, action: Action) { pending.settled.push(receipt); getPlayer(g, receipt.ecaz).spice += receipt.ecazAmount; getPlayer(g, receipt.ally).spice += receipt.allyAmount; + recordFremenCollection(g, receipt.ecaz, `shared:${receipt.territory}:${receipt.ecaz}`, receipt.ecazAmount); + recordFremenCollection(g, receipt.ally, `shared:${receipt.territory}:${receipt.ally}`, receipt.allyAmount); creditGiediCollection(g, receipt.ecaz, { kind: 'desert', amount: receipt.ecazAmount }); creditGiediCollection(g, receipt.ally, { kind: 'desert', amount: receipt.allyAmount }); log( @@ -22751,6 +23092,22 @@ function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number) : null; if (!canceled && bureaucratDiversion === undefined && offerBureaucratPayment(g,response.bureaucratPayment,{kind:'response'})) return; g.response = null; + if (g.homeworldOccupationPreview && + (response.occupiedPayment || response.source === 'ambassador' || response.kind === 'richesePurchaseIncome' && g.pendingRichesePurchaseIncome?.event || + response.kind === 'emperorIncome' && g.currentAuctionSale?.occupiedEvent) && + ['emperorIncome', 'guildIncome', 'richesePurchaseIncome'].includes(response.kind)) { + const gross = (response.kind === 'emperorIncome' ? response.source === 'ambassador' ? response.amount! : + auctionEmperorIncomeAmount(g.currentAuctionSale!) : response.amount!) - (bureaucratDiversion ?? 0); + const suffix: OccupiedPercentageSuffix = response.source === 'ambassador' ? { kind: 'ambassadorBonus' } : + response.kind === 'emperorIncome' ? { kind: 'auctionBonus' } : response.kind === 'richesePurchaseIncome' + ? { kind: 'richesePurchase', resume: g.pendingRichesePurchaseIncome!.resume } : { kind: 'none', decision: g.decision }; + if (response.kind === 'emperorIncome') awardBankerIncome(g, g.currentAuctionSale?.spiceBankerIncomePayment, canceled); + if (response.kind === 'guildIncome') awardBankerIncome(g, response.spiceBankerIncomePayment, canceled); + const pending = settleOccupiedPayment(g, response, gross, canceled, suffix); + if (!pending) runOccupiedPercentageSuffix(g, suffix); + return; + } + if (offerOccupiedBonus(g, response)) return; if (response.kind === 'ecazOccupy') { const b = g.battle, occupy = b?.ecazOccupy; requireRule(b && occupy?.lead && occupy.ecaz === response.owner && !b.revealed && @@ -23204,6 +23561,7 @@ function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number) owner: atreides.id, passed: [], }; + if (g.response?.kind === 'atreidesAuction') openOccupiedAuctionInspection(g); else settleRicheseLot(g); } } else if (response.kind === 'ixAuction' || response.kind === 'ixTechnology') { @@ -23588,12 +23946,17 @@ function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number) chooseGuildTiming(g, response.take!); } } else if (response.kind === 'atreidesAuction') { + if (g.homeworldOccupationPreview) { + if (!g.homeworldAuctionInspection) openOccupiedAuctionInspection(g); + const saved = g.homeworldAuctionInspection!, occupation = quoteStableHomeworldOccupation(g, 'caladan'); + requireRule(saved.lot === originalAuctionInspectionLot(g) && + saved.occupation === JSON.stringify(occupation), 'The original Caladan inspection source changed.'); + saved.occupiedKnown = !!occupation.entitlement && !occupation.blocked; + } if (g.richeseAuction) { g.richesePeekKnown = !canceled; settleRicheseLot(g); - } else - g.auction!.peekKnown = - terminal?.kind === 'atreidesAuction' ? terminal.known : !canceled; + } else g.auction!.peekKnown = terminal?.kind === 'atreidesAuction' ? terminal.known : !canceled; } else if (response.kind === 'atreidesSpice') { const blocked = homeworldRule(() => homeworldMovementForesightBlock(g, response.owner)); requireRule(!blocked, blocked ?? 'Caladan prevents this foresight.'); @@ -23936,7 +24299,9 @@ function commitJunctionTransport(g: Game, p: Player, declaration: JunctionTransp const income = (ordinaryIncome ? quote.cost - allyPayment : 0) + (p.ally === option.owner ? 0 : allyPayment); if (!settlement?.recipient && income > 0) g.response = guildPaymentResponse(g, option.owner, - [ordinaryIncome ? quote.cost - allyPayment : 0, p.ally === option.owner ? 0 : allyPayment]); + [ordinaryIncome ? quote.cost - allyPayment : 0, p.ally === option.owner ? 0 : allyPayment], undefined, + [...(ordinaryIncome ? [{ payer: p.id, amount: quote.cost - allyPayment }] : []), + ...(p.ally !== option.owner && allyPayment ? [{ payer: p.ally!, amount: allyPayment }] : [])]); const sourceName = quote.originKind === 'arrakis' ? territory(quote.origin).name : quote.sources.map((s) => combatLocationName(g, s.key)).join(' and '); log(g, `${p.name} accepted ${getPlayer(g, option.owner).name}’s Junction ${rate}-price offer and transported ${quote.amount} physical forces (${quote.elite} special) from ${sourceName} to ${arrival ? `${territory(arrival.territory).name}, sector ${arrival.sector}` : combatLocationName(g, destination)} for ${quote.cost} spice (${quote.cost - allyPayment} own, ${allyPayment} pledged). The shipment is used; movement remains available.`, @@ -24052,7 +24417,7 @@ function commitHomeworldShipment(g: Game, shipment: PendingHomeworldShipment, se const guild = byFaction(g, 'guild'); const income = guild && p.ally !== guild.id ? shipment.allyPayment : 0; if (!settlement?.recipient && guild && income > 0) - g.response = guildPaymentResponse(g, guild.id, [income]); + g.response = guildPaymentResponse(g, guild.id, [income], p.ally!); finishShipmentPromises(g, p, null); log(g, `${p.name} shipped ${quote.amount} physical forces (${quote.elite} special) from ${quote.sourceNames.join(' and ')} to ${combatLocationName(g, shipment.destination)} for ${quote.cost} spice (${quote.cost - shipment.allyPayment} own, ${shipment.allyPayment} pledged). This uses their shipment; movement remains available.${shipment.guildSecretEvent ? ' Guild Secret Ally is spent for this half-price shipment; the payment goes to the bank.' : ''}`, {faction: shipment.guildSecretEvent ? 'guild' : p.faction, name: shipment.guildSecretEvent ? 'Secret Ally Homeworld shipment' : 'Homeworld shipment'}); @@ -24546,9 +24911,9 @@ function commitGuildTransport( bankOnly: g.karamaShipping?.player === p.id, }); if (!settlement?.recipient && guild && guildPayment > 0) - g.response = guildPaymentResponse(g, guild.id, - shipmentIncomeContributions(g, p, cost, allyPayment), - allyPayment ? undefined : p.id); + g.response = guildPaymentResponse(g, guild.id, shipmentIncomeContributions(g, p, cost, allyPayment), + allyPayment ? undefined : p.id, [{ payer: p.id, amount: p.id === guild.id ? 0 : cost - allyPayment }, + ...(allyPayment && p.ally !== guild.id ? [{ payer: p.ally!, amount: allyPayment }] : [])]); settleBankerShipmentIncome(g, p, cost, allyPayment); if (fromReserves) { p.reserves -= amount; @@ -24770,7 +25135,9 @@ function commitShipment(g: Game, shipment: PendingShipment, settlement?: {recipi bankOnly: g.karamaShipping?.player === p.id, }); if (!settlement?.recipient && guild && guildPayment > 0) - g.response = guildPaymentResponse(g, guild.id, shipmentIncomeContributions(g, p, cost, allyPayment),allyPayment ? undefined : p.id); + g.response = guildPaymentResponse(g, guild.id, shipmentIncomeContributions(g, p, cost, allyPayment), + allyPayment ? undefined : p.id, [{ payer: p.id, amount: p.id === guild.id ? 0 : cost - allyPayment }, + ...(allyPayment && p.ally !== guild.id ? [{ payer: p.ally!, amount: allyPayment }] : [])]); settleBankerShipmentIncome(g, p, cost, allyPayment); g.karamaShipping = null; log( @@ -26163,6 +26530,7 @@ export function executeSpecialKaramaIntent( const emperor = byFaction(g, 'emperor'); if (emperor) { g.pendingRichesePurchaseIncome = { + ...(g.homeworldOccupationPreview ? { event: crypto.randomUUID(), buyer: p.id } : {}), owner: emperor.id, turn: g.turn, phase: g.phase, @@ -26712,6 +27080,7 @@ function finishActionContinuations(g: Game) { resumeMarketGhola(g); resumeHomeworldOccupiedIncome(g); resumeGrummanCollection(g); + completeOccupiedFremenCollection(g); if (!g.truthtrance && !g.decision && !g.response) advanceSetup(g); if (g.status === 'playing' && g.leaderSkills && !g.decision && !g.response && !g.truthtrance && !g.phaseOpening) { const owner = g.order.find((id) => g.leaderSkills!.offers[id]); @@ -26775,6 +27144,26 @@ function settleAutomaticContinuations(g: Game) { if (g.truthtrance || g.phaseOpening || g.status === 'finished') return; const response = g.response; const before = JSON.stringify(g); + const bonus = g.pendingHomeworldOccupiedBonus; + if (bonus && g.decision?.kind === 'homeworldOccupiedBonus') { + const authority = quoteOccupiedBiddingAuthority(g, 'harkonnenBonus'); + const saved = JSON.parse(bonus.proof) as { occupation: string; producer: string }; + if (!authority.blocked && !authority.bonusRecipients.length && + saved.occupation === JSON.stringify(quoteStableHomeworldOccupation(g, 'giedi_prime')) && + saved.producer === occupiedProducerProof(g, 'bonus', bonus.response)) { + if (bonus.response.source === 'ambassador') validateAmbassadorPurchaseResponse(g, bonus.response); + else currentAuctionContinuationQuote(g, { kind: 'cancel', response: bonus.response }); + g.pendingHomeworldOccupiedBonus = null; + g.decision = null; + log(g, 'The original purchase produced no occupied bonus card: its actual recipients have no hand space or no drawable stock remains.', + { faction: 'harkonnen', name: 'Occupied purchase bonus' }); + if (bonus.response.source === 'ambassador') finishAmbassador(g); else nextAuction(g); + continue; + } + } + if (g.pendingHomeworldOccupiedPercentage || g.pendingHomeworldOccupiedBonus && !g.decision) return; + if (response?.kind === 'ixAuction' && g.homeworldOccupationPreview && + quoteOccupiedBiddingAuthority(g, 'ixAuction').blocked) return; if (response) { if ( !( @@ -26993,6 +27382,12 @@ function applyActionInner( g.guildTimingLocked ??= false; normalizeBattle(g); const p = getPlayer(g, id); + requireRule(!g.pendingHomeworldOccupiedPercentage || g.decision?.kind === 'homeworldOccupiedPercentage', + 'The original displaced percentage source is blocked.'); + requireRule(!g.pendingHomeworldOccupiedBonus || g.decision?.kind === 'homeworldOccupiedBonus', + 'The original displaced bonus source is blocked.'); + requireRule(!(g.homeworldOccupationPreview && g.richeseBidding?.stage === 'cacheOffer' && !g.decision && + quoteOccupiedBiddingAuthority(g, 'richeseCache').blocked), 'The original displaced cache-card source is blocked.'); requireRule( action && typeof action.type === 'string', 'Action type is required.', @@ -27481,6 +27876,7 @@ function applyActionInner( decision.kind === 'richeseBlackMarket' || decision.kind === 'richeseDeclaration' || decision.kind === 'richeseCache' || + decision.kind === 'richeseCacheTerms' || decision.kind === 'richeseUnbid' ) { decideRichese(g, p, decision, action); @@ -27568,6 +27964,14 @@ function applyActionInner( decideGrummanCollection(g, p, action); return g; } + if (decision.kind === 'homeworldOccupiedPercentage') { + decideOccupiedPercentage(g, p, action); + return g; + } + if (decision.kind === 'homeworldOccupiedBonus') { + decideOccupiedBonus(g, p, action); + return g; + } if (decision.kind === 'homeworldOccupiedIncome') { decideHomeworldOccupiedIncome(g, p, decision, action); return g; @@ -27898,9 +28302,12 @@ function applyActionInner( ); } else if (decision.kind === 'ixAuction') { requireRule( - g.ixAuction && p.faction === 'ixians', + g.ixAuction && (g.homeworldOccupationPreview + ? homeworldRule(() => requireOccupiedBiddingController(g, 'ixAuction', p.id)).provider === byFaction(g, 'ixians')!.id : p.faction === 'ixians'), 'The auction pool is not available.', ); + if (g.ixAuction.authority) requireRule(g.ixAuction.authority === JSON.stringify(quoteStableHomeworldOccupation(g, 'ix')), + 'The original Ixian pool authority changed before selection.'); const card = g.ixAuction.cards.find((c) => c.id === action.card); requireRule(card, 'Choose a card from the auction pool.'); requireRule( @@ -27910,7 +28317,8 @@ function applyActionInner( if (action.position === 'top') g.deck.unshift(card); else g.deck.push(card); const rest = g.ixAuction.cards.filter((c) => c.id !== card.id); - g.ixAuctionKnown = { turn: g.turn, cards: [...rest] }; + g.ixAuctionKnown = { turn: g.turn, cards: [...rest], + ...(g.homeworldOccupationPreview ? { controller: p.id } : {}) }; g.ixAuction = null; setAuction(g, shuffle(rest)); log( @@ -30891,6 +31299,15 @@ export function viewGame(state: Game, id: string) { const leaderRevivals = leaderRevivalOptions(visibleRevivalGame, { ...me, leaders: me.leaders.map((leader) => projectLeader(g, leader, id)) }); const b = g.battle; + const ixAuthority = g.homeworldOccupationPreview ? quoteOccupiedBiddingAuthority(g, 'ixAuction') : null; + const ixPoolStale = !!(g.homeworldOccupationPreview && g.ixAuction?.authority && + g.ixAuction.authority !== JSON.stringify(quoteStableHomeworldOccupation(g, 'ix'))); + const ixPoolAuthorized = !g.homeworldOccupationPreview || !ixPoolStale && !ixAuthority?.blocked && + ixAuthority?.controller === id && g.decision?.kind === 'ixAuction' && g.decision.player === id; + const cacheAuthority = g.homeworldOccupationPreview && g.richeseBidding?.stage === 'cacheOffer' + ? quoteOccupiedBiddingAuthority(g, 'richeseCache') : null; + const cacheStale = !!(cacheAuthority && g.richeseBidding?.cacheAuthority && + g.richeseBidding.cacheAuthority !== JSON.stringify(quoteStableHomeworldOccupation(g, 'richese'))); const completion = !g.pendingTreacheryDiscard && b && @@ -31006,6 +31423,8 @@ export function viewGame(state: Game, id: string) { me.faction === 'moritani' ? (g.pendingMoritaniPlacement ?? null) : null, grummanCollection: projectedGrummanCollection(g, id), homeworldOccupiedIncome: projectedHomeworldOccupiedIncome(g, id), + homeworldOccupiedPercentage: projectedOccupiedPercentage(g), + homeworldOccupiedBonus: projectedOccupiedBonus(g), tupileIntelligence: projectedTupileIntelligence(g, id), terrorEntry: g.pendingTerrorEntry ? (() => { @@ -31048,14 +31467,17 @@ export function viewGame(state: Game, id: string) { })() : null, ixTechnology: - me.faction === 'ixians' + me.faction === 'ixians' || g.ixAuctionKnown?.controller === id || + g.decision?.kind === 'ixAuction' && g.decision.player === id ? { - setup: g.ixSetupCards ?? null, - pool: g.ixAuction?.cards.length + blocked: ixAuthority?.blocked ?? (ixPoolStale ? 'The original Ixian pool authority changed.' : null), + setup: me.faction === 'ixians' ? g.ixSetupCards ?? null : null, + pool: ixPoolAuthorized && g.ixAuction?.cards.length ? [...g.ixAuction.cards].sort((a, b) => a.id.localeCompare(b.id)) : null, known: - g.ixAuctionKnown?.turn === g.turn + g.ixAuctionKnown?.turn === g.turn && + (!g.homeworldOccupationPreview || g.ixAuctionKnown.controller === id) ? [...g.ixAuctionKnown.cards].sort((a, b) => a.id.localeCompare(b.id), ) @@ -31531,6 +31953,7 @@ export function viewGame(state: Game, id: string) { ...(g.response.bureaucratPayment ? {bureaucratPayment:undefined,bureaucratPaymentEvent:undefined} : {}), ...(g.response.guildContributions ? { guildContributions: undefined } : {}), ...(g.response.guildPaymentProof ? { guildPaymentProof: undefined } : {}), + ...(g.response.occupiedPayment ? { occupiedPayment: undefined } : {}), ...(g.response.sandmasterProof ? { sandmasterProof: undefined } : {}), ...(g.response.noFieldSkillProof ? { noFieldSkillProof: undefined } : {}), passed: g.response.passed.includes(id) ? [id] : [], @@ -31814,9 +32237,11 @@ export function viewGame(state: Game, id: string) { stage: g.richeseBidding.stage, position: g.richeseBidding.position, normalCount: g.richeseBidding.normalCount, - offerBlocked: null, - cache: - g.richeseBidding.owner === id ? (g.richeseCache ?? []) : null, + cacheTerms: g.richeseBidding.cacheTerms ?? null, + offerBlocked: cacheAuthority?.blocked ?? (cacheStale ? 'The original cache choice authority changed.' : null), + cache: g.richeseBidding.owner === id || cacheAuthority && !cacheStale && !cacheAuthority.blocked && + g.decision?.kind === 'richeseCache' && g.decision.player === id && cacheAuthority.controller === id + ? g.richeseCache ?? [] : null, } : null, richeseAuction: g.richeseAuction @@ -31825,7 +32250,10 @@ export function viewGame(state: Game, id: string) { card: g.richeseAuction.source === 'cache' || g.richeseAuction.owner === id || - (me.faction === 'atreides' && g.richesePeekKnown) + (me.faction === 'atreides' && g.richesePeekKnown) || + (g.homeworldOccupationPreview && g.homeworldAuctionInspection?.lot === originalAuctionInspectionLot(g) && + g.homeworldAuctionInspection.occupiedKnown && id !== g.homeworldAuctionInspection.native && + g.homeworldAuctionInspection.audience.includes(id)) ? (g.richeseOfferedCard ?? richeseLotCard(g) ?? null) : null, claim: g.richeseClaim ?? null, @@ -31835,6 +32263,7 @@ export function viewGame(state: Game, id: string) { richeseOwnCommitment(g.richeseAuction, g.richeseFunding ?? {}, id), allyAvailable: aidFor(g, me)?.amount ?? 0, peekKnown: !!g.richesePeekKnown, + inspectionBlocked: g.homeworldOccupationPreview ? quoteOccupiedBiddingAuthority(g, 'atreidesInspection').blocked : null, } : null, auction: g.auction @@ -31846,8 +32275,12 @@ export function viewGame(state: Game, id: string) { ? { allyPayment: g.auction.allyPayment ?? 0 } : {}), remaining: g.auction.cards.length - g.auction.index, + inspectionBlocked: g.homeworldOccupationPreview ? quoteOccupiedBiddingAuthority(g, 'atreidesInspection').blocked : null, card: - me.faction === 'atreides' && g.auction.peekKnown + (me.faction === 'atreides' && g.auction.peekKnown) || + (g.homeworldOccupationPreview && g.homeworldAuctionInspection?.lot === originalAuctionInspectionLot(g) && + g.homeworldAuctionInspection.occupiedKnown && id !== g.homeworldAuctionInspection.native && + g.homeworldAuctionInspection.audience.includes(id)) ? g.auction.cards[g.auction.index] : null, } diff --git a/game/homeworld-occupied-bidding.ts b/game/homeworld-occupied-bidding.ts new file mode 100644 index 00000000..a8fc31c8 --- /dev/null +++ b/game/homeworld-occupied-bidding.ts @@ -0,0 +1,124 @@ +import type { FactionId } from './catalog'; +import { handLimit, type Game } from './engine'; +import type { HomeworldId } from './homeworld-cards'; +import { HomeworldCustodyError } from './homeworld-custody'; +import { quoteStableHomeworldOccupation } from './homeworld-stable-occupation'; + +export type OccupiedBiddingKind = + | 'atreidesInspection' + | 'ixAuction' + | 'richeseCache' + | 'harkonnenBonus'; +export type OccupiedBiddingAuthority = { + kind: OccupiedBiddingKind; + source: 'auctionLot' | 'ixAuctionPool' | 'richeseCache' | 'treacheryStock'; + /** Original native power provider, not a substitute buyer, seller or payer. */ + provider: string | null; + controller: string | null; + inspectionAudience: string[]; + /** Alternatives for ONE existing purchase bonus, never one card per player. */ + bonusRecipients: string[]; + bonusStockAvailable: boolean | null; + occupied: boolean; + blocked: string | null; +}; + +export class OccupiedBiddingError extends HomeworldCustodyError {} + +const sources: Record = { + atreidesInspection: { card: 'caladan', faction: 'atreides', source: 'auctionLot' }, + ixAuction: { card: 'ix', faction: 'ixians', source: 'ixAuctionPool' }, + richeseCache: { card: 'richese', faction: 'richese', source: 'richeseCache' }, + harkonnenBonus: { card: 'giedi_prime', faction: 'harkonnen', source: 'treacheryStock' }, +}; + +/** Authority for an ORIGINAL native Bidding effect only. Its producer still + * validates the invoice/lot/window and physical card custody. This pure quote + * neither initiates another inspection/draw nor confers captured Nexus powers. + * Sources: original Caladan, Ix, Richese and Giedi Prime occupied card faces; + * HOMEWORLD_OCCUPATION_RULES7–33,35–56 through the shared stable-source gate. */ +export function quoteOccupiedBiddingAuthority( + game: Game, + kind: OccupiedBiddingKind, +): OccupiedBiddingAuthority { + const rule = sources[kind]; + if (!rule) throw new OccupiedBiddingError('Unknown original Homeworld Bidding effect.'); + const native = game.players.find(player => player.faction === rule.faction); + const bonusStockAvailable = kind === 'harkonnenBonus' + ? game.deck.length > 0 || game.discard.length > 0 : null; + const quote: OccupiedBiddingAuthority = { + kind, source: rule.source, provider: native?.id ?? null, controller: null, + inspectionAudience: [], bonusRecipients: [], bonusStockAvailable, + occupied: false, blocked: null, + }; + if (!native) return quote; + const occupation = quoteStableHomeworldOccupation(game, rule.card); + if (occupation.blocked) return { ...quote, blocked: occupation.blocked }; + const entitlement = occupation.entitlement; + quote.occupied = entitlement !== null; + quote.controller = entitlement?.occupier ?? native.id; + switch (kind) { + case 'atreidesInspection': + // SHARES: native inspection survives. No borrowing by the occupier's ally. + quote.controller = native.id; + quote.inspectionAudience = entitlement ? [native.id, entitlement.occupier] : [native.id]; + break; + case 'ixAuction': + // CONTROLS: the actual drawn pool/selection has one decision owner and + // audience, not an extra native peek. Setup/Technology are separate powers. + quote.inspectionAudience = [quote.controller]; + break; + case 'richeseCache': + // CHOOSES: only this cache-card choice changes owner. The original cache + // remains native custody/knowledge; declaration, Black Market, unbid-card + // disposal, seller/payment and special-lot substitution do not transfer. + quote.inspectionAudience = entitlement ? [native.id, entitlement.occupier] : [native.id]; + break; + case 'harkonnenBonus': { + // The printed condition is the RECEIVER's real hand space, not the + // native buyer's post-purchase space. The same draw uses deck/discard; + // cards in cache, a pending pool or other hands are not drawable stock. + const candidates = entitlement + ? [entitlement.occupier, ...(entitlement.ally ? [entitlement.ally] : [])] + : [native.id]; + if (bonusStockAvailable) quote.bonusRecipients = candidates.filter(id => { + const recipient = game.players.find(player => player.id === id); + return recipient !== undefined && recipient.hand.length < handLimit(recipient); + }); + break; + } + } + return quote; +} + +/** Validate the decision actor without replacing the original source provider. */ +export function requireOccupiedBiddingController( + game: Game, + kind: OccupiedBiddingKind, + player: string, +): OccupiedBiddingAuthority { + const quote = quoteOccupiedBiddingAuthority(game, kind); + if (quote.blocked) throw new OccupiedBiddingError(quote.blocked); + if (kind === 'atreidesInspection' + ? !quote.inspectionAudience.includes(player) + : quote.controller !== player) + throw new OccupiedBiddingError('This player does not control the original Bidding effect.'); + return quote; +} + +/** Requote at acceptance: alliances, genuine hand space and draw stock can + * change while the original purchase continuation is awaiting its recipient. */ +export function requireOccupiedBiddingBonusRecipient( + game: Game, + recipient: string, +): OccupiedBiddingAuthority { + const quote = quoteOccupiedBiddingAuthority(game, 'harkonnenBonus'); + if (quote.blocked) throw new OccupiedBiddingError(quote.blocked); + if (!quote.bonusRecipients.includes(recipient)) + throw new OccupiedBiddingError('This player cannot receive the original purchase bonus.'); + return quote; +} diff --git a/game/homeworld-occupied-percentage.ts b/game/homeworld-occupied-percentage.ts new file mode 100644 index 00000000..eb60b941 --- /dev/null +++ b/game/homeworld-occupied-percentage.ts @@ -0,0 +1,279 @@ +import { HomeworldCustodyError } from './homeworld-custody'; +import type { HomeworldId } from './homeworld-cards'; +import { quoteGuildPaymentRounding, quoteHomeworldPaymentIncome } from './homeworld-payment-income'; +import { + quoteStableHomeworldOccupation, + type StableHomeworldOccupationContext, + type StableHomeworldOccupier, +} from './homeworld-stable-occupation'; + +export type OccupiedPercentageKind = + | 'emperor-treachery' | 'guild-shipping' | 'richese-income' | 'fremen-collection'; +export type OccupiedPercentageContribution = Readonly<{ payer: string; amount: number }>; +/** The original producer supplies an immutable receipt identifier and its full + * payment proof, not a new debit instruction. Guild own contributions remain in + * this proof but are excluded from the eligible amount. */ +export type OccupiedPercentagePayment = Readonly<{ + id: string; + binding: string; + paid: number; + contributions: readonly OccupiedPercentageContribution[]; + recipient: string; +}>; +export type OccupiedPercentageSource = Readonly<{ + event: string; + native: string; + amount: number; +}> & ( + | Readonly<{ + kind: 'emperor-treachery' | 'guild-shipping'; + payment: OccupiedPercentagePayment; + nativeIncome: 'received' | 'karama-canceled'; + }> + | Readonly<{ + kind: 'richese-income'; + payment: OccupiedPercentagePayment & Readonly<{ received: number }>; + }> + | Readonly<{ + kind: 'fremen-collection'; + collection: Readonly<{ + id: string; + binding: string; + complete: true; + /** All actual native Collection credits, after original shared allocation. + * These are settled credit legs, not pile capacities or forecast awards. */ + collected: readonly Readonly<{ id: string; amount: number }>[]; + }>; + }> +); +export type OccupiedPercentageContext = StableHomeworldOccupationContext; +export type OccupiedPercentageCredit = Readonly<{ player: string; amount: number }>; +export type OccupiedPercentageReceipt = Readonly<{ + source: OccupiedPercentageSource; + turn: number; + entitlement: Readonly | null; + nativeLow: boolean; + nativeRetained: number; + occupiedAmount: number; + bankRetained: number; + status: 'pending' | 'settled' | 'blocked'; + blocked: string | null; + ownAmount: number | null; + credits: readonly OccupiedPercentageCredit[]; +}>; +/** One persisted ledger owns source admission and allocation. Applying cash and + * replacing this state must be a single engine transaction; a pure quote cannot + * prevent a caller from applying the same returned credits twice. */ +export type OccupiedPercentageState = Readonly<{ + version: 1; + receipts: readonly OccupiedPercentageReceipt[]; + signature: string; +}>; +export type OccupiedPercentageQuote = { + state: OccupiedPercentageState; + receipt: OccupiedPercentageReceipt; +}; +export type OccupiedPercentageAllocation = OccupiedPercentageQuote & { + credits: readonly OccupiedPercentageCredit[]; +}; + +const cards: Record = { + 'emperor-treachery': 'kaitain', 'guild-shipping': 'junction', + 'richese-income': 'richese', 'fremen-collection': 'southern_hemisphere', +}; +const factions: Record = { + 'emperor-treachery': 'emperor', 'guild-shipping': 'guild', + 'richese-income': 'richese', 'fremen-collection': 'fremen', +}; +const whole = (value: unknown): value is number => Number.isSafeInteger(value) && (value as number) >= 0; +const text = (value: unknown): value is string => typeof value === 'string' && value.trim().length > 0; +function requireReceipt(condition: unknown, message: string): asserts condition { + if (!condition) throw new HomeworldCustodyError(`Occupied percentage receipt: ${message}`); +} +function keys(value: unknown, expected: readonly string[]): boolean { + return !!value && typeof value === 'object' && !Array.isArray(value) && + Object.keys(value).length === expected.length && expected.every(key => Object.hasOwn(value, key)); +} +function total(amounts: readonly number[]): number { + let sum = 0; + for (const amount of amounts) { + requireReceipt(whole(amount) && Number.isSafeInteger(sum + amount), 'invalid or overflowing actual source amount.'); + sum += amount; + } + return sum; +} +/** Canonical copied provenance also gives property-order-independent JSON binding. */ +function freezeSource(source: OccupiedPercentageSource, game: OccupiedPercentageContext): OccupiedPercentageSource { + requireReceipt(source && Object.hasOwn(cards, source.kind) && text(source.event) && text(source.native) && whole(source.amount), + 'unknown source or invalid event, native or amount.'); + requireReceipt(game.players.some(player => player.id === source.native && player.faction === factions[source.kind]), + 'the original source native has the wrong faction.'); + const base = { kind: source.kind, event: source.event, native: source.native, amount: source.amount }; + if (source.kind === 'fremen-collection') { + requireReceipt(keys(source, ['kind', 'event', 'native', 'amount', 'collection']) && + keys(source.collection, ['id', 'binding', 'complete', 'collected']) && text(source.collection.id) && + text(source.collection.binding) && source.collection.complete === true && Array.isArray(source.collection.collected), + 'Collection requires its completed original aggregate.'); + const seen: Record = Object.create(null); + const collected = source.collection.collected.map(leg => { + requireReceipt(keys(leg, ['id', 'amount']) && text(leg.id) && whole(leg.amount) && !Object.hasOwn(seen, leg.id), + 'duplicate or invalid actual Collection leg.'); + seen[leg.id] = true; + return Object.freeze({ id: leg.id, amount: leg.amount }); + }); + requireReceipt(total(collected.map(leg => leg.amount)) === source.amount, 'Collection total lost actual native credits.'); + return Object.freeze({ ...base, kind: source.kind, collection: Object.freeze({ + id: source.collection.id, binding: source.collection.binding, complete: true as const, collected: Object.freeze(collected), + }) }); + } + const richese = source.kind === 'richese-income'; + 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) && + 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); + const contributions = source.payment.contributions.map(leg => { + requireReceipt(keys(leg, ['payer', 'amount']) && text(leg.payer) && whole(leg.amount) && + game.players.some(player => player.id === leg.payer) && !Object.hasOwn(seen, leg.payer), + 'duplicate or invalid original payer contribution.'); + seen[leg.payer] = true; + return Object.freeze({ payer: leg.payer, amount: leg.amount }); + }); + 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 }; + 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.'); + return Object.freeze({ ...base, kind: source.kind, payment: Object.freeze({ ...payment, received: source.payment.received }) }); + } + 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.'); + return Object.freeze({ ...base, kind: source.kind, payment: Object.freeze(payment), nativeIncome: source.nativeIncome }); +} +function amounts(source: OccupiedPercentageSource, nativeLow: boolean, occupied: boolean) { + const occupiedAmount = occupied ? Math.floor(source.amount / 2) : 0; + const payment = source.kind === 'emperor-treachery' || source.kind === 'guild-shipping'; + const nativeRetained = payment + ? source.nativeIncome === 'karama-canceled' ? 0 : occupied || nativeLow ? Math.ceil(source.amount / 2) : source.amount + : source.amount - occupiedAmount; + return { nativeRetained, occupiedAmount, bankRetained: source.amount - nativeRetained - occupiedAmount }; +} +function allocation(entitlement: Readonly | null, amount: number, ownAmount: number): readonly OccupiedPercentageCredit[] { + const credits: OccupiedPercentageCredit[] = []; + if (entitlement && ownAmount > 0) credits.push(Object.freeze({ player: entitlement.occupier, amount: ownAmount })); + if (entitlement?.ally && ownAmount < amount) credits.push(Object.freeze({ player: entitlement.ally, amount: amount - ownAmount })); + return Object.freeze(credits); +} +function stateWith(receipts: readonly OccupiedPercentageReceipt[]): OccupiedPercentageState { + return Object.freeze({ version: 1, receipts: Object.freeze(receipts), signature: JSON.stringify([1, receipts]) }); +} +export function createOccupiedPercentageState(): OccupiedPercentageState { return stateWith([]); } + +/** Validate historical receipts without replacing their frozen source or owner + * with a current army. Static BANK icon amounts do not constrain these awards. */ +export function validateOccupiedPercentageState(state: OccupiedPercentageState, game: OccupiedPercentageContext): void { + requireReceipt(keys(state, ['version', 'receipts', 'signature']) && state.version === 1 && Array.isArray(state.receipts) && + typeof state.signature === 'string', 'invalid persisted ledger.'); + for (let i = 0; i < state.receipts.length; i++) { + const row = state.receipts[i]; + requireReceipt(keys(row, ['source', 'turn', 'entitlement', 'nativeLow', 'nativeRetained', 'occupiedAmount', 'bankRetained', + 'status', 'blocked', 'ownAmount', 'credits']) && whole(row.turn) && row.turn > 0 && row.turn <= game.turn && + typeof row.nativeLow === 'boolean' && Array.isArray(row.credits), 'invalid saved receipt.'); + const source = freezeSource(row.source, game), entitlement = row.entitlement; + if (entitlement !== null) { + requireReceipt(keys(entitlement, ['world', 'card', 'native', 'occupier', 'ally', 'qualification', 'turn', 'spice']) && + entitlement.card === cards[source.kind] && entitlement.native === source.native && entitlement.turn === row.turn && + entitlement.world === `homeworld:${factions[source.kind]}` && text(entitlement.qualification) && whole(entitlement.spice) && + game.players.some(player => player.id === entitlement.occupier && player.id !== source.native) && + (entitlement.ally === null || game.players.some(player => player.id === entitlement.ally && player.id !== entitlement.occupier)), + 'invalid frozen original entitlement.'); + } + requireReceipt(row.blocked === null || text(row.blocked), 'invalid source blocker.'); + const expected = amounts(source, row.nativeLow, entitlement !== null || row.blocked !== null); + requireReceipt(row.nativeRetained === expected.nativeRetained && row.occupiedAmount === expected.occupiedAmount && + row.bankRetained === expected.bankRetained && row.bankRetained >= 0, 'saved cash legs changed the actual receipt.'); + if (row.status === 'settled') { + requireReceipt(row.blocked === null && whole(row.ownAmount) && row.ownAmount <= row.occupiedAmount && + (entitlement?.ally || row.ownAmount === row.occupiedAmount) && + (row.occupiedAmount === 0 || entitlement !== null) && + JSON.stringify(row.credits) === JSON.stringify(allocation(entitlement, row.occupiedAmount, row.ownAmount)), + 'settled allocation does not conserve the occupied portion.'); + } else { + requireReceipt(row.occupiedAmount > 0 && row.ownAmount === null && row.credits.length === 0 && + (row.status === 'pending' && entitlement !== null && row.blocked === null || + row.status === 'blocked' && entitlement === null && row.blocked !== null), 'invalid pending or blocked receipt.'); + } + for (let j = 0; j < i; j++) requireReceipt(!sameSource(state.receipts[j], source, row.turn), 'duplicate original source/event.'); + } + requireReceipt(state.signature === JSON.stringify([1, state.receipts]), 'saved receipt ledger changed.'); +} +function sameSource(row: OccupiedPercentageReceipt, source: OccupiedPercentageSource, turn: number): boolean { + const originalId = row.source.kind === 'fremen-collection' ? row.source.collection.id : row.source.payment.id; + const candidateId = source.kind === 'fremen-collection' ? source.collection.id : source.payment.id; + return row.source.event === source.event || row.source.kind === source.kind && row.source.native === source.native && + (originalId === candidateId || source.kind === 'fremen-collection' && row.turn === turn); +} + +/** Quote already-paid/collected spice only. Native Karama removes native income, + * not the printed occupied effect. Richese sale-origin classification and other + * economic priorities stay in the original producer. Southern Hemisphere admits + * one completed total per native/turn, never independently rounded piles. + * Sources: original occupied card faces; HOMEWORLD_RULES48–62,98–100. */ +export function quoteOccupiedPercentageSource( + game: OccupiedPercentageContext, original: OccupiedPercentageSource, state: OccupiedPercentageState, +): OccupiedPercentageQuote { + validateOccupiedPercentageState(state, game); + requireReceipt(whole(game.turn) && game.turn > 0, 'invalid source turn.'); + const source = freezeSource(original, game); + requireReceipt(!state.receipts.some(row => sameSource(row, source, game.turn)), 'source/event already admitted or settled.'); + const stable = quoteStableHomeworldOccupation(game, cards[source.kind]); + const payment = source.kind === 'emperor-treachery' || source.kind === 'guild-shipping' + ? quoteHomeworldPaymentIncome(game, source.native, source.kind === 'guild-shipping' ? 'shipment' : 'treachery', source.amount) + : null; + // Keep the existing two-odd-contributor ruling gate. Exclusion happens before + // comparing rounding; no new transaction-versus-contribution policy is invented. + if (source.kind === 'guild-shipping' && (payment?.low || stable.entitlement || stable.blocked)) { + const eligible = source.payment.contributions.filter(leg => leg.payer !== source.native && leg.amount > 0).map(leg => leg.amount); + requireReceipt(quoteGuildPaymentRounding(source.amount, eligible).unambiguous, + 'multiple-contributor shipping rounding awaits its original ruling.'); + } + const candidate = Math.floor(source.amount / 2); + const blocked = candidate > 0 ? stable.blocked : null; + const entitlement = stable.entitlement ? Object.freeze({ ...stable.entitlement }) : null; + const split = amounts(source, payment?.low ?? false, entitlement !== null || blocked !== null); + const status = blocked ? 'blocked' : split.occupiedAmount > 0 ? 'pending' : 'settled'; + const receipt: OccupiedPercentageReceipt = Object.freeze({ source, turn: game.turn, entitlement, + nativeLow: payment?.low ?? false, ...split, status, blocked, + ownAmount: status === 'settled' ? 0 : null, credits: Object.freeze([]) }); + return { receipt, state: stateWith([...state.receipts, receipt]) }; +} + +/** Return occupied-only credits. Native/bank amounts are final SOURCE legs, not + * additional wallet awards. The engine preserves the original payer debit once, + * reconciles any already-credited native cash (Collection must not credit the + * same total again), and commits actual credits plus the ledger atomically. + * The live original producer proof must be supplied again; frozen amount, + * recipient, source and ally cannot silently follow a changed lot. */ +export function allocateOccupiedPercentageReceipt( + state: OccupiedPercentageState, game: OccupiedPercentageContext, actor: string, + event: string, ownAmount: number, originalSource: OccupiedPercentageSource, +): OccupiedPercentageAllocation { + validateOccupiedPercentageState(state, game); + const index = state.receipts.findIndex(row => row.source.event === event), row = state.receipts[index]; + requireReceipt(row && row.status === 'pending' && row.turn === game.turn && row.entitlement?.occupier === actor, + 'wrong owner, stale event/turn, blocked or replayed receipt.'); + requireReceipt(JSON.stringify(freezeSource(originalSource, game)) === JSON.stringify(row.source), 'original source changed before allocation.'); + const current = quoteStableHomeworldOccupation(game, row.entitlement.card); + requireReceipt(!current.blocked && current.entitlement && JSON.stringify(current.entitlement) === JSON.stringify(row.entitlement), + 'original continuous entitlement or reciprocal ally changed.'); + requireReceipt(whole(ownAmount) && ownAmount <= row.occupiedAmount && + (row.entitlement.ally !== null || ownAmount === row.occupiedAmount), 'invalid occupied income split.'); + const credits = allocation(row.entitlement, row.occupiedAmount, ownAmount); + const receipt: OccupiedPercentageReceipt = Object.freeze({ ...row, status: 'settled', ownAmount, credits }); + return { receipt, credits, state: stateWith(state.receipts.map((prior, i) => i === index ? receipt : prior)) }; +} diff --git a/game/homeworld-stable-occupation.ts b/game/homeworld-stable-occupation.ts index 2694af0c..92714769 100644 --- a/game/homeworld-stable-occupation.ts +++ b/game/homeworld-stable-occupation.ts @@ -22,11 +22,10 @@ export type StableHomeworldOccupationQuote = { }; type SourceWorld = [string, [string, number, number][]]; -/** Printed qualification is not a current-controller shortcut. This fresh - * profile admits only one continuously present qualifier, with no competing - * foreign garrison or restored native high threshold since qualification. - * Departure/turnover/contested/repopulation questions remain unanswered. - * Sources: HOMEWORLD_OCCUPATION_RULES7–33,35–56; original occupied card faces. */ +/** Original source history supplies entitlement, never a current-controller + * shortcut. Advanced uses the authorized supplied rulebook p.22: qualification + * requires sole foreign presence and survives until that occupier leaves. + * Basic retains the publisher-only pending lifecycle guards below. */ export function quoteStableHomeworldOccupation( game: StableHomeworldOccupationContext, cardId: HomeworldId, @@ -45,6 +44,31 @@ export function quoteStableHomeworldOccupation( const world = homeworldForceGroups(context, custody).find(home => home.native === native.id && home.secondary === (cardId === 'salusa_secundus')); if (!world) throw new HomeworldCustodyError('The occupied card lost its original native Homeworld.'); + if (game.advanced) { + let retained: { player: string; event: string } | null = null; + for (const source of history.sources) { + const snapshot = JSON.parse(history.snapshots[source.snapshot]) as SourceWorld[]; + const groups = snapshot.find(row => row[0] === world.id)![1] + .filter(([, normal, elite]) => normal + elite > 0); + if (retained && !groups.some(([id]) => id === retained!.player)) retained = null; + if (!retained && source.cause !== 'setup' && groups.length === 1 && groups[0][0] !== native.id) + retained = { player: groups[0][0], event: source.event }; + } + const present = Object.entries(world.forces).filter(([, forces]) => forces.normal + forces.elite > 0); + if (retained && !present.some(([id]) => id === retained!.player)) retained = null; + if (!retained) { + // A producer may query between a physical write and its semantic + // observation; do not invent a new qualifying event from that write. + return present.length === 1 && present[0][0] !== native.id + ? { entitlement: null, blocked: 'The original Advanced sole-occupation change must be observed before its benefit settles.' } + : none; + } + const occupier = game.players.find(player => player.id === retained!.player)!; + const ally = occupier.ally && game.players.some(player => player.id === occupier.ally && player.ally === occupier.id) + ? occupier.ally : null; + return { entitlement: { world: world.id, card: cardId, native: native.id, occupier: occupier.id, + ally, qualification: retained.event, turn: game.turn, spice: card.occupied.spiceIcons }, blocked: null }; + } const first = history.qualifications.find(f => f.world === world.id); if (!first) return none; const blocked = (detail: string): StableHomeworldOccupationQuote => ({ entitlement: null, diff --git a/game/reference.ts b/game/reference.ts index 95382246..04e2446f 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -3846,9 +3846,10 @@ export const RULE_TOPICS: RuleTopic[] = [ 'When several Terror tokens share a stronghold, Moritani privately selects one for an eligible entry, then chooses whether to reveal it or offer an alliance. The other tokens remain hidden. The board shows the public stack count; refresh preserves the selected token and original arrival.', 'While Junction has high population, Guild may offer another faction transport at half or full price during that faction’s shipment. The recipient can choose exact forces from one territory or Homeworld, including a return to their own world. An allied Homeworld remains forbidden. Half-price totals round up; five forces to a Homeworld cost three spice. The Junction route and offered rate resist Karama.', 'Homeworld shipment, battle and occupation have special rules. Occupiers receive the printed Collection income and card effects. An alliance can gain a special Homeworld victory through high-population Ecaz; this is separate from ordinary stronghold victory.', - 'The fresh local homeworld-occupation profile now connects continuously proved, unambiguous occupation with original selected decks and no other modules. Qualification history must prove one still-present occupier without departure, competing foreigners or restored native high population; a current garrison alone grants nothing. Ambiguous histories retain the source guards, not a new expiry or replacement policy.', - 'In that profile, printed occupied bank income is collected once and may immediately be allocated to a reciprocal ally through the original Collection choice. Actual credit receipts survive refresh; views and replay cannot pay again. Actual shared Homeworld credits feed the existing once-per-phase Giedi bonus. Percentage receipts remain a separate unfinished source.', + 'The fresh local homeworld-occupation profile uses original selected decks and no other modules. Advanced occupation begins with sole foreign presence and its benefits remain until the occupier’s last force leaves, including across contests, native population recovery and later turns. A later sole arrival after departure creates a new source epoch. Basic still guards unresolved lifecycle cases; original history, not a current garrison shortcut, proves entitlement.', + 'Printed occupied bank income is collected once and can be immediately allocated to a reciprocal ally. Separate Kaitain and Junction paid-receipt shares and Richese’s actual net sale income use frozen original invoices without charging the payer again. Southern Hemisphere rounds one completed actual native Collection total, including settled shared and occupied-bank credits; it redistributes rather than collecting twice. Ordinary native income cancellation remains distinct from printed occupied effects.', 'Proved Wallach IX occupation protects the occupier and ally from native Voice; Tleilax prevents native Face Dancers against them; Grumman prevents revealing Terror on their entry. Occupied Salusa removes Sardaukar advantage without changing starred physical identity. These printed effects cannot be restored or canceled with Karama. Other occupied powers and full Homeworld acceptance remain open.', + 'Caladan shares the admitted original Bidding inspection with the occupier, not its ally; native inspection can still be prevented independently. Ix gives the occupier the actual private drawn pool and selection, not unrelated setup or Technology. Richese sets its own method/direction before the occupier chooses only the physical cache card; seller, payment and other decisions stay native. Giedi gives one original purchase bonus to the occupier or its reciprocal ally with real receiver capacity, even when Harkonnen’s purchased card fills its hand.', 'The card collection below is available for reference. Starting a Homeworld game remains disabled while shipment, battles, occupation, economy and faction interactions are being completed.', ], example: @@ -3866,7 +3867,7 @@ export const RULE_TOPICS: RuleTopic[] = [ area: 'Implementation', status: 'Partial', detail: - 'All 13 cards, saved typed custody, supported-deck setup, Emperor movement, native Arrakis shipment sources, revival destinations, low charity/free-revival benefits, Tleilax’s phase-start income penalty and high-Salusa free Sardaukar support are connected. World-to-world invasion, Guild transport from Arrakis and Guild native returns use exact source pools, funding and interception. Homeworld battles connect native bonuses, limited explosion casualties, native-only Traitors and Face Dancers, and Ixian substitution. Junction offers connect sponsored board and Homeworld routes, including own returns, foreign departures and their distinct payments. Caladan foresight, Wallach advisor counts, Ix stronghold movement and Richese token movement now respect native population. High Tupile blocks Worthless sales and Ambassador payouts, including after an interrupted Ghola revival. Kaitain paid discards share the closing Bidding opportunity with CHOAM; Ecaz poison income uses actual physical disposal and current native population. Mandatory winning-card disposal follows winner casualties. Supported low Kaitain and Junction payment receipts preserve the payer’s cost and use current native population; divergent allied rounding remains guarded. Giedi grants its once-per-phase bonus on actual positive desert receipts, including settled Ecaz shares. Low Grumman uses original public entry counts across Terror and competing-reaction checks. Southern and Tleilax preserve the exact eligible revival group through one optional placement, with held income and explicit unresolved destination rules. Fresh stable occupation additionally connects printed bank Collection/immediate ally allocation, actual Giedi Homeworld sources and Wallach/Tleilax/Grumman/Salusa protections. Departure/replacement/contested/repopulation, percentage receipts, other occupied powers and concealed transport remain unfinished; public starts stay disabled.', + 'All 13 cards, saved typed custody, supported-deck setup, Emperor movement, native Arrakis shipment sources, revival destinations, low charity/free-revival benefits, Tleilax’s phase-start income penalty and high-Salusa free Sardaukar support are connected. World-to-world invasion, Guild transport from Arrakis and Guild native returns use exact source pools, funding and interception. Homeworld battles connect native bonuses, limited explosion casualties, native-only Traitors and Face Dancers, and Ixian substitution. Junction offers connect sponsored board and Homeworld routes, including own returns, foreign departures and their distinct payments. Caladan foresight, Wallach advisor counts, Ix stronghold movement and Richese token movement now respect native population. High Tupile blocks Worthless sales and Ambassador payouts, including after an interrupted Ghola revival. Kaitain paid discards share the closing Bidding opportunity with CHOAM; Ecaz poison income uses actual physical disposal and current native population. Mandatory winning-card disposal follows winner casualties. Supported low Kaitain and Junction payment receipts preserve the payer’s cost and use current native population; divergent allied rounding remains guarded. Giedi grants its once-per-phase bonus on actual positive desert receipts, including settled Ecaz shares. Low Grumman uses original public entry counts across Terror and competing-reaction checks. Southern and Tleilax preserve the exact eligible revival group through one optional placement, with held income and explicit unresolved destination rules. Fresh occupation additionally connects bank and percentage receipts, completed Southern Collection, immediate sharing, Caladan inspection, displaced Ix pool, Richese card-only choice with native terms, real Giedi receiver choice and native protections. Advanced retains sole-qualified ownership until last departure; Basic lifecycle, Tupile capacity, exceptional Duke, concealed transport and full/public acceptance remain unfinished.', evidence: [ 'game/homeworld-cards.ts', 'game/homeworld-game.ts', @@ -3893,6 +3894,11 @@ export const RULE_TOPICS: RuleTopic[] = [ 'game/homeworld-payment-income.ts', 'game/homeworld-collection.ts', 'game/homeworld-occupation-history.ts', + 'game/homeworld-stable-occupation.ts', + 'game/homeworld-occupied-income.ts', + 'game/homeworld-occupied-percentage.ts', + 'game/homeworld-occupied-bidding.ts', + 'game/homeworld-occupied-defenses.ts', 'game/tupile-intelligence.ts', 'game/tupile-intelligence-answer.ts', 'game/tupile-intelligence-state.ts', @@ -3909,7 +3915,7 @@ export const RULE_TOPICS: RuleTopic[] = [ area: 'Player controls', status: 'Partial', detail: - 'Card inspection is available in the reference. Development tables display native reserves, explicit Imperial shipment sources, world-to-world shipment and Guild transport from Arrakis with costs and pledged funding, Guild interception, optional Junction half/full offers and typed sponsored transport, transfers between Kaitain and Salusa, charity payment sources, current revival allowances, population-based movement restrictions and one/two-advisor choices, native battle bonuses, Homeworld casualties, replacement controls, Tupile sale explanations, shared end-of-Bidding actions, Ecaz’s private poison-income record, current low-population payment splits, automatic Giedi collection history, count-dependent Ambassador destinations and the optional revived-group placement; starting this module remains unavailable.', + 'Card inspection is available in the reference. Development tables display native reserves, explicit Imperial shipment sources, world-to-world shipment and Guild transport from Arrakis with costs and pledged funding, Guild interception, optional Junction half/full offers and typed sponsored transport, transfers between Kaitain and Salusa, charity payment sources, current revival allowances, population-based movement restrictions and one/two-advisor choices, native battle bonuses, Homeworld casualties, replacement controls, Tupile sale explanations, shared end-of-Bidding actions, Ecaz’s private poison-income record, current low-population payment splits, automatic Giedi collection history, count-dependent Ambassador destinations and the optional revived-group placement. Fresh occupation adds bounded actual percentage allocation, real bonus receiver choice, source-authorized Ix pool/cache controls and separate native Richese terms. Normal starts remain unavailable.', evidence: [ 'components/homeworld-cards.tsx', 'components/homeworld-table.tsx', @@ -3920,20 +3926,25 @@ export const RULE_TOPICS: RuleTopic[] = [ 'components/junction-transport.tsx', 'components/bidding-end.tsx', 'components/ecaz-poison-income.tsx', + 'components/homeworld-occupied-income.tsx', + 'components/homeworld-occupied-percentage.tsx', + 'components/homeworld-occupied-bonus.tsx', + 'components/ix-technology.tsx', + 'components/richese-auctions.tsx', ], }, { area: 'AI', status: 'Partial', detail: - 'Shared public choices supply native shipment counters, Imperial transfers and current low revival allowances. All four profiles use Salusa’s per-type support legality and respect unresolved special-Karama and mid-battle Ghola timing boundaries. All four profiles handle world-to-world shipment, Guild transport from Arrakis, native returns and interception, Homeworld battle choices, typed losses, late defense and supported replacement actions. All four profiles can offer and use Junction transport through public quotes, respect population-based movement restrictions and choose legal Spiritual Advisor quantities. Market choices exclude high-Tupile Worthless sales. All four profiles use eligible unused low-Tupile requests without reading opponents’ private hands or balances. All four profiles use the shared closing opportunity for CHOAM actions, affordable Kaitain discards and readiness. Ecaz income needs no separate claim. Concealed transport, occupation and remaining card-effect strategies are unfinished.', + 'Shared public choices supply native shipment counters, Imperial transfers and current low revival allowances. All four profiles use Salusa’s per-type support legality and respect unresolved special-Karama and mid-battle Ghola timing boundaries. All four profiles handle world-to-world shipment, Guild transport from Arrakis, native returns and interception, Homeworld battle choices, typed losses, late defense and supported replacement actions. All four profiles can offer and use Junction transport through public quotes, respect population-based movement restrictions and choose legal Spiritual Advisor quantities. Market choices exclude high-Tupile Worthless sales. All four profiles use eligible unused low-Tupile requests without reading opponents’ private hands or balances. All four profiles use the shared closing opportunity for CHOAM actions, affordable Kaitain discards and readiness. Ecaz income needs no separate claim. Fresh occupied decisions supply legal bank/percentage allocation, one eligible real bonus receiver, displaced Ix/cache selection and native Richese terms. Concealed transport, remaining card-effect strategy and difficulty calibration are unfinished.', evidence: ['game/homeworld-options.ts', 'game/homeworld-shipment-options.ts', 'game/guild-homeworld-shipment-options.ts', 'game/junction-transport-options.ts'], }, { area: 'Documentation', status: 'Partial', detail: - 'All 26 faces, global rules and source discrepancies are documented. Imperial shipment and movement controls explain sources, costs and unavailable actions. Card-economy notes explain shared closing actions, private poison income, winner-discard ordering and low-population payment splits. Divergent allied rounding and occupation entitlement remain unresolved; full interaction guidance remains in progress.', + 'All 26 faces, global rules and source discrepancies are documented. Imperial shipment and movement controls explain sources, costs and unavailable actions. Card-economy notes explain shared closing actions, private poison income, winner-discard ordering and low-population payment splits. The authorized Advanced sole/last-departure cutover and original occupied economic/Bidding ownership are explicit. Divergent allied rounding, Basic occupation lifecycle and remaining effects stay separate; full interaction guidance remains in progress.', evidence: [ 'docs/HOMEWORLD_COMPONENT_AUDIT.md', 'docs/HOMEWORLD_RULES.md', @@ -3964,7 +3975,7 @@ export const RULE_TOPICS: RuleTopic[] = [ area: 'Verification', status: 'Partial', detail: - 'Tests cover genuine setup with implemented decks, Basic special-counter identity, actual transfer/shipment/revival, saved-state rejection and competing database submissions. Complete Homeworld games and module combinations are not yet verified.', + 'Original setup, typed transfers, native payments, occupied percentages/Bidding/private-lot binding and preserved BG accompaniment have consumer regressions and actual390px controls. The bounded profile completes44/50 genuine samples; four Basic occupation/two CHOAM ruling captures remain. Five original Advanced captures resume at their original seeds, and five fresh six-seat Advanced games finish after the advisor repair. Full Homeworld rules, all combinations, final assurance and deployed acceptance remain unverified.', evidence: [ 'tests/homeworld-custody.test.ts', 'tests/homeworld-cards.test.ts', @@ -4030,6 +4041,13 @@ export const RULE_TOPICS: RuleTopic[] = [ 'tests/tupile-intelligence-engine.test.ts', 'tests/tupile-intelligence-recovery.test.ts', 'tests/homeworld-occupation-history.test.ts', + 'tests/homeworld-occupied-income.test.ts', + 'tests/homeworld-occupied-percentage.test.ts', + 'tests/homeworld-occupied-bidding.test.ts', + 'tests/homeworld-occupied-defenses.test.ts', + 'tests/homeworld-occupation-runtime.test.ts', + 'tests/homeworld-occupied-producers-engine.test.ts', + 'tests/homeworld-occupied-private-lots.test.ts', 'tests/grumman-collection.test.ts', 'tests/grumman-collection-engine.test.ts', 'tests/grumman-collection-controls.test.ts', diff --git a/game/richese-cancellation.ts b/game/richese-cancellation.ts index af7d0cc6..eb6e0f55 100644 --- a/game/richese-cancellation.ts +++ b/game/richese-cancellation.ts @@ -6,6 +6,8 @@ import { RICHESE_CARD_DEFINITIONS } from './richese-cards'; const DECISIONS = { ecazBattleLead: true, homeworldOccupiedIncome: true, + homeworldOccupiedPercentage: true, + homeworldOccupiedBonus: true, leaderSkillVisibility: true, leaderSkillRevival: true, moritaniAssassinate: true, @@ -23,6 +25,7 @@ const DECISIONS = { richeseBlackMarket: true, richeseDeclaration: true, richeseCache: true, + richeseCacheTerms: true, richeseUnbid: true, richeseAllyShipment: true, richeseAllyOpportunity: true, diff --git a/tests/fixture-homeworld-occupied-bidding.ts b/tests/fixture-homeworld-occupied-bidding.ts new file mode 100644 index 00000000..42f9d04e --- /dev/null +++ b/tests/fixture-homeworld-occupied-bidding.ts @@ -0,0 +1,89 @@ +import assert from 'node:assert/strict'; +import { + applyAction, createGame, initializeHomeworldOccupationGameForAudit, + joinGame, newPlayer, viewGame, type Game, +} from '../game/engine'; +import { FACTIONS, type FactionId } from '../game/catalog'; +import { botActions } from '../game/bots'; +import { assertDefenseInventory } from './fixture-homeworld-occupied-defenses'; + +export const biddingPlayer = (game: Game, id: string) => game.players.find(player => player.id === id)!; + +/** Original lobby, selected expansion decks and full original setup pipeline. + * All later conserved positions are labelled controlled fixtures, not gameplay. */ +export function freshBiddingGame(native: FactionId, ally: FactionId = 'emperor', advanced = true): Game { + const roster: [string, FactionId][] = [ + ['native', native], ['occupier', 'guild'], ['ally', ally], + ['observer', 'beneGesserit'], ['fremen', 'fremen'], + ]; + const expansions = [...new Set(roster.map(([, faction]) => + FACTIONS.find(card => card.id === faction)!.expansion))].filter(expansion => expansion !== 'base'); + let game = createGame('OCCUPIEDBIDDING', newPlayer('native', native, native), advanced, expansions); + for (const [id, faction] of roster.slice(1)) joinGame(game, newPlayer(id, id, faction)); + game = applyAction(game, 'native', { type: 'homeworlds', enabled: true }); + for (const player of game.players) game = applyAction(game, player.id, { type: 'ready' }); + game = initializeHomeworldOccupationGameForAudit(game); + for (let step = 0; game.status === 'setup' && step < 100; step++) { + let progressed = false; + for (const player of game.players) { + const view = viewGame(game, player.id); + view.players.find(seat => seat.id === player.id)!.bot = 'Easy'; + const action = botActions(view)[0]; + if (!action) continue; + game = applyAction(game, player.id, action); + progressed = true; + break; + } + assert.ok(progressed, 'original Bidding fixture setup has a legal next choice'); + } + assert.equal(game.status, 'playing'); + assert.equal(game.homeworldOccupationPreview, true); + assert.deepEqual(game.homeworldOccupationHistory!.qualifications, []); + assertDefenseInventory(game); + assert.equal(new Set(biddingPhysicalIds(game)).size, biddingPhysicalIds(game).length); + return game; +} + +/** Live physical ownership only: original knowledge aliases are not cards. */ +export function biddingPhysicalIds(game: Game): string[] { + return [ + ...game.players.flatMap(player => player.hand), ...game.deck, ...game.discard, + ...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []), + ...(game.ixSetupCards ?? []), ...(game.ixAuction?.cards ?? []), + ...(game.ornithopter ? [game.ornithopter.card] : []), + ...(game.auction?.cards.slice(game.auction.index + Number(game.currentAuctionSale?.origin === 'normal')) ?? []), + ].map(card => card.id).sort(); +} + +/** Controlled original-card transfers to stage receiver capacity. No fake + * Treachery card, duplicate or destruction is used to manufacture a full hand. */ +export function stageBiddingHandSize(game: Game, id: string, count: number) { + const original = biddingPhysicalIds(game); + const player = biddingPlayer(game, id); + while (player.hand.length > count) game.deck.push(player.hand.pop()!); + while (player.hand.length < count) { + const card = game.deck.shift() ?? game.discard.shift(); + assert.ok(card, 'capacity fixture requires an original drawable card'); + player.hand.push(card); + } + assert.equal(player.hand.length, count); + assert.deepEqual(biddingPhysicalIds(game), original, 'controlled hand transfer conserves all original physical IDs'); +} + +/** Labelled conserved transfer from original stock, including already-dealt + * cards. Random setup may deal every Karama; never fabricate a replacement. */ +export function stageBiddingKarama(game: Game, id: string) { + const original = biddingPhysicalIds(game), receiver = biddingPlayer(game, id); + const held = receiver.hand.find(card => card.effect === 'karama'); + if (held) return held; + const stock = [game.deck, game.discard, ...game.players.filter(player => player.id !== id).map(player => player.hand)]; + for (const source of stock) { + const index = source.findIndex(card => card.effect === 'karama'); + if (index < 0) continue; + const card = source.splice(index, 1)[0]; + receiver.hand.push(card); + assert.deepEqual(biddingPhysicalIds(game), original); + return card; + } + throw new Error('Original unused fixture stock has no Karama.'); +} diff --git a/tests/fixture-homeworld-occupied-private-lots.ts b/tests/fixture-homeworld-occupied-private-lots.ts new file mode 100644 index 00000000..47076c13 --- /dev/null +++ b/tests/fixture-homeworld-occupied-private-lots.ts @@ -0,0 +1,119 @@ +import assert from 'node:assert/strict'; +import { + applyAction, createGame, initializeHomeworldOccupationGameForAudit, + joinGame, newPlayer, type Game, +} from '../game/engine'; +import { FACTIONS, type FactionId } from '../game/catalog'; +import { + assertDefenseInventory, qualifyDefensePosition, +} from './fixture-homeworld-occupied-defenses'; +import { biddingPhysicalIds, biddingPlayer, stageBiddingKarama } from './fixture-homeworld-occupied-bidding'; +import { advanceOriginal } from './fixture-homeworld-occupied-producers'; + +export const privateLotPlayer = biddingPlayer; +export const privateLotPhysicalIds = biddingPhysicalIds; +export const privateLotReload = (game: Game): Game => JSON.parse(JSON.stringify(game)); + +/** Real fresh audit setup with the originally selected faction decks. Only the + * subsequent conserved qualification/card positions are controlled fixtures. */ +export function freshPrivateLotGame(excludeCardId?: string) { + const roster: [string, FactionId][] = [ + ['nativeRichese', 'richese'], ['nativeAt', 'atreides'], ['occupier', 'guild'], + ['ally', 'emperor'], ['rival', 'beneGesserit'], ['fremen', 'fremen'], + ]; + const expansions = [...new Set(roster.map(([, faction]) => + FACTIONS.find(card => card.id === faction)!.expansion))].filter(expansion => expansion !== 'base'); + let game = createGame('OCCUPIEDPRIVATELOTS', newPlayer('nativeRichese', 'Richese', 'richese'), true, expansions); + for (const [id, faction] of roster.slice(1)) joinGame(game, newPlayer(id, id, faction)); + game = applyAction(game, 'nativeRichese', { type: 'homeworlds', enabled: true }); + for (const player of game.players) game = applyAction(game, player.id, { type: 'ready' }); + game = initializeHomeworldOccupationGameForAudit(game); + game = advanceOriginal(game, game => game.status === 'playing'); + assert.equal(game.homeworldOccupationPreview, true); + assert.deepEqual(game.homeworldOccupationHistory!.qualifications, []); + assertDefenseInventory(game); + const original = privateLotPhysicalIds(game); + assert.equal(new Set(original).size, original.length); + + // Controlled source-helper qualification: original native counters go to + // their own Tanks and one original Guild reserve becomes the sole visitor. + qualifyDefensePosition(game, 'homeworld:atreides'); + privateLotPlayer(game, 'occupier').ally = 'ally'; + privateLotPlayer(game, 'ally').ally = 'occupier'; + + // Original unused physical stock, not a manufactured face or cache alias. + const cardAt = game.deck.findIndex(card => card.effect !== 'karama' && card.id !== excludeCardId); + assert.ok(cardAt >= 0); + const card = game.deck.splice(cardAt, 1)[0]; + privateLotPlayer(game, 'nativeRichese').hand.push(card); + const karama = stageBiddingKarama(game, 'rival'); + assert.deepEqual(privateLotPhysicalIds(game), original); + assertDefenseInventory(game); + + game = advanceOriginal(game, game => game.decision?.kind === 'richeseBlackMarket'); + assert.equal(game.decision!.player, 'nativeRichese'); + game = applyAction(game, 'nativeRichese', { + type: 'decision', event: game.richeseBidding!.event, + card: card.id, method: 'silent', + }); + assert.equal(game.richeseAuction!.source, 'blackMarket'); + assert.equal(game.richeseAuction!.owner, 'nativeRichese'); + assert.equal(game.response?.kind, 'richeseBlackMarket'); + assert.deepEqual(privateLotPhysicalIds(game), original); + return { game, card, karama, original }; +} + +/** Complete only the actual source response, without bot decisions/bids or + * phase-clock mutation. Every original eligible seat passes once. */ +export function passPrivateLotWindow(game: Game, kind: 'richeseBlackMarket' | 'richeseAuction' | 'atreidesAuction'): Game { + assert.equal(game.response?.kind, kind); + for (let step = 0; game.response?.kind === kind && step < game.players.length; step++) { + const actor = game.players.find(player => !game.response!.passed.includes(player.id)); + assert.ok(actor); + game = applyAction(game, actor.id, { type: 'passResponse' }); + } + assert.notEqual(game.response?.kind, kind); + return game; +} + +/** Pay the original funded Silent invoice: native Richese cannot self-bid; + * the Guild buyer pays its own real spice, every other eligible seat bids zero. */ +export function sellPrivateBlackMarketLot(game: Game, amount = 2): Game { + assert.equal(game.response, null); + assert.equal(game.richeseAuction!.source, 'blackMarket'); + assert.equal(game.richeseAuction!.method, 'silent'); + const event = game.richeseAuction!.event; + const eligible = [...game.richeseAuction!.eligible]; + assert.ok(eligible.includes('occupier')); + assert.ok(privateLotPlayer(game, 'occupier').spice >= amount); + for (const actor of eligible) game = applyAction(game, actor, { + type: 'richeseBid', event, amount: actor === 'occupier' ? amount : 0, allyPayment: 0, + }); + assert.equal(game.decision?.kind, 'richeseDeclaration'); + assert.equal(game.richeseBidding!.blackMarketSold, true); + assert.equal(game.currentAuctionSale, null); + assert.equal(game.richeseAuction, null); + return game; +} + +/** Controlled conserved recycle position, NOT a claim that a game effect + * returns Black Market purchases. The next pool/lot is produced by the actual + * native declaration, original draw and original inspection handler. */ +export function recyclePrivatePurchaseIntoOriginalNormalLot(game: Game, cardId: string): Game { + assert.equal(game.decision?.kind, 'richeseDeclaration'); + const original = privateLotPhysicalIds(game); + const buyer = privateLotPlayer(game, 'occupier'); + const at = buyer.hand.findIndex(card => card.id === cardId); + assert.ok(at >= 0); + game.deck.unshift(buyer.hand.splice(at, 1)[0]); + assert.deepEqual(privateLotPhysicalIds(game), original); + game = applyAction(game, 'nativeRichese', { + type: 'decision', event: game.richeseBidding!.event, position: 'last', + }); + game = passPrivateLotWindow(game, 'richeseAuction'); + assert.equal(game.response?.kind, 'atreidesAuction'); + assert.equal(game.auction!.cards[game.auction!.index].id, cardId); + assert.equal(game.auction!.peekKnown, false); + assert.deepEqual(privateLotPhysicalIds(game), original); + return game; +} diff --git a/tests/fixture-homeworld-occupied-producers.ts b/tests/fixture-homeworld-occupied-producers.ts new file mode 100644 index 00000000..36ccb25f --- /dev/null +++ b/tests/fixture-homeworld-occupied-producers.ts @@ -0,0 +1,72 @@ +import assert from 'node:assert/strict'; +import { applyAction, createGame, initializeHomeworldOccupationGameForAudit, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine'; +import { botActions } from '../game/bots'; + +/** Drive original phase/control producers, never synthesize a paid receipt or + * advance the phase clock by assignment. Tests may stop before any choice. */ +export function advanceOriginal(game: Game, stop: (game: Game) => boolean, limit = 600): Game { + for (let step = 0; (game.phaseOpening || !stop(game)) && step < limit; step++) { + let choice: { player: string; action: Action } | undefined; + if (game.phaseOpening) { + const player = game.players.find(player => !game.phaseOpening!.passed.includes(player.id)); + if (player) choice = { player: player.id, action: { type: 'ready' } }; + } else if (game.response) { + const player = game.players.find(player => !game.response!.passed.includes(player.id)); + if (player) choice = { player: player.id, action: { type: 'passResponse' } }; + } + if (!choice) for (const player of game.players) { + const view = viewGame(game, player.id); + view.players.find(seat => seat.id === player.id)!.bot = 'Easy'; + const action = botActions(view)[0]; + if (!action) continue; + if (action.type === 'stormDial') action.amount = 0; + choice = { player: player.id, action }; + break; + } + assert.ok(choice, 'original source progression must have a legal next action'); + game = applyAction(game, choice.player, choice.action); + } + assert.ok(!game.phaseOpening && stop(game), 'original source progression must reach its settled requested boundary'); + return game; +} + +export function buyOriginalNormalLot(game: Game, buyer: string, amount: number): Game { + assert.ok(game.auction && !game.phaseOpening && !game.response && !game.decision); + for (let step = 0; game.auction!.active !== buyer && step < game.players.length; step++) + game = applyAction(game, game.auction!.active, { type: 'passBid' }); + assert.equal(game.auction!.active, buyer); + game = applyAction(game, buyer, { type: 'bid', amount, allyPayment: 0 }); + for (let step = 0; !game.currentAuctionSale && step < game.players.length + 2; step++) { + if (game.decision?.kind === 'auctionPayment') + game = applyAction(game, buyer, { type: 'decision', karama: false }); + else game = applyAction(game, game.auction!.active, { type: 'passBid' }); + } + assert.ok(game.currentAuctionSale, 'the original purchase producer must own its paid invoice'); + return game; +} + +export function cash(game: Game): Record { + return Object.fromEntries(game.players.map(player => [player.id, player.spice])); +} + +/** Fresh original Collection roster includes Ecaz shared legs and a real + * Harkonnen occupier; the ordinary setup inventory is never replaced. */ +export function freshSouthernProducerGame(): Game { + let game = createGame('SOUTHERNPRODUCER', newPlayer('native', 'Fremen', 'fremen'), true, ['ecaz']); + for (const [id, faction] of [['occupier', 'harkonnen'], ['ally', 'guild'], ['ecaz', 'ecaz'], ['atreides', 'atreides']] as const) + joinGame(game, newPlayer(id, id, faction)); + game = applyAction(game, 'native', { type: 'homeworlds', enabled: true }); + for (const player of game.players) game = applyAction(game, player.id, { type: 'ready' }); + game = initializeHomeworldOccupationGameForAudit(game); + return advanceOriginal(game, game => game.status === 'playing'); +} + +/** Original native Guild payment can coexist with a BG accompaniment choice. */ +export function freshGuildAdvisorProducerGame(): Game { + let game = createGame('GUILDADVISORPRODUCER', newPlayer('native', 'Guild', 'guild'), true); + for (const [id, faction] of [['occupier', 'atreides'], ['observer', 'beneGesserit'], ['buyer', 'emperor']] as const) + joinGame(game, newPlayer(id, id, faction)); + game = applyAction(game, 'native', { type: 'homeworlds', enabled: true }); + for (const player of game.players) game = applyAction(game, player.id, { type: 'ready' }); + return advanceOriginal(initializeHomeworldOccupationGameForAudit(game), game => game.status === 'playing'); +} diff --git a/tests/homeworld-occupied-bidding.test.ts b/tests/homeworld-occupied-bidding.test.ts new file mode 100644 index 00000000..23b13258 --- /dev/null +++ b/tests/homeworld-occupied-bidding.test.ts @@ -0,0 +1,281 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { handLimit, type Game } from '../game/engine'; +import type { FactionId } from '../game/catalog'; +import { HOMEWORLD_CARDS, type HomeworldId } from '../game/homeworld-cards'; +import { HomeworldCustodyError } from '../game/homeworld-custody'; +import { + quoteOccupiedBiddingAuthority, requireOccupiedBiddingController, + requireOccupiedBiddingBonusRecipient, OccupiedBiddingError, + type OccupiedBiddingKind, +} from '../game/homeworld-occupied-bidding'; +import { + addDefenseVisitor, assertDefenseInventory, clearDefenseNative, + qualifyDefensePosition, recordDefensePosition, removeDefenseVisitor, +} from './fixture-homeworld-occupied-defenses'; +import { + biddingPhysicalIds, biddingPlayer, freshBiddingGame, stageBiddingHandSize, +} from './fixture-homeworld-occupied-bidding'; + +const effects: Record = { + atreidesInspection: { native: 'atreides', card: 'caladan' }, + ixAuction: { native: 'ixians', card: 'ix' }, + richeseCache: { native: 'richese', card: 'richese' }, + harkonnenBonus: { native: 'harkonnen', card: 'giedi_prime' }, +}; + +void test('Caladan shares native inspection with exactly the original qualifier, never ally-only borrowing', () => { + const game = freshBiddingGame('atreides'); + const originalCards = biddingPhysicalIds(game); + assert.deepEqual(quoteOccupiedBiddingAuthority(game, 'atreidesInspection').inspectionAudience, ['native']); + qualifyDefensePosition(game, 'homeworld:atreides'); + biddingPlayer(game, 'occupier').ally = 'ally'; + biddingPlayer(game, 'ally').ally = 'occupier'; + const before = structuredClone(game); + const shared = quoteOccupiedBiddingAuthority(game, 'atreidesInspection'); + assert.equal(shared.provider, 'native', 'native Atreides still produces the original inspection'); + assert.deepEqual(shared.inspectionAudience, ['native', 'occupier']); + assert.equal(shared.inspectionAudience.includes('ally'), false); + assert.equal(shared.inspectionAudience.includes('observer'), false); + assert.deepEqual(shared.bonusRecipients, [], 'sharing an inspection never awards a card'); + assert.equal(requireOccupiedBiddingController(game, 'atreidesInspection', 'native').provider, 'native'); + assert.equal(requireOccupiedBiddingController(game, 'atreidesInspection', 'occupier').provider, 'native'); + assert.throws(() => requireOccupiedBiddingController(game, 'atreidesInspection', 'ally'), HomeworldCustodyError); + assert.deepEqual(game, before); + assert.deepEqual(biddingPhysicalIds(game), originalCards); + const saved: Game = JSON.parse(JSON.stringify(game)); + assert.deepEqual(quoteOccupiedBiddingAuthority(saved, 'atreidesInspection').inspectionAudience, ['native', 'occupier']); + delete saved.homeworldOccupationPreview; + assert.deepEqual(quoteOccupiedBiddingAuthority(saved, 'atreidesInspection').inspectionAudience, ['native']); +}); + +void test('Ix transfers the one actual selection and pool audience, not native setup or a duplicate peek', () => { + const game = freshBiddingGame('ixians'); + const native = quoteOccupiedBiddingAuthority(game, 'ixAuction'); + assert.equal(native.controller, 'native'); + qualifyDefensePosition(game, 'homeworld:ixians'); + // Labelled conserved original pool: every card leaves its deck once. The + // known receipt is only an alias; quoting cannot draw or return it again. + const originalCards = biddingPhysicalIds(game); + const pool = game.deck.splice(0, 3); + assert.equal(pool.length, 3); + game.ixAuction = { count: 2, cards: pool }; + game.ixAuctionKnown = { turn: game.turn, cards: [...pool] }; + biddingPlayer(game, 'occupier').ally = 'ally'; + biddingPlayer(game, 'ally').ally = 'occupier'; + const before = structuredClone(game); + const occupied = requireOccupiedBiddingController(game, 'ixAuction', 'occupier'); + assert.equal(occupied.provider, 'native'); + assert.deepEqual(occupied.inspectionAudience, ['occupier']); + assert.equal(occupied.inspectionAudience.includes('native'), false, 'native provider has no second pool inspection'); + assert.equal(occupied.inspectionAudience.includes('ally'), false); + assert.throws(() => requireOccupiedBiddingController(game, 'ixAuction', 'native'), OccupiedBiddingError); + assert.throws(() => requireOccupiedBiddingController(game, 'ixAuction', 'ally'), HomeworldCustodyError); + assert.deepEqual(game, before, 'no source draw, sale-count change, selection, return or shuffle is performed by authority'); + assert.deepEqual(biddingPhysicalIds(game), originalCards); + const saved: Game = JSON.parse(JSON.stringify(game)); + assert.equal(requireOccupiedBiddingController(saved, 'ixAuction', 'occupier').provider, 'native'); + delete saved.homeworldOccupationPreview; + assert.equal(requireOccupiedBiddingController(saved, 'ixAuction', 'native').controller, 'native'); + assert.deepEqual(quoteOccupiedBiddingAuthority(saved, 'ixAuction').inspectionAudience, ['native']); +}); + +void test('Richese transfers only original cache choice while native cache custody and seller stay intact', () => { + const game = freshBiddingGame('richese'); + assert.ok(game.richeseCache!.length > 0, 'original setup supplied real cache cards'); + const originalCards = biddingPhysicalIds(game); + const originalCache = structuredClone(game.richeseCache); + qualifyDefensePosition(game, 'homeworld:richese'); + const before = structuredClone(game); + const authority = requireOccupiedBiddingController(game, 'richeseCache', 'occupier'); + assert.equal(authority.provider, 'native', 'choosing cannot become the sale/payment provider'); + assert.deepEqual(authority.inspectionAudience, ['native', 'occupier'], 'native still knows its original cache'); + assert.equal(authority.inspectionAudience.includes('ally'), false); + assert.equal(authority.inspectionAudience.includes('observer'), false); + assert.throws(() => requireOccupiedBiddingController(game, 'richeseCache', 'native'), OccupiedBiddingError); + assert.throws(() => requireOccupiedBiddingController(game, 'richeseCache', 'observer'), HomeworldCustodyError); + assert.deepEqual(game.richeseCache, originalCache); + assert.deepEqual(biddingPhysicalIds(game), originalCards); + assert.deepEqual(game, before, 'authority never removes/offers a cache card or changes native sale terms'); + const saved: Game = JSON.parse(JSON.stringify(game)); + assert.equal(requireOccupiedBiddingController(saved, 'richeseCache', 'occupier').provider, 'native'); + delete saved.homeworldOccupationPreview; + assert.equal(requireOccupiedBiddingController(saved, 'richeseCache', 'native').controller, 'native'); +}); + +void test('Giedi checks genuine four/five/eight-card receiver space, independent of the full native buyer', () => { + const game = freshBiddingGame('harkonnen', 'choam'); + qualifyDefensePosition(game, 'homeworld:harkonnen'); + const originalCards = biddingPhysicalIds(game); + const native = biddingPlayer(game, 'native'); + const occupier = biddingPlayer(game, 'occupier'); + const ally = biddingPlayer(game, 'ally'); + assert.equal(handLimit(occupier), 4); + assert.equal(handLimit(ally), 5); + assert.equal(handLimit(native), 8); + occupier.ally = 'ally'; + ally.ally = 'occupier'; + stageBiddingHandSize(game, 'native', 8); + stageBiddingHandSize(game, 'occupier', 3); + stageBiddingHandSize(game, 'ally', 4); + const before = structuredClone(game); + const both = quoteOccupiedBiddingAuthority(game, 'harkonnenBonus'); + assert.equal(both.provider, 'native'); + assert.equal(both.controller, 'occupier'); + assert.deepEqual(both.bonusRecipients, ['occupier', 'ally'], 'two alternatives for the same physical extra card'); + assert.equal(requireOccupiedBiddingBonusRecipient(game, 'ally').provider, 'native'); + assert.throws(() => requireOccupiedBiddingBonusRecipient(game, 'native'), OccupiedBiddingError); + assert.deepEqual(game, before, 'quoting alternatives never awards either card, let alone two'); + + stageBiddingHandSize(game, 'occupier', 4); + assert.deepEqual(quoteOccupiedBiddingAuthority(game, 'harkonnenBonus').bonusRecipients, ['ally']); + stageBiddingHandSize(game, 'native', 1); + assert.deepEqual(quoteOccupiedBiddingAuthority(game, 'harkonnenBonus').bonusRecipients, ['ally'], 'buyer space cannot replace full receiver space'); + stageBiddingHandSize(game, 'ally', 5); + assert.deepEqual(quoteOccupiedBiddingAuthority(game, 'harkonnenBonus').bonusRecipients, []); + assert.throws(() => requireOccupiedBiddingBonusRecipient(game, 'ally'), HomeworldCustodyError); + + // An eight-card faction can receive only as the current reciprocal ally, + // not automatically as native buyer. Requote against that real limit. + occupier.ally = 'native'; + native.ally = 'occupier'; + stageBiddingHandSize(game, 'native', 7); + assert.deepEqual(quoteOccupiedBiddingAuthority(game, 'harkonnenBonus').bonusRecipients, ['native']); + stageBiddingHandSize(game, 'native', 8); + assert.deepEqual(quoteOccupiedBiddingAuthority(game, 'harkonnenBonus').bonusRecipients, []); + stageBiddingHandSize(game, 'occupier', 3); + occupier.atomicsHandLimitPenalty = true; + assert.equal(handLimit(occupier), 3); + assert.deepEqual(quoteOccupiedBiddingAuthority(game, 'harkonnenBonus').bonusRecipients, [], 'printed base limit cannot bypass an actual capacity penalty'); + assert.deepEqual(biddingPhysicalIds(game), originalCards); + assertDefenseInventory(game); +}); + +void test('Giedi recipient acceptance rechecks reciprocal alliance changes and retains native legacy behavior', () => { + const game = freshBiddingGame('harkonnen', 'choam'); + qualifyDefensePosition(game, 'homeworld:harkonnen'); + const occupier = biddingPlayer(game, 'occupier'); + stageBiddingHandSize(game, 'occupier', 4); + stageBiddingHandSize(game, 'ally', 4); + stageBiddingHandSize(game, 'observer', 3); + occupier.ally = 'ally'; + assert.deepEqual(quoteOccupiedBiddingAuthority(game, 'harkonnenBonus').bonusRecipients, [], 'one-way alliance confers no card'); + biddingPlayer(game, 'ally').ally = 'occupier'; + assert.equal(requireOccupiedBiddingBonusRecipient(game, 'ally').controller, 'occupier'); + occupier.ally = 'observer'; + assert.throws(() => requireOccupiedBiddingBonusRecipient(game, 'ally'), OccupiedBiddingError); + assert.throws(() => requireOccupiedBiddingBonusRecipient(game, 'observer'), OccupiedBiddingError); + biddingPlayer(game, 'observer').ally = 'occupier'; + assert.deepEqual(quoteOccupiedBiddingAuthority(game, 'harkonnenBonus').bonusRecipients, ['observer']); + const saved: Game = JSON.parse(JSON.stringify(game)); + assert.equal(requireOccupiedBiddingBonusRecipient(saved, 'observer').provider, 'native'); + delete saved.homeworldOccupationPreview; + assert.deepEqual(quoteOccupiedBiddingAuthority(saved, 'harkonnenBonus').bonusRecipients, ['native']); + stageBiddingHandSize(saved, 'native', 8); + assert.deepEqual(quoteOccupiedBiddingAuthority(saved, 'harkonnenBonus').bonusRecipients, []); +}); + +void test('Giedi uses original discard refill stock without ever drawing a physical card during a quote', () => { + const game = freshBiddingGame('harkonnen'); + qualifyDefensePosition(game, 'homeworld:harkonnen'); + const originalCards = biddingPhysicalIds(game); + // Conserved exhausted-deck position: the original discarded stock is the + // only possible refill, not a cache/hand/knowledge receipt treated as supply. + game.discard.push(...game.deck.splice(0)); + assert.equal(game.deck.length, 0); + assert.ok(game.discard.length > 0); + const before = structuredClone(game); + const authority = requireOccupiedBiddingBonusRecipient(game, 'occupier'); + assert.equal(authority.bonusStockAvailable, true); + assert.deepEqual(authority.bonusRecipients, ['occupier']); + assert.deepEqual(game, before); + assert.deepEqual(biddingPhysicalIds(game), originalCards); +}); + +for (const [kind, rule] of Object.entries(effects) as [OccupiedBiddingKind, { native: FactionId; card: HomeworldId }][]) { + void test(`${rule.card}: Basic retains publisher turn-boundary qualification rather than a current garrison shortcut`, () => { + const game = freshBiddingGame(rule.native, 'emperor', false); + const world = `homeworld:${rule.native}`; + const high = HOMEWORLD_CARDS.find(card => card.id === rule.card)!.high.reserves.min; + const native = biddingPlayer(game, 'native'); + const originalCards = biddingPhysicalIds(game); + clearDefenseNative(game, world); + native.tanks -= high - 1; + native.reserves += high - 1; + addDefenseVisitor(game, world); + recordDefensePosition(game, 'native-below-high-with-original-foreign-visitor'); + assert.equal(quoteOccupiedBiddingAuthority(game, kind).occupied, false, 'a foreign visitor alongside native forces has no sole-source qualification'); + recordDefensePosition(game, 'original-turn-end-qualifies-foreign-group', 'turnEnd'); + const authority = quoteOccupiedBiddingAuthority(game, kind); + assert.equal(authority.occupied, true); + assert.equal(authority.controller, kind === 'atreidesInspection' ? 'native' : 'occupier'); + assert.equal(authority.provider, 'native'); + assert.equal(authority.blocked, null); + assert.deepEqual(biddingPhysicalIds(game), originalCards); + assertDefenseInventory(game); + }); + + void test(`${rule.card}: Basic pending lifecycle and missing historical sources fail closed`, () => { + const qualified = freshBiddingGame(rule.native, 'emperor', false); + const world = `homeworld:${rule.native}`; + qualifyDefensePosition(qualified, world); + const high = HOMEWORLD_CARDS.find(card => card.id === rule.card)!.high.reserves.min; + for (const mode of ['departure', 'competition', 'replacement', 'repopulation', 'expired', 'missing-history'] as const) { + const game = structuredClone(qualified); + if (mode === 'departure') { + removeDefenseVisitor(game, world); + recordDefensePosition(game, 'original-qualifier-left'); + addDefenseVisitor(game, world); + recordDefensePosition(game, 'same-qualifier-returned-with-no-invented-expiry-rule'); + } else if (mode === 'competition') { + addDefenseVisitor(game, world, 'observer'); + recordDefensePosition(game, 'second-original-foreign-group-arrived'); + removeDefenseVisitor(game, world, 'observer'); + recordDefensePosition(game, 'contest-cleared-with-history-retained'); + } else if (mode === 'replacement') { + removeDefenseVisitor(game, world); + recordDefensePosition(game, 'original-qualifier-departed-before-turnover'); + addDefenseVisitor(game, world, 'observer'); + recordDefensePosition(game, 'different-original-sole-qualifier-arrived'); + } else if (mode === 'repopulation') { + const native = biddingPlayer(game, 'native'); + native.tanks -= high; + native.reserves += high; + recordDefensePosition(game, 'native-original-high-threshold-restored'); + clearDefenseNative(game, world); + recordDefensePosition(game, 'native-fell-low-after-restoration'); + } else if (mode === 'expired') { + game.turn++; + } else delete game.homeworldOccupationHistory; + const before = structuredClone(game); + const blocked = quoteOccupiedBiddingAuthority(game, kind); + assert.notEqual(blocked.blocked, null); + assert.equal(blocked.provider, 'native', 'source provider is not reassigned even when effect must wait'); + assert.equal(blocked.controller, null); + assert.deepEqual(blocked.inspectionAudience, [], 'uncertain history never leaks an original private pool/card'); + assert.deepEqual(blocked.bonusRecipients, []); + assert.throws(() => requireOccupiedBiddingController(game, kind, 'native'), HomeworldCustodyError); + assert.throws(() => requireOccupiedBiddingController(game, kind, 'occupier'), HomeworldCustodyError); + if (kind === 'harkonnenBonus') + assert.throws(() => requireOccupiedBiddingBonusRecipient(game, 'occupier'), HomeworldCustodyError); + assert.deepEqual(game, before); + assertDefenseInventory(game); + const saved: Game = JSON.parse(JSON.stringify(game)); + assert.equal(quoteOccupiedBiddingAuthority(saved, kind).controller, null); + assert.notEqual(quoteOccupiedBiddingAuthority(saved, kind).blocked, null); + delete saved.homeworldOccupationPreview; + const legacy = quoteOccupiedBiddingAuthority(saved, kind); + assert.equal(legacy.controller, 'native'); + assert.equal(legacy.blocked, null, 'outside fresh preview does not convert or gate native saved-game rules'); + } + }); +} + +void test('tampered original qualification evidence is a custody-domain error, never private permission', () => { + const game = freshBiddingGame('ixians'); + qualifyDefensePosition(game, 'homeworld:ixians'); + game.homeworldOccupationHistory!.qualifications[0].player = 'observer'; + const before = structuredClone(game); + assert.throws(() => quoteOccupiedBiddingAuthority(game, 'ixAuction'), HomeworldCustodyError); + assert.deepEqual(game, before); +}); diff --git a/tests/homeworld-occupied-defenses.test.ts b/tests/homeworld-occupied-defenses.test.ts index f755bd0b..c4b80aff 100644 --- a/tests/homeworld-occupied-defenses.test.ts +++ b/tests/homeworld-occupied-defenses.test.ts @@ -146,7 +146,7 @@ const ambiguityRules: Record { + void test(`${effect}: Advanced retains the original occupier until departure and admits a newly sole replacement`, () => { const qualified = freshDefenseGame(rule.native); qualifyDefensePosition(qualified, rule.world); for (const mode of ['departed', 'contested', 'competing', 'restored-high', 'expired'] as const) { @@ -184,12 +184,13 @@ for (const [effect, rule] of Object.entries(ambiguityRules)) { } const before = structuredClone(game); const actual = rule.quote(game); - assert.notEqual(actual.blocked, null, 'an ambiguous beneficiary cannot authorize the native effect'); - if (effect !== 'sardaukar') assert.equal(actual.status, 'unresolved'); - if (effect === 'sardaukar') assert.equal(actual.suppressed, false, 'blocked is not selected strength'); + if (effect !== 'sardaukar' && mode === 'competing') assert.notEqual(actual.blocked, null); + else assert.equal(actual.blocked, null); + if (effect !== 'sardaukar') assert.equal(actual.status, mode === 'competing' ? 'prohibited' : 'allowed'); + if (effect === 'sardaukar') assert.equal(actual.suppressed, true); assert.deepEqual(game, before); const saved: Game = JSON.parse(JSON.stringify(game)); - assert.deepEqual(rule.quote(saved), actual, 'original JSON history retains ambiguity'); + assert.deepEqual(rule.quote(saved), actual, 'original JSON history retains the Advanced beneficiary'); assertDefenseInventory(saved); delete saved.homeworldOccupationPreview; assert.deepEqual(rule.quote(saved), effect === 'sardaukar' diff --git a/tests/homeworld-occupied-income.test.ts b/tests/homeworld-occupied-income.test.ts index 18083104..43009776 100644 --- a/tests/homeworld-occupied-income.test.ts +++ b/tests/homeworld-occupied-income.test.ts @@ -202,7 +202,7 @@ void test('restoring the native high threshold after qualification blocks the pe assert.deepEqual({ game, state }, before); }); -void test('zero-icon contested Salusa cannot block a real Kaitain bank award or choose a Sardaukar strength', () => { +void test('Advanced contested zero-icon Salusa retains its original strength penalty without blocking Kaitain income', () => { const { game } = occupiedIncomeFixture('kaitain', true, false); const secondary = 'homeworld:emperor:salusa'; const owner = game.players.find(p => p.id === 'owner')!; @@ -212,13 +212,52 @@ void test('zero-icon contested Salusa cannot block a real Kaitain bank award or game.players.find(p => p.id === 'competitor')!.reserves--; game.homeworlds!.custody!.visitors[secondary].competitor = { normal: 1, elite: 0 }; observeControlledIncomePosition(game, 'controlled-secondary-contested'); - assert.notEqual(occupiedHomeworldSardaukarStatus(game).blocked, null); + assert.deepEqual(occupiedHomeworldSardaukarStatus(game), { suppressed: true, blocked: null }); const before = owner.spice; const pending = beginHomeworldOccupiedIncome(game, 'collection-positive-primary-zero-secondary'); const result = settle(game, pending, 2); assert.equal(owner.spice - before, 2); - assert.equal(result.state.cursor, result.state.queue.length); - assert.equal(occupiedHomeworldSardaukarStatus(game).suppressed, false); - assert.notEqual(occupiedHomeworldSardaukarStatus(game).blocked, null, - 'Collection does not guess the still-unresolved mandatory combat penalty.'); + const zero = settle(game, result.state, 0); + assert.equal(zero.state.cursor, zero.state.queue.length); + assert.equal(owner.spice - before, 2); + assert.deepEqual(occupiedHomeworldSardaukarStatus(game), { suppressed: true, blocked: null }); +}); + +void test('Advanced bank owner survives native return, foreign contest and turn change but expires at its last departure', () => { + const { game, world } = occupiedIncomeFixture('caladan', true, false); + const owner = game.players.find(player => player.id === 'owner')!; + const native = game.players.find(player => player.id === 'native')!; + const competitor = game.players.find(player => player.id === 'competitor')!; + const initial = beginHomeworldOccupiedIncome(game, 'advanced-original-collection'); + const qualification = initial.queue[0].qualification; + native.tanks -= 6; + native.reserves += 6; + game.homeworlds!.custody!.visitors[world].competitor = { normal: 1, elite: 0 }; + competitor.reserves--; + observeControlledIncomePosition(game, 'advanced-native-high-and-foreign-contest'); + assert.equal(homeworldOccupiedIncomeOffer(initial, game, owner.id)!.blocked, null); + const before = owner.spice; + settle(game, initial, 2); + assert.equal(owner.spice - before, 2); + game.turn++; + observeControlledIncomePosition(game, 'advanced-next-turn-original-owner-remains'); + const retained = beginHomeworldOccupiedIncome(game, 'advanced-next-collection'); + assert.equal(retained.queue[0].occupier, owner.id); + assert.equal(retained.queue[0].qualification, qualification); + delete game.homeworlds!.custody!.visitors[world].owner; + owner.reserves++; + observeControlledIncomePosition(game, 'advanced-last-original-force-left'); + assert.equal(beginHomeworldOccupiedIncome(game, 'advanced-no-sole-successor').queue.length, 0); + assert.throws(() => quoteHomeworldOccupiedIncomeChoice(retained, game, owner.id, retained.event, world, 2), HomeworldCustodyError); + native.tanks += native.reserves; + native.reserves = 0; + observeControlledIncomePosition(game, 'advanced-successor-alone'); + const replacement = beginHomeworldOccupiedIncome(game, 'advanced-successor-collection'); + assert.equal(replacement.queue[0].occupier, competitor.id); + assert.notEqual(replacement.queue[0].qualification, qualification); + const successorBalance = competitor.spice; + settle(game, replacement, 2); + assert.equal(competitor.spice - successorBalance, 2); + assert.equal(owner.spice - before, 2); + homeworldGameIntegrity(game); }); diff --git a/tests/homeworld-occupied-percentage.test.ts b/tests/homeworld-occupied-percentage.test.ts new file mode 100644 index 00000000..33e1981d --- /dev/null +++ b/tests/homeworld-occupied-percentage.test.ts @@ -0,0 +1,382 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import type { Game } from '../game/engine'; +import { HomeworldCustodyError } from '../game/homeworld-custody'; +import { homeworldGameIntegrity } from '../game/homeworld-game'; +import { + allocateOccupiedPercentageReceipt, createOccupiedPercentageState, quoteOccupiedPercentageSource, + validateOccupiedPercentageState, + type OccupiedPercentageKind, type OccupiedPercentageQuote, type OccupiedPercentageSource, + type OccupiedPercentageState, +} from '../game/homeworld-occupied-percentage'; +import { occupiedIncomeFixture as originalIncomeFixture, observeControlledIncomePosition } from './fixture-homeworld-occupied-income'; +import type { HomeworldId } from '../game/homeworld-cards'; + +const worlds: Record = { + 'emperor-treachery': 'kaitain', 'guild-shipping': 'junction', + 'richese-income': 'richese', 'fremen-collection': 'southern_hemisphere', +}; +/** Labelled cash-only fixture redistribution conserves the original setup total + * and keeps every payer solvent. Physical cards/forces/history remain original. */ +function occupiedIncomeFixture(...args: Parameters) { + const fixture = originalIncomeFixture(...args), game = fixture.game; + const before = game.players.reduce((sum, player) => sum + player.spice, 0); + const minimum: Record = { native: 5, owner: 5, ally: 0, competitor: 11 }; + for (const receiver of game.players) { + for (const donor of game.players) { + if (receiver.id === donor.id) continue; + const transfer = Math.min(Math.max(0, minimum[receiver.id] - receiver.spice), + Math.max(0, donor.spice - minimum[donor.id])); + donor.spice -= transfer; + receiver.spice += transfer; + } + assert.ok(receiver.spice >= minimum[receiver.id], 'Original setup cash must fund the labelled source.'); + } + assert.equal(game.players.reduce((sum, player) => sum + player.spice, 0), before); + return fixture; +} +function actualSource(kind: OccupiedPercentageKind, amount: number, event = 'actual-source'): OccupiedPercentageSource { + const base = { event, native: 'native', amount }; + if (kind === 'fremen-collection') return { ...base, kind, collection: { + id: event, binding: 'completed original Collection credit proof', complete: true, + collected: [{ id: 'actual-credit', amount }], + } }; + const payment = { id: event, binding: 'original funded producer receipt', paid: amount, + contributions: [{ payer: 'competitor', amount }], recipient: 'native' }; + if (kind === 'richese-income') return { ...base, kind, payment: { ...payment, received: amount } }; + return { ...base, kind, payment, nativeIncome: 'received' }; +} +function balances(game: Game): Record { + return Object.fromEntries(game.players.map(player => [player.id, player.spice])); +} +/** Exercise exactly the parent's cash contract against real setup wallets: + * payer debits occur before admission, then native/occupied cash is committed. + * bankRetained stays in the Bank; quote/allocation never change any wallet. */ +function commit(game: Game, quote: OccupiedPercentageQuote, ownAmount = quote.receipt.occupiedAmount) { + const before = structuredClone({ game, state: quote.state }); + const result = quote.receipt.status === 'pending' + ? allocateOccupiedPercentageReceipt(quote.state, game, 'owner', quote.receipt.source.event, ownAmount, quote.receipt.source) + : { ...quote, credits: quote.receipt.credits }; + assert.deepEqual({ game, state: quote.state }, before); + assert.equal(result.receipt.status, 'settled'); + game.players.find(player => player.id === result.receipt.source.native)!.spice += result.receipt.nativeRetained; + for (const credit of result.credits) game.players.find(player => player.id === credit.player)!.spice += credit.amount; + return result; +} + +void test('each printed producer conserves odd/even/zero actual sources without touching original payer or physical custody', () => { + for (const kind of Object.keys(worlds) as OccupiedPercentageKind[]) { + for (const amount of [0, 1, 5, 8]) { + const { game, cards } = occupiedIncomeFixture(worlds[kind], false, false); + const original = actualSource(kind, amount), initial = balances(game); + if (kind !== 'fremen-collection') game.players.find(player => player.id === 'competitor')!.spice -= amount; + const paid = structuredClone({ game, original }); + const quote = quoteOccupiedPercentageSource(game, original, createOccupiedPercentageState()); + assert.deepEqual({ game, original }, paid, 'admission never charges a payer again'); + const done = commit(game, quote); + const after = balances(game); + assert.equal(after.native - initial.native, Math.ceil(amount / 2)); + assert.equal(after.owner - initial.owner, Math.floor(amount / 2)); + assert.equal(after.ally - initial.ally, 0); + assert.equal(after.competitor, initial.competitor - (kind === 'fremen-collection' ? 0 : amount)); + assert.equal(done.receipt.nativeRetained + done.credits.reduce((sum, credit) => sum + credit.amount, 0) + + done.receipt.bankRetained, amount); + assert.equal(Object.values(after).reduce((sum, cash) => sum + cash, 0) - + Object.values(initial).reduce((sum, cash) => sum + cash, 0), kind === 'fremen-collection' ? amount : 0); + assert.deepEqual([...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand)].map(card => card.id).sort(), cards); + homeworldGameIntegrity(game); + } + } +}); + +void test('Kaitain FAQ actual five-spice occupier purchase is debited once and returns three/two, including immediate ally splits', () => { + for (const ownAmount of [2, 1, 0]) { + const { game } = occupiedIncomeFixture('kaitain'); + const original = actualSource('emperor-treachery', 5); + assert.equal(original.kind, 'emperor-treachery'); + if (original.kind !== 'emperor-treachery') throw new Error('Fixture source kind'); + const source: OccupiedPercentageSource = { ...original, + payment: { ...original.payment, contributions: [{ payer: 'owner', amount: 5 }] } }; + const before = balances(game); + game.players.find(player => player.id === 'owner')!.spice -= 5; + const quote = quoteOccupiedPercentageSource(game, source, createOccupiedPercentageState()); + const done = commit(game, quote, ownAmount), after = balances(game); + assert.equal(after.native - before.native, 3); + assert.equal(after.owner - before.owner, -5 + ownAmount); + assert.equal(after.ally - before.ally, 2 - ownAmount); + assert.equal(done.credits.reduce((sum, credit) => sum + credit.amount, 0), 2); + assert.equal(Object.values(after).reduce((sum, cash) => sum + cash, 0), Object.values(before).reduce((sum, cash) => sum + cash, 0)); + } +}); + +void test('Junction excludes actual Guild funding before rounding; Guild-only payment has no allocation', () => { + for (const otherAmount of [0, 2, 5]) { + const { game } = occupiedIncomeFixture('junction', false, false); + const original = actualSource('guild-shipping', otherAmount); + if (original.kind !== 'guild-shipping') throw new Error('Fixture source kind'); + const source: OccupiedPercentageSource = { ...original, payment: { ...original.payment, paid: otherAmount + 5, + contributions: [{ payer: 'native', amount: 5 }, { payer: 'competitor', amount: otherAmount }] } }; + const before = balances(game); + game.players.find(player => player.id === 'native')!.spice -= 5; + game.players.find(player => player.id === 'competitor')!.spice -= otherAmount; + const quote = quoteOccupiedPercentageSource(game, source, createOccupiedPercentageState()); + const done = commit(game, quote), after = balances(game); + assert.equal(after.native - before.native, -5 + Math.ceil(otherAmount / 2)); + assert.equal(after.owner - before.owner, Math.floor(otherAmount / 2)); + assert.equal(after.competitor, before.competitor - otherAmount); + assert.equal(Object.values(after).reduce((sum, cash) => sum + cash, 0) - Object.values(before).reduce((sum, cash) => sum + cash, 0), -5); + if (otherAmount === 0) { + assert.equal(quote.receipt.status, 'settled'); + assert.deepEqual(done.credits, []); + assert.throws(() => allocateOccupiedPercentageReceipt(done.state, game, 'owner', source.event, 0, source), HomeworldCustodyError); + } + } +}); + +void test('Guild unresolved two odd contributions remain blocked, while a conserved even/odd original payment is reachable', () => { + const { game } = occupiedIncomeFixture('junction', false, false); + const original = actualSource('guild-shipping', 4); + if (original.kind !== 'guild-shipping') throw new Error('Fixture source kind'); + const twoOdd: OccupiedPercentageSource = { ...original, payment: { ...original.payment, + contributions: [{ payer: 'owner', amount: 1 }, { payer: 'competitor', amount: 3 }] } }; + const before = structuredClone(game); + assert.throws(() => quoteOccupiedPercentageSource(game, twoOdd, createOccupiedPercentageState()), HomeworldCustodyError); + assert.deepEqual(game, before); + const supported: OccupiedPercentageSource = { ...original, amount: 5, payment: { ...original.payment, paid: 5, + contributions: [{ payer: 'owner', amount: 2 }, { payer: 'competitor', amount: 3 }] } }; + const initial = balances(game); + game.players.find(player => player.id === 'owner')!.spice -= 2; + game.players.find(player => player.id === 'competitor')!.spice -= 3; + commit(game, quoteOccupiedPercentageSource(game, supported, createOccupiedPercentageState())); + const after = balances(game); + assert.equal(after.native - initial.native, 3); + assert.equal(after.owner - initial.owner, 0); + assert.equal(after.competitor - initial.competitor, -3); +}); + +void test('Richese splits only actual native net received income, not gross sale or money routed elsewhere', () => { + const { game } = occupiedIncomeFixture('richese', false, false); + const original = actualSource('richese-income', 7); + if (original.kind !== 'richese-income') throw new Error('Fixture source kind'); + const source: OccupiedPercentageSource = { ...original, payment: { ...original.payment, paid: 10, + contributions: [{ payer: 'competitor', amount: 10 }], received: 7 } }; + const before = balances(game); + game.players.find(player => player.id === 'competitor')!.spice -= 10; + // The original producer, not this percentage helper, routed the other three. + game.players.find(player => player.id === 'ally')!.spice += 3; + commit(game, quoteOccupiedPercentageSource(game, source, createOccupiedPercentageState())); + const after = balances(game); + assert.equal(after.native - before.native, 4); + assert.equal(after.owner - before.owner, 3); + assert.equal(after.ally - before.ally, 3); + assert.equal(after.competitor - before.competitor, -10); + assert.equal(Object.values(after).reduce((sum, cash) => sum + cash, 0), Object.values(before).reduce((sum, cash) => sum + cash, 0)); + for (const wrong of [ + { ...source, amount: 10 }, + { ...source, payment: { ...source.payment, recipient: 'owner' } }, + { ...source, payment: { ...source.payment, received: 11 } }, + ]) assert.throws(() => quoteOccupiedPercentageSource(game, wrong, createOccupiedPercentageState()), HomeworldCustodyError); +}); + +void test('Southern Hemisphere rounds the completed actual Collection total once, not odd piles independently', () => { + const { game } = occupiedIncomeFixture('southern_hemisphere'); + const source: OccupiedPercentageSource = { kind: 'fremen-collection', native: 'native', event: 'collection-total', amount: 5, + collection: { id: 'collection-total', binding: 'all settled native Collection legs', complete: true, + collected: [{ id: 'first-pile', amount: 1 }, { id: 'shared-allocation', amount: 1 }, { id: 'last-credit', amount: 3 }] } }; + const before = balances(game); + const quote = quoteOccupiedPercentageSource(game, source, createOccupiedPercentageState()); + const done = commit(game, quote, 1), after = balances(game); + assert.equal(after.native - before.native, 3); + assert.equal(after.owner - before.owner, 1); + assert.equal(after.ally - before.ally, 1); + assert.equal(Object.values(after).reduce((sum, cash) => sum + cash, 0) - Object.values(before).reduce((sum, cash) => sum + cash, 0), 5); + const another = actualSource('fremen-collection', 3, 'another-pile-event'); + assert.throws(() => quoteOccupiedPercentageSource(game, another, done.state), HomeworldCustodyError); + const wrongTotal = { ...source, amount: 3 }; + assert.throws(() => quoteOccupiedPercentageSource(game, wrongTotal, createOccupiedPercentageState()), HomeworldCustodyError); + const unfinished = { ...source, collection: { ...source.collection, complete: false } } as unknown as OccupiedPercentageSource; + assert.throws(() => quoteOccupiedPercentageSource(game, unfinished, createOccupiedPercentageState()), HomeworldCustodyError); +}); + +void test('native Karama does not cancel printed occupied percentage receipts or manufacture native/bank cash', () => { + for (const kind of ['emperor-treachery', 'guild-shipping'] as const) { + const { game } = occupiedIncomeFixture(worlds[kind], false, false); + const original = actualSource(kind, 5); + if (original.kind !== 'emperor-treachery' && original.kind !== 'guild-shipping') throw new Error('Fixture source kind'); + const source: OccupiedPercentageSource = { ...original, nativeIncome: 'karama-canceled' }; + const before = balances(game); + game.players.find(player => player.id === 'competitor')!.spice -= 5; + const done = commit(game, quoteOccupiedPercentageSource(game, source, createOccupiedPercentageState())); + const after = balances(game); + assert.equal(after.native - before.native, 0); + assert.equal(after.owner - before.owner, 2); + assert.equal(after.competitor - before.competitor, -5); + assert.equal(done.receipt.bankRetained, 3); + assert.equal(Object.values(after).reduce((sum, cash) => sum + cash, 0) - Object.values(before).reduce((sum, cash) => sum + cash, 0), -3); + } +}); + +void test('outside the original fresh occupation profile physical-low Kaitain/Junction keep their existing bank remainder', () => { + for (const kind of ['emperor-treachery', 'guild-shipping'] as const) { + const { game } = occupiedIncomeFixture(worlds[kind]); + delete game.homeworldOccupationPreview; + const source = actualSource(kind, 5), before = balances(game); + game.players.find(player => player.id === 'competitor')!.spice -= 5; + const quote = quoteOccupiedPercentageSource(game, source, createOccupiedPercentageState()); + assert.equal(quote.receipt.status, 'settled'); + const done = commit(game, quote), after = balances(game); + assert.equal(after.native - before.native, 3); + assert.equal(after.owner - before.owner, 0); + assert.equal(after.ally - before.ally, 0); + assert.equal(done.receipt.bankRetained, 2); + assert.equal(Object.values(after).reduce((sum, cash) => sum + cash, 0) - Object.values(before).reduce((sum, cash) => sum + cash, 0), -2); + } +}); + +void test('JSON pending and completed ledger preserve source/beneficiary and reject re-admission, replay and changed provenance', () => { + const { game } = occupiedIncomeFixture('richese'); + const source = actualSource('richese-income', 11), initial = balances(game); + game.players.find(player => player.id === 'competitor')!.spice -= 11; + const quote = quoteOccupiedPercentageSource(game, source, createOccupiedPercentageState()); + const resumedGame: Game = JSON.parse(JSON.stringify(game)); + const pending: OccupiedPercentageState = JSON.parse(JSON.stringify(quote.state)); + const before = structuredClone({ game: resumedGame, state: pending }); + assert.throws(() => quoteOccupiedPercentageSource(resumedGame, source, pending), HomeworldCustodyError); + assert.throws(() => allocateOccupiedPercentageReceipt(pending, resumedGame, 'owner', source.event, 3, + { ...source, event: 'different-source' }), HomeworldCustodyError); + if (source.kind !== 'richese-income') throw new Error('Fixture source kind'); + for (const changed of [ + { ...source, payment: { ...source.payment, binding: 'different original lot' } }, + { ...source, payment: { ...source.payment, contributions: [{ payer: 'owner', amount: 11 }] } }, + { ...source, amount: 9, payment: { ...source.payment, received: 9 } }, + ]) assert.throws(() => allocateOccupiedPercentageReceipt(pending, resumedGame, 'owner', source.event, 3, changed), HomeworldCustodyError); + assert.deepEqual({ game: resumedGame, state: pending }, before); + const done = commit(resumedGame, { receipt: pending.receipts[0], state: pending }, 3); + const after = balances(resumedGame); + assert.equal(after.native - initial.native, 6); + assert.equal(after.owner - initial.owner, 3); + assert.equal(after.ally - initial.ally, 2); + const settled: OccupiedPercentageState = JSON.parse(JSON.stringify(done.state)); + validateOccupiedPercentageState(settled, resumedGame); + const stableCash = balances(resumedGame); + assert.throws(() => allocateOccupiedPercentageReceipt(settled, resumedGame, 'owner', source.event, 3, source), HomeworldCustodyError); + assert.throws(() => quoteOccupiedPercentageSource(resumedGame, { ...source, event: 'new-event-same-payment' }, settled), HomeworldCustodyError); + assert.deepEqual(balances(resumedGame), stableCash); + const tampered: OccupiedPercentageState = { ...done.state, receipts: done.state.receipts.map((row, i) => + i === 0 ? { ...row, nativeRetained: row.nativeRetained + 1 } : row) }; + assert.throws(() => validateOccupiedPercentageState(tampered, resumedGame), HomeworldCustodyError); +}); + +void test('wrong actor/event/turn and invalid splits cannot consume a pending percentage receipt', () => { + for (const allied of [false, true]) { + const { game } = occupiedIncomeFixture('kaitain', false, allied); + const source = actualSource('emperor-treachery', 9); + const quote = quoteOccupiedPercentageSource(game, source, createOccupiedPercentageState()); + const before = structuredClone({ game, state: quote.state }); + for (const actor of ['native', 'ally', 'competitor']) + assert.throws(() => allocateOccupiedPercentageReceipt(quote.state, game, actor, source.event, 4, source), HomeworldCustodyError); + assert.throws(() => allocateOccupiedPercentageReceipt(quote.state, game, 'owner', 'stale-event', 4, source), HomeworldCustodyError); + for (const amount of [-1, 5, 1.5, NaN, Infinity, ...(allied ? [] : [0, 3])]) + assert.throws(() => allocateOccupiedPercentageReceipt(quote.state, game, 'owner', source.event, amount, source), HomeworldCustodyError); + assert.deepEqual({ game, state: quote.state }, before); + const stale = structuredClone(game); + stale.turn++; + assert.throws(() => allocateOccupiedPercentageReceipt(quote.state, stale, 'owner', source.event, 4, source), HomeworldCustodyError); + const initial = balances(game); + commit(game, quote, 4); + assert.equal(balances(game).owner - initial.owner, 4); + assert.equal(balances(game).native - initial.native, 5); + } +}); + +void test('changed reciprocal alliance or continuous entitlement cannot replace the original frozen recipient', () => { + for (const change of ['alliance', 'departure-return', 'competitor', 'repopulation'] as const) { + const { game, world } = occupiedIncomeFixture('richese'); + const source = actualSource('richese-income', 7); + const quote = quoteOccupiedPercentageSource(game, source, createOccupiedPercentageState()); + const owner = game.players.find(player => player.id === 'owner')!; + if (change === 'alliance') game.players.find(player => player.id === 'ally')!.ally = null; + else if (change === 'departure-return') { + delete game.homeworlds!.custody!.visitors[world]; + owner.reserves++; + observeControlledIncomePosition(game, 'labelled-original-owner-departure'); + game.homeworlds!.custody!.visitors[world] = { owner: { normal: 1, elite: 0 } }; + owner.reserves--; + observeControlledIncomePosition(game, 'labelled-original-owner-return'); + } else if (change === 'competitor') { + game.homeworlds!.custody!.visitors[world].competitor = { normal: 1, elite: 0 }; + game.players.find(player => player.id === 'competitor')!.reserves--; + observeControlledIncomePosition(game, 'labelled-second-foreign-garrison'); + } else { + const native = game.players.find(player => player.id === 'native')!; + native.reserves = 10; + native.tanks -= 10; + observeControlledIncomePosition(game, 'labelled-native-high-restored'); + } + const before = structuredClone({ game, state: quote.state }); + assert.throws(() => allocateOccupiedPercentageReceipt(quote.state, game, 'owner', source.event, 2, source), HomeworldCustodyError); + assert.deepEqual({ game, state: quote.state }, before); + if (change !== 'alliance') { + const unknown = quoteOccupiedPercentageSource(game, { ...source, event: 'blocked-new-actual-source' }, createOccupiedPercentageState()); + assert.equal(unknown.receipt.status, 'blocked'); + assert.equal(unknown.receipt.entitlement, null); + assert.deepEqual(unknown.receipt.credits, []); + assert.deepEqual(balances(game), balances(before.game)); + assert.throws(() => allocateOccupiedPercentageReceipt(unknown.state, game, 'competitor', unknown.receipt.source.event, 3, + unknown.receipt.source), HomeworldCustodyError); + } + homeworldGameIntegrity(game); + } +}); + +void test('zero-share unknown entitlement completes through JSON without selecting a replacement or needing allocation', () => { + const { game, world } = occupiedIncomeFixture('southern_hemisphere'); + game.homeworlds!.custody!.visitors[world].competitor = { normal: 1, elite: 0 }; + game.players.find(player => player.id === 'competitor')!.reserves--; + observeControlledIncomePosition(game, 'labelled-zero-contested-world'); + for (const amount of [0, 1]) { + const source = actualSource('fremen-collection', amount), before = balances(game); + const quote = quoteOccupiedPercentageSource(game, source, createOccupiedPercentageState()); + const saved: OccupiedPercentageState = JSON.parse(JSON.stringify(quote.state)); + validateOccupiedPercentageState(saved, game); + assert.equal(saved.receipts[0].status, 'settled'); + assert.equal(saved.receipts[0].entitlement, null); + commit(game, { receipt: saved.receipts[0], state: saved }); + const after = balances(game); + assert.equal(after.native - before.native, amount); + assert.equal(after.owner - before.owner, 0); + assert.equal(after.ally - before.ally, 0); + assert.equal(after.competitor - before.competitor, 0); + assert.throws(() => allocateOccupiedPercentageReceipt(saved, game, 'owner', source.event, 0, source), HomeworldCustodyError); + assert.throws(() => quoteOccupiedPercentageSource(game, source, saved), HomeworldCustodyError); + } +}); + +void test('malformed or unbacked paid provenance cannot manufacture a percentage source, including safe-integer limits', () => { + const { game } = occupiedIncomeFixture('kaitain'); + const original = actualSource('emperor-treachery', 5); + if (original.kind !== 'emperor-treachery') throw new Error('Fixture source kind'); + const state = createOccupiedPercentageState(), before = structuredClone({ game, state }); + const malformed: OccupiedPercentageSource[] = [ + ...[-1, 0.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1].map(amount => ({ ...original, amount })), + { ...original, native: 'owner' }, + { ...original, payment: { ...original.payment, recipient: 'owner' } }, + { ...original, payment: { ...original.payment, paid: 6 } }, + { ...original, payment: { ...original.payment, binding: '' } }, + { ...original, payment: { ...original.payment, contributions: [{ payer: 'missing-player', amount: 5 }] } }, + { ...original, payment: { ...original.payment, contributions: [{ payer: 'competitor', amount: 2 }, { payer: 'competitor', amount: 3 }] } }, + { ...original, payment: { ...original.payment, paid: Number.MAX_SAFE_INTEGER, + contributions: [{ payer: 'owner', amount: Number.MAX_SAFE_INTEGER }, { payer: 'competitor', amount: 1 }] } }, + ]; + for (const source of malformed) + assert.throws(() => quoteOccupiedPercentageSource(game, source, state), HomeworldCustodyError); + assert.deepEqual({ game, state }, before); + const initial = balances(game); + game.players.find(player => player.id === 'competitor')!.spice -= 5; + commit(game, quoteOccupiedPercentageSource(game, original, state), 1); + const after = balances(game); + assert.equal(after.native - initial.native, 3); + assert.equal(after.owner - initial.owner, 1); + assert.equal(after.ally - initial.ally, 1); + assert.equal(after.competitor - initial.competitor, -5); +}); diff --git a/tests/homeworld-occupied-private-lots.test.ts b/tests/homeworld-occupied-private-lots.test.ts new file mode 100644 index 00000000..08eb5cc8 --- /dev/null +++ b/tests/homeworld-occupied-private-lots.test.ts @@ -0,0 +1,186 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { applyAction, viewGame, RuleError, type Game } from '../game/engine'; +import { + addDefenseVisitor, assertDefenseInventory, recordDefensePosition, removeDefenseVisitor, +} from './fixture-homeworld-occupied-defenses'; +import { cash } from './fixture-homeworld-occupied-producers'; +import { + freshPrivateLotGame, passPrivateLotWindow, privateLotPhysicalIds, + privateLotPlayer, privateLotReload, recyclePrivatePurchaseIntoOriginalNormalLot, + sellPrivateBlackMarketLot, +} from './fixture-homeworld-occupied-private-lots'; + +function concealedBlackMarket(game: Game) { + for (const id of ['nativeAt', 'occupier', 'ally', 'rival', 'fremen']) { + assert.equal(viewGame(game, id).richeseAuction!.card, null, `${id} has no private face before the original peek`); + assert.equal(viewGame(game, id).richeseAuction!.cardId, null, `${id} has no seller-only identity`); + } + assert.deepEqual(viewGame(game, 'nativeRichese').richeseAuction!.card, game.richeseOfferedCard); +} + +for (const canceled of [false, true]) { + void test(`concealed original Black Market inspection ${canceled ? 'survives actual native Karama denial only for the occupier' : 'shares the actual face with native Atreides and the occupier only'}`, () => { + const fixture = freshPrivateLotGame(); + let game = fixture.game; + concealedBlackMarket(game); + assert.equal(game.richesePeekKnown, false); + const beforeCash = cash(game); + const sellerHand = structuredClone(privateLotPlayer(game, 'nativeRichese').hand); + const custody = structuredClone(game.homeworlds!.custody); + game = passPrivateLotWindow(privateLotReload(game), 'richeseBlackMarket'); + assert.equal(game.response?.kind, 'atreidesAuction'); + assert.equal(game.response!.owner, 'nativeAt'); + concealedBlackMarket(game); + assert.equal(game.homeworldAuctionInspection!.occupiedKnown, false); + + if (canceled) game = applyAction(privateLotReload(game), 'rival', { + type: 'card', card: fixture.karama.id, mode: 'cancel', + }); + else game = passPrivateLotWindow(privateLotReload(game), 'atreidesAuction'); + + assert.equal(game.response, null); + assert.equal(game.richesePeekKnown, !canceled, 'native cancellation remains separate from the printed shared inspection'); + assert.equal(game.homeworldAuctionInspection!.occupiedKnown, true); + assert.deepEqual(viewGame(game, 'nativeAt').richeseAuction!.card, canceled ? null : fixture.card); + assert.deepEqual(viewGame(game, 'occupier').richeseAuction!.card, fixture.card); + assert.deepEqual(viewGame(game, 'nativeRichese').richeseAuction!.card, fixture.card); + for (const id of ['ally', 'rival', 'fremen']) assert.equal(viewGame(game, id).richeseAuction!.card, null); + assert.deepEqual(privateLotPlayer(game, 'nativeRichese').hand, sellerHand, 'inspection never acquires the seller card'); + assert.deepEqual(game.homeworlds!.custody, custody); + assert.deepEqual(cash(game), beforeCash, 'inspection or native cancellation cannot change the invoice payer'); + assert.deepEqual(privateLotPhysicalIds(game), fixture.original); + if (canceled) { + assert.ok(game.discard.some(card => card.id === fixture.karama.id)); + assert.equal(privateLotPlayer(game, 'rival').hand.some(card => card.id === fixture.karama.id), false); + } + const saved = privateLotReload(game); + assert.deepEqual(viewGame(saved, 'occupier').richeseAuction!.card, fixture.card); + assert.deepEqual(viewGame(saved, 'nativeAt').richeseAuction!.card, canceled ? null : fixture.card); + assert.equal(viewGame(saved, 'ally').richeseAuction!.card, null); + + game = sellPrivateBlackMarketLot(saved); + assert.equal(privateLotPlayer(game, 'occupier').spice, beforeCash.occupier - 2); + assert.equal(privateLotPlayer(game, 'nativeRichese').spice, beforeCash.nativeRichese + 2); + for (const id of ['nativeAt', 'ally', 'rival', 'fremen']) assert.equal(privateLotPlayer(game, id).spice, beforeCash[id]); + assert.ok(privateLotPlayer(game, 'occupier').hand.some(card => card.id === fixture.card.id)); + assert.equal(privateLotPlayer(game, 'nativeRichese').hand.some(card => card.id === fixture.card.id), false); + assert.deepEqual(privateLotPhysicalIds(game), fixture.original); + assert.equal(game.homeworldAuctionInspection, undefined, 'original sale completion retires its private receipt'); + assertDefenseInventory(game); + }); +} + +void test('a frozen Black Market receipt cannot authorize the same physical card recycled into an actual new original normal lot', () => { + const fixture = freshPrivateLotGame(); + let game = passPrivateLotWindow(fixture.game, 'richeseBlackMarket'); + game = passPrivateLotWindow(game, 'atreidesAuction'); + const oldReceipt = structuredClone(game.homeworldAuctionInspection!); + assert.equal(oldReceipt.occupiedKnown, true); + assert.deepEqual(viewGame(game, 'occupier').richeseAuction!.card, fixture.card); + game = sellPrivateBlackMarketLot(privateLotReload(game)); + const paidCash = cash(game); + game = recyclePrivatePurchaseIntoOriginalNormalLot(game, fixture.card.id); + const currentReceipt = structuredClone(game.homeworldAuctionInspection!); + assert.notEqual(currentReceipt.lot, oldReceipt.lot); + assert.equal(currentReceipt.occupiedKnown, false); + assert.equal(game.auction!.cards[game.auction!.index].id, fixture.card.id); + for (const id of ['nativeAt', 'occupier', 'ally', 'rival']) assert.equal(viewGame(game, id).auction!.card, null); + + const stale = privateLotReload(game); + stale.homeworldAuctionInspection = oldReceipt; + const frozen = structuredClone(stale); + const physical = privateLotPhysicalIds(stale); + for (const id of ['nativeAt', 'occupier', 'ally', 'rival']) assert.equal(viewGame(stale, id).auction!.card, null); + assert.deepEqual(stale, frozen, 'private projection cannot mutate or repair a stale receipt'); + assert.deepEqual(privateLotPhysicalIds(stale), physical); + assert.deepEqual(cash(stale), paidCash); + assert.throws(() => passPrivateLotWindow(stale, 'atreidesAuction'), 'the old lot receipt cannot settle the new original source'); + assert.deepEqual(stale, frozen, 'rejected original continuation preserves the exact stale source'); + + // Real current receipt, same card, original current response: the face becomes + // visible only when THIS lot's original inspection opportunity completes. + game = passPrivateLotWindow(privateLotReload(game), 'atreidesAuction'); + assert.deepEqual(viewGame(game, 'nativeAt').auction!.card, fixture.card); + assert.deepEqual(viewGame(game, 'occupier').auction!.card, fixture.card); + assert.equal(viewGame(game, 'ally').auction!.card, null); + assert.equal(viewGame(game, 'rival').auction!.card, null); + assert.deepEqual(cash(game), paidCash); + assert.deepEqual(privateLotPhysicalIds(game), fixture.original); +}); + +void test('a frozen receipt discloses neither a new concealed original Black Market card nor its face before the original peek opportunity', () => { + const first = freshPrivateLotGame(); + let game = passPrivateLotWindow(first.game, 'richeseBlackMarket'); + game = passPrivateLotWindow(game, 'atreidesAuction'); + const oldReceipt = structuredClone(game.homeworldAuctionInspection!); + game = sellPrivateBlackMarketLot(game); + assert.ok(privateLotPlayer(game, 'occupier').hand.some(card => card.id === first.card.id)); + + // Independent fresh original producer: no artificial round/phase/event. + // Pick a different original unused card before the native offer is made. + const next = freshPrivateLotGame(first.card.id); + assert.notEqual(next.game.richeseAuction!.event, oldReceipt.lot); + assert.notEqual(next.card.id, first.card.id); + const saved = privateLotReload(next.game); + saved.homeworldAuctionInspection = oldReceipt; + concealedBlackMarket(saved); + assert.equal(saved.richesePeekKnown, false); + const beforeCash = cash(saved); + const physical = privateLotPhysicalIds(saved); + const opened = passPrivateLotWindow(saved, 'richeseBlackMarket'); + assert.equal(opened.homeworldAuctionInspection!.lot, opened.richeseAuction!.event); + assert.equal(opened.homeworldAuctionInspection!.occupiedKnown, false); + concealedBlackMarket(opened); + const revealed = passPrivateLotWindow(privateLotReload(opened), 'atreidesAuction'); + assert.deepEqual(viewGame(revealed, 'occupier').richeseAuction!.card, next.card); + assert.equal(viewGame(revealed, 'ally').richeseAuction!.card, null); + assert.deepEqual(cash(revealed), beforeCash); + assert.deepEqual(privateLotPhysicalIds(revealed), physical); +}); + +void test('Advanced p22 retains the original shared private inspection while the qualifier remains despite native return and a competitor', () => { + const fixture = freshPrivateLotGame(); + let game = fixture.game; + // Labelled conserved original counters: no new sole qualifier is fabricated. + const native = privateLotPlayer(game, 'nativeAt'); + native.tanks--; + native.reserves++; + addDefenseVisitor(game, 'homeworld:atreides', 'rival'); + recordDefensePosition(game, 'private-lot-native-return-and-competing-visitor-with-original-qualifier-retained'); + const custody = structuredClone(game.homeworlds!.custody); + let departed = privateLotReload(game); + removeDefenseVisitor(departed, 'homeworld:atreides'); + recordDefensePosition(departed, 'private-lot-last-original-qualifier-departure'); + game = passPrivateLotWindow(privateLotReload(game), 'richeseBlackMarket'); + game = passPrivateLotWindow(game, 'atreidesAuction'); + assert.deepEqual(viewGame(game, 'nativeAt').richeseAuction!.card, fixture.card); + assert.deepEqual(viewGame(game, 'occupier').richeseAuction!.card, fixture.card); + assert.equal(viewGame(game, 'rival').richeseAuction!.card, null); + assert.equal(viewGame(game, 'ally').richeseAuction!.card, null); + assert.deepEqual(game.homeworlds!.custody, custody); + assert.deepEqual(privateLotPhysicalIds(game), fixture.original); + assertDefenseInventory(game); + departed = passPrivateLotWindow(privateLotReload(departed), 'richeseBlackMarket'); + departed = passPrivateLotWindow(departed, 'atreidesAuction'); + assert.deepEqual(viewGame(departed, 'nativeAt').richeseAuction!.card, fixture.card); + assert.equal(viewGame(departed, 'occupier').richeseAuction!.card, null, 'last departure ends the retained printed share'); + assert.equal(viewGame(departed, 'rival').richeseAuction!.card, null, 'a competitor alongside native forces never qualified as a replacement'); + assert.equal(viewGame(departed, 'ally').richeseAuction!.card, null); + assert.deepEqual(privateLotPhysicalIds(departed), fixture.original); + assertDefenseInventory(departed); +}); + +void test('a JSON continuation missing original qualification history cannot use the frozen original private inspection receipt', () => { + const fixture = freshPrivateLotGame(); + let game = passPrivateLotWindow(fixture.game, 'richeseBlackMarket'); + assert.equal(game.response?.kind, 'atreidesAuction'); + game = privateLotReload(game); + delete game.homeworldOccupationHistory; + const frozen = structuredClone(game); + for (const viewer of ['nativeAt', 'occupier', 'ally', 'nativeRichese']) + assert.throws(() => viewGame(game, viewer), RuleError); + assert.throws(() => passPrivateLotWindow(game, 'atreidesAuction'), RuleError); + assert.deepEqual(game, frozen); + assert.deepEqual(privateLotPhysicalIds(game), fixture.original); +}); diff --git a/tests/homeworld-occupied-producers-engine.test.ts b/tests/homeworld-occupied-producers-engine.test.ts new file mode 100644 index 00000000..3fdd7798 --- /dev/null +++ b/tests/homeworld-occupied-producers-engine.test.ts @@ -0,0 +1,564 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { applyAction, normalizeAutomaticGame, RuleError, viewGame, type Game } from '../game/engine'; +import { botActions } from '../game/bots'; +import { freshBiddingGame, biddingPlayer, biddingPhysicalIds, stageBiddingHandSize, stageBiddingKarama } from './fixture-homeworld-occupied-bidding'; +import { qualifyDefensePosition, clearDefenseNative, removeDefenseVisitor, recordDefensePosition, assertDefenseInventory } from './fixture-homeworld-occupied-defenses'; +import { occupiedIncomeMovementFixture } from './fixture-homeworld-occupied-income'; +import { advanceOriginal, buyOriginalNormalLot, cash, freshSouthernProducerGame, freshGuildAdvisorProducerGame } from './fixture-homeworld-occupied-producers'; +import { validateAmbassadors } from '../game/ecaz-ambassadors'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { nativeShipmentSources } from '../game/homeworld-options'; +import { distance } from '../game/board'; + +function normalBidding(game: Game): Game { + return advanceOriginal(game, game => game.phase === 3 && !!game.auction && !game.phaseOpening && !game.response && !game.decision); +} + +void test('original paid Kaitain invoice conserves cash/cards and immediately allocates its original occupied share after JSON reload', () => { + let game = freshBiddingGame('emperor', 'harkonnen'); + qualifyDefensePosition(game, 'homeworld:emperor'); + biddingPlayer(game, 'occupier').ally = 'ally'; + biddingPlayer(game, 'ally').ally = 'occupier'; + game = normalBidding(game); + const original = biddingPhysicalIds(game), before = cash(game), bought = game.auction!.cards[game.auction!.index].id; + game = buyOriginalNormalLot(game, 'occupier', 5); + game = advanceOriginal(game, game => game.decision?.kind === 'homeworldOccupiedPercentage'); + assert.equal(game.currentAuctionSale!.winner, 'occupier'); + assert.equal(game.currentAuctionSale!.amount, 5); + assert.equal(biddingPlayer(game, 'occupier').spice, before.occupier - 5); + assert.equal(biddingPlayer(game, 'native').spice, before.native + 3); + assert.ok(biddingPlayer(game, 'occupier').hand.some(card => card.id === bought)); + const offer = viewGame(game, 'occupier').homeworldOccupiedPercentage!; + assert.equal(offer.amount, 2); + assert.equal(offer.world, 'homeworld:emperor'); + for (const difficulty of DIFFICULTIES) { + const view = viewGame(game, 'occupier'); + view.players.find(player => player.id === 'occupier')!.bot = difficulty; + const choice = botActions(view)[0]; + assert.ok(choice, `${difficulty} can settle the actual original paid source`); + const result = applyAction(JSON.parse(JSON.stringify(game)), 'occupier', choice); + assert.equal(result.homeworldOccupiedPercentageLedger!.receipts[0].status, 'settled'); + assert.equal(Object.values(cash(result)).reduce((sum, n) => sum + n, 0), Object.values(before).reduce((sum, n) => sum + n, 0)); + assert.deepEqual(biddingPhysicalIds(result), original); + } + const action = { type: 'decision', event: offer.event, ownAmount: 1 }; + for (const [actor, invalid] of [ + ['native', action], ['occupier', { ...action, ownAmount: 3 }], + ['occupier', { ...action, event: `${offer.event}:stale` }], + ] as const) { + const frozen = structuredClone(game); + assert.throws(() => applyAction(game, actor, invalid), RuleError); + assert.deepEqual(game, frozen, 'rejection leaves cash, ledger and exact purchase suffix untouched'); + } + const changed: Game = structuredClone(game); + changed.currentAuctionSale!.amount++; + const frozen = structuredClone(changed); + assert.throws(() => applyAction(changed, 'occupier', action), RuleError); + assert.deepEqual(changed, frozen); + game = applyAction(JSON.parse(JSON.stringify(game)), 'occupier', action); + assert.equal(biddingPlayer(game, 'native').spice, before.native + 3); + assert.equal(biddingPlayer(game, 'occupier').spice, before.occupier - 4); + assert.equal(biddingPlayer(game, 'ally').spice, before.ally + 1); + assert.equal(Object.values(cash(game)).reduce((sum, n) => sum + n, 0), Object.values(before).reduce((sum, n) => sum + n, 0)); + assert.deepEqual(biddingPhysicalIds(game), original); + assert.equal(game.homeworldOccupiedPercentageLedger!.receipts.filter(row => row.source.kind === 'emperor-treachery').length, 1); + assert.throws(() => applyAction(game, 'occupier', action), RuleError); +}); + +void test('actual native Karama denial removes only the native Kaitain leg, not the printed occupied allocation', () => { + let game = freshBiddingGame('emperor', 'harkonnen'); + qualifyDefensePosition(game, 'homeworld:emperor'); + const counter = stageBiddingKarama(game, 'observer'); + game = normalBidding(game); + const before = cash(game), original = biddingPhysicalIds(game); + game = buyOriginalNormalLot(game, 'occupier', 5); + assert.equal(game.response?.kind, 'emperorIncome'); + game = applyAction(game, 'observer', { type: 'card', card: counter.id, mode: 'cancel' }); + game = advanceOriginal(game, game => game.decision?.kind === 'homeworldOccupiedPercentage'); + const offer = viewGame(game, 'occupier').homeworldOccupiedPercentage!; + game = applyAction(game, 'occupier', { type: 'decision', event: offer.event, ownAmount: 2 }); + assert.equal(biddingPlayer(game, 'native').spice, before.native); + assert.equal(biddingPlayer(game, 'occupier').spice, before.occupier - 3); + assert.deepEqual(biddingPhysicalIds(game), original); + const receipt = game.homeworldOccupiedPercentageLedger!.receipts.find(row => row.source.kind === 'emperor-treachery')!; + assert.equal(receipt.nativeRetained, 0); + assert.equal(receipt.occupiedAmount, 2); + assert.equal(receipt.bankRetained, 3); +}); + +void test('original Caladan lot inspection shares one actual private face with native plus original occupier, never their ally', () => { + let game = freshBiddingGame('atreides'); + qualifyDefensePosition(game, 'homeworld:atreides'); + biddingPlayer(game, 'occupier').ally = 'ally'; + biddingPlayer(game, 'ally').ally = 'occupier'; + game = normalBidding(game); + const actual = game.auction!.cards[game.auction!.index]; + assert.deepEqual(viewGame(game, 'native').auction!.card, actual); + assert.deepEqual(viewGame(game, 'occupier').auction!.card, actual); + assert.equal(viewGame(game, 'ally').auction!.card, null); + assert.equal(viewGame(game, 'observer').auction!.card, null); + assert.deepEqual(viewGame(JSON.parse(JSON.stringify(game)), 'occupier').auction!.card, actual); +}); + +void test('original Ix draw has one displaced decision and private native-stock pool, with stale source/actor atomic rejection', () => { + let game = freshBiddingGame('ixians'); + qualifyDefensePosition(game, 'homeworld:ixians'); + const original = biddingPhysicalIds(game); + game = advanceOriginal(game, game => game.decision?.kind === 'ixAuction'); + assert.equal(game.phaseOpening, null, 'the original Amal window settles before the displaced Ix action'); + assert.equal(game.decision!.player, 'occupier'); + assert.ok(viewGame(game, 'occupier').ixTechnology!.pool!.length > 1); + assert.equal(viewGame(game, 'native').ixTechnology!.pool, null); + assert.equal(viewGame(game, 'observer').ixTechnology, null); + const returned = game.ixAuction!.cards[0]; + const action = { type: 'decision', card: returned.id, position: 'top' }; + for (const difficulty of DIFFICULTIES) { + const view = viewGame(game, 'occupier'); + view.players.find(player => player.id === 'occupier')!.bot = difficulty; + const choice = botActions(view)[0]; + assert.ok(choice); + const result = applyAction(JSON.parse(JSON.stringify(game)), 'occupier', choice); + assert.equal(result.ixAuction, null); + assert.equal(result.auction!.cards.length, game.ixAuction!.cards.length - 1); + assert.deepEqual(viewGame(result, 'native').ixTechnology!.known, []); + assert.deepEqual(biddingPhysicalIds(result), original); + } + const frozen = structuredClone(game); + assert.throws(() => applyAction(game, 'native', action), RuleError); + assert.deepEqual(game, frozen); + const departed = structuredClone(game); + removeDefenseVisitor(departed, 'homeworld:ixians'); + recordDefensePosition(departed, 'pending-original-pool-departure'); + const before = structuredClone(departed); + assert.equal(viewGame(departed, 'occupier').ixTechnology!.pool, null); + assert.notEqual(viewGame(departed, 'occupier').ixTechnology!.blocked, null); + assert.equal(viewGame(departed, 'native').ixTechnology!.pool, null, 'a stale source cannot silently reveal the old pool to its native provider'); + assert.throws(() => applyAction(departed, 'occupier', action), RuleError); + assert.deepEqual(departed, before); + const rest = game.ixAuction!.cards.slice(1).map(card => card.id).sort(); + game = applyAction(JSON.parse(JSON.stringify(game)), 'occupier', action); + assert.equal(game.deck[0].id, returned.id); + assert.deepEqual(game.auction!.cards.map(card => card.id).sort(), rest); + assert.deepEqual(viewGame(game, 'occupier').ixTechnology!.known.map(card => card.id).sort(), rest); + assert.deepEqual(viewGame(game, 'native').ixTechnology!.known, []); + assert.deepEqual(biddingPhysicalIds(game), original); + assert.throws(() => applyAction(game, 'occupier', action), RuleError); +}); + +void test('Richese original seller sets terms; displaced chooser selects physical cache card; actual seller net is split without replacing invoice', () => { + let game = freshBiddingGame('richese'); + qualifyDefensePosition(game, 'homeworld:richese'); + game = advanceOriginal(game, game => game.decision?.kind === 'richeseCacheTerms'); + assert.equal(game.decision!.player, 'native'); + const termsEvent = game.richeseBidding!.event; + for (const difficulty of DIFFICULTIES) { + const view = viewGame(game, 'native'); + view.players.find(player => player.id === 'native')!.bot = difficulty; + const terms = botActions(view)[0]; + assert.ok(terms); + let result = applyAction(JSON.parse(JSON.stringify(game)), 'native', terms); + const choiceView = viewGame(result, 'occupier'); + choiceView.players.find(player => player.id === 'occupier')!.bot = difficulty; + const choice = botActions(choiceView)[0]; + assert.ok(choice); + result = applyAction(result, 'occupier', choice); + assert.equal(result.richeseAuction!.owner, 'native'); + assert.equal(result.richeseAuction!.method, result.richeseBidding!.cacheTerms!.method); + assert.ok(game.richeseCache!.some(card => card.id === result.richeseAuction!.cardId)); + assert.deepEqual(biddingPhysicalIds(result), biddingPhysicalIds(game)); + } + game = applyAction(game, 'native', { type: 'decision', event: termsEvent, method: 'onceAround', direction: 'clockwise' }); + assert.equal(game.decision?.kind, 'richeseCache'); + assert.equal(game.decision!.player, 'occupier'); + const card = game.richeseCache![0], original = biddingPhysicalIds(game), before = cash(game); + const event = game.richeseBidding!.event; + const frozen = structuredClone(game); + assert.throws(() => applyAction(game, 'native', { type: 'decision', event, card: card.id }), RuleError); + assert.throws(() => applyAction(game, 'occupier', { type: 'decision', event, card: card.id, method: 'silent' }), RuleError); + assert.deepEqual(game, frozen); + game = applyAction(JSON.parse(JSON.stringify(game)), 'occupier', { type: 'decision', event, card: card.id }); + game = advanceOriginal(game, game => !!game.richeseAuction && !game.response && !game.decision); + assert.equal(game.richeseAuction!.owner, 'native'); + assert.equal(game.richeseAuction!.method, 'onceAround'); + const lotEvent = game.richeseAuction!.event; + for (let step = 0; !game.currentAuctionSale && step < game.players.length + 1; step++) { + const active = game.richeseAuction!.active!; + game = applyAction(game, active, { type: 'richeseBid', event: lotEvent, amount: active === 'occupier' ? 5 : null, allyPayment: 0 }); + } + assert.equal(game.decision?.kind, 'homeworldOccupiedPercentage'); + assert.equal(game.currentAuctionSale!.seller, 'native'); + assert.equal(game.currentAuctionSale!.winner, 'occupier'); + assert.equal(biddingPlayer(game, 'native').spice, before.native + 3); + const offer = viewGame(game, 'occupier').homeworldOccupiedPercentage!; + game = applyAction(game, 'occupier', { type: 'decision', event: offer.event, ownAmount: 2 }); + assert.equal(biddingPlayer(game, 'occupier').spice, before.occupier - 3); + assert.ok(biddingPlayer(game, 'occupier').hand.some(held => held.id === card.id)); + assert.deepEqual(biddingPhysicalIds(game), original); +}); + +void test('Giedi original purchase gives one actual occupied bonus to reciprocal ally even when native buyer fills its hand', () => { + let game = freshBiddingGame('harkonnen'); + qualifyDefensePosition(game, 'homeworld:harkonnen'); + biddingPlayer(game, 'occupier').ally = 'ally'; + biddingPlayer(game, 'ally').ally = 'occupier'; + stageBiddingHandSize(game, 'native', 7); + stageBiddingHandSize(game, 'occupier', 4); + stageBiddingHandSize(game, 'ally', 3); + game = normalBidding(game); + // Original Storm/card choices can spend a staged card. Freeze the tested + // receiving capacities at the settled original purchase boundary. + stageBiddingHandSize(game, 'native', 7); + stageBiddingHandSize(game, 'occupier', 4); + stageBiddingHandSize(game, 'ally', 3); + const original = biddingPhysicalIds(game), before = cash(game), allyCards = biddingPlayer(game, 'ally').hand.length; + game = buyOriginalNormalLot(game, 'native', 5); + game = advanceOriginal(game, game => game.decision?.kind === 'homeworldOccupiedBonus'); + assert.equal(biddingPlayer(game, 'native').hand.length, 8); + const offer = viewGame(game, 'occupier').homeworldOccupiedBonus!; + assert.deepEqual(offer.recipients.map(recipient => recipient.player), ['ally']); + const filled = structuredClone(game); + stageBiddingHandSize(filled, 'ally', 4); + const exhaustedStock = filled.deck.length + filled.discard.length; + const continued = normalizeAutomaticGame(JSON.parse(JSON.stringify(filled))); + assert.equal(continued.pendingHomeworldOccupiedBonus, null, 'a now-impossible real receiver consumes the original suffix without awarding a card'); + assert.equal(biddingPlayer(continued, 'native').hand.length, 8); + assert.equal(biddingPlayer(continued, 'occupier').hand.length, 4); + assert.equal(biddingPlayer(continued, 'ally').hand.length, 4); + assert.equal(continued.deck.length + continued.discard.length, exhaustedStock); + assert.deepEqual(biddingPhysicalIds(continued), original); + for (const difficulty of DIFFICULTIES) { + const view = viewGame(game, 'occupier'); + view.players.find(player => player.id === 'occupier')!.bot = difficulty; + const choice = botActions(view)[0]; + assert.ok(choice); + const result = applyAction(JSON.parse(JSON.stringify(game)), 'occupier', choice); + assert.equal(biddingPlayer(result, 'ally').hand.length, allyCards + 1); + assert.equal(biddingPlayer(result, 'native').hand.length, 8); + assert.equal(biddingPlayer(result, 'occupier').hand.length, 4); + assert.deepEqual(biddingPhysicalIds(result), original); + } + const action = { type: 'decision', event: offer.event, recipient: 'ally' }; + for (const [actor, choice] of [['native', action], ['occupier', { ...action, recipient: 'native' }]] as const) { + const frozen = structuredClone(game); + assert.throws(() => applyAction(game, actor, choice), RuleError); + assert.deepEqual(game, frozen); + } + const corrupted = structuredClone(game); + corrupted.currentAuctionSale!.winner = 'occupier'; + const frozen = structuredClone(corrupted); + assert.throws(() => applyAction(corrupted, 'occupier', action), RuleError); + assert.deepEqual(corrupted, frozen); + game = applyAction(JSON.parse(JSON.stringify(game)), 'occupier', action); + assert.equal(biddingPlayer(game, 'native').hand.length, 8); + assert.equal(biddingPlayer(game, 'occupier').hand.length, 4); + assert.equal(biddingPlayer(game, 'ally').hand.length, allyCards + 1); + assert.equal(biddingPlayer(game, 'native').spice, before.native - 5); + assert.deepEqual(biddingPhysicalIds(game), original); + assert.throws(() => applyAction(game, 'occupier', action), RuleError); +}); + +void test('Junction original shipment debits payer once, then allocates actual eligible native payment without changing movement', () => { + let game = occupiedIncomeMovementFixture('junction', true, true).game; + game = advanceOriginal(game, game => game.phase === 5 && game.active === 'owner' && !game.phaseOpening && !game.response && !game.decision); + const view = viewGame(game, 'owner'); + view.players.find(seat => seat.id === 'owner')!.bot = 'Easy'; + const shipment = botActions(view).find(action => action.type === 'ship'); + assert.ok(shipment, 'original legal shipment control must offer a native-to-Arrakis purchase'); + const homeworldSources = nativeShipmentSources(view, 3, 0); + assert.ok(homeworldSources, 'three original normal native reserve counters must be available'); + assert.deepEqual(homeworldSources, { 'homeworld:atreides': { normal: 3, elite: 0 } }); + const before = cash(game), cards = biddingPhysicalIds(game), reserves = biddingPlayer(game, 'owner').reserves; + assert.ok(typeof shipment.territory === 'string' && typeof shipment.sector === 'number'); + const destination = `${shipment.territory}:${shipment.sector}`; + const forces = biddingPlayer(game, 'owner').forces[destination] ?? 0; + const mismatched = structuredClone(game), frozen = structuredClone(mismatched); + assert.throws(() => applyAction(mismatched, 'owner', + { ...shipment, amount: 2, elite: 0, allyPayment: 0, homeworldSources }), RuleError); + assert.deepEqual(mismatched, frozen, 'a changed amount cannot reuse the original three-force source allocation'); + game = applyAction(JSON.parse(JSON.stringify(game)), 'owner', + { ...shipment, amount: 3, elite: 0, allyPayment: 0, homeworldSources }); + assert.equal(game.decision?.kind, 'guildShipment'); + game = applyAction(game, game.decision!.player, { type: 'decision', allow: true }); + game = advanceOriginal(game, game => game.decision?.kind === 'homeworldOccupiedPercentage'); + const source = game.pendingHomeworldOccupiedPercentage!.source; + assert.equal(source.kind, 'guild-shipping'); + const native = game.players.find(player => player.id === 'native')!, owner = game.players.find(player => player.id === 'owner')!; + assert.equal(native.spice - before.native, Math.ceil(source.amount / 2)); + assert.equal(owner.spice - before.owner, -source.amount); + assert.equal(owner.shipped, true); + assert.equal(owner.reserves, reserves - 3); + assert.equal(owner.forces[destination], forces + 3); + assertDefenseInventory(game); + const movement = structuredClone(owner.forces), offer = viewGame(game, 'owner').homeworldOccupiedPercentage!; + game = applyAction(JSON.parse(JSON.stringify(game)), 'owner', { type: 'decision', event: offer.event, ownAmount: 0 }); + assert.deepEqual(game.players.find(player => player.id === 'owner')!.forces, movement); + assert.equal(game.players.find(player => player.id === 'ally')!.spice - before.ally, Math.floor(source.amount / 2)); + assert.deepEqual(biddingPhysicalIds(game), cards); +}); + +void test('native Caladan cancellation keeps its actual face hidden from native while the separate printed occupier inspection settles', () => { + let game = freshBiddingGame('atreides'); + qualifyDefensePosition(game, 'homeworld:atreides'); + const counter = stageBiddingKarama(game, 'observer'); + game = advanceOriginal(game, game => game.response?.kind === 'atreidesAuction'); + const actual = game.auction!.cards[game.auction!.index], original = biddingPhysicalIds(game); + assert.equal(viewGame(game, 'native').auction!.card, null); + assert.equal(viewGame(game, 'occupier').auction!.card, null, 'printed sharing does not inspect before the original opportunity resolves'); + game = applyAction(game, 'observer', { type: 'card', card: counter.id, mode: 'cancel' }); + game = advanceOriginal(game, game => !game.response && !game.decision); + assert.equal(game.auction!.peekKnown, false); + assert.equal(viewGame(game, 'native').auction!.card, null); + assert.deepEqual(viewGame(game, 'occupier').auction!.card, actual); + assert.equal(viewGame(game, 'ally').auction!.card, null); + assert.equal(viewGame(game, 'observer').auction!.card, null); + assert.deepEqual(biddingPhysicalIds(game), original); +}); + +void test('Southern aggregates actual desert, completed shared legs and a Fremen occupied-bank award once; redistribution triggers actual Giedi hook only once', () => { + let game = freshSouthernProducerGame(); + game = advanceOriginal(game, game => game.phase === 5 && !game.phaseOpening && !game.response && !game.decision); + // Labelled conserved position: original Arrakis counters return to their own + // reserves before being placed, with starred identities kept in their subset. + for (const player of game.players) { + player.reserves += Object.values(player.forces).reduce((sum, n) => sum + n, 0); + player.forces = {}; + if (player.elites) { + player.elites.reserves += Object.values(player.elites.forces).reduce((sum, n) => sum + n, 0); + player.elites.forces = {}; + } + } + const native = biddingPlayer(game, 'native'), ecaz = biddingPlayer(game, 'ecaz'); + assert.ok(native.reserves - native.elites!.reserves >= 4); + native.reserves -= 4; + native.forces = { 'hagga_basin:12': 2, 'red_chasm:7': 1 }; + clearDefenseNative(game, 'homeworld:atreides'); + game.homeworlds!.custody!.visitors['homeworld:atreides'] = { native: { normal: 1, elite: 0 } }; + ecaz.reserves--; + ecaz.forces['hagga_basin:12'] = 1; + native.ally = ecaz.id; ecaz.ally = native.id; + biddingPlayer(game, 'occupier').ally = 'ally'; + biddingPlayer(game, 'ally').ally = 'occupier'; + game.spice['hagga_basin:12'] = 5; + game.spice['red_chasm:7'] = 4; + qualifyDefensePosition(game, 'homeworld:fremen'); + assertDefenseInventory(game); + const original = biddingPhysicalIds(game), before = cash(game); + const tanks = game.players.map(player => [player.id, player.tanks, player.elites?.tanks ?? 0]); + let bankLeg = ''; + for (let step = 0; game.decision?.kind !== 'homeworldOccupiedPercentage' && step < 500; step++) { + if (game.phase === 5 && !game.response && !game.decision && !game.phaseOpening) + game = applyAction(game, game.active!, { type: 'endMovement' }); + else if (game.decision?.kind === 'ecazSpice') + game = applyAction(game, game.decision.player, { type: 'decision', event: game.ecazCollection!.event, allocation: { kind: 'equal' } }); + else if (game.decision?.kind === 'homeworldOccupiedIncome') { + const offer = viewGame(game, game.decision.player).homeworldOccupiedIncome!; + if (offer.world === 'homeworld:atreides') { + assert.equal(offer.amount, 2); + assert.equal(offer.owner, 'native'); + bankLeg = `${offer.event}:${offer.world}:${offer.owner}`; + } + game = applyAction(game, game.decision.player, { type: 'decision', event: offer.event, world: offer.world, ownAmount: offer.amount }); + } else { + const prior = game; + game = advanceOriginal(game, next => next !== prior); + } + } + assert.equal(game.decision?.kind, 'homeworldOccupiedPercentage'); + const source = game.pendingHomeworldOccupiedPercentage!.source; + assert.equal(source.kind, 'fremen-collection'); + if (source.kind !== 'fremen-collection') throw new RuleError('Expected original Collection source'); + assert.equal(source.amount, 7, 'two printed desert spice, three actual shared spice and two actual Caladan bank spice aggregate before rounding'); + assert.ok(bankLeg, 'the actual native Caladan bank allocation must have completed'); + assert.deepEqual(source.collection.collected, [ + { id: 'ordinary:native', amount: 2 }, + { id: 'shared:hagga_basin:native', amount: 3 }, + { id: bankLeg, amount: 2 }, + ]); + assert.equal(game.spice['red_chasm:7'], 2, 'one normal desert force collects two, without Arrakeen/Carthag rate three'); + assert.equal(game.spice['hagga_basin:12'], 0); + assert.equal(game.ecazCollection!.stage, 'complete'); + assert.equal(game.ecazCollection!.settled[0].ecazAmount, 2); + assert.equal(game.ecazCollection!.settled[0].allyAmount, 3); + assert.equal(biddingPlayer(game, 'native').spice - before.native, 4, 'native keeps half of already credited actual Collection, not another credit'); + assert.ok(game.giediCollection, 'original occupied bank Collection already exercised high Giedi once'); + const offer = viewGame(game, 'occupier').homeworldOccupiedPercentage!; + game = applyAction(JSON.parse(JSON.stringify(game)), 'occupier', { type: 'decision', event: offer.event, ownAmount: 2 }); + assert.equal(biddingPlayer(game, 'native').spice - before.native, 4); + assert.equal(biddingPlayer(game, 'occupier').spice - before.occupier, 6, 'two bank icons, one two-spice Giedi hook and two occupied percentage spice'); + assert.equal(biddingPlayer(game, 'ally').spice - before.ally, 1); + assert.equal(Object.values(cash(game)).reduce((sum, amount) => sum + amount, 0) - + Object.values(before).reduce((sum, amount) => sum + amount, 0), 13, + 'seven physical desert spice plus four printed bank icons and one two-spice Giedi award enter wallets once'); + assert.equal(game.homeworldOccupiedPercentageLedger!.receipts.filter(row => row.source.kind === 'fremen-collection').length, 1); + assert.deepEqual(biddingPhysicalIds(game), original); + assert.deepEqual(game.players.map(player => [player.id, player.tanks, player.elites?.tanks ?? 0]), tanks); + assertDefenseInventory(game); +}); + +void test('original Richese Ambassador purchase displaces one real Giedi bonus, while full-hand failed acquisition creates no bonus', () => { + for (const nativeHand of [7, 8]) { + let game = freshBiddingGame('harkonnen', 'ecaz'); + qualifyDefensePosition(game, 'homeworld:harkonnen'); + game = advanceOriginal(game, game => game.phase === 5 && game.active === 'occupier' && + !game.response && !game.decision && !game.phaseOpening); + // Controlled conserved marker position, not a fabricated purchase. Every + // original token identity remains; exchanging two cohort positions only + // arranges which original marker the forthcoming real entry will meet. + const state = game.ecazAmbassadors!, marker = state.tokens.find(token => token.effect === 'richese')!; + if (!state.cohort.includes(marker.id)) { + const displaced = state.tokens.find(token => token.id === state.cohort[0])!; + state.cohort[0] = marker.id; + displaced.zone = 'pool'; displaced.location = null; + } + for (const token of state.tokens) if (token.location === 'arrakeen') { + token.location = null; token.zone = 'supply'; + } + marker.zone = 'placed'; marker.location = 'arrakeen'; + validateAmbassadors(state); + // Restore a quiet original-stock position before the native entry producer. + for (const player of game.players) { + player.reserves += Object.values(player.forces).reduce((sum, n) => sum + n, 0); + player.forces = {}; + if (player.elites) { + player.elites.reserves += Object.values(player.elites.forces).reduce((sum, n) => sum + n, 0); + player.elites.forces = {}; + } + } + biddingPlayer(game, 'native').ally = 'ally'; + biddingPlayer(game, 'ally').ally = 'native'; + const mover = biddingPlayer(game, 'occupier'); + assert.ok(mover.reserves > 0); + mover.reserves--; + mover.forces['imperial_basin:10'] = 1; + assert.equal(distance('imperial_basin:10', 'arrakeen:10'), 1); + recordDefensePosition(game, 'conserved-original-ambassador-adjacent-movement-position'); + assertDefenseInventory(game); + stageBiddingHandSize(game, 'native', nativeHand); + stageBiddingHandSize(game, 'occupier', 2); + const buyer = biddingPlayer(game, 'native'); + if (buyer.spice < 3) { + const donor = game.players.find(player => player.id !== buyer.id && player.spice >= 3)!; + const amount = 3 - buyer.spice; + donor.spice -= amount; buyer.spice += amount; + } + const original = biddingPhysicalIds(game); + const entryCash = cash(game), tanks = game.players.map(player => [player.id, player.tanks, player.elites?.tanks ?? 0]); + game = applyAction(game, 'occupier', + { type: 'move', from: 'imperial_basin:10', territory: 'arrakeen', sector: 10, amount: 1, elite: 0 }); + game = advanceOriginal(game, game => game.pendingAmbassador?.stage === 'offer'); + assert.deepEqual(cash(game), entryCash, 'original clean movement creates no paid Guild income response'); + assert.equal(game.response, null); + assert.equal(biddingPlayer(game, 'occupier').forces['imperial_basin:10'] ?? 0, 0); + assert.equal(biddingPlayer(game, 'occupier').forces['arrakeen:10'], 1); + assertDefenseInventory(game); + const before = cash(game), stock = game.deck.length + game.discard.length; + const entry = game.pendingAmbassador!.event; + game = applyAction(game, 'ally', { type: 'decision', event: entry, trigger: true, beneficiary: 'native' }); + if (nativeHand === 8) { + assert.equal(game.pendingHomeworldOccupiedBonus, null); + assert.equal(biddingPlayer(game, 'native').spice, before.native); + assert.equal(game.deck.length + game.discard.length, stock); + assert.equal(biddingPlayer(game, 'native').hand.length, 8); + assert.equal(game.pendingAmbassador, null); + assert.equal(biddingPlayer(game, 'occupier').hand.length, 2); + } else { + assert.equal(game.decision?.kind, 'homeworldOccupiedBonus'); + assert.equal(game.pendingAmbassador!.purchaseReceipt!.buyer, 'native'); + assert.equal(biddingPlayer(game, 'native').spice, before.native - 3); + assert.equal(biddingPlayer(game, 'native').hand.length, 8); + const offer = viewGame(game, 'occupier').homeworldOccupiedBonus!; + assert.deepEqual(offer.recipients.map(recipient => recipient.player), ['occupier']); + game = applyAction(JSON.parse(JSON.stringify(game)), 'occupier', + { type: 'decision', event: offer.event, recipient: 'occupier' }); + assert.equal(biddingPlayer(game, 'occupier').hand.length, 3); + assert.equal(biddingPlayer(game, 'native').hand.length, 8); + assert.equal(game.deck.length + game.discard.length, stock - 2, 'one paid acquisition and exactly one physical bonus'); + assert.equal(game.pendingAmbassador, null); + } + assert.deepEqual(biddingPhysicalIds(game), original); + assert.deepEqual(game.players.map(player => [player.id, player.tanks, player.elites?.tanks ?? 0]), tanks); + assertDefenseInventory(game); + } +}); + +void test('native Richese conducting-auction cancellation remains SOURCE-BLOCKED pending the original ordinary-lot-count ruling, with immutable stock and wallets', () => { + let game = freshBiddingGame('richese'); + qualifyDefensePosition(game, 'homeworld:richese'); + const counter = stageBiddingKarama(game, 'observer'); + game = advanceOriginal(game, game => game.response?.kind === 'richeseAuction' && !game.phaseOpening); + assert.equal(game.response!.owner, 'native'); + const cache = game.richeseCache!.map(card => card.id), original = biddingPhysicalIds(game), before = cash(game); + const receipts = game.homeworldOccupiedPercentageLedger!.receipts.length; + const action = { type: 'card', card: counter.id, mode: 'cancel' }; + for (const state of [game, JSON.parse(JSON.stringify(game)) as Game]) { + const frozen = structuredClone(state); + assert.throws(() => applyAction(state, 'observer', action), RuleError); + assert.deepEqual(state, frozen, 'blocked original cancellation preserves exact card/cache/wallet/control state'); + assert.ok(biddingPlayer(state, 'observer').hand.some(card => card.id === counter.id)); + assert.deepEqual(state.richeseCache!.map(card => card.id), cache); + assert.deepEqual(cash(state), before); + assert.equal(state.pendingHomeworldOccupiedPercentage, null); + assert.equal(state.pendingHomeworldOccupiedBonus, null); + assert.equal(state.homeworldOccupiedPercentageLedger!.receipts.length, receipts); + assert.deepEqual(biddingPhysicalIds(state), original); + assertDefenseInventory(state); + } +}); + +void test('occupied Guild receipt preserves the original concurrent BG accompaniment until after its percentage choice', () => { + let game = freshGuildAdvisorProducerGame(); + qualifyDefensePosition(game, 'homeworld:guild'); + game = advanceOriginal(game, game => game.phase === 5 && !game.phaseOpening && !game.response && !game.decision); + // Labelled conserved quiet board; no original force or wallet is minted. + for (const player of game.players) { + player.reserves += Object.values(player.forces).reduce((sum, amount) => sum + amount, 0); + player.forces = {}; + if (player.elites) { + player.elites.reserves += Object.values(player.elites.forces).reduce((sum, amount) => sum + amount, 0); + player.elites.forces = {}; + } + } + const buyer = biddingPlayer(game, 'buyer'), totalCash = Object.values(cash(game)).reduce((sum, amount) => sum + amount, 0); + for (const donor of game.players) { + if (donor.id === buyer.id || buyer.spice >= 6) continue; + const amount = Math.min(donor.spice, 6 - buyer.spice); + donor.spice -= amount; buyer.spice += amount; + } + assert.ok(buyer.spice >= 6); + assert.equal(Object.values(cash(game)).reduce((sum, amount) => sum + amount, 0), totalCash); + recordDefensePosition(game, 'quiet-original-guild-advisor-source'); + for (let step = 0; game.active !== 'buyer' && step < 80; step++) { + if (!game.phaseOpening && !game.response && !game.decision && game.active) + game = applyAction(game, game.active, { type: 'endMovement' }); + else { const before = game; game = advanceOriginal(game, next => next !== before); } + } + assert.equal(game.active, 'buyer'); + stageBiddingKarama(game, 'observer'); + const before = cash(game), cards = biddingPhysicalIds(game); + game = applyAction(game, 'buyer', { type: 'ship', territory: 'hagga_basin', sector: 12, + amount: 3, elite: 0, allyPayment: 0, homeworldSources: nativeShipmentSources(viewGame(game, 'buyer'), 3, 0) }); + assert.equal(game.decision?.kind, 'guildShipment'); + game = applyAction(game, game.decision!.player, { type: 'decision', allow: true }); + assert.equal(game.response?.kind, 'guildIncome'); + assert.equal(game.decision?.kind, 'advisor'); + game = advanceOriginal(game, game => game.decision?.kind === 'homeworldOccupiedPercentage'); + const offer = viewGame(game, 'occupier').homeworldOccupiedPercentage!; + assert.equal(offer.amount, 3); + game = applyAction(JSON.parse(JSON.stringify(game)), 'occupier', + { type: 'decision', event: offer.event, ownAmount: 3 }); + assert.equal(game.decision?.kind, 'advisor'); + assert.equal(game.decision!.player, 'observer'); + const reserves = biddingPlayer(game, 'observer').reserves; + game = applyAction(game, 'observer', { type: 'decision', accept: true, accompany: true, amount: 1 }); + game = advanceOriginal(game, game => !game.response && !game.decision); + assert.equal(biddingPlayer(game, 'observer').reserves, reserves - 1); + assert.equal(biddingPlayer(game, 'observer').forces['hagga_basin:12'], 1); + assert.equal(biddingPlayer(game, 'buyer').forces['hagga_basin:12'], 3); + assert.equal(biddingPlayer(game, 'buyer').spice, before.buyer - 6); + assert.equal(biddingPlayer(game, 'native').spice, before.native + 3); + assert.equal(biddingPlayer(game, 'occupier').spice, before.occupier + 3); + assert.equal(biddingPlayer(game, 'observer').spice, before.observer); + assert.deepEqual(biddingPhysicalIds(game), cards); + assertDefenseInventory(game); +}); diff --git a/tests/richese-acquisition-bots.test.ts b/tests/richese-acquisition-bots.test.ts index 6831a8fe..92f5165f 100644 --- a/tests/richese-acquisition-bots.test.ts +++ b/tests/richese-acquisition-bots.test.ts @@ -174,6 +174,7 @@ void test('response, decision and Truthtrance controls retain priority over opti position: null, cache: [], normalCount: null, + cacheTerms: null, offerBlocked: null, }; assert.equal(botActions(v)[0].type, 'decision'); diff --git a/tests/richese-no-field-bots.test.ts b/tests/richese-no-field-bots.test.ts index 38327d53..b1f4859e 100644 --- a/tests/richese-no-field-bots.test.ts +++ b/tests/richese-no-field-bots.test.ts @@ -246,6 +246,7 @@ void test('pending responses, decisions and Truthtrance precede all optional No- position: null, cache: [], normalCount: null, + cacheTerms: null, offerBlocked: null, }; assert.equal(botActions(v)[0].type, 'decision'); diff --git a/tools/start-prototype.ts b/tools/start-prototype.ts index 0d9a8177..0d173834 100644 --- a/tools/start-prototype.ts +++ b/tools/start-prototype.ts @@ -37,7 +37,7 @@ async function main() { '\nThese classic or paired one-family skill-Nexus entries preserve selected unused original Tech Tokens at3–6 Basic/Advanced and/or Stronghold Cards at2–6 Advanced; both require3–6 Advanced. Real first-Storm/end-Mentat custody, phase-end industry, held subsidy, typed skill rescue/equal substitution, signed one-invoice marker pairs and mandatory original-winner Tech before Face Dance use original handlers. No mixed-family/native free-return accounting or pending rulings are opened.', '\nThe same fresh skill/Nexus entry admits standalone Ecaz OR Moritani with classics, exact ecaz33/all14/all12, Basic/Advanced2–6. Optional unused Tech requires3+; Strongholds requires Advanced; both3+ Advanced. Native Ecaz five-disc/Duke assignment and quiet living-Duke Cunning, plus original Advanced Moritani skill-first assassination/normal-traitor forfeiture, remain. Advanced Harkonnen, shared-Duke assignment, enhanced Terror relocation/HMS/Grumman and other pending effects stay guarded.', '\nIn the standalone Ecaz skill/Nexus profile, mandatory Advanced Occupy composes the chosen faction’s skill with separately labelled armies. Ecaz-led Suk rescues its own fixed casualties after the ally’s variable losses; Diplomat uses the chosen faction’s undialed own pool. Shared training, Basic odd rounding and other pending effects remain separate.', - '\nHomeworld-occupation opts a fresh ready 2–6-seat Basic/Advanced lobby with original selected faction decks into continuous unambiguous occupation, original occupied bank Collection and immediate ally sharing, plus native occupied defensive effects. One continuously present original qualifier and low native population are required. Departed, replaced, contested or repopulated histories retain their source guards; no current-garrison shortcut or expiry ruling is inferred. Paid occupied percentage receipts, borrowed/exceptional powers, other modules, public starts and old-game conversion remain separate.', + '\nHomeworld-occupation opts a fresh ready 2–6-seat Basic/Advanced lobby with original selected faction decks into occupied bank Collection/immediate ally sharing, Kaitain/Junction/Richese percentage receipts, completed total Southern Hemisphere Collection, Caladan shared inspection, displaced Ix pool control, Richese card-only selection with native seller terms, Giedi receiver choice and occupied native defenses. Advanced follows the authorized supplied rulebook p22: sole foreign qualification, retained until its last force leaves; later contests/native repopulation do not expire it, and a new sole arrival after departure has a new source epoch. Basic retains unresolved lifecycle guards. Native income/peek cancellation remains distinct from printed occupied effects. Other borrowed/exceptional powers, overlays, public starts and old-game conversion remain separate.', ); return; }