From ac64adaa3f1b7e78483ce650b38ecb990930899e Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Tue, 13 Oct 2026 09:32:36 +1100 Subject: [PATCH] Connect native paired E3 Bureaucrat funding --- docs/BUREAUCRAT_PAYMENTS.md | 51 ++++- docs/CURRENT_STATUS.md | 24 ++ docs/IMPLEMENTATION_STATUS.md | 38 +++ docs/RULE_DECISIONS.md | 2 +- game/bureaucrat-payment.ts | 7 +- game/reference.ts | 2 +- tests/bureaucrat-e3-funding.test.ts | 306 +++++++++++++++++++++++++ tests/fixture-bureaucrat-e3-funding.ts | 255 +++++++++++++++++++++ 8 files changed, 677 insertions(+), 8 deletions(-) create mode 100644 tests/bureaucrat-e3-funding.test.ts create mode 100644 tests/fixture-bureaucrat-e3-funding.ts diff --git a/docs/BUREAUCRAT_PAYMENTS.md b/docs/BUREAUCRAT_PAYMENTS.md index 1c7aca48..e5b39d5d 100644 --- a/docs/BUREAUCRAT_PAYMENTS.md +++ b/docs/BUREAUCRAT_PAYMENTS.md @@ -93,12 +93,13 @@ Allied funding is evaluated per actual payer, not by adding different payers' shares to create the five-spice trigger. A three-plus-three payment, or four-plus-three, has no eligible payer and proceeds through original funding, payment, card/force commitment and recipient income without a Bureaucrat offer. -In supported classic and E2 profiles without Homeworlds, individually qualifying -allied-funded auction and shipment receipts now offer one optional redirection: +In supported classic, E2 and E3 profiles without Homeworlds, individually +qualifying allied-funded auction and shipment receipts now offer one optional +redirection: five-plus-three, three-plus-five, five-plus-five and fully ally-funded five. The receipt loses exactly two, never two per contributing payer. A share paid by the trainer or recipient cannot qualify, but does not mask a different -eligible payer. Qualifying E1/E3 funding and Homeworld allied/split-recipient +eligible payer. Qualifying E1 funding and Homeworld allied/split-recipient composition retain their separate integration boundaries. Independent Karama shipments paid entirely to the Bank bypass player-income guards. A successful use remains tied to the physical card's turn and phase, @@ -224,6 +225,50 @@ case is removed from the affected CHOAM integration file. No source rule, strategy, Homeworld funding, public-start, save/deployed or complete-mode claim is changed by this bounded proof. +## Native paired E3 receipts — 13 October 2026 + +`tests/fixture-bureaucrat-e3-funding.ts` uses the original paired E3 roster +Ecaz/Moritani/Guild/Atreides/Emperor with skills. Atreides genuinely trains +Bureaucrat; a real turn-two Great Maker worm forms the actual Ecaz/Moritani +alliance. Real armies, wallets, phase and assignment come from native setup; +only original unplayed Spice ordering and, for the cancellation case, one +unplayed card in a real hand are controlled. + +Unlike E2, the paired E3 profile was already admitted to Bureaucrat mode +support, so the funded five-plus-five was never rejected by a module guard: it +rejected at the allied-funding guard +('Bureaucrat on allied funding or multiple payer contributions in this module +composition is awaiting integration.') for both families and modes, byte-identical +after a real Moritani→Ecaz pledge5. Boundary wallets Basic: +Ecaz12/Moritani12/Guild5/Atreides10/Emperor10; Advanced differs only at Guild6 +and Atreides12. + +The gate now admits no-Homeworlds single-Ecaz paired E3 while preserving every +prior admission (empty-expansion classic/nexus, single-CHOAM plain or paired). +Ix paired, Discovery, Tech, Stronghold, multi-expansion and Homeworld allied +funding stay excluded. Ix paired is deliberately deferred: enabling it would +newly admit the existing revival payment stamp in an Ixian-native Tleilaxu +composition whose Bureaucrat handling is not yet source-reviewed. + +Ordinary auctions pay Emperor and retain original Emperor-income cancellation +(banked gross, no offer/use); Guild shipments retain their original interception, +income and Movement continuation. Forty-six consumer cases cover both families +and modes, 5+3/3+5/5+5/fully donor-funded five, decline/redirect, sub-five +nonpooling, trainer and payee exclusion without masking an eligible payer, +public gross/payee with payer null and no shares, stale/cross-actor rejection, +four minimal bots, and a real bribe-to-Mentat phase refresh. Guild-as-ally makes +a Guild-payee exclusion shipment free by native rule, so payee exclusion is +covered through the Emperor auction payee instead; that is a native rule, not +a gap. + +Two actual390px GameTable flows use the genuine paid snapshots: Basic allied +auction5+3 redirects2 (Emperor10→16, Ecaz12→7, Moritani12→9, one use, next lot +at Guild) and Advanced allied shipment3+5 redirects2 (Guild6→12, Ecaz12→9 with +eight reserves committed at Habbanya17 beside Imperial Basin6, Moritani12→7, +Atreides12 unchanged, one use). Browser errors are empty. Artifact outside Git: +`/tmp/dune-e3-bureaucrat-3227.json`. Local rule/control proof only; no +deployed, recovery or full-mode claim. + ## Saved choices and verification Only the skill owner may redirect or allow the current payment. The saved event diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index bfca218f..1d4be065 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -29,6 +29,30 @@ 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 / native paired E3 funding — 13 October.** +Original paired E3 roster Ecaz/Moritani/Guild/Atreides/Emperor with skills now +admits qualifying allied ordinary-auction (Emperor) and Guild-shipment receipts +without Homeworlds. Atreides genuinely trains Bureaucrat; a real turn-two worm +forms the actual Ecaz/Moritani alliance, retaining native armies and Basic +Ecaz12/Moritani12/Guild5/Atreides10/Emperor10 (Advanced differs only at Guild6, +Atreides12). Unlike E2, E3 mode support already existed, so both families and +modes rejected only at the allied-funding guard after a real pledge5, byte-identical. +The gate now admits single-Ecaz paired E3 and preserves every prior admission; +Ix paired, Discovery, Tech, Stronghold, multi-expansion and Homeworld funding +stay excluded. Ix paired is deferred because enabling it would newly admit the +existing revival payment stamp in an Ixian-native Tleilaxu composition whose +Bureaucrat handling is not yet source-reviewed. Forty-six consumer cases cover +both families and modes, 5+3/3+5/5+5/fully donor-funded five, decline/redirect, +sub-five nonpooling, owner/payee exclusion without masking an eligible payer, +public gross/payee with payer null and no shares, stale/cross-actor rejection, +four bots, original Emperor-income cancellation and a real phase refresh. +Two actual390px flows redirect2: Basic auction5+3 (Emperor10→16, Ecaz12→7, +Moritani12→9, one use, next lot at Guild) and Advanced shipment3+5 (Guild6→12, +Ecaz12→9 with eight reserves at Habbanya17, Moritani12→7, Atreides12 unchanged). +Artifact: `/tmp/dune-e3-bureaucrat-3227.json`; browser errors empty. Frozen +union203/203 across nine files and types/lint/build pass. No deployed, +save/recovery or full-mode claim follows. + **Bureaucrat / native paired E2 funding — 13 October.** Original six-seat CHOAM/Richese/Harkonnen/Fremen/Emperor/Guild Skills Nexus now admits qualifying allied normal/cache/Advanced Black Market and shipment diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 8febff63..5b38bf2e 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,43 @@ # Dune implementation status +## 13 October 2026 — native paired E3 Bureaucrat funding + +The paired E3 Nexus skill profile was already admitted to Bureaucrat mode +support before this slice, so a funded E3 payment was never blocked by a module +guard: both payment families and both modes rejected at the allied-funding +guard, byte-identical after a real Moritani→Ecaz pledge5. The gate now admits +no-Homeworlds single-Ecaz paired E3; all prior admissions (empty-expansion +classic/nexus, single-CHOAM plain or paired) are preserved, and Ix paired, +Discovery, Tech, Stronghold, multi-expansion and Homeworld allied funding stay +excluded. Ix paired is deferred deliberately: enabling it would newly admit the +existing revival payment stamp in an Ixian-native Tleilaxu composition whose +Bureaucrat handling is not yet source-reviewed. + +The new fixture retains the original paired E3 roster and native setup. A real +turn-two Great Maker worm forms the actual Ecaz/Moritani alliance; only original +unplayed Spice ordering and, for the cancellation case, one unplayed card in a +real hand are controlled. Boundary wallets Basic: Ecaz12/Moritani12/Guild5/ +Atreides10/Emperor10; Advanced differs only at Guild6 and Atreides12. Atreides +genuinely trains Bureaucrat. + +Forty-six consumer cases pass: both families and modes, 5+3/3+5/5+5/fully +donor-funded five, decline/redirect, sub-five nonpooling, trainer and payee +exclusion without masking an eligible payer, public gross/payee with payer null +and no shares, stale/cross-actor rejection, four minimal bots, original +Emperor-income cancellation (banked gross, no offer/use) and a real bribe-to- +Mentat phase refresh. Guild-as-ally makes a Guild-payee exclusion shipment free +by native rule, so payee exclusion is covered through the Emperor auction payee. + +Two actual390px GameTable flows use the genuine paid snapshots. Basic allied +auction5+3 redirects2: Emperor10→16, Ecaz12→7, Moritani12→9, one use, next lot +at Guild. Advanced allied shipment3+5 redirects2: Guild6→12, Ecaz12→9 with +eight reserves committed at Habbanya17 beside Imperial Basin6, Moritani12→7, +Atreides12 unchanged, one use. Browser errors are empty; artifact outside Git +`/tmp/dune-e3-bureaucrat-3227.json`. The frozen union203/203 across nine files +and types/lint/build pass with unchanged build warnings. No source rule, +strategy, Homeworld funding, public-start, save/deployed or full-mode claim is +opened by this bounded proof. + ## 13 October 2026 — native paired E2 Bureaucrat funding The earlier mode guard excluded genuine paired E2 Nexus skill games before diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 1a32f866..9f7bd0d0 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/E2 no-Homeworlds ordinary and Richese-source 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 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. Qualifying E1/E3 funding, Homeworld split recipients and 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/E2/E3 no-Homeworlds ordinary and Richese-source 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 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. Qualifying E1 funding, Homeworld split recipients and 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 554033ff..6cab9b81 100644 --- a/game/bureaucrat-payment.ts +++ b/game/bureaucrat-payment.ts @@ -48,9 +48,10 @@ export function bureaucratPaymentModeSupported(game: LeaderSkillProfile): boolea nativeExpansionLeaderSkillsProfile(game))) || homeworldLeaderSkillsProfile(game) || classicTechLeaderSkillsProfile(game) || nativeTechLeaderSkillsProfile(game) || strongholdLeaderSkillsProfile(game) || classicNexusLeaderSkillsProfile(game) || standaloneE3NexusLeaderSkillsProfile(game) || classicDiscoveryLeaderSkillsProfile(game) || nativeDiscoveryLeaderSkillsProfile(game); } export function bureaucratAlliedFundingModeSupported(game: LeaderSkillProfile): boolean { - return !game.homeworlds && - (!game.expansions.length || game.expansions.length === 1 && game.expansions[0] === 'choam') && - bureaucratPaymentModeSupported(game); + if (game.homeworlds || !bureaucratPaymentModeSupported(game)) return false; + if (game.expansions.length === 1 && game.expansions[0] === 'ecaz') + return pairedE3NexusLeaderSkillsProfile(game); + return !game.expansions.length || game.expansions.length === 1 && game.expansions[0] === 'choam'; } export function bureaucratUsed( used: readonly BureaucratPaymentUse[], diff --git a/game/reference.ts b/game/reference.ts index 1dc958dc..6f15b52d 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 and E2 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. 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. Qualifying E1/E3 funding and Homeworld allied/split-recipient composition remain separate. 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, 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. 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. Qualifying E1 funding and Homeworld allied/split-recipient composition remain separate. 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-e3-funding.test.ts b/tests/bureaucrat-e3-funding.test.ts new file mode 100644 index 00000000..4fcc73fb --- /dev/null +++ b/tests/bureaucrat-e3-funding.test.ts @@ -0,0 +1,306 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { applyAction, viewGame, type Action, type Game } from '../game/engine'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { advancePairedE3Nexus, quietPairedE3NexusChoice, pairedE3NexusClean } from './fixture-paired-e3-nexus-modules'; +import { + allowE3Responses, bureaucratE3PhysicalCards, chooseBureaucratE3Payment, + createBureaucratE3AuctionFixture, createBureaucratE3ShipmentFixture, + declareBureaucratE3Shipment, payBureaucratE3Auction, + type BureaucratE3AuctionFixture, type BureaucratE3ShipmentFixture, +} from './fixture-bureaucrat-e3-funding'; + +const seat = (game: Game, id: string) => game.players.find((p) => p.id === id)!; +/** Real worm-alliance partners whose contributions cannot qualify. */ +const ALLIANCE = { trainer: 'atreides', auctionPayee: 'emperor' } as const; +const counters = (game: Game) => game.players.map((p) => ({ id: p.id, forces: p.forces, + reserves: p.reserves, tanks: p.tanks, elites: p.elites })); +const hand = (game: Game, id: string) => seat(game, id).hand.map((card) => card.id); + +function rejectUnchanged(game: Game, actor: string, action: Action): void { + const before = structuredClone(game); + assert.throws(() => applyAction(game, actor, action)); + assert.deepEqual(game, before); +} + +/** Full declared shares leave the payer wallet before any Bureaucrat choice. */ +function assertAuctionCosts(f: BureaucratE3AuctionFixture, game: Game): void { + assert.equal(seat(game, f.payer).spice, seat(f.beforePledge, f.payer).spice - f.payerShare); + assert.equal(game.aid[f.donor]?.amount ?? 0, 0); + assert.deepEqual(bureaucratE3PhysicalCards(game), f.physical); +} + +function assertAuctionOutcome(f: BureaucratE3AuctionFixture, done: Game, diverted: number): void { + assertAuctionCosts(f, done); + const donorLeg = f.donor === f.payee ? f.donorShare : 0; + assert.equal(seat(done, f.payee).spice, + seat(f.beforePledge, f.payee).spice - donorLeg + f.amount - diverted); + assert.equal(seat(done, f.donor).spice, + seat(f.beforePledge, f.donor).spice - f.donorShare + (f.donor === f.payee ? f.amount - diverted : 0)); + assert.deepEqual(hand(done, f.payer), [...hand(f.beforeAction, f.payer), f.card]); + assert.equal(done.currentAuctionSale, null); + assert.equal(done.bureaucratPayments?.used.length ?? 0, diverted ? 1 : 0); + assert.equal(viewGame(done, f.trainer).bureaucrat.usedThisPhase, diverted === 2); + assert.equal(done.auction!.index, f.beforeAction.auction!.index + 1); +} + +function assertShipmentCosts(f: BureaucratE3ShipmentFixture, game: Game): void { + assert.equal(seat(game, f.payer).spice, seat(f.beforePledge, f.payer).spice - f.primary); + assert.equal(seat(game, f.donor).spice, seat(f.beforePledge, f.donor).spice - f.donorAmount); + assert.equal(game.aid[f.donor]?.amount ?? 0, 0); + assert.equal(seat(game, f.payer).reserves, seat(f.beforePledge, f.payer).reserves - f.amount); + assert.deepEqual(seat(game, f.payer).forces, + { ...seat(f.beforePledge, f.payer).forces, [`${f.target}:17`]: f.amount }); + assert.equal(seat(game, f.payer).shipped, true); + for (const id of f.game.players.map((p) => p.id).filter((id) => id !== f.payer)) { + assert.deepEqual(seat(game, id).forces, seat(f.beforePledge, id).forces); + assert.equal(seat(game, id).reserves, seat(f.beforePledge, id).reserves); + } + assert.deepEqual(bureaucratE3PhysicalCards(game), f.physical); +} + +for (const advanced of [false, true]) + for (const [payerShare, donorShare] of [[5, 3], [3, 5], [5, 5], [0, 5]]) + for (const redirect of [false, true]) { + void test(`${advanced ? 'Advanced' : 'Basic'} paired E3 native auction ${payerShare}+${donorShare}: ${redirect ? 'Bank two' : 'full Emperor income'}, one purchase and original next lot`, () => { + const f = createBureaucratE3AuctionFixture({ advanced, payerShare, donorShare }); + assert.equal(seat(f.beforeAction, f.payer).spice, seat(f.beforePledge, f.payer).spice); + assert.equal(seat(f.beforeAction, f.donor).spice, seat(f.beforePledge, f.donor).spice - donorShare); + assert.equal(f.beforeAction.aid[f.donor].amount, donorShare); + assert.deepEqual(counters(f.beforeAction), counters(f.beforePledge)); + const paid = payBureaucratE3Auction(f); + assert.equal(paid.currentAuctionSale?.winner, f.payer); + assert.equal(paid.currentAuctionSale?.amount, f.amount); + assert.equal(paid.currentAuctionSale?.seller, null); + assertAuctionCosts(f, paid); + assert.equal(seat(paid, f.donor).spice, seat(f.beforePledge, f.donor).spice - donorShare); + const pending = allowE3Responses(paid); + assert.equal(pending.decision?.kind, 'bureaucratPayment'); + assert.equal(pending.decision?.player, f.trainer); + assert.deepEqual(counters(pending), counters(paid)); + assert.deepEqual(hand(pending, f.payer), hand(paid, f.payer)); + const done = allowE3Responses(chooseBureaucratE3Payment(pending, redirect)); + assertAuctionOutcome(f, done, redirect ? 2 : 0); + assert.deepEqual(counters(done), counters(pending)); + assert.equal(done.bureaucratPayments?.pending, undefined); + rejectUnchanged(done, f.trainer, { type: 'decision', event: pending.bureaucratPaymentEvent, redirect: true }); + rejectUnchanged(done, f.payer, f.action); + }); + } + +for (const advanced of [false, true]) + for (const [primary, pledge] of [[5, 3], [3, 5], [5, 5], [0, 5]]) + for (const redirect of [false, true]) { + void test(`${advanced ? 'Advanced' : 'Basic'} paired E3 native shipment ${primary}+${pledge}: ${redirect ? 'Bank two' : 'full Guild income'}, army commitment and Movement continuation`, () => { + const f = createBureaucratE3ShipmentFixture({ advanced, primary, pledge }); + assert.equal(f.game.turn, 2); + assert.equal(f.game.phase, 5); + assert.equal(f.game.active, f.payer); + assert.equal(seat(f.beforeAction, f.donor).spice, seat(f.beforePledge, f.donor).spice - pledge); + assert.equal(f.beforeAction.aid[f.donor].amount, pledge); + assert.deepEqual(counters(f.beforeAction), counters(f.beforePledge)); + const { game: paid, declared } = declareBureaucratE3Shipment(f); + if (advanced) { + assert.equal(declared.decision?.kind, 'guildShipment'); + assert.equal(declared.decision?.player, f.guild); + assert.deepEqual(counters(declared), counters(f.beforeAction)); + assert.deepEqual(seat(declared, f.payer).hand, seat(f.beforeAction, f.payer).hand); + } + assertShipmentCosts(f, paid); + const pending = allowE3Responses(paid); + assert.equal(pending.decision?.kind, 'bureaucratPayment'); + assert.equal(pending.decision?.player, f.trainer); + assert.deepEqual(counters(pending), counters(paid)); + assert.deepEqual(hand(pending, f.payer), hand(paid, f.payer)); + const done = allowE3Responses(chooseBureaucratE3Payment(pending, redirect)); + assert.equal(seat(done, f.guild).spice, seat(f.beforePledge, f.guild).spice + f.amount - (redirect ? 2 : 0)); + for (const id of [f.payer, f.donor, f.trainer, f.emperor]) { + assert.equal(seat(done, id).spice, seat(paid, id).spice); + assert.equal(seat(done, id).bribes, seat(paid, id).bribes); + } + assert.equal(done.bureaucratPayments?.used.length ?? 0, redirect ? 1 : 0); + assertShipmentCosts(f, done); + assert.deepEqual(counters(done), counters(paid)); + assert.equal(done.phase, 5); + assert.equal(done.active, f.payer); + rejectUnchanged(done, f.trainer, { type: 'decision', event: pending.bureaucratPaymentEvent, redirect: true }); + const ended = applyAction(structuredClone(done), f.payer, { type: 'endMovement' }); + assert.notEqual(ended.active, f.payer); + assert.deepEqual(counters(ended), counters(done)); + }); + } + +for (const advanced of [false, true]) { + void test(`${advanced ? 'Advanced' : 'Basic'} paired E3 native receipts never pool sub-five shares`, () => { + const auction = createBureaucratE3AuctionFixture({ advanced, payerShare: 3, donorShare: 3 }); + const auctionDone = allowE3Responses(payBureaucratE3Auction(auction)); + assert.notEqual(auctionDone.decision?.kind, 'bureaucratPayment'); + assert.equal(auctionDone.bureaucratPayments?.used.length ?? 0, 0); + assert.equal(seat(auctionDone, auction.emperor).spice, seat(auction.beforePledge, auction.emperor).spice + 6); + assertAuctionOutcome(auction, auctionDone, 0); + const shipment = createBureaucratE3ShipmentFixture({ advanced, primary: 3, pledge: 3 }); + const shipmentDone = allowE3Responses(declareBureaucratE3Shipment(shipment).paid); + assert.notEqual(shipmentDone.decision?.kind, 'bureaucratPayment'); + assert.equal(shipmentDone.bureaucratPayments?.used.length ?? 0, 0); + assert.equal(seat(shipmentDone, shipment.guild).spice, seat(shipment.beforePledge, shipment.guild).spice + 6); + assertShipmentCosts(shipment, shipmentDone); + }); + + void test(`${advanced ? 'Advanced' : 'Basic'} original Emperor Karama cancels the gross paired auction income to Bank without a Bureaucrat use`, () => { + const f = createBureaucratE3AuctionFixture({ advanced, payerShare: 5, donorShare: 5, counter: true }); + assert.ok(f.staging.some((line) => line.includes('swap original unplayed'))); + const paid = payBureaucratE3Auction(f); + assert.equal(paid.response?.kind, 'emperorIncome'); + assert.equal(paid.response?.owner, f.emperor); + assert.equal(viewGame(paid, f.trainer).responseControls?.cancelCards.includes(f.counterCard!), true); + assertAuctionCosts(f, paid); + const done = allowE3Responses(applyAction(paid, f.trainer, { type: 'card', card: f.counterCard!, mode: 'cancel' })); + assert.equal(seat(done, f.emperor).spice, seat(f.beforeAction, f.emperor).spice); + assert.deepEqual(hand(done, f.payer), [...hand(f.beforeAction, f.payer), f.card]); + assert.equal(done.discard.filter((card) => card.id === f.counterCard).length, 1); + assert.equal(done.players.some((p) => p.hand.some((card) => card.id === f.counterCard)), false); + assert.equal(done.bureaucratPayments?.used.length ?? 0, 0); + assert.equal(viewGame(done, f.trainer).bureaucrat.pending, null); + assert.equal(done.auction!.index, f.beforeAction.auction!.index + 1); + assertAuctionCosts(f, done); + }); + + void test(`${advanced ? 'Advanced' : 'Basic'} paired E3 pending invoices expose only the public gross and payee`, () => { + const primary = createBureaucratE3AuctionFixture({ advanced, payerShare: 5, donorShare: 3 }); + const donor = createBureaucratE3AuctionFixture({ advanced, payerShare: 3, donorShare: 5 }); + const left = allowE3Responses(payBureaucratE3Auction(primary)); + const right = allowE3Responses(payBureaucratE3Auction(donor)); + for (const viewer of [primary.trainer, primary.guild, primary.emperor]) { + const invoice = viewGame(left, viewer).bureaucrat.pending!; + const other = viewGame(right, viewer).bureaucrat.pending!; + assert.equal(invoice.payer, null); + assert.equal(invoice.payee, primary.emperor); + assert.equal(invoice.amount, 8); + assert.equal(invoice.kind, 'auction'); + assert.equal(invoice.redirect, 2); + assert.equal(invoice.owner, primary.trainer); + assert.equal('contributions' in invoice, false); + assert.equal('signature' in invoice, false); + assert.equal(other.amount, 8); + assert.equal('contributions' in other, false); + } + const shipment = createBureaucratE3ShipmentFixture({ advanced, primary: 5, pledge: 5 }); + const pending = allowE3Responses(declareBureaucratE3Shipment(shipment).paid); + for (const viewer of pending.players) { + const invoice = viewGame(pending, viewer.id).bureaucrat.pending!; + assert.equal(invoice.payer, null); + assert.equal(invoice.payee, shipment.guild); + assert.equal(invoice.amount, 10); + assert.equal(invoice.kind, 'shipment'); + assert.equal(invoice.owner, shipment.trainer); + assert.equal('contributions' in invoice, false); + assert.equal('signature' in invoice, false); + assert.equal('bureaucratPayments' in viewGame(pending, viewer.id), false); + assert.equal('bureaucratUseEvents' in viewGame(pending, viewer.id), false); + } + }); + + void test(`${advanced ? 'Advanced' : 'Basic'} paired E3 trainer and payee contributions cannot qualify or mask a real payer`, () => { + for (const donor of [ALLIANCE.trainer, ALLIANCE.auctionPayee]) { + const allowed = createBureaucratE3AuctionFixture({ advanced, payerShare: 5, donorShare: 5, donor }); + assert.equal(allowed.worm.phase, 1); + assert.equal(allowed.worm.nexus, true); + assert.ok(allowed.worm.players.every((p) => p.ally === null)); + assert.equal(seat(allowed.alliance, allowed.payer).ally, allowed.donor); + assert.equal(seat(allowed.alliance, allowed.donor).ally, allowed.payer); + assert.equal(allowed.actions.filter((step) => step.action.type === 'alliance').length, 2); + const pending = allowE3Responses(payBureaucratE3Auction(allowed)); + assert.equal(pending.decision?.kind, 'bureaucratPayment'); + assertAuctionOutcome(allowed, allowE3Responses(chooseBureaucratE3Payment(pending, true)), 2); + const masked = createBureaucratE3AuctionFixture({ advanced, payerShare: 3, donorShare: 5, donor }); + const done = allowE3Responses(payBureaucratE3Auction(masked)); + assert.notEqual(done.decision?.kind, 'bureaucratPayment'); + assert.equal(done.bureaucratPayments?.used.length ?? 0, 0); + } + { + const allowed = createBureaucratE3ShipmentFixture({ advanced, primary: 5, pledge: 5, partner: ALLIANCE.trainer }); + const pending = allowE3Responses(declareBureaucratE3Shipment(allowed).paid); + assert.equal(pending.decision?.kind, 'bureaucratPayment'); + assertShipmentCosts(allowed, allowE3Responses(chooseBureaucratE3Payment(pending, true))); + const masked = createBureaucratE3ShipmentFixture({ advanced, primary: 3, pledge: 5, partner: ALLIANCE.trainer }); + const done = allowE3Responses(declareBureaucratE3Shipment(masked).paid); + assert.notEqual(done.decision?.kind, 'bureaucratPayment'); + assert.equal(done.bureaucratPayments?.used.length ?? 0, 0); + } + }); + + void test(`${advanced ? 'Advanced' : 'Basic'} paired E3 pending choices reject cross-actor and stale input unchanged`, () => { + const auction = createBureaucratE3AuctionFixture({ advanced, payerShare: 5, donorShare: 5 }); + const auctionPending = allowE3Responses(payBureaucratE3Auction(auction)); + rejectUnchanged(auctionPending, auction.emperor, { type: 'decision', event: auctionPending.bureaucratPaymentEvent, redirect: true }); + rejectUnchanged(auctionPending, auction.trainer, { type: 'decision', event: 'stale-auction-invoice', redirect: true }); + rejectUnchanged(auctionPending, auction.payer, auction.action); + assertAuctionOutcome(auction, allowE3Responses(chooseBureaucratE3Payment(auctionPending)), 2); + const shipment = createBureaucratE3ShipmentFixture({ advanced, primary: 5, pledge: 5 }); + const shipmentPending = allowE3Responses(declareBureaucratE3Shipment(shipment).paid); + rejectUnchanged(shipmentPending, shipment.donor, { type: 'decision', event: shipmentPending.bureaucratPaymentEvent, redirect: true }); + rejectUnchanged(shipmentPending, shipment.trainer, { type: 'decision', event: 'stale-shipment-invoice', redirect: true }); + rejectUnchanged(shipmentPending, shipment.payer, { type: 'endMovement' }); + rejectUnchanged(shipmentPending, shipment.donor, { type: 'pledgeAid', amount: 1 }); + assertShipmentCosts(shipment, allowE3Responses(chooseBureaucratE3Payment(shipmentPending))); + }); + + void test(`${advanced ? 'Advanced' : 'Basic'} four minimal owner bot choices settle the paired private-funded receipt and resume Movement`, () => { + for (const bot of DIFFICULTIES) { + const f = createBureaucratE3ShipmentFixture({ advanced, primary: 5, pledge: 5 }); + const pending = allowE3Responses(declareBureaucratE3Shipment(f).paid); + const view = viewGame(pending, f.trainer); + view.players.find((p) => p.id === f.trainer)!.bot = bot; + const actions = botActions(view); + assert.equal(actions.length, 1); + const after = applyAction(structuredClone(pending), f.trainer, actions[0]); + assert.equal(seat(after, f.guild).spice, seat(f.beforePledge, f.guild).spice + f.amount - 2); + assert.equal(after.bureaucratPayments?.used.length, 1); + assert.deepEqual(counters(after), counters(pending)); + for (const id of [f.payer, f.donor, f.trainer, f.emperor]) assert.equal(seat(after, id).spice, seat(pending, id).spice); + assert.equal(after.phase, 5); + assert.equal(after.active, f.payer); + const ended = applyAction(structuredClone(after), f.payer, { type: 'endMovement' }); + assert.notEqual(ended.active, f.payer); + assert.deepEqual(counters(ended), counters(after)); + rejectUnchanged(ended, f.trainer, actions[0]); + } + }); + + void test(`${advanced ? 'Advanced' : 'Basic'} one physical Bureaucrat use per native phase survives a decline and renews after Mentat`, () => { + for (const redirect of [false, true]) { + const f = createBureaucratE3ShipmentFixture({ advanced, primary: 5, pledge: 3 }); + const pending = allowE3Responses(declareBureaucratE3Shipment(f).paid); + const settled = allowE3Responses(chooseBureaucratE3Payment(pending, redirect)); + const following = applyAction(structuredClone(settled), f.emperor, { type: 'bribe', target: f.guild, amount: 5 }); + assert.equal(seat(following, f.emperor).spice, 5); + assert.deepEqual(counters(following), counters(settled)); + let used: Game; + if (redirect) { + assert.notEqual(following.decision?.kind, 'bureaucratPayment'); + assert.equal(seat(following, f.guild).bribes, seat(settled, f.guild).bribes + 5); + used = following; + } else { + assert.equal(following.decision?.kind, 'bureaucratPayment'); + rejectUnchanged(following, f.trainer, { type: 'decision', event: pending.bureaucratPaymentEvent, redirect: true }); + used = allowE3Responses(chooseBureaucratE3Payment(following, true)); + assert.equal(seat(used, f.guild).bribes, seat(settled, f.guild).bribes + 3); + assert.equal(seat(used, f.guild).spice, seat(settled, f.guild).spice); + } + assert.equal(used.bureaucratPayments?.used.length, 1); + const ended = applyAction(used, f.payer, { type: 'endMovement' }); + const next = advancePairedE3Nexus(ended, (state) => state.turn === 3 && state.phase === 1 && + pairedE3NexusClean(state), undefined, quietPairedE3NexusChoice); + assert.equal(viewGame(next, f.trainer).bureaucrat.usedThisPhase, false); + const payment = applyAction(next, f.emperor, { type: 'bribe', target: f.guild, amount: 5 }); + assert.equal(payment.decision?.kind, 'bureaucratPayment'); + const renewed = allowE3Responses(chooseBureaucratE3Payment(payment, true)); + assert.equal(renewed.bureaucratPayments?.used.length, 2); + assert.deepEqual(renewed.bureaucratPayments!.used.map((use) => ({ turn: use.turn, phase: use.phase })), + [{ turn: 2, phase: 5 }, { turn: 3, phase: 1 }]); + assert.equal(viewGame(renewed, f.trainer).bureaucrat.usedThisPhase, true); + assert.deepEqual(counters(renewed), counters(next)); + } + }); +} diff --git a/tests/fixture-bureaucrat-e3-funding.ts b/tests/fixture-bureaucrat-e3-funding.ts new file mode 100644 index 00000000..eed8092f --- /dev/null +++ b/tests/fixture-bureaucrat-e3-funding.ts @@ -0,0 +1,255 @@ +import assert from 'node:assert/strict'; +import { applyAction, viewGame, type Action, type Game } from '../game/engine'; +import { + advancePairedE3Nexus, createPairedE3NexusSetup, pairedE3NexusClean, + pairedE3NexusInventory, pairedE3NexusPlayer, pairedE3NexusReload, + quietPairedE3NexusChoice, stepPairedE3Nexus, +} from './fixture-paired-e3-nexus-modules'; + +export type BureaucratE3FundingStep = { actor: string; action: Action }; + +/** Original six/ five-seat paired E3 roster: both natives, three classics. */ +export const BUREAUCRAT_E3_ROSTER = ['ecaz', 'moritani', 'guild', 'atreides', 'emperor'] as const; +/** The single classic seat trained with the native Bureaucrat skill. */ +export const BUREAUCRAT_E3_TRAINER = 'atreides'; + +export interface BureaucratE3FundingBase { + actions: BureaucratE3FundingStep[]; + staging: string[]; + /** Turn-2 Spice blow Nexus window: real alliances may still form here. */ + worm: Game; + /** Turn-2 Bidding boundary with the requested real mutual alliance formed. */ + game: Game; + alliance: Game; + payer: string; + donor: string; + guild: string; + emperor: string; + trainer: string; +} + +function frontSpice( + game: Game, + match: (card: Game['spiceDeck'][number]) => boolean, + position: number, +): void { + const at = game.spiceDeck.findIndex((c, i) => i >= position && match(c)); + assert.ok(at >= position, 'An original unplayed Spice Card must remain for staging.'); + game.spiceDeck.splice(position, 0, game.spiceDeck.splice(at, 1)[0]); +} + +/** Only counter-card custody is controlled here: swap an original unplayed + * card without adding a card, hand space, wallet, army, receipt or turn. */ +function holdE3Card( + game: Game, + actor: string, + match: (card: Game['deck'][number]) => boolean, + staging: string[], +): string { + const owner = pairedE3NexusPlayer(game, actor); + const held = owner.hand.find(match); + if (held) return held.id; + const index = game.deck.findIndex(match); + assert.ok(index >= 0 && owner.hand.length > 0); + const outgoing = owner.hand[0]; + const incoming = game.deck[index]; + owner.hand[0] = incoming; + game.deck[index] = outgoing; + staging.push( + `Native response window: swap original unplayed ${incoming.id} with held ${outgoing.id} for ${actor}; retain physical cards and hand size.`, + ); + return incoming.id; +} + +/** Genuine paired E3 setup with skills, a real turn-2 worm Nexus and a real + * mutual alliance between the payer and the requested donor. Never assigns + * wallets, armies, phase, assignment or alliance state. */ +export function createBureaucratE3FundingBase( + { advanced = false, donor = 'moritani' }: { advanced?: boolean; donor?: string } = {}, +): BureaucratE3FundingBase { + const options = { roster: [...BUREAUCRAT_E3_ROSTER], advanced, skills: true }; + const setup = createPairedE3NexusSetup(options); + assert.ok(setup.afterSetup.leaderSkills!.assignments.some( + (a) => a.owner === BUREAUCRAT_E3_TRAINER && a.skill === 'bureaucrat' && a.leader, + )); + assert.deepEqual(setup.afterSetup.players.map((p) => p.faction), [...BUREAUCRAT_E3_ROSTER]); + const actions: BureaucratE3FundingStep[] = []; + const staging: string[] = []; + let game = pairedE3NexusReload(setup.afterSetup); + const act = (actor: string, action: Action): void => { + actions.push({ actor, action }); + game = stepPairedE3Nexus(game, { actor, action }); + }; + // Reach turn 2's real Spice blow, then stage only original unplayed Spice + // Cards so the Great Maker reveals a worm and a Nexus forms. + game = advancePairedE3Nexus(game, (s) => s.turn === 2 && s.phase === 1 && pairedE3NexusClean(s), actions, quietPairedE3NexusChoice); + if (advanced) for (let i = 1; i <= 2; i++) frontSpice(game, (c) => 'territory' in c && !c.discovery, i); + frontSpice(game, (c) => 'worm' in c && !c.greatMaker && !c.suppressed, 0); + staging.push('Reorder only original still-unplayed Spice Cards so the turn-2 Great Maker reveals a worm.'); + game = advancePairedE3Nexus(game, (s) => !!s.nexus && !s.spiceWindow && !s.spiceResolution && pairedE3NexusClean(s), actions, quietPairedE3NexusChoice); + const worm = pairedE3NexusReload(game); + assert.ok(pairedE3NexusPlayer(worm, 'ecaz').ally === null); + act('ecaz', { type: 'alliance', target: donor }); + act(donor, { type: 'alliance', target: 'ecaz' }); + assert.equal(pairedE3NexusPlayer(game, 'ecaz').ally, donor); + assert.equal(pairedE3NexusPlayer(game, donor).ally, 'ecaz'); + const alliance = pairedE3NexusReload(game); + game = advancePairedE3Nexus(game, (s) => s.turn === 2 && s.phase === 3 && pairedE3NexusClean(s), actions, quietPairedE3NexusChoice); + pairedE3NexusInventory(game); + return { + actions, staging, worm, game, alliance, + payer: 'ecaz', donor, guild: 'guild', emperor: 'emperor', trainer: BUREAUCRAT_E3_TRAINER, + }; +} + +/** Pass the original income/counter windows without manufacturing a receipt. */ +export function allowE3Responses(state: Game): Game { + let game = state; + for (let n = 0; game.response && n < 40; n++) { + const actor = game.players.find((p) => { + const controls = viewGame(game, p.id).responseControls; + return controls && !controls.hasPassed && controls.cancelCards.length > 0; + }); + assert.ok(actor, 'An original unpassed counter holder must remain'); + game = applyAction(game, actor.id, { type: 'passResponse' }); + } + assert.equal(game.response, null); + return game; +} + +// --------------------------------------------------------------------------- +// Ordinary auction whose original payee is the Emperor. +// --------------------------------------------------------------------------- + +export interface BureaucratE3AuctionFixture extends BureaucratE3FundingBase { + beforePledge: Game; + beforeAction: Game; + card: string; + amount: number; + payerShare: number; + donorShare: number; + action: Action; + payee: string; + counterCard?: string; + physical: string[]; +} + +/** Only original unplayed-card custody is controlled here. */ +export function bureaucratE3PhysicalCards(game: Game): string[] { + const auction = game.auction; + const remaining = auction + ? auction.cards.slice(auction.index + Number(game.currentAuctionSale?.origin === 'normal')) + : []; + return [...game.deck, ...game.discard, ...game.players.flatMap((p) => p.hand), ...remaining] + .map((card) => card.id).sort(); +} + +export function createBureaucratE3AuctionFixture({ + advanced = false, payerShare = 5, donorShare = 3, donor = 'moritani', counter = false, +}: { + advanced?: boolean; payerShare?: number; donorShare?: number; donor?: string; counter?: boolean; +} = {}): BureaucratE3AuctionFixture { + const base = createBureaucratE3FundingBase({ advanced, donor }); + const actions = [...base.actions]; + const staging = [...base.staging]; + let game = pairedE3NexusReload(base.game); + // Spend the original passes until the payer's genuine bid is next. + for (let n = 0; game.auction!.active !== base.payer && n < game.players.length; n++) { + const action: Action = { type: 'passBid' }; + actions.push({ actor: game.auction!.active, action }); + game = applyAction(game, game.auction!.active, action); + } + assert.equal(game.auction!.active, base.payer); + const physical = bureaucratE3PhysicalCards(game); + const counterCard = counter ? holdE3Card(game, base.trainer, (card) => card.effect === 'karama', staging) : undefined; + const beforePledge = pairedE3NexusReload(game); + const pledge: Action = { type: 'pledgeAid', amount: donorShare }; + actions.push({ actor: donor, action: pledge }); + game = applyAction(game, donor, pledge); + assert.equal(game.aid[donor].amount, donorShare); + const beforeAction = pairedE3NexusReload(game); + const card = game.auction!.cards[game.auction!.index].id; + const amount = payerShare + donorShare; + const action: Action = { type: 'bid', amount, allyPayment: donorShare }; + return { ...base, actions, staging, game, physical, counterCard, + beforePledge, beforeAction, card, amount, payerShare, donorShare, action, payee: base.emperor }; +} + +/** Commit the declared bid. Passes the remaining original bidders. */ +export function payBureaucratE3Auction(fixture: BureaucratE3AuctionFixture, state = fixture.game): Game { + const index = state.auction!.index; + let game = applyAction(state, fixture.payer, fixture.action); + for (let n = 0; !game.currentAuctionSale && game.auction?.index === index && n < game.players.length + 2; n++) { + if (game.decision?.kind === 'auctionPayment') { + game = applyAction(game, fixture.payer, { type: 'decision', karama: false }); + break; + } + assert.ok(!game.decision && !game.response); + game = applyAction(game, game.auction!.active, { type: 'passBid' }); + } + return game; +} + +export function chooseBureaucratE3Payment(game: Game, redirect = true): Game { + assert.equal(game.decision?.kind, 'bureaucratPayment'); + return applyAction(game, game.decision!.player, { type: 'decision', event: game.bureaucratPaymentEvent, redirect }); +} + +// --------------------------------------------------------------------------- +// Ordinary shipment whose original payee is the Guild. +// --------------------------------------------------------------------------- + +export interface BureaucratE3ShipmentFixture extends BureaucratE3FundingBase { + beforePledge: Game; + beforeAction: Game; + target: string; + action: Action; + primary: number; + donorAmount: number; + amount: number; + payee: string; + physical: string[]; +} + +export function createBureaucratE3ShipmentFixture({ + advanced = false, primary = 5, partner = 'moritani', pledge = 3, +}: { + advanced?: boolean; primary?: number; partner?: string; pledge?: number; +} = {}): BureaucratE3ShipmentFixture { + const base = createBureaucratE3FundingBase({ advanced, donor: partner }); + const actions = [...base.actions]; + const staging = [...base.staging]; + let game = pairedE3NexusReload(base.game); + game = advancePairedE3Nexus(game, (s) => s.turn === 2 && s.phase === 5 && s.active === base.payer && pairedE3NexusClean(s), actions, quietPairedE3NexusChoice); + assert.equal(game.turn, 2); + const target = 'habbanya_ridge_sietch'; + assert.ok(game.players.every((p) => !Object.keys(p.forces).some((key) => key.startsWith(target)))); + const physical = bureaucratE3PhysicalCards(game); + const beforePledge = pairedE3NexusReload(game); + const aid: Action = { type: 'pledgeAid', amount: pledge }; + actions.push({ actor: partner, action: aid }); + game = applyAction(game, partner, aid); + assert.equal(game.aid[partner].amount, pledge); + const beforeAction = pairedE3NexusReload(game); + const amount = primary + pledge; + const action: Action = { type: 'ship', territory: target, sector: 17, amount, elite: 0, allyPayment: pledge }; + return { ...base, actions, staging, game, physical, + beforePledge, beforeAction, target, action, primary, donorAmount: pledge, amount, payee: base.guild }; +} + +/** Declare once. In Advanced the original Guild interception commits the ship. */ +export function declareBureaucratE3Shipment( + fixture: BureaucratE3ShipmentFixture, +): { game: Game; declared: Game; paid: Game; actions: BureaucratE3FundingStep[] } { + const actions: BureaucratE3FundingStep[] = []; + let game = applyAction(pairedE3NexusReload(fixture.beforeAction), fixture.payer, fixture.action); + actions.push({ actor: fixture.payer, action: fixture.action }); + const declared = pairedE3NexusReload(game); + if (game.decision?.kind === 'guildShipment') { + const actor = game.decision.player; + const action: Action = { type: 'decision', allow: true }; + actions.push({ actor, action }); + game = applyAction(game, actor, action); + } + return { game, declared, paid: pairedE3NexusReload(game), actions }; +}