diff --git a/README.md b/README.md index e6b59414..d4aa9e6d 100644 --- a/README.md +++ b/README.md @@ -215,6 +215,18 @@ exchange. This is a bounded provisional local timing/privacy convention; bounded verification is Partial/Development Verified; the universal reaction-policy question stays open. See the [source contract and excluded modes](docs/NEXUS_HARKONNEN_RULES.md#bounded-betrayal-preview-contract--1-october-2026). +For local **normal Spice Banker income**, use +`node --import tsx tools/start-prototype.ts --profile banker-income` +on a fresh ready Basic/Advanced classic lobby with base Treachery and all +fourteen Leader Skills alone. Existing exact-version/private-backup safeguards +apply; public starts and existing games are unchanged. Another payer's one +actual bank payment of at least four gains one mandatory front-shield spice +once per phase; it stays unspendable until automatic native Mentat collection. +Original payment, recipient and separate payer legs stay intact. Earned +original-faction retention after death/capture/reassignment is provisional +in this profile; bounded verification is Partial/Development Verified and the existing question stays open. +See the [normal-band source and scope](docs/SPICE_BANKER_RUNTIME.md#normal-income-local-contract--2-october-2026). + Above the board, **Treachery draw pile** and **Spice draw pile** show live card counts separately from hands, auction cards and discards. See [draw-pile counts](docs/DRAW_PILES.md). diff --git a/components/game-table.tsx b/components/game-table.tsx index 6013946c..fc65ea6d 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -190,6 +190,7 @@ import { HarassWithdrawGuide, harassWithdrawControlState } from './harass-withdr import { HarassWithdrawChoice } from './harass-withdraw-choice'; import { PrivateBattlePlan } from './private-battle-plan'; import { SpiceBankerControl, spiceBankerControlState } from './spice-banker'; +import { SpiceBankerIncome } from './spice-banker-income'; import { canUsePlanetologistBattleSpecial } from '../game/leader-skill-combat'; import type { Card } from '../game/cards'; import { RevealedBattle } from './revealed-battle'; @@ -1409,6 +1410,7 @@ export function GameTable({
+
diff --git a/components/spice-banker-income.tsx b/components/spice-banker-income.tsx new file mode 100644 index 00000000..5ca362a0 --- /dev/null +++ b/components/spice-banker-income.tsx @@ -0,0 +1,23 @@ +import type { GameView } from '@/game/engine'; + +/** Only the projected physical front-shield piles are public, not their payment receipts. */ +export function SpiceBankerIncome({ game }: { game: GameView }) { + const state = game.spiceBankerIncome; + if (!state || state.deferred.length === 0) return null; + const name = (owner: string) => game.players.find(player => player.id === owner)?.name ?? owner; + return ( +
+

Spice Banker · deferred income

+
    + {state.deferred.map(pile => ( +
  • {name(pile.owner)}: {pile.amount} spice in front of the shield.
  • + ))} +
+

+ This earned spice is not spendable yet. The original gaining faction collects it automatically + at the current Mentat opening; it cannot fund a bid, shipment, revival or Battle Plan beforehand. +

+ Income timing and preview limits +
+ ); +} diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 3fb56447..d22245a8 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -16,6 +16,35 @@ open mode gates. ## Current checkpoint and work +Normal Spice Banker income now connects every reachable actual final-bank +auction, shipment, paid revival and resolved support source in fresh local +`banker-income`: unique classic2..6-seat Basic/Advanced, base33/all14-skills only. +One physical-card turn/phase stamp gains1 unavailable front-shield spice; +real native Mentat collects once before optional work/victory. No public +start, existing-game retrofit or combined overlay is activated. Original +earned-faction retention after trainer death/capture/reassignment and separate +payer/source nonaggregation remain explicit LOCAL provisional boundaries, +not an answer to the existing custody question or a publisher split FAQ. + +Three implementation owners and two final independent reviews covered runtime, +canonical history and private authenticated entry/recovery; both final reviews +have no finding. Types/lint, **6,487 offline tests**, **140 focused cases** and +build pass. Eighteen native source-to-Mentat cases conserve all33 Treachery +Cards/14 skills; all four legal policies exclude deferred money from budgets. +Original private-backed-up six-seat setups **6VZ8G5BY Basic v38** and +**8TN8BTLT Advanced v31** exercised native Emperor self-auction and Guild +own-bank shipment/allow, human390px controls, refresh and final Collection +readiness to actual Mentat (original Banker wallets10→11 and3→4). +The initial26px notice squeeze was reproduced and repaired in the main column; +fresh notice width is354px. Cards/skills/normal and elite forces are conserved; +the23 prior isolated QA rooms' states/versions are unchanged. After final +guide-worker reload, all82 original seats across25 targeted QA rooms restore; +Basicv38/Advancedv31 remain at native Mentat without double collection. +Local HTTP remains **49/55 with six upstream503 rejection failures**, not green. +Guide coverage is Partial / Development Verified; final guide10/10, +types/lint/build and expanded390px guide pass. Checkpoint push is being +finished. No deployed/full-module claim. + Harkonnen Nexus Betrayal now connects actual personal and allied native traitor calls, exact immediate physical retirement, the surviving original battle once and one private actual Mentat replacement. Fresh local `harkonnen-betrayal` diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 8403e684..2c6e08a3 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,65 @@ # Dune implementation status +## 2 October 2026 — normal Spice Banker final-bank income and native Mentat collection + +Three exclusive implementation owners connect the fresh `banker-income` +classic2..6-seat Basic/Advanced base33/all14-skills-only profile. Parent +integrates the small public notice/source guide, actual proof and shipping. +Three is the useful slice count, not a five-agent minimum. + +The photographed normal band gains1 additional bank spice when ANOTHER +payer makes ONE actual bank payment of at least4, once per physical card, +turn and phase. Native paid auctions (including Emperor self-purchase), +ordinary/cross/return shipment, actual paid force/leader/KH/Emperor-extra +revival and resolved support use their original final bank portions. +Real counter-free allowances remain automatic; actual held counters retain +native suspension/cancellation. Income paid to a player, free/stopped/waived +sources, self-payment and smaller individual payer legs do not qualify. +Distinct source/payer legs and lower Banker spending never pool into4. +Original money/recipient/card/force/suffix costs finish once; deferred income +does not fund its source or any pre-Mentat spending. + +New grants follow the actual living available trainer and post-battle +survival/posture, not hidden pre-reveal plans. A physical-card stamp cannot +reset through death/return/reassignment. Already-earned currency stays with +its ORIGINAL gaining faction under an explicit conservative LOCAL policy; +the earlier death/capture question and universal split interpretation remain +open. Real Mentat atomically marks and credits all uncollected grants once +before optional actions/victory. Closed fixed-size consistency history does +not depend on today's wallet/trainer/card; no arbitrary read heal or +fictitious persisted half-phase8 collect choice is used. Public projection +exposes only physical owner/count and current-phase use, not source invoices. + +Integrated verification reproduced and repaired nonbattle trainer completion, +Emperor own-auction final recipient and secret suspended-invoice projection. +Genuine fixture repairs distinguish economic payer from final auction actor, +native Harkonnen free2/cost2 from Emperor free1/cost4, KH revival cost2, +actual skilled death/revival offers and already-held weapon custody. Native +bounty/Collection quotes replace incorrect gross-wallet cost assertions. +Two final independent money and recovery/privacy reviews have no finding. +Types/lint, **6,487 offline tests**, **140 focused Banker/profile cases** and +build pass. Eighteen native payment/trainer/threshold cases conserve all33 +Treachery Cards/14 skills through original source and actual Mentat. All four +minimal legal policies preserve available-budget actions with deferred money. + +Original private-backed-up six-seat CLI rooms **6VZ8G5BY Basic v38** and +**8TN8BTLT Advanced v31** retained native setup/decks/offers/identities/circles. +Human390px native Emperor self-auction and Guild own-bank shipment/allow +earn1 while wallets stay unchanged. Refresh retains the physical notice; +actual final Collection readiness opens Mentat once (Banker10→11 and3→4). +Phone evidence caught a26px player-strip notice squeeze; moving it into the +main column gives354px width. Necessary isolated-worker reload preserved +Basicv14/Advancedv13 continuation before further actions. All card/skill/ +normal and elite force inventories are conserved;23 prior isolated QA rooms' +states/versions are unchanged. Final guide10/10, types/lint/build and expanded +390px Partial/DevelopmentVerified guidance pass. All82 original saved seats +across25 targeted QA rooms restore after final guide-worker reload; the two +Banker rooms remain at native Mentat without double collection. Local HTTP +is **49/55** with six existing upstream503 rejection failures, not green. +Owned temporary drivers are removed; private inputs/backups remain outside +the checkout. Checkpoint push is being finished; full skill/module, +combined-mode and deployed acceptance remain open. + ## 2 October 2026 — original Harkonnen Nexus traitor retirement and Mentat replacement Four exclusive implementation owners connect fresh local `harkonnen-betrayal`: diff --git a/docs/LEADER_SKILLS_RUNTIME.md b/docs/LEADER_SKILLS_RUNTIME.md index b83f9c4f..7f0cfed2 100644 --- a/docs/LEADER_SKILLS_RUNTIME.md +++ b/docs/LEADER_SKILLS_RUNTIME.md @@ -5,6 +5,21 @@ Normal mode starts and publication remain gated. The [source contract](LEADER_SK contains all fourteen physical cards; the [capture interpretation](LEADER_SKILLS_CAPTURE.md) records the fresh publisher/designer search requested by the user. +**2 October normal Banker checkpoint:** the separate fresh local `banker-income` +profile connects actual final-bank payment legs, one physical Banker phase +grant and automatic current Mentat collection. The full deck14 +and existing lower Banker spending remain intact. Read-only public front-shield +counts explain unavailable currency; no collect action/confirmation is added. +Original earned-faction retention after death/capture/reassignment and per-payer +nonaggregation are explicit conservative LOCAL prototype choices, not answers +to the earlier pending custody/split questions or full-module certification. +Bounded verification is Partial / Development Verified: 6,487 offline tests, +140 focused cases, eighteen native source-to-Mentat cases, four legal budgets +and two preserved six-seat CLI/human-phone rooms pass; local HTTP remains +49/55 with six upstream503 failures. Full-module/deployed acceptance stays open; see the +[existing Banker normal-band contract](SPICE_BANKER_RUNTIME.md#normal-income-local-contract--2-october-2026). + + ## Connected boundary The [Basic Tleilaxu follow-up](TLEILAXU_LEADER_SKILLS.md) adds the full-deck @@ -67,9 +82,12 @@ posture and saved history under an explicit private opt-in. The uniform mandator response step awaits the user's UX decision; ordinary activation remains disabled. [Bureaucrat payment redirection](BUREAUCRAT_PAYMENTS.md) connects the ordinary auction, shipment, Richese sale and paid-bribe families within its recorded -boundary. Other payments and split funding remain incomplete. Spice Banker -income remains missing. [Banker battle spending](SPICE_BANKER_RUNTIME.md) -now connects its lower band with separate sealed funds and survivor strength. [Smuggler shipping](SMUGGLER_SHIPMENT.md) +boundary. Other Bureaucrat payments and split funding remain incomplete. +[Banker normal income](SPICE_BANKER_RUNTIME.md#normal-income-local-contract--2-october-2026) +is connected only in its separate fresh classic `banker-income` profile; +ordinary/default and combined skill starts gain no new activation. +[Banker battle spending](SPICE_BANKER_RUNTIME.md) retains its separate +sealed funds and survivor strength. [Smuggler shipping](SMUGGLER_SHIPMENT.md) connects its optional normal reserve bonus and the [owned No-Field companion](SMUGGLER_NO_FIELD.md). [Diplomat defense](DIPLOMAT_DEFENSE.md) connects its normal band for a physical Worthless card copying a canonical base Shield or Snooper. Its bounded lower diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index f50f8556..72e3fcb1 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -129,6 +129,7 @@ independent decisions are not reconciled by this cutover. | Basic Ixians / Leader Skills | Per-origin cyborg range, room-local HMS force routes, rescue before actual cyborg Tank substitution, Rihani before faction substitution and mandatory cleanup compose existing contracts. Ambiguous rescued cyborg reserve origins are explicit player choices. Exact green Ix Specials are Thumper, Harvester and Amal. | [Integration and physical sources](IX_LEADER_SKILLS.md) | | Basic Tleilaxu / Leader Skills | Compose settled native Rihani Face Dancer eligibility, own revival offers and Face Dance death return through the full deck. Zoal copies opposing printed disc value for unmodified Smuggler collection. Advanced foreign gholas and existing skill questions remain gated. | [Integration and sources](TLEILAXU_LEADER_SKILLS.md) | | Leader Skills | Fourteen physical cards, variant setup order, public/hidden assignment, death return, own-leader revival and captured lower-band effects are sourced. Five battle disciplines, Planetologist movement/Special substitution, Suk Graduate rescues and common lifecycle are prototyped; Rihani, lower Mentat/Bureaucrat/Sandmaster, normal Smuggler and normal Diplomat now have connected follow-ups. Remaining bands and combinations stay missing. Known skilled capture identifies the leader through its unique public card. | [Source contract](LEADER_SKILLS_RULES.md), [capture inference](LEADER_SKILLS_CAPTURE.md), [runtime](LEADER_SKILLS_RUNTIME.md), [Diplomat](DIPLOMAT_DEFENSE.md), [Planetologist](PLANETOLOGIST_RULES.md), [Suk Graduate](SUK_GRADUATE_RULES.md) | +| Normal Spice Banker local preview | Fresh classic/base33/all14-skills-only `banker-income` gains one additional deferred bank spice once per physical card/turn/phase from another payer's one actual bank payment of at least four. Original recipients/own-donor legs/waivers and native costs stay separate; no pooling smaller payments or using future income to fund the payment. Actual Mentat collects once to original gaining faction. Earned retention through trainer death/capture/reassignment and per-payer splitting are explicit conservative LOCAL choices, not answers to the earlier custody question or publisher examples. Public/old/combined modes stay gated; bounded verification is Partial/Development Verified, not deployed/full-module acceptance. | [Normal-band contract](SPICE_BANKER_RUNTIME.md#normal-income-local-contract--2-october-2026), [skill authority](LEADER_SKILLS_RULES.md) | | Discoveries | Preserve seven sourced Spice Cards and eight physical tokens, private faction peeks and Collection choices. Revealed locations are separate territories; only Jacurutu counts toward stronghold victory. Next-turn free entry, sole-occupant Cistern income, bounded sole-occupant Orgiz transfer and later-turn Ornithopter spending are prototyped; Jacurutu income, Testing Station and Shrine also have bounded paths. Contested and mixed cases remain pending. | [Component authority](DISCOVERY_COMPONENTS.md), [prototype boundary](DISCOVERY_PROTOTYPE.md) | | Guild Secret Ally | One explicit Guild-priced ordinary shipment; borrowed prices/routes preserve native identity. Free Fremen reinforcement remains a separate choice. | [Route contract](NEXUS_GUILD_SECRET_ALLY_RULES.md), [verified runtime](NEXUS_GUILD_SECRET_ALLY_RUNTIME.md) | | Homeworld custody and mobility | Physical home/visitor groups, population thresholds, tariffs and transport permissions retain distinct sources; Homeworld immunity is not universal immunity. | [Homeworld rules](HOMEWORLD_RULES.md), [benefits](HOMEWORLD_BENEFITS_RULES.md), [mobility](HOMEWORLD_MOBILITY_RULES.md), [invasion](HOMEWORLD_INVASION_RULES.md), [Guild transport](GUILD_HOMEWORLD_TRANSPORT_RULES.md) | diff --git a/docs/SPICE_BANKER_RUNTIME.md b/docs/SPICE_BANKER_RUNTIME.md index 213bae91..0b8f64cc 100644 --- a/docs/SPICE_BANKER_RUNTIME.md +++ b/docs/SPICE_BANKER_RUNTIME.md @@ -1,8 +1,92 @@ -# Spice Banker battle commitment prototype +# Spice Banker battle commitment and normal income prototypes -13 September 2026. **Prototyped**, with **Partial** rules coverage. This connects -the lower battle band. The normal income band and combined-module acceptance -remain unfinished; public mode starts and publication stay gated. +**2 October normal-band checkpoint:** a separate fresh local `banker-income` +classic/base33/all-fourteen-skills-only Basic/Advanced profile connects actual +positive bank payments to automatic current Mentat collection. +Bounded coverage is **Partial / Development Verified**. No public start, +old-game retrofit or other module is enabled; this is not deployed acceptance. + +## Normal income local contract — 2 October 2026 + +The original photographed card says gain one spice when another player makes +one bank payment of at least four, once per phase. This is a mandatory +additional bank grant, not skimming the original payer or recipient; no new +action, Karama response or confirmation is invented. Ordinary costs and +their original counters/recipient/physical suffix finish once before the grant. + +The private-backed-up profile admits unique2..6 classic factions, +Basic or Advanced, base33 Treachery and all14 Leader Skills alone. Actual +paid normal auctions, funded shipments, paid force/leader/KH/Emperor-extra +revivals and resolved bank support use original final-bank receipts. Player +income, free/waived/stopped/invalid sources and self-payment do not qualify. +The original bank recipient matters: canceled Emperor or Guild income may +leave a bank payment, while income actually received by a player does not. + +One payment is not several smaller payments summed together. Actual own/donor +legs keep their payer identity; two payers at two each do not make one payer +at four. The lower1..3 Banker battle payment stays separate from support. +This per-payer/source composition is a conservative local interpretation, +not a separately located publisher split-funding example. No future award +can finance the original payment. + +An actual available living native trainer supplies new normal income. A +captive supplies no upper band; concealed unselected skills are unavailable. +Own-battle selected-trainer eligibility uses actual resolved survival, not +private pre-reveal timing. One physical card's turn/phase stamp cannot reset +through death, return, refresh or same-phase reassignment. + +The already-earned front-shield pile remains bound to its ORIGINAL gaining +faction provisionally in this fresh profile even if the trainer dies, is +captured or the card returns/reassigns. This conserves earned currency and +does not transfer it with a skill card. It does **not** answer the previously +asked death/capture question or create universal completed-mode authority. +The historical pending boundary below remains recorded outside this profile. + +Deferred spice is not added to a spendable wallet before actual native Mentat +opening. Human/four-policy bids, shipping, revival, support and lower Banker +spending use original available money. Mentat atomically marks/credits each +uncollected grant once before optional work/victory; collection is not a +qualifying payment and creates no recursive income. There is no arbitrary +read heal or fictitious persisted half-phase collect choice. + +Only physical public front-shield owner/count and current-phase use are +projected. Private bank amounts, source/receipt, hidden plans and later skill +entitlement stay private. Current-source corrupt/stale/duplicate/foreign +actions and recovery reject immutably; closed history no longer requires the +original trainer/card or old wallet to remain in place. Bounded signatures +check consistency, not authentication of wholesale rewritten saved JSON. + +Frozen verification passes types/lint, **6,487 offline tests**, the **140-case** +Banker/profile union and build. Eighteen actual native producer/threshold/ +trainer cases conserve all33 Treachery Cards and14 skills through original +payment and real Mentat; independent combat/Collection quotes separate native +bounty/income from support costs. All four minimal legal policies exclude the +front-shield pile from available budgets. Native death/capture/reassignment, +once-phase and separate payer legs have meaningful JSON/authenticated SQLite +regressions, including actual final Collection races and private suspended +Emperor/Guild invoices. Two final independent scoped reviews have no finding. + +Original private-backed-up six-seat CLI setups were continued without +redealing: Basic **6VZ8G5BY v38** used native Emperor self-auction payment to +the Bank; Advanced **8TN8BTLT v31** used native Guild own-bank shipment and +its genuine allow decision. Human390px controls earned1 without changing the +Banker's available wallet, retained the notice through refresh and collected +at actual final Collection readiness to Mentat exactly once (10→11 and3→4). +The notice's initial26px player-strip squeeze was reproduced visually and +fixed by placing it in the main column; fresh proof shows354px width. +All cards/skills/normal and elite force inventories remain conserved, with +the23 prior isolated QA rooms' states/versions unchanged. Final guide10/10, +types/lint/build and expanded390px guidance pass; all82 original seats in25 +targeted QA rooms restore after final worker reload without double collection. +Local HTTP is **49/55**, with six known upstream503 rejection failures: +not a green suite. Publication/deployment and complete skill/module acceptance +remain open. + + +The historical13 September checkpoint below is **Prototyped**, with **Partial** +rules coverage, and connects the lower battle band only. The normal band is +now connected solely in the fresh profile above; combined acceptance and +public mode starts remain gated. ## Source and payment contract @@ -33,7 +117,8 @@ Mentat Pause. Neither publisher nor designer material located in the renewed search settles custody of that accumulated income if the trainer dies or is captured first. The user has been asked whether to retain the pending boundary or rule that already-earned spice stays with the original faction. No answer -or income implementation is assumed here. +is assumed; only the explicitly provisional fresh profile above implements +normal income. This historical question remains open outside that profile. ## Connected behavior diff --git a/game/engine.ts b/game/engine.ts index bab76330..53c26082 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -20,6 +20,7 @@ import { createSmugglerBattle, settleSmugglerBattle, smugglerBattleModeSupported import { quoteSandmasterMovement, validateSandmasterMovement, sandmasterRouteDistance, type SandmasterMovement, type SandmasterOrder } from './sandmaster-movement'; import { sandmasterWormCollection } from './sandmaster-worm'; import { spiceBankerModeSupported, validateSpiceBankerSpend } from './spice-banker'; +import { BankerIncomeError, createBankerIncomeState, validateBankerIncomeState, quoteBankerIncome, commitBankerIncome, quoteBankerIncomeCollection, commitBankerIncomeCollection, projectBankerIncome, type BankerIncomeState, type BankerIncomeAuthority, type BankerIncomeContext } from './spice-banker-income'; import { quoteDiplomatDefense, diplomatDefenseModeSupported, type DiplomatDefenseQuote } from './diplomat-defense'; import { diplomatRetreatChoices, type DiplomatRetreatSelection, type DiplomatRetreatDestination } from './diplomat-retreat'; import { ECAZ_START_FORCES, quoteEcazStartingForces } from './ecaz-setup'; @@ -927,7 +928,17 @@ export type Decision = event?: string; } | { kind: 'battleCards'; player: string; territory: string; cards: string[] }; +type SpiceBankerIncomePayment = { + event: string; + turn: number; + phase: number; + kind: BankerIncomeAuthority['kind']; + legs: {payer: string; amount: number; recipient: 'bank' | 'player'}[]; + signature: string; +}; + export type ResponseWindow = { + spiceBankerIncomePayment?: SpiceBankerIncomePayment; bureaucratPayment?: BureaucratPaymentSource; bureaucratPaymentEvent?: string; noFieldSkillProof?: string; @@ -1516,6 +1527,7 @@ export type Game = { }; ixRicheseTechnologyEvent?: string; currentAuctionSale?: { + spiceBankerIncomePayment?: SpiceBankerIncomePayment; bureaucratPayment?: BureaucratPaymentSource; bureaucratPaymentEvent?: string; winner: string; @@ -1904,6 +1916,9 @@ export type Game = { /** Absent on legacy rooms whose starting cards were already dealt. */ setupStage?: 'prediction' | 'skillTreachery' | 'leaderSkills' | 'traitors' | 'forces'; leaderSkills?: LeaderSkillsState; + /** Fresh local classic profile; earned custody remains with its original faction. */ + spiceBankerIncomePreview?: true; + spiceBankerIncome?: BankerIncomeState; bureaucratPayments?: {used:BureaucratPaymentUse[];pending?:{ source:BureaucratPaymentSource;owner:string;leader:string; resume:{response:ResponseWindow|null;decision:Decision|null}; @@ -7569,6 +7584,7 @@ function replaceMoritaniAssassinationTraitors(g: Game) { } } function leaderSkillsIntegrity(g: Game) { + spiceBankerIncomeIntegrity(g); moritaniAssassinateIntegrity(g); bureaucratPaymentIntegrity(g); mentatQuestionIntegrity(g); @@ -7667,6 +7683,129 @@ function nativeLeaderSkill(g: Game, owner: string) { (a) => a.owner === owner && leaderSkillController(g, a) === owner, ); } +function bankerIncomeRule(operation: () => T): T { + try { return operation(); } catch (error) { + if (error instanceof BankerIncomeError) throw new RuleError(error.message); + throw error; + } +} +function bankerIncomeContext(g: Game): BankerIncomeContext { + return {turn: g.turn, phase: g.phase, + players: g.players.map(p => ({id: p.id, leaders: p.leaders.map(l => ({ + id: l.id, dead: l.dead, ...(l.capturedBy ? {capturedBy: l.capturedBy} : {}), + ...(l.gholaBy ? {gholaBy: l.gholaBy} : {}), + }))})), + assignments: g.leaderSkills?.assignments ?? []}; +} +function bankerIncomeProfile(g: Game): boolean { + return g.players.length >= 2 && g.players.length <= 6 && + new Set(g.players.map(p => p.faction)).size === g.players.length && + g.players.every(p => FACTIONS.some(f => f.id === p.faction && f.expansion === 'base')) && + g.expansions.length === 0 && !g.nexusCards && !g.homeworlds && !g.techTokens && + !g.strongholdCards && !g.discoveryEnabled && !g.discoveries && !g.discoveryStash && + !g.greatMaker && !g.ecazTreachery && !g.semutaPreview && !g.mentatQuestionPreview && + !g.moritaniAssassinatePreview && !g.moritaniAssassinate && !g.advancedPreview && + !g.kullPreview && !g.nexusKullPreview && !g.guildBetrayalPreview && !g.richeseBetrayalPreview && + !g.nexusIxianReplacementPreview && !g.nexusIxianBetrayalPreview && !g.nexusHarkonnenBetrayalPreview; +} +function bankerIncomePaymentSignature(payment: SpiceBankerIncomePayment): string { + return JSON.stringify([payment.event, payment.turn, payment.phase, payment.kind, payment.legs]); +} +function spiceBankerIncomeIntegrity(g: Game): void { + if (!g.spiceBankerIncomePreview) { + requireRule(g.spiceBankerIncomePreview === undefined && g.spiceBankerIncome === undefined, + 'Deferred Spice Banker income requires its fresh local profile.'); + return; + } + requireRule(g.spiceBankerIncomePreview === true && bankerIncomeProfile(g) && !!g.leaderSkills && !!g.spiceBankerIncome, + 'The saved Spice Banker income profile lost its classic physical setup.'); + bankerIncomeRule(() => validateBankerIncomeState(g.spiceBankerIncome!, bankerIncomeContext(g))); + const continuation = g.pendingTreacheryDiscard?.continuation; + const responses = [g, g.pendingExchange, g.pendingNullentropy?.resume, g.pendingRicheseGift?.resume, + g.pendingRichesePurchaseIncome?.resume, g.summonedWorm?.resume, g.bureaucratPayments?.pending?.resume, + continuation && 'resume' in continuation ? continuation.resume : null] + .flatMap(context => context?.response ? [context.response] : []); + if (g.pendingKarama?.use.kind === 'cancel') responses.push(g.pendingKarama.use.response); + for (const carrier of [g.currentAuctionSale, ...responses]) { + const payment = carrier?.spiceBankerIncomePayment; + if (!payment) { + requireRule(!carrier || ('winner' in carrier ? carrier.free || carrier.origin !== 'normal' : carrier.kind !== 'guildIncome'), + 'The original paid transaction lost its native bank invoice.'); + continue; + } + requireRule(payment.signature === bankerIncomePaymentSignature(payment) && payment.turn === g.turn && + payment.phase === g.phase && typeof payment.event === 'string' && payment.event.length > 0 && + Array.isArray(payment.legs) && payment.legs.every(leg => g.players.some(p => p.id === leg.payer) && + Number.isSafeInteger(leg.amount) && leg.amount > 0 && ['bank', 'player'].includes(leg.recipient)) && + new Set(payment.legs.map(leg => leg.payer)).size === payment.legs.length, + 'The original paid bank transaction changed its event or contribution legs.'); + if (carrier && 'winner' in carrier) + requireRule(payment.kind === 'auction' && !carrier.free && carrier.origin === 'normal' && + payment.legs.reduce((sum, leg) => sum + leg.amount, 0) === carrier.amount && + payment.legs.every(leg => leg.payer === carrier.winner || leg.payer === getPlayer(g, carrier.winner).ally), + 'The deferred bank invoice no longer matches its original normal auction.'); + else if (carrier) + requireRule(carrier.kind === 'guildIncome' && payment.kind === 'shipment' && + payment.legs.filter(leg => leg.recipient === 'player').reduce((sum, leg) => sum + leg.amount, 0) === carrier.amount, + 'The deferred bank invoice no longer matches its original Guild income.'); + } +} +function bankerIncomePayment(g: Game, kind: BankerIncomeAuthority['kind'], + legs: SpiceBankerIncomePayment['legs']): SpiceBankerIncomePayment | undefined { + if (!g.spiceBankerIncomePreview) return undefined; + const payment = {event: crypto.randomUUID(), turn: g.turn, phase: g.phase, kind, + legs: legs.filter(leg => leg.amount > 0), signature: ''}; + payment.signature = bankerIncomePaymentSignature(payment); + return payment; +} +function awardBankerIncome(g: Game, payment: SpiceBankerIncomePayment | undefined, + canceled = false, resolvedBattle = false): void { + if (!payment) return; + const bankLegs = payment.legs.filter(leg => leg.recipient === 'bank' || canceled) + .map(({payer, amount}) => ({payer, amount})); + if (!bankLegs.length) return; + const assignment = g.leaderSkills?.assignments.find(a => a.skill === 'spice-banker'); + const leader = assignment && getPlayer(g, assignment.owner).leaders.find(l => l.id === assignment.leader); + const trainer = assignment && leader && !leader.dead && !leader.capturedBy && !leader.gholaBy ? { + assignment: {...assignment}, faceUp: !g.battle?.leaderSkillHidden?.[assignment.owner], + captured: false, selected: resolvedBattle && g.battle?.plans[assignment.owner]?.leader === assignment.leader, + resolved: payment.kind !== 'battle-support' || resolvedBattle, survived: true, + } : null; + const authority: BankerIncomeAuthority = {kind: payment.kind, event: payment.event, + turn: payment.turn, phase: payment.phase, bankLegs, trainer}; + const context = bankerIncomeContext(g); + const quote = bankerIncomeRule(() => quoteBankerIncome(g.spiceBankerIncome!, context, authority)); + g.spiceBankerIncome = bankerIncomeRule(() => commitBankerIncome(g.spiceBankerIncome!, context, authority, quote.receipt)); + if (quote.receipt?.grant) { + const owner = getPlayer(g, quote.receipt.grant.owner); + log(g, `${owner.name} gained 1 Spice Banker spice in front of the shield, unavailable until Mentat Pause.`, + {faction: owner.faction, name: 'Spice Banker'}); + } +} +function collectBankerIncome(g: Game): void { + if (!g.spiceBankerIncomePreview) return; + const context = bankerIncomeContext(g), authority = {event: crypto.randomUUID(), turn: g.turn, phase: 8 as const}; + const quote = bankerIncomeRule(() => quoteBankerIncomeCollection(g.spiceBankerIncome!, context, authority)); + g.spiceBankerIncome = bankerIncomeRule(() => commitBankerIncomeCollection(g.spiceBankerIncome!, context, authority, quote.collection)); + for (const credit of quote.credits) { + const owner = getPlayer(g, credit.owner); + owner.spice += credit.amount; + log(g, `${owner.name} collected ${credit.amount} deferred Spice Banker spice during Mentat Pause.`, + {faction: owner.faction, name: 'Spice Banker'}); + } +} +function settleBankerShipmentIncome(g: Game, p: Player, cost: number, allyPayment: number): void { + if (!g.spiceBankerIncomePreview) return; + const guild = byFaction(g, 'guild'); + const bankOnly = !guild || g.karamaShipping?.player === p.id; + const payment = bankerIncomePayment(g, 'shipment', [ + {payer: p.id, amount: cost - allyPayment, recipient: bankOnly || p.id === guild?.id ? 'bank' : 'player'}, + ...(allyPayment ? [{payer: p.ally!, amount: allyPayment, + recipient: bankOnly || p.ally === guild?.id ? 'bank' as const : 'player' as const}] : []), + ]); + if (g.response?.kind === 'guildIncome') g.response.spiceBankerIncomePayment = payment; + else awardBankerIncome(g, payment); +} function nativeBureaucrat(g: Game) { return g.leaderSkills?.assignments.find(a => a.skill === 'bureaucrat' && getPlayer(g,a.owner).leaders.some(l => l.id === a.leader && !l.dead && !l.capturedBy && !l.gholaBy) && @@ -9024,6 +9163,18 @@ export function initializeLeaderSkillsGameForAudit(state: Game): Game { return initializeSetupGameForAudit(g, false, false, false, false, true, g.expansions.length === 1 && g.expansions[0] === 'choam', basicExpansionLeaderSkillsProfile(g)); } +/** Fresh private all-fourteen-card classic setup; no public action enables income. */ +export function initializeSpiceBankerIncomeGameForAudit(state: Game): Game { + requireRule(bankerIncomeProfile(state) && !state.leaderSkills && + !state.spiceBankerIncomePreview && state.spiceBankerIncome === undefined, + 'Spice Banker income requires a fresh classic lobby, base Treachery and Leader Skills alone.'); + requireFreshSetup(state); + const g = structuredClone(state); + g.leaderSkills = createLeaderSkills(random); + g.spiceBankerIncomePreview = true; + g.spiceBankerIncome = bankerIncomeRule(() => createBankerIncomeState(bankerIncomeContext(g))); + return initializeSetupGameForAudit(g, false, false, false, false, true); +} /** Gated development setup for the independent three-card Ecaz variant. */ export function initializeEcazTreacheryGameForAudit(state: Game): Game { requireRule(!state.ecazTreachery, 'Ecaz Treachery Cards cannot redeal an existing inventory.'); @@ -11257,7 +11408,15 @@ function settleAuction(g: Game, free = false, automatic = false, emperorNexus = const winner = getPlayer(g, a.bidder!); if (!free) payWithAlly(g, winner, a.bid, a.allyPayment ?? 0); winner.hand.push(a.cards[a.index]); + const emperor = byFaction(g, 'emperor'); + const paysEmperor = !!emperor && emperor.id !== winner.id; + const incomePayment = !free && g.spiceBankerIncomePreview ? bankerIncomePayment(g, 'auction', [ + {payer: winner.id, amount: a.bid - (a.allyPayment ?? 0), recipient: paysEmperor ? 'player' : 'bank'}, + ...(a.allyPayment ? [{payer: winner.ally!, amount: a.allyPayment, + recipient: paysEmperor ? 'player' as const : 'bank' as const}] : []), + ]) : undefined; g.currentAuctionSale = { + ...(incomePayment ? {spiceBankerIncomePayment: incomePayment} : {}), winner: winner.id, amount: a.bid, free, @@ -11265,6 +11424,7 @@ function settleAuction(g: Game, free = false, automatic = false, emperorNexus = seller: null, ...(!free ? stampAuctionBureaucrat(g,winner,a.bid,a.allyPayment ?? 0) : {}), }; + if (!paysEmperor) awardBankerIncome(g, incomePayment); log( g, `${faction(winner.faction).name} won a treachery card for ${emperorNexus ? `the Emperor Nexus card after proving ${a.bid} spice` : free ? 'a Karama' : `${a.bid} spice`}.${automatic ? ' The declared spice payment was the only available payment method.' : ''}`, @@ -17175,6 +17335,7 @@ function beginPhase(g: Game) { collect(g); } if (g.phase === 8) { + collectBankerIncome(g); drawHarkonnenNexusBetrayalReplacement(g); replaceMoritaniAssassinationTraitors(g); if (g.inflation && g.inflation.updatedTurn < g.turn) { @@ -20283,6 +20444,13 @@ function resolveBattle(g: Game, retreat?: DiplomatRetreatSelection) { : { faction: winner!.faction, name: 'Battle' }, ); } + if (g.spiceBankerIncomePreview) for (const payment of quote.payments) { + const player = getPlayer(g, payment.player); + awardBankerIncome(g, bankerIncomePayment(g, 'battle-support', [ + {payer: player.id, amount: payment.ownPayment, recipient: 'bank'}, + ...(payment.allyPayment ? [{payer: player.ally!, amount: payment.allyPayment, recipient: 'bank' as const}] : []), + ]), false, true); + } if (jacurutuIncome?.kind === 'income' && jacurutuIncome.amount > 0) { winner!.spice += jacurutuIncome.amount; log( @@ -21691,6 +21859,10 @@ function finishRevival( }) : null; payer.spice -= revival.cost; + if (g.spiceBankerIncomePreview && !byFaction(g, 'tleilaxu')) awardBankerIncome(g, bankerIncomePayment(g, + revival.emperorExtra ? 'emperor-extra-revival' : revival.kind === 'forces' ? 'force-revival' : + revival.kind === 'kwisatz' ? 'kh-revival' : 'leader-revival', + [{payer: payer.id, amount: revival.cost, recipient: 'bank'}])); if (revival.kind === 'forces') { const n = revival.amount!, elite = revival.elite ?? 0; @@ -22091,6 +22263,8 @@ function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number) return; } if (auctionCancellation) { + if (response.kind === 'emperorIncome') + awardBankerIncome(g, g.currentAuctionSale?.spiceBankerIncomePayment, true); if (response.kind === 'harkonnenBonus') log(g, 'Karama prevented the Harkonnen bonus treachery card.'); commitAuctionContinuation(g, auctionCancellation); @@ -22952,6 +23126,7 @@ function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number) } } else if (response.kind === 'guildIncome') { const payment = !canceled ? creditFactionPayment(g, response.owner, 'shipment', response.amount!-(bureaucratDiversion ?? 0)) : null; + awardBankerIncome(g, response.spiceBankerIncomePayment, canceled); log( g, canceled @@ -23806,6 +23981,7 @@ function commitGuildTransport( g.response = guildPaymentResponse(g, guild.id, shipmentIncomeContributions(g, p, cost, allyPayment), allyPayment ? undefined : p.id); + settleBankerShipmentIncome(g, p, cost, allyPayment); if (fromReserves) { p.reserves -= amount; if (p.elites) p.elites.reserves -= elite; @@ -24027,6 +24203,7 @@ function commitShipment(g: Game, shipment: PendingShipment, settlement?: {recipi }); if (!settlement?.recipient && guild && guildPayment > 0) g.response = guildPaymentResponse(g, guild.id, shipmentIncomeContributions(g, p, cost, allyPayment),allyPayment ? undefined : p.id); + settleBankerShipmentIncome(g, p, cost, allyPayment); g.karamaShipping = null; log( g, @@ -25698,6 +25875,22 @@ function normalizeCardNames(g: Game) { ]); } export function applyAction(state: Game, id: string, action: Action): Game { + if (state.spiceBankerIncomePreview) { + const keysBySource: Record = { + bid: ['type','amount','allyPayment'], passBid: ['type'], + ship: ['type','territory','sector','amount','elite','allyPayment','smuggler'], + guildShip: ['type','from','forces','eliteForces','territory','sector','amount','elite','allyPayment'], + revive: ['type','amount','elite'], reviveLeader: ['type','leader'], reviveKwisatz: ['type'], + emperorRevival: ['type','amount','elite'], passResponse: ['type'], + traitorCall: ['type','call','leader'], + }; + const allowed = action.type === 'decision' && state.decision?.kind === 'auctionPayment' + ? ['type','karama','card'] : keysBySource[action.type]; + requireRule(!allowed || Object.keys(action).every(key => allowed.includes(key)), + 'Use only the original native payment fields; Spice Banker income is automatic.'); + requireRule(!Object.keys(action).some(key => ['actor','bankerIncome','paymentSource','spiceBankerIncome','bankLegs','trainer'].includes(key)), + 'Spice Banker payment authority comes only from the original native transaction.'); + } harkonnenNexusBetrayalIntegrity(state); ixianNexusBetrayalIntegrity(state); ixianNexusReplacementIntegrity(state); @@ -30143,6 +30336,9 @@ export function viewGame(state: Game, id: string) { setupPending: setupPending(g), leaderSkills: projectedLeaderSkills(g, id), bureaucrat: projectedBureaucrat(g, id), + spiceBankerIncomePreview: g.spiceBankerIncomePreview === true, + spiceBankerIncome: g.spiceBankerIncomePreview + ? bankerIncomeRule(() => projectBankerIncome(g.spiceBankerIncome!, bankerIncomeContext(g))) : null, bribeOptions: projectedBribes(g, me), mentat: projectedMentat(g, id), rihani: projectedRihani(g, id), @@ -30705,6 +30901,7 @@ export function viewGame(state: Game, id: string) { response: g.pendingGuildBetrayal || g.pendingRicheseBetrayal || g.pendingKull?.stage === 'offer' ? null : g.response ? { ...g.response, + ...(g.response.spiceBankerIncomePayment ? {spiceBankerIncomePayment: undefined} : {}), ...(g.response.bureaucratPayment ? {bureaucratPayment:undefined,bureaucratPaymentEvent:undefined} : {}), ...(g.response.guildContributions ? { guildContributions: undefined } : {}), ...(g.response.guildPaymentProof ? { guildPaymentProof: undefined } : {}), diff --git a/game/reference.ts b/game/reference.ts index e7913017..0af35d8b 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -4439,6 +4439,37 @@ export const RULE_TOPICS: RuleTopic[] = [ { area: 'Verification', status: 'Partial', detail: 'Genuine setup, all physical assignments, Terror death/revival, movement/payment/battle continuations, mandatory retention exclusions and authenticated concurrent recovery have focused checks. Sample and browser results are recorded separately; full combined-rule acceptance remains open.', evidence: ['tests/moritani-skills-integration.test.ts', 'tests/moritani-skills-custody.test.ts', 'tests/moritani-skills-battle.test.ts', 'tests/moritani-skills-recovery.test.ts', 'tests/moritani-skills-worm.test.ts'] }, ], }, + { + id: 'spice-banker-income', + title: 'Spice Banker: front-shield income until Mentat', + category: 'Advanced & expansions', + coverage: 'Partial', + developmentStage: 'Verified', + summary: 'The fresh banker-income preview gains one deferred spice once per phase when another player makes one qualifying payment to the Spice Bank, then collects automatically at native Mentat.', + searchText: 'Spice Banker normal income bank payment four spice phase deferred front shield original owner death capture Mentat', + steps: [ + 'This separate fresh local profile requires unique two-to-six classic factions, Basic or Advanced, the base Treachery Deck and all fourteen Leader Skills alone. No public start, old-game retrofit, expansion deck, Nexus, Homeworld, Discovery, Tech Token or other overlay is enabled.', + 'The printed normal band is mandatory once per phase: when another player makes a payment of at least four spice to the Spice Bank, gain one spice and place it in front of the shield. It is an additional bank grant, not a reduction of the payer’s payment or another player’s income.', + 'Only an actual completed positive bank payment qualifies. A payment to a player, zero/free/waived cost, self-payment, invalid declaration or unfinished possible payment supplies no income. Several smaller payments do not combine to reach four.', + 'Use the original actual bank portion after recipient and native-counter settlement, not a quoted fee or guessed wallet change. Paid auctions, funded shipments, paid revival and eligible resolved battle support retain their original money, card, force and phase continuation once. Canceled Emperor or Guild income can leave a real bank payment; a stopped shipment cannot.', + 'Actual payer contribution legs stay distinct. Two different players paying two each do not become one player paying four. Banker’s separate one-to-three battle payment does not combine with two support to manufacture a qualifying payment. These split-source conventions are explicit conservative local compositions, not a publisher split-payment example.', + 'A new grant requires an actual available living native trainer. A captive grants no normal income. Hidden unselected skills do not supply their normal band; in the trainer’s own battle, its actual selected surviving leader supplies the appropriate eligibility after reveal/resolution, not private pre-reveal timing.', + 'One physical Banker card has one use per turn and phase. Repeated payments, refresh, death, return or same-phase reassignment cannot reset that use; the next phase or turn permits a new grant.', + 'The front-shield pile is not spendable spice. It cannot fund the triggering payment, a later bid, shipment, revival, support or lower-band Banker commitment before collection. Human and all four AI policies keep using their actual available balance.', + 'At the real current Mentat opening, collect the uncollected grants automatically once before optional actions and victory. No extra response, counter, confirmation or collect action is added. Collection itself is not another player-to-bank payment.', + 'This fresh local prototype provisionally keeps already-earned spice with the original gaining faction when the trainer dies, is captured or the skill changes owner. That is a conservative earned-currency custody choice, not an answer to the previously asked universal death/capture question.', + 'Public projection shows only physical front-shield owner/count and current-phase use. Private payment receipts, exact hidden source amounts, plans, and future skill entitlement are not exposed. Current and collected history survive JSON/recovery without replaying either original payment or collection.', + 'Bounded development verification covers actual payment families, threshold/recipient/trainer eligibility, deferred budgets, lifecycle custody and authenticated payment/collection races. Native smoke preserves all 33 Treachery Cards and 14 skills; two original-backed-up six-seat Basic/Advanced rooms verify human phone payment, refresh and actual Mentat collection. Local HTTP remains 49/55 with six upstream 503 failures, not green. Existing lower battle spending stays separate; complete skill/module and deployment acceptance remain open.', + ], + related: ['leader-skills', 'battle', 'bidding', 'revival', 'implementation-checklist'], + checklist: [ + { area: 'Implementation', status: 'Implemented', detail: 'Actual final bank payer legs, native skill eligibility, one physical-card phase grant and original-owner automatic Mentat collection connect through the fresh classic skill profile.', evidence: ['game/engine.ts', 'game/spice-banker-income.ts'] }, + { area: 'Player controls', status: 'Implemented', detail: 'A full-width read-only public front-shield notice explains unavailable spice and automatic collection; genuine original payment and final Collection controls work at 390px through refresh. No new income action or confirmation.', evidence: ['components/spice-banker-income.tsx', 'components/game-table.tsx'] }, + { area: 'AI', status: 'Partial', detail: 'All four minimal legal policies preserve identical available-budget actions with deferred income present or absent. Original wallets exclude front-shield currency; strategy/calibration remain deferred.', evidence: ['game/bots.ts', 'game/spice-banker.ts'] }, + { area: 'Documentation', status: 'Partial', detail: 'Printed gain/threshold/one-payment/phase/collection, original payment separation, provisional earned custody and per-payer split composition are explicit without universal rulings.', evidence: ['docs/LEADER_SKILLS_RULES.md', 'docs/SPICE_BANKER_RUNTIME.md', 'docs/RULE_DECISIONS.md'] }, + { area: 'Verification', status: 'Partial', detail: 'Types/lint, 6,487 offline tests and 140 focused cases pass. Eighteen native source-to-Mentat cases, four legal-budget policies, two preserved six-seat CLI/phone rooms and two independent no-finding reviews cover this bounded profile. Local HTTP 49/55 remains limited by six 503 failures; no deployed/full-module claim.', evidence: ['tests/fixture-spice-banker-income.ts', 'tests/spice-banker-income.test.ts', 'tests/spice-banker-income-runtime.test.ts', 'tests/spice-banker-income-recovery.test.ts', 'tests/prototype-room.test.ts'] }, + ], + }, { id: 'leader-skills', title: 'Leader Skills', @@ -4460,17 +4491,17 @@ export const RULE_TOPICS: RuleTopic[] = [ 'A surviving skilled Sandmaster victory automatically adds three board spice to an existing pile in the battle territory, even with no surviving forces. Multiple piles await a placement ruling.', 'Rihani normally inspects two random Traitor Deck cards privately, then reshuffles them. A surviving selected native Rihani also offers a separate optional draw of two. Decide before seeing them: once drawn, keep one new card and publicly reveal and return one unused old card. The other new card returns secretly. A captured Rihani grants only the optional exchange. Your private history records both samples; a card just called to win is already used.', 'Smuggler can include one free accompanying force in an off-planet shipment into an empty territory. Select the entire physical total; three forces cost the ordinary price of two. The checkbox can decline this optional benefit. Guild rounding follows the free force. Richese may instead ship one separate free reserve force beside its owned No-Field into an empty territory; the marker retains its ordinary price and hidden value. Existing forces, including advisors, prevent the bonus; ordinary Fremen on-planet reinforcement has no bonus.', - 'Spice Banker can commit one through three own spice with its Battle Plan, separate from force support. Only the surviving selected skilled disc gains that strength. The amount stays private until authorized plan inspection or public reveal. It is paid on win or loss, including leader death, except a sole traitor-call winner spends nothing. Normal deferred income remains unfinished.', + 'Spice Banker can commit one through three own spice with its Battle Plan, separate from force support. Only the surviving selected skilled disc gains that strength. The amount stays private until authorized plan inspection or public reveal. It is paid on win or loss, including leader death, except a sole traitor-call winner spends nothing. Normal deferred income connects only in the separate fresh classic banker-income profile; ordinary/default and combined starts are unchanged.', 'Diplomat may use one committed Worthless card as the opponent’s base Shield or Snooper if you played no defense. After both plans reveal, choose the card or decline before traitor decisions. A native Diplomat may protect its own selected trained leader this way; a captive grants no upper effect. The chosen Worthless must be discarded afterward, even if you win. A surviving trained leader that loses a supported battle may retreat selected undialed physical counters up to its printed/copied disc strength into one empty adjacent non-stronghold territory before ordinary loser losses; modified defenses remain unfinished.', 'Mentat has a private developer preview for naming a specific weapon before posture and faction powers. The target privately shows the named held card or chooses another held card. It stays in hand and need not enter a Battle Plan. Every answer uses the same private response step; ordinary activation awaits the user’s timing-design decision. Empty hands skip without an answer. Private history records an observation, not a later hand guarantee.', 'Bureaucrat may redirect two spice from a payment of at least five between two other players, once per phase. The payer still pays in full; the recipient receives two less. Declining keeps the use available for a later payment. Paid bribes qualify when transferred, with the remaining share still unavailable until Mentat Pause. Ordinary auctions, Guild income, Richese sales and actual paid Tleilaxu revival income in the Basic Tleilaxu skill profile have connected payment paths. A separate free-revival award is not part of the paid price and stays with Tleilaxu. Split funding, gifts, other payment families and combined modules remain unfinished.', 'Smuggler records the existing battle-territory spice when plans reveal, capped by the selected leader’s unmodified strength. If that leader survives, it collects automatically even when losing or no forces remain. A captive pays its controller. Pending spice cannot fund the plan. Collection precedes Sandmaster’s addition; a drained pile receives none. Modified strength, multiple positive piles and combined modules remain guarded. Older saved battles keep their original behavior.', - 'Other special Sandmaster relocation and Spice Banker normal income remain missing. Diplomat retreat uses a documented conservative outcome-order interpretation in standalone Leader Skills play. Foreign gholas, Face Dancers and combined modules remain unfinished. This development prototype does not enable public module starts.', + 'Other special Sandmaster relocation remains missing. Normal Banker income has a separate fresh classic source-to-Mentat preview with provisional original-earned-faction custody, not general activation. Diplomat retreat uses a documented conservative outcome-order interpretation in standalone Leader Skills play. Foreign gholas, Face Dancers and combined modules remain unfinished. This development prototype does not enable public module starts.', ...LEADER_SKILL_CARDS.map(card => `${card.name}. Normal: ${card.normal.join(' ')} Skilled battle: ${card.battle.join(' ')}`), ], - related: ['choam-leader-skills', 'ixian-leader-skills', 'tleilaxu-leader-skills', 'moritani-leader-skills', 'setup','choam-modules','implementation-checklist'], + related: ['spice-banker-income', 'choam-leader-skills', 'ixian-leader-skills', 'tleilaxu-leader-skills', 'moritani-leader-skills', 'setup','choam-modules','implementation-checklist'], checklist: [ - { area:'Implementation',status:'Partial',detail:'Exact fourteen-card custody, source-ordered setup, public capture, concealment, death and own revival connect five role bonuses, Planetologist movement/Special substitution and Suk Graduate casualty rescue. Rihani inspection/exchange and lower Mentat/Bureaucrat/Sandmaster effects are connected. Smuggler normal reserve shipping, the owned Richese No-Field companion, unmodified battle collection and optional Sandmaster ground-route and native Fremen worm-ride collection are connected. A separately opted-in Mentat preview connects private pre-plan questions and historical observations while ordinary activation awaits the uniform-response decision. Spice Banker lower spending and native Diplomat base Shield/Snooper copying are connected; skilled Diplomat retreat now transfers undialed normal/elite forces before losing casualties in supported battles. Spice Banker normal income, other remaining bands and combined modules remain missing. Bureaucrat redirects supported third-party auction, shipment and bribe payments with exact saved once-per-phase use.',evidence:['game/leader-skill-cards.ts','game/leader-skills.ts','game/leader-skill-combat.ts','game/suk-graduate.ts','game/rihani-decipherer.ts','game/mentat-question.ts','game/bureaucrat-payment.ts','game/leader-skill-battle-board.ts','game/smuggler-shipment.ts','game/smuggler-no-field.ts','game/smuggler-battle.ts','game/spice-banker.ts','game/sandmaster-movement.ts','game/sandmaster-worm.ts','game/diplomat-defense.ts','game/diplomat-retreat.ts','game/engine.ts'] }, + { area:'Implementation',status:'Partial',detail:'Exact fourteen-card custody, source-ordered setup, public capture, concealment, death and own revival connect five role bonuses, Planetologist movement/Special substitution and Suk Graduate casualty rescue. Rihani inspection/exchange and lower Mentat/Bureaucrat/Sandmaster effects are connected. Smuggler normal reserve shipping, the owned Richese No-Field companion, unmodified battle collection and optional Sandmaster ground-route and native Fremen worm-ride collection are connected. A separately opted-in Mentat preview connects private pre-plan questions and historical observations while ordinary activation awaits the uniform-response decision. Spice Banker lower spending and native Diplomat base Shield/Snooper copying are connected; skilled Diplomat retreat now transfers undialed normal/elite forces before losing casualties in supported battles. Normal Banker income connects only in the separate fresh classic banker-income profile; other remaining bands and combined modules remain missing. Bureaucrat redirects supported third-party auction, shipment and bribe payments with exact saved once-per-phase use.',evidence:['game/leader-skill-cards.ts','game/leader-skills.ts','game/leader-skill-combat.ts','game/suk-graduate.ts','game/rihani-decipherer.ts','game/mentat-question.ts','game/bureaucrat-payment.ts','game/leader-skill-battle-board.ts','game/smuggler-shipment.ts','game/smuggler-no-field.ts','game/smuggler-battle.ts','game/spice-banker.ts','game/spice-banker-income.ts','game/sandmaster-movement.ts','game/sandmaster-worm.ts','game/diplomat-defense.ts','game/diplomat-retreat.ts','game/engine.ts'] }, { area:'Player controls',status:'Partial',detail:'Private card/leader selection, readable inspectors, public battle posture and revival draw/decline, Planetologist range/gather controls, Special weapon selection and Suk Graduate physical rescue choices are connected. Rihani has private history and separate keep-new/reveal-old controls; automatic battle effects need no confirmation. Smuggler has explicit ordinary shipping opt-out and No-Field companion opt-in with physical-versus-priced force counts, plus automatic surviving-leader battle collection with reveal-time guidance. Banker exposes separate sealed spending and authorized inspection. Sandmaster exposes explicit legal routes, per-territory collection choices and an optional worm-destination checkbox. Diplomat offers a named committed Worthless choice or decline after public reveal, and a separate surviving-loser destination/normal/elite retreat or decline. Bureaucrat offers redirect or full payment with explicit recipient amounts and deferred bribe custody. Remaining effect controls are missing.',evidence:['components/leader-skills.tsx','components/planetologist-movement.tsx','components/suk-graduate.tsx','components/rihani-decipherer.tsx','components/mentat-question.tsx','components/bureaucrat-payment.tsx','components/leader-skill-battle-guide.tsx','components/smuggler-shipment.tsx','components/spice-banker.tsx','components/sandmaster-movement.tsx','components/sandmaster-worm.tsx','components/diplomat-defense.tsx','components/diplomat-retreat.tsx','components/game-table.tsx'] }, { area:'AI',status:'Partial',detail:'All four profiles use private offers, legal visibility/revival actions and Planetologist movement/battle candidates and legal Suk Graduate rescues, Rihani exchanges, shared battle scoring, Smuggler reserve/owned No-Field shipments and unmodified collection plans, Sandmaster route and worm collection, funded Banker commitments and optional Diplomat copying from public revealed defenses or choosing legal losing-battle retreat. Bureaucrat uses the public payee alliance to redirect enemy income or preserve allied income. These are legal prototype paths; complete skill strategy and difficulty calibration remain unfinished.',evidence:['game/bots.ts'] }, { area:'Documentation',status:'Partial',detail:'All fourteen faces and common rules are recorded. Skilled capture follows public physical-card custody; replacement entitlement and other material timing interpretations remain explicit.',evidence:['docs/LEADER_SKILLS_RULES.md','docs/LEADER_SKILLS_CAPTURE.md','docs/LEADER_SKILLS_RUNTIME.md','docs/PLANETOLOGIST_RULES.md','docs/SUK_GRADUATE_RULES.md','docs/LEADER_BATTLE_EFFECTS.md','docs/MENTAT_QUESTION.md','docs/BUREAUCRAT_PAYMENTS.md','docs/SMUGGLER_SHIPMENT.md','docs/SPICE_BANKER_RUNTIME.md','docs/SANDMASTER_WORM.md','docs/DIPLOMAT_DEFENSE.md'] }, diff --git a/game/spice-banker-income.ts b/game/spice-banker-income.ts new file mode 100644 index 00000000..b45b764c --- /dev/null +++ b/game/spice-banker-income.ts @@ -0,0 +1,382 @@ +import { LEADER_SKILL_CARDS } from './leader-skill-cards'; +import type { LeaderSkillAssignment, SkillRoster } from './leader-skills'; + +export type BankerIncomeSourceKind = + | 'auction' + | 'shipment' + | 'force-revival' + | 'leader-revival' + | 'kh-revival' + | 'emperor-extra-revival' + | 'battle-support'; +export type BankerIncomeContext = { + turn: number; + phase: number; + players: SkillRoster; + assignments: readonly LeaderSkillAssignment[]; +}; +export type BankerIncomeAuthority = { + kind: BankerIncomeSourceKind; + event: string; + turn: number; + phase: number; + /** Only positive, actually paid final BANK legs of this one native payment. */ + bankLegs: { payer: string; amount: number }[]; + trainer: { + assignment: LeaderSkillAssignment; + faceUp: boolean; + captured: boolean; + selected: boolean; + resolved: boolean; + survived: boolean; + } | null; +}; +export type BankerIncomeGrant = { owner: string; amount: 1; stamp: string }; +export type BankerIncomeReceipt = { + cursor: number; + previous: string; + source: BankerIncomeAuthority; + grant: BankerIncomeGrant | null; + signature: string; +}; +export type BankerIncomeCredit = { owner: string; amount: number }; +export type BankerIncomeCollectionAuthority = { event: string; turn: number; phase: 8 }; +export type BankerIncomeCollection = BankerIncomeCollectionAuthority & { + cursor: number; + previous: string; + grants: number[]; + credits: BankerIncomeCredit[]; + signature: string; +}; +export type BankerIncomeState = { + version: 1; + seats: string[]; + sources: BankerIncomeReceipt[]; + collections: BankerIncomeCollection[]; + cursor: number; + head: string; + signature: string; +}; +export class BankerIncomeError extends Error { + constructor(message: string) { + super(message); + this.name = 'BankerIncomeError'; + } +} +const LIMIT = 50_000; +const sourcePhases: Record = { + auction: 3, shipment: 5, 'force-revival': 4, 'leader-revival': 4, + 'kh-revival': 4, 'emperor-extra-revival': 4, 'battle-support': 6, +}; +const text = (value: unknown): value is string => + typeof value === 'string' && value.length > 0 && value.length <= 512 && value.trim() === value; +const whole = (value: unknown): value is number => + typeof value === 'number' && Number.isSafeInteger(value) && value >= 0; +const array = (value: unknown): boolean => Array.isArray(value); +function requireIncome(condition: unknown, message: string): asserts condition { + if (!condition) throw new BankerIncomeError(message); +} +function keys(value: unknown, expected: readonly string[]): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value) && + [Object.prototype, null].includes(Object.getPrototypeOf(value)) && + Object.keys(value).length === expected.length && expected.every((key) => Object.hasOwn(value, key)); +} +/** Fixed-size consistency checksum, not authentication of a rewritten save. */ +function signature(value: unknown): string { + const serialized = JSON.stringify(value); + let first = 2166136261; + let second = 3339675911; + for (let i = 0; i < serialized.length; i++) { + const code = serialized.charCodeAt(i); + first = Math.imul(first ^ code, 16777619); + second = Math.imul(second ^ code, 2246822519); + } + return `${(first >>> 0).toString(16).padStart(8, '0')}${(second >>> 0).toString(16).padStart(8, '0')}`; +} +const validSignature = (value: unknown): value is string => + typeof value === 'string' && /^[0-9a-f]{16}$/.test(value); +function sourceFields(source: BankerIncomeAuthority): unknown[] { + const trainer = source.trainer; + return [source.kind, source.event, source.turn, source.phase, + source.bankLegs.map((leg) => [leg.payer, leg.amount]), trainer === null ? null : [ + trainer.assignment.skill, trainer.assignment.leader, trainer.assignment.owner, + trainer.faceUp, trainer.captured, trainer.selected, trainer.resolved, trainer.survived, + ]]; +} +function receiptSignature(receipt: BankerIncomeReceipt): string { + return signature(['banker-income-source', receipt.cursor, receipt.previous, + sourceFields(receipt.source), receipt.grant === null ? null : + [receipt.grant.owner, receipt.grant.amount, receipt.grant.stamp]]); +} +function collectionSignature(collection: BankerIncomeCollection): string { + return signature(['banker-income-collection', collection.cursor, collection.previous, + collection.event, collection.turn, collection.phase, collection.grants, + collection.credits.map((credit) => [credit.owner, credit.amount])]); +} +function stateSignature(state: BankerIncomeState): string { + return signature(['banker-income-state', state.version, state.seats, + state.sources.length, state.collections.length, state.cursor, state.head]); +} +function stamp(turn: number, phase: number): string { + return JSON.stringify([turn, phase, 'spice-banker']); +} +function validateContext(context: BankerIncomeContext): string[] { + requireIncome(keys(context, ['turn', 'phase', 'players', 'assignments']) && + whole(context.turn) && context.turn > 0 && whole(context.phase) && context.phase <= 8 && + array(context.players) && context.players.length >= 2 && context.players.length <= 6 && + array(context.assignments), 'Invalid Banker income native context.'); + const seats: string[] = []; + const leaders = new Set(); + for (const player of context.players) { + requireIncome(keys(player, ['id', 'leaders']) && text(player.id) && array(player.leaders) && + !seats.includes(player.id), 'Invalid Banker income native roster.'); + seats.push(player.id); + for (const leader of player.leaders) { + requireIncome(leader && typeof leader === 'object' && !Array.isArray(leader) && + Object.keys(leader).every((key) => ['id', 'dead', 'capturedBy', 'gholaBy'].includes(key)) && + text(leader.id) && typeof leader.dead === 'boolean' && !leaders.has(leader.id) && + (leader.capturedBy === undefined || text(leader.capturedBy)) && + (leader.gholaBy === undefined || text(leader.gholaBy)), 'Invalid Banker income native leader.'); + leaders.add(leader.id); + } + } + const skills = new Set(); + const owners = new Set(); + for (const assignment of context.assignments) { + requireIncome(keys(assignment, ['skill', 'leader', 'owner']) && + LEADER_SKILL_CARDS.some((card) => card.id === assignment.skill) && + !skills.has(assignment.skill) && !owners.has(assignment.owner) && + context.players.some((player) => player.id === assignment.owner && + player.leaders.some((leader) => leader.id === assignment.leader)), + 'Invalid Banker income native skill assignment.'); + skills.add(assignment.skill); + owners.add(assignment.owner); + } + return seats.sort(); +} +function validateSource(context: BankerIncomeContext, source: BankerIncomeAuthority, current: boolean): void { + requireIncome(keys(source, ['kind', 'event', 'turn', 'phase', 'bankLegs', 'trainer']) && + Object.hasOwn(sourcePhases, source.kind) && text(source.event) && + whole(source.turn) && source.turn > 0 && whole(source.phase) && + source.phase === sourcePhases[source.kind] && + (source.turn < context.turn || (source.turn === context.turn && source.phase <= context.phase)) && + (!current || (source.turn === context.turn && source.phase === context.phase)) && + array(source.bankLegs) && source.bankLegs.length <= context.players.length, + 'Invalid, unsupported or stale Banker income payment source.'); + const payers = new Set(); + for (const leg of source.bankLegs) { + requireIncome(keys(leg, ['payer', 'amount']) && text(leg.payer) && + context.players.some((player) => player.id === leg.payer) && !payers.has(leg.payer) && + whole(leg.amount) && leg.amount > 0, 'Banker income requires distinct actual positive BANK payer legs.'); + payers.add(leg.payer); + } + const trainer = source.trainer; + if (trainer === null) return; + requireIncome(keys(trainer, ['assignment', 'faceUp', 'captured', 'selected', 'resolved', 'survived']) && + keys(trainer.assignment, ['skill', 'leader', 'owner']) && + trainer.assignment.skill === 'spice-banker' && text(trainer.assignment.leader) && + text(trainer.assignment.owner) && + [trainer.faceUp, trainer.captured, trainer.selected, trainer.resolved, trainer.survived] + .every((value) => typeof value === 'boolean') && + (!trainer.selected || (!trainer.faceUp && source.kind === 'battle-support')) && + (source.kind === 'battle-support' || (!trainer.selected && trainer.resolved && trainer.survived)), + 'Invalid Banker income trainer posture or resolved survival.'); + const native = context.players.find((player) => player.id === trainer.assignment.owner) + ?.leaders.find((leader) => leader.id === trainer.assignment.leader); + requireIncome(native, 'Banker income trainer is not its original native disc.'); + if (current) { + requireIncome(!native.dead && !!native.capturedBy === trainer.captured && + context.assignments.some((assignment) => assignment.skill === 'spice-banker' && + assignment.owner === trainer.assignment.owner && assignment.leader === trainer.assignment.leader), + 'Banker income source lacks the actual living native trainer and physical skill.'); + } +} +export function validateBankerIncomeSource(context: BankerIncomeContext, source: BankerIncomeAuthority): void { + validateContext(context); + validateSource(context, source, true); +} +function expectedGrant(source: BankerIncomeAuthority, used: ReadonlySet): BankerIncomeGrant | null { + const trainer = source.trainer; + const phaseStamp = stamp(source.turn, source.phase); + if (trainer === null || trainer.captured || !trainer.survived || + (source.kind === 'battle-support' && !trainer.resolved) || + (!trainer.faceUp && !(trainer.selected && trainer.resolved)) || used.has(phaseStamp) || + !source.bankLegs.some((leg) => leg.payer !== trainer.assignment.owner && leg.amount >= 4)) return null; + return { owner: trainer.assignment.owner, amount: 1, stamp: phaseStamp }; +} +function creditsFor(seats: readonly string[], pending: readonly BankerIncomeReceipt[]): BankerIncomeCredit[] { + return seats.flatMap((owner) => { + const amount = pending.reduce((total, receipt) => total + (receipt.grant?.owner === owner ? 1 : 0), 0); + return amount === 0 ? [] : [{ owner, amount }]; + }); +} +function sameCredits(left: readonly BankerIncomeCredit[], right: readonly BankerIncomeCredit[]): boolean { + return left.length === right.length && left.every((credit, index) => + keys(credit, ['owner', 'amount']) && credit.owner === right[index].owner && credit.amount === right[index].amount); +} +/** Closed history checks original entitlement, never current life, card custody or wallet. */ +export function validateBankerIncomeState(state: BankerIncomeState, context: BankerIncomeContext): void { + const seats = validateContext(context); + requireIncome(keys(state, ['version', 'seats', 'sources', 'collections', 'cursor', 'head', 'signature']) && + state.version === 1 && array(state.seats) && + state.seats.length === seats.length && state.seats.every((seat, index) => seat === seats[index]) && + array(state.sources) && array(state.collections) && whole(state.cursor) && + state.cursor === state.sources.length + state.collections.length && state.cursor <= LIMIT && + validSignature(state.head) && validSignature(state.signature), 'Malformed Banker income history.'); + let head = signature(['banker-income-genesis', seats]); + let sourceIndex = 0; + let collectionIndex = 0; + let lastTurn = 0; + let lastPhase = 0; + const events = new Set(); + const used = new Set(); + const collectedTurns = new Set(); + let pending: BankerIncomeReceipt[] = []; + for (let cursor = 1; cursor <= state.cursor; cursor++) { + const receipt = state.sources[sourceIndex]; + const collection = state.collections[collectionIndex]; + const isSource = receipt?.cursor === cursor; + requireIncome(isSource !== (collection?.cursor === cursor), 'Missing or duplicate Banker income history cursor.'); + let event: string; + let turn: number; + let phase: number; + if (isSource) { + requireIncome(keys(receipt, ['cursor', 'previous', 'source', 'grant', 'signature']) && + receipt.previous === head && validSignature(receipt.signature), 'Corrupt Banker income source head.'); + validateSource(context, receipt.source, false); + const grant = expectedGrant(receipt.source, used); + requireIncome(grant === null ? receipt.grant === null : + keys(receipt.grant, ['owner', 'amount', 'stamp']) && receipt.grant.owner === grant.owner && + receipt.grant.amount === 1 && receipt.grant.stamp === grant.stamp, 'Corrupt Banker income deferred grant.'); + requireIncome(receipt.signature === receiptSignature(receipt), 'Corrupt Banker income source signature.'); + if (grant !== null) { + used.add(grant.stamp); + pending.push(receipt); + } + ({ event, turn, phase } = receipt.source); + head = receipt.signature; + sourceIndex++; + } else { + requireIncome(keys(collection, ['cursor', 'previous', 'event', 'turn', 'phase', 'grants', 'credits', 'signature']) && + collection.previous === head && text(collection.event) && whole(collection.turn) && collection.turn > 0 && + collection.phase === 8 && !collectedTurns.has(collection.turn) && + (collection.turn < context.turn || (collection.turn === context.turn && context.phase === 8)) && + Array.isArray(collection.grants) && collection.grants.length === pending.length && + collection.grants.every((grant, index) => grant === pending[index].cursor) && + Array.isArray(collection.credits) && sameCredits(collection.credits, creditsFor(seats, pending)) && + validSignature(collection.signature) && collection.signature === collectionSignature(collection), + 'Corrupt, incomplete or replayed Banker income collection.'); + ({ event, turn, phase } = collection); + collectedTurns.add(turn); + pending = []; + head = collection.signature; + collectionIndex++; + } + requireIncome(!events.has(event) && (turn > lastTurn || (turn === lastTurn && phase >= lastPhase)), + 'Duplicate or out-of-order Banker income native event.'); + events.add(event); + lastTurn = turn; + lastPhase = phase; + } + requireIncome(head === state.head && sourceIndex === state.sources.length && + collectionIndex === state.collections.length && state.signature === stateSignature(state), + 'Corrupt Banker income current head or signature.'); +} +export function createBankerIncomeState(context: BankerIncomeContext): BankerIncomeState { + const seats = validateContext(context); + const state: BankerIncomeState = { + version: 1, seats, sources: [], collections: [], cursor: 0, + head: signature(['banker-income-genesis', seats]), signature: '', + }; + state.signature = stateSignature(state); + return state; +} +function requireFreshEvent(state: BankerIncomeState, event: string): void { + requireIncome(state.cursor < LIMIT && !state.sources.some((receipt) => receipt.source.event === event) && + !state.collections.some((collection) => collection.event === event), + 'Banker income native event was already committed or history is full.'); +} +export function quoteBankerIncome(state: BankerIncomeState, context: BankerIncomeContext, authority: BankerIncomeAuthority): { + next: BankerIncomeState; receipt: BankerIncomeReceipt; +} { + validateBankerIncomeState(state, context); + validateSource(context, authority, true); + requireFreshEvent(state, authority.event); + const used = new Set(state.sources.flatMap((receipt) => receipt.grant === null ? [] : [receipt.grant.stamp])); + const receipt: BankerIncomeReceipt = { + cursor: state.cursor + 1, previous: state.head, source: structuredClone(authority), + grant: expectedGrant(authority, used), signature: '', + }; + receipt.signature = receiptSignature(receipt); + const next = structuredClone(state); + next.sources.push(receipt); + next.cursor = receipt.cursor; + next.head = receipt.signature; + next.signature = stateSignature(next); + return { next, receipt: structuredClone(receipt) }; +} +/** Caller commits this ledger and original native payment/suffix in the same room write. */ +export function commitBankerIncome(state: BankerIncomeState, context: BankerIncomeContext, + authority: BankerIncomeAuthority, receipt: BankerIncomeReceipt): BankerIncomeState { + const quote = quoteBankerIncome(state, context, authority); + requireIncome(keys(receipt, ['cursor', 'previous', 'source', 'grant', 'signature']), 'Malformed Banker income receipt.'); + validateSource(context, receipt.source, true); + requireIncome(receipt.cursor === quote.receipt.cursor && receipt.previous === quote.receipt.previous && + (quote.receipt.grant === null ? receipt.grant === null : keys(receipt.grant, ['owner', 'amount', 'stamp']) && + receipt.grant.owner === quote.receipt.grant.owner && receipt.grant.amount === 1 && + receipt.grant.stamp === quote.receipt.grant.stamp) && + receipt.signature === quote.receipt.signature && receipt.signature === receiptSignature(receipt), + 'Banker income receipt is foreign, stale or disagrees with independent native authority.'); + return quote.next; +} +export function quoteBankerIncomeCollection(state: BankerIncomeState, context: BankerIncomeContext, + authority: BankerIncomeCollectionAuthority): { + next: BankerIncomeState; collection: BankerIncomeCollection; credits: BankerIncomeCredit[]; + } { + validateBankerIncomeState(state, context); + requireIncome(keys(authority, ['event', 'turn', 'phase']) && text(authority.event) && + authority.turn === context.turn && authority.phase === 8 && context.phase === 8, + 'Banker income collects only at the actual current native Mentat opening.'); + requireFreshEvent(state, authority.event); + requireIncome(!state.collections.some((collection) => collection.turn === authority.turn), + 'Banker income already collected at this turn\'s Mentat opening.'); + const collected = new Set(state.collections.flatMap((collection) => collection.grants)); + const pending = state.sources.filter((receipt) => receipt.grant !== null && !collected.has(receipt.cursor)); + const credits = creditsFor(state.seats, pending); + const collection: BankerIncomeCollection = { + ...authority, cursor: state.cursor + 1, previous: state.head, + grants: pending.map((receipt) => receipt.cursor), credits, signature: '', + }; + collection.signature = collectionSignature(collection); + const next = structuredClone(state); + next.collections.push(collection); + next.cursor = collection.cursor; + next.head = collection.signature; + next.signature = stateSignature(next); + return { next, collection: structuredClone(collection), credits: structuredClone(credits) }; +} +export function commitBankerIncomeCollection(state: BankerIncomeState, context: BankerIncomeContext, + authority: BankerIncomeCollectionAuthority, collection: BankerIncomeCollection): BankerIncomeState { + const quote = quoteBankerIncomeCollection(state, context, authority); + requireIncome(keys(collection, ['cursor', 'previous', 'event', 'turn', 'phase', 'grants', 'credits', 'signature']) && + collection.cursor === quote.collection.cursor && collection.previous === quote.collection.previous && + collection.event === authority.event && collection.turn === authority.turn && collection.phase === 8 && + Array.isArray(collection.grants) && collection.grants.length === quote.collection.grants.length && + collection.grants.every((grant, index) => grant === quote.collection.grants[index]) && + Array.isArray(collection.credits) && sameCredits(collection.credits, quote.credits) && + collection.signature === quote.collection.signature && collection.signature === collectionSignature(collection), + 'Banker income collection is foreign, stale, incomplete or corrupt.'); + return quote.next; +} +export function projectBankerIncome(state: BankerIncomeState, context: BankerIncomeContext): { + deferred: BankerIncomeCredit[]; usedThisPhase: string[]; +} { + validateBankerIncomeState(state, context); + const collected = new Set(state.collections.flatMap((collection) => collection.grants)); + const pending = state.sources.filter((receipt) => receipt.grant !== null && !collected.has(receipt.cursor)); + return { + deferred: creditsFor(state.seats, pending), + usedThisPhase: state.sources.flatMap((receipt) => + receipt.grant?.stamp === stamp(context.turn, context.phase) ? [receipt.grant.owner] : []), + }; +} diff --git a/tests/fixture-spice-banker-income.ts b/tests/fixture-spice-banker-income.ts new file mode 100644 index 00000000..d482aee4 --- /dev/null +++ b/tests/fixture-spice-banker-income.ts @@ -0,0 +1,386 @@ +import assert from 'node:assert/strict'; +import {applyAction,createGame,handLimit,initializeSpiceBankerIncomeGameForAudit,joinGame,newPlayer,viewGame,type Action,type Game} from '../game/engine'; +import {botActions} from '../game/bots'; +import {TERRITORIES} from '../game/board'; +import {LEADER_SKILL_CARDS} from '../game/leader-skill-cards'; +import type {FactionId} from '../game/catalog'; +import {quoteNormalAuctionNext} from '../game/normal-auction'; +import type {BankerIncomeSourceKind} from '../game/spice-banker-income'; +import {forceRevivalQuote} from '../game/revival'; +import {quoteBattleResolution,type ResolutionCombatant} from '../game/battle-resolution-quote'; +import {quoteSpiceCollection} from '../game/board-resolution-quote'; +import {leaderSkillStrongholdCount} from '../game/leader-skill-battle-board'; +import type {Card} from '../game/cards'; + +export type SpiceBankerIncomeFixtureOptions = {initial?:Game;advanced?:boolean;kind?:BankerIncomeSourceKind|'cross-shipment'|'return-shipment';ownerIsPayer?:boolean;payerFaction?:FactionId;bankerFaction?:FactionId;stageKarama?:boolean;stageIncomeCounter?:boolean;amount?:number;allySplit?:number;emperor?:boolean;guild?:boolean;seatIds?:string[];trainerSelected?:boolean;trainerDies?:boolean;trainerHidden?:boolean}; +export type SpiceBankerIncomeNativeStep = {actor:string;action:Action}; +export type SpiceBankerIncomeFixture = {game:Game;beforePayment:Game;actor:string;payer:string;paymentAction:Action;owner:string;leader:string;event:string;kind:NonNullable;amount:number;bankLegs:{payer:string;amount:number}[]}; + +export function nextSpiceBankerIncomeNativeStep(game:Game):SpiceBankerIncomeNativeStep|null { + if(game.status==='finished') return null; + if(game.pendingTreacheryDiscard) return {actor:game.host,action:{type:'advanceBots'}}; + if(game.response) return {actor:game.players.find(p=>!game.response!.passed.includes(p.id))!.id,action:{type:'passResponse'}}; + if(game.phaseOpening) return {actor:game.players.find(p=>!game.phaseOpening!.passed.includes(p.id))!.id,action:{type:'ready'}}; + if(game.decision?.kind==='bureaucratPayment') return {actor:game.decision.player,action:{type:'decision',event:game.decision.event,redirect:false}}; + if(game.decision?.kind==='auctionPayment') return {actor:game.decision.player,action:{type:'decision',karama:false}}; + if(game.decision?.kind==='guildShipment') return {actor:game.decision.player,action:{type:'decision',allow:true}}; + if(game.decision?.kind==='captureOffer') return {actor:game.decision.player,action:{type:'decision',accept:false}}; + if(game.decision?.kind==='leaderSkillVisibility') return {actor:game.decision.player,action:{type:'leaderSkillVisibility',event:game.decision.event,hide:false}}; + if(game.decision?.kind==='mentatQuestion') return {actor:game.decision.player,action:{type:'decision',event:game.decision.event,decline:true}}; + if(game.battle?.preparation) return {actor:game.battle.preparation.owner,action:{type:'declineBattlePower'}}; + if(game.decision?.kind==='fullPlanOffer') return {actor:game.decision.player,action:{type:'decision',decline:true}}; + if(game.status==='playing'&&!game.decision) { + if(game.phase===0&&game.stormPending===null) return {actor:game.stormDialers.find(id=>game.stormDials[id]===undefined)!,action:{type:'stormDial',amount:game.turn===1?0:1}}; + if(game.phase===3&&game.auction) return {actor:game.auction.active,action:{type:'passBid'}}; + if(game.phase===5) return {actor:game.active!,action:{type:'endMovement'}}; + if(game.battle?.revealed) { + const voter=viewGame(game,game.host).battle!.traitorVoters.find(id=>game.battle!.traitorCalls[id]===undefined); + if(voter) return {actor:voter,action:{type:'traitorCall',call:false}}; + } + if(game.phase!==6||!game.active) { + const seat=game.players.find(p=>!game.ready.includes(p.id)); + if(seat) return {actor:seat.id,action:{type:'ready'}}; + } + } + for(const p of game.players) { + if(game.decision&&game.decision.player!==p.id) continue; + const view=viewGame(game,p.id); view.players.find(s=>s.id===p.id)!.bot='Easy'; + const action=botActions(view)[0]; + if(action) return {actor:p.id,action}; + } + throw new Error(`No native Banker fixture step at ${game.status}/${game.turn}/${game.phase}/${game.decision?.kind}`); +} +function step(game:Game):Game { + const next=nextSpiceBankerIncomeNativeStep(game);assert.ok(next); + return applyAction(game,next.actor,next.action); +} +export function advanceSpiceBankerIncomeToMentat(state:Game):Game { + let game=state;const turn=game.turn; + for(let i=0;game.phase!==8&&game.turn===turn&&game.status==='playing'&&i<1000;i++) game=step(game); + assert.equal(game.turn,turn);assert.equal(game.phase,8); + return game; +} + +/** Scope deterministic entropy to the first real initializer, including CLI admission. */ +export function withSpiceBankerIncomeSetupRandomness(initialize:()=>T):T { + const descriptor=Object.getOwnPropertyDescriptor(crypto,'getRandomValues'); + const index=LEADER_SKILL_CARDS.findIndex(c=>c.id==='spice-banker');let shuffleIndex=LEADER_SKILL_CARDS.length-1; + crypto.getRandomValues=(array:V):V=>{ + assert.ok(array);const values=new Uint32Array(array.buffer,array.byteOffset,array.byteLength/4); + for(let i=0;iinitializeSpiceBankerIncomeGameForAudit(game)); + } else assert.ok(game.status==='setup'&&game.spiceBankerIncomePreview&&game.turn===1&&game.phase===0,'Only the original fresh admitted setup can continue.'); + for(let i=0;game.status==='setup'&&i<200;i++) { + if(game.setupStage==='leaderSkills') { + const owner=Object.keys(game.leaderSkills!.offers)[0]; + const offer=game.leaderSkills!.offers[owner]; + const view=viewGame(game,owner).leaderSkills!; + const skill=offer.cards.includes('spice-banker')?'spice-banker':offer.cards.find(c=>!view.unavailableSkills?.[c]); + assert.ok(skill);assert.ok(view.eligibleLeaders.length); + game=applyAction(game,owner,{type:'leaderSkill',event:offer.event,skill,leader:view.eligibleLeaders[0].id}); + } else game=step(game); + } + assert.equal(game.status,'playing'); + assert.ok(game.leaderSkills!.assignments.some(a=>a.skill==='spice-banker'),'The original native setup has no real Spice Banker offer; fixture prerequisite missing.'); + return game; +} +function phase(game:Game,target:number):Game { + for(let i=0;(game.phase!==target||game.phaseOpening||game.response||game.decision)&&i<1000;i++) game=step(game); + assert.equal(game.phase,target);return game; +} + +/** Reuse real custody first; any explicit staging transfers the canonical card + * out of the remaining deck, without duplicating or redealing starting hands. */ +function stageHeldCard(game:Game,player:Game['players'][number],matches:(card:Card)=>boolean):Card { + const held=player.hand.find(matches); + if(held) return held; + const index=game.deck.findIndex(matches);assert.ok(index>=0,'No matching real card remains available for conserved staging.'); + const card=game.deck.splice(index,1)[0];player.hand.push(card);return card; +} + +/** Independent native quotes for these classic support battles. Collection is + * quoted on the resolved physical board, with its pre-collection spice restored, + * so ordinary stronghold/desert income is not mistaken for a smaller debit. */ +export function quoteSpiceBankerIncomeBattleEconomics(before:Game,paid:Game) { + const battle=before.battle;assert.ok(battle?.revealed); + const combatant=(id:string,opponentId:string):ResolutionCombatant=>{ + const player=before.players.find(p=>p.id===id)!; + const opponent=before.players.find(p=>p.id===opponentId)!; + const groups=Object.entries(player.forces).filter(([key])=>key.startsWith(`${battle.territory}:`)); + const elite=groups.reduce((sum,[key])=>sum+(player.elites?.forces[key]??0),0); + return { + id,faction:player.faction,ally:player.ally,spice:player.spice,hand:player.hand, + plan:battle.plans[id],leader:player.leaders.find(l=>l.id===battle.plans[id].leader), + leaderSkills:before.leaderSkills!.assignments.filter(a=>{ + const trainer=before.players.find(p=>p.id===a.owner)!.leaders.find(l=>l.id===a.leader)!; + return (trainer.capturedBy??a.owner)===id; + }).map(a=>({skill:a.skill,leader:a.leader,faceUp:a.owner===id&&!battle.leaderSkillHidden?.[id],captured:a.owner!==id})), + occupiedStrongholds:leaderSkillStrongholdCount(before,player), + forces:{normal:groups.reduce((sum,[,amount])=>sum+amount,0)-elite,elite,normalFixedHalf:false, + eliteStrength:!before.advanced||battle.eliteBlocked?.includes(id)||(player.faction==='emperor'&&opponent.faction==='fremen')?1:2, + freeSupport:!before.advanced||(player.faction==='fremen'&&!battle.fremenSupportBlocked)}, + }; + }; + const resolution=quoteBattleResolution({ + advanced:before.advanced,turn:before.turn,territory:battle.territory, + attacker:combatant(battle.attacker,battle.defender),defender:combatant(battle.defender,battle.attacker), + voters:viewGame(before,before.host).battle!.traitorVoters.map(id=>({ + id,beneficiary:[battle.attacker,battle.defender].includes(id)?id:before.players.find(p=>p.id===id)!.ally!, + called:battle.traitorCalls[id]??false,traitors:before.players.find(p=>p.id===id)!.traitors, + })), + participants:before.players, + physicalCards:[...before.deck,...before.discard,...before.players.flatMap(p=>p.hand)], + pendingAuditorPresent:false,pendingRetentionPresent:false, + }); + const spice={...before.spice}; + const smuggler=paid.lastBattleContext?.smugglerCollection; + if(smuggler?.key&&smuggler.stage==='collected') spice[smuggler.key]=smuggler.before-smuggler.amount; + const sandmaster=paid.lastBattleContext?.sandmaster; + if(sandmaster) spice[sandmaster.key]=sandmaster.after; + const nativeCollection=paid.phase===7?quoteSpiceCollection({...paid,spice,players:paid.players.map(p=>({...p,spice:0}))}).receipts:[]; + return {resolution,nativeCollection}; +} +/** Genuine setup and native phase transitions; only the conserved source force, + * casualty and funding position is staged. Every debit/effect uses its native action. */ +export function createSpiceBankerIncomeFixture(options:SpiceBankerIncomeFixtureOptions={}):SpiceBankerIncomeFixture { + const kind=options.kind??'auction';let game:Game; + if(options.initial) game=structuredClone(options.initial); + else { + const ids=options.seatIds??['p0','p1','p2','p3']; + const bankerFaction=options.bankerFaction??'atreides'; + const payerFaction=options.payerFaction??(bankerFaction==='harkonnen'?'atreides':'harkonnen'); + game=createGame('BANKERINCOME',newPlayer(ids[0],'Banker',bankerFaction),options.advanced??false); + if(payerFaction!==bankerFaction) joinGame(game,newPlayer(ids[1],'Payer',payerFaction)); + if((options.emperor||kind==='emperor-extra-revival')&&!game.players.some(p=>p.faction==='emperor')) joinGame(game,newPlayer(ids[game.players.length],'Emperor','emperor')); + if((options.guild||kind==='cross-shipment'||kind==='return-shipment')&&!game.players.some(p=>p.faction==='guild')) joinGame(game,newPlayer(ids[game.players.length],'Guild','guild')); + if(options.allySplit&&!game.players.some(p=>p.faction==='emperor'||p.faction==='guild')) joinGame(game,newPlayer(ids[game.players.length],'Donor','beneGesserit')); + if(game.players.length===1) { + const opponentFaction=bankerFaction==='harkonnen'?'atreides':'harkonnen'; + joinGame(game,newPlayer(ids[1],'Opponent',opponentFaction)); + } + } + game=setup(game); + const assignment=game.leaderSkills!.assignments.find(a=>a.skill==='spice-banker')!; + const owner=assignment.owner,leader=assignment.leader; + let actor=options.payerFaction?game.players.find(p=>p.faction===options.payerFaction)!.id: + options.ownerIsPayer?owner:game.players.find(p=>p.id!==owner&&p.faction==='harkonnen')?.id??game.players.find(p=>p.id!==owner)!.id; + if(kind==='kh-revival') actor=game.players.find(p=>p.faction==='atreides')!.id; + let paymentActor:string|undefined; + let paymentAction:Action|undefined;let amount=options.amount??4; + const split=options.allySplit??0; + if(kind==='auction') { + game=phase(game,3); + assert.ok(game.auction); + const payer=game.players.find(p=>p.id===actor)!; + if(options.stageKarama) stageHeldCard(game,payer,c=>c.effect==='karama'); + if(options.stageIncomeCounter) stageHeldCard(game,game.players.find(p=>p.id===owner)!,c=>c.effect==='karama'); + for(let i=0;game.auction!.active!==actor&&i<10;i++) game=applyAction(game,game.auction!.active,{type:'passBid'}); + if(split) { + const donor=game.players.find(p=>p.id!==actor&&p.id!==owner)!;const payer=game.players.find(p=>p.id===actor)!; + payer.ally=donor.id;donor.ally=actor;game=applyAction(game,donor.id,{type:'pledgeAid',amount:split}); + } + game=applyAction(game,actor,{type:'bid',amount,allyPayment:split}); + for(let i=0;i<10;i++) { + const auction=game.auction!; + const next=quoteNormalAuctionNext({...auction,order:game.order, + eligible:game.players.filter(p=>p.hand.lengthp.id), + passed:[...auction.passed,auction.active]}); + if(next.kind==='payment') { + if(options.stageKarama) { + game=applyAction(game,auction.active,{type:'passBid'}); + assert.equal(game.decision?.kind,'auctionPayment'); + paymentAction={type:'decision',karama:false}; + } else { + paymentActor=auction.active;paymentAction={type:'passBid'}; + } + break; + } + game=applyAction(game,auction.active,{type:'passBid'}); + } + assert.ok(paymentAction,'The original winning lot must reach its native payment step.'); + } else if(kind==='force-revival'||kind==='leader-revival'||kind==='kh-revival'||kind==='emperor-extra-revival') { + if(kind==='leader-revival'||kind==='kh-revival') { + if(kind==='kh-revival') { + assert.ok(game.advanced);assert.equal(game.players.find(p=>p.id===actor)!.faction,'atreides'); + // Seven actual native force casualties unlock KH before its later battle. + game=stageSpiceBankerIncomeTrainerBattle(game,{trainerOwner:actor,forceCasualties:7}); + } + game=stageSpiceBankerIncomeTrainerBattle(game,{trainerOwner:actor,death:true,kwisatz:kind==='kh-revival'}); + game=advanceSpiceBankerIncomeToMentat(game); + } + game=phase(game,4);const payer=game.players.find(p=>p.id===actor)!; + if(kind==='force-revival') { + payer.reserves-=3;payer.tanks+=3;paymentAction={type:'revive',amount:3};amount=forceRevivalQuote(game,payer,3).cost; + } else if(kind==='leader-revival') { + const target=payer.leaders.find(l=>l.strength===(options.amount??4))!;assert.ok(target,'No native leader at requested price.'); + assert.ok(payer.leaders.every(l=>l.dead)); + paymentAction={type:'reviveLeader',leader:target.id};amount=target.strength; + } else if(kind==='kh-revival') { + assert.equal(payer.faction,'atreides');assert.ok(game.advanced); + assert.ok(payer.leaders.every(l=>l.dead));assert.equal(payer.kwisatz?.dead,true); + paymentAction={type:'reviveKwisatz'};amount=2; + } else { + const emperor=game.players.find(p=>p.faction==='emperor')!;const recipient=game.players.find(p=>p.id!==emperor.id&&p.id!==owner)!; + emperor.ally=recipient.id;recipient.ally=emperor.id;recipient.reserves-=2;recipient.tanks+=2; + actor=emperor.id;paymentAction={type:'emperorRevival',amount:2};amount=4; + } + } else if(kind==='shipment'||kind==='cross-shipment'||kind==='return-shipment') { + game=phase(game,5); + if(kind!=='shipment') actor=game.players.find(p=>p.faction==='guild')!.id; + for(let i=0;game.active!==actor&&i<10;i++) game=applyAction(game,game.active!,{type:'endMovement'}); + let payer=game.players.find(p=>p.id===actor)!; + if(options.stageIncomeCounter) stageHeldCard(game,game.players.find(p=>p.id===owner)!,c=>c.effect==='karama'); + const destination=TERRITORIES.find(t=>t.type==='stronghold'&&!t.sectors.includes(game.storm)&&game.players.every(p=>!Object.keys(p.forces).some(k=>k.startsWith(`${t.id}:`))))!; + assert.ok(destination); + if(split) {const donor=game.players.find(p=>p.id!==actor&&p.id!==owner)!;payer.ally=donor.id;donor.ally=actor;game=applyAction(game,donor.id,{type:'pledgeAid',amount:split});payer=game.players.find(p=>p.id===actor)!;} + if(kind==='shipment') paymentAction={type:'ship',territory:destination.id,sector:destination.sectors[0],amount:payer.faction==='guild'?amount*2:amount,allyPayment:split}; + else { + const origin=TERRITORIES.find(t=>t.type==='sand'&&!t.sectors.includes(game.storm))!; + payer.reserves-=amount*2;payer.forces[`${origin.id}:${origin.sectors[0]}`]=(payer.forces[`${origin.id}:${origin.sectors[0]}`]??0)+amount*2; + paymentAction={type:'guildShip',forces:{[`${origin.id}:${origin.sectors[0]}`]:amount*2},territory:kind==='return-shipment'?'reserves':destination.id,sector:destination.sectors[0],allyPayment:split}; + } + } else { + assert.ok(game.advanced,'Paid support requires Advanced combat.');game=phase(game,5); + const territory=TERRITORIES.find(t=>t.type==='sand'&&!t.sectors.includes(game.storm)&&game.players.every(p=>!Object.keys(p.forces).some(k=>k.startsWith(`${t.id}:`))))!; + for(const id of [owner,actor]) {const p=game.players.find(p=>p.id===id)!;p.reserves-=4;p.forces[`${territory.id}:${territory.sectors[0]}`]=4;} + let weapon:string|undefined; + if(options.trainerDies) { + weapon=stageHeldCard(game,game.players.find(p=>p.id===actor)!,c=>c.kind==='projectile').id; + } + while(game.phase===5) game=step(game); + game=applyAction(game,game.active!,{type:'chooseBattle',territory:territory.id,target:game.active===owner?actor:owner}); + for(let i=0;(game.response||game.decision||game.battle?.preparation)&&i<100;i++) { + if(game.decision?.kind==='leaderSkillVisibility'&&game.decision.player===owner&&(options.trainerSelected||options.trainerHidden)) + game=applyAction(game,owner,{type:'leaderSkillVisibility',event:game.decision.event,hide:true}); + else game=step(game); + } + for(const id of [game.battle!.attacker,game.battle!.defender]) { + const p=game.players.find(p=>p.id===id)!; + const nativeTrainer=game.leaderSkills!.assignments.find(a=>a.owner===id)?.leader; + game=applyAction(game,id,{type:'battlePlan', + leader:id===owner&&options.trainerSelected?leader:p.leaders.find(l=>l.id!==nativeTrainer&&!l.dead)!.id, + dial:id===actor?amount:0,support:id===actor?amount:0,...(id===actor&&weapon?{weapon}:{}), + }); + } + for(let i=0;(game.response||game.decision)&&i<100;i++) game=step(game); + game=applyAction(game,owner,{type:'traitorCall',call:false});paymentAction={type:'traitorCall',call:false}; + } + assert.ok(paymentAction); + const bankLegs=[{payer:actor,amount:amount-split},...(split?[{payer:game.players.find(p=>p.id===actor)!.ally!,amount:split}]:[])]; + return {game,beforePayment:structuredClone(game),actor:paymentActor??actor,payer:actor,paymentAction,owner,leader,event:game.battle?.event??game.code,kind,amount,bankLegs}; +} + +/** Conserved trainer battle, resolved through native casualty, death, skill-return + * and capture actions. The optional trainerOwner selects an existing assignment; + * no assigned trainer or earned-currency record is directly edited. */ +export function stageSpiceBankerIncomeTrainerBattle(state:Game,options:{death?:boolean;capture?:boolean;reassignToOpponent?:boolean;trainerOwner?:string;forceCasualties?:number;kwisatz?:boolean}={}):Game { + let game=phase(structuredClone(state),5); + const assignment=game.leaderSkills!.assignments.find(a=>options.trainerOwner?a.owner===options.trainerOwner:a.skill==='spice-banker')!;assert.ok(assignment); + const owner=game.players.find(p=>p.id===assignment.owner)!; + const enemy=game.players.find(p=>p.faction==='harkonnen'&&p.id!==owner.id)??game.players.find(p=>p.id!==owner.id)!; + const enemyTrainer=game.leaderSkills!.assignments.find(a=>a.owner===enemy.id)?.leader; + if(options.reassignToOpponent) {assert.ok(options.death);assert.ok(enemyTrainer);} + if(options.capture) {assert.ok(game.advanced);assert.equal(enemy.faction,'harkonnen');} + const territory=TERRITORIES.find(t=>t.type==='sand'&&!t.sectors.includes(game.storm)&&game.players.every(p=>!Object.keys(p.forces).some(k=>k.startsWith(`${t.id}:`))))!; + for(const p of [owner,enemy]) { + const amount=p.id===owner.id?(options.forceCasualties??2):2; + assert.ok(p.reserves>=amount);p.reserves-=amount;p.forces[`${territory.id}:${territory.sectors[0]}`]=amount; + } + let weapon:string|undefined; + if(options.death||options.capture||options.forceCasualties) { + weapon=stageHeldCard(game,enemy,c=>options.kwisatz?c.kind==='lasgun':c.kind==='projectile').id; + } + let ownerWeapon:string|undefined; + if(options.reassignToOpponent) { + ownerWeapon=stageHeldCard(game,owner,c=>c.kind==='projectile').id; + for(const l of enemy.leaders) if(l.id!==enemyTrainer) {l.dead=true;l.deaths=1;} + } + let ownerDefense:string|undefined; + if(options.kwisatz) { + assert.ok(game.advanced&&owner.faction==='atreides'&&owner.battleLosses>=7); + ownerDefense=stageHeldCard(game,owner,c=>c.kind==='shield').id; + } + const sacrificial=owner.leaders.find(l=>l.id!==assignment.leader&&!l.dead)!; + for(const l of owner.leaders) if(l.id!==assignment.leader&& + (options.death||(options.capture&&l.id!==sacrificial.id))) {l.dead=true;l.deaths=1;} + while(game.phase===5) game=step(game); + const chooser=game.active!;assert.ok([owner.id,enemy.id].includes(chooser)); + game=applyAction(game,chooser,{type:'chooseBattle',territory:territory.id,target:chooser===owner.id?enemy.id:owner.id}); + for(let i=0;(game.response||game.decision||game.battle?.preparation)&&i<100;i++) { + if(game.decision?.kind==='leaderSkillVisibility'&& + (game.decision.player===owner.id||(options.reassignToOpponent&&game.decision.player===enemy.id))) + game=applyAction(game,game.decision.player,{type:'leaderSkillVisibility',event:game.decision.event,hide:!!options.death}); + else game=step(game); + } + for(const id of [game.battle!.attacker,game.battle!.defender]) { + const p=game.players.find(p=>p.id===id)!; + game=applyAction(game,id,{type:'battlePlan',dial:0,support:0, + leader:id===owner.id?(options.death?assignment.leader:sacrificial.id): + options.reassignToOpponent?enemyTrainer:p.leaders.find(l=>!l.dead&&l.id!==game.leaderSkills!.assignments.find(a=>a.owner===id)?.leader)!.id, + ...(id===enemy.id&&weapon?{weapon}:{}),...(id===owner.id&&ownerWeapon?{weapon:ownerWeapon}:{}), + ...(id===owner.id&&ownerDefense?{defense:ownerDefense,kwisatz:true}:{}), + }); + } + for(let i=0;game.battle&&i<100;i++) { + const next=nextSpiceBankerIncomeNativeStep(game);assert.ok(next); + const finalCall=options.death&&next.action.type==='traitorCall'&& + viewGame(game,game.host).battle!.traitorVoters.every(id=>id===next.actor||game.battle!.traitorCalls[id]!==undefined); + if(finalCall) { + const descriptor=Object.getOwnPropertyDescriptor(crypto,'getRandomValues'); + const returned=options.reassignToOpponent?2:1; + const size=game.leaderSkills!.deck.length+returned;let roll=0; + crypto.getRandomValues=(array:T):T=>{ + assert.ok(array);const values=new Uint32Array(array.buffer,array.byteOffset,array.byteLength/4); + for(let j=0;jp.id===owner.id)!.leaders.find(l=>l.id===assignment.leader)!.capturedBy,enemy.id); + game=applyAction(game,enemy.id,{type:'decision',mode:'keep'});break; + } else game=step(game); + } + } + if(options.death) assert.equal(game.players.find(p=>p.id===owner.id)!.leaders.find(l=>l.id===assignment.leader)!.dead,true); + if(options.forceCasualties) assert.ok(game.players.find(p=>p.id===owner.id)!.battleLosses>=options.forceCasualties); + if(options.kwisatz) assert.equal(game.players.find(p=>p.id===owner.id)!.kwisatz?.dead,true); + return game; +} + +export type SpiceBankerIncomeReassignmentFixture = { + beforeRevival:Game;game:Game;actor:string;leader:string;originalOwner:string;revivalAction:Action; +}; +/** Real original payment -> mutual skilled deaths -> native Mentat -> next-turn + * normal revival. The two-card offer is drawn only by its original native action. */ +export function createSpiceBankerIncomeReassignmentFixture(state:Game):SpiceBankerIncomeReassignmentFixture { + const originalOwner=state.leaderSkills!.assignments.find(a=>a.skill==='spice-banker')!.owner; + const actor=state.players.find(p=>p.faction==='harkonnen'&&p.id!==originalOwner)!.id; + const leader=state.leaderSkills!.assignments.find(a=>a.owner===actor)!.leader; + let game=stageSpiceBankerIncomeTrainerBattle(state,{death:true,reassignToOpponent:true}); + game=advanceSpiceBankerIncomeToMentat(game); + game=phase(game,4); + const trainer=game.players.find(p=>p.id===actor)!.leaders.find(l=>l.id===leader)!; + assert.ok(trainer.dead&&game.players.find(p=>p.id===actor)!.spice>=trainer.strength); + assert.ok(game.leaderSkills!.deck.slice(0,2).includes('spice-banker'),'The actual singleton return shuffle must supply the native two-card offer.'); + const revivalAction:Action={type:'reviveLeader',leader}; + return {beforeRevival:structuredClone(game),game,actor,leader,originalOwner,revivalAction}; +} diff --git a/tests/prototype-room.test.ts b/tests/prototype-room.test.ts index aa9b5016..8a3a3010 100644 --- a/tests/prototype-room.test.ts +++ b/tests/prototype-room.test.ts @@ -805,3 +805,150 @@ void test('Harkonnen Betrayal CLI backs up every room and seat privately before assert.deepEqual(current.prepare('SELECT * FROM rooms ORDER BY code').all(), after); assert.deepEqual(current.prepare('SELECT * FROM seats ORDER BY player_id').all(), seats); }); + +for (const advanced of [false, true]) { + for (const seats of [2, 3, 4, 5, 6]) { + void test(`Banker income ${advanced ? 'Advanced' : 'Basic'} ${seats}-seat entry conserves the original base and skill inventories without changing credentials`, t => { + const { db } = fixture(t); + const lobby = harkonnenLobby(advanced, seats); + db.prepare('UPDATE rooms SET state=? WHERE code=?').run(JSON.stringify(lobby), lobby.code); + const credentials = db.prepare('SELECT * FROM seats').all(); + const other = db.prepare('SELECT * FROM rooms WHERE code=?').get('KEEPME00'); + const before = db.prepare('SELECT * FROM rooms ORDER BY code').all(); + assert.throws(() => startPrototypeRoom(db, lobby.code, 6, 'banker-income')); + assert.deepEqual(db.prepare('SELECT * FROM rooms ORDER BY code').all(), before); + startPrototypeRoom(db, lobby.code, 7, 'banker-income'); + const saved = JSON.parse(db.prepare('SELECT state FROM rooms WHERE code=?').get(lobby.code)!.state as string) as Game; + assert.equal(saved.version, 8); + assert.equal(saved.status, 'setup'); + assert.equal(saved.spiceBankerIncomePreview, true); + assert.equal(saved.advanced, advanced); + assert.equal(saved.host, lobby.host); + assert.deepEqual(saved.playerPositions, lobby.playerPositions); + assert.deepEqual(saved.players.map(p => [p.id, p.name, p.faction]), lobby.players.map(p => [p.id, p.name, p.faction])); + const stock = [...saved.deck, ...saved.discard, ...saved.players.flatMap(p => p.hand)]; + assert.equal(stock.length, 33); + assert.equal(new Set(stock.map(card => card.id)).size, 33); + assert.ok(saved.leaderSkills); + const skills = [...saved.leaderSkills.deck, ...Object.values(saved.leaderSkills.offers).flatMap(offer => offer.cards), + ...saved.leaderSkills.assignments.map(assignment => assignment.skill)]; + assert.deepEqual(skills.sort(), createLeaderSkills(() => 0.5).deck.sort()); + for (const player of saved.players) { + const view = viewGame(saved, player.id); + assert.deepEqual(view.spiceBankerIncome, { deferred: [], usedThisPhase: [] }); + assert.equal(view.spiceBankerIncomePreview, true); + } + const started = db.prepare('SELECT * FROM rooms ORDER BY code').all(); + for (const profile of ['banker-income', 'leader-skills', 'nexus'] as const) + assert.throws(() => startPrototypeRoom(db, lobby.code, 8, profile)); + assert.deepEqual(db.prepare('SELECT * FROM rooms ORDER BY code').all(), started); + assert.deepEqual(db.prepare('SELECT * FROM seats').all(), credentials); + assert.deepEqual(db.prepare('SELECT * FROM rooms WHERE code=?').get('KEEPME00'), other); + }); + } +} + +void test('Banker income rejects incompatible profiles and public activation without modifying any saved room or credential', t => { + const { db } = fixture(t); + const native = harkonnenLobby(); + const changes: ((game: Game) => void)[] = [ + game => { game.players[0].ready = false; }, + game => { game.code = 'NOTMATCH'; }, + game => { game.players[1].faction = game.players[0].faction; }, + ...(['ixians', 'tleilaxu', 'choam', 'richese', 'ecaz', 'moritani'] as const).map(faction => + (game: Game) => { game.players[1].faction = faction; }), + ...(['ix', 'choam', 'ecaz'] as const).map(expansion => + (game: Game) => { game.expansions = [expansion]; }), + game => { game.discoveryEnabled = true; }, + game => { game.ecazTreachery = true; }, + game => { game.semutaPreview = true; }, + game => { game.nexusCards = { cards: null, phase: null }; }, + game => { game.nexusIxianReplacementPreview = true; }, + game => { game.nexusIxianBetrayalPreview = true; }, + game => { game.nexusHarkonnenBetrayalPreview = true; }, + game => { game.kullPreview = true; }, + game => { game.nexusKullPreview = true; }, + game => { game.guildBetrayalPreview = true; }, + game => { game.richeseBetrayalPreview = true; }, + game => { game.moritaniAssassinatePreview = true; }, + game => { game.spiceBankerIncomePreview = true; }, + game => { game.leaderSkills = createLeaderSkills(() => 0.5); }, + game => { game.players.splice(1); }, + game => { game.players.push(...Array.from({ length: 4 }, (_, index) => newPlayer(`extra${index}`, `Extra ${index}`, 'guild'))); }, + ]; + const modules = ['homeworlds', 'techTokens', 'strongholdCards'].map(type => { + const game = applyAction(native, native.host, { type, enabled: true }); + for (const player of game.players) player.ready = true; + return game; + }); + for (const game of [...changes.map(change => { + const game = structuredClone(native); + change(game); + return game; + }), ...modules]) { + db.prepare('UPDATE rooms SET state=? WHERE code=?').run(JSON.stringify(game), native.code); + const rooms = db.prepare('SELECT * FROM rooms ORDER BY code').all(); + const credentials = db.prepare('SELECT * FROM seats').all(); + assert.throws(() => startPrototypeRoom(db, native.code, 7, 'banker-income')); + assert.deepEqual(db.prepare('SELECT * FROM rooms ORDER BY code').all(), rooms); + assert.deepEqual(db.prepare('SELECT * FROM seats').all(), credentials); + } + const original = structuredClone(native); + for (const action of [{ type: 'start', profile: 'banker-income' }, { type: 'initializeSpiceBankerIncomeGameForAudit' }, + { type: 'spiceBankerIncome', enabled: true }]) + assert.throws(() => applyAction(native, native.host, action)); + assert.deepEqual(native, original); +}); + +void test('Banker income CLI requires a fresh exact-version private backup and preserves native setup, old rooms and credentials', t => { + const directory = mkdtempSync(join(tmpdir(), 'dune-banker-income-')); + t.after(() => rmSync(directory, { recursive: true, force: true })); + const database = join(directory, 'rooms.sqlite'); + const db = new DatabaseSync(database); + db.exec('CREATE TABLE rooms(code TEXT PRIMARY KEY,state TEXT,version INTEGER,updated_at INTEGER); CREATE TABLE seats(room_code TEXT,player_id TEXT,token_hash TEXT);'); + const lobby = harkonnenLobby(true, 6); + db.prepare('INSERT INTO rooms VALUES(?,?,?,?)').run(lobby.code, JSON.stringify(lobby), 7, 100); + const other = harkonnenLobby(false, 2); + other.code = 'KEEPGAME'; + db.prepare('INSERT INTO rooms VALUES(?,?,?,?)').run(other.code, JSON.stringify(other), 9, 50); + startPrototypeRoom(db, other.code, 9, 'leader-skills'); + for (const player of lobby.players) + db.prepare('INSERT INTO seats VALUES(?,?,?)').run(lobby.code, player.id, 'isolated-test-' + player.id); + const before = db.prepare('SELECT * FROM rooms ORDER BY code').all(); + const credentials = db.prepare('SELECT * FROM seats ORDER BY player_id').all(); + db.close(); + const output = join(directory, 'private-proof'); + const invoke = (version: number, out: string) => spawnSync(process.execPath, [ + '--import', 'tsx', fileURLToPath(new URL('../tools/start-prototype.ts', import.meta.url)), + '--profile', 'banker-income', '--db', database, '--room', lobby.code, + '--version', String(version), '--out', out, + ], { encoding: 'utf8', timeout: 120000 }); + const result = invoke(7, output); + assert.equal(result.status, 0, result.stderr); + assert.equal(statSync(output).mode & 0o777, 0o700); + for (const name of ['games.sqlite', 'snapshot.json', 'prototype.json']) + assert.equal(statSync(join(output, name)).mode & 0o777, 0o600); + const backup = new DatabaseSync(join(output, 'games.sqlite'), { readOnly: true }); + assert.deepEqual(backup.prepare('SELECT * FROM rooms ORDER BY code').all(), before); + assert.deepEqual(backup.prepare('SELECT * FROM seats ORDER BY player_id').all(), credentials); + backup.close(); + const current = new DatabaseSync(database, { readOnly: true }); + t.after(() => current.close()); + const after = current.prepare('SELECT * FROM rooms ORDER BY code').all(); + assert.deepEqual(current.prepare('SELECT * FROM rooms WHERE code=?').get(other.code), before.find(row => row.code === other.code)); + assert.deepEqual(current.prepare('SELECT * FROM seats ORDER BY player_id').all(), credentials); + const row = current.prepare('SELECT state,version FROM rooms WHERE code=?').get(lobby.code)!; + const saved = JSON.parse(row.state as string) as Game; + assert.equal(row.version, 8); + assert.equal(saved.spiceBankerIncomePreview, true); + assert.equal(saved.status, 'setup'); + assert.equal(saved.setupStage, 'prediction'); + assert.deepEqual(saved.players.map(p => [p.id, p.name, p.faction]), lobby.players.map(p => [p.id, p.name, p.faction])); + assert.deepEqual(saved.players.map(p => p.hand), lobby.players.map(p => p.hand)); + assert.deepEqual(saved.leaderSkills!.offers, {}); + assert.notEqual(invoke(8, output).status, 0); + assert.notEqual(invoke(7, join(directory, 'stale-proof')).status, 0); + assert.notEqual(invoke(8, join(directory, 'redeal-proof')).status, 0); + assert.deepEqual(current.prepare('SELECT * FROM rooms ORDER BY code').all(), after); + assert.deepEqual(current.prepare('SELECT * FROM seats ORDER BY player_id').all(), credentials); +}); diff --git a/tests/spice-banker-income-recovery.test.ts b/tests/spice-banker-income-recovery.test.ts new file mode 100644 index 00000000..7facd16a --- /dev/null +++ b/tests/spice-banker-income-recovery.test.ts @@ -0,0 +1,794 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { DatabaseSync, SQLOutputValue } from 'node:sqlite'; +import { applyAction, viewGame, type Action, type Game } from '../game/engine'; +import { createLeaderSkills } from '../game/leader-skills'; +import type { RoomsClock, SeatAuth } from '../db/rooms'; +import type * as Rooms from '../db/rooms'; +import { startPrototypeRoom } from '../tools/prototype-room'; +import { unitStore } from './fixture-nexus-room-store'; +import { + createSpiceBankerIncomeFixture, + createSpiceBankerIncomeReassignmentFixture, + nextSpiceBankerIncomeNativeStep, + quoteSpiceBankerIncomeBattleEconomics, + stageSpiceBankerIncomeTrainerBattle, + withSpiceBankerIncomeSetupRandomness, + type SpiceBankerIncomeFixture, + type SpiceBankerIncomeFixtureOptions as Options, +} from './fixture-spice-banker-income'; + +type SqlRow = Record; +interface Fixture extends SpiceBankerIncomeFixture { + sqlite: DatabaseSync; + restart(): typeof Rooms; + hooks: { beforeWrite?: () => Promise }; + tokens: string[]; + auths: SeatAuth[]; + code: string; + unrelatedCode: string; + unrelatedToken: string; + protectedRows: Record; + stock: string[]; + save(game: Game): void; +} +const clock: RoomsClock = { now: () => 10000, sleep: async () => {} }; + +function records(sqlite: DatabaseSync, excludedRoom?: string) { + const result: Record = {}; + for (const row of sqlite.prepare("SELECT name FROM sqlite_schema WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name").all()) { + assert.equal(typeof row.name, 'string'); + const table = String(row.name).replaceAll('"', '""'); + result[String(row.name)] = sqlite.prepare(`SELECT * FROM "${table}"`).all() + .filter(value => !(row.name === 'rooms' && value.code === excludedRoom)); + } + return result; +} +function player(game: Game, id: string) { + const value = game.players.find(seat => seat.id === id); + assert.ok(value); + return value; +} +function physical(game: Game) { + const cards = [...game.deck, ...game.discard, ...game.players.flatMap(seat => seat.hand), + ...(game.auction?.cards.slice(game.auction.index + Number(game.currentAuctionSale?.origin === 'normal')) ?? [])]; + const ids = cards.map(card => card.id).sort(); + assert.equal(new Set(ids).size, ids.length, 'one physical custodian per original Treachery Card'); + return ids; +} +async function authenticate(f: Fixture, id: string) { + const index = f.auths.findIndex(auth => auth.playerId === id); + assert.ok(index >= 0); + const auth = await f.restart().authenticate(f.code, f.tokens[index]); + assert.equal(auth.playerId, id); + return auth; +} +async function act(f: Fixture, game: Game, actor: string, action: Action) { + await f.restart().act(f.code, await authenticate(f, actor), game.version, action, clock); + if (action.type === 'advanceBots') await f.restart().continueRoomAutomatic(f.code, clock); + return f.restart().readRoom(f.code); +} +async function fixture(t: test.TestContext, options: Options = {}): Promise { + const store = unitStore(); + t.after(() => store.sqlite.close()); + const bankerFaction = options.bankerFaction ?? 'atreides'; + const made = await store.rooms.createRoom('Original Banker faction', bankerFaction, options.advanced ?? true, []); + const code = made.view.code; + const tokens = [made.token]; + const factions = [bankerFaction === 'harkonnen' ? 'atreides' : 'harkonnen', 'beneGesserit'] as const; + for (const faction of [...factions, ...(options.guild ? ['guild'] as const : []), + ...(options.emperor || options.kind === 'emperor-extra-revival' ? ['emperor'] as const : [])]) { + const joined = await store.rooms.joinRoom(code, faction, faction); + assert.ok(joined.token); + tokens.push(joined.token); + } + const auths = await Promise.all(tokens.map(token => store.rooms.authenticate(code, token))); + let lobby = await store.rooms.readRoom(code); + for (const auth of auths) { + await store.rooms.act(code, auth, lobby.version, { type: 'ready' }, clock); + lobby = await store.rooms.readRoom(code); + } + // BG keeps the entry's actual native setup undealt. The fixture consumes that + // original setup; it cannot reset already dealt cards or replace an offer. + withSpiceBankerIncomeSetupRandomness(() => startPrototypeRoom(store.sqlite, code, lobby.version, 'banker-income')); + const setup = await store.rooms.readRoom(code); + const native = createSpiceBankerIncomeFixture({ ...options, initial: setup }); + assert.equal(native.beforePayment.code, code); + assert.equal(native.beforePayment.host, lobby.host); + assert.deepEqual(native.beforePayment.playerPositions, lobby.playerPositions); + assert.deepEqual(native.beforePayment.players.map(seat => [seat.id, seat.name, seat.faction]), + lobby.players.map(seat => [seat.id, seat.name, seat.faction])); + const save = (game: Game) => { + assert.equal(store.sqlite.prepare('UPDATE rooms SET state=?,version=? WHERE code=?') + .run(JSON.stringify(game), game.version, code).changes, 1); + }; + save(native.beforePayment); + const unrelated = await store.rooms.createRoom('Unrelated private lobby', 'guild', false, []); + return { ...store, ...native, code, tokens, auths, save, unrelatedCode: unrelated.view.code, + unrelatedToken: unrelated.token, protectedRows: records(store.sqlite, code), stock: physical(native.beforePayment) }; +} +async function restored(f: Fixture, game: Game) { + const before = records(f.sqlite); + for (const auth of f.auths) { + const view = await f.restart().readSeatView(f.code, await authenticate(f, auth.playerId)); + assert.deepEqual(view, viewGame(game, auth.playerId)); + assert.deepEqual(Object.keys(view.spiceBankerIncome!).sort(), ['deferred', 'usedThisPhase']); + assert.equal(Object.hasOwn(view, 'deck'), false); + assert.equal(Object.hasOwn(view, 'spiceBankerIncomePayment'), false); + const projected = JSON.stringify(view); + const income = JSON.stringify(view.spiceBankerIncome); + for (const receipt of game.spiceBankerIncome!.sources) { + assert.equal(projected.includes(receipt.source.event), false, 'native payment event stays private in the entire view'); + assert.equal(projected.includes(receipt.signature), false, 'receipt consistency data stays private in the entire view'); + assert.equal(income.includes(JSON.stringify(receipt.source.bankLegs)), false, 'actual payer legs stay server-only'); + } + const pendingPayment = game.response?.spiceBankerIncomePayment; + if (pendingPayment) { + assert.equal(view.response!.spiceBankerIncomePayment, undefined); + assert.equal(projected.includes('"spiceBankerIncomePayment":'), false, 'serialized response has no private invoice field'); + assert.equal(projected.includes(pendingPayment.event), false, 'pending native invoice event stays private'); + assert.equal(projected.includes(pendingPayment.signature), false, 'pending invoice signature stays private'); + assert.equal(projected.includes(JSON.stringify(pendingPayment.legs)), false, 'pending own and donor legs stay private'); + } + for (const seat of view.players) + if (seat.id !== auth.playerId) assert.equal(seat.hand, undefined); + } + assert.deepEqual(await f.restart().readRoom(f.code), game); + assert.deepEqual(records(f.sqlite), before, 'authenticated projections never award, collect or repair'); + assert.deepEqual(records(f.sqlite, f.code), f.protectedRows); + assert.deepEqual(physical(game), f.stock); +} +async function rejected(f: Fixture, game: Game, auth: SeatAuth, action: Action, version = game.version) { + const before = records(f.sqlite); + await assert.rejects(f.restart().act(f.code, auth, version, action, clock)); + assert.deepEqual(records(f.sqlite), before, 'rejected action changes no currency, card, credential or room'); +} +async function settlePayment(f: Fixture, before = f.beforePayment) { + let game = await act(f, before, f.actor, f.paymentAction); + for (let steps = 0; (game.response || game.decision?.kind === 'guildShipment') && steps < 30; steps++) { + const step = nextSpiceBankerIncomeNativeStep(game); + assert.ok(step); + game = await act(f, game, step.actor, step.action); + } + assert.equal(game.response, null); + return game; +} +async function finalCollectionStep(f: Fixture, initial: Game) { + let game = initial; + for (let steps = 0; steps < 500; steps++) { + assert.notEqual(game.phase, 8, 'the original final Collection action must still be pending'); + const step = nextSpiceBankerIncomeNativeStep(game); + assert.ok(step, `native continuation is available in phase ${game.phase}`); + if (game.phase === 7 && applyAction(game, step.actor, step.action).phase === 8) + return { game, step }; + game = await act(f, game, step.actor, step.action); + } + assert.fail('native Collection completion did not become available'); +} + +for (const advanced of [false, true]) { + void test(`SQLite ${advanced ? 'Advanced' : 'Basic'} original auction pays once, holds unavailable income through restart and collects at actual native Mentat`, async t => { + const f = await fixture(t, { advanced }); + const before = f.beforePayment; + assert.ok(before.auction?.bidder); + const payer = before.auction.bidder; + const card = before.auction.cards[before.auction.index]; + const payerSpice = player(before, payer).spice; + const ownerSpice = player(before, f.owner).spice; + const paid = await settlePayment(f); + assert.equal(player(paid, payer).spice, payerSpice - 4); + assert.equal(player(paid, payer).hand.filter(held => held.id === card.id).length, 1); + assert.equal(player(paid, f.owner).spice, ownerSpice); + assert.deepEqual(viewGame(paid, f.owner).spiceBankerIncome!.deferred, [{ owner: f.owner, amount: 1 }]); + assert.equal(paid.spiceBankerIncome!.sources.filter(source => source.grant).length, 1); + await restored(f, paid); + await rejected(f, paid, await authenticate(f, f.actor), f.paymentAction, before.version); + const final = await finalCollectionStep(f, paid); + await restored(f, final.game); + const funds = player(final.game, f.owner).spice; + const done = await act(f, final.game, final.step.actor, final.step.action); + assert.equal(done.phase, 8); + assert.equal(player(done, f.owner).spice, funds + 1); + assert.deepEqual(viewGame(done, f.owner).spiceBankerIncome!.deferred, []); + assert.equal(done.spiceBankerIncome!.collections.length, 1); + assert.deepEqual(done.spiceBankerIncome!.collections[0].credits, [{ owner: f.owner, amount: 1 }]); + await Promise.all([f.restart().continueRoomAutomatic(f.code, clock), f.restart().continueRoomAutomatic(f.code, clock)]); + assert.deepEqual(await f.restart().readRoom(f.code), done); + await rejected(f, done, await authenticate(f, final.step.actor), final.step.action, final.game.version); + await restored(f, done); + }); +} + +for (const options of [{ amount: 3 }, { ownerIsPayer: true }] satisfies Options[]) { + void test(`SQLite original auction ${options.amount === 3 ? 'below four' : 'by Banker owner'} cannot produce normal income`, async t => { + const f = await fixture(t, options); + const before = f.beforePayment; + assert.ok(before.auction?.bidder); + const payer = before.auction.bidder; + const amount = options.amount ?? 4; + const done = await settlePayment(f); + assert.equal(player(done, payer).spice, player(before, payer).spice - amount); + assert.deepEqual(viewGame(done, f.owner).spiceBankerIncome!.deferred, []); + assert.equal(done.spiceBankerIncome!.sources.filter(source => source.grant).length, 0); + await restored(f, done); + }); +} + +void test('SQLite duplicate original payment race admits one debit, one physical card and one deferred grant', async t => { + const f = await fixture(t); + const before = f.beforePayment; + assert.ok(before.auction?.bidder); + const payer = before.auction.bidder; + const card = before.auction.cards[before.auction.index]; + const auth = await authenticate(f, f.actor); + let arrivals = 0; + let release!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + f.hooks.beforeWrite = async () => { if (++arrivals === 2) release(); await gate; }; + let results: PromiseSettledResult[]; + try { + results = await Promise.allSettled([f.restart(), f.restart()].map(rooms => + rooms.act(f.code, auth, before.version, f.paymentAction, clock).finally(release))); + } finally { delete f.hooks.beforeWrite; } + assert.equal(results.filter(result => result.status === 'fulfilled').length, 1); + assert.equal(results.filter(result => result.status === 'rejected').length, 1); + const done = await f.restart().readRoom(f.code); + assert.equal(done.version, before.version + 1); + assert.equal(player(done, payer).spice, player(before, payer).spice - 4); + assert.equal(player(done, payer).hand.filter(held => held.id === card.id).length, 1); + assert.equal(done.spiceBankerIncome!.sources.filter(source => source.grant).length, 1); + await restored(f, done); +}); + +void test('SQLite lost original payment CAS cannot leak a speculative grant or retire the original card', async t => { + const f = await fixture(t); + const before = f.beforePayment; + const competing = structuredClone(before); + competing.version++; + f.hooks.beforeWrite = async () => { + delete f.hooks.beforeWrite; + f.save(competing); + }; + await assert.rejects(f.restart().act(f.code, await authenticate(f, f.actor), before.version, f.paymentAction, clock)); + assert.deepEqual(await f.restart().readRoom(f.code), competing); + assert.deepEqual(viewGame(competing, f.owner).spiceBankerIncome!.deferred, []); + await restored(f, competing); + const done = await settlePayment(f, competing); + assert.equal(done.spiceBankerIncome!.sources.filter(source => source.grant).length, 1); + await restored(f, done); +}); + +void test('SQLite racing final Collection readiness and automatic recovery credit the original owner atomically once', async t => { + const f = await fixture(t); + const paid = await settlePayment(f); + const { game, step } = await finalCollectionStep(f, paid); + const funds = player(game, f.owner).spice; + const auth = await authenticate(f, step.actor); + let arrivals = 0; + let release!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + f.hooks.beforeWrite = async () => { if (++arrivals === 2) release(); await gate; }; + let results: PromiseSettledResult[]; + try { + results = await Promise.allSettled([ + f.restart().act(f.code, auth, game.version, step.action, clock).finally(release), + f.restart().act(f.code, auth, game.version, step.action, clock).finally(release), + f.restart().continueRoomAutomatic(f.code, clock), + f.restart().continueRoomAutomatic(f.code, clock), + ]); + } finally { delete f.hooks.beforeWrite; } + assert.equal(results.slice(0, 2).filter(result => result.status === 'fulfilled').length, 1); + const done = await f.restart().readRoom(f.code); + assert.equal(done.phase, 8); + assert.equal(done.version, game.version + 1); + assert.equal(player(done, f.owner).spice, funds + 1); + assert.equal(done.spiceBankerIncome!.collections.length, 1); + assert.deepEqual(viewGame(done, f.owner).spiceBankerIncome!.deferred, []); + await Promise.all([f.restart().continueRoomAutomatic(f.code, clock), f.restart().continueRoomAutomatic(f.code, clock)]); + assert.deepEqual(await f.restart().readRoom(f.code), done); + await restored(f, done); +}); + +void test('SQLite foreign credentials, wrong native actor, stale version and forged automatic-income actions cannot mutate payment state', async t => { + const f = await fixture(t); + const before = records(f.sqlite); + const foreign = await f.restart().authenticate(f.unrelatedCode, f.unrelatedToken); + await assert.rejects(f.restart().authenticate(f.code, f.unrelatedToken)); + await assert.rejects(f.restart().readSeatView(f.code, foreign)); + await assert.rejects(f.restart().readSeatView(f.code, { playerId: f.actor, tokenHash: foreign.tokenHash })); + assert.deepEqual(records(f.sqlite), before); + for (const auth of [foreign, { playerId: f.actor, tokenHash: foreign.tokenHash }, + await authenticate(f, f.auths.find(auth => auth.playerId !== f.actor)!.playerId)]) + await rejected(f, f.beforePayment, auth, f.paymentAction); + const auth = await authenticate(f, f.actor); + await rejected(f, f.beforePayment, auth, f.paymentAction, f.beforePayment.version - 1); + for (const action of [{ type: 'spiceBankerIncome', amount: 1 }, { type: 'collectSpiceBankerIncome' }, + { ...f.paymentAction, actor: f.owner }, { ...f.paymentAction, bankerIncome: 1 }, + { ...f.paymentAction, paymentSource: 'bank' }]) + await rejected(f, f.beforePayment, auth, action); + f.sqlite.prepare('UPDATE seats SET revoked=1 WHERE room_code=? AND player_id=?').run(f.code, f.actor); + f.protectedRows = records(f.sqlite, f.code); + await assert.rejects(f.restart().readSeatView(f.code, auth)); + await rejected(f, f.beforePayment, auth, f.paymentAction); +}); + +void test('SQLite corrupt earned source, recipient, amount, stamps and collection history fail closed without read or recovery repair', async t => { + const f = await fixture(t); + const paid = await settlePayment(f); + const changes: ((game: Game) => void)[] = [ + game => { game.spiceBankerIncome!.sources[0].source.bankLegs[0].amount++; }, + game => { game.spiceBankerIncome!.sources[0].source.event += ':forged'; }, + game => { game.spiceBankerIncome!.sources[0].grant!.owner = f.payer; }, + game => { game.spiceBankerIncome!.sources[0].grant!.stamp += ':reset'; }, + game => { game.spiceBankerIncome!.sources.push(structuredClone(game.spiceBankerIncome!.sources[0])); }, + game => { game.spiceBankerIncome!.head += ':forged'; }, + game => { game.spiceBankerIncome!.signature += ':forged'; }, + ]; + const final = await finalCollectionStep(f, paid); + const collected = await act(f, final.game, final.step.actor, final.step.action); + const corruptCollection = structuredClone(collected); + corruptCollection.spiceBankerIncome!.collections[0].credits[0].amount++; + for (const corrupt of [...changes.map(change => { const game = structuredClone(paid); change(game); return game; }), corruptCollection]) { + f.save(corrupt); + const before = records(f.sqlite); + const snapshot = structuredClone(corrupt); + for (const auth of f.auths) + await assert.rejects(f.restart().readSeatView(f.code, await authenticate(f, auth.playerId))); + await rejected(f, corrupt, await authenticate(f, f.actor), { type: 'ready' }); + await Promise.allSettled([f.restart().continueRoomAutomatic(f.code, clock), f.restart().continueRoomAutomatic(f.code, clock)]); + assert.deepEqual(records(f.sqlite), before); + assert.deepEqual(corrupt, snapshot); + } +}); + +void test('SQLite front-shield income cannot fund a native bid before Mentat collection', async t => { + const f = await fixture(t); + let game = await settlePayment(f); + for (let steps = 0; game.phase === 3 && game.auction?.active !== f.owner && steps < 30; steps++) { + const step = nextSpiceBankerIncomeNativeStep(game); + assert.ok(step); + game = await act(f, game, step.actor, step.action); + } + assert.equal(game.phase, 3); + assert.equal(game.auction?.active, f.owner); + assert.deepEqual(viewGame(game, f.owner).spiceBankerIncome!.deferred, [{ owner: f.owner, amount: 1 }]); + assert.equal(player(game, f.owner).hand.some(card => card.effect === 'karama'), false, + 'this is an ordinary funded bid, not an authorized free Karama overbid'); + await rejected(f, game, await authenticate(f, f.owner), + { type: 'bid', amount: player(game, f.owner).spice + 1 }); + await restored(f, game); +}); + +for (const guild of [false, true]) { + void test(`SQLite original shipment to ${guild ? 'native Guild' : 'Bank'} preserves its actual recipient and credits only the Bank case`, async t => { + const f = await fixture(t, { kind: 'shipment', guild }); + const before = f.beforePayment; + const payer = player(before, f.payer); + const shipped = Number(f.paymentAction.amount ?? before.pendingShipment?.amount); + assert.ok(Number.isSafeInteger(shipped) && shipped > 0); + const recipient = before.players.find(seat => seat.faction === 'guild'); + const done = await settlePayment(f); + assert.equal(player(done, f.payer).spice, payer.spice - f.amount); + assert.equal(player(done, f.payer).reserves, payer.reserves - shipped); + assert.equal(player(done, f.payer).shipped, true); + if (f.actor !== f.payer) + assert.equal(player(done, f.actor).reserves, player(before, f.actor).reserves, + 'the native executor does not ship the economic payer’s reserves'); + if (recipient) assert.equal(player(done, recipient.id).spice, recipient.spice + f.amount); + assert.deepEqual(viewGame(done, f.owner).spiceBankerIncome!.deferred, + guild ? [] : [{ owner: f.owner, amount: 1 }]); + await restored(f, done); + }); +} + +void test('SQLite genuine free force revival has zero bank payment and never manufactures income', async t => { + const f = await fixture(t, { kind: 'force-revival' }); + const before = f.beforePayment; + const funds = player(before, f.actor).spice; + const tanks = player(before, f.actor).tanks; + const reserves = player(before, f.actor).reserves; + const done = await act(f, before, f.actor, { type: 'revive', amount: 1 }); + assert.equal(player(done, f.actor).spice, funds); + assert.equal(player(done, f.actor).tanks, tanks - 1); + assert.equal(player(done, f.actor).reserves, reserves + 1); + assert.deepEqual(viewGame(done, f.owner).spiceBankerIncome!.deferred, []); + assert.equal(done.spiceBankerIncome!.sources.filter(source => source.grant).length, 0); + await restored(f, done); +}); + +void test('SQLite pending native Guild income keeps its funded own and donor invoice private and rejects invoice corruption without repair', async t => { + const f = await fixture(t, { kind: 'shipment', guild: true, amount: 5, allySplit: 1, stageIncomeCounter: true }); + const before = f.beforePayment; + assert.ok(player(before, f.owner).hand.some(card => card.effect === 'karama'), + 'an actual held counter keeps the original Guild income window eligible'); + const donor = player(before, f.payer).ally; + const guild = before.players.find(seat => seat.faction === 'guild'); + assert.ok(donor && guild); + let pending = await act(f, before, f.actor, f.paymentAction); + for (let steps = 0; (pending.decision?.kind === 'guildShipment' || + pending.response?.kind === 'guildRate') && steps < 30; steps++) { + assert.equal(pending.phase, before.phase, 'only the original shipment’s native windows may continue'); + const step = nextSpiceBankerIncomeNativeStep(pending); + assert.ok(step); + pending = await act(f, pending, step.actor, step.action); + } + assert.equal(pending.response?.kind, 'guildIncome'); + const invoice = pending.response!.spiceBankerIncomePayment; + assert.ok(invoice); + assert.deepEqual(invoice.legs, [ + { payer: f.payer, amount: 4, recipient: 'player' }, + { payer: donor, amount: 1, recipient: 'player' }, + ]); + assert.equal(player(pending, f.payer).spice, player(before, f.payer).spice - 4); + assert.equal(player(pending, donor).spice, player(before, donor).spice, 'native donor escrow was paid before declaration'); + assert.equal(player(pending, f.payer).reserves, player(before, f.payer).reserves - 5); + assert.equal(player(pending, guild.id).spice, guild.spice, 'original recipient is still awaiting its response'); + assert.deepEqual(viewGame(pending, f.owner).spiceBankerIncome!.deferred, []); + await restored(f, pending); + assert.throws(() => viewGame(pending, 'spectator')); + const privateValues: readonly string[] = [invoice.event, invoice.signature, JSON.stringify(invoice.legs)]; + for (const seat of pending.players) { + const publicView = viewGame(pending, seat.id); + assert.equal(publicView.response!.spiceBankerIncomePayment, undefined); + assert.equal(JSON.stringify(publicView).includes('"spiceBankerIncomePayment":'), false); + for (const privateValue of privateValues) + assert.equal(JSON.stringify(publicView).includes(privateValue), false); + } + const changes: ((game: Game) => void)[] = [ + game => { delete game.response!.spiceBankerIncomePayment; }, + game => { game.response!.spiceBankerIncomePayment!.event += ':forged'; }, + game => { game.response!.spiceBankerIncomePayment!.signature += ':forged'; }, + game => { game.response!.spiceBankerIncomePayment!.legs[0].amount++; }, + game => { game.response!.spiceBankerIncomePayment!.legs[1].payer = f.payer; }, + game => { game.response!.spiceBankerIncomePayment!.legs[0].recipient = 'bank'; }, + game => { game.response!.spiceBankerIncomePayment!.kind = 'auction'; }, + ]; + const continuation = nextSpiceBankerIncomeNativeStep(pending); + assert.ok(continuation); + for (const change of changes) { + const corrupt = structuredClone(pending); + change(corrupt); + f.save(corrupt); + const rows = records(f.sqlite); + for (const auth of f.auths) + await assert.rejects(f.restart().readSeatView(f.code, await authenticate(f, auth.playerId))); + await rejected(f, corrupt, await authenticate(f, continuation.actor), continuation.action); + await Promise.allSettled([f.restart().continueRoomAutomatic(f.code, clock), f.restart().continueRoomAutomatic(f.code, clock)]); + assert.deepEqual(records(f.sqlite), rows, 'corrupt pending invoices cannot be repaired by refresh or automatic recovery'); + } + f.save(pending); + let paid = pending; + for (let steps = 0; paid.response && steps < 30; steps++) { + const step = nextSpiceBankerIncomeNativeStep(paid); + assert.ok(step); + paid = await act(f, paid, step.actor, step.action); + } + assert.equal(paid.response, null); + assert.equal(player(paid, guild.id).spice, guild.spice + 5); + assert.equal(player(paid, f.payer).spice, player(pending, f.payer).spice); + assert.deepEqual(player(paid, f.payer).forces, player(pending, f.payer).forces); + assert.deepEqual(viewGame(paid, f.owner).spiceBankerIncome!.deferred, []); + await restored(f, paid); +}); + +void test('SQLite competing original shipment declarations preserve only the winning tariff and delivered group', async t => { + const f = await fixture(t, { kind: 'shipment' }); + const before = f.beforePayment; + const auth = await authenticate(f, f.actor); + const actions = [f.paymentAction, { ...f.paymentAction, amount: 5 }]; + let arrivals = 0; + let release!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + f.hooks.beforeWrite = async () => { if (++arrivals === 2) release(); await gate; }; + let results: PromiseSettledResult[]; + try { + results = await Promise.allSettled(actions.map(action => + f.restart().act(f.code, auth, before.version, action, clock).finally(release))); + } finally { delete f.hooks.beforeWrite; } + assert.equal(results.filter(result => result.status === 'fulfilled').length, 1); + const winner = results.findIndex(result => result.status === 'fulfilled'); + const amount = winner === 0 ? 4 : 5; + const done = await f.restart().readRoom(f.code); + assert.equal(player(done, f.payer).spice, player(before, f.payer).spice - amount); + assert.equal(player(done, f.payer).reserves, player(before, f.payer).reserves - amount); + assert.equal(done.spiceBankerIncome!.sources.filter(source => source.grant).length, 1); + assert.deepEqual(viewGame(done, f.owner).spiceBankerIncome!.deferred, [{ owner: f.owner, amount: 1 }]); + await restored(f, done); +}); + +for (const fate of ['death', 'capture'] as const) { + void test(`SQLite original earned currency survives actual native trainer ${fate} and collects for the original faction under the local policy`, async t => { + const f = await fixture(t, { advanced: true }); + const paid = await settlePayment(f); + const earned = structuredClone(paid.spiceBankerIncome); + // The fixture stages conserved battle pieces, then executes the genuine + // visibility, plans, survival and capture choices; it never edits income. + const changed = stageSpiceBankerIncomeTrainerBattle(paid, { [fate]: true }); + const trainer = player(changed, f.owner).leaders.find(leader => leader.id === f.leader); + assert.ok(trainer); + if (fate === 'death') assert.equal(trainer.dead, true); + else assert.equal(trainer.capturedBy, changed.players.find(seat => seat.faction === 'harkonnen')!.id); + assert.deepEqual(changed.spiceBankerIncome, earned); + assert.deepEqual(viewGame(changed, f.owner).spiceBankerIncome!.deferred, [{ owner: f.owner, amount: 1 }]); + f.save(changed); + await restored(f, changed); + const { game, step } = await finalCollectionStep(f, changed); + const funds = game.players.map(seat => [seat.id, seat.spice] as const); + const done = await act(f, game, step.actor, step.action); + for (const [id, spice] of funds) + assert.equal(player(done, id).spice, spice + Number(id === f.owner)); + assert.deepEqual(done.spiceBankerIncome!.collections[0].credits, [{ owner: f.owner, amount: 1 }]); + await Promise.all([f.restart().continueRoomAutomatic(f.code, clock), f.restart().continueRoomAutomatic(f.code, clock)]); + assert.deepEqual(await f.restart().readRoom(f.code), done); + await restored(f, done); + }); +} + +void test('SQLite two distinct native auction purchases retain both original costs but award only once in their phase', async t => { + const f = await fixture(t); + const firstPurchase = f.beforePayment.auction!.cards[f.beforePayment.auction!.index].id; + const firstBonus = f.beforePayment.deck[0].id; + const firstHand = player(f.beforePayment, f.payer).hand.map(card => card.id); + assert.equal(player(f.beforePayment, f.payer).faction, 'harkonnen'); + const first = await settlePayment(f); + assert.equal(player(first, f.payer).spice, player(f.beforePayment, f.payer).spice - 4); + assert.notEqual(firstPurchase, firstBonus); + assert.deepEqual(player(first, f.payer).hand.map(card => card.id).sort(), + [...firstHand, firstPurchase, firstBonus].sort()); + assert.deepEqual(first.deck.map(card => card.id), f.beforePayment.deck.slice(1).map(card => card.id)); + let game = first; + for (let steps = 0; game.auction?.active !== f.payer && steps < 30; steps++) { + const step = nextSpiceBankerIncomeNativeStep(game); + assert.ok(step); + game = await act(f, game, step.actor, step.action); + } + assert.equal(game.phase, 3); + assert.equal(game.auction?.active, f.payer); + const funds = player(game, f.payer).spice; + const hand = player(game, f.payer).hand.map(card => card.id); + const acquired = game.auction!.cards[game.auction!.index].id; + const bonus = game.deck[0].id; + const deck = game.deck.map(card => card.id); + assert.equal(hand.includes(acquired), false); + assert.equal(hand.includes(bonus), false); + assert.notEqual(acquired, firstPurchase, 'the second purchase is a distinct original auction lot'); + assert.notEqual(acquired, bonus); + game = await act(f, game, f.payer, { type: 'bid', amount: 4 }); + for (let steps = 0; (!player(game, f.payer).hand.some(card => card.id === acquired) || + game.response?.kind === 'harkonnenBonus') && steps < 30; steps++) { + if (game.decision?.kind === 'auctionPayment') + game = await act(f, game, f.payer, { type: 'decision', karama: false }); + else { + const step = nextSpiceBankerIncomeNativeStep(game); + assert.ok(step); + game = await act(f, game, step.actor, step.action); + } + } + assert.equal(player(game, f.payer).spice, funds - 4); + assert.deepEqual(player(game, f.payer).hand.map(card => card.id).sort(), [...hand, acquired, bonus].sort()); + assert.deepEqual(game.deck.map(card => card.id), deck.slice(1), 'the actual native bonus leaves its sole deck custodian once'); + assert.equal(game.spiceBankerIncome!.sources.length, first.spiceBankerIncome!.sources.length + 1); + assert.equal(game.spiceBankerIncome!.sources.filter(source => source.grant).length, 1); + assert.notEqual(game.spiceBankerIncome!.sources[0].source.event, game.spiceBankerIncome!.sources[1].source.event); + assert.deepEqual(game.spiceBankerIncome!.sources.map(receipt => receipt.source.bankLegs), + [[{ payer: f.payer, amount: 4 }], [{ payer: f.payer, amount: 4 }]]); + assert.deepEqual(viewGame(game, f.owner).spiceBankerIncome!.deferred, [{ owner: f.owner, amount: 1 }]); + await restored(f, game); +}); + +void test('SQLite one funded auction split between actual payers never pools three and one into a qualifying payment', async t => { + const f = await fixture(t, { amount: 4, allySplit: 1 }); + const before = f.beforePayment; + const donor = player(before, f.payer).ally; + assert.ok(donor); + const done = await settlePayment(f); + assert.equal(player(done, f.payer).spice, player(before, f.payer).spice - 3); + assert.equal(player(done, donor).spice, player(before, donor).spice, 'already committed donor escrow is not charged twice'); + const source = done.spiceBankerIncome!.sources.at(-1); + assert.ok(source); + assert.deepEqual(source.source.bankLegs, [{ payer: f.payer, amount: 3 }, { payer: donor, amount: 1 }]); + assert.equal(source.grant, null); + assert.deepEqual(viewGame(done, f.owner).spiceBankerIncome!.deferred, []); + await restored(f, done); +}); + +void test('SQLite the actual returned Banker card can be legally revived and assigned to a different faction without transferring original earned currency', async t => { + const f = await fixture(t, { advanced: true }); + const paid = await settlePayment(f); + const originalSource = structuredClone(paid.spiceBankerIncome!.sources[0]); + // Parent-approved entropy is scoped to the genuine native mutual-death return + // shuffle. The helper then follows real Mentat and next-turn Revival; it never + // reorders a started deck, fabricates an offer or edits an income receipt. + const lifecycle = createSpiceBankerIncomeReassignmentFixture(paid); + let game = lifecycle.beforeRevival; + assert.notEqual(lifecycle.actor, lifecycle.originalOwner); + assert.equal(game.turn, paid.turn + 1); + assert.equal(game.phase, 4); + assert.deepEqual(game.spiceBankerIncome!.sources[0], originalSource); + assert.deepEqual(game.spiceBankerIncome!.collections[0].credits, [{ owner: lifecycle.originalOwner, amount: 1 }]); + assert.deepEqual(viewGame(game, lifecycle.originalOwner).spiceBankerIncome!.deferred, []); + assert.equal(game.leaderSkills!.assignments.some(assignment => assignment.skill === 'spice-banker'), false); + f.save(game); + await restored(f, game); + const funds = player(game, lifecycle.actor).spice; + const originalFunds = player(game, lifecycle.originalOwner).spice; + const trainer = player(game, lifecycle.actor).leaders.find(leader => leader.id === lifecycle.leader); + assert.ok(trainer?.dead); + const price = trainer.strength; + game = await act(f, game, lifecycle.actor, lifecycle.revivalAction); + for (let steps = 0; game.response && steps < 30; steps++) { + const step = nextSpiceBankerIncomeNativeStep(game); + assert.ok(step); + game = await act(f, game, step.actor, step.action); + } + assert.equal(game.decision?.kind, 'leaderSkillRevival'); + assert.equal(player(game, lifecycle.actor).spice, funds - price); + assert.equal(player(game, lifecycle.originalOwner).spice, originalFunds); + assert.equal(player(game, lifecycle.actor).leaders.find(leader => leader.id === lifecycle.leader)!.dead, false); + assert.equal(game.spiceBankerIncome!.sources.at(-1)!.grant, null, 'future skill assignment cannot award for the earlier revival payment'); + await restored(f, game); + const event = game.decision!.event; + game = await act(f, game, lifecycle.actor, { type: 'leaderSkill', event, mode: 'draw' }); + assert.ok(game.leaderSkills!.offers[lifecycle.actor].cards.includes('spice-banker')); + await restored(f, game); + const beforeSelection = game; + const select: Action = { type: 'leaderSkill', event, skill: 'spice-banker', leader: lifecycle.leader }; + game = await act(f, game, lifecycle.actor, select); + assert.deepEqual(game.leaderSkills!.assignments.find(assignment => assignment.skill === 'spice-banker'), + { owner: lifecycle.actor, leader: lifecycle.leader, skill: 'spice-banker' }); + const skills = [...game.leaderSkills!.deck, ...Object.values(game.leaderSkills!.offers).flatMap(offer => offer.cards), + ...game.leaderSkills!.assignments.map(assignment => assignment.skill)]; + assert.deepEqual(skills.sort(), createLeaderSkills(() => 0.5).deck.sort(), 'all fourteen physical skills remain in one custodian'); + assert.deepEqual(game.spiceBankerIncome!.sources[0], originalSource); + assert.deepEqual(game.spiceBankerIncome!.collections[0].credits, [{ owner: lifecycle.originalOwner, amount: 1 }]); + assert.equal(player(game, lifecycle.actor).spice, funds - price); + assert.equal(player(game, lifecycle.originalOwner).spice, originalFunds); + assert.deepEqual(viewGame(game, lifecycle.actor).spiceBankerIncome!.deferred, []); + await rejected(f, game, await authenticate(f, lifecycle.actor), select, beforeSelection.version); + await rejected(f, game, await authenticate(f, lifecycle.actor), select); + await Promise.all([f.restart().continueRoomAutomatic(f.code, clock), f.restart().continueRoomAutomatic(f.code, clock)]); + assert.deepEqual(await f.restart().readRoom(f.code), game); + await restored(f, game); +}); + +for (const kind of ['force-revival', 'leader-revival', 'kh-revival', 'emperor-extra-revival'] as const) { + void test(`SQLite actual paid ${kind} preserves native payer, revived beneficiary and allowance while retaining only qualifying bank income`, async t => { + const f = await fixture(t, { kind, advanced: true, + ...(kind === 'force-revival' ? { payerFaction: 'emperor', emperor: true } : {}), + ...(kind === 'kh-revival' ? { bankerFaction: 'harkonnen', payerFaction: 'atreides' } : {}) }); + const before = f.beforePayment; + const payer = player(before, f.payer); + const beneficiary = kind === 'emperor-extra-revival' ? player(before, payer.ally!) : payer; + const trainer = player(before, f.owner).leaders.find(leader => leader.id === f.leader); + assert.ok(trainer && !trainer.dead && !trainer.capturedBy); + assert.notEqual(f.owner, payer.id, 'these are another real payer’s bank payment'); + const paid = await settlePayment(f); + const revived = player(paid, beneficiary.id); + assert.equal(player(paid, payer.id).spice, payer.spice - f.amount); + assert.equal(player(paid, f.owner).spice, player(before, f.owner).spice); + if (kind === 'force-revival') { + assert.equal(payer.faction, 'emperor', 'native free-one revival leaves two paid forces'); + assert.equal(f.amount, 4); + assert.equal(revived.reserves, beneficiary.reserves + 3); + assert.equal(revived.tanks, beneficiary.tanks - 3); + assert.equal(revived.revived, beneficiary.revived + 3); + assert.equal(revived.freeForcesRevived, (beneficiary.freeForcesRevived ?? 0) + 1); + } else if (kind === 'leader-revival') { + const target = beneficiary.leaders.find(leader => leader.id === f.paymentAction.leader); + assert.ok(target?.dead); + assert.equal(revived.leaders.find(leader => leader.id === target.id)!.dead, false); + assert.equal(revived.leaderRevived, true); + assert.equal(paid.decision?.kind, 'leaderSkillRevival'); + } else if (kind === 'kh-revival') { + assert.equal(f.amount, 2, 'KH is its actual native two-spice price, never a fabricated four'); + assert.equal(beneficiary.kwisatz?.dead, true); + assert.equal(revived.kwisatz?.dead, false); + assert.equal(revived.kwisatz?.revivalCycle, Math.max(revived.revivalCycle, beneficiary.kwisatz!.revivalCycle ?? 1) + 1); + assert.equal(revived.leaderRevived, true); + } else { + assert.notEqual(payer.id, beneficiary.id); + assert.equal(payer.faction, 'emperor'); + assert.equal(revived.spice, beneficiary.spice, 'the revived ally does not pay the Emperor’s debit'); + assert.equal(revived.reserves, beneficiary.reserves + 2); + assert.equal(revived.tanks, beneficiary.tanks - 2); + assert.equal(revived.revived, beneficiary.revived, 'extra revivals do not consume the normal allowance'); + assert.equal(revived.freeForcesRevived, beneficiary.freeForcesRevived); + assert.equal(paid.emperorExtra[beneficiary.id], (before.emperorExtra[beneficiary.id] ?? 0) + 2); + } + const source = paid.spiceBankerIncome!.sources.at(-1); + assert.ok(source); + assert.equal(paid.spiceBankerIncome!.sources.length, before.spiceBankerIncome!.sources.length + 1); + assert.equal(source.source.kind, kind); + assert.equal(source.source.turn, before.turn); + assert.equal(source.source.phase, 4); + assert.deepEqual(source.source.bankLegs, [{ payer: payer.id, amount: f.amount }]); + assert.equal(source.source.trainer?.resolved, true, 'the source authority denotes committed native payment'); + assert.deepEqual(viewGame(paid, f.owner).spiceBankerIncome!.deferred, + kind === 'kh-revival' ? [] : [{ owner: f.owner, amount: 1 }]); + await rejected(f, paid, await authenticate(f, f.actor), f.paymentAction, before.version); + await restored(f, paid); + }); +} + +for (const options of [ + { trainerSelected: true, amount: 4, expected: 1 }, + { trainerSelected: true, trainerDies: true, amount: 4, expected: 0 }, + { trainerHidden: true, amount: 4, expected: 0 }, + { amount: 3, expected: 0 }, +]) { + void test(`SQLite resolved native support preserves payment and obeys trainer survival, concealment and threshold ${JSON.stringify(options)}`, async t => { + const { expected, ...native } = options; + const f = await fixture(t, { kind: 'battle-support', advanced: true, ...native }); + const before = f.beforePayment; + assert.ok(before.battle?.revealed); + assert.equal(before.battle.plans[f.payer].support, options.amount); + assert.deepEqual(viewGame(before, f.owner).spiceBankerIncome!.deferred, []); + const paid = await settlePayment(f); + const trainer = player(paid, f.owner).leaders.find(leader => leader.id === f.leader); + assert.ok(trainer); + assert.equal(trainer.dead, 'trainerDies' in options && options.trainerDies); + assert.equal(paid.lastBattleContext?.event, before.battle.event); + assert.equal(paid.lastBattleContext?.result, 'normal'); + const economics = quoteSpiceBankerIncomeBattleEconomics(before, paid); + const support = economics.resolution.payments.find(payment => payment.player === f.payer); + assert.ok(support); + assert.equal(support.ownPayment, options.amount, 'the original native support debit is not netted against income'); + const bounty = economics.resolution.bounty; + assert.equal(bounty?.amount ?? 0, trainer.dead ? trainer.strength : 0); + if (bounty) assert.equal(bounty.player, f.payer, 'the winner earns the real killed-leader bounty'); + for (const id of [f.payer, f.owner]) { + const debit = economics.resolution.payments.find(payment => payment.player === id)!.ownPayment; + const collection = economics.nativeCollection.find(receipt => receipt.player === id); + const nativeIncome = (bounty?.player === id ? bounty.amount : 0) + + (collection ? collection.strongholds + collection.collected : 0); + assert.equal(player(paid, id).spice, player(before, id).spice - debit + nativeIncome, + 'original support, native bounty and actual Collection remain separate from deferred Banker income'); + } + const source = paid.spiceBankerIncome!.sources.at(-1); + assert.ok(source); + assert.equal(source.source.kind, 'battle-support'); + assert.deepEqual(source.source.bankLegs, [{ payer: f.payer, amount: options.amount }]); + if (trainer.dead) assert.equal(source.source.trainer, null); + else { + assert.equal(source.source.trainer?.resolved, true); + assert.equal(source.source.trainer?.selected, 'trainerSelected' in options && options.trainerSelected); + assert.equal(source.source.trainer?.faceUp, !('trainerSelected' in options || 'trainerHidden' in options)); + } + assert.equal(source.grant?.amount ?? 0, expected); + assert.deepEqual(viewGame(paid, f.owner).spiceBankerIncome!.deferred, + expected ? [{ owner: f.owner, amount: 1 }] : []); + await rejected(f, paid, await authenticate(f, f.actor), f.paymentAction, before.version); + await restored(f, paid); + }); +} + +void test('SQLite Emperor self-purchase actually pays the Bank, retains its original card and auction counter, and collects deferred income at native Mentat once', async t => { + const f = await fixture(t, { emperor: true, payerFaction: 'emperor' }); + const before = f.beforePayment; + const emperor = player(before, f.payer); + assert.equal(emperor.faction, 'emperor'); + assert.notEqual(emperor.id, f.owner); + assert.ok(before.auction); + const card = before.auction.cards[before.auction.index]; + const paid = await settlePayment(f); + assert.equal(player(paid, emperor.id).spice, emperor.spice - 4, 'self purchase is a real bank debit, not self-recipient income'); + assert.equal(player(paid, emperor.id).hand.filter(held => held.id === card.id).length, 1); + assert.equal(paid.auction?.index, before.auction.index + 1); + assert.equal(player(paid, f.owner).spice, player(before, f.owner).spice); + const receipt = paid.spiceBankerIncome!.sources.at(-1); + assert.ok(receipt); + assert.deepEqual(receipt.source.bankLegs, [{ payer: emperor.id, amount: 4 }]); + assert.deepEqual(viewGame(paid, f.owner).spiceBankerIncome!.deferred, [{ owner: f.owner, amount: 1 }]); + await restored(f, paid); + const final = await finalCollectionStep(f, paid); + const funds = player(final.game, f.owner).spice; + const collected = await act(f, final.game, final.step.actor, final.step.action); + assert.equal(collected.phase, 8); + assert.equal(player(collected, f.owner).spice, funds + 1); + assert.deepEqual(collected.spiceBankerIncome!.collections.at(-1)!.credits, [{ owner: f.owner, amount: 1 }]); + assert.deepEqual(viewGame(collected, f.owner).spiceBankerIncome!.deferred, []); + await Promise.all([f.restart().continueRoomAutomatic(f.code, clock), f.restart().continueRoomAutomatic(f.code, clock)]); + assert.deepEqual(await f.restart().readRoom(f.code), collected); + await rejected(f, collected, await authenticate(f, final.step.actor), final.step.action, final.game.version); + await restored(f, collected); +}); diff --git a/tests/spice-banker-income-runtime.test.ts b/tests/spice-banker-income-runtime.test.ts new file mode 100644 index 00000000..1f9316dc --- /dev/null +++ b/tests/spice-banker-income-runtime.test.ts @@ -0,0 +1,304 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import {applyAction,viewGame,type Game} from '../game/engine'; +import {advanceSpiceBankerIncomeToMentat,createSpiceBankerIncomeFixture,createSpiceBankerIncomeReassignmentFixture,nextSpiceBankerIncomeNativeStep,quoteSpiceBankerIncomeBattleEconomics,stageSpiceBankerIncomeTrainerBattle,type SpiceBankerIncomeFixture,type SpiceBankerIncomeFixtureOptions} from './fixture-spice-banker-income'; +import {forceRevivalQuote} from '../game/revival'; + +function pay(fixture:SpiceBankerIncomeFixture,action=fixture.paymentAction):Game { + let game=applyAction(fixture.game,fixture.actor,action); + for(let i=0;(game.response||game.decision)&&i<100;i++) { + const next=nextSpiceBankerIncomeNativeStep(game);assert.ok(next);game=applyAction(game,next.actor,next.action); + } + return game; +} +function deferred(game:Game,owner:string):number { + return viewGame(game,owner).spiceBankerIncome!.deferred.find(row=>row.owner===owner)?.amount??0; +} +function nativeCounters(game:Game):Game { + for(let i=0;game.response&&i<100;i++) { + const next=nextSpiceBankerIncomeNativeStep(game);assert.ok(next);game=applyAction(game,next.actor,next.action); + } + return game; +} +function beforeMentat(state:Game):Game { + let game=state; + for(let i=0;(game.phase!==7||game.phaseOpening||game.response||game.decision)&&i<1000;i++) { + const next=nextSpiceBankerIncomeNativeStep(game);assert.ok(next);game=applyAction(game,next.actor,next.action); + } + assert.equal(game.phase,7);return game; +} + +for(const advanced of [false,true]) for(const amount of [3,4]) { + void test(`normal paid bank auction ${amount}, ${advanced?'Advanced':'Basic'} retains price/card and defers only qualifying income`,()=>{ + const fixture=createSpiceBankerIncomeFixture({advanced,amount}); + const payer=fixture.game.players.find(p=>p.id===fixture.payer)!; + const card=fixture.game.auction!.cards[fixture.game.auction!.index]; + const balance=payer.spice,ownerBalance=fixture.game.players.find(p=>p.id===fixture.owner)!.spice; + const paid=pay(fixture); + assert.equal(paid.players.find(p=>p.id===fixture.payer)!.spice,balance-amount); + assert.equal(paid.players.find(p=>p.id===fixture.payer)!.hand.filter(c=>c.id===card.id).length,1); + assert.equal(paid.players.find(p=>p.id===fixture.owner)!.spice,ownerBalance); + assert.equal(deferred(paid,fixture.owner),amount===4?1:0); + const collection=beforeMentat(paid),nativeBalance=collection.players.find(p=>p.id===fixture.owner)!.spice; + const mentat=advanceSpiceBankerIncomeToMentat(collection); + assert.equal(deferred(mentat,fixture.owner),0); + assert.equal(mentat.players.find(p=>p.id===fixture.owner)!.spice,nativeBalance+(amount===4?1:0)); + }); +} +for(const kind of ['shipment','force-revival','leader-revival','emperor-extra-revival','cross-shipment','return-shipment','battle-support'] as const) { + void test(`actual ${kind} original native cost and effect commit once with deferred normal Banker income`,()=>{ + const fixture=createSpiceBankerIncomeFixture({kind,advanced:true,...(kind==='force-revival'?{payerFaction:'emperor' as const}:{})}); + const payerBefore=fixture.game.players.find(p=>p.id===fixture.payer)!; + const balance=payerBefore.spice,forces=payerBefore.reserves,tanks=payerBefore.tanks; + if(kind==='leader-revival') { + assert.ok(payerBefore.leaders.every(l=>l.dead)); + assert.equal(fixture.game.leaderSkills!.assignments.some(a=>a.owner===fixture.payer),false); + assert.ok(fixture.game.leaderSkills!.assignments.some(a=>a.owner===fixture.owner&&a.skill==='spice-banker')); + assert.equal(fixture.game.players.find(p=>p.id===fixture.owner)!.leaders.find(l=>l.id===fixture.leader)!.dead,false); + } + const paid=pay(fixture),payer=paid.players.find(p=>p.id===fixture.payer)!; + if(kind==='battle-support') { + const {resolution,nativeCollection}=quoteSpiceBankerIncomeBattleEconomics(fixture.game,paid); + const payment=resolution.payments.find(row=>row.player===fixture.payer)!; + assert.equal(payment.ownPayment,fixture.game.battle!.plans[fixture.payer].support); + assert.deepEqual(paid.spiceBankerIncome!.sources.at(-1)!.source.bankLegs,[{payer:fixture.payer,amount:payment.ownPayment}]); + const collection=nativeCollection.find(row=>row.player===fixture.payer); + assert.equal(payer.spice,balance-payment.ownPayment+(resolution.bounty?.player===fixture.payer?resolution.bounty.amount:0) + +(collection?collection.strongholds+collection.collected:0)); + } else assert.equal(payer.spice,balance-fixture.amount); + assert.equal(deferred(paid,fixture.owner),1); + if(kind==='shipment') assert.equal(payer.reserves,forces-4); + if(kind==='force-revival') { + const quote=forceRevivalQuote(fixture.game,payerBefore,3); + assert.equal(payerBefore.faction,'emperor');assert.equal(quote.free,1);assert.equal(quote.cost,4); + assert.equal(fixture.amount,quote.cost); + assert.equal(payer.reserves,forces+3);assert.equal(payer.tanks,tanks-3);assert.equal(payer.revived,3); + assert.equal(payer.freeForcesRevived,(payerBefore.freeForcesRevived??0)+quote.free); + } + if(kind==='leader-revival') assert.equal(payer.leaders.find(l=>l.id===fixture.paymentAction.leader)!.dead,false); + if(kind==='return-shipment') assert.equal(payer.reserves,forces+8); + const source=paid.spiceBankerIncome!.sources.find(r=>r.grant)!; + assert.equal(source.source.kind,kind==='cross-shipment'||kind==='return-shipment'?'shipment':kind); + const restored=JSON.parse(JSON.stringify(paid)) as Game; + assert.equal(deferred(restored,fixture.owner),1); + assert.equal(restored.players.find(p=>p.id===fixture.payer)!.spice,payer.spice); + }); +} +void test('native Harkonnen force revival uses its two-free allowance and real two-spice boundary',()=>{ + const fixture=createSpiceBankerIncomeFixture({kind:'force-revival',advanced:true}); + const payer=fixture.game.players.find(p=>p.id===fixture.payer)!; + assert.equal(payer.faction,'harkonnen');assert.notEqual(fixture.payer,fixture.owner); + const quote=forceRevivalQuote(fixture.game,payer,3); + assert.equal(quote.free,2);assert.equal(quote.cost,2);assert.equal(fixture.amount,quote.cost); + const paid=pay(fixture),revived=paid.players.find(p=>p.id===fixture.payer)!; + assert.equal(revived.spice,payer.spice-quote.cost); + assert.equal(revived.freeForcesRevived,(payer.freeForcesRevived??0)+quote.free); + assert.equal(revived.reserves,payer.reserves+3);assert.equal(revived.tanks,payer.tanks-3); + assert.deepEqual(paid.spiceBankerIncome!.sources.at(-1)!.source.bankLegs,[{payer:fixture.payer,amount:quote.cost}]); + assert.equal(deferred(paid,fixture.owner),0); +}); +for(const options of [{ownerIsPayer:true},{emperor:true},{amount:4,allySplit:2},{guild:true,kind:'shipment'}] satisfies SpiceBankerIncomeFixtureOptions[]) { + void test(`nonqualifying native payment ${JSON.stringify(options)} does not manufacture bank income`,()=>{ + const fixture=createSpiceBankerIncomeFixture(options);const paid=pay(fixture); + assert.equal(deferred(paid,fixture.owner),0); + assert.equal(paid.spiceBankerIncome!.sources.some(r=>r.grant),false); + }); +} +void test('Emperor native self-auction pays BANK once and qualifies another living Banker',()=>{ + const fixture=createSpiceBankerIncomeFixture({emperor:true,payerFaction:'emperor',amount:4}); + const emperor=fixture.game.players.find(p=>p.id===fixture.payer)!; + assert.equal(emperor.faction,'emperor');assert.notEqual(fixture.payer,fixture.owner); + const card=fixture.game.auction!.cards[fixture.game.auction!.index]; + const paid=pay(fixture),buyer=paid.players.find(p=>p.id===fixture.payer)!; + assert.equal(buyer.spice,emperor.spice-4); + assert.equal(buyer.hand.filter(c=>c.id===card.id).length,1); + assert.equal(paid.response?.kind==='emperorIncome',false); + assert.equal(deferred(paid,fixture.owner),1); + assert.deepEqual(paid.spiceBankerIncome!.sources.find(r=>r.grant)!.source.bankLegs,[{payer:fixture.payer,amount:4}]); +}); +for(const bankerFaction of ['atreides','harkonnen'] as const) { + void test(`native KH explosion death and next-turn two-spice revival never qualify (${bankerFaction} Banker)`,()=>{ + const fixture=createSpiceBankerIncomeFixture({kind:'kh-revival',advanced:true,bankerFaction,payerFaction:'atreides'}); + const payer=fixture.game.players.find(p=>p.id===fixture.payer)!; + assert.ok(payer.battleLosses>=7);assert.equal(payer.kwisatz?.dead,true); + assert.ok(payer.leaders.every(l=>l.dead)); + assert.equal(fixture.game.leaderSkills!.assignments.some(a=>a.owner===payer.id),false); + if(fixture.owner!==fixture.payer) { + assert.ok(fixture.game.leaderSkills!.assignments.some(a=>a.owner===fixture.owner&&a.skill==='spice-banker')); + assert.equal(fixture.game.players.find(p=>p.id===fixture.owner)!.leaders.find(l=>l.id===fixture.leader)!.dead,false); + } + const paid=pay(fixture),revived=paid.players.find(p=>p.id===fixture.payer)!; + assert.equal(revived.spice,payer.spice-2);assert.equal(revived.kwisatz?.dead,false); + assert.equal(revived.leaderRevived,true);assert.equal(deferred(paid,fixture.owner),0); + assert.equal(paid.spiceBankerIncome!.sources.some(r=>r.grant),false); + }); +} +void test('native self leader death returns Banker before legal revival, so its paid return grants no income',()=>{ + const fixture=createSpiceBankerIncomeFixture({kind:'leader-revival',advanced:true,ownerIsPayer:true}); + assert.equal(fixture.payer,fixture.owner); + assert.equal(fixture.game.leaderSkills!.assignments.some(a=>a.skill==='spice-banker'),false); + const payer=fixture.game.players.find(p=>p.id===fixture.payer)!; + const paid=pay(fixture),revived=paid.players.find(p=>p.id===fixture.payer)!; + assert.equal(revived.spice,payer.spice-4); + assert.equal(revived.leaders.find(l=>l.id===fixture.paymentAction.leader)!.dead,false); + assert.equal(deferred(paid,fixture.owner),0); +}); +void test('printed Karama auction and free native revival have no positive bank payment',()=>{ + const auction=createSpiceBankerIncomeFixture({stageKarama:true});const payer=auction.game.players.find(p=>p.id===auction.payer)!; + const card=payer.hand.find(c=>c.effect==='karama')!;assert.ok(card);const balance=payer.spice; + const free=pay(auction,{type:'decision',karama:true,card:card.id}); + assert.equal(free.players.find(p=>p.id===auction.payer)!.spice,balance); + assert.equal(free.players.find(p=>p.id===auction.payer)!.hand.some(c=>c.id===card.id),false); + assert.equal(free.discard.filter(c=>c.id===card.id).length,1); + assert.equal(deferred(free,auction.owner),0); + const revival=createSpiceBankerIncomeFixture({kind:'force-revival'}); + const revived=pay(revival,{type:'revive',amount:1}); + assert.equal(revived.players.find(p=>p.id===revival.payer)!.spice,revival.game.players.find(p=>p.id===revival.payer)!.spice); + assert.equal(deferred(revived,revival.owner),0); +}); +void test('canceled Emperor income goes to BANK without charging buyer twice or losing card',()=>{ + const fixture=createSpiceBankerIncomeFixture({emperor:true,stageIncomeCounter:true}); + const purchased=fixture.game.auction!.cards[fixture.game.auction!.index]; + const emperorBefore=fixture.game.players.find(p=>p.faction==='emperor')!; + let game=applyAction(fixture.game,fixture.actor,fixture.paymentAction); + assert.equal(game.response?.kind,'emperorIncome'); + const canceler=game.players.find(p=>p.id===fixture.owner)!; + const card=canceler.hand.find(c=>c.effect==='karama');assert.ok(card); + const balance=game.players.find(p=>p.id===fixture.payer)!.spice; + assert.equal(balance,fixture.game.players.find(p=>p.id===fixture.payer)!.spice-fixture.amount); + game=applyAction(game,canceler.id,{type:'card',card:card.id,mode:'cancel'});game=nativeCounters(game); + assert.equal(game.players.find(p=>p.id===fixture.payer)!.spice,balance); + assert.equal(game.players.find(p=>p.id===fixture.payer)!.hand.filter(c=>c.id===purchased.id).length,1); + assert.equal(game.players.find(p=>p.id===emperorBefore.id)!.spice,emperorBefore.spice); + assert.equal(game.discard.filter(c=>c.id===card.id).length,1); + assert.deepEqual(game.spiceBankerIncome!.sources.at(-1)!.source.bankLegs,fixture.bankLegs); + assert.equal(deferred(game,fixture.owner),1); +}); +void test('canceled Guild income uses the actual funded shipment legs, not waived or pooled tariff',()=>{ + const fixture=createSpiceBankerIncomeFixture({guild:true,kind:'shipment',stageIncomeCounter:true}); + const guildBefore=fixture.game.players.find(p=>p.faction==='guild')!; + let game=applyAction(fixture.game,fixture.actor,fixture.paymentAction);game=nativeCountersUntil(game,'guildIncome'); + assert.equal(game.response?.kind,'guildIncome'); + const canceler=game.players.find(p=>p.id===fixture.owner)!; + const card=canceler.hand.find(c=>c.effect==='karama');assert.ok(card); + const forces=game.players.find(p=>p.id===fixture.payer)!.forces; + const balance=game.players.find(p=>p.id===fixture.payer)!.spice; + assert.equal(balance,fixture.game.players.find(p=>p.id===fixture.payer)!.spice-fixture.amount); + game=applyAction(game,canceler.id,{type:'card',card:card.id,mode:'cancel'});game=nativeCounters(game); + assert.deepEqual(game.players.find(p=>p.id===fixture.payer)!.forces,forces); + assert.equal(game.players.find(p=>p.id===fixture.payer)!.spice,balance); + assert.equal(game.players.find(p=>p.id===guildBefore.id)!.spice,guildBefore.spice); + assert.equal(game.discard.filter(c=>c.id===card.id).length,1); + assert.deepEqual(game.spiceBankerIncome!.sources.at(-1)!.source.bankLegs,fixture.bankLegs); + assert.equal(deferred(game,fixture.owner),1); +}); +function nativeCountersUntil(game:Game,kind:string):Game { + for(let i=0;(game.response&&game.response.kind!==kind||game.decision?.kind==='guildShipment')&&i<100;i++) { + const next=nextSpiceBankerIncomeNativeStep(game);assert.ok(next);game=applyAction(game,next.actor,next.action); + } + return game; +} +void test('one physical Banker stamps once per phase and permits another native phase payment',()=>{ + const fixture=createSpiceBankerIncomeFixture({amount:5,allySplit:1});let game=pay(fixture); + assert.ok(game.auction); + for (;;) { + const auction = game.auction; + assert.ok(auction, 'The second funded native lot must still be offered.'); + if (auction.active === fixture.payer) break; + game = applyAction(game, auction.active, {type:'passBid'}); + } + const index=game.auction!.index; + game=applyAction(game,fixture.payer,{type:'bid',amount:4}); + for(let i=0;(game.auction?.index===index||game.response||game.decision)&&i<100;i++) { + const next=nextSpiceBankerIncomeNativeStep(game);assert.ok(next);game=applyAction(game,next.actor,next.action); + } + assert.equal(deferred(game,fixture.owner),1); + assert.equal(game.spiceBankerIncome!.sources.filter(r=>r.grant).length,1); + while(game.phase===3) {const next=nextSpiceBankerIncomeNativeStep(game);assert.ok(next);game=applyAction(game,next.actor,next.action);} + while(game.phaseOpening||game.response||game.decision) {const next=nextSpiceBankerIncomeNativeStep(game);assert.ok(next);game=applyAction(game,next.actor,next.action);} + assert.equal(game.phase,4); + const payer=game.players.find(p=>p.faction==='beneGesserit')!;payer.reserves-=3;payer.tanks+=3; + game=applyAction(game,payer.id,{type:'revive',amount:3});game=nativeCounters(game); + assert.equal(deferred(game,fixture.owner),2); + assert.equal(new Set(game.spiceBankerIncome!.sources.filter(r=>r.grant).map(r=>r.grant!.stamp)).size,2); +}); +for(const lifecycle of ['death','capture'] as const) { + void test(`earned entitlement stays original through genuine native trainer ${lifecycle} and actual Mentat collection`,()=>{ + const fixture=createSpiceBankerIncomeFixture({advanced:true});const paid=pay(fixture); + const staged=stageSpiceBankerIncomeTrainerBattle(paid,{[lifecycle]:true}); + assert.equal(deferred(staged,fixture.owner),1); + const collection=beforeMentat(staged),balance=collection.players.find(p=>p.id===fixture.owner)!.spice; + const mentat=advanceSpiceBankerIncomeToMentat(collection); + assert.equal(mentat.players.find(p=>p.id===fixture.owner)!.spice,balance+1); + assert.equal(deferred(mentat,fixture.owner),0); + const restored=JSON.parse(JSON.stringify(mentat)) as Game; + for(const p of restored.players) {const view=viewGame(restored,p.id);assert.deepEqual(view.spiceBankerIncome,{deferred:[],usedThisPhase:[]});} + }); +} +void test('public front-shield counts never disclose private source/authority and malformed inputs reject immutably',()=>{ + const fixture=createSpiceBankerIncomeFixture();const paid=pay(fixture); + assert.equal(paid.phase,fixture.game.phase,'compare use while the native auction phase is still offered'); + for(const p of paid.players) assert.deepEqual(viewGame(paid,p.id).spiceBankerIncome,{deferred:[{owner:fixture.owner,amount:1}],usedThisPhase:[fixture.owner]}); + const before=JSON.stringify(fixture.game); + assert.throws(()=>applyAction(fixture.game,fixture.owner,{...fixture.paymentAction,actor:fixture.actor})); + assert.equal(JSON.stringify(fixture.game),before); + const corrupt=JSON.parse(JSON.stringify(paid)) as Game; + corrupt.spiceBankerIncome!.sources[0].source.bankLegs[0].amount++; + const corruptBefore=JSON.stringify(corrupt); + assert.throws(()=>viewGame(corrupt,fixture.owner));assert.throws(()=>applyAction(corrupt,fixture.owner,{type:'ready'})); + assert.equal(JSON.stringify(corrupt),corruptBefore); +}); +for(const options of [ + {trainerSelected:true,expected:1}, + {trainerSelected:true,trainerDies:true,expected:0}, + {trainerHidden:true,expected:0}, + {amount:3,expected:0}, +]) { + void test(`battle income follows resolved native trainer eligibility ${JSON.stringify(options)}`,()=>{ + const fixture=createSpiceBankerIncomeFixture({kind:'battle-support',advanced:true,...options}); + assert.equal(deferred(fixture.game,fixture.owner),0); + const paid=pay(fixture); + assert.equal(deferred(paid,fixture.owner),options.expected); + const {resolution,nativeCollection}=quoteSpiceBankerIncomeBattleEconomics(fixture.game,paid); + const payment=resolution.payments.find(row=>row.player===fixture.payer)!; + assert.equal(payment.ownPayment,fixture.game.battle!.plans[fixture.payer].support); + assert.deepEqual(paid.spiceBankerIncome!.sources.at(-1)!.source.bankLegs,[{payer:fixture.payer,amount:payment.ownPayment}]); + const bounty=resolution.bounty?.player===fixture.payer?resolution.bounty.amount:0; + const collection=nativeCollection.find(row=>row.player===fixture.payer); + assert.equal(paid.players.find(p=>p.id===fixture.payer)!.spice, + fixture.game.players.find(p=>p.id===fixture.payer)!.spice-payment.ownPayment+bounty + +(collection?collection.strongholds+collection.collected:0)); + assert.equal(paid.phase,7); + for(const p of paid.players) assert.deepEqual(viewGame(paid,p.id).spiceBankerIncome!.usedThisPhase,[]); + }); +} +void test('deferred automatic spice cannot fund a bid before actual Mentat collection',()=>{ + const fixture=createSpiceBankerIncomeFixture({ownerIsPayer:false});const paid=pay(fixture); + assert.equal(deferred(paid,fixture.owner),1); + let game=paid; + while(game.auction!.active!==fixture.owner) game=applyAction(game,game.auction!.active,{type:'passBid'}); + const wallet=game.players.find(p=>p.id===fixture.owner)!.spice,before=JSON.stringify(game); + assert.throws(()=>applyAction(game,fixture.owner,{type:'bid',amount:wallet+1})); + assert.equal(JSON.stringify(game),before); +}); +void test('a real singleton skill return and different-faction native revival retain old owner custody but use the new eligible owner',()=>{ + const fixture=createSpiceBankerIncomeFixture({advanced:true});const paid=pay(fixture); + const lifecycle=createSpiceBankerIncomeReassignmentFixture(paid); + assert.equal(lifecycle.game.spiceBankerIncome!.sources.find(r=>r.grant)!.grant!.owner,fixture.owner); + assert.equal(lifecycle.game.spiceBankerIncome!.collections[0].credits.find(c=>c.owner===fixture.owner)!.amount,1); + let game=applyAction(lifecycle.game,lifecycle.actor,lifecycle.revivalAction); + game=nativeCounters(game); + assert.equal(game.decision?.kind,'leaderSkillRevival'); + let offer=game.leaderSkills!.offers[lifecycle.actor]; + game=applyAction(game,lifecycle.actor,{type:'leaderSkill',event:offer.event,mode:'draw'}); + offer=game.leaderSkills!.offers[lifecycle.actor];assert.ok(offer.cards.includes('spice-banker')); + game=applyAction(game,lifecycle.actor,{type:'leaderSkill',event:offer.event,skill:'spice-banker',leader:lifecycle.leader}); + assert.equal(game.leaderSkills!.assignments.find(a=>a.skill==='spice-banker')!.owner,lifecycle.actor); + const balance=game.players.find(p=>p.id===lifecycle.actor)!.spice; + game=applyAction(game,fixture.owner,{type:'reviveLeader',leader:fixture.leader});game=nativeCounters(game); + assert.equal(deferred(game,lifecycle.actor),1); + assert.equal(deferred(game,fixture.owner),0); + assert.equal(game.players.find(p=>p.id===lifecycle.actor)!.spice,balance); + assert.equal(game.spiceBankerIncome!.sources.filter(r=>r.grant)[0].grant!.owner,fixture.owner); + assert.equal(game.spiceBankerIncome!.sources.filter(r=>r.grant)[1].grant!.owner,lifecycle.actor); +}); diff --git a/tests/spice-banker-income.test.ts b/tests/spice-banker-income.test.ts new file mode 100644 index 00000000..fd82492e --- /dev/null +++ b/tests/spice-banker-income.test.ts @@ -0,0 +1,279 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + BankerIncomeError, + commitBankerIncome, + commitBankerIncomeCollection, + createBankerIncomeState, + projectBankerIncome, + quoteBankerIncome, + quoteBankerIncomeCollection, + validateBankerIncomeSource, + validateBankerIncomeState, + type BankerIncomeAuthority, + type BankerIncomeCollectionAuthority, + type BankerIncomeContext, + type BankerIncomeReceipt, + type BankerIncomeState, +} from '../game/spice-banker-income'; + +function context(turn = 1, phase = 3, owner = 'a'): BankerIncomeContext { + return { + turn, phase, + players: [ + { id: 'a', leaders: [{ id: 'emperor-0', dead: false }] }, + { id: 'b', leaders: [{ id: 'guild-0', dead: false }] }, + { id: 'c', leaders: [{ id: 'atreides-0', dead: false }] }, + ], + assignments: [{ skill: 'spice-banker', leader: owner === 'a' ? 'emperor-0' : 'guild-0', owner }], + }; +} +function source(ctx: BankerIncomeContext, event = 'paid-auction', amount = 4, payer = 'b'): BankerIncomeAuthority { + return { + event, kind: 'auction', turn: ctx.turn, phase: ctx.phase, + bankLegs: amount === 0 ? [] : [{ payer, amount }], + trainer: { + assignment: { ...ctx.assignments[0] }, faceUp: true, captured: false, + selected: false, resolved: true, survived: true, + }, + }; +} +function earn(state: BankerIncomeState, ctx: BankerIncomeContext, authority: BankerIncomeAuthority): BankerIncomeState { + const quote = quoteBankerIncome(state, ctx, authority); + return commitBankerIncome(state, ctx, authority, quote.receipt); +} +function immutableReject(state: BankerIncomeState, action: () => unknown): void { + const before = JSON.stringify(state); + assert.throws(action, BankerIncomeError); + assert.equal(JSON.stringify(state), before); +} +const opening: BankerIncomeCollectionAuthority = { event: 'native-mentat-1', turn: 1, phase: 8 }; + +void test('another payer must actually pay at least four in one final BANK leg; gain is additional deferred one', () => { + const ctx = context(); + for (const [payer, amount, expected] of [['b', 3, 0], ['b', 4, 1], ['b', 19, 1], ['a', 4, 0], ['b', 0, 0]] as const) { + const initial = createBankerIncomeState(ctx); + const authority = source(ctx, `${payer}-${amount}`, amount, payer); + const before = JSON.stringify({ initial, authority, ctx }); + const quoted = quoteBankerIncome(initial, ctx, authority); + assert.equal(JSON.stringify({ initial, authority, ctx }), before); + assert.equal(quoted.receipt.grant?.amount ?? 0, expected); + assert.deepEqual(projectBankerIncome(quoted.next, ctx), { + deferred: expected ? [{ owner: 'a', amount: 1 }] : [], + usedThisPhase: expected ? ['a'] : [], + }); + assert.deepEqual(quoted.receipt.source.bankLegs, authority.bankLegs); + assert.deepEqual(commitBankerIncome(initial, ctx, authority, quoted.receipt), quoted.next); + } +}); +void test('separate native payments and different donor payers never aggregate to a threshold', () => { + const ctx = context(); + let state = createBankerIncomeState(ctx); + for (const event of ['first-two', 'second-two']) state = earn(state, ctx, source(ctx, event, 2)); + const split = source(ctx, 'different-payers'); + split.bankLegs = [{ payer: 'b', amount: 2 }, { payer: 'c', amount: 2 }]; + state = earn(state, ctx, split); + assert.deepEqual(projectBankerIncome(state, ctx).deferred, []); + const ownAndDonor = source(ctx, 'own-plus-donor'); + ownAndDonor.bankLegs = [{ payer: 'a', amount: 8 }, { payer: 'c', amount: 4 }]; + state = earn(state, ctx, ownAndDonor); + assert.deepEqual(projectBankerIncome(state, ctx).deferred, [{ owner: 'a', amount: 1 }]); + const duplicate = source(ctx, 'duplicate-donor'); + duplicate.bankLegs = [{ payer: 'b', amount: 2 }, { payer: 'b', amount: 2 }]; + immutableReject(state, () => quoteBankerIncome(state, ctx, duplicate)); +}); +void test('physical card once-phase stamp survives death, absence, capture and same-phase reassignment', () => { + const ctx = context(); + let state = earn(createBankerIncomeState(ctx), ctx, source(ctx)); + const dead = context(); + dead.players[0].leaders[0].dead = true; + dead.assignments = []; + const unavailable = { ...source(ctx, 'no-card'), trainer: null }; + state = earn(state, dead, unavailable); + assert.deepEqual(projectBankerIncome(state, dead), { deferred: [{ owner: 'a', amount: 1 }], usedThisPhase: ['a'] }); + const transferred = context(1, 3, 'b'); + state = earn(state, transferred, source(transferred, 'reassigned-this-phase', 6, 'c')); + assert.equal(state.sources[2].grant, null); + assert.deepEqual(projectBankerIncome(state, transferred).usedThisPhase, ['a']); + const nextPhase = context(1, 4, 'b'); + const revival = { ...source(nextPhase, 'next-phase', 4, 'c'), kind: 'leader-revival' as const }; + state = earn(state, nextPhase, revival); + assert.deepEqual(projectBankerIncome(state, nextPhase), { + deferred: [{ owner: 'a', amount: 1 }, { owner: 'b', amount: 1 }], usedThisPhase: ['b'], + }); + const nextTurn = context(2, 3, 'b'); + state = earn(state, nextTurn, source(nextTurn, 'next-turn', 4, 'c')); + assert.deepEqual(projectBankerIncome(state, nextTurn).deferred, [{ owner: 'a', amount: 1 }, { owner: 'b', amount: 2 }]); +}); +void test('fresh eligibility requires actual native living assignment and not a captive normal band', () => { + const ctx = context(); + const state = createBankerIncomeState(ctx); + const dead = context(); + dead.players[0].leaders[0].dead = true; + immutableReject(state, () => quoteBankerIncome(state, dead, source(ctx))); + const absent = context(); + absent.assignments = []; + immutableReject(state, () => quoteBankerIncome(state, absent, source(ctx))); + const foreign = source(ctx); + foreign.trainer!.assignment.leader = 'guild-0'; + immutableReject(state, () => quoteBankerIncome(state, ctx, foreign)); + const captured = context(); + captured.players[0].leaders[0].capturedBy = 'b'; + const captive = source(captured); + captive.trainer!.captured = true; + const quoted = quoteBankerIncome(state, captured, captive); + assert.equal(quoted.receipt.grant, null); + captive.trainer!.captured = false; + immutableReject(state, () => quoteBankerIncome(state, captured, captive)); +}); +void test('concealed battle trainer needs actual revealed selection and resolved survival, not private plan timing', () => { + const ctx = context(1, 6); + for (const [faceUp, selected, resolved, survived, expected] of [ + [true, false, true, true, 1], + [true, false, false, true, 0], + [true, false, true, false, 0], + [false, false, true, true, 0], + [false, true, false, true, 0], + [false, true, true, false, 0], + [false, true, true, true, 1], + ] as const) { + const authority = { ...source(ctx, 'resolved-support'), kind: 'battle-support' as const }; + Object.assign(authority.trainer!, { faceUp, selected, resolved, survived }); + const quote = quoteBankerIncome(createBankerIncomeState(ctx), ctx, authority); + assert.equal(quote.receipt.grant?.amount ?? 0, expected); + } + const impossible = { ...source(ctx), kind: 'battle-support' as const }; + impossible.trainer!.selected = true; + assert.throws(() => validateBankerIncomeSource(ctx, impossible), BankerIncomeError); +}); +void test('all named native producer kinds are phase-bound and empty bank settlement never triggers', () => { + const kinds = [ + ['auction', 3], ['shipment', 5], ['force-revival', 4], ['leader-revival', 4], + ['kh-revival', 4], ['emperor-extra-revival', 4], ['battle-support', 6], + ] as const; + for (const [kind, phase] of kinds) { + const ctx = context(1, phase); + const authority = { ...source(ctx, `native-${kind}`), kind }; + assert.equal(quoteBankerIncome(createBankerIncomeState(ctx), ctx, authority).receipt.grant?.amount, 1); + authority.bankLegs = []; + assert.equal(quoteBankerIncome(createBankerIncomeState(ctx), ctx, authority).receipt.grant, null); + authority.phase = phase === 3 ? 4 : 3; + assert.throws(() => validateBankerIncomeSource(ctx, authority), BankerIncomeError); + } +}); +void test('native Mentat atomically collects every original-owner grant once despite trainer/card changes', () => { + const ctx = context(); + let state = earn(createBankerIncomeState(ctx), ctx, source(ctx)); + const revivalContext = context(1, 4, 'b'); + state = earn(state, revivalContext, { ...source(revivalContext, 'revival', 4, 'c'), kind: 'force-revival' }); + const mentat = context(1, 8); + mentat.players[0].leaders[0].dead = true; + mentat.players[1].leaders[0].capturedBy = 'c'; + mentat.assignments = []; + const before = JSON.stringify(state); + assert.deepEqual(projectBankerIncome(state, mentat).deferred, [{ owner: 'a', amount: 1 }, { owner: 'b', amount: 1 }]); + const quoted = quoteBankerIncomeCollection(state, mentat, opening); + assert.equal(JSON.stringify(state), before); + assert.deepEqual(quoted.credits, [{ owner: 'a', amount: 1 }, { owner: 'b', amount: 1 }]); + assert.deepEqual(quoted.collection.grants, [1, 2]); + state = commitBankerIncomeCollection(state, mentat, opening, quoted.collection); + assert.deepEqual(projectBankerIncome(state, mentat), { deferred: [], usedThisPhase: [] }); + immutableReject(state, () => quoteBankerIncomeCollection(state, mentat, opening)); + immutableReject(state, () => quoteBankerIncomeCollection(state, mentat, { ...opening, event: 'different-opening-id' })); + const later = context(2, 3, 'b'); + later.players[0].leaders[0].capturedBy = 'b'; + assert.deepEqual(projectBankerIncome(JSON.parse(JSON.stringify(state)) as BankerIncomeState, later), { deferred: [], usedThisPhase: [] }); + state = earn(state, later, source(later, 'new-turn-payment', 4, 'c')); + const second = { event: 'native-mentat-2', turn: 2, phase: 8 as const }; + const nextOpening = quoteBankerIncomeCollection(state, context(2, 8, 'b'), second); + assert.deepEqual(nextOpening.credits, [{ owner: 'b', amount: 1 }]); +}); +void test('collection cannot happen early, change owner/amount, omit pending grants, or commit an old head', () => { + const ctx = context(); + const state = earn(createBankerIncomeState(ctx), ctx, source(ctx)); + immutableReject(state, () => quoteBankerIncomeCollection(state, ctx, opening)); + const mentat = context(1, 8); + const quote = quoteBankerIncomeCollection(state, mentat, opening); + for (const corrupt of [ + { ...quote.collection, grants: [] }, + { ...quote.collection, credits: [{ owner: 'b', amount: 1 }] }, + { ...quote.collection, credits: [{ owner: 'a', amount: 2 }] }, + { ...quote.collection, event: 'foreign-opening' }, + { ...quote.collection, previous: '0000000000000000' }, + ]) immutableReject(state, () => commitBankerIncomeCollection(state, mentat, opening, corrupt)); + const fresh = { ...source(context(1, 4), 'extra-revival'), kind: 'leader-revival' as const }; + const advanced = earn(state, context(1, 4), fresh); + immutableReject(advanced, () => commitBankerIncomeCollection(advanced, mentat, opening, quote.collection)); +}); +void test('native authority is independent of stored receipt, and committed source cannot be replayed', () => { + const ctx = context(); + const state = createBankerIncomeState(ctx); + const authority = source(ctx); + const quote = quoteBankerIncome(state, ctx, authority); + const changedAuthority = source(ctx, authority.event, 3); + immutableReject(state, () => commitBankerIncome(state, ctx, changedAuthority, quote.receipt)); + const changedReceipt = structuredClone(quote.receipt); + changedReceipt.source.bankLegs[0].amount = 5; + immutableReject(state, () => commitBankerIncome(state, ctx, authority, changedReceipt)); + const committed = commitBankerIncome(state, ctx, authority, quote.receipt); + immutableReject(committed, () => commitBankerIncome(committed, ctx, authority, quote.receipt)); + immutableReject(committed, () => quoteBankerIncome(committed, context(2, 3), authority)); + quote.receipt.source.bankLegs[0].amount = 90; + authority.bankLegs[0].amount = 100; + assert.equal(committed.sources[0].source.bankLegs[0].amount, 4); +}); +void test('malformed payments, foreign payers, extra fields and unsupported sources reject without mutation', () => { + const ctx = context(); + const state = createBankerIncomeState(ctx); + const valid = source(ctx); + const invalid: unknown[] = [null, {}, { ...valid, kind: 'gift' }, { ...valid, recipient: 'bank' }, + { ...valid, turn: 0 }, { ...valid, turn: 2 }, { ...valid, event: '' }, + ...[-1, 0, 1.5, Number.NaN, Number.POSITIVE_INFINITY, Number.MAX_SAFE_INTEGER + 1].map((amount) => + ({ ...valid, bankLegs: [{ payer: 'b', amount }] })), + { ...valid, bankLegs: [{ payer: 'bank', amount: 4 }] }, + { ...valid, bankLegs: [{ payer: 'b', amount: 4, wallet: 99 }] }, + { ...valid, trainer: { ...valid.trainer, survived: 'yes' } }, + ]; + for (const value of invalid) immutableReject(state, () => quoteBankerIncome(state, ctx, value as BankerIncomeAuthority)); +}); +void test('strict historical signatures, grant entitlement, cursor and collection custody reject corrupted JSON', () => { + const ctx = context(); + const earned = earn(createBankerIncomeState(ctx), ctx, source(ctx)); + const mentat = context(1, 8); + const collected = quoteBankerIncomeCollection(earned, mentat, opening).next; + const changedPayer = structuredClone(earned); + changedPayer.sources[0].source.bankLegs[0].payer = 'a'; + const changedOwner = structuredClone(earned); + changedOwner.sources[0].grant!.owner = 'b'; + const alteredSource = structuredClone(earned); + alteredSource.sources[0].source.bankLegs[0].amount = 3; + const duplicateSource = structuredClone(earned); + duplicateSource.sources.push(structuredClone(duplicateSource.sources[0])); + duplicateSource.cursor++; + const alteredCollection = structuredClone(collected); + alteredCollection.collections[0].credits[0].owner = 'b'; + const missingCollection = structuredClone(collected); + missingCollection.collections[0].grants = []; + const invalid: unknown[] = [null, { ...earned, extra: true }, { ...earned, head: '0000000000000000' }, + { ...earned, signature: '0000000000000000' }, { ...earned, cursor: 0 }, + { ...earned, seats: ['a', 'b', 'foreign'] }, changedPayer, changedOwner, alteredSource, + duplicateSource, alteredCollection, missingCollection, + { ...earned, sources: [{ ...earned.sources[0], grant: { ...earned.sources[0].grant, amount: 2 } }] }, + { ...earned, sources: [{ ...earned.sources[0], extra: true }] }, + ]; + for (const value of invalid) assert.throws(() => validateBankerIncomeState(value as BankerIncomeState, mentat), BankerIncomeError); +}); +void test('public projection exposes only shield custody and used owner, never exact hidden source/trainer/payment', () => { + const ctx = context(); + const authority = source(ctx, 'private-native-event-id', 17, 'c'); + const state = earn(createBankerIncomeState(ctx), ctx, authority); + const before = JSON.stringify(state); + const publicView = projectBankerIncome(state, ctx); + assert.deepEqual(publicView, { deferred: [{ owner: 'a', amount: 1 }], usedThisPhase: ['a'] }); + publicView.deferred[0].amount = 90; + publicView.usedThisPhase[0] = 'b'; + assert.equal(JSON.stringify(state), before); + const receipt: BankerIncomeReceipt = state.sources[0]; + assert.equal(receipt.source.bankLegs[0].amount, 17); + assert.equal(receipt.grant!.owner, 'a'); +}); diff --git a/tools/prototype-room.ts b/tools/prototype-room.ts index bfb91b52..f2e95843 100644 --- a/tools/prototype-room.ts +++ b/tools/prototype-room.ts @@ -3,6 +3,7 @@ import { initializeIxGameForAudit, initializeDiscoveryGameForAudit, initializeLeaderSkillsGameForAudit, + initializeSpiceBankerIncomeGameForAudit, initializeFactionExpansionsGameForAudit, initializeNexusGameForAudit, initializeMoritaniAssassinateGameForAudit, @@ -23,6 +24,7 @@ export const PROTOTYPE_PROFILES = [ 'ix', 'discovery', 'leader-skills', + 'banker-income', 'factions', 'nexus', 'moritani-assassinate', @@ -124,6 +126,8 @@ export function startPrototypeRoom( }) : profile === 'leader-skills' ? initializeLeaderSkillsGameForAudit(initial) + : profile === 'banker-income' + ? initializeSpiceBankerIncomeGameForAudit(initial) : initializeDiscoveryGameForAudit({ ...initial, discoveryEnabled: true, diff --git a/tools/start-prototype.ts b/tools/start-prototype.ts index 0032c051..6faaf6f2 100644 --- a/tools/start-prototype.ts +++ b/tools/start-prototype.ts @@ -20,7 +20,7 @@ async function main() { }); if (values.help) { console.log( - 'Usage: node --import tsx tools/start-prototype.ts --profile ix|discovery|leader-skills|factions|nexus|moritani-assassinate|ecaz-treachery|semuta|kull|richese-betrayal|nexus-kull|guild-betrayal|ixian-replacement|ixian-betrayal|harkonnen-betrayal --db PATH --room CODE --version NUMBER --out /private/new-directory\nStarts only a fresh ready lobby for the selected development prototype. Nexus enables the existing private Nexus setup. Moritani-assassinate explicitly opts into the bounded Advanced assassination preview. Semuta opts a fresh Richese roster into bounded clean-discard reactions; an included Ix deck supplies physical Thumper without opening public starts. Kull opts a fresh eligible CHOAM roster into the user-selected Karama interception preview. Richese-betrayal opts a fresh paired CHOAM/Richese Nexus roster into cache-purchase veto and sale-bank diversion. Nexus-kull opts a fresh CHOAM-plus-classic roster with CHOAM/Ix decks and Nexus into source-aware printed or any-Treachery-fueled Cunning Kull. Guild-betrayal opts a fresh classic roster with native Guild into full funded shipment-payment replacement; it preserves an explicitly enabled Homeworld module for Junction routes. Ixian-replacement opts a fresh classic Basic/Advanced roster with base Treachery and Nexus only into the optional exact normal-auction purchased-card replacement; native Ixians, Harkonnen buyers and special purchase sources remain unsupported. Ixian-betrayal opts a fresh 2–6-seat Basic/Advanced native Ixian plus classic/Tleilaxu roster with Ix 47-card Treachery and Nexus only into prevention of one actual native Bidding draw or Advanced Technology exchange, after native Karama counters. Harkonnen-betrayal opts a fresh 2–6-seat classic Basic/Advanced roster with native Harkonnen, base 33-card Treachery and Nexus alone into cancellation of an actual declared personal or allied Harkonnen Traitor Card after native counters, physical retirement and shuffle, and one private actual Mentat replacement. Modules, overlays, public starts and old-game retrofits remain excluded. Nexus is not dealt initially: a qualifying alliance requires at least three seats; two unallied receivers require four. Backs up all rooms first, preserves sessions and existing games, and rejects stale versions. Normal game-start and publication gates remain closed.', + 'Usage: node --import tsx tools/start-prototype.ts --profile ix|discovery|leader-skills|banker-income|factions|nexus|moritani-assassinate|ecaz-treachery|semuta|kull|richese-betrayal|nexus-kull|guild-betrayal|ixian-replacement|ixian-betrayal|harkonnen-betrayal --db PATH --room CODE --version NUMBER --out /private/new-directory\nStarts only a fresh ready lobby for the selected development prototype. Banker-income opts a fresh 2–6-seat classic Basic/Advanced roster with base 33-card Treachery and all fourteen Leader Skills alone into automatic deferred normal-band income and native Mentat collection; already-earned spice stays with its original faction under this explicit local policy. Nexus enables the existing private Nexus setup. Moritani-assassinate explicitly opts into the bounded Advanced assassination preview. Semuta opts a fresh Richese roster into bounded clean-discard reactions; an included Ix deck supplies physical Thumper without opening public starts. Kull opts a fresh eligible CHOAM roster into the user-selected Karama interception preview. Richese-betrayal opts a fresh paired CHOAM/Richese Nexus roster into cache-purchase veto and sale-bank diversion. Nexus-kull opts a fresh CHOAM-plus-classic roster with CHOAM/Ix decks and Nexus into source-aware printed or any-Treachery-fueled Cunning Kull. Guild-betrayal opts a fresh classic roster with native Guild into full funded shipment-payment replacement; it preserves an explicitly enabled Homeworld module for Junction routes. Ixian-replacement opts a fresh classic Basic/Advanced roster with base Treachery and Nexus only into the optional exact normal-auction purchased-card replacement; native Ixians, Harkonnen buyers and special purchase sources remain unsupported. Ixian-betrayal opts a fresh 2–6-seat Basic/Advanced native Ixian plus classic/Tleilaxu roster with Ix 47-card Treachery and Nexus only into prevention of one actual native Bidding draw or Advanced Technology exchange, after native Karama counters. Harkonnen-betrayal opts a fresh 2–6-seat classic Basic/Advanced roster with native Harkonnen, base 33-card Treachery and Nexus alone into cancellation of an actual declared personal or allied Harkonnen Traitor Card after native counters, physical retirement and shuffle, and one private actual Mentat replacement. Modules, overlays, public starts and old-game retrofits remain excluded. Nexus is not dealt initially: a qualifying alliance requires at least three seats; two unallied receivers require four. Backs up all rooms first, preserves sessions and existing games, and rejects stale versions. Normal game-start and publication gates remain closed.', ); return; }