From df9bbc5e042ba799abd6c5a695fdd9052daeb7f1 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Tue, 13 Oct 2026 17:47:00 +1100 Subject: [PATCH] Connect Guild Nexus Betrayal receipt to Bureaucrat per ruling --- docs/BUREAUCRAT_PAYMENTS.md | 17 +- docs/CURRENT_STATUS.md | 20 ++ docs/IMPLEMENTATION_STATUS.md | 49 ++++ docs/RULE_DECISIONS.md | 2 +- game/bureaucrat-payment.ts | 3 +- game/engine.ts | 51 ++++- game/leader-skill-profile.ts | 19 +- game/reference.ts | 2 +- tests/bureaucrat-betrayal-funding.test.ts | 228 +++++++++++++++++++ tests/fixture-bureaucrat-betrayal-funding.ts | 147 ++++++++++++ tests/fixture-nexus-guild-betrayal.ts | 12 +- 11 files changed, 534 insertions(+), 16 deletions(-) create mode 100644 tests/bureaucrat-betrayal-funding.test.ts create mode 100644 tests/fixture-bureaucrat-betrayal-funding.ts diff --git a/docs/BUREAUCRAT_PAYMENTS.md b/docs/BUREAUCRAT_PAYMENTS.md index 07930af1..b8f699d3 100644 --- a/docs/BUREAUCRAT_PAYMENTS.md +++ b/docs/BUREAUCRAT_PAYMENTS.md @@ -126,8 +126,15 @@ change, not a silent extension. shipment fee to the Nexus holder, while Bureaucrat says the recipient receives two less. The user ruled that **Bureaucrat applies**: the holder receives price−2 and exactly two goes to the Bank. Neither printed card addresses the -other, so this is an explicit user ruling, not a source derivation. The Betrayal -preview still excludes Leader Skills until that composition is implemented. +other, so this is an explicit user ruling, not a source derivation. It is now +connected through a bounded classic Nexus + Leader Skills Betrayal composition +(`initializeGuildBetrayalLeaderSkillsGameForAudit`): the accepted Use completes +the original native shipment and records the full-fee receipt unchanged, then +offers Bureaucrat from the invoice's own payment legs with the holder as payee. +A redirect subtracts exactly two from the holder's receipt; a decline leaves the +full fee. The offer is skipped when the holder is the shipper, no individual leg +reaches five, or the skill was already used this phase. No other Betrayal or +preview composition is admitted. ### Payment families deliberately left unconnected — 13 October 2026 @@ -143,10 +150,8 @@ qualifying gap: payer and correctly carry no stamp. Moritani Robbery is theft, not a payment. - **Emperor gifts** remain excluded by the explicit 11 October ruling. - **Guild Nexus Betrayal full-fee receipt** is ruled to carry Bureaucrat (holder - receives price−2; see the 13 October ruling above). It remains unconnected - because the recorded Betrayal preview contract expressly excludes Leader - Skills, so implementing it requires deliberately widening that preview - envelope; it is not a silent omission. + receives price−2; see the 13 October ruling above) and is now connected + through the bounded classic Nexus + Leader Skills Betrayal composition. - **Homeworld allied/split-recipient income** is now connected under the ruled gross-first ordering above. Only the ruling makes the ordering authoritative; it must not be attributed to the printed source, which is silent. diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index c891f573..de675b74 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -29,6 +29,26 @@ optional skill bands, combinations and public expansion starts remain open. Rules implementation continues; comprehensive assurance and administration remain deferred until all rules are implemented. +**Bureaucrat / Guild Nexus Betrayal receipt — 13 October.** +Per the user ruling, a Betrayal receipt now carries Bureaucrat: the holder +receives the fee minus two. A new bounded composition +(`initializeGuildBetrayalLeaderSkillsGameForAudit`, `guildBetrayalNexusLeaderSkillsProfile`) +admits classic Nexus + Leader Skills with the Betrayal preview, and the accepted +Use completes the original native shipment and records the unchanged full-fee +receipt before offering Bureaucrat from the invoice's own payment legs with the +holder as payee. Redirect subtracts exactly two from the holder; decline leaves +the full fee; no offer when the holder is the shipper, no leg reaches five, or +the skill was already used. No other Betrayal or preview composition is admitted. +35 consumer cases cover both modes and 5+0/5+3/3+5/5+5/0+5 legs, redirect and +decline, unchanged invoice/payer/donor debits/escrow/physical shipment/receipt, +single Nexus discard, no-offer boundaries, private projection with payer null, +stale/tampered rejection and all four bots. Two actual390px flows redirect2: +single-leg5 (holder14→12, shipper still pays5) and allied5+3 (holder17→15, +private payer null, donor escrow consumed). The shared betrayal fixture gained +distinct conserved Nexus faces for additional unallied seats; existing +single-extra expectations are unchanged. Union462/462 across twenty-four files, +types/lint and build pass. No deployed, save/recovery or full-mode claim follows. + **Bureaucrat / Homeworld allied income and two 13 October rulings — 13 October.** Homeworlds + Leader Skills games now connect qualifying allied-funded ordinary auction and Guild-shipment income (`bureaucratAlliedFundingModeSupported` returns diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index fd980b8d..06c9f429 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,54 @@ # Dune implementation status +## 13 October 2026 — Guild Nexus Betrayal Bureaucrat receipt + +The user ruled that a Betrayal receipt still carries Bureaucrat: the holder +receives the fee minus two and exactly two goes to the Bank. Neither printed card +addresses the other, so this is an explicit ruling rather than a source +derivation. A new bounded composition implements it: +`guildBetrayalNexusLeaderSkillsProfile` (classic Nexus plus Leader Skills plus +the Betrayal preview) is admitted by `bureaucratPaymentModeSupported`, +`bureaucratAlliedFundingModeSupported` and `guildBetrayalModeSupported`, and +`initializeGuildBetrayalLeaderSkillsGameForAudit` deals the composition from a +fresh classic native-Guild lobby. No other Betrayal or preview composition is +admitted, and existing no-skills Betrayal behavior is unchanged. + +On an accepted Use the engine completes the original native shipment and records +the unchanged full-fee receipt first, then stamps a `shipment` Bureaucrat source +from the invoice's own payment legs (shipper `ownPayment`, donor `allyPayment` +only when a donor exists) with the holder as payee, and offers a new signed +`guildBetrayal` continuation. Redirect subtracts exactly two from the holder's +already-credited receipt; decline leaves the full fee. No offer when the holder +is the shipper, no individual leg reaches five, or the skill was already used +this phase. The continuation integrity check requires the completed receipt with +the same event, recipient and full price. + +The new fixture composes the bounded setup (Bene Gesserit prediction then the +real leader-skill choices, Bureaucrat trained on a seat distinct from shipper +and holder) and reuses the shared betrayal fixture for the genuine funded +shipment. Thirty-five consumer cases cover both modes and 5+0/5+3/3+5/5+5/0+5 +invoice legs with redirect and decline, unchanged invoice/payer/donor debits, +escrow consumption, physical shipment, receipt/history and single Nexus discard, +replay and double-completion rejection, the no-offer boundaries, private +projection (gross and holder only, payer null), stale/cross-actor and re-signed +malformed-continuation rejection, and all four owner bots. The shared fixture +`holdGuildFixtureFace` now assigns distinct conserved Nexus faces to each +additional unallied seat; the first extra keeps the established `fremen` +assignment, so existing expectations are unchanged. + +Two actual390px GameTable flows redirect two: a single-leg five (holder 14→12 +while the shipper still pays five, one use, original continuation resumed) and an +allied 5+3 (holder 17→15 with a private payer-null prompt and the donor's escrow +consumed once). One click was retried after a stale accessibility node; no +action was applied twice. Artifact outside Git +`/tmp/dune-betrayal-bureaucrat-3230.json`. + +The affected union462/462 across twenty-four files, types/lint and the app build +pass with unchanged build warnings. A guidance string initially used a plain +apostrophe inside a single-quoted literal and broke the parse; it was corrected +to the file's typographic apostrophe. No source rule, strategy, public-start, +save/deployed or full-mode claim is opened by this bounded proof. + ## 13 October 2026 — Homeworld allied income and two Bureaucrat rulings `bureaucratAlliedFundingModeSupported` now returns `homeworldLeaderSkillsProfile` diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 5183e4ae..5ea41b2f 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -400,7 +400,7 @@ dedicated retrieved worked FAQs. [Contract and remaining limits](HOMEWORLD_OCCUP | Smuggler shipping | Optional free companion is included in the actual total; apply Guild rounding to the reduced priced count. Ordinary Fremen reserves are on planet. The officially permitted owned No-Field plus a separate free force is now prototyped at the unchanged one-marker price. Legacy unstamped ordinary declarations keep their quoted full price. | [Ordinary contract](SMUGGLER_SHIPMENT.md), [No-Field combination](SMUGGLER_NO_FIELD.md) | | Sandmaster movement | One optional board spice per distinct entered territory; source-only repositioning earns none. Per-movement Hajr reset is a documented grammatical inference. Canceled moves earn nothing. Native Fremen worm rides and physical Fremen Ambassador relocations collect optionally at the destination before arrival reactions, without an invented intermediate route. Owned one-pile-per-territory allocation now covers ground, direct destinations and native HMS relocation; sector allocation remains provisional and concealed No-Field movement stays separate. | [Ground/direct/HMS relocation](SANDMASTER_MOVEMENT.md), [worm rides](SANDMASTER_WORM.md) | | Diplomat defense | A committed Worthless copies the opponent's canonical printed defensive role: base/Ix Shield/Snooper, hybrid, defensive Weirding/Chemistry or sealed Portable. Named combat distinctions remain. Copying the used role, not activation or own-slot prerequisites, is a visibly qualified first version. Reveal-before-traitor timing and mandatory disposal remain implementation interpretations; existing base-only battle windows are not reopened. Late/mirror/numeric modifications remain separate. | [Source and connected prototype](DIPLOMAT_DEFENSE.md) | -| Bureaucrat payments | Once per phase, optionally divert two when one third-party payer pays at least five; payer cost is unchanged. Different below-five allied shares never pool. Supported classic/E1/E2/E3 no-Homeworlds and Homeworlds+Leader Skills ordinary, Richese-source and Guild auctions/shipment funding, including a fully qualifying ally, offer one gross-receipt redirection; private shares and selected qualifying payer remain withheld. Owner/payee contributions cannot qualify but do not mask a different eligible payer. Bribes trigger at transfer to the shield, not later collection. Existing Guild/Emperor/revival income follows Karama allowance as an outcome-based inference; canceled gross creates no offer/use. Richese uses its original seller-credit callback, not an invented income cancellation. By user ruling, the two comes off the gross before any Homeworld occupation split, and a Guild Nexus Betrayal receipt still carries Bureaucrat (holder receives the fee minus two); both compositions are fenced from Leader Skills today. Remaining payment families need further integration. | [Source and connected boundary](BUREAUCRAT_PAYMENTS.md) | +| Bureaucrat payments | Once per phase, optionally divert two when one third-party payer pays at least five; payer cost is unchanged. Different below-five allied shares never pool. Supported classic/E1/E2/E3 no-Homeworlds and Homeworlds+Leader Skills ordinary, Richese-source and Guild auctions/shipment funding, including a fully qualifying ally, offer one gross-receipt redirection; private shares and selected qualifying payer remain withheld. Owner/payee contributions cannot qualify but do not mask a different eligible payer. Bribes trigger at transfer to the shield, not later collection. Existing Guild/Emperor/revival income follows Karama allowance as an outcome-based inference; canceled gross creates no offer/use. Richese uses its original seller-credit callback, not an invented income cancellation. By user ruling, the two comes off the gross before any Homeworld occupation split (that split-recipient composition stays fenced from Leader Skills), and a Guild Nexus Betrayal receipt still carries Bureaucrat so the holder receives the fee minus two; the bounded classic Nexus plus Leader Skills Betrayal composition now connects that offer from the invoice's own legs. Remaining payment families need further integration. | [Source and connected boundary](BUREAUCRAT_PAYMENTS.md) | | Leader battle effects | Rihani normal peek and reshuffle precede a separate committed optional draw; ordering is an explicit printed-order inference. Unused excludes actual calls, not mere disclosure. Mentat/Bureaucrat change scores; Sandmaster adds board spice. Owned multi-pile Smuggler debits and Sandmaster victory placement are connected under provisional allocation policies; modifier and exceptional Face Dancer composition remain separate. | [Source contract and prototype](LEADER_BATTLE_EFFECTS.md), [Smuggler allocation](SMUGGLER_BATTLE.md) | | Source and readiness | Separate published rules, explicit implementation choices and unfinished interpretations; Advanced/expansion gates stay closed. | [Base/Advanced audit](BASE_ADVANCED_READINESS_20260907.md), [checklist](../game/reference.ts) | | Setup and Fremen movement | Preserve source-ordered setup and the distinct declared-movement cancellation; do not recreate fixed historical gaps. | [Setup contract](ADVANCED_SETUP_TEST_SEAM.md), [Fremen Karama](FREMEN_MOVEMENT_KARAMA_RULES.md) | diff --git a/game/bureaucrat-payment.ts b/game/bureaucrat-payment.ts index c0afd164..92b71d4a 100644 --- a/game/bureaucrat-payment.ts +++ b/game/bureaucrat-payment.ts @@ -1,4 +1,4 @@ -import { homeworldLeaderSkillsProfile, classicNexusLeaderSkillsProfile, classicTechLeaderSkillsProfile, mixedE1E2NexusLeaderSkillsProfile, mixedE3NexusLeaderSkillsProfile, nativeDiscoveryLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, nativeTechLeaderSkillsProfile, noOtherLeaderSkillModules, pairedE3NexusLeaderSkillsProfile, pairedNexusLeaderSkillsProfile, standaloneE3NexusLeaderSkillsProfile, strongholdLeaderSkillsProfile, type LeaderSkillProfile } from './leader-skill-profile'; +import { homeworldLeaderSkillsProfile, classicNexusLeaderSkillsProfile, classicTechLeaderSkillsProfile, guildBetrayalNexusLeaderSkillsProfile, mixedE1E2NexusLeaderSkillsProfile, mixedE3NexusLeaderSkillsProfile, nativeDiscoveryLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, nativeTechLeaderSkillsProfile, noOtherLeaderSkillModules, pairedE3NexusLeaderSkillsProfile, pairedNexusLeaderSkillsProfile, standaloneE3NexusLeaderSkillsProfile, strongholdLeaderSkillsProfile, type LeaderSkillProfile } from './leader-skill-profile'; import { classicDiscoveryLeaderSkillsProfile } from './discovery-module-profile'; export type BureaucratPaymentKind = 'auction' | 'shipment' | 'bribe' | 'revival'; export type BureaucratPaymentSource = { @@ -43,6 +43,7 @@ export function bureaucratPaymentModeSupported(game: LeaderSkillProfile): boolea (game.expansions[0] === 'choam' || game.expansions[0] === 'ix') && pairedNexusLeaderSkillsProfile(game)) || mixedE3NexusLeaderSkillsProfile(game) || mixedE1E2NexusLeaderSkillsProfile(game) || pairedE3NexusLeaderSkillsProfile(game) || + guildBetrayalNexusLeaderSkillsProfile(game) || (noOtherLeaderSkillModules(game) && (!game.expansions.length || (game.expansions.length === 1 && game.expansions[0] === 'choam') || diff --git a/game/engine.ts b/game/engine.ts index 0c5ded35..3e68e1f7 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -1,7 +1,7 @@ import { classicNexusModulesProfile, mixedE1E2NexusProfile, mixedE3NexusProfile, pairedE3NexusProfile, pairedNexusModulesProfile, singleE1E2NexusProfile, standaloneE3NexusProfile } from './nexus-module-profile'; import { nativeFactionTechProfile } from './faction-module-profile'; import { classicDiscoveryLeaderSkillsProfile, classicDiscoveryNexusProfile, homeworldDiscoveryProfile, discoveryModeSupported, discoveryModuleProfile, mixedE1E2DiscoveryNexusProfile, mixedE3DiscoveryNexusProfile, pairedDiscoveryNexusProfile, pairedE3DiscoveryNexusProfile, singleE1E2DiscoveryNexusProfile, standaloneE3DiscoveryNexusProfile } from './discovery-module-profile'; -import { advancedMoritaniLeaderSkillsProfile, advancedNativeLeaderSkillsProfile, homeworldLeaderSkillsProfile, classicNexusLeaderSkillsProfile, classicTechLeaderSkillsProfile, mixedE1E2NexusLeaderSkillsProfile, mixedE3NexusLeaderSkillsProfile, nativeDiscoveryLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, nativeTechLeaderSkillsProfile, ordinaryLeaderSkillModeSupported, pairedE3NexusLeaderSkillsProfile, pairedNexusLeaderSkillsProfile, singleE1E2NexusLeaderSkillsProfile, standaloneE3NexusLeaderSkillsProfile, strongholdLeaderSkillsProfile } from './leader-skill-profile'; +import { advancedMoritaniLeaderSkillsProfile, advancedNativeLeaderSkillsProfile, homeworldLeaderSkillsProfile, classicNexusLeaderSkillsProfile, classicTechLeaderSkillsProfile, guildBetrayalNexusLeaderSkillsProfile, mixedE1E2NexusLeaderSkillsProfile, mixedE3NexusLeaderSkillsProfile, nativeDiscoveryLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, nativeTechLeaderSkillsProfile, ordinaryLeaderSkillModeSupported, pairedE3NexusLeaderSkillsProfile, pairedNexusLeaderSkillsProfile, singleE1E2NexusLeaderSkillsProfile, standaloneE3NexusLeaderSkillsProfile, strongholdLeaderSkillsProfile } from './leader-skill-profile'; import { bribeTimingBlock, maximumBribe, type BribeOptions } from './bribe-options'; import { quoteSpicePlacement, stormExposesTerritory, stormSectorAfter, wormConsumesForces } from './disaster-rules'; import { isStormCardDistance, type StormCardComponent } from './storm-cards'; @@ -2040,7 +2040,7 @@ export type Game = { bureaucratPayments?: {used:BureaucratPaymentUse[];pending?:{ source:BureaucratPaymentSource;owner:string;leader:string; resume:{response:ResponseWindow|null;decision:Decision|null}; - continuation:{kind:'response'}|{kind:'auction';quote:AuctionContinuationQuote}|{kind:'bribe'}; + continuation:{kind:'response'}|{kind:'auction';quote:AuctionContinuationQuote}|{kind:'bribe'}|{kind:'guildBetrayal';event:string;recipient:string;amount:number}; frame:string;signature:string; }}; bureaucratPaymentEvent?: string; @@ -8562,6 +8562,16 @@ function bureaucratPaymentIntegrity(g: Game) { else if (pending.continuation.kind === 'auction') requireRule(g.currentAuctionSale?.bureaucratPaymentEvent === pending.source.event && JSON.stringify(pending.continuation.quote) === JSON.stringify(currentAuctionContinuationQuote(g,{kind:'sale',free:false})), 'The Bureaucrat sale lost its original seller-credit continuation.'); + else if (pending.continuation.kind === 'guildBetrayal') { + const betrayal = pending.continuation; + const receipt = (g.nexusGuildBetrayalHistory ?? []).find(entry => entry.invoice.event === betrayal.event); + requireRule(pending.source.kind === 'shipment' && g.guildBetrayal?.status === 'completed' && + !!receipt && receipt.recipient === betrayal.recipient && + receipt.invoice.price === betrayal.amount && + pending.source.payee === betrayal.recipient && + pending.source.amount === betrayal.amount, + 'The Bureaucrat Betrayal receipt lost its original holder payment.'); + } else requireRule(pending.continuation.kind === 'bribe' && pending.source.kind === 'bribe', 'The Bureaucrat payment has an invalid continuation.'); } @@ -8604,6 +8614,11 @@ function actBureaucratPayment(g: Game,action:Action) { g.response = pending.resume.response;g.decision = pending.resume.decision; if (pending.continuation.kind === 'response') finishResponse(g,false,redirected); else if (pending.continuation.kind === 'auction') commitAuctionContinuation(g,pending.continuation.quote,redirected); + else if (pending.continuation.kind === 'guildBetrayal') { + // The native adapter already credited the full fee to the holder; the ruled + // diversion takes exactly two from that receipt. + if (redirected) getPlayer(g,pending.continuation.recipient).spice -= redirected; + } else getPlayer(g,pending.source.payee).bribes += pending.source.amount-redirected; } function leaderSkillRevivalBlocked(g: Game, owner: string): boolean { @@ -9485,11 +9500,31 @@ export function initializeGuildBetrayalGameForAudit(state: Game): Game { g.pendingGuildBetrayal = null; return g; } +/** Development-only composition of the bounded Betrayal preview with classic Leader Skills. */ +export function initializeGuildBetrayalLeaderSkillsGameForAudit(state: Game): Game { + requireRule(!state.leaderSkills && !state.guildBetrayalPreview && !state.guildBetrayal && + !state.pendingGuildBetrayal && !state.nexusGuildBetrayalHistory && !state.guildBetrayalSourceReceipts && + (!state.nexusCards || state.nexusCards.cards === null && state.nexusCards.phase === null) && + !state.homeworlds && !state.discoveryEnabled && !state.discoveries && !state.techTokens && + !state.strongholdCards && !state.ecazTreachery && !state.kullPreview && !state.nexusKullPreview && + !state.richeseBetrayalPreview && !state.mobileStronghold && state.expansions.length === 0 && + state.players.length >= 2 && state.players.length <= 6 && !!byFaction(state, 'guild') && + state.players.every(p => CLASSIC_FACTIONS[p.faction] === true), + 'Guild Betrayal with Leader Skills needs a fresh classic native-Guild lobby with only Nexus.'); + const g = structuredClone(state); + g.nexusCards = {cards: null, phase: null}; + g.guildBetrayalPreview = true; + g.guildBetrayal = {sequence: 0, event: null, turn: null, sourceSignature: null, status: 'completed'}; + g.nexusGuildBetrayalHistory = []; + g.guildBetrayalSourceReceipts = []; + g.pendingGuildBetrayal = null; + return initializeLeaderSkillsGameForAudit(g); +} function guildBetrayalModeSupported(g: Game): boolean { return !!g.nexusCards && g.expansions.length === 0 && g.players.length >= 2 && g.players.length <= 6 && !!byFaction(g, 'guild') && g.players.every(p => CLASSIC_FACTIONS[p.faction] === true) && - !g.leaderSkills && !g.discoveryEnabled && !g.discoveries && !g.techTokens && + (!g.leaderSkills || guildBetrayalNexusLeaderSkillsProfile(g)) && !g.discoveryEnabled && !g.discoveries && !g.techTokens && !g.strongholdCards && !g.ecazTreachery && !g.kullPreview && !g.nexusKullPreview && !g.richeseBetrayalPreview && !g.mobileStronghold; } @@ -9717,6 +9752,14 @@ function decideGuildBetrayal(g: Game, p: Player, action: Action): void { sourceSignature: frame.invoice.sourceSignature, status: 'completed'}; if (use) log(g, `${p.name} spent Guild Nexus Betrayal to receive the full ${frame.invoice.price}-spice shipment payment. The original funded shipment completed once.`, {faction: 'guild', name: 'Betrayal'}); + if (use && settlement.recipient) { + const legs = [{payer: frame.invoice.shipper, amount: frame.invoice.ownPayment}, + ...(frame.invoice.donor ? [{payer: frame.invoice.donor, amount: frame.invoice.allyPayment}] : [])]; + const source = stampBureaucratContributors(g, 'shipment', settlement.recipient, legs, + frame.invoice.allyPayment > 0).bureaucratPayment; + if (offerBureaucratPayment(g, source, {kind: 'guildBetrayal', event: frame.invoice.event, + recipient: settlement.recipient, amount: frame.invoice.price})) return; + } } /** Offline paired-faction Nexus setup; public expansion/module starts remain gated. */ export function initializePairedNexusGameForAudit(state: Game): Game { @@ -9997,7 +10040,7 @@ export function initializeLeaderSkillsGameForAudit(state: Game): Game { const mixedE3 = mixedE3NexusLeaderSkillsProfile(g); const initialized = initializeSetupGameForAudit(g, homeworlds, classicNexus || pairedNexusLeaderSkillsProfile(g) || singleE1E2NexusLeaderSkillsProfile(g) || mixedE1E2NexusLeaderSkillsProfile(g) || - mixedE3 || standaloneE3NexusLeaderSkillsProfile(g) || pairedE3, false, + mixedE3 || standaloneE3NexusLeaderSkillsProfile(g) || pairedE3 || guildBetrayalNexusLeaderSkillsProfile(g), false, classicDiscoveryLeaderSkillsProfile(g) || nativeDiscoveryLeaderSkillsProfile(g) || (g.discoveryEnabled === true && classicNexus), true, g.expansions.length === 1 && g.expansions[0] === 'choam', nativeExpansionLeaderSkillsProfile(g)); if (advancedMoritaniLeaderSkillsProfile(initialized) || ((mixedE3 || pairedE3) && initialized.advanced && diff --git a/game/leader-skill-profile.ts b/game/leader-skill-profile.ts index 3d499482..8df46f0a 100644 --- a/game/leader-skill-profile.ts +++ b/game/leader-skill-profile.ts @@ -34,7 +34,7 @@ export function noOtherLeaderSkillModules(game: LeaderSkillProfile): boolean { !game.discoveryEnabled && !game.strongholdCards && !game.techTokens && !game.ecazTreachery; } -function nexusLeaderSkillModulesSupported(game: LeaderSkillProfile, assassination = false, discovery = false): boolean { +function nexusLeaderSkillModulesSupported(game: LeaderSkillProfile, assassination = false, discovery = false, betrayal = false): boolean { return !!game.nexusCards && typeof game.advanced === 'boolean' && !!game.players && game.players.length >= 2 && game.players.length <= 6 && ((discovery && game.discoveryEnabled === true) || @@ -46,7 +46,7 @@ function nexusLeaderSkillModulesSupported(game: LeaderSkillProfile, assassinatio !game.discoveryEnabled && !game.discoveries)) && (!game.moritaniAssassinatePreview || assassination) && !game.advancedPreview && !game.kullPreview && !game.nexusKullPreview && - !game.guildBetrayalPreview && !game.richeseBetrayalPreview && + (!game.guildBetrayalPreview || betrayal) && !game.richeseBetrayalPreview && !game.nexusIxianReplacementPreview && !game.nexusIxianBetrayalPreview && !game.nexusHarkonnenBetrayalPreview && !game.spiceBankerIncomePreview; @@ -66,6 +66,21 @@ export function classicNexusLeaderSkillsProfile(game: LeaderSkillProfile): boole return true; } +/** Fresh classic Nexus/Skills composed only with the bounded Guild Betrayal preview. */ +export function guildBetrayalNexusLeaderSkillsProfile(game: LeaderSkillProfile): boolean { + if (game.expansions.length || !game.guildBetrayalPreview || + !nexusLeaderSkillModulesSupported(game, false, false, true)) return false; + const players = game.players!; + for (let i = 0; i < players.length; i++) { + for (let j = 0; j < i; j++) if (players[j].faction === players[i].faction) return false; + let classic = false; + for (const faction of FACTIONS) + if (faction.id === players[i].faction) { classic = faction.expansion === 'base'; break; } + if (!classic) return false; + } + return true; +} + /** Both original natives in one E1/E2 family, optionally Homeworlds/Discovery/Tech/Strongholds. */ export function pairedNexusLeaderSkillsProfile(game: LeaderSkillProfile): boolean { if (game.expansions.length !== 1 || !nexusLeaderSkillModulesSupported(game, false, true)) return false; diff --git a/game/reference.ts b/game/reference.ts index 2cfd744d..3ed1dd02 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -4802,7 +4802,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'Spice Banker can commit one through three own spice with its Battle Plan, separate from force support. Only the surviving selected skilled disc gains that strength. The amount stays private until authorized plan inspection or public reveal. It is paid on win or loss, including leader death, except a sole traitor-call winner spends nothing. Normal deferred income connects only in the separate fresh classic or supported native banker-income profiles, including the bounded Advanced Moritani roster; ordinary/default starts are unchanged.', 'Diplomat may use one committed Worthless as the opponent’s canonical printed defensive role when you played no actual defense: Shield, Snooper, Shield Snooper, defensive Weirding Way or Chemistry, or sealed Portable Snooper. Hybrid protection, Weirding’s non-Shield behavior and Chemistry’s Poison Tooth exception remain distinct. Copying the used role without its activation or own-slot prerequisite is the explicit first-version interpretation. Choose or decline after reveal, before traitors; the Worthless must be discarded even if you win. Already-open base-only battles keep that source window. A native Diplomat can protect its own selected disc; a captive grants no upper band. A surviving trained loser may retreat selected undialed physical counters up to printed/copied disc strength to one empty adjacent non-stronghold before losses. Late/mirror/numeric modifications remain separate.', 'Normal Mentat questions use the user-approved uniform private card-show step before posture and faction powers in supported Leader Skills games. The opponent shows the named held weapon or chooses another held card, even for an exact match or sole eligible card. Weirding Way is a nameable default weapon; Chemistry is not. Cards stay held and need not enter a Battle Plan. New battles activate the question without an extra opt-in; already-opened unversioned battles retain their original flow. Empty hands skip without an answer. Private history records an observation, not a later hand guarantee.', - 'Bureaucrat may redirect two spice when one other player pays at least five to a player other than the skill owner, once per phase. The payer still pays in full; the recipient receives two less. Declining keeps the use available for a later payment. Paid bribes qualify when transferred, with their remaining share unavailable until Mentat Pause. Supported auctions, Guild income, Richese sales and paid Tleilaxu revival have connected paths; a separate free-revival award does not make a smaller price qualify. Allied payer shares below five each, such as three plus three, never pool to qualify. Supported classic, E1, E2 and E3 games without Homeworlds additionally connect qualifying allied-funded ordinary auctions, Richese cache/Advanced Black Market and shipments, including fully ally-funded five: one optional two-spice diversion from the gross recipient receipt, unchanged full contributor costs. Supported Homeworlds plus Leader Skills games connect the same allied-funded auction and Guild-shipment income. Original Guild/Emperor income cancellation resolves first; Richese proceeds use their original direct seller-credit callback without an invented cancellation window. Basic still has no Black Market and free Karama purchases remain normal-only. A trainer or payee contribution cannot qualify, but does not hide an eligible other payer. The prompt shows public gross and recipient, not private shares or which payer qualified. By user ruling the diversion comes off the gross before any Homeworld occupation split, but that split-recipient composition remains fenced from Leader Skills. By user ruling a Guild Nexus Betrayal receipt still carries Bureaucrat (holder receives the fee minus two), pending that composition. By user ruling, Emperor gifts are not payments: no Bureaucrat prompt/use or threshold disclosure, and exact gift amounts remain private. Other payment-family/module interactions remain unfinished.', + 'Bureaucrat may redirect two spice when one other player pays at least five to a player other than the skill owner, once per phase. The payer still pays in full; the recipient receives two less. Declining keeps the use available for a later payment. Paid bribes qualify when transferred, with their remaining share unavailable until Mentat Pause. Supported auctions, Guild income, Richese sales and paid Tleilaxu revival have connected paths; a separate free-revival award does not make a smaller price qualify. Allied payer shares below five each, such as three plus three, never pool to qualify. Supported classic, E1, E2 and E3 games without Homeworlds additionally connect qualifying allied-funded ordinary auctions, Richese cache/Advanced Black Market and shipments, including fully ally-funded five: one optional two-spice diversion from the gross recipient receipt, unchanged full contributor costs. Supported Homeworlds plus Leader Skills games connect the same allied-funded auction and Guild-shipment income. Original Guild/Emperor income cancellation resolves first; Richese proceeds use their original direct seller-credit callback without an invented cancellation window. Basic still has no Black Market and free Karama purchases remain normal-only. A trainer or payee contribution cannot qualify, but does not hide an eligible other payer. The prompt shows public gross and recipient, not private shares or which payer qualified. By user ruling the diversion comes off the gross before any Homeworld occupation split, but that split-recipient composition remains fenced from Leader Skills. By user ruling a Guild Nexus Betrayal receipt still carries Bureaucrat: the holder receives the fee minus two, and the bounded classic Nexus plus Leader Skills Betrayal composition now connects that offer from the invoice’s own payment legs. By user ruling, Emperor gifts are not payments: no Bureaucrat prompt/use or threshold disclosure, and exact gift amounts remain private. Other payment-family/module interactions remain unfinished.', 'Smuggler records all existing positive battle-territory piles when plans reveal, capped in total by the selected leader’s unmodified strength. A surviving controller collects even when losing or no forces remain. A partial collection across several piles asks that owner for exact sector amounts; all/none and single-pile collection stay automatic. This allocation policy is provisional. Pending spice cannot fund the plan. Collection precedes Sandmaster addition; a drained pile receives none. Modified strength and other combined-module limits remain guarded. Older saved battles retain their recorded behavior.', 'In classic and supported E1/E2 native Tech composition, actual Smuggler prices and normal Banker qualifying fees remain distinct from phase-end Tech income. Tech piles cannot fund their triggering action; Banker front-shield grants still wait for actual Mentat. Winning physical Suk rescue and Sandmaster board spice precede original played-card cleanup and mandatory winner token reward. One token transfers automatically, several require a selection, and no token skips without an invented decline. A later Face Dancer retains the original winner’s cards, bounty and Tech reward, kills the matching trainer without another bounty and replaces only actual survivors from real sources. Native Tleilaxu-only free revival excludes Axlotl; CHOAM initial income is not Charity. Richese’s free companion does not change its marker price or create the concealed counters early.', 'Physical Fremen Ambassador relocation now offers optional Sandmaster destination collection before arrival reactions, without an invented route or ordinary movement use. Same-territory repositioning earns none; concealed relocation and other special movement still retain their own limits. Normal Banker income has separate fresh classic or supported native source-to-Mentat previews with provisional original-earned-faction custody, not general activation. Diplomat retreat retains its documented conservative outcome order. Native Advanced foreign gholas/Face Dancers, skill-first Moritani assassination and ordinary-disc Ecaz skills follow their bounded contracts; shared-Duke assignment, combined Occupy skills and other combined modules remain unfinished. This prototype does not enable public module starts.', diff --git a/tests/bureaucrat-betrayal-funding.test.ts b/tests/bureaucrat-betrayal-funding.test.ts new file mode 100644 index 00000000..9d105afb --- /dev/null +++ b/tests/bureaucrat-betrayal-funding.test.ts @@ -0,0 +1,228 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { applyAction, normalizeAutomaticGame, viewGame, type Action, type Game } from '../game/engine'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { bureaucratAlliedFundingModeSupported, bureaucratPaymentModeSupported, bureaucratPaymentSignature } from '../game/bureaucrat-payment'; +import { guildBetrayalNexusLeaderSkillsProfile } from '../game/leader-skill-profile'; +import { validateLeaderSkills } from '../game/leader-skills'; +import { validateNexusCards } from '../game/nexus-cards'; +import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards'; +import { createGuildBetrayalFixture } from './fixture-nexus-guild-betrayal'; +import { + betrayalFundingSeat as seat, chooseBetrayalFunding, createBureaucratBetrayalFundingFixture, + settleBetrayalFunding, useBetrayalFunding, type BureaucratBetrayalFundingFixture, +} from './fixture-bureaucrat-betrayal-funding'; + +function physicalShipment(game: Game) { + return { forces: game.players.map(p => ({ id: p.id, forces: p.forces, reserves: p.reserves, + tanks: p.tanks, elites: p.elites, advisors: p.advisors, shipped: p.shipped, moved: p.moved })), + phase: game.phase, active: game.active, remaining: game.movementRemaining }; +} + +/** The Bank has no wallet field: its exact net receipt is the decrease in all + * player-controlled spice, including deferred bribes and actual aid escrow. */ +function playerControlledSpice(game: Game): number { + return game.players.reduce((total, p) => total + p.spice + p.bribes, 0) + + Object.values(game.aid).reduce((total, aid) => total + aid.amount, 0); +} + +function rejectUnchanged(game: Game, actor: string, action: Action): void { + const before = structuredClone(game); + assert.throws(() => applyAction(game, actor, action)); + assert.deepEqual(game, before, 'A rejected choice must not mutate any saved resource or continuation.'); +} + +function proveReceipt(f: BureaucratBetrayalFundingFixture, done: Game, diverted: number): void { + const invoice = f.game.pendingGuildBetrayal!.invoice; + assert.equal(invoice.price, f.primary + f.donorAmount); + assert.equal(invoice.ownPayment, f.primary); + assert.equal(invoice.allyPayment, f.donorAmount); + for (const p of done.players) { + assert.equal(p.spice, seat(f.beforePledge, p.id).spice - + (p.id === f.shipper ? f.primary : 0) - (p.id === f.donor ? f.donorAmount : 0) + + (p.id === f.holder ? f.price - diverted : 0), `Exactly the original debit/receipt for ${p.id}`); + assert.equal(p.bribes, seat(f.beforePledge, p.id).bribes); + } + if (f.donor) { + assert.equal(seat(f.before, f.donor).spice, seat(f.beforePledge, f.donor).spice - f.donorAmount); + assert.equal(f.before.aid[f.donor].amount, f.donorAmount); + assert.equal(done.aid[f.donor].amount, 0, 'The native donor escrow is consumed once, without a second wallet debit.'); + } + assert.equal(playerControlledSpice(f.before) - playerControlledSpice(done), diverted, + 'Only the ruled diversion leaves player custody for the implicit Spice Bank.'); + assert.deepEqual(physicalShipment(done), physicalShipment(f.original), 'The original native shipment suffix runs once.'); + const destination = `${String(f.declaration.territory)}:${Number(f.declaration.sector)}`; + assert.equal(seat(done, f.shipper).reserves, seat(f.before, f.shipper).reserves - f.price); + assert.equal(seat(done, f.shipper).forces[destination], (seat(f.before, f.shipper).forces[destination] ?? 0) + f.price); + assert.equal(seat(done, f.shipper).shipped, true); + assert.equal(done.pendingGuildBetrayal, null); + assert.equal(done.guildBetrayal!.status, 'completed'); + assert.equal(done.nexusGuildBetrayalHistory!.length, f.before.nexusGuildBetrayalHistory!.length + 1); + assert.equal(done.guildBetrayalSourceReceipts!.length, f.before.guildBetrayalSourceReceipts!.length + 1); + const receipt = done.nexusGuildBetrayalHistory!.at(-1)!; + assert.deepEqual(receipt.invoice, invoice, 'Bureaucrat does not rewrite the gross invoice or its original funding.'); + assert.equal(receipt.recipient, f.holder); + assert.equal(receipt.mode, 'betrayal'); + assert.equal(receipt.nexusDiscardIndex, f.before.nexusCards!.cards!.discard.length); + assert.equal(done.guildBetrayalSourceReceipts!.at(-1)!.receipt, receipt.sourceReceipt); + assert.equal(done.nexusCards!.cards!.hands[f.holder], null); + assert.deepEqual(done.nexusCards!.cards!.discard, [...f.before.nexusCards!.cards!.discard, 'guild']); + validateNexusCards(done.nexusCards!.cards!, done.players); + assert.equal(done.bureaucratPayments?.pending, undefined); + rejectUnchanged(done, f.holder, { type: 'guildBetrayalUse', event: f.event }); + rejectUnchanged(done, f.shipper, f.declaration); + const ended = applyAction(done, f.shipper, { type: 'endMovement' }); + assert.notEqual(ended.active, f.shipper); + assert.deepEqual(ended.nexusGuildBetrayalHistory, done.nexusGuildBetrayalHistory); + assert.deepEqual(ended.guildBetrayalSourceReceipts, done.guildBetrayalSourceReceipts); + assert.deepEqual(ended.players.map(p => p.forces), done.players.map(p => p.forces)); + rejectUnchanged(ended, f.shipper, { type: 'endMovement' }); +} + +void test('Fresh native Guild classic setup genuinely trains its distinct Bureaucrat and preserves all14 custody', () => { + const f = createBureaucratBetrayalFundingFixture({ donorAmount: 3 }); + assert.equal(f.initial.status, 'setup'); + assert.equal(f.initial.leaderSkills!.assignments.length, 0); + assert.equal(f.trained.status, 'setup'); + assert.equal(f.trained.leaderSkills!.assignments.find(a => a.skill === 'bureaucrat')?.owner, f.trainer); + assert.ok(![f.shipper, f.holder, f.guild, f.donor].includes(f.trainer)); + validateLeaderSkills(f.trained.leaderSkills!, f.trained.players); + const skills = f.trained.leaderSkills!; + assert.deepEqual([...skills.deck, ...Object.values(skills.offers).flatMap(o => o.cards), ...skills.assignments.map(a => a.skill)].sort(), + LEADER_SKILL_CARDS.map(c => c.id).sort()); + assert.equal(guildBetrayalNexusLeaderSkillsProfile(f.game), true); + assert.equal(bureaucratPaymentModeSupported(f.game), true); + assert.equal(bureaucratAlliedFundingModeSupported(f.game), true); +}); + +for (const advanced of [false, true]) { + for (const [primary, donorAmount] of [[5, 0], [5, 3], [3, 5], [5, 5], [0, 5]]) { + for (const redirect of [false, true]) { + void test(`${advanced ? 'Advanced' : 'Basic'} Betrayal ${primary}+${donorAmount}: ${redirect ? 'holder gross minus two; Bank two' : 'holder retains gross'}`, () => { + const f = createBureaucratBetrayalFundingFixture({ advanced, primary, donorAmount }); + const pending = useBetrayalFunding(f); + assert.equal(pending.decision?.kind, 'bureaucratPayment'); + assert.equal(pending.decision?.player, f.trainer); + assert.equal(seat(pending, f.holder).spice, seat(f.before, f.holder).spice + f.price); + assert.deepEqual(physicalShipment(pending), physicalShipment(f.original)); + const source = pending.bureaucratPayments!.pending!.source; + assert.equal(source.kind, 'shipment'); + assert.equal(source.payee, f.holder); + assert.equal(source.amount, f.price); + assert.equal(source.payer, primary >= 5 ? f.shipper : f.donor); + if (f.donor) assert.deepEqual(source.contributions, [ + ...(primary ? [{ payer: f.shipper, amount: primary }] : []), { payer: f.donor, amount: donorAmount }, + ]); + const done = settleBetrayalFunding(chooseBetrayalFunding(pending, redirect)); + proveReceipt(f, done, redirect ? 2 : 0); + assert.deepEqual(done.nexusGuildBetrayalHistory, pending.nexusGuildBetrayalHistory); + assert.deepEqual(done.guildBetrayalSourceReceipts, pending.guildBetrayalSourceReceipts); + assert.deepEqual(done.nexusCards!.cards, pending.nexusCards!.cards); + assert.equal(viewGame(done, f.trainer).bureaucrat.usedThisPhase, redirect); + assert.equal(done.bureaucratPayments?.used.length ?? 0, redirect ? 1 : 0); + rejectUnchanged(done, f.trainer, { type: 'decision', event: pending.bureaucratPaymentEvent, redirect: true }); + }); + } + } + + for (const options of [{ own: true }, { primary: 3, donorAmount: 3 }, { alreadyUsed: true }]) { + void test(`${advanced ? 'Advanced' : 'Basic'} Betrayal no offer ${JSON.stringify(options)}`, () => { + const f = createBureaucratBetrayalFundingFixture({ advanced, ...options }); + const taken = useBetrayalFunding(f); + assert.notEqual(taken.decision?.kind, 'bureaucratPayment'); + assert.equal(taken.bureaucratPayments?.pending, undefined); + proveReceipt(f, settleBetrayalFunding(taken), 0); + assert.equal(viewGame(taken, f.trainer).bureaucrat.usedThisPhase, 'alreadyUsed' in options); + if ('own' in options) assert.equal(f.holder, f.shipper); + if ('donorAmount' in options) { + assert.ok(f.price >= 5); + assert.ok(f.primary < 5 && f.donorAmount < 5, 'Gross never pools sub-five native legs.'); + } + }); + } + + void test(`${advanced ? 'Advanced' : 'Basic'} private receipt hides which native leg qualifies`, () => { + const primary = createBureaucratBetrayalFundingFixture({ advanced, primary: 5, donorAmount: 3 }); + const donor = createBureaucratBetrayalFundingFixture({ advanced, primary: 3, donorAmount: 5 }); + const left = useBetrayalFunding(primary), right = useBetrayalFunding(donor); + for (const viewer of left.players) { + const a = viewGame(left, viewer.id), b = viewGame(right, viewer.id); + for (const projected of [a, b]) { + const prompt = projected.bureaucrat.pending!; + assert.equal(prompt.payer, null); + assert.equal(prompt.payee, primary.holder); + assert.equal(prompt.amount, 8); + assert.equal(prompt.owner, primary.trainer); + assert.equal(prompt.kind, 'shipment'); + assert.equal(prompt.redirect, 2); + assert.equal('contributions' in prompt, false); + assert.equal('signature' in prompt, false); + assert.equal('bureaucratPayments' in projected, false); + assert.equal('bureaucratUseEvents' in projected, false); + } + } + }); + + void test(`${advanced ? 'Advanced' : 'Basic'} stale, cross-actor and malformed continuation choices reject immutably`, () => { + const f = createBureaucratBetrayalFundingFixture({ advanced, primary: 3, donorAmount: 5 }); + const pending = useBetrayalFunding(f); + const choice: Action = { type: 'decision', event: pending.bureaucratPaymentEvent, redirect: true }; + for (const actor of pending.players.filter(p => p.id !== f.trainer)) rejectUnchanged(pending, actor.id, choice); + rejectUnchanged(pending, f.trainer, { ...choice, event: f.event }); + rejectUnchanged(pending, f.trainer, { ...choice, event: 'stale-payment' }); + rejectUnchanged(pending, f.trainer, { ...choice, redirect: 'yes' }); + rejectUnchanged(pending, f.trainer, { ...choice, amount: f.price }); + rejectUnchanged(pending, f.shipper, { type: 'endMovement' }); + rejectUnchanged(pending, f.holder, { type: 'guildBetrayalUse', event: f.event }); + for (const field of ['event', 'recipient', 'amount', 'kind']) { + const malformed = structuredClone(pending); + const frame = malformed.bureaucratPayments!.pending!; + assert.equal(frame.continuation.kind, 'guildBetrayal'); + Object.assign(frame.continuation, { [field]: field === 'amount' ? f.price + 1 : field === 'recipient' ? f.guild : 'foreign' }); + // Re-sign the outer frame to exercise the native receipt/continuation link, + // not merely the generic changed-signature check. + frame.signature = bureaucratPaymentSignature(frame); + const saved = structuredClone(malformed); + assert.throws(() => viewGame(malformed, f.trainer)); + assert.throws(() => normalizeAutomaticGame(malformed)); + rejectUnchanged(malformed, f.trainer, choice); + assert.deepEqual(malformed, saved); + } + proveReceipt(f, settleBetrayalFunding(chooseBetrayalFunding(pending, true)), 2); + }); + + void test(`${advanced ? 'Advanced' : 'Basic'} all four minimal owner bots legally resolve the original Betrayal receipt`, () => { + const f = createBureaucratBetrayalFundingFixture({ advanced, primary: 3, donorAmount: 5 }); + const pending = useBetrayalFunding(f); + assert.equal(DIFFICULTIES.length, 4); + for (const difficulty of DIFFICULTIES) { + const view = viewGame(pending, f.trainer); + view.players.find(p => p.id === f.trainer)!.bot = difficulty; + const choices = botActions(view); + assert.equal(choices.length, 1); + const done = settleBetrayalFunding(applyAction(pending, f.trainer, choices[0])); + proveReceipt(f, done, 2); + assert.equal(viewGame(done, f.trainer).bureaucrat.usedThisPhase, true); + rejectUnchanged(done, f.trainer, choices[0]); + } + }); + + void test(`${advanced ? 'Advanced' : 'Basic'} native no-skills Betrayal still credits its entire funded invoice without an offer`, () => { + const f = createGuildBetrayalFixture({ advanced, allyPayment: 1 }); + const taken = applyAction(f.game, f.holder, { type: 'guildBetrayalUse', event: f.event }); + assert.notEqual(taken.decision?.kind, 'bureaucratPayment'); + assert.equal(taken.bureaucratPayments?.pending, undefined); + const done = settleBetrayalFunding(taken); + assert.equal(seat(done, f.holder).spice, seat(f.before, f.holder).spice + f.price); + assert.equal(seat(done, f.shipper).spice, seat(f.before, f.shipper).spice - f.game.pendingGuildBetrayal!.invoice.ownPayment); + assert.equal(seat(done, f.donor!).spice, seat(f.before, f.donor!).spice); + assert.equal(done.aid[f.donor!].amount, 0); + assert.equal(playerControlledSpice(done), playerControlledSpice(f.before)); + assert.deepEqual(physicalShipment(done), physicalShipment(f.original)); + assert.deepEqual(done.nexusGuildBetrayalHistory!.at(-1)!.invoice, f.game.pendingGuildBetrayal!.invoice); + assert.equal(done.nexusGuildBetrayalHistory!.at(-1)!.recipient, f.holder); + assert.deepEqual(done.nexusCards!.cards!.discard, [...f.before.nexusCards!.cards!.discard, 'guild']); + rejectUnchanged(done, f.holder, { type: 'guildBetrayalUse', event: f.event }); + }); +} diff --git a/tests/fixture-bureaucrat-betrayal-funding.ts b/tests/fixture-bureaucrat-betrayal-funding.ts new file mode 100644 index 00000000..297f23a7 --- /dev/null +++ b/tests/fixture-bureaucrat-betrayal-funding.ts @@ -0,0 +1,147 @@ +import assert from 'node:assert/strict'; +import { + applyAction, createGame, initializeGuildBetrayalLeaderSkillsGameForAudit, joinGame, + newPlayer, viewGame, type Action, type Game, +} from '../game/engine'; +import type { FactionId } from '../game/catalog'; +import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards'; +import { validateLeaderSkills } from '../game/leader-skills'; +import { createGuildBetrayalFixture, type GuildBetrayalFixture } from './fixture-nexus-guild-betrayal'; + +export const betrayalFundingSeat = (game: Game, id: string) => game.players.find(p => p.id === id)!; + +/** Scope exactly the thirteen Fisher–Yates draws of the original all14 deck; + * all later setup entropy remains native. No assignment or offer is replaced. */ +function skillLottery(selected: LeaderSkillId[], operation: () => T): T { + const working: LeaderSkillId[] = LEADER_SKILL_CARDS.map(card => card.id); + const remainder = working.filter(skill => !selected.includes(skill)); + const desired = [...selected.flatMap(skill => [skill, remainder.shift()!]), ...remainder]; + const rolls: number[] = []; + for (let i = working.length - 1; i > 0; i--) { + const j = working.indexOf(desired[i]); + assert.ok(j >= 0 && j <= i); + rolls.push(Math.floor((j + 0.5) * 0x100000000 / (i + 1))); + [working[i], working[j]] = [working[j], working[i]]; + } + const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues'); + const native = crypto.getRandomValues.bind(crypto); + let cursor = 0; + crypto.getRandomValues = (array: V): V => { + if (array instanceof Uint32Array && array.length === 1 && cursor < rolls.length) + array[0] = rolls[cursor++]; + else Reflect.apply(native, crypto, [array]); + return array; + }; + try { const result = operation(); assert.equal(cursor, 13); return result; } + finally { + if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor); + else Reflect.deleteProperty(crypto, 'getRandomValues'); + } +} + +function trainedSetup(advanced: boolean, allied: boolean): { initial: Game; trained: Game; trainer: string } { + const roster: [string, FactionId, LeaderSkillId][] = [ + ['holder', 'atreides', 'warmaster'], ['guild', 'guild', 'killer-medic'], + ['shipper', 'emperor', 'master-of-assassins'], + ...(allied ? [['donor', 'harkonnen', 'swordmaster-of-ginaz'] as [string, FactionId, LeaderSkillId]] : []), + ['trainer', 'beneGesserit', 'bureaucrat'], + ]; + let game = createGame('BUREAUCRATBETRAYAL', newPlayer(roster[0][0], roster[0][0], roster[0][1]), advanced, []); + for (const [id, faction] of roster.slice(1)) joinGame(game, newPlayer(id, id, faction)); + for (const player of game.players) game = applyAction(game, player.id, { type: 'ready' }); + game = skillLottery(roster.map(([, , skill]) => skill), () => initializeGuildBetrayalLeaderSkillsGameForAudit(game)); + const initial = structuredClone(game); + assert.equal(game.status, 'setup'); + if (game.setupStage === 'prediction') + game = applyAction(game, 'trainer', { type: 'predict', faction: 'emperor', turn: 4 }); + assert.equal(game.setupStage, 'leaderSkills'); + while (game.setupStage === 'leaderSkills') { + const actor = Object.keys(game.leaderSkills!.offers)[0]; + const offer = viewGame(game, actor).leaderSkills!; + const skill = roster.find(([id]) => id === actor)![2]; + assert.ok(offer.offer!.cards.includes(skill) && !offer.unavailableSkills?.[skill]); + const leader = offer.eligibleLeaders[0]; + assert.ok(leader); + game = applyAction(game, actor, { type: 'leaderSkill', event: offer.offer!.event, skill, leader: leader.id }); + } + assert.equal(game.status, 'setup', 'The shared fixture owns the remaining native setup.'); + validateLeaderSkills(game.leaderSkills!, game.players); + assert.equal(game.leaderSkills!.assignments.find(a => a.skill === 'bureaucrat')?.owner, 'trainer'); + return { initial, trained: game, trainer: 'trainer' }; +} + +/** Finish only real native acknowledgements/income, declining optional ordinary + * Bureaucrat income for the all-pass physical-shipment comparison. */ +export function settleBetrayalFunding(state: Game): Game { + let game = state; + for (let n = 0; n < 100; n++) { + if (game.pendingGuildBetrayal) { + const frame = game.pendingGuildBetrayal; + const actor = frame.required.find(id => !frame.passed.includes(id))!; + game = applyAction(game, actor, { type: 'guildBetrayalPass', event: frame.invoice.event }); + } else if (game.decision?.kind === 'bureaucratPayment') { + game = chooseBetrayalFunding(game, false); + } else if (game.decision?.kind === 'guildShipment') { + game = applyAction(game, game.decision.player, { type: 'decision', allow: true }); + } else if (game.decision?.kind === 'advisor') { + game = applyAction(game, game.decision.player, { type: 'decision', accept: false }); + } else if (game.response) { + const actor = game.players.find(p => !game.response!.passed.includes(p.id))!.id; + game = applyAction(game, actor, { type: 'passResponse' }); + } else return game; + } + throw Error('The original Betrayal shipment continuation did not settle.'); +} + +export function chooseBetrayalFunding(game: Game, redirect: boolean): Game { + assert.equal(game.decision?.kind, 'bureaucratPayment'); + return applyAction(game, game.decision!.player, { type: 'decision', event: game.bureaucratPaymentEvent, redirect }); +} + +export interface BureaucratBetrayalFundingFixture extends GuildBetrayalFixture { + initial: Game; + trained: Game; + trainer: string; + beforePledge: Game; + primary: number; + donorAmount: number; +} + +/** Reuse the shared native fixture's conserved card custody and original setup, + * then declare the requested larger shipment from its pre-declaration boundary. + * The provisional two-force branch is never replayed into this branch. Wallets, + * forces, signed invoices, escrow and usage ledgers are not assigned here. */ +export function createBureaucratBetrayalFundingFixture({ + advanced = false, primary = 5, donorAmount = 0, own = false, alreadyUsed = false, +}: { advanced?: boolean; primary?: number; donorAmount?: number; own?: boolean; alreadyUsed?: boolean } = {}): BureaucratBetrayalFundingFixture { + const setup = trainedSetup(advanced, donorAmount > 0); + const base = createGuildBetrayalFixture({ initial: setup.trained, advanced, + payer: own ? 'holder' : 'other', allyPayment: donorAmount > 0 ? 1 : 0 }); + let game = structuredClone(base.before); + if (base.donor) game = applyAction(game, base.donor, { type: 'pledgeAid', amount: 0 }); + if (alreadyUsed) { + assert.ok(betrayalFundingSeat(game, base.shipper).spice >= primary + 5); + game = applyAction(game, base.shipper, { type: 'bribe', target: base.guild, amount: 5 }); + game = chooseBetrayalFunding(game, true); + assert.equal(viewGame(game, setup.trainer).bureaucrat.usedThisPhase, true); + } + const beforePledge = structuredClone(game); + if (base.donor) game = applyAction(game, base.donor, { type: 'pledgeAid', amount: donorAmount }); + assert.ok(betrayalFundingSeat(game, base.shipper).spice >= primary, 'The original wallet must fund its real invoice leg.'); + const before = structuredClone(game); + const declaration: Action = { ...base.declaration, amount: primary + donorAmount, allyPayment: donorAmount }; + game = applyAction(game, base.shipper, declaration); + if (game.decision?.kind === 'guildShipment') + game = applyAction(game, game.decision.player, { type: 'decision', allow: true }); + assert.ok(game.pendingGuildBetrayal, 'A real accepted paid shipment creates the invoice.'); + const invoice = game.pendingGuildBetrayal.invoice; + assert.equal(invoice.price, primary + donorAmount); + assert.equal(invoice.ownPayment, primary); + assert.equal(invoice.allyPayment, donorAmount); + return { ...base, ...setup, beforePledge, primary, donorAmount, game, before, declaration, + event: invoice.event, price: invoice.price, original: settleBetrayalFunding(game) }; +} + +export function useBetrayalFunding(fixture: BureaucratBetrayalFundingFixture): Game { + return applyAction(fixture.game, fixture.holder, { type: 'guildBetrayalUse', event: fixture.event }); +} diff --git a/tests/fixture-nexus-guild-betrayal.ts b/tests/fixture-nexus-guild-betrayal.ts index 214ea765..a56eab83 100644 --- a/tests/fixture-nexus-guild-betrayal.ts +++ b/tests/fixture-nexus-guild-betrayal.ts @@ -85,6 +85,8 @@ function enterGuildFixtureMovement(state: Game, minimumTurn = 1): Game { throw new Error('Genuine Guild fixture did not reach Shipment and Movement.'); } function holdGuildFixtureFace(game: Game, holder: string, face: 'guild' | 'choam'): void { + const extraFaces: FactionId[] = ['fremen', 'harkonnen', 'ixians', 'choam', 'ecaz', 'moritani', 'beneGesserit', 'atreides']; + let extraIndex = 0; // Arrange only the conserved physical Nexus inventory, before any invoice. // Public held-card presence is identical across hidden-face fixture variants. let cards = game.nexusCards!.cards!; @@ -93,7 +95,15 @@ function holdGuildFixtureFace(game: Game, holder: string, face: 'guild' | 'choam if (held !== null) {cards.deck.push(held); cards.hands[p.id] = null;} } for (const p of game.players.filter(seat => !seat.ally)) { - const desired = p.id === holder ? face : p.faction === 'guild' ? 'atreides' : 'fremen'; + let desired: FactionId; + if (p.id === holder) desired = face; + else if (p.faction === 'guild') desired = 'atreides'; + else { + // Distinct conserved faces for every additional unallied seat; the first + // extra keeps the established 'fremen' assignment. + desired = extraFaces[extraIndex % extraFaces.length]; + extraIndex++; + } let index = cards.deck.indexOf(desired); if (index < 0) { index = cards.discard.indexOf(desired);