diff --git a/docs/BUREAUCRAT_PAYMENTS.md b/docs/BUREAUCRAT_PAYMENTS.md index e5b39d5d..7226f14b 100644 --- a/docs/BUREAUCRAT_PAYMENTS.md +++ b/docs/BUREAUCRAT_PAYMENTS.md @@ -93,14 +93,14 @@ 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, E2 and E3 profiles without Homeworlds, individually +In supported classic, E1, 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 funding and Homeworld allied/split-recipient -composition retain their separate integration boundaries. +eligible payer. Homeworld allied/split-recipient composition retains its +separate integration boundary. 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, including same-phase ownership changes; that accounting is an implementation @@ -269,6 +269,50 @@ 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. +## Native paired E1 receipts — 13 October 2026 + +`tests/fixture-bureaucrat-e1-funding.ts` uses the original paired E1 roster +Ixians/Tleilaxu + classics with skills; a classic seat (Atreides, or Harkonnen +in the five-seat revival roster) genuinely trains Bureaucrat. Real Storm, +Mentat, conserved worm, real alliance and real closing draws are reused from the +existing paired Ix fixture; only the native all14 shuffle order and original +unplayed card custody are scoped. + +Before this slice paired E1 was absent from Bureaucrat mode support, so a funded +auction or shipment rejected at the mode guard +('Bureaucrat with other optional modules is still being integrated.'), +byte-identical after a real pledge. A paid revival produced no offer and no +error: `stampBureaucratPayment` early-returns when mode support is false, and +the revival path never calls the contributor check. Both gates now admit the +no-Homeworlds paired E1 Nexus profile and nothing broader: single-native E1, +preview overlays, multi-expansion and Homeworld allied funding stay excluded. + +Paid Tleilaxu revival is a genuine qualifying source: the source is stamped from +the payer's actual `revival.cost` (not the combined receipt), so a separate +one-spice free-revival award is excluded. Source kind is `revival`, phase 4, +single payer, seated Tleilaxu payee. Axlotl accrual is a separate deferred token; +Face Dance has no player-to-player payment; foreign-ghola acquisition is +Tleilaxu-paid and yields no payer income. None of these are new blockers. + +Forty-nine consumer cases cover both modes: auction and shipment 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 minimal bots, one physical use +per phase, a single-payer bribe regression, and the revival cases above +(qualifying price, below-five no-offer, Karama-canceled no-offer). The Guild's +genuine Nexus Guild Cunning card is exercised: a Guild-authored shipment costs +the bank and has no third-party payer, so it yields no receipt; Guild Secret Ally +is unreachable because that offer requires a game without a Guild faction. +Recorded, not fabricated. + +Two actual390px GameTable flows: Basic allied shipment5+3 redirects2 (Guild5→11, +Harkonnen10→5 with eight reserves at Habbanya17, Ixians10→7, one use) and Basic +paid revival (Atreides10→4 for cost6, Tleilaxu5→10 = paid6 + separate free award1 +− 2, one use). The private revival prompt shows the payer only to the owner. +Browser errors are empty apart from the known Vite optimizer abort for +`next_image.js`. Artifact outside Git: `/tmp/dune-e1-bureaucrat-3228.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 1d4be065..4db79753 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -29,6 +29,27 @@ 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 E1 funding — 13 October.** +Original paired E1 (Ixians/Tleilaxu + classics) with skills now admits +qualifying allied ordinary-auction (Emperor), Guild-shipment and paid Tleilaxu +revival receipts without Homeworlds. A classic seat genuinely trains Bureaucrat; +the existing genuine paired-Ix setup supplies real Storm/Mentat/worm/alliance +and native closing draws. Before this slice both families rejected at the mode +guard after a real pledge; a paid revival silently produced no offer (mode +support false). Both gates now admit the no-Homeworlds paired E1 Nexus profile +only: single-native E1, preview overlays, multi-expansion and Homeworld funding +stay excluded. Paid revival is stamped from the payer's actual cost, so the +separate one-spice free award is excluded; Axlotl is a separate token, Face +Dance has no payment, and foreign-ghola acquisition is Tleilaxu-paid. Forty-nine +consumer cases cover both modes, 5+3/3+5/5+5/fully donor-funded five, +decline/redirect, sub-five nonpooling, owner/payee exclusion, public gross/payee +with payer null, stale/cross-actor, four bots, bribe regression, one use per +phase and the revival cases. Two actual390px flows redirect2: Basic shipment5+3 +(Guild5→11, Harkonnen10→5 with eight reserves at Habbanya17, Ixians10→7) and +Basic paid revival (Atreides10→4 for cost6, Tleilaxu5→10 = paid6 + free award1 +− 2). Artifact: `/tmp/dune-e1-bureaucrat-3228.json`. Frozen union + types/lint/ +build pass. No deployed, save/recovery or full-mode claim follows. + **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 diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 5b38bf2e..5d771b59 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,47 @@ # Dune implementation status +## 13 October 2026 — native paired E1 Bureaucrat funding + +Before this slice the no-Homeworlds paired E1 Nexus profile was absent from +Bureaucrat mode support, so a funded auction or shipment rejected at the mode +guard ('Bureaucrat with other optional modules is still being integrated.'), +byte-identical after a real pledge. A genuine paid revival produced no offer and +no error: stampBureaucratPayment early-returns when mode support is false and the +revival path never calls the contributor check. Both gates now admit the paired +E1 Nexus profile only; single-native E1, preview overlays, multi-expansion and +Homeworld allied funding stay excluded. + +A read-only review confirmed no revival-source change is needed: the source is +stamped from the payer's actual revival.cost, so the separate one-spice +free-revival award is excluded; the source is phase 4, single-payer, seated +Tleilaxu. Axlotl accrual is a separate deferred token; Face Dance has no +player-to-player payment; foreign-ghola acquisition is Tleilaxu-paid and yields +no payer income. + +The new fixture reuses the genuine paired-Ix setup (real Storm, Mentat, +conserved worm, real alliance, native closing draws) and scopes only the native +all14 shuffle order and original unplayed card custody. A classic seat genuinely +trains Bureaucrat. Forty-nine consumer cases pass across both modes: auction and +shipment 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 +minimal bots, one physical use per phase, a single-payer bribe regression, and +the revival cases (qualifying price, below-five no-offer, Karama-canceled +no-offer). The genuine Guild Nexus card is exercised: a Guild-authored shipment +costs the bank and has no third-party payer, so it yields no receipt; Guild +Secret Ally is unreachable because that offer requires a game without a Guild +faction. Recorded, not fabricated. + +Two actual390px GameTable flows: Basic allied shipment5+3 redirects2 (Guild5→11, +Harkonnen10→5 with eight reserves committed at Habbanya17, Ixians10→7, one use) +and Basic paid revival (Atreides10→4 for cost6, Tleilaxu5→10 = paid6 + separate +free award1 − 2, one use; the private prompt shows the payer only to the owner). +Browser errors are empty apart from the known Vite optimizer abort for +next_image.js. Artifact outside Git `/tmp/dune-e1-bureaucrat-3228.json`. Frozen +union + 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 E3 Bureaucrat funding The paired E3 Nexus skill profile was already admitted to Bureaucrat mode diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 9f7bd0d0..816946a7 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/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) | +| 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 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. 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 6cab9b81..8e29819b 100644 --- a/game/bureaucrat-payment.ts +++ b/game/bureaucrat-payment.ts @@ -39,7 +39,8 @@ export function bureaucratPaymentSignature(value: object): string { return JSON.stringify({ ...value, signature: undefined }); } export function bureaucratPaymentModeSupported(game: LeaderSkillProfile): boolean { - return (!game.homeworlds && game.expansions.length === 1 && game.expansions[0] === 'choam' && + return (!game.homeworlds && game.expansions.length === 1 && + (game.expansions[0] === 'choam' || game.expansions[0] === 'ix') && pairedNexusLeaderSkillsProfile(game)) || mixedE3NexusLeaderSkillsProfile(game) || mixedE1E2NexusLeaderSkillsProfile(game) || pairedE3NexusLeaderSkillsProfile(game) || (noOtherLeaderSkillModules(game) && @@ -49,8 +50,8 @@ export function bureaucratPaymentModeSupported(game: LeaderSkillProfile): boolea } export function bureaucratAlliedFundingModeSupported(game: LeaderSkillProfile): boolean { if (game.homeworlds || !bureaucratPaymentModeSupported(game)) return false; - if (game.expansions.length === 1 && game.expansions[0] === 'ecaz') - return pairedE3NexusLeaderSkillsProfile(game); + if (game.expansions.length === 1 && (game.expansions[0] === 'ecaz' || game.expansions[0] === 'ix')) + return pairedNexusLeaderSkillsProfile(game) || pairedE3NexusLeaderSkillsProfile(game); return !game.expansions.length || game.expansions.length === 1 && game.expansions[0] === 'choam'; } export function bureaucratUsed( diff --git a/game/reference.ts b/game/reference.ts index 6f15b52d..ffb74e52 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, 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.', + '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. 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. Homeworld allied/split-recipient composition remains 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-e1-funding.test.ts b/tests/bureaucrat-e1-funding.test.ts new file mode 100644 index 00000000..44d78f1d --- /dev/null +++ b/tests/bureaucrat-e1-funding.test.ts @@ -0,0 +1,403 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { applyAction, viewGame, type Action, type Game } from '../game/engine'; +import { territory } from '../game/board'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { bureaucratAlliedFundingModeSupported, bureaucratPaymentModeSupported } from '../game/bureaucrat-payment'; +import { advancePairedIxNexusSkills } from './fixture-paired-ix-nexus-skills'; +import { + allowE1Responses, bureaucratE1PhysicalCards, chooseBureaucratE1Payment, + createBureaucratE1AuctionFixture, createBureaucratE1FundingBase, + createBureaucratE1RevivalFixture, createBureaucratE1ShipmentFixture, + declareBureaucratE1Shipment, payBureaucratE1Auction, payBureaucratE1Revival, + reviveBureaucratE1, + type BureaucratE1AuctionFixture, type BureaucratE1ShipmentFixture, +} from './fixture-bureaucrat-e1-funding'; + +const seat = (game: Game, id: string) => game.players.find(p => p.id === id)!; +const hand = (game: Game, id: string) => seat(game, id).hand.map(card => card.id); +const counters = (game: Game) => game.players.map(p => ({ + id: p.id, forces: p.forces, reserves: p.reserves, tanks: p.tanks, elites: p.elites, +})); + +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. + * A donor that is also the payee collects the sale later, so only its pledge is + * checked here. */ +function assertAuctionCosts(f: BureaucratE1AuctionFixture, game: Game): void { + assert.equal(seat(game, f.payer).spice, seat(f.beforePledge, f.payer).spice - f.payerShare); + if (f.donor !== f.payee) + assert.equal(seat(game, f.donor).spice, seat(f.beforePledge, f.donor).spice - f.donorShare); + assert.equal(game.aid[f.donor]?.amount ?? 0, 0); + assert.deepEqual(bureaucratE1PhysicalCards(game), f.physical); +} + +/** One purchase (with its original replacement draw) and original next lot. */ +function assertAuctionOutcome(f: BureaucratE1AuctionFixture, 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.deepEqual(hand(done, f.payer), + [...hand(f.beforeAction, f.payer), f.card, f.beforeAction.deck[0].id]); + 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: BureaucratE1ShipmentFixture, 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}:${f.sector}`]: 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(bureaucratE1PhysicalCards(game), f.physical); +} + +void test('paired E1 no-Homeworlds nexus skills admit both Bureaucrat mode gates and nothing broader', () => { + const base = createBureaucratE1FundingBase({}); + assert.deepEqual(base.game.expansions, ['ix']); + assert.equal(base.game.homeworlds, undefined); + assert.equal(bureaucratPaymentModeSupported(base.game), true); + assert.equal(bureaucratAlliedFundingModeSupported(base.game), true); + const single = structuredClone(base.game); + single.players = single.players.filter(p => p.id !== 'tleilaxu'); + assert.equal(bureaucratPaymentModeSupported(single), false); + const multi = structuredClone(base.game); + multi.expansions = ['ix', 'choam']; + assert.equal(bureaucratPaymentModeSupported(multi), false); + assert.equal(bureaucratAlliedFundingModeSupported(multi), false); +}); + +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 E1 native auction ${payerShare}+${donorShare}: ${redirect ? 'Bank two' : 'full Emperor income'}, one purchase and original next lot`, () => { + const f = createBureaucratE1AuctionFixture({ 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 = payBureaucratE1Auction(f); + assert.equal(paid.currentAuctionSale?.winner, f.payer); + assert.equal(paid.currentAuctionSale?.amount, f.amount); + assert.equal(paid.currentAuctionSale?.seller, null); + assertAuctionCosts(f, paid); + const pending = allowE1Responses(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 = allowE1Responses(chooseBureaucratE1Payment(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 E1 native shipment ${primary}+${pledge}: ${redirect ? 'Bank two' : 'full Guild income'}, army commitment and Movement continuation`, () => { + const f = createBureaucratE1ShipmentFixture({ 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 } = declareBureaucratE1Shipment(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); + } + const pending = allowE1Responses(paid); + assert.equal(pending.decision?.kind, 'bureaucratPayment'); + assert.equal(pending.decision?.player, f.trainer); + assert.deepEqual(counters(pending), counters(paid)); + const done = allowE1Responses(chooseBureaucratE1Payment(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 E1 native receipts never pool sub-five shares`, () => { + const auction = createBureaucratE1AuctionFixture({ advanced, payerShare: 3, donorShare: 3 }); + const auctionDone = allowE1Responses(payBureaucratE1Auction(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 = createBureaucratE1ShipmentFixture({ advanced, primary: 3, pledge: 3 }); + const shipmentDone = allowE1Responses(declareBureaucratE1Shipment(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'} paired E1 trainer and payee contributions cannot qualify or mask a real payer`, () => { + for (const donor of ['atreides', 'emperor']) { + const allowed = createBureaucratE1AuctionFixture({ advanced, payerShare: 5, donorShare: 5, donor }); + assert.equal(seat(allowed.alliance, allowed.payer).ally, donor); + assert.equal(seat(allowed.alliance, donor).ally, allowed.payer); + const pending = allowE1Responses(payBureaucratE1Auction(allowed)); + assert.equal(pending.decision?.kind, 'bureaucratPayment'); + assert.equal(pending.bureaucratPayments?.pending?.source.payer, allowed.payer); + assertAuctionOutcome(allowed, allowE1Responses(chooseBureaucratE1Payment(pending, true)), 2); + const masked = createBureaucratE1AuctionFixture({ advanced, payerShare: 3, donorShare: 5, donor }); + const done = allowE1Responses(payBureaucratE1Auction(masked)); + assert.notEqual(done.decision?.kind, 'bureaucratPayment'); + assert.equal(done.bureaucratPayments?.used.length ?? 0, 0); + } + { + const allowed = createBureaucratE1ShipmentFixture({ advanced, primary: 5, pledge: 5, donor: 'atreides' }); + const pending = allowE1Responses(declareBureaucratE1Shipment(allowed).paid); + assert.equal(pending.decision?.kind, 'bureaucratPayment'); + assert.equal(pending.bureaucratPayments?.pending?.source.payer, allowed.payer); + assertShipmentCosts(allowed, allowE1Responses(chooseBureaucratE1Payment(pending, true))); + const masked = createBureaucratE1ShipmentFixture({ advanced, primary: 3, pledge: 5, donor: 'atreides' }); + const done = allowE1Responses(declareBureaucratE1Shipment(masked).paid); + assert.notEqual(done.decision?.kind, 'bureaucratPayment'); + assert.equal(done.bureaucratPayments?.used.length ?? 0, 0); + } + // A Guild ally is charged the half price and its own shipment leg is zero, + // so the payee can never contribute a qualifying share to its own receipt. + const payee = createBureaucratE1ShipmentFixture({ advanced, primary: 5, pledge: 5, donor: 'guild' }); + const payeeDone = allowE1Responses(declareBureaucratE1Shipment(payee).paid); + assert.notEqual(payeeDone.decision?.kind, 'bureaucratPayment'); + assert.equal(seat(payeeDone, payee.payer).spice, seat(payee.beforePledge, payee.payer).spice); + assert.equal(seat(payeeDone, payee.guild).spice, seat(payee.beforePledge, payee.guild).spice - 5); + assert.deepEqual(seat(payeeDone, payee.payer).forces, + { ...seat(payee.beforePledge, payee.payer).forces, [`${payee.target}:${payee.sector}`]: payee.amount }); + }); + + void test(`${advanced ? 'Advanced' : 'Basic'} paired E1 pending invoices expose only the public gross and payee`, () => { + const primary = createBureaucratE1AuctionFixture({ advanced, payerShare: 5, donorShare: 3 }); + const donorFunded = createBureaucratE1AuctionFixture({ advanced, payerShare: 3, donorShare: 5 }); + const left = allowE1Responses(payBureaucratE1Auction(primary)); + const right = allowE1Responses(payBureaucratE1Auction(donorFunded)); + assert.equal(left.bureaucratPayments?.pending?.source.payer, primary.payer); + assert.equal(right.bureaucratPayments?.pending?.source.payer, donorFunded.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 = createBureaucratE1ShipmentFixture({ advanced, primary: 5, pledge: 5 }); + const pending = allowE1Responses(declareBureaucratE1Shipment(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 E1 pending choices reject cross-actor and stale input unchanged`, () => { + const auction = createBureaucratE1AuctionFixture({ advanced, payerShare: 5, donorShare: 5 }); + const auctionPending = allowE1Responses(payBureaucratE1Auction(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, allowE1Responses(chooseBureaucratE1Payment(auctionPending)), 2); + const shipment = createBureaucratE1ShipmentFixture({ advanced, primary: 5, pledge: 5 }); + const shipmentPending = allowE1Responses(declareBureaucratE1Shipment(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, allowE1Responses(chooseBureaucratE1Payment(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 = createBureaucratE1ShipmentFixture({ advanced, primary: 5, pledge: 5 }); + const pending = allowE1Responses(declareBureaucratE1Shipment(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 = createBureaucratE1ShipmentFixture({ advanced, primary: 5, pledge: 3 }); + const pending = allowE1Responses(declareBureaucratE1Shipment(f).paid); + const settled = allowE1Responses(chooseBureaucratE1Payment(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 = allowE1Responses(chooseBureaucratE1Payment(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 = advancePairedIxNexusSkills(ended, (state) => state.turn === 3 && state.phase === 1 && + !state.phaseOpening && !state.response && !state.decision); + 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 = allowE1Responses(chooseBureaucratE1Payment(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)); + } + }); +} + +void test('paired E1 single-payer bribe now reaches the same opened mode gate', () => { + const base = createBureaucratE1FundingBase({}); + const bribed = applyAction(structuredClone(base.game), 'emperor', { type: 'bribe', target: 'guild', amount: 5 }); + assert.equal(bribed.decision?.kind, 'bureaucratPayment'); + assert.equal(bribed.bureaucratPayments?.pending?.source.kind, 'bribe'); + assert.equal(bribed.bureaucratPayments?.pending?.source.payer, 'emperor'); + assert.equal(bribed.bureaucratPayments?.pending?.source.payee, 'guild'); + assert.equal(bribed.bureaucratPayments?.pending?.source.amount, 5); + assert.equal(seat(bribed, 'emperor').spice, 5); + const done = allowE1Responses(chooseBureaucratE1Payment(bribed, true)); + assert.equal(seat(done, 'guild').bribes, 3); + assert.equal(seat(done, 'guild').spice, seat(base.game, 'guild').spice); + assert.equal(done.bureaucratPayments?.used.length, 1); +}); + +void test('paired E1 paid Tleilaxu revival income offers Bureaucrat at phase four and redirects exactly two', () => { + const f = createBureaucratE1RevivalFixture({}); + assert.equal(f.game.turn, 3); + assert.equal(f.game.phase, 4); + const before = structuredClone(f.game); + const pending = payBureaucratE1Revival(f); + assert.equal(pending.decision?.kind, 'bureaucratPayment'); + assert.equal(pending.decision?.player, f.trainer); + const source = pending.bureaucratPayments!.pending!.source; + assert.equal(source.kind, 'revival'); + assert.equal(source.phase, 4); + assert.equal(source.payer, f.payer); + assert.equal(source.payee, f.tleilaxu); + assert.equal(source.amount, 6); + assert.equal(source.privateFunding, undefined); + assert.equal(seat(pending, f.payer).spice, seat(before, f.payer).spice - 6); + assert.equal(viewGame(pending, f.payer).bureaucrat.pending?.amount, 6); + assert.equal(viewGame(pending, f.payer).bureaucrat.pending?.payer, f.payer); + assert.equal(viewGame(pending, 'ixians').bureaucrat.pending, null); + const done = allowE1Responses(chooseBureaucratE1Payment(pending, true)); + assert.equal(seat(done, f.payer).spice, seat(before, f.payer).spice - 6); + assert.equal(seat(done, f.tleilaxu).spice, seat(before, f.tleilaxu).spice + 5); + assert.equal(done.bureaucratPayments?.used.length, 1); + assert.equal(done.response, null); + assert.equal(done.decision, null); + rejectUnchanged(done, f.trainer, { type: 'decision', event: pending.bureaucratPaymentEvent, redirect: true }); +}); + +void test('paired E1 below-five paid revival and a Karama-canceled native revival window create no offer', () => { + const small = createBureaucratE1RevivalFixture({ amount: 3 }); + const smallBefore = structuredClone(small.game); + const smallDone = payBureaucratE1Revival(small); + assert.notEqual(smallDone.decision?.kind, 'bureaucratPayment'); + assert.equal(smallDone.bureaucratPayments?.pending, undefined); + assert.equal(seat(smallDone, small.payer).spice, seat(smallBefore, small.payer).spice - 2); + assert.equal(seat(smallDone, small.tleilaxu).spice, seat(smallBefore, small.tleilaxu).spice + 3); + const canceled = createBureaucratE1RevivalFixture({ counter: true }); + const canceledBefore = structuredClone(canceled.game); + assert.ok(canceled.counterCard && canceled.staging.some(line => line.includes('swap original unplayed'))); + const held = reviveBureaucratE1(canceled); + assert.equal(held.response?.kind, 'revivalLimit'); + assert.equal(held.response?.owner, canceled.tleilaxu); + const done = allowE1Responses(applyAction(held, canceled.trainer, { type: 'card', card: canceled.counterCard!, mode: 'cancel' })); + assert.notEqual(done.decision?.kind, 'bureaucratPayment'); + assert.equal(done.bureaucratPayments?.pending, undefined); + assert.equal(seat(done, canceled.payer).spice, seat(canceledBefore, canceled.payer).spice); + assert.equal(seat(done, canceled.tleilaxu).spice, seat(canceledBefore, canceled.tleilaxu).spice); + assert.equal(done.discard.filter(card => card.id === canceled.counterCard).length, 1); +}); + +void test('paired E1 Nexus Guild route: Guild Cunning is the Guild\'s own card and a Guild-authored shipment has no third-party payer', () => { + const base = createBureaucratE1FundingBase({}); + assert.ok(base.worm.nexusCards!.cards!.deck.includes('guild')); + assert.equal(base.alliance.players.find(p => p.id === base.guild)!.ally, null); + const game = advancePairedIxNexusSkills(structuredClone(base.game), + (state) => state.phase === 5 && state.active === base.guild && + !state.phaseOpening && !state.response && !state.decision); + const before = structuredClone(game); + const target = 'habbanya_ridge_sietch'; + const sector = territory(target).sectors[0]; + assert.ok(game.players.every(p => !Object.keys(p.forces).some(key => key.startsWith(target)))); + let paid = applyAction(game, base.guild, { type: 'ship', territory: target, sector, amount: 5, elite: 0, allyPayment: 0 }); + if (paid.decision?.kind === 'guildShipment') + paid = applyAction(paid, paid.decision.player, { type: 'decision', allow: true }); + paid = allowE1Responses(paid); + assert.notEqual(paid.decision?.kind, 'bureaucratPayment'); + assert.equal(paid.bureaucratPayments?.pending, undefined); + assert.equal(seat(paid, base.guild).spice, seat(before, base.guild).spice - 3); + assert.equal(seat(paid, base.guild).reserves, seat(before, base.guild).reserves - 5); + assert.deepEqual(seat(paid, base.guild).forces, + { ...seat(before, base.guild).forces, [`${target}:${sector}`]: 5 }); +}); diff --git a/tests/fixture-bureaucrat-e1-funding.ts b/tests/fixture-bureaucrat-e1-funding.ts new file mode 100644 index 00000000..bf9d08b1 --- /dev/null +++ b/tests/fixture-bureaucrat-e1-funding.ts @@ -0,0 +1,356 @@ +import assert from 'node:assert/strict'; +import { + applyAction, createGame, handLimit, initializeLeaderSkillsGameForAudit, joinGame, + newPlayer, type Action, type Game, +} from '../game/engine'; +import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards'; +import type { FactionId } from '../game/catalog'; +import { territory } from '../game/board'; +import { + advancePairedIxNexusSkills, createPairedIxNexusSkillsFixture, + finishPairedIxNexusSkillsBattle, pairedIxNexusSkillsPlayer, + revealPairedIxNexusSkillsBattle, type PairedIxNexusSkillsFixture, +} from './fixture-paired-ix-nexus-skills'; + +export type BureaucratE1FundingStep = { actor: string; action: Action }; +const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision; + +/** Original six-seat paired E1 roster: both natives, four classics. */ +export const BUREAUCRAT_E1_ROSTER = ['ixians', 'tleilaxu', 'guild', 'emperor', 'atreides', 'harkonnen'] as const; +/** The single classic seat trained with the native Bureaucrat skill. */ +export const BUREAUCRAT_E1_TRAINER = 'atreides'; +/** Original five-seat paired E1 roster without a Guild faction: the battle loser + * can genuinely fund a five-force revival from its own spice. */ +export const BUREAUCRAT_E1_REVIVAL_ROSTER = ['ixians', 'tleilaxu', 'atreides', 'harkonnen', 'emperor'] as const; +export const BUREAUCRAT_E1_REVIVAL_TRAINER = 'harkonnen'; + +/** Original 47/14/12 setup → real Storm → real Mentat → conserved worm → + * real alliance → real closing draws. Only the native all14 shuffle is scoped; + * wallets, phases, assignments and armies are never assigned. */ +function entropy(operation: () => T, rolls: number[] = []): T { + const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues'); + const original = crypto.getRandomValues.bind(crypto); + let cursor = 0; + crypto.getRandomValues = (array: V): V => { + if (array instanceof Uint32Array) { + for (let i = 0; i < array.length; i++) array[i] = rolls[cursor++] ?? 0xffffffff; + } else Reflect.apply(original, crypto, [array]); + return array; + }; + try { return operation(); } + finally { + if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor); + else Reflect.deleteProperty(crypto, 'getRandomValues'); + } +} +function shuffleRolls(source: string[], desired: string[]): number[] { + const working = [...source], rolls: number[] = []; + assert.deepEqual([...source].sort(), [...desired].sort()); + for (let i = working.length - 1; i > 0; i--) { + const from = working.indexOf(desired[i]); assert.ok(from >= 0 && from <= i); + rolls.push(Math.floor((from + 0.5) * 0x100000000 / (i + 1))); + [working[i], working[from]] = [working[from], working[i]]; + } + return rolls; +} +function skillRolls(selected: LeaderSkillId[]): number[] { + const source = LEADER_SKILL_CARDS.map(c => c.id); + const remainder = source.filter(c => !selected.includes(c)); + const desired = selected.flatMap(c => [c, remainder.shift()!]); + return shuffleRolls(source, [...desired, ...remainder]); +} +/** Only the native all14 shuffle order is scoped, so the named classic seat can + * genuinely select the Bureaucrat card from its own original offer. */ +function pairedE1Initial( + code: string, + factions: readonly string[], + trainer: string, + skills: Record, + advanced: boolean, +): Game { + let game = createGame(code, newPlayer('ixians', 'Ixians', 'ixians'), advanced, ['ix']); + for (const faction of factions.slice(1)) joinGame(game, newPlayer(faction, faction, faction as FactionId)); + game.nexusCards ??= { cards: null, phase: null }; + for (const p of game.players) if (!p.ready) game = applyAction(game, p.id, { type: 'ready' }); + assert.ok(factions.includes(trainer) && skills[trainer] === 'bureaucrat' && skills.ixians === 'suk-graduate'); + const selected = game.players.map(p => skills[p.faction]); + return entropy(() => initializeLeaderSkillsGameForAudit(game), skillRolls(selected)); +} +/** Build the shared paired E1 fixture with the native skills requested. The + * shared helper assigns each seat the first card of its genuine offer. */ +function pairedE1Skills(factions: readonly string[], trainer: string, advanced: boolean): PairedIxNexusSkillsFixture { + const skills: Record = { + ixians: 'suk-graduate', tleilaxu: 'sandmaster', guild: 'smuggler', + emperor: 'rihani-decipherer', atreides: 'prana-bindu-adept', harkonnen: 'killer-medic', + [trainer]: 'bureaucrat', + }; + const fixture = createPairedIxNexusSkillsFixture({ + initial: pairedE1Initial(`BUREAUCRATE1${factions.length}${advanced ? 'A' : 'B'}`, factions, trainer, skills, advanced), + advanced, skill: 'suk-graduate', + }); + assert.equal(fixture.afterSetup.leaderSkills!.assignments.find(a => a.skill === 'bureaucrat')?.owner, trainer); + return fixture; +} +/** Native response windows: pass every original unpassed counter holder. */ +export function allowE1Responses(state: Game): Game { + let game = state; + for (let n = 0; game.response && n < 60; n++) { + const actor = game.players.find(p => !game.response!.passed.includes(p.id)); + assert.ok(actor, 'An original unpassed counter holder must remain'); + game = applyAction(game, actor.id, { type: 'passResponse' }); + } + return game; +} +export function chooseBureaucratE1Payment(game: Game, redirect = true): Game { + assert.equal(game.decision?.kind, 'bureaucratPayment'); + return applyAction(game, game.decision!.player, { type: 'decision', event: game.bureaucratPaymentEvent, redirect }); +} + +export interface BureaucratE1FundingBase { + fixture: PairedIxNexusSkillsFixture; + actions: BureaucratE1FundingStep[]; + /** Turn-2 Nexus window before the requested real alliance forms. */ + worm: Game; + /** Genuine mutual alliance between the payer and the requested donor. */ + alliance: Game; + /** Turn-2 Bidding boundary with the requested alliance already formed. */ + game: Game; + payer: string; + donor: string; + trainer: string; + ixians: string; + tleilaxu: string; + guild: string; + emperor: string; +} +/** Genuine paired E1 setup with skills, a real 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 createBureaucratE1FundingBase({ + advanced = false, payer = 'harkonnen', donor = 'ixians', +}: { advanced?: boolean; payer?: string; donor?: string } = {}): BureaucratE1FundingBase { + assert.notEqual(payer, donor); + const fixture = pairedE1Skills(BUREAUCRAT_E1_ROSTER, BUREAUCRAT_E1_TRAINER, advanced); + const actions: BureaucratE1FundingStep[] = []; + let game = structuredClone(fixture.beforeAlliance); + assert.equal(game.turn, 2); assert.equal(game.phase, 1); + assert.ok(game.nexus && game.players.every(p => p.ally === null)); + const worm = structuredClone(game); + const allianceActions: BureaucratE1FundingStep[] = [payer, donor].map((actor, i) => ({ + actor, action: { type: 'alliance', target: [donor, payer][i] }, + })); + for (const step of allianceActions) { actions.push(step); game = applyAction(game, step.actor, step.action); } + assert.equal(pairedIxNexusSkillsPlayer(game, payer).ally, donor); + assert.equal(pairedIxNexusSkillsPlayer(game, donor).ally, payer); + const alliance = structuredClone(game); + game = advancePairedIxNexusSkills(game, s => s.turn === 2 && s.phase === 3 && clean(s), actions); + return { + fixture, actions, worm, alliance, game, + payer, donor, trainer: BUREAUCRAT_E1_TRAINER, + ixians: 'ixians', tleilaxu: 'tleilaxu', guild: 'guild', emperor: 'emperor', + }; +} + +// --------------------------------------------------------------------------- +// Ordinary auction whose original payee is the Emperor. +// --------------------------------------------------------------------------- + +export interface BureaucratE1AuctionFixture extends BureaucratE1FundingBase { + beforePledge: Game; + beforeAction: Game; + card: string; + amount: number; + payerShare: number; + donorShare: number; + action: Action; + payee: string; + physical: string[]; +} + +/** Only original unplayed-card custody is controlled here. */ +export function bureaucratE1PhysicalCards(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 createBureaucratE1AuctionFixture({ + advanced = false, payerShare = 5, donorShare = 3, donor = 'ixians', +}: { advanced?: boolean; payerShare?: number; donorShare?: number; donor?: string } = {}): BureaucratE1AuctionFixture { + const base = createBureaucratE1FundingBase({ advanced, donor }); + const actions = [...base.actions]; + let game = structuredClone(base.game); + for (let n = 0; game.auction!.active !== base.payer && n < game.players.length + 2; 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 = bureaucratE1PhysicalCards(game); + const beforePledge = structuredClone(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 = structuredClone(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, game, physical, + beforePledge, beforeAction, card, amount, payerShare, donorShare, action, payee: base.emperor }; +} + +/** Commit the declared bid. Passes the remaining original bidders and declines + * the native Ixian ally-card replacement (keeping the purchased card). */ +export function payBureaucratE1Auction(fixture: BureaucratE1AuctionFixture, 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' }); + } + if (game.decision?.kind === 'ixAllyCard') + game = applyAction(game, game.decision.player, { type: 'decision', accept: false }); + return game; +} + +// --------------------------------------------------------------------------- +// Ordinary shipment whose original payee is the Guild. +// --------------------------------------------------------------------------- + +export interface BureaucratE1ShipmentFixture extends BureaucratE1FundingBase { + beforePledge: Game; + beforeAction: Game; + target: string; + sector: number; + action: Action; + primary: number; + donorAmount: number; + amount: number; + payee: string; + physical: string[]; +} + +export function createBureaucratE1ShipmentFixture({ + advanced = false, primary = 5, donor = 'ixians', pledge = 3, +}: { advanced?: boolean; primary?: number; donor?: string; pledge?: number } = {}): BureaucratE1ShipmentFixture { + const base = createBureaucratE1FundingBase({ advanced, donor }); + const actions = [...base.actions]; + let game = structuredClone(base.game); + const payer = base.payer; + game = advancePairedIxNexusSkills(game, s => s.turn === 2 && s.phase === 5 && s.active === payer && clean(s), actions); + assert.equal(game.turn, 2); assert.equal(game.phase, 5); + const target = 'habbanya_ridge_sietch'; + const sector = territory(target).sectors[0]; + assert.ok(game.players.every(p => !Object.keys(p.forces).some(key => key.startsWith(target)))); + const physical = bureaucratE1PhysicalCards(game); + const beforePledge = structuredClone(game); + const aid: Action = { type: 'pledgeAid', amount: pledge }; + actions.push({ actor: donor, action: aid }); + game = applyAction(game, donor, aid); + assert.equal(game.aid[donor].amount, pledge); + const beforeAction = structuredClone(game); + const amount = primary + pledge; + const action: Action = { type: 'ship', territory: target, sector, amount, elite: 0, allyPayment: pledge }; + return { ...base, actions, game, physical, + beforePledge, beforeAction, target, sector, action, primary, donorAmount: pledge, amount, payee: base.guild }; +} + +/** Declare once. In Advanced the original Guild interception commits the ship. */ +export function declareBureaucratE1Shipment( + fixture: BureaucratE1ShipmentFixture, +): { game: Game; declared: Game; paid: Game; actions: BureaucratE1FundingStep[] } { + const actions: BureaucratE1FundingStep[] = []; + let game = applyAction(structuredClone(fixture.beforeAction), fixture.payer, fixture.action); + actions.push({ actor: fixture.payer, action: fixture.action }); + const declared = structuredClone(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: structuredClone(game), actions }; +} + +// --------------------------------------------------------------------------- +// Paid Tleilaxu revival income at phase four. +// --------------------------------------------------------------------------- + +export interface BureaucratE1RevivalFixture { + fixture: PairedIxNexusSkillsFixture; + actions: BureaucratE1FundingStep[]; + staging: string[]; + game: Game; + payer: string; + trainer: string; + tleilaxu: string; + opponent: string; + amount: number; + counterCard?: string; +} + +function holdE1Card( + game: Game, + actor: string, + match: (card: Game['deck'][number]) => boolean, + staging: string[], +): string { + const owner = pairedIxNexusSkillsPlayer(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 && owner.hand.length <= handLimit(owner)); + const outgoing = owner.hand[0], incoming = game.deck[index]; + owner.hand[0] = incoming; game.deck[index] = outgoing; + staging.push(`Native revival boundary: swap original unplayed ${incoming.id} with held ${outgoing.id} for ${actor}; retain physical cards and hand size.`); + return incoming.id; +} + +/** Genuine paired E1 revival boundary: a real battle returns the payer's forces + * to the tanks, then Tleilaxu's native expanded-limit benefit genuinely lets a + * five-force paid revival exceed five spice. Never assigns wallets or tanks. */ +export function createBureaucratE1RevivalFixture({ + advanced = false, payer = 'atreides', amount = 5, counter = false, +}: { advanced?: boolean; payer?: string; amount?: number; counter?: boolean } = {}): BureaucratE1RevivalFixture { + const fixture = pairedE1Skills(BUREAUCRAT_E1_REVIVAL_ROSTER, BUREAUCRAT_E1_REVIVAL_TRAINER, advanced); + const actions: BureaucratE1FundingStep[] = []; + const staging: string[] = []; + assert.notEqual(payer, BUREAUCRAT_E1_REVIVAL_TRAINER); + assert.equal(fixture.opponent, payer, 'The requested revival payer must be the original battle loser with tanks'); + let game = finishPairedIxNexusSkillsBattle(revealPairedIxNexusSkillsBattle(fixture), 'complete'); + const counterCard = counter + ? holdE1Card(game, BUREAUCRAT_E1_REVIVAL_TRAINER, card => card.effect === 'karama', staging) + : undefined; + game = advancePairedIxNexusSkills(game, s => s.turn === 3 && s.phase === 4 && clean(s), actions); + const grant: Action = { type: 'tleilaxuRevivalLimit', target: payer }; + actions.push({ actor: 'tleilaxu', action: grant }); + game = applyAction(game, 'tleilaxu', grant); + return { + fixture, actions, staging, game, payer, trainer: BUREAUCRAT_E1_REVIVAL_TRAINER, + tleilaxu: 'tleilaxu', opponent: fixture.opponent, amount, counterCard, + }; +} + +/** Declare the paid force revival and clear Tleilaxu's native stop. The + * resulting income response either resolves inside the action (no counter + * holder) or remains for the caller to cancel or pass. */ +export function reviveBureaucratE1(fixture: BureaucratE1RevivalFixture, state = fixture.game): Game { + let game = applyAction(state, fixture.payer, { type: 'revive', amount: fixture.amount }); + if (game.decision?.kind === 'revivalStop') { + assert.equal(game.decision.player, fixture.tleilaxu); + game = applyAction(game, fixture.tleilaxu, { type: 'decision', decline: true }); + } + return game; +} + +export function payBureaucratE1Revival(fixture: BureaucratE1RevivalFixture, state = fixture.game): Game { + return allowE1Responses(reviveBureaucratE1(fixture, state)); +}