From 68f7a101a38315b23076620c288e1e9a7956e4d1 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Tue, 13 Oct 2026 00:12:10 +1100 Subject: [PATCH] Respect individual Bureaucrat payment thresholds and correct Homeworld evidence --- docs/BUREAUCRAT_PAYMENTS.md | 42 ++++++-- docs/CURRENT_STATUS.md | 57 +++++++---- docs/IMPLEMENTATION_STATUS.md | 101 +++++++++++++++----- docs/RULE_DECISIONS.md | 2 +- docs/SEMUTA_SOURCE_UPDATE_20260907.md | 8 +- game/engine.ts | 11 +-- game/reference.ts | 2 +- tests/bureaucrat-payment.test.ts | 27 +----- tests/bureaucrat-split-threshold.test.ts | 80 ++++++++++++++++ tests/fixture-bureaucrat-split-threshold.ts | 69 +++++++++++++ tests/semuta-homeworld-ordinary.test.ts | 23 ++--- 11 files changed, 318 insertions(+), 104 deletions(-) create mode 100644 tests/bureaucrat-split-threshold.test.ts create mode 100644 tests/fixture-bureaucrat-split-threshold.ts diff --git a/docs/BUREAUCRAT_PAYMENTS.md b/docs/BUREAUCRAT_PAYMENTS.md index 55eec5e6..7569243b 100644 --- a/docs/BUREAUCRAT_PAYMENTS.md +++ b/docs/BUREAUCRAT_PAYMENTS.md @@ -87,15 +87,39 @@ available for a later qualifying payment in that phase. Existing auction, shipment and bribe paths have public payment amounts; the negotiated revival price remains limited to the participating seats and trainer. -Qualifying allied funding, -including an aggregate such as three plus three and a payment funded entirely by -the ally, is guarded before costs or commitment. It needs separate payment-unit -and privacy work, as does split-recipient Homeworld income. Independent Karama -shipments paid entirely to the Bank bypass this player-income guard. Do not infer -eligibility from an aggregate that silently combines payers or reveals concealed -funding. A successful use is retained for the physical -card's turn and phase, including a same-phase ownership change. That physical -card accounting is an explicit implementation inference. +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. +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 +inference. + +## Individual allied-payer threshold — 13 October 2026 + +The shared contributor check previously aggregated all payer shares and +rejected three-plus-three before commitment. The card's threshold names +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 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. ## Saved choices and verification diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index bdbcdc27..37144cfc 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -29,6 +29,29 @@ 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 individual-payer threshold and evidence correction — 13 October.** +Separate allied shares below five are no longer summed to create Bureaucrat +eligibility. Basic/Advanced native setup and an actual worm alliance reproduce +the rejected three-plus-three shipment; four post-cutover shipment/auction +programs retain the original six-spice payment and full recipient income. +Eight cases cover three/four personal plus three allied and the still-guarded +five-plus-three contribution. The actual390px Basic controls pledge three, +ship six counters for three personal plus three allied and finish Movement. +This does not open individually qualifying private/split-payment reactions. + +The Homeworld Box, Distrans and Truthtrance controls now have six actual390px +claim/pass flows, including real paid search/transfer/question controls and +original Movement completion. The evidence bundle is +`/tmp/dune-homeworld-controls-proof-3212.json`; it is local, not deployed or +saved-room acceptance. Earlier summaries wrongly claimed these UI runs and +pre-cutover Box proof; the feature log now distinguishes observed evidence. +The unrelated shared parent-signature augmentation is removed. It had changed +other Homeworld receipt bytes; no historical-save certification follows. +The frozen batch passed types, lint and135/135 across13 unit files. Eight +native bot programs (both modes × four profiles) complete shipment without +rejected candidates; Hard/Brutal actually use below-threshold allied shares. + + **Semuta / Homeworld Truthtrance reaction — 11 October.** `truthtranceDiscard` is now admitted in the same envelope, so a definite Truthtrance answer offers the consumed card. A genuine original Homeworld @@ -48,32 +71,30 @@ occupation authority and original Movement are unchanged. The claim/pass pair and all four legal bots pass alongside the existing Homeworld cases. **Semuta / Homeworld paid Box reaction — 11 October.** -The same envelope also admitted no paid Nullentropy Box reaction, so a -completed Box search produced no Semuta offer. `semutaOfferSupported` now -admits `nullentropyDiscard` there as well, and the shared parent signature -binds Homeworld custody, occupation history and the percentage ledger when a -Homeworld module is present; non-Homeworld continuations keep their existing -shape. A genuine original Homeworld state runs a real paid Box search -(two-spice payment, shuffled pile, used Box on top) and then offers the used -Box. Claim recovers the exact Box, pass leaves it discarded; the payment, -searched card, occupation authority and original Movement are unchanged. -One before program had no offer; the claim/pass pair and all four legal bots -pass. Other Homeworld producers remain guarded. +`semutaOfferSupported` admits `nullentropyDiscard` in this envelope. +A real post-cutover singleton search pays two spice, recovers the selected +card and places the used Box on top before offering Semuta. Claim recovers +the exact Box; pass leaves it discarded. No pre-cutover Box program was run; +the previous absence claim was inferred from the gate. The unrelated shared +parent-signature augmentation is removed by the subsequent correction: +it changed other Homeworld receipts and was unnecessary for admission. +The final Box checkpoint passed 107/107 across ten files with types/lint; +other producers remain guarded. **Semuta / Homeworld ordinary-card reaction — 11 October.** The Homeworld Semuta envelope admitted only three producers, so a used ordinary special card played there produced no reaction even though the printed card has no module restriction. `semutaOfferSupported` now also -admits `ordinaryCardDiscard` in that roster; its existing signature already -binds Homeworld custody, occupation history and percentage ledger, so no new -source identity or payment rule was added. A genuine original Homeworld +admits `ordinaryCardDiscard` in that roster; its existing effect signature +includes Homeworlds/revival state, not the separate history/percentage fields. +No new payment rule was added. A genuine original Homeworld occupation state at an active Movement owner plays Hajr, commits its movement grant, then offers the used card. Claim recovers it, pass leaves it discarded; wallets, custody, occupation history, percentage ledger and the -Hajr grant are unchanged and original Movement still finishes. One before -program had no offer; four claim/pass and all four legal bot profiles pass; -seven consumer cases and the frozen six-file union pass with types/lint -clean. Other Homeworld producers remain guarded. +Hajr grant are unchanged and original Movement still finishes. The before +program printed no offer, then failed an incidental null/undefined assertion. +Four post-cutover programs, seven new cases and the six-file union77/77 +passed with types/lint clean. The actual390px claim and finish were exercised. **Tleilaxu Ambassador first-death Kwisatz Haderach — 11 October.** The source-supported separate KH identity (base p17) now appears in the diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index dd3ed429..b2cdb2e3 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,27 +1,73 @@ # Dune implementation status +## 13 October 2026 — individual Bureaucrat payer threshold; evidence correction + +The printed skill names one other payer's payment of at least five spice. +`checkBureaucratContributors` instead aggregated different payer shares, +rejecting three-plus-three before shipment or bid commitment. It now enters +the existing integration guard only when an eligible individual payer reaches +five. Private/split payments with a qualifying individual still retain that +guard; no reaction, recipient split or private-funding convention is invented. + +The new fixture uses real original all14 Skills setup, training and a real +turn-two worm alliance. Conserved counters and undealt Spice Card ordering +isolate payment without assigning wallets, alliances, phase or ledger state. +Two valid Basic/Advanced shipment programs rejected before the change. +Four post-cutover shipment and auction programs charge personal three and +allied three, credit the full six to Guild/Emperor, preserve the physical +shipment/acquisition and keep Bureaucrat unused. Eight cases also cover +four-plus-three and rejection before mutation for five-plus-three. +The older aggregate-three-plus-three rejection expectation is removed. +The actual390px Basic table pledges Fremen's three native spice, ships six +ordinary counters into Habbanya Sietch17, credits Guild5→11 and finishes +Harkonnen's original Movement. Four initial optimizer requests aborted; +the controls subsequently worked. No deployed or full-family claim. + +Separately, the Box/Distrans/Truthtrance Homeworld UI proof is now real rather +than retroactively attributed to earlier checkpoints. Six390px claim/pass +flows use actual paid Box search, private Distrans transfer, Truthtrance +declaration/priority/question/answer and original Movement completion. +The proof bundle `/tmp/dune-homeworld-controls-proof-3212.json` retains fresh +AX, screenshots, accepted actions and observed state outside Git. Parent +inspected the three claimed-card screenshots. Local prebuilt native fixture +progression is not deployed, persisted-room or natural full-game proof. + +The shared parent-signature Homeworld augmentation is removed: it changed +unrelated receipt bytes and was unnecessary to enable the new discard +producers. Previous claims about unchanged in-flight receipts and already +signed occupation-history fields were incorrect. No historical-save or +intermediate-checkpoint receipt certification is asserted. +Verification: TypeScript passed; the first lint run found an unused mock +import and a never-reassigned setup variable, both removed. The final lint +and13-file unit union passed135/135. Eight native bot programs (both modes × +four profiles) complete shipment with zero rejected candidates. A real bribe +reduces the payer's native ten spice to three, its original Bureaucrat choice +is declined, then Fremen pledges three. Hard/Brutal use actual three-plus-one +or three-plus-two funding; the normal Advanced Guild allowance finishes before +checking committed forces/payment. No strategy or difficulty tuning changed. + + ## 11 October 2026 — Homeworld Truthtrance Semuta reaction `truthtranceDiscard` is now admitted in the same Homeworld envelope, so a definite Truthtrance answer offers the consumed card. Its signature already -binds the committed answer record, queue remainder and the shared parent -signature (Homeworld-aware when the module is present). +binds the committed answer record, queue remainder and suspended controls. A genuine original Homeworld state declares Truthtrance, passes storm priority and answers a real held-card fact; the consumed card is then the fresh batch. Claim recovers the exact Truthtrance, pass leaves it discarded, and the published answer history, wallets, occupation authority and original Movement are unchanged; the answer cannot be replayed. The claim/pass pair -and all four legal bot profiles pass. Twenty-five consumer cases and the -frozen six-file union pass with types/lint. Battle, Ornithopter, Sapho and -other Homeworld producers remain guarded. +and all four legal bot profiles pass. The final checkpoint ran 25 cases in +the new file and 140/140 across 11 affected files, with types/lint clean. +Battle, Ornithopter, Sapho and other Homeworld producers remain guarded. ## 11 October 2026 — Homeworld Distrans Semuta reaction `distransDiscard` is now admitted in the same Homeworld envelope, so a completed private Distrans transfer offers its used card. The continuation -already binds both hands and the shared parent signature (now Homeworld-aware -when the module is present), so no new custody or price rule was added. +already binds both hands and suspended controls; no new custody or price +rule was required. A genuine original Homeworld state plays Distrans, delivers the chosen private card to a real seat, and offers the used Distrans. The transferred card is @@ -29,20 +75,19 @@ never a candidate; the gift has already completed. Claim recovers the exact Distrans, pass leaves it discarded, and the gift, wallets, occupation authority and original Movement are unchanged; replaying the transfer is rejected. The claim/pass pair and all four legal bot profiles pass alongside -the existing Homeworld cases. Nineteen consumer cases and the frozen -six-file union pass with types/lint. Truthtrance, battle and other Homeworld -producers remain guarded. +the existing Homeworld cases. The final checkpoint ran 19 cases in the new +file and 113/113 across 10 affected files, with types/lint clean. Truthtrance +was still excluded at that checkpoint. ## 11 October 2026 — Homeworld paid Box Semuta reaction The Homeworld envelope also admitted no paid Nullentropy Box reaction, so a completed Box search in that roster produced no Semuta offer. -`semutaOfferSupported` now admits `nullentropyDiscard` there, and the shared -`nullentropyParentSignature` additionally binds Homeworld custody, occupation -history and the percentage ledger when `g.homeworlds` is present. Non-Homeworld -continuations keep their previous signature shape, so no existing saved or -in-flight receipt changes meaning. +`semutaOfferSupported` now admits `nullentropyDiscard` there. The checkpoint +also added Homeworld state to the unrelated shared parent signature. That +addition changed other Homeworld receipt bytes, so the claim that no existing +receipt changed was incorrect; the subsequent correction removes it. A genuine original Homeworld state runs a real paid Box search: two uncommitted spice are paid, the pile is shuffled, the selected card is @@ -50,9 +95,11 @@ recovered and the used Box is placed on top. Semuta is then offered for that used Box. Claim recovers the exact Box; pass leaves it discarded. The payment, the searched card, Homeworld custody, occupation history, the percentage ledger and the remaining Movement are unchanged, and the Box cannot be -searched again. The before program produced no offer; the claim/pass pair and -all four legal bot profiles pass. Thirteen consumer cases and the frozen -six-file union pass with types/lint. Other Homeworld producers remain guarded. +searched again by its former owner. No pre-cutover Box program was run: +the absence was inferred from the gate, not observed. The real post-cutover +program showed a singleton search completing automatically before Semuta. +The final checkpoint ran 13 cases in the new file and 107/107 across 10 +affected files, with types/lint clean. Other producers remain guarded. ## 11 October 2026 — Homeworld ordinary-card Semuta reaction @@ -63,20 +110,22 @@ card carries no module restriction; the exclusion was an implementation boundary of the new envelope. `semutaOfferSupported` now also admits `ordinaryCardDiscard` when the -Homeworld roster matches. That continuation's existing signature already -binds Homeworld custody, occupation history and the percentage ledger, so no -new source identity, price or economic rule was introduced. Ordinary resume -already restores the saved response/decision/pending-Karama/opening. +Homeworld roster matches. The existing effect signature includes Homeworlds +and revival state, but not the separate occupation-history/percentage fields; +the earlier claim that those fields were already signed was incorrect. +Ordinary resume restores saved response/decision/pending-Karama/opening. A genuine original Homeworld occupation state at an active Movement owner plays Hajr: its movement grant commits, the used card becomes the fresh batch, and Semuta is offered. Claim recovers the exact used card; pass leaves it discarded. Wallets, Homeworld custody, occupation history, percentage ledger, the Hajr grant and the remaining Movement are unchanged; a second -play and a repeated commit are rejected. The before program produced no -offer; four claim/pass and all four legal bot profiles pass. Seven consumer -cases and the frozen six-file union pass with types/lint. Other Homeworld -producers, Nexus/Skills/module mixtures and public starts remain guarded. +play and a repeated commit are rejected. The before program printed no offer, +then failed an incidental null-versus-undefined assertion; it was not a green +before check. Four post-cutover programs and seven consumer cases passed. +The final checkpoint passed 77/77 across six files with types/lint clean; +the actual 390px Hajr claim and original Movement finish were exercised. +Other producers, module mixtures and public starts remain guarded. ## 11 October 2026 — source-supported first-death KH Ambassador return diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index bac2b5ce..a34073cb 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 from an actual third-party payment of at least five; payer cost is unchanged. Bribes trigger at transfer to the recipient's shield, not later collection. Native income is offered after Karama allowance as an explicit outcome-based inference; 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 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) | | 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/docs/SEMUTA_SOURCE_UPDATE_20260907.md b/docs/SEMUTA_SOURCE_UPDATE_20260907.md index f1c6bd71..fe1c80c8 100644 --- a/docs/SEMUTA_SOURCE_UPDATE_20260907.md +++ b/docs/SEMUTA_SOURCE_UPDATE_20260907.md @@ -862,9 +862,11 @@ card of a completed paid Nullentropy Box search, not only paid Kaitain disposal, Tupile cleanup and printed Karama canceling income. The used card is the same public fresh batch; claim recovers it and pass leaves it discarded, with Homeworld custody, occupation history, the percentage ledger, wallets, the two-spice Box payment and the card's own -committed effect unchanged. The shared parent signature additionally binds -Homeworld state when that module is present. One before program lacked the -offer; the claim/pass pair and all four legal bots pass. Other Homeworld +committed effect unchanged. The shared parent-signature augmentation was +unnecessary for this admission and changed other Homeworld receipt bytes; +the correction removes it. The Hajr before program printed no offer then +failed an incidental null/undefined assertion. Box, Distrans and Truthtrance +had post-cutover programs but no observed pre-cutover run. Other Homeworld producers remain guarded, not prohibited by a newly found printed rule. **11 October 2026 — native paid-income cancellation costs.** Printed Karama diff --git a/game/engine.ts b/game/engine.ts index 62c52da1..50de9430 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -4947,14 +4947,7 @@ function nullentropyParentSignature( ) .sort(([a], [b]) => a.localeCompare(b)), ); - return JSON.stringify({ - searchEvent, resume, parents, - ...(g.homeworlds ? { - homeworlds: g.homeworlds, - occupation: g.homeworldOccupationHistory, - percentages: g.homeworldOccupiedPercentageLedger, - } : {}), - }); + return JSON.stringify({ searchEvent, resume, parents }); } type OrdinaryDiscardContinuation = Extract< NonNullable['continuation'], @@ -8442,7 +8435,7 @@ function checkBureaucratContributors(g: Game,payee:string|undefined,shares:{paye const paid = shares.filter(share => share.amount > 0); const owner = nativeBureaucrat(g)?.owner; if (!owner || owner === payee || bureaucratUsed(g.bureaucratPayments?.used ?? [],g.turn,g.phase) || - paid.reduce((total,share) => total+share.amount,0) < 5 || !paid.some(share => share.payer !== owner && share.payer !== payee)) return; + !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.'); } diff --git a/game/reference.ts b/game/reference.ts index e900bd88..3f7f8d3c 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -4778,7 +4778,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 from a payment of at least five between two other players, once per phase. The payer still pays in full; the recipient receives two less. Declining keeps the use available for a later payment. Paid bribes qualify when transferred, with 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. By user ruling, Emperor gifts are not payments: no Bureaucrat prompt/use or threshold disclosure, and exact gift amounts remain private. Split funding and 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, 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.', '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.test.ts b/tests/bureaucrat-payment.test.ts index 889617c8..5b6dfbb3 100644 --- a/tests/bureaucrat-payment.test.ts +++ b/tests/bureaucrat-payment.test.ts @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import test, { mock } from 'node:test'; +import test from 'node:test'; import { applyAction, normalizeAutomaticGame, @@ -294,37 +294,20 @@ 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('aggregate 3+3 and fully allied-funded qualifying payments reject before cost, force/card commitment or RNG', () => { - for (const allyPayment of [1, 3, 6]) { +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 }); - mock.method(globalThis.crypto, 'randomUUID', () => { - throw new Error('Unexpected event before payment preflight'); - }); - try { - reject( - g, - 'p', - { ...ship, amount: 6, allyPayment }, - /private payment-split/, - ); - } finally { - mock.restoreAll(); - } + 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 }, - /private payment-split/, - ); + 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', () => { diff --git a/tests/bureaucrat-split-threshold.test.ts b/tests/bureaucrat-split-threshold.test.ts new file mode 100644 index 00000000..53e17e62 --- /dev/null +++ b/tests/bureaucrat-split-threshold.test.ts @@ -0,0 +1,80 @@ +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-split-threshold.ts b/tests/fixture-bureaucrat-split-threshold.ts new file mode 100644 index 00000000..87c9c5a0 --- /dev/null +++ b/tests/fixture-bureaucrat-split-threshold.ts @@ -0,0 +1,69 @@ +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; +} diff --git a/tests/semuta-homeworld-ordinary.test.ts b/tests/semuta-homeworld-ordinary.test.ts index b6a58670..57eb5751 100644 --- a/tests/semuta-homeworld-ordinary.test.ts +++ b/tests/semuta-homeworld-ordinary.test.ts @@ -15,20 +15,11 @@ import { for (const claim of [false, true]) void test( `Homeworld ordinary special play Semuta ${claim ? 'claims' : 'passes'} the used card without disturbing occupation`, () => { const f = createSemutaHomeworldOrdinaryFixture(); - assert.equal(f.game.phase, 5); - assert.equal(f.game.active, 'a'); - assert.ok(semutaEmperorPlayer(f.game, f.holder).hand.some(card => card.id === SEMUTA_DRUG_ID)); const played = applyAction(f.game, 'a', f.action); const pending = played.pendingTreacheryDiscard; assert.ok(pending, 'The used ordinary special must offer its printed Semuta cost'); - assert.equal(pending.batch.cause, 'ordinary:hajr'); - assert.deepEqual(pending.batch.entries.map(entry => ({ - id: entry.card.id, by: entry.discardedBy, face: entry.publicFace, - })), [{ id: f.hajr, by: 'a', face: true }]); - assert.equal(pending.reaction?.stage, 'offer'); assert.deepEqual(played.hajr, ['a'], 'The Hajr movement grant already committed'); assert.equal(played.discard.filter(card => card.id === f.hajr).length, 1); - for (const player of played.players) viewGame(played, player.id); const before = semutaHomeworldOrdinaryState(played); const event = pending.batch.event; assert.throws(() => applyAction(played, f.owner, { type: 'semutaCommit', event })); @@ -59,14 +50,14 @@ for (const claim of [false, true]) void test( const searched = applyAction(f.game, f.owner, f.action); const pending = searched.pendingTreacheryDiscard; assert.ok(pending, 'The completed paid Box search must offer its printed Semuta cost'); - assert.equal(pending.batch.cause, 'nullentropyBox'); - assert.deepEqual(pending.batch.entries.map(entry => ({ - id: entry.card.id, by: entry.discardedBy, face: entry.publicFace, - })), [{ id: f.box, by: f.owner, face: true }]); assert.equal(searched.discard.at(-1)?.id, f.box, 'The used Box stays on top of the shuffled pile'); + const continuation = pending.continuation; + assert.equal(continuation.kind, 'nullentropyDiscard'); + if (continuation.kind !== 'nullentropyDiscard') throw Error('Missing completed Box search'); + const selected = continuation.selected; + assert.ok(semutaEmperorPlayer(searched, f.owner).hand.some(card => card.id === selected.id)); assert.equal(semutaEmperorPlayer(searched, f.owner).spice, semutaEmperorPlayer(f.game, f.owner).spice - f.cost); - for (const player of searched.players) viewGame(searched, player.id); const before = semutaHomeworldOrdinaryState(searched); const event = pending.batch.event; assert.throws(() => applyAction(searched, f.holder, { type: 'semutaSelect', event, card: f.box })); @@ -77,6 +68,9 @@ for (const claim of [false, true]) void test( assert.equal(holder.hand.some(card => card.id === f.box), claim); assert.equal(holder.hand.some(card => card.id === SEMUTA_DRUG_ID), !claim); assert.equal(done.discard.some(card => card.id === f.box), !claim); + assert.deepEqual(semutaEmperorPlayer(done, f.owner).hand, + semutaEmperorPlayer(searched, f.owner).hand, + 'The selected card remains in the searching player’s hand without a second search'); assert.deepEqual(semutaHomeworldOrdinaryState(done), before, 'The two-spice payment, searched card and occupation authority are unchanged'); assert.throws(() => applyAction(done, f.owner, f.action)); @@ -172,7 +166,6 @@ for (const claim of [false, true]) void test( assert.equal(answered.truthHistory!.length, 1); assert.equal(answered.truthHistory![0].answer, 'yes'); assert.equal(answered.truthtrance, null); - for (const player of answered.players) viewGame(answered, player.id); const before = semutaHomeworldOrdinaryState(answered); const event = pending.batch.event; assert.throws(() => applyAction(answered, f.holder, { type: 'semutaSelect', event, card: f.truthtrance }));