diff --git a/components/bureaucrat-payment.tsx b/components/bureaucrat-payment.tsx index 3fd9017b..1ac36969 100644 --- a/components/bureaucrat-payment.tsx +++ b/components/bureaucrat-payment.tsx @@ -29,9 +29,10 @@ export function BureaucratPayment({

Bureaucrat payment

- The payment from {payer} to {payee} is {pending.amount} spice. You may redirect {pending.redirect}{' '} - spice to the Bank: {payee} receives {redirectAmount} spice and the Bank receives{' '} - {pending.redirect}, while {payer} still pays the full amount. + {payer ? <>The payment from {payer} to {payee} : <>The qualifying allied-funded payment to {payee}}{' '} + is {pending.amount} spice. You may redirect {pending.redirect} spice to the Bank: {payee} receives{' '} + {redirectAmount} spice and the Bank receives {pending.redirect}, while{' '} + {payer ? `${payer} still pays the full amount` : 'each contributor still pays their full original share'}.

{pending.kind === 'bribe' && (

diff --git a/docs/BUREAUCRAT_PAYMENTS.md b/docs/BUREAUCRAT_PAYMENTS.md index 7569243b..0053cdc2 100644 --- a/docs/BUREAUCRAT_PAYMENTS.md +++ b/docs/BUREAUCRAT_PAYMENTS.md @@ -91,9 +91,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. -Individually qualifying private or split payments (including a payment funded -entirely by a qualifying ally) still need payment-unit/privacy integration and -remain guarded before costs. Split-recipient Homeworld income stays separate. +In supported classic 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 expansion-faction funding and split-recipient +Homeworld income retain their separate integration guards. 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 @@ -107,20 +111,67 @@ one paying player; separate below-threshold costs are not one six-spice payment by that player. The check now preserves that individual threshold without opening the unresolved qualifying split-payment reaction. -Real Basic and Advanced skill setup trains Bureaucrat; a real turn-two worm -forms the Harkonnen/Fremen alliance. Conserved starting counters and undealt -Spice Card ordering isolate the payments; no wallet, phase, alliance or ledger -is assigned. Both before shipment programs rejected valid three-plus-three. -Afterward, Basic/Advanced shipment and ordinary auction programs preserve the -six-spice debit and full Guild/Emperor receipt. Eight cases in -`tests/bureaucrat-split-threshold.test.ts` cover three/four personal spice plus -three allied, ordinary acquisition and the remaining five-plus-three guard. +The former threshold fixture used real skill training, turn-two worm and +mutual Harkonnen/Fremen alliance, but folded original board armies into +reserves. Its below-five checks and obsolete qualifying-guard assertions are +now replaced by the original-army fixtures below; it is not current native-army +evidence. Before the threshold fix, both shipment programs rejected valid +three-plus-three. Afterward, the six-spice debit and full Guild/Emperor receipt +continued without an offer. The actual390px Basic controls pledge Fremen's three, ship six counters into Habbanya Sietch17 for three personal plus three allied, credit Guild5→11 and finish the original Movement turn. Initial Vite optimizer request aborts and cosmetic action notices are recorded; this is local proof, not deployed or full-family acceptance. +## Qualifying classic allied receipts — 13 October 2026 + +`tests/fixture-bureaucrat-qualifying-split.ts` retains original setup armies, +wallets and phase progression. A real first-turn three-spice bribe is collected +at the original Mentat Pause; the actual next worm and mutual alliance precede +the turn-two pledge. Only unplayed physical card ordering/custody is staged. +All six Basic/Advanced qualifying shipment and all six auction before programs +rejected immutably after the real pledge at the old integration guard. + +The signed internal receipt retains each positive actual payer share. Existing +single-payer receipts gain no new signature fields. The public prompt retains +the already public gross price and recipient, but its payer is null for allied +funding: no exact shares, selected qualifying identity or internal source is +projected. Controls explain unchanged full contributor costs and gross-minus-two +recipient income. This conservative prompt-composition policy reveals that an +eligible share exists, not which private contribution meets the threshold. + +Original Karama income cancellation still precedes Bureaucrat. Cancellation +banks the entire gross receipt and creates no offer/use; allowance keeps original +purchase/force commitment and asks the native trainer before credit. Decline +preserves later use; a five-plus-five receipt still consumes at most one use. +Actual native owner/payee funding and real phase-boundary cases are covered. +The existing bot payee/alliance choice needs no private contributor identity. + +Four parent actual programs additionally exercise entirely Fremen-funded five +in both payment families and modes. Harkonnen pays zero, Fremen6→1, Guild +receives three after redirection or Emperor10→13; the original five shipped +reserves or purchased card and one legal Harkonnen bonus remain committed. +Two actual390px GameTable flows use original Karama allowance then the new +choice: Basic five-plus-three redirects two, Guild5→11/H2/F3/B10 and original +Finish hands Movement to Fremen; Advanced three-plus-five allows all eight, +Emperor10→18/H6/F1/B12, one purchased card plus one bonus, then original next-lot +inspection allowance returns native Bidding at index1. Artifact outside Git: +`/tmp/dune-native-bureaucrat-3225.json`. + +The isolated host initially failed virtual React-refresh module resolution; +its resolver was corrected. The actual built app stylesheet supplied native +styling. A stale accessibility-node failure followed an already accepted +income allowance; authoritative state was inspected and no action replayed. +The generic network-idle wait timed out against the live dev host; direct +controls and fresh state/visual observations supplied the evidence instead. +This is local rule/control proof, not production, recovery or full-mode proof. +The native fixtures and consumer cases are +`tests/fixture-bureaucrat-qualifying-shipment.ts`, +`tests/bureaucrat-qualifying-shipment.test.ts`, +`tests/fixture-bureaucrat-qualifying-auction.ts` and +`tests/bureaucrat-qualifying-auction.test.ts`. + ## 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 e9d55ae8..95a6b450 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -29,6 +29,34 @@ 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 / qualifying classic allied receipts — 13 October.** +Original Basic/Advanced five-plus-three, three-plus-five, five-plus-five and +fully ally-funded five auction/shipment payments now offer one two-spice Bank +redirection after original Karama allowance, with every original payer cost +unchanged. Six before shipment and six before auction programs rejected +immutably at the old guard after actual pledges. New fixtures retain original +setup armies, first-turn bribe/Mentat credit, real next worm and mutual alliance; +the old folded-army threshold fixture and obsolete guard assertions are removed. +Public gross/recipient remain, but private shares and qualifying identity do +not: allied prompt payer is null. Owner/payee contributions cannot qualify; +both-five still spends one physical use. Qualifying expansion-faction funding +and split-recipient Homeworld income remain guarded; gifts stay excluded. +Four parent actual fully-funded programs and two actual390px GameTable flows +preserve original forces/acquisitions and one bonus/continuation. Basic Guild +redirect finishes Movement at Fremen with G11/H2/F3/B10; Advanced Emperor +decline returns original next lot1 with E18/H6/F1/B12 and no use. +Artifact: `/tmp/dune-native-bureaucrat-3225.json`. +The isolated virtual-refresh resolver needed repair and the actual built +stylesheet supplied styling; one accepted allowance preceded a stale-node +tool failure and was not replayed. Network-idle timed out; direct fresh control +and state observations prove both paths. Types/lint and66/66 across five +affected unit files pass (44 new native consumer cases plus one quote case); +build passes with unchanged deprecation/chunk/route-classification warnings. +Initial feedback removed superseded guards, copy-only renderer pins and an +incorrect unchanged-wallet expectation across legitimate Advanced Collection; +fixture narrowing was repaired, not game rules or bots. +No deployed, save/recovery, whole-game or full-mode acceptance follows. + **Semuta / native battle-reward cancellation — 13 October.** Original Advanced Harkonnen capture, CHOAM Auditor and quoted force-income printed Karama costs now offer before denial. Six positive before programs diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 4fd71ea1..5c58967e 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,63 @@ # Dune implementation status +## 13 October 2026 — qualifying classic allied Bureaucrat receipts + +Supported classic no-Homeworlds auction and shipment funding now retains each +actual positive private payer share in the original signed internal receipt. +Eligibility is per other payer at least5, excluding trainer and recipient; +gross prices cannot pool below-five shares. Exactly2 goes to Bank from one +gross recipient credit, including both-five. Original full debits, committed +cards/forces, Karama-before-credit ordering and physical once-per-phase use +remain. A canceled gross receipt creates no Bureaucrat offer/use. +Single-payer receipts gain no new signature fields. New private receipts project +public gross/payee with payer null, never shares or selected qualifying payer; +the owned control explains unchanged contribution costs. Existing legal bot +choices need only recipient/alliance, not private payer details. +Qualifying expansion-faction funding and split-recipient Homeworld cases keep +their integration guards; gifts remain explicitly excluded. + +The new shared fixture preserves native five-seat classic armies and wallets. +Actual first-turn bribe3, original Mentat collection, second-turn worm and +mutual alliance fund real Fremen pledges. Only conserved unplayed card ordering +or custody is staged. Both modes and all three qualifying splits rejected +immutably before at the exact old guard: six shipment plus six auction programs. +The former threshold fixture folded original board armies into reserves; +its duplicated below-five and superseded guard tests are deleted, not repinned. + +Children each exercised one actual after scenario: five-plus-five shipment in +both modes (G5/6→13/14 on redirect,15/16 on decline, unchanged on cancellation; +H2/4,F1,B10/12 and native ten-reserve deployment), and Advanced five-plus-five +auction (E10→18,H4,F1,B12, original purchased treachery-6 before choice and +one native treachery-11 bonus afterwards). Parent actual programs prove +fully allied-funded five in both modes and families: H pays zero/F6→1; +Guild credits3 or Emperor10→13, original five shipped reserves or purchase/ +one bonus remain. No wallet, phase, force or alliance is assigned. + +Two actual390px GameTable flows use original income allowance and new owned +control. Basic five-plus-three redirects2: G5→11,H2,F3,B10, original eight +reserves commit beside Carthag10, Finish hands Movement to Fremen once. +Advanced three-plus-five allows8: E10→18,H6,F1,B12; original one purchase +and one bonus precede original next-lot inspection allowance, native lot1/F. +Artifact: `/tmp/dune-native-bureaucrat-3225.json`. +The isolated host initially failed its virtual React-refresh self import; +the resolver was corrected. Native styling came from the actual new app +stylesheet. One stale accessibility-node failure followed accepted allowance; +authoritative state was inspected without replay. A generic network-idle +wait timed out; direct controls and fresh state/screenshots supplied proof. +No production or saved-game/full-mode claim follows. + +Initial frozen feedback ran69 cases:67 passed, an obsolete qualifying guard +and an incorrect unchanged-wallet expectation across native Advanced Collection +failed. Both implementation/incidental pins were removed, not repinned. +Four fixture typing issues were repaired; two copy-only renderer tests and dead +image-alias hooks were removed. The repaired focused two-case iteration passes; +types pass. Final focused union66/66 across five files, types/lint and app build +pass with unchanged deprecation, chunk-size and route-classification warnings. +The new native consumer files register44 cases plus one contributor-quote +regression. Original aid bounds, owner/payee exclusions, cancellation, stale +choices, original card/force costs, four legal bots and real phase refresh +are covered. Broader assurance remains deferred until all rules. + ## 13 October 2026 — original Homeworld battle-reward Karama costs Only existing `karamaBattleDiscard` is newly admitted by the Homeworld diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index a34073cb..86b01e49 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 are not aggregated to create eligibility and proceed without an offer. Bribes trigger at transfer to the recipient's shield, not later collection. Native income follows Karama allowance as an explicit outcome-based inference; individually qualifying split contributions, 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 no-Homeworlds auction/shipment funding, including a fully qualifying ally, now offers 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. Native income follows Karama allowance as an outcome-based inference; canceled gross income creates no offer/use. Qualifying expansion-faction funding, 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 933328e6..fb19254a 100644 --- a/game/bureaucrat-payment.ts +++ b/game/bureaucrat-payment.ts @@ -9,6 +9,9 @@ export type BureaucratPaymentSource = { payer: string; payee: string; amount: number; + /** Exact internal allied-payer receipts; never projected as public shares. */ + contributions?: readonly { payer: string; amount: number }[]; + privateFunding?: true; signature: string; }; /** The one physical skill card's use survives death, redraw and controller changes. */ @@ -23,7 +26,7 @@ export type BureaucratPaymentView = { pending: null | { event: string; owner: string; - payer: string; + payer: string | null; payee: string; amount: number; kind: BureaucratPaymentKind; @@ -55,6 +58,7 @@ export function quoteBureaucratPayment(input: { payer: string; payee: string; amount: number; + contributions?: readonly { payer: string; amount: number }[]; turn: number; phase: number; used: readonly BureaucratPaymentUse[]; @@ -73,11 +77,30 @@ export function quoteBureaucratPayment(input: { throw new BureaucratPaymentError( 'Bureaucrat needs a valid actual player payment.', ); + let eligible = input.owner !== input.payer && input.payer !== input.payee && + input.amount >= 5; + if (input.contributions) { + let total = 0; + eligible = false; + for (let index = 0; index < input.contributions.length; index++) { + const contribution = input.contributions[index]; + if (!contribution.payer || !Number.isSafeInteger(contribution.amount) || + contribution.amount <= 0) + throw new BureaucratPaymentError('Bureaucrat needs distinct actual positive payer contributions.'); + for (let prior = 0; prior < index; prior++) { + if (input.contributions[prior].payer === contribution.payer) + throw new BureaucratPaymentError('Bureaucrat needs distinct actual positive payer contributions.'); + } + total += contribution.amount; + if (contribution.amount >= 5 && contribution.payer !== input.owner && + contribution.payer !== input.payee) eligible = true; + } + if (!Number.isSafeInteger(total) || total !== input.amount) + throw new BureaucratPaymentError('Bureaucrat contributions must equal the actual recipient payment.'); + } return input.owner && - input.owner !== input.payer && input.owner !== input.payee && - input.payer !== input.payee && - input.amount >= 5 && + eligible && !bureaucratUsed(input.used, input.turn, input.phase) ? { redirect: 2, income: input.amount - 2 } : null; diff --git a/game/engine.ts b/game/engine.ts index e4bc7c25..738737b5 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -8449,7 +8449,7 @@ function nativeBureaucrat(g: Game) { getPlayer(g,a.owner).leaders.some(l => l.id === a.leader && !l.dead && !l.capturedBy && !l.gholaBy) && !g.battle?.leaderSkillHidden?.[a.owner]); } -function bureaucratPaymentQuote(g: Game,source: Pick) { +function bureaucratPaymentQuote(g: Game,source: Pick) { const owner = nativeBureaucrat(g)?.owner ?? null; return quoteBureaucratPayment({...source,owner,turn:g.turn,phase:g.phase,used:g.bureaucratPayments?.used ?? []}); } @@ -8460,15 +8460,29 @@ function checkBureaucratContributors(g: Game,payee:string|undefined,shares:{paye if (!owner || owner === payee || bureaucratUsed(g.bureaucratPayments?.used ?? [],g.turn,g.phase) || !paid.some(share => share.amount >= 5 && share.payer !== owner && share.payer !== payee)) return; requireRule(bureaucratPaymentModeSupported(g),'Bureaucrat with other optional modules is still being integrated.'); - requireRule(paid.length === 1 && !privateFunding,'Bureaucrat on allied funding or multiple payer contributions is awaiting private payment-split integration.'); + requireRule(paid.length === 1 && !privateFunding || !g.homeworlds && !g.expansions.length, + 'Bureaucrat on allied funding or multiple payer contributions with Homeworlds or expansion factions is awaiting integration.'); } -function stampBureaucratPayment(g: Game,kind:BureaucratPaymentSource['kind'],payer:string,payee:string,amount:number): +function stampBureaucratPayment(g: Game,kind:BureaucratPaymentSource['kind'],payer:string,payee:string,amount:number, + contributions?:BureaucratPaymentSource['contributions']): {bureaucratPayment?:BureaucratPaymentSource;bureaucratPaymentEvent?:string} { if (!g.leaderSkills || !bureaucratPaymentModeSupported(g) || amount < 5 || payer === payee) return {}; - const source:BureaucratPaymentSource = {event:crypto.randomUUID(),turn:g.turn,phase:g.phase,kind,payer,payee,amount,signature:''}; + const source:BureaucratPaymentSource = {event:crypto.randomUUID(),turn:g.turn,phase:g.phase,kind,payer,payee,amount, + ...(contributions ? {contributions,privateFunding:true as const} : {}),signature:''}; source.signature = bureaucratPaymentSignature(source); return {bureaucratPayment:source,bureaucratPaymentEvent:source.event}; } +function stampBureaucratContributors(g:Game,kind:'auction'|'shipment',payee:string, + shares:{payer:string;amount:number}[],privateFunding:boolean) { + const paid = shares.filter(share => share.amount > 0); + if (!privateFunding && paid.length === 1) + return stampBureaucratPayment(g,kind,paid[0].payer,payee,paid[0].amount); + if (g.homeworlds || g.expansions.length) return {}; + const owner = nativeBureaucrat(g)?.owner; + const qualifying = paid.find(share => share.amount >= 5 && share.payer !== owner && share.payer !== payee); + return qualifying ? stampBureaucratPayment(g,kind,qualifying.payer,payee, + paid.reduce((total,share) => total + share.amount,0),paid) : {}; +} function bureaucratPaymentFrame(g: Game): string { return JSON.stringify({turn:g.turn,phase:g.phase,active:g.active,order:g.order, players:g.players.map(p => ({id:p.id,spice:p.spice,bribes:p.bribes,hand:p.hand,leaders:p.leaders,forces:p.forces, @@ -8484,6 +8498,15 @@ function validateBureaucratSource(g: Game,source:BureaucratPaymentSource) { g.players.some(p => p.id === source.payer) && g.players.some(p => p.id === source.payee) && source.payer !== source.payee && Number.isSafeInteger(source.amount) && source.amount >= 5 && source.signature === bureaucratPaymentSignature(source), 'The saved Bureaucrat payment changed its actual payer, payee, amount or event.'); + if (source.contributions) { + requireRule(!g.homeworlds && !g.expansions.length && source.privateFunding === true && + (source.kind === 'auction' || source.kind === 'shipment') && + source.contributions.length >= 1 && source.contributions.length <= 2 && + source.contributions.every(share => g.players.some(player => player.id === share.payer)) && + source.contributions.some(share => share.payer === source.payer && share.amount >= 5), + 'The saved Bureaucrat payment lost its original allied contributors.'); + bureaucratPaymentQuote(g,source); + } else requireRule(!source.privateFunding,'The private Bureaucrat payment lost its actual payer contributions.'); } function bureaucratPaymentIntegrity(g: Game) { const state = g.bureaucratPayments,events = g.bureaucratUseEvents ?? []; @@ -8561,7 +8584,8 @@ function projectedBureaucrat(g: Game,viewer: string): BureaucratPaymentView { const pending = g.bureaucratPayments?.pending; const privateRevival = pending?.source.kind === 'revival' && ![pending.owner,pending.source.payer,pending.source.payee,pending.resume.response?.recipient].includes(viewer); - return {pending:pending && !privateRevival ? {event:pending.source.event,owner:pending.owner,payer:pending.source.payer,payee:pending.source.payee, + return {pending:pending && !privateRevival ? {event:pending.source.event,owner:pending.owner, + payer:pending.source.privateFunding ? null : pending.source.payer,payee:pending.source.payee, amount:pending.source.amount,kind:pending.source.kind,redirect:2} : null, usedThisPhase:bureaucratUsed(g.bureaucratPayments?.used ?? [],g.turn,g.phase)}; } @@ -12246,7 +12270,7 @@ function auctionBureaucratParties(g:Game,p:Player,amount:number,allyPayment:numb } function stampAuctionBureaucrat(g:Game,p:Player,amount:number,allyPayment:number,seller?:string) { const {payee,shares} = auctionBureaucratParties(g,p,amount,allyPayment,seller); - return payee && !allyPayment && shares.length === 1 ? stampBureaucratPayment(g,'auction',shares[0].payer,payee,amount) : {}; + return payee ? stampBureaucratContributors(g,'auction',payee,shares,allyPayment > 0) : {}; } function auctionNext(g: Game) { const a = g.auction!; @@ -16578,7 +16602,8 @@ function guildPaymentResponse(g: Game, owner: string, amounts: number[],payer?:s return { kind: 'guildIncome', owner, amount, passed: [], ...(g.homeworldOccupationPreview ? { occupiedPayment: { event: crypto.randomUUID(), contributions: named ?? [{ payer: payer!, amount }] } } : {}), - ...(contributions.length === 1 && payer ? stampBureaucratPayment(g,'shipment',payer,owner,amount) : {}), + ...(named ? stampBureaucratContributors(g,'shipment',owner,named,!payer) + : contributions.length === 1 && payer ? stampBureaucratPayment(g,'shipment',payer,owner,amount) : {}), ...(g.homeworlds?.custody ? { guildContributions: contributions, guildPaymentProof: guildPaymentSignature(g, owner, amount, contributions) } : {}) }; } diff --git a/game/reference.ts b/game/reference.ts index 1c73ef14..2d38142e 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, do not qualify merely because their total exceeds five and may proceed through original payment controls. Individually qualifying split funding remains guarded. 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 games without Homeworlds additionally connect qualifying allied-funded auctions and shipments, including fully ally-funded five: one optional two-spice diversion from the gross recipient receipt, unchanged full contributor costs and original Karama income cancellation first. 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 expansion-faction funding and split-recipient Homeworld income remain guarded. 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-payment-controls.test.tsx b/tests/bureaucrat-payment-controls.test.tsx index bc6fc84b..67c0670d 100644 --- a/tests/bureaucrat-payment-controls.test.tsx +++ b/tests/bureaucrat-payment-controls.test.tsx @@ -1,19 +1,10 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { registerHooks } from 'node:module'; import { createElement } from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; import { BureaucratPayment } from '../components/bureaucrat-payment'; import type { GameView } from '../game/engine'; -const aliases = registerHooks({ - resolve(specifier, context, next) { - return next( - specifier === 'next/image' ? 'vinext/shims/image' : specifier, - context, - ); - }, -}); function view(overrides: Record = {}) { return { @@ -50,15 +41,6 @@ function markup(game: GameView, busy = false) { ); } -void test('owned matching Bureaucrat decision shows both payment costs and public names', () => { - const html = markup(view({ me: 'owner' })); - assert.match(html, /payment from House Payer to House Payee is 5 spice/); - assert.match(html, /House Payee receives 3 spice/); - assert.match(html, /Bank receives 2/); - assert.match(html, /still pays the full amount/); - assert.match(html, /Redirect 2 spice to the Bank/); - assert.match(html, /Allow full 5-spice payment/); -}); void test('non-owner and stale decisions render no Bureaucrat controls', () => { assert.equal(markup(view()), ''); @@ -73,13 +55,3 @@ void test('busy Bureaucrat controls are disabled', () => { assert.ok((html.match(/disabled=""/g) ?? []).length >= 2); }); -void test('bribe redirection keeps the remaining payment unavailable until Mentat', () => { - const game = view({ me: 'owner' }); - game.bureaucrat.pending!.kind = 'bribe'; - const html = markup(game); - assert.match(html, /recipient keeps this spice in front of their shield until the next Mentat Pause/); - assert.match(html, /cannot be spent before then/); - assert.match(html, /House Payee receives 3 spice/); -}); - -aliases.deregister(); diff --git a/tests/bureaucrat-payment.test.ts b/tests/bureaucrat-payment.test.ts index 5b6dfbb3..f61dbedf 100644 --- a/tests/bureaucrat-payment.test.ts +++ b/tests/bureaucrat-payment.test.ts @@ -121,6 +121,26 @@ void test('Bureaucrat requires two other players and tracks the unique physical ); }); +void test('allied receipts qualify per other payer, redirect only two once, and reject invalid contribution totals', () => { + const base = {owner:'b',payer:'p',payee:'g',amount:8,turn:2,phase:5,used:[]}; + const contributions = [{payer:'p',amount:3},{payer:'f',amount:5}]; + assert.deepEqual(quoteBureaucratPayment({...base,contributions}), {redirect:2,income:6}); + assert.equal(quoteBureaucratPayment({...base,owner:'f',contributions}), null); + assert.deepEqual(quoteBureaucratPayment({...base,owner:'p',contributions}), {redirect:2,income:6}); + assert.equal(quoteBureaucratPayment({...base,payee:'b',contributions}), null); + assert.equal(quoteBureaucratPayment({...base,amount:6, + contributions:[{payer:'p',amount:3},{payer:'f',amount:3}]}), null); + assert.deepEqual(quoteBureaucratPayment({...base,amount:10, + contributions:[{payer:'p',amount:5},{payer:'f',amount:5}]}), {redirect:2,income:8}); + assert.deepEqual(quoteBureaucratPayment({...base,payer:'f',amount:5, + contributions:[{payer:'f',amount:5}]}), {redirect:2,income:3}); + for (const invalid of [ + [{payer:'p',amount:3},{payer:'f',amount:4}], + [{payer:'p',amount:3},{payer:'p',amount:5}], + [{payer:'p',amount:3},{payer:'f',amount:-1}], + ]) assert.throws(() => quoteBureaucratPayment({...base,contributions:invalid})); +}); + void test('Emperor gifts keep private amounts and do not consume Bureaucrat before a later qualifying payment', () => { let game = bureaucratPaymentGame({ advanced: true }); bureaucratPlayer(game, 'e').ally = 'g'; @@ -294,22 +314,6 @@ void test('a real simultaneous Bene Gesserit advisor choice survives the deferre assert.deepEqual(done.decision, original); assert.equal(bureaucratPlayer(done, 'g').spice, 33); }); -void test('individually qualifying mixed or fully allied-funded payments retain the integration guard before commitment', () => { - for (const allyPayment of [1, 6]) { - let g = bureaucratPaymentGame(); - bureaucratPlayer(g, 'p').ally = 'e'; - bureaucratPlayer(g, 'e').ally = 'p'; - g = applyAction(g, 'e', { type: 'pledgeAid', amount: 6 }); - reject(g, 'p', { ...ship, amount: 6, allyPayment }); - g = bureaucratPaymentGame(); - bureaucratPlayer(g, 'p').ally = 'g'; - bureaucratPlayer(g, 'g').ally = 'p'; - g = beginBureaucratAuction(g); - g = applyAction(g, 'g', { type: 'pledgeAid', amount: 6 }); - g = applyAction(g, 'b', { type: 'passBid' }); - reject(g, 'p', { type: 'bid', amount: 6, allyPayment }); - } -}); void test('saved Bureaucrat receipt, source, unique decision, continuation and phase-use mutations reject immutably before reads', () => { const pending = applyAction(bureaucratPaymentGame(), 'p', ship), done = choose(pending); diff --git a/tests/bureaucrat-qualifying-auction.test.ts b/tests/bureaucrat-qualifying-auction.test.ts new file mode 100644 index 00000000..ef135403 --- /dev/null +++ b/tests/bureaucrat-qualifying-auction.test.ts @@ -0,0 +1,262 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { applyAction, handLimit, viewGame, type Action, type Game } from '../game/engine'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { advanceBureaucratQualifyingSplit } from './fixture-bureaucrat-qualifying-split'; +import { + createBureaucratQualifyingAuctionFixture, payBureaucratQualifyingAuction, + allowQualifyingAuctionResponses, chooseQualifyingAuction, qualifyingAuctionPlayer, + qualifyingAuctionPhysicalCards, type BureaucratQualifyingAuctionFixture, +} from './fixture-bureaucrat-qualifying-auction'; + +function rejectUnchanged(game: Game, actor: string, action: Action) { + const before = JSON.stringify(game); + assert.throws(() => applyAction(game, actor, action)); + assert.equal(JSON.stringify(game), before); +} +function assertPaid(fixture: BureaucratQualifyingAuctionFixture, game: Game) { + const payer = qualifyingAuctionPlayer(game, fixture.payer), donor = qualifyingAuctionPlayer(game, fixture.donor); + assert.equal(payer.spice, qualifyingAuctionPlayer(fixture.beforePledge, fixture.payer).spice - fixture.payerShare); + assert.equal(donor.spice, qualifyingAuctionPlayer(fixture.beforePledge, fixture.donor).spice - fixture.donorShare); + assert.equal(game.aid[fixture.donor].amount, 0); + assert.equal(payer.hand.length, qualifyingAuctionPlayer(fixture.beforeAction, fixture.payer).hand.length + 1); + assert.equal(payer.hand.filter(card => card.id === fixture.reservedCard).length, 1); + assert.equal(qualifyingAuctionPlayer(game, fixture.emperor).spice, qualifyingAuctionPlayer(fixture.beforeAction, fixture.emperor).spice); + assert.deepEqual(qualifyingAuctionPhysicalCards(game), fixture.physical); + assert.equal(game.auction!.index, fixture.beforeAction.auction!.index); + assert.deepEqual(game.auction!.cards, fixture.sourceSetup.auction!.cards); + for (const original of fixture.beforeAction.players) { + const actual = qualifyingAuctionPlayer(game, original.id); + assert.deepEqual(actual.forces, original.forces); + assert.equal(actual.reserves, original.reserves); + } +} +function assertCompleted(fixture: BureaucratQualifyingAuctionFixture, pending: Game, done: Game, diverted: number) { + assert.equal(qualifyingAuctionPlayer(done, fixture.emperor).spice, + qualifyingAuctionPlayer(fixture.beforeAction, fixture.emperor).spice + fixture.amount - diverted); + for (const actor of [fixture.payer, fixture.donor, fixture.trainer]) + assert.equal(qualifyingAuctionPlayer(done, actor).spice, qualifyingAuctionPlayer(pending, actor).spice); + const payer = qualifyingAuctionPlayer(done, fixture.payer); + assert.deepEqual(payer.hand.map(card => card.id), [ + ...qualifyingAuctionPlayer(pending, fixture.payer).hand.map(card => card.id), fixture.bonusCard, + ]); + assert.deepEqual(done.deck.map(card => card.id), pending.deck.slice(1).map(card => card.id)); + assert.deepEqual(qualifyingAuctionPhysicalCards(done), fixture.physical); + assert.equal(done.currentAuctionSale, null); + assert.equal(done.auction!.index, fixture.beforeAction.auction!.index + 1); + assert.equal(done.auction!.cards[done.auction!.index].id, fixture.beforeAction.auction!.cards[fixture.beforeAction.auction!.index + 1].id); + assert.equal(done.auction!.bid, 0); + assert.equal(done.auction!.bidder, null); + assert.equal(done.bureaucratPayments?.used.length ?? 0, diverted ? 1 : 0); + assert.equal(viewGame(done, fixture.trainer).bureaucrat.usedThisPhase, diverted === 2); +} + +for (const advanced of [false, true]) { + for (const [payerShare, donorShare] of [[5, 3], [3, 5], [5, 5], [0, 5]]) { + for (const redirect of [true, false]) { + void test(`${advanced ? 'Advanced' : 'Basic'} native ordinary auction ${payerShare}+${donorShare}: ${redirect ? 'Bank two' : 'full Emperor income'} and one Harkonnen bonus`, () => { + const fixture = createBureaucratQualifyingAuctionFixture({ advanced, payerShare, donorShare }); + const paid = payBureaucratQualifyingAuction(fixture); + assertPaid(fixture, paid); + const pending = allowQualifyingAuctionResponses(paid); + assert.equal(pending.decision?.kind, 'bureaucratPayment'); + assertPaid(fixture, pending); + const done = allowQualifyingAuctionResponses(chooseQualifyingAuction(JSON.parse(JSON.stringify(pending)) as Game, redirect)); + assertCompleted(fixture, pending, done, redirect ? 2 : 0); + rejectUnchanged(done, fixture.trainer, { type: 'decision', event: pending.bureaucratPaymentEvent, redirect: true }); + }); + } + } + for (const [payerShare, donorShare] of [[3, 3], [4, 3]]) { + void test(`${advanced ? 'Advanced' : 'Basic'} native ordinary auction ${payerShare}+${donorShare} cannot pool sub-five shares`, () => { + const fixture = createBureaucratQualifyingAuctionFixture({ advanced, payerShare, donorShare }); + const paid = payBureaucratQualifyingAuction(fixture); + assertPaid(fixture, paid); + const done = allowQualifyingAuctionResponses(paid); + assertCompleted(fixture, paid, done, 0); + assert.equal(viewGame(done, fixture.trainer).bureaucrat.pending, null); + }); + } + void test(`${advanced ? 'Advanced' : 'Basic'} original Emperor Karama cancellation banks the whole allied invoice, not just the primary contribution`, () => { + const fixture = createBureaucratQualifyingAuctionFixture({ advanced, payerShare: 5, donorShare: 5, counter: true }); + const paid = payBureaucratQualifyingAuction(fixture); + assertPaid(fixture, paid); + assert.equal(paid.response?.kind, 'emperorIncome'); + assert.equal(viewGame(paid, fixture.trainer).bureaucrat.pending, null); + const canceled = applyAction(paid, fixture.trainer, { type: 'card', mode: 'cancel', card: fixture.counterCard }); + const done = allowQualifyingAuctionResponses(canceled); + assert.equal(qualifyingAuctionPlayer(done, fixture.emperor).spice, qualifyingAuctionPlayer(fixture.beforeAction, fixture.emperor).spice); + assert.equal(qualifyingAuctionPlayer(done, fixture.payer).spice, qualifyingAuctionPlayer(paid, fixture.payer).spice); + assert.equal(qualifyingAuctionPlayer(done, fixture.donor).spice, qualifyingAuctionPlayer(paid, fixture.donor).spice); + assert.equal(qualifyingAuctionPlayer(done, fixture.payer).hand.length, qualifyingAuctionPlayer(paid, fixture.payer).hand.length + 1); + assert.equal(done.discard.filter(card => card.id === fixture.counterCard).length, 1); + assert.deepEqual(qualifyingAuctionPhysicalCards(done), fixture.physical); + assert.equal(done.auction!.index, 1); + assert.equal(done.bureaucratPayments?.used.length ?? 0, 0); + assert.equal(viewGame(done, fixture.trainer).bureaucrat.pending, null); + }); +} + +void test('equal public eight-spice invoices conceal which allied contribution qualified from Bureaucrat and outside audiences', () => { + const primary = createBureaucratQualifyingAuctionFixture({ payerShare: 5, donorShare: 3 }); + const ally = createBureaucratQualifyingAuctionFixture({ payerShare: 3, donorShare: 5 }); + const a = allowQualifyingAuctionResponses(payBureaucratQualifyingAuction(primary)); + const b = allowQualifyingAuctionResponses(payBureaucratQualifyingAuction(ally)); + for (const viewer of [primary.trainer, primary.guild, primary.emperor]) { + const left = viewGame(a, viewer), right = viewGame(b, viewer); + assert.equal(left.bureaucrat.pending!.payer, null); + assert.equal(left.bureaucrat.pending!.amount, 8); + assert.equal(left.bureaucrat.pending!.payee, primary.emperor); + assert.equal(left.auction!.bid, 8); + assert.equal(left.auction!.bidder, primary.payer); + assert.equal(left.auction!.allyPayment, undefined); + const { event: leftEvent, ...leftPayment } = left.bureaucrat.pending!; + const { event: rightEvent, ...rightPayment } = right.bureaucrat.pending!; + assert.notEqual(leftEvent, rightEvent); + assert.deepEqual(leftPayment, rightPayment); + assert.equal('contributions' in leftPayment, false); + assert.equal('contributions' in rightPayment, false); + assert.deepEqual(left.log.slice(primary.beforePledge.log.length).map(entry => entry.text), + right.log.slice(ally.beforePledge.log.length).map(entry => entry.text)); + } +}); + +void test('a real original pledge is private funding, not a gift or a second income/payment action', () => { + const fixture = createBureaucratQualifyingAuctionFixture({ payerShare: 3, donorShare: 5 }); + assert.equal(qualifyingAuctionPlayer(fixture.pledged, fixture.donor).spice, 1); + assert.equal(qualifyingAuctionPlayer(fixture.pledged, fixture.payer).spice, qualifyingAuctionPlayer(fixture.beforePledge, fixture.payer).spice); + assert.equal(qualifyingAuctionPlayer(fixture.pledged, fixture.payer).bribes, 0); + assert.equal(fixture.pledged.aid[fixture.donor].amount, 5); + assert.equal(fixture.pledged.decision, null); + assert.equal(fixture.pledged.response, null); + assert.equal(fixture.pledged.bureaucratPayments?.used.length ?? 0, 0); + const pending = allowQualifyingAuctionResponses(payBureaucratQualifyingAuction(fixture)); + assert.equal(pending.decision?.kind, 'bureaucratPayment'); + assertPaid(fixture, pending); +}); + +void test('stale and cross-actor choices cannot spend, refund, duplicate, or modify the reserved ordinary lot', () => { + const fixture = createBureaucratQualifyingAuctionFixture(); + const bid = applyAction(fixture.game, fixture.payer, fixture.action); + rejectUnchanged(bid, fixture.donor, { type: 'pledgeAid', amount: fixture.donorShare - 1 }); + const pending = allowQualifyingAuctionResponses(payBureaucratQualifyingAuction(fixture)); + rejectUnchanged(pending, fixture.donor, { type: 'decision', event: pending.bureaucratPaymentEvent, redirect: true }); + rejectUnchanged(pending, fixture.trainer, { type: 'decision', event: 'old-auction-invoice', redirect: true }); + rejectUnchanged(pending, fixture.payer, { type: 'bid', amount: 1 }); + rejectUnchanged(pending, fixture.donor, { type: 'pledgeAid', amount: 0 }); + const done = allowQualifyingAuctionResponses(chooseQualifyingAuction(pending)); + assertCompleted(fixture, pending, done, 2); +}); + +void test('an original purchase using the last hand space has no premature or overflow Harkonnen bonus', () => { + const fixture = createBureaucratQualifyingAuctionFixture({ lastHandSpace: true }); + const pending = allowQualifyingAuctionResponses(payBureaucratQualifyingAuction(fixture)); + assertPaid(fixture, pending); + assert.equal(qualifyingAuctionPlayer(pending, fixture.payer).hand.length, handLimit(qualifyingAuctionPlayer(pending, fixture.payer))); + const done = allowQualifyingAuctionResponses(chooseQualifyingAuction(pending)); + assert.deepEqual(qualifyingAuctionPlayer(done, fixture.payer).hand, qualifyingAuctionPlayer(pending, fixture.payer).hand); + assert.deepEqual(done.deck, pending.deck); + assert.equal(qualifyingAuctionPlayer(done, fixture.emperor).spice, qualifyingAuctionPlayer(pending, fixture.emperor).spice + 6); + assert.deepEqual(qualifyingAuctionPhysicalCards(done), fixture.physical); + assert.equal(done.auction!.index, 1); + assert.equal(done.bureaucratPayments!.used.length, 1); +}); + +for (const difficulty of DIFFICULTIES) { + void test(`${difficulty} Bureaucrat bot resolves the actual concealed allied invoice and hands control to the native next lot`, () => { + const fixture = createBureaucratQualifyingAuctionFixture({ payerShare: 5, donorShare: 5 }); + const pending = allowQualifyingAuctionResponses(payBureaucratQualifyingAuction(fixture)); + const view = viewGame(pending, fixture.trainer); + view.players.find(player => player.id === fixture.trainer)!.bot = difficulty; + const action = botActions(view)[0]; + const done = allowQualifyingAuctionResponses(applyAction(pending, fixture.trainer, action)); + assertCompleted(fixture, pending, done, action.redirect ? 2 : 0); + const next = advanceBureaucratQualifyingSplit(done, game => !game.decision && !game.response && !!game.auction?.peekKnown); + assert.equal(next.auction!.index, 1); + assert.equal(next.active, next.auction!.active); + assert.ok(next.order.includes(next.active!)); + assert.equal(viewGame(next, fixture.trainer).bureaucrat.pending, null); + }); +} + +void test('one split use covers both qualifying shares, blocks a later same-phase native payment, and refreshes only at a real phase boundary', () => { + const fixture = createBureaucratQualifyingAuctionFixture({ payerShare: 5, donorShare: 5 }); + const pending = allowQualifyingAuctionResponses(payBureaucratQualifyingAuction(fixture)); + let game = allowQualifyingAuctionResponses(chooseQualifyingAuction(pending)); + const use = structuredClone(game.bureaucratPayments!.used); + const secondLot = game.auction!.cards[1].id; + game = advanceBureaucratQualifyingSplit(game, state => !!state.auction && state.auction.active === fixture.guild && !state.decision && !state.response); + const guildBefore = qualifyingAuctionPlayer(game, fixture.guild).spice; + const emperorBefore = qualifyingAuctionPlayer(game, fixture.emperor).spice; + game = applyAction(game, fixture.guild, { type: 'bid', amount: 5 }); + for (let n = 0; game.auction?.index === 1 && !game.response && !game.decision && n < game.players.length + 2; n++) + game = applyAction(game, game.auction!.active, { type: 'passBid' }); + if (game.decision?.kind === 'auctionPayment') game = applyAction(game, fixture.guild, { type: 'decision', karama: false }); + game = allowQualifyingAuctionResponses(game); + assert.equal(viewGame(game, fixture.trainer).bureaucrat.pending, null); + assert.deepEqual(game.bureaucratPayments!.used, use); + assert.equal(qualifyingAuctionPlayer(game, fixture.guild).spice, guildBefore - 5); + assert.equal(qualifyingAuctionPlayer(game, fixture.guild).hand.filter(card => card.id === secondLot).length, 1); + assert.equal(qualifyingAuctionPlayer(game, fixture.emperor).spice, emperorBefore + 5); + assert.equal(game.auction!.index, 2); + game = advanceBureaucratQualifyingSplit(game, state => state.phase === 5 && !state.phaseOpening && !state.response && !state.decision); + assert.equal(game.turn, pending.turn); + assert.equal(viewGame(game, fixture.trainer).bureaucrat.usedThisPhase, false); + const fresh = applyAction(game, fixture.emperor, { type: 'bribe', target: fixture.guild, amount: 5 }); + assert.equal(fresh.decision?.kind, 'bureaucratPayment'); + assert.notEqual(fresh.bureaucratPaymentEvent, pending.bureaucratPaymentEvent); + const done = chooseQualifyingAuction(fresh); + assert.equal(qualifyingAuctionPlayer(done, fixture.guild).bribes, 3); + assert.equal(done.bureaucratPayments!.used.length, 2); + assert.equal(viewGame(done, fixture.trainer).bureaucrat.usedThisPhase, true); + assert.deepEqual(qualifyingAuctionPhysicalCards(done), fixture.physical); +}); + +for (const donor of ['b', 'e'] as const) { + for (const [payerShare, donorShare] of [[3, 5], [5, 3]]) { + void test(`native worm alliance with ${donor === 'b' ? 'Bureaucrat owner' : 'Emperor payee'} excludes only that contributor's threshold`, () => { + const fixture = createBureaucratQualifyingAuctionFixture({ donor, payerShare, donorShare }); + assert.equal(qualifyingAuctionPlayer(fixture.alliance, fixture.payer).ally, donor); + assert.equal(qualifyingAuctionPlayer(fixture.alliance, donor).ally, fixture.payer); + const paid = payBureaucratQualifyingAuction(fixture); + assertPaid(fixture, paid); + const allowed = allowQualifyingAuctionResponses(paid); + const qualifies = payerShare === 5; + assert.equal(allowed.decision?.kind, qualifies ? 'bureaucratPayment' : undefined); + const done = qualifies ? allowQualifyingAuctionResponses(chooseQualifyingAuction(allowed)) : allowed; + assert.equal(qualifyingAuctionPlayer(done, fixture.payer).spice, + qualifyingAuctionPlayer(fixture.beforePledge, fixture.payer).spice - payerShare); + assert.equal(qualifyingAuctionPlayer(done, fixture.emperor).spice, + qualifyingAuctionPlayer(fixture.beforePledge, fixture.emperor).spice - (donor === 'e' ? donorShare : 0) + + fixture.amount - (qualifies ? 2 : 0)); + if (donor === 'b') assert.equal(qualifyingAuctionPlayer(done, donor).spice, + qualifyingAuctionPlayer(fixture.beforePledge, donor).spice - donorShare); + assert.equal(done.bureaucratPayments?.used.length ?? 0, qualifies ? 1 : 0); + assert.equal(qualifyingAuctionPlayer(done, fixture.payer).hand.filter(card => card.id === fixture.reservedCard).length, 1); + assert.equal(qualifyingAuctionPlayer(done, fixture.payer).hand.filter(card => card.id === fixture.bonusCard).length, 1); + assert.equal(done.auction!.index, 1); + assert.deepEqual(qualifyingAuctionPhysicalCards(done), fixture.physical); + }); + } +} + +void test('declining a native split invoice leaves the original card available for a distinct payment in the same Bidding phase', () => { + const fixture = createBureaucratQualifyingAuctionFixture({ payerShare: 3, donorShare: 5 }); + const pending = allowQualifyingAuctionResponses(payBureaucratQualifyingAuction(fixture)); + const done = allowQualifyingAuctionResponses(chooseQualifyingAuction(pending, false)); + assertCompleted(fixture, pending, done, 0); + const next = advanceBureaucratQualifyingSplit(done, state => !!state.auction?.peekKnown && + !state.phaseOpening && !state.response && !state.decision); + assert.equal(next.auction!.index, 1); + const paid = applyAction(next, fixture.emperor, { type: 'bribe', target: fixture.guild, amount: 5 }); + assert.equal(paid.phase, pending.phase); + assert.equal(paid.decision?.kind, 'bureaucratPayment'); + assert.notEqual(paid.bureaucratPaymentEvent, pending.bureaucratPaymentEvent); + const redirected = chooseQualifyingAuction(paid); + assert.equal(qualifyingAuctionPlayer(redirected, fixture.guild).bribes, 3); + assert.equal(redirected.bureaucratPayments!.used.length, 1); + assert.equal(redirected.auction!.index, 1); + assert.equal(qualifyingAuctionPlayer(redirected, fixture.payer).spice, qualifyingAuctionPlayer(done, fixture.payer).spice); + assert.deepEqual(qualifyingAuctionPhysicalCards(redirected), fixture.physical); +}); diff --git a/tests/bureaucrat-qualifying-shipment.test.ts b/tests/bureaucrat-qualifying-shipment.test.ts new file mode 100644 index 00000000..f23b5227 --- /dev/null +++ b/tests/bureaucrat-qualifying-shipment.test.ts @@ -0,0 +1,247 @@ +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 { + allowBureaucratQualifyingShipmentIncome, createBureaucratQualifyingShipmentFixture, + declareBureaucratQualifyingShipment, +} from './fixture-bureaucrat-qualifying-shipment'; + +const seat = (game: Game, id: string) => game.players.find(p => p.id === id)!; +const physical = (game: Game) => game.players.map(p => ({ id: p.id, forces: p.forces, + reserves: p.reserves, tanks: p.tanks, elites: p.elites, shipped: p.shipped, moved: p.moved })); +const wallets = (game: Game) => game.players.map(p => ({ id: p.id, spice: p.spice, bribes: p.bribes })); +function rejectUnchanged(game: Game, actor: string, action: Action) { + const before = structuredClone(game); + assert.throws(() => applyAction(game, actor, action)); + assert.deepEqual(game, before); +} +function ownerChoice(game: Game, redirect: boolean) { + assert.equal(game.decision?.kind, 'bureaucratPayment'); + return applyAction(structuredClone(game), 'b', { + type: 'decision', event: game.decision!.event, redirect, + }); +} + +void test('native Basic and Advanced allied shipments pay every original leg and divert at most two from Guild income', () => { + for (const advanced of [false, true]) + for (const [primary, donor] of [[5, 3], [3, 5], [5, 5], [0, 5]]) + for (const redirect of [false, true]) { + const f = createBureaucratQualifyingShipmentFixture({ advanced, primary, donor }); + const original = f.beforePledge; + assert.equal(seat(original, 'f').spice, 6); + assert.equal(seat(f.beforeAction, 'f').spice, 6 - donor); + assert.equal(f.beforeAction.aid.f.amount, donor); + assert.deepEqual(physical(f.beforeAction), physical(original)); + const { declared, paid } = declareBureaucratQualifyingShipment(f); + if (declared.decision?.kind === 'guildShipment') { + assert.deepEqual(wallets(declared), wallets(f.beforeAction)); + assert.deepEqual(physical(declared), physical(f.beforeAction)); + assert.equal(declared.decision.player, 'g'); + } + assert.equal(paid.response?.kind, 'guildIncome'); + assert.equal(paid.response?.amount, primary + donor); + assert.equal(seat(paid, 'p').spice, seat(original, 'p').spice - primary); + assert.equal(seat(paid, 'f').spice, 6 - donor); + assert.equal(paid.aid.f.amount, 0); + assert.equal(seat(paid, 'g').spice, advanced ? 6 : 5); + assert.equal(seat(paid, 'p').reserves, 10 - f.amount); + assert.deepEqual(seat(paid, 'p').forces, { 'carthag:11': 10, [f.target]: f.amount }); + assert.equal(seat(paid, 'p').shipped, true); + for (const id of ['b', 'f', 'e', 'g']) { + assert.deepEqual(seat(paid, id).forces, seat(original, id).forces); + assert.equal(seat(paid, id).reserves, seat(original, id).reserves); + } + const pending = allowBureaucratQualifyingShipmentIncome(paid); + assert.equal(pending.decision?.kind, 'bureaucratPayment'); + assert.deepEqual(physical(pending), physical(paid)); + assert.deepEqual(wallets(pending), wallets(paid)); + const after = ownerChoice(pending, redirect); + assert.equal(seat(after, 'g').spice, (advanced ? 6 : 5) + f.amount - (redirect ? 2 : 0)); + for (const id of ['p', 'f', 'b', 'e']) { + assert.equal(seat(after, id).spice, seat(paid, id).spice); + assert.equal(seat(after, id).bribes, seat(paid, id).bribes); + } + assert.equal(after.players.reduce((sum, p) => sum + p.spice, 0), + original.players.reduce((sum, p) => sum + p.spice, 0) - (redirect ? 2 : 0)); + assert.deepEqual(physical(after), physical(paid)); + assert.equal(after.bureaucratPayments?.used.length ?? 0, Number(redirect)); + assert.equal(after.bureaucratPayments?.pending, undefined); + assert.deepEqual(after.deck, pending.deck); + assert.deepEqual(after.discard, pending.discard); + assert.deepEqual(after.players.map(p => p.hand), pending.players.map(p => p.hand)); + rejectUnchanged(after, 'b', { type: 'decision', event: pending.decision!.event, redirect }); + rejectUnchanged(after, 'p', f.action); + const ended = applyAction(structuredClone(after), 'p', { type: 'endMovement' }); + assert.notEqual(ended.active, 'p'); + assert.deepEqual(physical(ended), physical(after)); + rejectUnchanged(ended, 'p', { type: 'endMovement' }); + } +}); + +void test('Karama cancels original gross Guild income after the same shipment and full allied debits, without spending Bureaucrat', () => { + for (const advanced of [false, true]) + for (const [primary, donor] of [[5, 3], [3, 5], [5, 5], [0, 5]]) { + const f = createBureaucratQualifyingShipmentFixture({ advanced, primary, donor }); + const { paid } = declareBureaucratQualifyingShipment(f); + assert.equal(paid.response?.amount, f.amount); + assert.equal(viewGame(paid, f.responder).responseControls?.cancelCards.includes(f.karama.id), true); + const after = applyAction(structuredClone(paid), f.responder, { + type: 'card', mode: 'cancel', card: f.karama.id, + }); + assert.equal(seat(after, 'g').spice, seat(f.beforePledge, 'g').spice); + assert.equal(seat(after, 'p').spice, seat(f.beforePledge, 'p').spice - primary); + assert.equal(seat(after, 'f').spice, 6 - donor); + assert.equal(after.players.reduce((sum, p) => sum + p.spice, 0), + f.beforePledge.players.reduce((sum, p) => sum + p.spice, 0) - f.amount); + assert.deepEqual(physical(after), physical(paid)); + assert.equal(after.discard.filter(card => card.id === f.karama.id).length, 1); + assert.equal(seat(after, f.responder).hand.some(card => card.id === f.karama.id), false); + assert.equal(after.bureaucratPayments?.pending, undefined); + assert.equal(after.bureaucratPayments?.used.length ?? 0, 0); + assert.notEqual(after.decision?.kind, 'bureaucratPayment'); + assert.equal(after.response, null); + rejectUnchanged(after, f.responder, { type: 'card', mode: 'cancel', card: f.karama.id }); + } +}); + +void test('neither pooled sub-five shares nor the native Bureaucrat owner contribution qualifies on its own', () => { + for (const advanced of [false, true]) + for (const options of [ + { primary: 3, donor: 3 }, { primary: 4, donor: 3 }, + { primary: 3, donor: 5, ownerFunding: true }, + ]) { + const f = createBureaucratQualifyingShipmentFixture({ advanced, ...options }); + if (options.ownerFunding) { + assert.equal(seat(f.beforePledge, 'p').ally, 'b'); + assert.equal(seat(f.beforePledge, 'b').ally, 'p'); + assert.equal(seat(f.beforePledge, 'f').ally, null); + } + const { paid } = declareBureaucratQualifyingShipment(f); + const after = allowBureaucratQualifyingShipmentIncome(paid); + assert.notEqual(after.decision?.kind, 'bureaucratPayment'); + assert.equal(after.bureaucratPayments?.used.length ?? 0, 0); + assert.equal(seat(after, 'g').spice, seat(f.beforePledge, 'g').spice + f.amount); + assert.equal(seat(after, 'p').spice, seat(f.beforePledge, 'p').spice - options.primary); + assert.equal(seat(after, f.donor).spice, seat(f.beforePledge, f.donor).spice - options.donor); + assert.deepEqual(physical(after), physical(paid)); + } +}); + +void test('an excluded owner funding leg does not mask the other actual qualifying third-party payer', () => { + for (const advanced of [false, true]) + for (const donor of [3, 5]) { + const f = createBureaucratQualifyingShipmentFixture({ advanced, primary: 5, donor, ownerFunding: true }); + assert.equal(f.donor, 'b'); + assert.equal(seat(f.alliance, 'p').ally, 'b'); + assert.equal(seat(f.alliance, 'b').ally, 'p'); + const { paid } = declareBureaucratQualifyingShipment(f); + const pending = allowBureaucratQualifyingShipmentIncome(paid); + const after = ownerChoice(pending, true); + assert.equal(seat(after, 'b').spice, seat(f.beforePledge, 'b').spice - donor); + assert.equal(seat(after, 'p').spice, seat(f.beforePledge, 'p').spice - 5); + assert.equal(seat(after, 'g').spice, seat(f.beforePledge, 'g').spice + f.amount - 2); + assert.equal(after.bureaucratPayments?.used.length, 1); + assert.deepEqual(physical(after), physical(paid)); + } +}); + +void test('pending allied-funded income exposes public gross and recipient, never which private contribution qualified', () => { + for (const advanced of [false, true]) + for (const [primary, donor] of [[5, 3], [3, 5], [5, 5], [0, 5]]) { + const f = createBureaucratQualifyingShipmentFixture({ advanced, primary, donor }); + const { paid } = declareBureaucratQualifyingShipment(f); + for (const viewer of paid.players) { + const response = viewGame(paid, viewer.id).response; + assert.equal(response?.amount, f.amount); + assert.equal(response?.owner, 'g'); + assert.equal(response?.bureaucratPayment, undefined); + assert.equal(response?.guildContributions, undefined); + assert.equal(response?.occupiedPayment, undefined); + } + const pending = allowBureaucratQualifyingShipmentIncome(paid); + for (const viewer of pending.players) { + const view = viewGame(pending, viewer.id); + const payment = view.bureaucrat.pending; + assert.ok(payment); + assert.equal(payment.amount, f.amount); + assert.equal(payment.payee, 'g'); + assert.equal(payment.payer, null); + assert.equal('contributions' in payment, false); + assert.equal('source' in payment, false); + assert.equal('signature' in payment, false); + assert.equal('bureaucratPayments' in view, false); + assert.equal('bureaucratUseEvents' in view, false); + for (const other of view.players.filter(p => p.id !== viewer.id)) { + assert.equal(other.spice, undefined); + assert.equal(other.hand, undefined); + } + } + for (const id of ['p', 'f', 'e', 'g']) + rejectUnchanged(pending, id, { type: 'decision', event: pending.bureaucratPaymentEvent, redirect: true }); + rejectUnchanged(pending, 'b', { type: 'decision', event: 'stale-income', redirect: true }); + rejectUnchanged(pending, 'b', { type: 'endMovement' }); + rejectUnchanged(pending, 'f', { type: 'pledgeAid', amount: 1 }); + } +}); + +void test('all four minimal Bureaucrat bot responses settle the original private-funded payment and resume native movement', () => { + for (const advanced of [false, true]) + for (const bot of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) { + const f = createBureaucratQualifyingShipmentFixture({ advanced, primary: 5, donor: 5 }); + const pending = allowBureaucratQualifyingShipmentIncome(declareBureaucratQualifyingShipment(f).paid); + const view = viewGame(pending, 'b'); + view.players.find(p => p.id === 'b')!.bot = bot; + const actions = botActions(view); + assert.equal(actions.length, 1); + const after = applyAction(structuredClone(pending), 'b', actions[0]); + assert.equal(seat(after, 'g').spice, seat(f.beforePledge, 'g').spice + 8); + assert.equal(after.bureaucratPayments?.used.length, 1); + assert.deepEqual(physical(after), physical(pending)); + for (const id of ['b', 'p', 'f', 'e']) assert.equal(seat(after, id).spice, seat(pending, id).spice); + const ended = applyAction(after, 'p', { type: 'endMovement' }); + assert.notEqual(ended.active, 'p'); + assert.deepEqual(physical(ended), physical(after)); + rejectUnchanged(ended, 'b', actions[0]); + } +}); + +void test('declining the shipment preserves the physical card for the next real payment, while redirecting spends it once per phase', () => { + for (const advanced of [false, true]) + for (const redirect of [false, true]) { + const f = createBureaucratQualifyingShipmentFixture({ advanced, primary: 5, donor: 3 }); + const pending = allowBureaucratQualifyingShipmentIncome(declareBureaucratQualifyingShipment(f).paid); + const settled = ownerChoice(pending, redirect); + const following = applyAction(structuredClone(settled), 'e', { type: 'bribe', target: 'f', amount: 5 }); + assert.equal(seat(following, 'e').spice, seat(settled, 'e').spice - 5); + assert.deepEqual(physical(following), physical(settled)); + if (redirect) { + assert.notEqual(following.decision?.kind, 'bureaucratPayment'); + assert.equal(seat(following, 'f').bribes, seat(settled, 'f').bribes + 5); + assert.equal(following.bureaucratPayments?.used.length, 1); + } else { + assert.equal(following.decision?.kind, 'bureaucratPayment'); + rejectUnchanged(following, 'b', { type: 'decision', event: pending.bureaucratPaymentEvent, redirect: true }); + const second = ownerChoice(following, true); + assert.equal(seat(second, 'f').bribes, seat(settled, 'f').bribes + 3); + assert.equal(second.bureaucratPayments?.used.length, 1); + assert.equal(seat(second, 'g').spice, seat(settled, 'g').spice); + } + } +}); + +void test('native shipment funding rejects more than the actual pledged aid without committing payment or counters', () => { + for (const advanced of [false, true]) { + const f = createBureaucratQualifyingShipmentFixture({ advanced, primary: 5, donor: 3 }); + assert.equal(viewGame(f.beforeAction, 'p').aid.available, 3); + assert.equal(viewGame(f.beforeAction, 'f').aid.pledged, 3); + assert.equal(viewGame(f.beforeAction, 'b').aid.available, 0); + rejectUnchanged(f.beforeAction, 'p', { ...f.action, allyPayment: 4 }); + rejectUnchanged(f.beforeAction, 'f', f.action); + assert.deepEqual(physical(f.beforeAction), physical(f.beforePledge)); + assert.equal(seat(f.beforeAction, 'p').spice, seat(f.beforePledge, 'p').spice); + assert.equal(seat(f.beforeAction, 'f').spice, 3); + assert.equal(f.beforeAction.response, null); + assert.equal(f.beforeAction.decision, null); + } +}); diff --git a/tests/bureaucrat-split-threshold.test.ts b/tests/bureaucrat-split-threshold.test.ts deleted file mode 100644 index 53e17e62..00000000 --- a/tests/bureaucrat-split-threshold.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { applyAction, viewGame, type Game } from '../game/engine'; -import { nextClassicNexusSkillsPaymentsNativeStep } from './fixture-classic-nexus-skills-payments'; -import { createBureaucratSplitThresholdFixture, advanceBureaucratThresholdToMovement } from './fixture-bureaucrat-split-threshold'; - -function settle(state: Game): Game { - let game = state; - for (let n = 0; (game.response || game.decision) && n < 40; n++) { - const next = nextClassicNexusSkillsPaymentsNativeStep(game); - assert.ok(next); - game = applyAction(game, next.actor, next.action); - } - assert.ok(!game.response && !game.decision); - return game; -} -const player = (game: Game, id: string) => game.players.find(p => p.id === id)!; - -for (const advanced of [false, true]) { - for (const personal of [3, 4]) void test(`${advanced ? 'Advanced' : 'Basic'} shipment preserves ${personal}+3 allied payments below Bureaucrat’s individual threshold`, () => { - const f = createBureaucratSplitThresholdFixture(advanced); - let game = advanceBureaucratThresholdToMovement(f.game, f.payer); - const before = structuredClone(game); - game = applyAction(game, f.donor, { type: 'pledgeAid', amount: 3 }); - const amount = personal + 3, key = 'habbanya_ridge_sietch:17'; - const paid = settle(applyAction(game, f.payer, { - type: 'ship', territory: 'habbanya_ridge_sietch', sector: 17, amount, allyPayment: 3, - })); - assert.equal(player(paid, f.payer).spice, player(before, f.payer).spice - personal); - assert.equal(player(paid, f.donor).spice, player(before, f.donor).spice - 3); - assert.equal(player(paid, f.guild).spice, player(before, f.guild).spice + amount); - assert.equal(player(paid, f.trainer).spice, player(before, f.trainer).spice); - assert.equal(player(paid, f.payer).forces[key], amount); - assert.equal(player(paid, f.payer).reserves, player(before, f.payer).reserves - amount); - assert.equal(paid.aid[f.donor]?.amount ?? 0, 0); - assert.equal(viewGame(paid, f.trainer).bureaucrat.usedThisPhase, false); - assert.throws(() => applyAction(paid, f.payer, { - type: 'ship', territory: 'habbanya_ridge_sietch', sector: 17, amount, allyPayment: 3, - })); - const finished = applyAction(paid, f.payer, { type: 'endMovement' }); - assert.ok(!finished.movementRemaining?.includes(f.payer)); - }); - - void test(`${advanced ? 'Advanced' : 'Basic'} auction preserves separate three-spice payer costs and full Emperor income`, () => { - const f = createBureaucratSplitThresholdFixture(advanced); - let game = f.game; - while (game.auction!.active !== f.payer) { - const next = nextClassicNexusSkillsPaymentsNativeStep(game); assert.ok(next); - game = applyAction(game, next.actor, next.action); - } - const card = game.auction!.cards[game.auction!.index].id; - const before = structuredClone(game); - game = applyAction(game, f.donor, { type: 'pledgeAid', amount: 3 }); - game = applyAction(game, f.payer, { type: 'bid', amount: 6, allyPayment: 3 }); - for (let n = 0; !player(game, f.payer).hand.some(c => c.id === card) && n < 40; n++) { - const next = nextClassicNexusSkillsPaymentsNativeStep(game); assert.ok(next); - game = applyAction(game, next.actor, next.action); - } - const paid = settle(game); - assert.ok(player(paid, f.payer).hand.some(c => c.id === card)); - assert.equal(player(paid, f.payer).spice, player(before, f.payer).spice - 3); - assert.equal(player(paid, f.donor).spice, player(before, f.donor).spice - 3); - assert.equal(player(paid, f.emperor).spice, player(before, f.emperor).spice + 6); - assert.equal(player(paid, f.trainer).spice, player(before, f.trainer).spice); - assert.equal(viewGame(paid, f.trainer).bureaucrat.usedThisPhase, false); - assert.equal(paid.auction!.index, before.auction!.index + 1); - assert.ok(!paid.auction!.cards.slice(paid.auction!.index).some(c => c.id === card)); - }); - - void test(`${advanced ? 'Advanced' : 'Basic'} an individual five-spice contribution retains the unresolved mixed-payment guard`, () => { - const f = createBureaucratSplitThresholdFixture(advanced); - let game = advanceBureaucratThresholdToMovement(f.game, f.payer); - game = applyAction(game, f.donor, { type: 'pledgeAid', amount: 3 }); - const before = structuredClone(game); - assert.throws(() => applyAction(game, f.payer, { - type: 'ship', territory: 'habbanya_ridge_sietch', sector: 17, amount: 8, allyPayment: 3, - })); - assert.deepEqual(game, before, 'No payer, escrow or physical force changes on the unsupported qualifying split'); - }); -} diff --git a/tests/fixture-bureaucrat-qualifying-auction.ts b/tests/fixture-bureaucrat-qualifying-auction.ts new file mode 100644 index 00000000..ab4d2ac6 --- /dev/null +++ b/tests/fixture-bureaucrat-qualifying-auction.ts @@ -0,0 +1,145 @@ +import assert from 'node:assert/strict'; +import { applyAction, handLimit, viewGame, type Action, type Game } from '../game/engine'; +import { createBureaucratQualifyingSplitSetup, advanceBureaucratQualifyingSplit, type BureaucratQualifyingSplitStep } from './fixture-bureaucrat-qualifying-split'; +export interface BureaucratQualifyingAuctionFixture { + game: Game; + setup: Game; + beforeBribe: Game; + bribe: Game; + afterFirstMentat: Game; + worm: Game; + alliance: Game; + sourceSetup: Game; + beforePledge: Game; + originalPledge: BureaucratQualifyingSplitStep; + pledged: Game; + beforeAction: Game; + action: Action; + payer: string; + donor: string; + trainer: string; + guild: string; + emperor: string; + payerShare: number; + donorShare: number; + amount: number; + counterCard?: string; + physical: string[]; + reservedCard: string; + bonusCard?: string; + actions: BureaucratQualifyingSplitStep[]; + staging: string[]; +} + + +export const qualifyingAuctionPlayer = (game: Game, id: string) => game.players.find(player => player.id === id)!; +export function qualifyingAuctionPhysicalCards(game: Game): string[] { + return [...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand), + ...(game.auction?.cards.slice(game.auction.index + (game.currentAuctionSale?.origin === 'normal' ? 1 : 0)) ?? [])] + .map(card => card.id).sort(); +} + +/** Swap conserved physical cards, retaining the original hand size and auction + * quantity. Harkonnen's Karama makes the native winning payment a real choice; + * no purchase bonus is dealt until the original income continuation finishes. */ +function positionKarama(game: Game, actor: string, staging: string[]): string { + const player = qualifyingAuctionPlayer(game, actor); + const held = player.hand.find(card => card.effect === 'karama'); + if (held) return held.id; + const index = game.deck.findIndex(card => card.effect === 'karama'); + assert.ok(index >= 0, 'An original undealt physical Karama must be available'); + const outgoing = player.hand[0]; + assert.ok(outgoing); + const incoming = game.deck[index]; + game.deck[index] = outgoing; + player.hand[0] = incoming; + staging.push(`Swap original held ${outgoing.id} with original undealt ${incoming.id} for ${actor}; retain hand size and committed lots.`); + return incoming.id; +} + +export function createBureaucratQualifyingAuctionFixture({ + advanced = false, payerShare = 5, donorShare = 3, donor = 'f', counter = false, lastHandSpace = false, +}: { advanced?: boolean; payerShare?: number; donorShare?: number; donor?: 'f' | 'b' | 'e'; counter?: boolean; lastHandSpace?: boolean } = {}): BureaucratQualifyingAuctionFixture { + const native = createBureaucratQualifyingSplitSetup(advanced); + let game = native.game, alliance = native.alliance; + let actions = native.actions; + const staging = native.staging; + if (donor !== native.donor) { + // Branch at the actual worm, before either mutual alliance proposal. + game = structuredClone(native.worm); + actions = actions.slice(0, actions.findIndex(step => step.action.type === 'alliance')); + for (const [actor, target] of [[native.payer, donor], [donor, native.payer]]) { + const action: Action = { type: 'alliance', target }; + actions.push({ actor, action }); + game = applyAction(game, actor, action); + } + alliance = structuredClone(game); + game = advanceBureaucratQualifyingSplit(game, state => state.phase === 3 && !!state.auction && + !state.phaseOpening && !state.response && !state.decision, actions); + } + const sourceSetup = structuredClone(game); + const physical = qualifyingAuctionPhysicalCards(game); + positionKarama(game, native.payer, staging); + const counterCard = counter ? positionKarama(game, native.trainer, staging) : undefined; + if (lastHandSpace) { + const payer = qualifyingAuctionPlayer(game, native.payer); + while (payer.hand.length < handLimit(payer) - 1) { + const incoming = game.deck.shift(); + assert.ok(incoming); + payer.hand.push(incoming); + staging.push(`Conserve original undealt ${incoming.id} into ${payer.id} after original auction quantity committed.`); + } + } + assert.deepEqual(qualifyingAuctionPhysicalCards(game), physical); + const originalPledge: BureaucratQualifyingSplitStep = { actor: donor, action: { type: 'pledgeAid', amount: donorShare } }; + const beforePledge = structuredClone(game); + actions.push(originalPledge); + game = applyAction(game, originalPledge.actor, originalPledge.action); + const pledged = structuredClone(game); + for (let n = 0; game.auction!.active !== native.payer && n < game.players.length; n++) { + const step: BureaucratQualifyingSplitStep = { actor: game.auction!.active, action: { type: 'passBid' } }; + actions.push(step); + game = applyAction(game, step.actor, step.action); + } + assert.equal(game.auction!.active, native.payer); + const beforeAction = structuredClone(game), amount = payerShare + donorShare; + const action: Action = { type: 'bid', amount, allyPayment: donorShare }; + return { ...native, donor, alliance, game, sourceSetup, beforePledge, originalPledge, pledged, beforeAction, action, + payerShare, donorShare, amount, counterCard, physical, reservedCard: game.auction!.cards[game.auction!.index].id, + bonusCard: game.deck[0]?.id, actions, staging }; +} + +export function payBureaucratQualifyingAuction(fixture: BureaucratQualifyingAuctionFixture, state = fixture.game): Game { + let game = applyAction(state, fixture.payer, fixture.action); + fixture.actions.push({ actor: fixture.payer, action: fixture.action }); + for (let n = 0; game.decision?.kind !== 'auctionPayment' && n < game.players.length + 2; n++) { + assert.ok(game.auction && !game.currentAuctionSale); + const step: BureaucratQualifyingSplitStep = { actor: game.auction.active, action: { type: 'passBid' } }; + fixture.actions.push(step); + game = applyAction(game, step.actor, step.action); + } + assert.equal(game.decision?.kind, 'auctionPayment'); + assert.equal(game.decision!.player, fixture.payer); + const action: Action = { type: 'decision', karama: false }; + fixture.actions.push({ actor: fixture.payer, action }); + return applyAction(game, fixture.payer, action); +} + +export function allowQualifyingAuctionResponses(state: Game): Game { + let game = state; + for (let n = 0; game.response && n < 40; n++) { + const actor = game.players.find(player => { + const controls = viewGame(game, player.id).responseControls; + return controls && !controls.hasPassed && controls.cancelCards.length > 0; + }); + assert.ok(actor, 'The original response must have a legal unpassed counter holder'); + game = applyAction(game, actor.id, { type: 'passResponse' }); + } + assert.equal(game.response, null); + return game; +} + +export function chooseQualifyingAuction(game: Game, redirect = true): Game { + assert.equal(game.decision?.kind, 'bureaucratPayment'); + return applyAction(game, game.decision!.player, { type: 'decision', event: game.bureaucratPaymentEvent, redirect }); +} diff --git a/tests/fixture-bureaucrat-qualifying-shipment.ts b/tests/fixture-bureaucrat-qualifying-shipment.ts new file mode 100644 index 00000000..38d016d2 --- /dev/null +++ b/tests/fixture-bureaucrat-qualifying-shipment.ts @@ -0,0 +1,96 @@ +import assert from 'node:assert/strict'; +import { applyAction, viewGame, type Action, type Game } from '../game/engine'; +import { + advanceBureaucratQualifyingSplit, createBureaucratQualifyingSplitSetup, + type BureaucratQualifyingSplitStep, +} from './fixture-bureaucrat-qualifying-split'; + +export interface BureaucratQualifyingShipmentDeclaration { + beforeAction: Game; + payer: string; + action: Action; +} + +export function createBureaucratQualifyingShipmentFixture({ + advanced = false, primary = 5, donor = 3, ownerFunding = false, +}: { advanced?: boolean; primary?: number; donor?: number; ownerFunding?: boolean } = {}) { + const setup = createBureaucratQualifyingSplitSetup(advanced); + const allianceIndex = setup.actions.findIndex(step => step.action.type === 'alliance'); + const actions = ownerFunding ? setup.actions.slice(0, allianceIndex) : [...setup.actions]; + const staging = [...setup.staging]; + const donorId = ownerFunding ? setup.trainer : setup.donor; + let game = structuredClone(ownerFunding ? setup.worm : setup.game); + if (ownerFunding) { + for (const [actor, target] of [[setup.payer, donorId], [donorId, setup.payer]]) { + const action: Action = { type: 'alliance', target }; + actions.push({ actor, action }); + game = applyAction(game, actor, action); + } + } + const alliance = ownerFunding ? structuredClone(game) : setup.alliance; + game = advanceBureaucratQualifyingSplit(game, state => state.phase === 5 && + state.active === setup.payer && !state.phaseOpening && !state.response && !state.decision, actions); + assert.equal(game.turn, 2); + const sourceSetup = structuredClone(game); + const sourceArmy = structuredClone(game.players.find(p => p.id === setup.payer)!); + assert.equal(sourceArmy.reserves, 10); + assert.deepEqual(sourceArmy.forces, { 'carthag:11': 10 }); + assert.ok(game.players.every(p => !p.forces['habbanya_ridge_sietch:17'])); + assert.equal(game.players.find(p => p.id === setup.donor)!.spice, 6); + assert.equal(game.players.find(p => p.id === setup.guild)!.spice, advanced ? 6 : 5); + + // Keep the original singleton in physical custody so the original income + // cancellation window is exercised, rather than manufacturing a response. + const responder = game.players.find(p => p.id === setup.emperor)!; + let karama = responder.hand.find(card => card.effect === 'karama'); + if (!karama) { + const index = game.deck.findIndex(card => card.effect === 'karama'); + assert.ok(index >= 0); + karama = game.deck.splice(index, 1)[0]; + responder.hand.push(karama); + staging.push(`Conserved original ${karama.id}: undealt deck to Emperor hand for native income cancellation.`); + } + const beforePledge = structuredClone(game); + const pledge: Action = { type: 'pledgeAid', amount: donor }; + actions.push({ actor: donorId, action: pledge }); + game = applyAction(game, donorId, pledge); + const beforeAction = structuredClone(game); + const amount = primary + donor; + const action: Action = { type: 'ship', territory: 'habbanya_ridge_sietch', sector: 17, amount, allyPayment: donor }; + assert.equal(game.aid[donorId].amount, donor); + return { ...setup, donor: donorId, alliance, game, sourceSetup, sourceArmy, beforePledge, pledge, beforeAction, + action, primary, donorAmount: donor, amount, target: 'habbanya_ridge_sietch:17', + karama, responder: responder.id, actions, staging }; +} + +/** Declare once; the original Guild interception, when present, commits it. */ +export function declareBureaucratQualifyingShipment( + fixture: BureaucratQualifyingShipmentDeclaration, +) { + const actions: BureaucratQualifyingSplitStep[] = []; + 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 }; +} + +/** Pass only original legal Karama responders; stop at the owner choice. */ +export function allowBureaucratQualifyingShipmentIncome(state: Game) { + let game = structuredClone(state); + for (let n = 0; game.response?.kind === 'guildIncome' && n < 12; 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, 'The conserved Karama must retain its original income response'); + game = applyAction(game, actor.id, { type: 'passResponse' }); + } + assert.notEqual(game.response?.kind, 'guildIncome'); + return game; +} diff --git a/tests/fixture-bureaucrat-qualifying-split.ts b/tests/fixture-bureaucrat-qualifying-split.ts new file mode 100644 index 00000000..b96e7ade --- /dev/null +++ b/tests/fixture-bureaucrat-qualifying-split.ts @@ -0,0 +1,74 @@ +import assert from 'node:assert/strict'; +import { applyAction, createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine'; +import { initializeClassicNexusSkillsPaymentsSetup, nextClassicNexusSkillsPaymentsNativeStep } from './fixture-classic-nexus-skills-payments'; + +export type BureaucratQualifyingSplitStep = { actor: string; action: Action }; +const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision; + +/** Keep every original army and wallet. A real three-spice bribe collects into + * Fremen's shield at the first Mentat; the actual second-turn worm forms H/F. */ +export function createBureaucratQualifyingSplitSetup(advanced = false) { + const lobby = createGame('BUREAUCRATQUALIFY', newPlayer('b', 'Bureaucrat', 'atreides'), advanced); + for (const [id, faction] of [['p', 'harkonnen'], ['f', 'fremen'], ['e', 'emperor'], ['g', 'guild']] as const) + joinGame(lobby, newPlayer(id, faction, faction)); + let game = initializeClassicNexusSkillsPaymentsSetup({ initial: lobby, skill: 'bureaucrat' }); + const actions: BureaucratQualifyingSplitStep[] = []; + const staging: string[] = []; + const act = (actor: string, action: Action) => { + actions.push({ actor, action }); + game = applyAction(game, actor, action); + }; + const advance = (until: (state: Game) => boolean) => { + for (let n = 0; !until(game) && n < 2400; n++) { + assert.notEqual(game.status, 'finished'); + const next = nextClassicNexusSkillsPaymentsNativeStep(game); + assert.ok(next, 'Original skill and phase controls must reach the payment boundary'); + act(next.actor, next.action); + } + assert.ok(until(game)); + }; + const orderSpice = (kinds: ('land' | 'worm')[]) => { + for (const [position, kind] of kinds.entries()) { + const index = game.spiceDeck.findIndex((card, i) => i >= position && + (kind === 'land' ? 'territory' in card : 'worm' in card)); + assert.ok(index >= position); + game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]); + } + staging.push('Reorder only original still-unplayed Spice Cards.'); + }; + advance(state => state.status === 'playing'); + const setup = structuredClone(game); + assert.ok(game.leaderSkills!.assignments.some(a => a.owner === 'b' && a.skill === 'bureaucrat')); + orderSpice(['land', 'land']); + advance(state => state.phase === 5 && state.active === 'p' && clean(state)); + const beforeBribe = structuredClone(game); + act('p', { type: 'bribe', target: 'f', amount: 3 }); + const bribe = structuredClone(game); + assert.equal(game.players.find(p => p.id === 'f')!.bribes, 3); + advance(state => state.turn === 2 && state.phase === 1 && clean(state)); + const afterFirstMentat = structuredClone(game); + assert.equal(game.players.find(p => p.id === 'f')!.spice, 6); + orderSpice(['worm', 'land', 'land']); + advance(state => state.nexus && !state.spiceWindow && !state.spiceResolution && clean(state)); + const worm = structuredClone(game); + act('p', { type: 'alliance', target: 'f' }); + act('f', { type: 'alliance', target: 'p' }); + const alliance = structuredClone(game); + advance(state => state.phase === 3 && !!state.auction && clean(state)); + return { game, setup, beforeBribe, bribe, afterFirstMentat, worm, alliance, + payer: 'p', donor: 'f', trainer: 'b', guild: 'g', emperor: 'e', actions, staging }; +} + +export function advanceBureaucratQualifyingSplit(state: Game, until: (game: Game) => boolean, + actions?: BureaucratQualifyingSplitStep[]): Game { + let game = state; + for (let n = 0; !until(game) && n < 1600; n++) { + assert.notEqual(game.status, 'finished'); + const next = nextClassicNexusSkillsPaymentsNativeStep(game); + assert.ok(next); + actions?.push(next); + game = applyAction(game, next.actor, next.action); + } + assert.ok(until(game)); + return game; +} diff --git a/tests/fixture-bureaucrat-split-threshold.ts b/tests/fixture-bureaucrat-split-threshold.ts deleted file mode 100644 index 87c9c5a0..00000000 --- a/tests/fixture-bureaucrat-split-threshold.ts +++ /dev/null @@ -1,69 +0,0 @@ -import assert from 'node:assert/strict'; -import { applyAction, createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine'; -import { initializeClassicNexusSkillsPaymentsSetup, nextClassicNexusSkillsPaymentsNativeStep } from './fixture-classic-nexus-skills-payments'; - -/** Original skill offers/setup and actual turn-two worm alliance. Conserved - * starting counters are staged into reserves to isolate payment, not combat. - * Wallets, alliances, clocks and payment records are never assigned. */ -export function createBureaucratSplitThresholdFixture(advanced = false) { - const lobby = createGame('BUREAUCRATTHRESHOLD', newPlayer('b', 'Bureaucrat', 'atreides'), advanced); - for (const [id, faction] of [['p', 'harkonnen'], ['f', 'fremen'], ['e', 'emperor'], ['g', 'guild']] as const) - joinGame(lobby, newPlayer(id, faction, faction)); - let game = initializeClassicNexusSkillsPaymentsSetup({ initial: lobby, skill: 'bureaucrat' }); - const actions: { actor: string; action: Action }[] = []; - const step = () => { - const next = nextClassicNexusSkillsPaymentsNativeStep(game); - assert.ok(next, `Native payment setup/progression stalled at ${game.phase}/${game.setupStage}`); - actions.push(next); - game = applyAction(game, next.actor, next.action); - }; - for (let n = 0; game.status === 'setup' && n < 240; n++) step(); - assert.equal(game.status, 'playing'); - assert.ok(game.leaderSkills!.assignments.some(a => a.owner === 'b' && a.skill === 'bureaucrat')); - for (const player of game.players) { - player.reserves += Object.values(player.forces).reduce((a, b) => a + b, 0); - player.forces = {}; - if (player.elites) { - player.elites.reserves += Object.values(player.elites.forces).reduce((a, b) => a + b, 0); - player.elites.forces = {}; - } - if (player.advisors) player.advisors = {}; - } - const orderSpice = (kinds: ('land' | 'worm')[]) => { - const front = kinds.map(kind => { - const index = game.spiceDeck.findIndex(card => kind === 'land' ? 'territory' in card : 'worm' in card); - assert.ok(index >= 0); - return game.spiceDeck.splice(index, 1)[0]; - }); - game.spiceDeck.unshift(...front); - }; - orderSpice(['land', 'land']); - for (let n = 0; !(game.turn === 2 && game.phase === 1) && n < 1800; n++) step(); - assert.equal(game.turn, 2); assert.equal(game.phase, 1); - orderSpice(['worm', 'land', 'land']); - let allied = false; - for (let n = 0; !(allied && game.phase === 3 && game.auction && !game.phaseOpening && !game.response && !game.decision) && n < 600; n++) { - if (game.nexus && !game.phaseOpening && !game.response && !game.decision && !game.spiceWindow && !allied) { - for (const [actor, target] of [['p', 'f'], ['f', 'p']]) { - const action: Action = { type: 'alliance', target }; - actions.push({ actor, action }); game = applyAction(game, actor, action); - } - allied = true; - } else step(); - } - assert.ok(allied && game.auction); - assert.equal(game.players.find(p => p.id === 'p')!.ally, 'f'); - assert.equal(game.players.find(p => p.id === 'f')!.ally, 'p'); - return { game, actions, payer: 'p', donor: 'f', trainer: 'b', guild: 'g', emperor: 'e' }; -} - -export function advanceBureaucratThresholdToMovement(state: Game, actor: string): Game { - let game = state; - for (let n = 0; !(game.phase === 5 && game.active === actor && !game.phaseOpening && !game.response && !game.decision) && n < 600; n++) { - const next = nextClassicNexusSkillsPaymentsNativeStep(game); - assert.ok(next, 'Native payment continuation stalled'); - game = applyAction(game, next.actor, next.action); - } - assert.equal(game.phase, 5); assert.equal(game.active, actor); - return game; -}