diff --git a/components/homeworld-revival-deployment.tsx b/components/homeworld-revival-deployment.tsx index f6b56b7a..8b4b4596 100644 --- a/components/homeworld-revival-deployment.tsx +++ b/components/homeworld-revival-deployment.tsx @@ -54,6 +54,14 @@ function DeploymentChoice({ game, act, busy }: Props) {

{offer.normal} ordinary · {offer.elite} starred · {amount} total

+

+ {offer.kind === 'fedaykin' + ? 'Southern placement uses a sector outside the storm.' + : 'Tleilax placement keeps the storm restriction.'} + {game.players.some(player => player.faction === 'ixians') && (offer.kind === 'fedaykin' + ? ' It does not grant the Force Movement route needed to enter the Hidden Mobile Stronghold.' + : ' It does not grant the Ixian route directly into the Hidden Mobile Stronghold.')} +

{game.players.some(player => player.faction === 'beneGesserit') &&

{offer.kind === 'tleilax' ? 'Arrakis placement is followed by the normal Bene Gesserit free-force choice.' diff --git a/components/nexus-richese-betrayal.tsx b/components/nexus-richese-betrayal.tsx index 31faf911..106c2f8c 100644 --- a/components/nexus-richese-betrayal.tsx +++ b/components/nexus-richese-betrayal.tsx @@ -17,8 +17,9 @@ export function NexusRicheseBetrayal({ game, act, busy }: { const canRespond = reaction.canPass && !reaction.hasPassed; const canUse = canRespond && reaction.canUse && !reaction.blocked; const disabled = busy || game.status !== 'playing'; + const blind = reaction.source === 'normal'; // The receipt grants no card-face entitlement. Only the already-public cache - // face is shown; a Black Market face is never inferred from a seller's hand. + // face is shown; concealed ordinary and Black Market faces stay private. const card = reaction.source === 'cache' && game.richeseAuction?.source === 'cache' ? game.richeseAuction.card : null; @@ -27,11 +28,18 @@ export function NexusRicheseBetrayal({ game, act, busy }: {

Richese Nexus Betrayal · before payment

- {buyer?.name ?? 'The buyer'} won {reaction.source === 'cache' ? 'the public cache lot' : 'the concealed Black Market lot'} for {reaction.price} spice. - {reaction.kind === 'purchase' + {buyer?.name ?? 'The buyer'} won {reaction.source === 'cache' ? 'the public cache lot' : blind ? 'the concealed ordinary auction lot' : 'the concealed Black Market lot'} for {reaction.price} spice. + {blind + ? ' This response does not disclose the card’s family or grant inspection before you spend.' + : reaction.kind === 'purchase' ? ` ${target?.name ?? 'Richese'} is buying its own cache card. Betrayal discards that card without payment or delivery.` : ` Payment would go to ${target?.name ?? 'Richese'}. Betrayal sends the same payment to the Spice Bank instead; the buyer still receives the card.`}

+ {blind &&

+ Provisional online rule: spend the Richese Nexus before learning whether this purchase can be stopped. + If it is a Richese card, that card is discarded without payment or delivery. + Otherwise your Nexus remains spent and the original purchase continues. +

} {card &&
} {reaction.source === 'blackMarket' &&

The card remains face down; this response does not grant inspection.

}

@@ -59,7 +67,7 @@ export function NexusRicheseBetrayal({ game, act, busy }: { disabled={disabled} onClick={() => { if (!disabled) act({ type: 'richeseBetrayalUse', event: reaction.event }); }} > - {reaction.kind === 'purchase' ? 'Use Richese Nexus · discard purchase without payment' : 'Use Richese Nexus · divert payment to Spice Bank'} + {blind ? 'Spend Richese Nexus · attempt to stop this purchase' : reaction.kind === 'purchase' ? 'Use Richese Nexus · discard purchase without payment' : 'Use Richese Nexus · divert payment to Spice Bank'} )} diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 7ceb1df9..b739fa3d 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -16,6 +16,50 @@ open mode gates. ## Current checkpoint and work +**Ordinary-deck Richese Nexus purchase veto — 25 October. Prototyped / Partial.** +A positive native Richese ordinary win now opens the existing neutral response +before payment choice. The card’s identity is concealed. Under the selected visible +provisional convention, Use commits the physical Richese Nexus first: an actual +Richese-family card is discarded without payment/acquisition/bonus, while a miss +spends the Nexus and resumes the original purchase. Pass keeps the card. The +same closed quote never reopens. Normal spice/Karama choice, actual allied aid, +Emperor income and next-lot continuation retain their native behavior; no paid +sale or cache lot is fabricated. Public event IDs contain no card identifier. +[Printed source, protocol and boundaries](NEXUS_RICHESE_RULES.md#ordinary-winning-bid-betrayal--25-october-2026). + +Three disjoint helper/controls/source lanes, bounded review and root integration +pass **17 files, 153/153**, types/lint/build and nine actual auction-action/engine/ +GameTable SSR journeys. Source116 Basic/124 Advanced uses genuine original audit +initialization/setup, an actual kept cache Richese Karama spent on a real first +normal purchase, later Worm/alliance/Nexus draw, then real bid3/passes. The one +controlled recycle moves that genuinely discarded card into the still-unopened +next deck; full exhaustion/reshuffle is not claimed. Original main/cache45 and +Nexus12 remain physical. Source125 includes a real phase3 two-spice ally pledge. +Veto, pass, miss and retained native Karama payment run in both rules bands. +The Advanced pledge case preserves the original aid split; four minimal profiles +pass blind attempts. Eight helper and +six actual component callback/profile cases supplement fourteen engine cases. +These are not parent hydration, DOM/browser/mobile or deployed evidence. + +Fixture feedback moves an early pledge into legal Bidding and fixes the first +unopened ordinary face so the no-Karama branch cannot accidentally buy another +Karama. Two readonly malformed-array test constructions are type-corrected; +legacy source-quote mutators only narrow the new frame union. No game rule or +start guard is weakened. Private source-bound logs/HTML: +`/home/quintine/.local/share/dune/checkpoints/20261025-richese-normal-betrayal/`. +No saved-room/D1/server/reset/restart/deployment operation or recovery campaign +occurred. Proactive immediate/private special Karama producers, wider valid +combinations, final assurance and exact-revision deployed acceptance remain due. + +**Selected revival storm/HMS route reasons — 25 October.** Source review retains +the current routes: Southern's named placement does not inherit Fremen storm +shipment permission or bypass the HMS pointing-territory/Force Movement route; +Tleilax retains the Ixian direct off-planet restriction. Southern applications +are visibly provisional. Reasons replace obsolete waiting labels and identify +the route before an outside pointing-sector storm; HMS itself stays sheltered. +Existing destinations are not opened. Focused quote/control/engine/HMS files +pass in the same batch. [Policy and source limits](HOMEWORLD_REVIVAL_DEPLOYMENT_RULES.md#selected-storm-and-hms-route-policy--25-october-2026). + **Revival Terror continuation — 25 October. Prototyped / Partial.** Physical revived entry now reaches native Terror Reveal/Decline, all six effects and Enemy of My Enemy. Exact original return/group metadata survives token diff --git a/docs/HOMEWORLD_REVIVAL_DEPLOYMENT_RULES.md b/docs/HOMEWORLD_REVIVAL_DEPLOYMENT_RULES.md index ff9cf294..09ae4132 100644 --- a/docs/HOMEWORLD_REVIVAL_DEPLOYMENT_RULES.md +++ b/docs/HOMEWORLD_REVIVAL_DEPLOYMENT_RULES.md @@ -2,6 +2,32 @@ Primary-source audit, 10 September 2026. This is a source and implementation-boundary contract, not a runtime completion claim. The coordinator has already asked about threshold restoration and splitting several revived Fedaykin; those answers remain pending. +## Selected storm and HMS route policy — 25 October 2026 + +Use the existing out-of-storm destinations and native HMS entry route, under +the 14 October recommended-option authority. The supplied authorized revision, +physical10/logical8 movement rule and physical32/logical30 Fremen B, separates +ordinary storm restrictions from the named **shipment** exception. Southern +**placement** does not inherit that exceptional shipment permission. This is +selected provisional composition for Southern, not a new Southern FAQ. + +Physical34/logical32 Ix B restricts non-Ix entry to reaching the pointing +territory and then using Force Movement; HMS itself remains storm- and +worm-protected. Southern placement grants no such route override. Tleilax's +off-planet SEND likewise retains the Ixian direct-shipment restriction. Existing +BG/Terror/Ambassador entry reactions do not grant a shipment or movement route. +An occupied interior does not create a revival destination merely because it is +sheltered. Display the route restriction first, even when the pointing sector +is in storm; do not describe the HMS interior as exposed to that storm. + +These choices replace the old waiting-for-ruling labels without opening an +unsupported route, spending another allowance or changing the eligible group. +They do not borrow the source-specific Moritani Cunning HMS exclusion as a +universal user ban. Fresh source review used the supplied44-page PDF visually; +E1/November publisher PDF retrieval returned403, so no fresh publisher +confirmation is claimed. The earlier audit and checkpoints below remain dated +evidence; their unresolved storm/HMS labels are superseded by this policy. + ## Revival Terror continuation — 25 October 2026 **Prototyped / Partial.** The adopted physical SEND-entry interpretation now diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index f8e343d4..a856bbe8 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,52 @@ # Dune implementation status +## 25 October 2026 — Original normal auction reaches Richese Nexus purchase veto + +**Ordinary-deck Richese Nexus purchase veto — 25 October. Prototyped / Partial.** +A positive native Richese ordinary win now opens the existing neutral response +before payment choice. The card’s identity is concealed. Under the selected visible +provisional convention, Use commits the physical Richese Nexus first: an actual +Richese-family card is discarded without payment/acquisition/bonus, while a miss +spends the Nexus and resumes the original purchase. Pass keeps the card. The +same closed quote never reopens. Normal spice/Karama choice, actual allied aid, +Emperor income and next-lot continuation retain their native behavior; no paid +sale or cache lot is fabricated. Public event IDs contain no card identifier. +[Printed source, protocol and boundaries](NEXUS_RICHESE_RULES.md#ordinary-winning-bid-betrayal--25-october-2026). + +Three disjoint helper/controls/source lanes, bounded review and root integration +pass **17 files, 153/153**, types/lint/build and nine actual auction-action/engine/ +GameTable SSR journeys. Source116 Basic/124 Advanced uses genuine original audit +initialization/setup, an actual kept cache Richese Karama spent on a real first +normal purchase, later Worm/alliance/Nexus draw, then real bid3/passes. The one +controlled recycle moves that genuinely discarded card into the still-unopened +next deck; full exhaustion/reshuffle is not claimed. Original main/cache45 and +Nexus12 remain physical. Source125 includes a real phase3 two-spice ally pledge. +Veto, pass, miss and retained native Karama payment run in both rules bands. +The Advanced pledge case preserves the original aid split; four minimal profiles +pass blind attempts. Eight helper and +six actual component callback/profile cases supplement fourteen engine cases. +These are not parent hydration, DOM/browser/mobile or deployed evidence. + +Fixture feedback moves an early pledge into legal Bidding and fixes the first +unopened ordinary face so the no-Karama branch cannot accidentally buy another +Karama. Two readonly malformed-array test constructions are type-corrected; +legacy source-quote mutators only narrow the new frame union. No game rule or +start guard is weakened. Private source-bound logs/HTML: +`/home/quintine/.local/share/dune/checkpoints/20261025-richese-normal-betrayal/`. +No saved-room/D1/server/reset/restart/deployment operation or recovery campaign +occurred. Proactive immediate/private special Karama producers, wider valid +combinations, final assurance and exact-revision deployed acceptance remain due. + +**Selected revival storm/HMS route reasons — 25 October.** Source review retains +the current routes: Southern's named placement does not inherit Fremen storm +shipment permission or bypass the HMS pointing-territory/Force Movement route; +Tleilax retains the Ixian direct off-planet restriction. Southern applications +are visibly provisional. Reasons replace obsolete waiting labels and identify +the route before an outside pointing-sector storm; HMS itself stays sheltered. +Existing destinations are not opened. Focused quote/control/engine/HMS files +pass in the same batch. [Policy and source limits](HOMEWORLD_REVIVAL_DEPLOYMENT_RULES.md#selected-storm-and-hms-route-policy--25-october-2026). + + ## 25 October 2026 — Original revival reaches Terror and ordered arrival children **Revival Terror continuation — 25 October. Prototyped / Partial.** diff --git a/docs/NEXUS_RICHESE_RULES.md b/docs/NEXUS_RICHESE_RULES.md index b6d8868a..f25a7cc4 100644 --- a/docs/NEXUS_RICHESE_RULES.md +++ b/docs/NEXUS_RICHESE_RULES.md @@ -2,6 +2,96 @@ Audited 10 September 2026, with a bounded Betrayal implementation contract added 30 September. This separates the ordinary Secret Ally shipment, native Cunning's two-marker operation and Betrayal's two auction outcomes. It does not certify full-family implementation or open the Nexus release gate. See [common Nexus rules](NEXUS_CARD_RULES.md), [auction custody and source boundaries](RICHESE_AUCTION_RULES.md), [No-Field custody](RICHESE_NO_FIELD_RULES.md), and [allied No-Field distinctions](RICHESE_ALLIED_SHIPMENT_AUDIT.md). +## Ordinary winning-bid Betrayal — 25 October 2026 + +**Prototyped / Partial.** The printed purchase-veto alternative has no cache- +origin limit. A Richese-family card may reach the ordinary discard/deck after +actual play. A positive native Richese winning bid in the existing supported +Richese/CHOAM Nexus profile now opens the neutral acknowledgement before native +Emperor funding and the original spice/Karama payment chooser. Keep the actual +ordinary auction; do not fabricate a Richese cache lot, paid sale or acquisition. + +**Selected blind-attempt convention:** under the 14 October recommended-option +authority, all publicly possible responders acknowledge the same boundary for +either hidden family. Privately eligible Use commits the physical Richese Nexus +first. Only then does an actual Richese-family card cause veto: discard that +exact reserved card without payment, delivery or purchase bonus, then advance +its native lot/round once. The exact face is not publicly announced. On another +family, the Nexus remains spent and the original purchase resumes. This is a +visibly provisional online convention, not printed voluntary-discard permission +or an established physical-table ruling for failed attempts. + +Pass keeps the Nexus. All-pass and a spent miss resume the original payment +choice and later native funding/income/delivery exactly once; the same closed +winning quote does not open a new counter. A later payment method can still +be Karama. The receipt's initial split is an unpaid winning quote, not proof +that spice was debited or that a later native funding effect already occurred. +No earlier bid, source card, price or recipient is changed by this pause. + +The native Richese Nexus face is recorded in the original GF9 component +photograph linked below; E2 pp4–6 covers face-down bidding and Richese cards +entering normal discard. The family symbol is on the face. No identifying back +or mandatory public family announcement was established by this review. Do not +invent a public family flag or family-dependent Use availability. Only public +held-Nexus presence determines the acknowledgement roster; ordinary card and +payment/Nexus identities remain in existing private views. A costed outcome is +not prior inspection. The persistent control explains both consequences before +commitment. Minimal profiles pass blind ordinary attempts; strategy is deferred. + +Fresh normal frames bind their original live pool index, round, opaque lot ID, +physical card, winning quote and source controls. Public event IDs contain no +card identifier. Intrinsic receipt shape and old cache/Black Market signature +bytes remain unchanged. The legacy alternatives keep their existing native +continuations. Proactive immediate Karama acquisition and private special- +Karama acquisition remain separate outstanding producers. Wider profile, +combination, recovery/privacy assurance and deployed acceptance are not claimed. + +Evidence: [invoice/outcome helper](../game/nexus-richese-betrayal.ts), +[normal source cases](../tests/nexus-richese-betrayal-normal.test.ts), +[isolated controls](../components/nexus-richese-betrayal.tsx), +[controls/profile cases](../tests/nexus-richese-betrayal-normal-controls.test.tsx), +[controlled recycle producer](../tests/fixture-richese-normal-betrayal.ts), +[actual continuation cases](../tests/richese-normal-betrayal-engine.test.ts). +The source distinguishes genuinely earned setup/cache play/discard/Nexus/bids +from the one controlled placement of an already discarded card into the unopened +ordinary deck. It does not claim a native full-deck exhaustion/reshuffle journey. +The older bounded contract's normal-self-purchase exclusion below is superseded +only for this original winning-bid boundary. + + +Frozen **17 files, 153/153**, types/lint/build and nine actual auction-action/ +engine/GameTable SSR journeys pass on unchanged source. Eight helper and six +connected component callback/profile cases accompany fourteen ordinary-source +engine cases. Basic116/Advanced124 original actions earn audit/setup, actual +cache Richese Karama retention and first normal winning-bid Karama payment, +then native Worm/alliance/Nexus draw and the new bid3 boundary. Only one labelled +recycle from that true discard into an unopened deck is controlled; native +full-deck exhaustion is not claimed. An additional original125-action case earns +a real phase3 two-spice ally pledge. Main/cache45 and Nexus12 inventories remain +physical. Success, pass, miss, paid/free original choice and aided invoice are +exercised; all four minimal profiles legally pass blind normal attempts. + +The ordinary frame adds private `normal:{round,lot,index}` beside the existing +receipt/signature. Public `richeseBetrayalReaction.source` additionally accepts +`normal`; its other fields/actions are unchanged. The lot UUID is opaque and +contains no private card identity. Legacy pending-source mutation tests narrow +the old quote/lot branch for TypeScript; the deferred recovery test has only +that type guard change and no recovery campaign is run. + +The first runtime catches a fixture pledge made in Charity; the actual action +is moved to Bidding. First related proof is152/153 because Advanced could earn +another Karama in the deliberately no-retained-K source; ordering an actual +still-unopened first Shield repairs that source without replacing a dealt or +played hand. Final type feedback replaces two mutations of a readonly fixture +array with equivalent malformed copies. No rules, profiles or start guards +are relaxed. Private matching code fingerprints, logs and rendered HTML: +`/home/quintine/.local/share/dune/checkpoints/20261025-richese-normal-betrayal/`. +Exact native discard/reshuffle, proactive immediate Karama, private special +acquisition, wider combinations, complete modes, final recovery/privacy/rules +assurance and browser/mobile/deployed acceptance remain unfinished. These +journeys are engine/SSR, not parent hydration or DOM clicks. No saved-room/D1/ +server/reset/restart/deployment operation occurred. + ## Purchased Karama rate with native Cunning — 25 October 2026 **Prototyped / Partial.** The native unallied two-token Cunning shipment now diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 1901b998..0c50b9dd 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1,5 +1,34 @@ # Rule decision index +## Richese Nexus ordinary-purchase blind attempt — 25 October 2026 + +The printed Richese veto is not cache-only, but an ordinary recycled card's +family may be concealed. Under the 14 October recommended-option authority, +use uniform publicly possible acknowledgements and a committed blind attempt: +spend the singleton Nexus before checking family. Actual Richese card: discard +without payment/acquisition and advance the native lot. Other family: spend +remains, original purchase continues. Pass retains the card; the same closed +quote does not reopen. No prior family flag or exact-face reveal is introduced. +This spend-on-miss convention is explicitly provisional online composition; +primary rules did not establish general voluntary Nexus discard or an identifying +family back. Existing cache/Black Market alternatives, private eligibility, +native later payment choice and source/profile guards remain. Proactive and +special Karama acquisition are still outstanding producers. +[Printed source, knowledge policy and scope](NEXUS_RICHESE_RULES.md#ordinary-winning-bid-betrayal--25-october-2026). + +## Revived placement retains storm and HMS routes — 25 October 2026 + +Under the 14 October recommended-option authority, keep Southern placement +outside storm rather than importing the Fremen **shipment** exception. Retain +the HMS pointing-territory/Force Movement route for Southern and the Ixian-only +direct off-planet route for Tleilax. Southern's applications are explicitly +provisional composition, not a new publisher Homeworld FAQ. HMS itself remains +storm-protected; its route reason takes precedence over the outside pointer's +storm. Prior named-entry reactions grant no route permission and the separate +Moritani Cunning user ruling supplies no general HMS ban. Supplied physical10, +32 and34 support the distinctions; fresh publisher PDF fallback returned403. +[Selected policy and source limits](HOMEWORLD_REVIVAL_DEPLOYMENT_RULES.md#selected-storm-and-hms-route-policy--25-october-2026). + ## Current Traitor-declaration Truthtrance — 25 October 2026 Compose the existing general current-turn rule with an actual unsubmitted @@ -1307,7 +1336,7 @@ These are unresolved in the cited records. Do not infer an answer from elapsed t | Repeat Karama on a canceled compulsory cache lot | After one Karama cancels the compulsory Richese cache auction and restores the ordinary pool, the engine currently still accepts a second Karama against the resumed window and re-derives the ordinary count from the frozen pre-cache field. Whether a repeat cancellation should be refused is not settled; the invariant that a canceled lot never restores more than its frozen pre-cache count does hold. | [Richese auction source](RICHESE_AUCTION_RULES.md), [exhausted-cache runtime](IMPLEMENTATION_STATUS.md) | | Base special Karama | Unfunded auction recovery, stopped-shipment settlement and Atreides full-plan commitment order remain provisional. Supplied physical27 F now sets its before-public-reveal deadline, including quiet later use after declining the initial offer. Ordinary preparation priority and target-first sequencing remain implementation compositions, with sealed plans retained and no added reading acknowledgement. | [Explicit base interpretations](BASE_ADVANCED_READINESS_20260907.md#explicit-unresolved-base-interpretations), [current full-plan timing](ATREIDES_FULL_PLAN_TIMING.md#before-reveal-window--24-october-2026) | | Homeworld high-threshold revival deployment | The publisher does not state whether a return that itself first reaches the high threshold may deploy on that same return. The content-first prototype evaluates the resulting population, so the crossing return qualifies; this is a visibly provisional composition, not a located timing ruling. The Tleilaxu low-income start-condition snapshot is unchanged, and partial groups, split destinations, storm placement, allied-Homeworld limits and other entry effects retain their documented boundaries. | [Benefits contract](HOMEWORLD_BENEFITS_RULES.md#provisional-high-threshold-revival-deployment--9-october-2026), [resumption record](IMPLEMENTATION_STATUS.md#9-october-2026--gated-captures-resumed-under-the-basic-occupation-and-odd-force-occupy-prototypes) | -| Revived force arrival / BG | Advanced Intrusion follows supplied p28 entry wording. Provisionally treat off-planet Tleilax SEND to Arrakis as earning ordinary BG Spiritual accompaniment; Southern is on-planet and Homeworld destinations are separate. Keep committed return/placement, ordinary counters, original income and one deferred Ambassador entry; add no fee/ordinary allowance/Guild stop/phase4 industry. Phase4 Spiritual binds the exact return or owned Guild child. Terror continuation remains explicitly unfinished. | [Current source and policy](HOMEWORLD_REVIVAL_DEPLOYMENT_RULES.md#revival-arrival-responses--25-october-2026) | +| Revived force arrival / BG | Advanced Intrusion follows supplied p28 entry wording. Provisionally treat off-planet Tleilax SEND to Arrakis as earning ordinary BG Spiritual accompaniment; Southern is on-planet and Homeworld destinations are separate. Keep committed return/placement, ordinary counters, original income and one deferred Ambassador entry; add no fee/ordinary allowance/Guild stop/phase4 industry. Phase4 Spiritual binds the exact return or owned Guild child. The later Terror continuation retains the original return, ordered Ambassador opportunity and actual Sneak children; complete combinations remain unfinished. | [Current source and policy](HOMEWORLD_REVIVAL_DEPLOYMENT_RULES.md#revival-arrival-responses--25-october-2026) | | Occupied Homeworlds | The publisher question about retention after departure, replacement or contest, unique-power composition and shared income remains open; the first-occupier/current-occupier question was already sent. The content-first Basic benefit selector now uses the visibly provisional current-sole policy above (Tupile excepted), which is an implementation choice, not an answer to that question. | [Occupation questions](HOMEWORLD_OCCUPATION_RULES.md#questions-the-retrieved-sources-do-not-settle), [lifecycle audit](HOMEWORLD_OCCUPATION_LIFECYCLE_AUDIT.md), [Basic policy](HOMEWORLD_OCCUPATION_RULES.md#basic-current-sole-benefit-policy--9-october-2026) | | Nexus responses and composition | Betrayal private-response timing, Emperor fewer-than-five, fresh advisor/storm cases, Duke restoration and remaining faction-specific combinations remain source-bound. Tleilaxu Secret Ally now has the explicitly provisional recommended combined policy in its own row below; it is not borrowed authority from native revival or Ghola. | [Common boundaries](NEXUS_CARD_RULES.md#remaining-source-questions-and-release-boundaries) and individual faction source/runtime documents | | Tleilaxu Secret Ally revival | Under choose-recommended-option priority, a quiet unallied actual holder with Tleilaxu absent returns one mandatory own dead leader free, using the ordinary leader slot, and zero-to-five exact counters at one bank spice each. The card shares an ordinary ceiling expanded to at least five; prior normal returns count, larger native/Recruits allowances remain, and the fixed card group is never doubled. Only the unavailable-roster trigger is waived; repeated-death/face-down and special custody rules remain, with original ordinary cycles preserved. No force-only use, native Tleilaxu income or nonexistent cancellation owner is invented. This is provisional implementation policy, not publisher clarification or a separately answered numerical user ruling. | [Recommended combined policy](NEXUS_TLEILAXU_RULES.md#recommended-combined-revival-prototype--14-october-2026) | diff --git a/game/bot-nexus-richese-betrayal.ts b/game/bot-nexus-richese-betrayal.ts index 6d6de40b..349a0b5c 100644 --- a/game/bot-nexus-richese-betrayal.ts +++ b/game/bot-nexus-richese-betrayal.ts @@ -9,7 +9,8 @@ export function botNexusRicheseBetrayalActions(game: GameView): Action[] { const own = game.players.find((player) => player.id === game.me); if (!(own?.bot ?? own?.autopilot)) return []; return [{ - type: reaction.canUse && !reaction.blocked ? 'richeseBetrayalUse' : 'richeseBetrayalPass', + // Minimal policies keep the card instead of guessing a concealed ordinary lot. + type: reaction.source !== 'normal' && reaction.canUse && !reaction.blocked ? 'richeseBetrayalUse' : 'richeseBetrayalPass', event: reaction.event, }]; } diff --git a/game/engine.ts b/game/engine.ts index 1e22ba3e..94c6d29e 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -531,7 +531,7 @@ import { STORM_START_SECTOR, PLAYER_CIRCLE_SECTORS } from './player-positions'; import { cashInCards } from './choam-karama'; import { kullBlocksKarama, activeKullRestrictions, validateKullPhaseRestrictions, validateKullAttemptStamp, kullNativeCostCards, kullNexusCostCards, kullCounterCards, type KullPhaseRestriction } from './choam-kull'; import { kullCancellationPurpose, kullSpecialPurpose, kullPurposeLabel, type KullPurpose } from './kull-purpose'; -import { quoteRicheseBetrayalInvoice, requiredRicheseBetrayalResponders, richeseBetrayalEligibility, richeseBetrayalEvent, createRicheseBetrayalReceipt, validateRicheseBetrayalCursor, type RicheseBetrayalCursor, type RicheseBetrayalReceipt } from './nexus-richese-betrayal'; +import { quoteRicheseBetrayalInvoice, quoteRicheseNormalBetrayalInvoice, richeseBetrayalVetoesPurchase, requiredRicheseBetrayalResponders, richeseBetrayalEligibility, richeseBetrayalEvent, createRicheseBetrayalReceipt, validateRicheseBetrayalCursor, type RicheseBetrayalCursor, type RicheseBetrayalReceipt } from './nexus-richese-betrayal'; import { saleOptions, quoteSale, type ChoamMarket } from './choam-market'; import { highKaitainDiscardsAvailable, quoteKaitainDiscards, homeworldWorthlessSaleBlock } from './homeworld-card-economy'; import { biddingEndError, biddingEndQuiet, biddingEndPubliclyEmpty, type BiddingEnd } from './bidding-end'; @@ -1370,10 +1370,14 @@ export type Game = { richeseBetrayal?: RicheseBetrayalCursor; pendingRicheseBetrayal?: { receipt: RicheseBetrayalReceipt; + signature: string; + } & ({ lot: RicheseAuction; quote: Extract; - signature: string; - } | null; + } | { + /** The actual unpaid ordinary pool, never a fabricated Richese cache lot. */ + normal: { round: string; lot: string; index: number }; + }) | null; /** Original normal-auction obligation; neutral acknowledgements precede funding. */ nexusEmperorPayment?: { sequence: number; current: string | null; completed: EmperorPaymentReceipt[] }; pendingNexusEmperorPayment?: { receipt: EmperorPaymentReceipt } | null; @@ -12718,6 +12722,12 @@ function richeseBetrayalResponders(g: Game): string[] { }))); } function richeseBetrayalSourceSignature(g: Game, frame: NonNullable): string { + if ('normal' in frame) return JSON.stringify({ + event:frame.receipt.event,normal:frame.normal,turn:g.turn,phase:g.phase,status:g.status, + round:g.richeseBidding,auction:g.auction,order:g.order,aid:g.aid, + players:g.players.map(p => ({id:p.id,faction:p.faction,ally:p.ally,hand:p.hand,spice:p.spice})), + active:g.active,decision:g.decision,response:g.response,phaseOpening:g.phaseOpening, + }); return JSON.stringify({ event:frame.receipt.event,lot:frame.lot,quote:frame.quote,turn:g.turn,phase:g.phase,status:g.status, round:g.richeseBidding,auction:g.richeseAuction,order:g.order,positions:normalizedPlayerPositions(g), @@ -12736,6 +12746,38 @@ function richeseBetrayalInvoice(g: Game, quote: Extract quoteRicheseNormalBetrayalInvoice({ + players:g.players,auction,donor:auction.allyPayment ? getPlayer(g,auction.bidder!).ally : null, + originalRecipient:byFaction(g,'emperor')?.id ?? null,physicalCards:physicalTreacheryCards(g), + })); +} +/** This is an unpaid ordinary winner, not a fabricated cache sale. Family is + * deliberately absent from admission, acknowledgement and payment choices. */ +function normalRicheseBetrayalPreflight(g: Game) { + requireRule(richeseBetrayalModeSupported(g) && g.status === 'playing' && g.phase === 3 && + g.richeseBidding?.stage === 'normal' && g.richeseBidding.turn === g.turn && + g.auction && !g.richeseAuction && !g.currentAuctionSale && + !g.response && !g.decision && !g.phaseOpening && !g.truthtrance && + !g.pendingKarama && !g.pendingNullentropy && !g.pendingTreacheryDiscard && + !g.pendingNexusEmperorPayment && !g.pendingNexusEmperorSellerPurchase && + !g.pendingKull && !g.pendingChoamWorthless && !g.choamMarket && !g.pendingIxAlly && + !g.pendingRicheseGift && !g.pendingRichesePurchaseIncome && !g.biddingEnd && + !g.bureaucratPayments?.pending && !pendingNexusTraitors(g), + 'Richese ordinary-purchase Betrayal needs its original unpaid normal winner.'); + const auction=g.auction!; + const next=quoteNormalAuctionNext({order:auction.saphoOrder ?? g.order, + eligible:g.order.filter(id=>getPlayer(g,id).hand.length): void { requireRule(richeseBetrayalModeSupported(g) && g.status === 'playing' && g.phase === 3 && g.richeseBidding?.stage === 'lot' && g.richeseBidding.turn === g.turn && @@ -12795,22 +12837,35 @@ function richeseBetrayalIntegrity(g: Game): void { nexusCardsIntegrity(g); nexusRule(() => validateRicheseBetrayalCursor(g,cursor,frame?.receipt ?? null)); if (frame) { - requireRule(Object.keys(frame).sort().join(',') === 'lot,quote,receipt,signature' && - frame.quote?.kind === 'sold' && frame.lot && typeof frame.signature === 'string', - 'The saved Richese Betrayal source frame is malformed.'); - richeseBetrayalSourcePreflight(g,frame.quote); - requireRule(frame.signature === richeseBetrayalSourceSignature(g,frame) && - JSON.stringify(frame.lot) === JSON.stringify(g.richeseAuction) && - JSON.stringify(frame.quote) === JSON.stringify(richeseSettlementQuote(g)) && - JSON.stringify(frame.receipt.invoice) === JSON.stringify(richeseBetrayalInvoice(g,frame.quote)) && - frame.receipt.round === g.richeseBidding!.event && frame.receipt.lot === g.richeseAuction!.event && - frame.receipt.turn === g.turn && - JSON.stringify(frame.receipt.required) === JSON.stringify(richeseBetrayalResponders(g)), - 'The saved Richese Betrayal opportunity lost its original quote, custody, responders or source continuation.'); + if ('normal' in frame) { + const invoice=normalRicheseBetrayalPreflight(g); + requireRule(Object.keys(frame).sort().join(',') === 'normal,receipt,signature' && + Object.keys(frame.normal).sort().join(',') === 'index,lot,round' && + frame.normal.index === g.auction!.index && frame.normal.round === g.richeseBidding!.event && + frame.normal.lot === frame.receipt.lot && frame.receipt.round === frame.normal.round && + frame.receipt.turn === g.turn && frame.receipt.invoice.source === 'normal' && + JSON.stringify(frame.receipt.invoice) === JSON.stringify(invoice) && + frame.signature === richeseBetrayalSourceSignature(g,frame) && + JSON.stringify(frame.receipt.required) === JSON.stringify(richeseBetrayalResponders(g)), + 'The ordinary Richese reaction lost its original unpaid pool, invoice or acknowledgements.'); + } else { + requireRule(Object.keys(frame).sort().join(',') === 'lot,quote,receipt,signature' && + frame.quote?.kind === 'sold' && frame.lot && typeof frame.signature === 'string', + 'The saved Richese Betrayal source frame is malformed.'); + richeseBetrayalSourcePreflight(g,frame.quote); + requireRule(frame.signature === richeseBetrayalSourceSignature(g,frame) && + JSON.stringify(frame.lot) === JSON.stringify(g.richeseAuction) && + JSON.stringify(frame.quote) === JSON.stringify(richeseSettlementQuote(g)) && + JSON.stringify(frame.receipt.invoice) === JSON.stringify(richeseBetrayalInvoice(g,frame.quote)) && + frame.receipt.round === g.richeseBidding!.event && frame.receipt.lot === g.richeseAuction!.event && + frame.receipt.turn === g.turn && + JSON.stringify(frame.receipt.required) === JSON.stringify(richeseBetrayalResponders(g)), + 'The saved Richese Betrayal opportunity lost its original quote, custody, responders or source continuation.'); + } } const sale = g.currentAuctionSale; const completed = cursor.completed.at(-1); - if (completed && completed.lot === g.richeseAuction?.event) { + if (completed && completed.invoice.source !== 'normal' && completed.lot === g.richeseAuction?.event) { const waiting = g.pendingNexusEmperorSellerPurchase; requireRule(g.status === 'playing' && g.phase === 3 && completed.turn === g.turn && completed.round === g.richeseBidding?.event && @@ -12868,6 +12923,27 @@ function beginRicheseBetrayal(g: Game, quote: Extractreceipt.round===round && receipt.invoice.source==='normal' && + JSON.stringify(receipt.invoice)===JSON.stringify(invoice))) return false; + const required=richeseBetrayalResponders(g); + if (!required.length) return false; + const sequence=cursor.sequence+1,lot=crypto.randomUUID(); + const receipt=nexusRule(()=>createRicheseBetrayalReceipt(g,{ + event:richeseBetrayalEvent(g.turn,sequence,round,lot),turn:g.turn,sequence,round,lot, + invoice,required,passed:[],stage:'pending',actor:null,nexusDiscardIndex:null, + })); + const frame:NonNullable={receipt, + normal:{round,lot,index:g.auction.index},signature:''}; + frame.signature=richeseBetrayalSourceSignature(g,frame); + cursor.sequence=sequence;cursor.current=receipt.event;g.pendingRicheseBetrayal=frame; + return true; +} function decideRicheseBetrayal(g: Game, p: Player, action: Action): void { const frame = g.pendingRicheseBetrayal!, receipt = frame.receipt; requireRule(['richeseBetrayalPass','richeseBetrayalUse'].includes(action.type) && @@ -12890,6 +12966,19 @@ function decideRicheseBetrayal(g: Game, p: Player, action: Action): void { g.richeseBetrayal!.completed.push(next); g.richeseBetrayal!.current = null; g.pendingRicheseBetrayal = null; + if ('normal' in frame) { + // Cost precedes the private family test. A miss cannot be a free oracle. + if (use && richeseBetrayalVetoesPurchase(receipt.invoice)) { + const continuation=currentAuctionContinuationQuote(g,{kind:'next'}); + g.discard.push(g.auction!.cards[g.auction!.index]); + log(g,`${p.name} spent Richese Nexus Betrayal and prevented the ordinary purchase. The reserved card was discarded without payment or acquisition; its face stays private.`); + commitAuctionContinuation(g,continuation); + } else { + if (use) log(g,`${p.name} spent a committed Richese Nexus Betrayal attempt. The original ordinary purchase continues; the response grants no card inspection.`); + auctionNext(g); + } + return; + } if (use && receipt.invoice.kind === 'purchase') { const card = frame.quote.card; g.richeseCache = g.richeseCache!.filter(c => c.id !== card.id); @@ -13968,6 +14057,7 @@ function auctionNext(g: Game) { finishNormalBidding(g); return; } + if (beginNormalRicheseBetrayal(g)) return; if (beginNexusEmperorPayment(g)) return; g.decision = { kind: 'auctionPayment', player: next.player }; g.active = next.player; diff --git a/game/homeworld-revival-destinations.ts b/game/homeworld-revival-destinations.ts index 388e8820..1ce97c45 100644 --- a/game/homeworld-revival-destinations.ts +++ b/game/homeworld-revival-destinations.ts @@ -35,12 +35,14 @@ export function homeworldRevivalDestinations( const effectiveSector = t.id === MOBILE_STRONGHOLD ? splitLocation(g.mobileStronghold!.location!).sector : sector; let blocked = grant.blocked; + if (!blocked && t.id === MOBILE_STRONGHOLD) + blocked = grant.kind === 'fedaykin' + ? 'Southern Hemisphere placement does not bypass the mobile stronghold’s entry route. Reach its pointing territory and enter by Force Movement.' + : 'Only Ixians may enter the mobile stronghold directly from off-planet reserves. Tleilax revival deployment does not grant that route.'; if (!blocked && effectiveSector !== 0 && effectiveSector === g.storm) blocked = grant.kind === 'fedaykin' - ? 'Fedaykin revival placement into storm awaits its own permission ruling.' + ? 'Southern Hemisphere placement must use a sector outside the storm. The Fremen storm-shipment exception does not grant revival placement.' : 'Revival deployment cannot enter a sector in storm.'; - if (!blocked && t.id === MOBILE_STRONGHOLD) - blocked = 'Revival placement in the Hidden Mobile Stronghold awaits its shipment-classification ruling.'; blocked ??= territoryEntryBlock(g.players, p.id, t.id); return { id: location(t.id, sector), name: `${t.name}${sector ? ` · sector ${sector}` : ''}`, territory: t.id, sector, blocked }; diff --git a/game/nexus-richese-betrayal.ts b/game/nexus-richese-betrayal.ts index 1ba5e1e2..cba85877 100644 --- a/game/nexus-richese-betrayal.ts +++ b/game/nexus-richese-betrayal.ts @@ -1,4 +1,5 @@ import type { Card } from './cards'; +import type { Game } from './engine'; import { nexusCardMode, validateNexusCards, validateNexusPlayers, type NexusPlayer, type NexusState } from './nexus-cards'; import type { RicheseAuction } from './richese-auction'; import { richeseCardDefinition } from './richese-cards'; @@ -8,7 +9,7 @@ export type RicheseBetrayalInvoice = { kind: 'purchase' | 'sale'; target: string; buyer: string; - source: 'cache' | 'blackMarket'; + source: 'cache' | 'blackMarket' | 'normal'; price: number; ownPayment: number; allyPayment: number; @@ -87,18 +88,20 @@ function validateInvoice(players: readonly NexusPlayer[], invoice: RicheseBetray const target = players.find(p => p.id === invoice.target); const buyer = players.find(p => p.id === invoice.buyer); requireBetrayal(target?.faction === 'richese' && buyer && - ['cache', 'blackMarket'].includes(invoice.source) && + ['cache', 'blackMarket', 'normal'].includes(invoice.source) && whole(invoice.price) && invoice.price > 0 && whole(invoice.ownPayment) && whole(invoice.allyPayment) && invoice.allyPayment <= invoice.price && invoice.ownPayment === invoice.price - invoice.allyPayment && (invoice.allyPayment === 0 ? invoice.donor === null : text(invoice.donor) && invoice.donor !== invoice.buyer && players.some(p => p.id === invoice.donor)) && validCard(invoice.card), 'Richese Betrayal needs a positive original price, exact funding split and physical card.'); if (invoice.kind === 'purchase') { - requireBetrayal(invoice.source === 'cache' && buyer.id === target.id && richeseCardDefinition(invoice.card) && + requireBetrayal((invoice.source === 'normal' || invoice.source === 'cache' && richeseCardDefinition(invoice.card)) && + buyer.id === target.id && invoice.originalRecipient === (players.find(p => p.faction === 'emperor')?.id ?? null), - 'Richese self-veto requires a real public cache purchase paid to Emperor or bank.'); + 'Richese self-veto requires an original cache or normal purchase paid to Emperor or bank.'); } else { - requireBetrayal(invoice.kind === 'sale' && buyer.id !== target.id && invoice.originalRecipient === target.id && + requireBetrayal(invoice.kind === 'sale' && invoice.source !== 'normal' && + buyer.id !== target.id && invoice.originalRecipient === target.id && (invoice.source !== 'cache' || richeseCardDefinition(invoice.card)), 'Richese sale diversion requires another buyer and income originally owed to Richese.'); } @@ -143,6 +146,51 @@ export function quoteRicheseBetrayalInvoice(input: { return invoice; } +/** Bind an ordinary Richese purchase without using the concealed card's family + * to decide whether the blind Nexus counter is available. Native settlement + * separately proves the payer's actual wallet and pledged ally funding. */ +export function quoteRicheseNormalBetrayalInvoice(input: { + players: readonly NexusPlayer[]; + auction: Pick, 'cards' | 'index' | 'bid' | 'bidder' | 'allyPayment'>; + donor: string | null; + originalRecipient: string | null; + physicalCards: readonly Card[]; +}): RicheseBetrayalInvoice { + requireBetrayal(plain(input), 'Richese Betrayal needs an original normal auction.'); + validatePlayers(input.players); + const auction = input.auction; + requireBetrayal(plain(auction) && Array.isArray(auction.cards) && auction.cards.every(validCard) && + whole(auction.index) && auction.index < auction.cards.length && whole(auction.bid) && auction.bid > 0 && + text(auction.bidder) && input.players.some(player => player.id === auction.bidder && player.faction === 'richese'), + 'Richese Betrayal needs the native Richese winner and its current positive normal lot.'); + const allyPayment = auction.allyPayment === undefined ? 0 : auction.allyPayment; + const invoice: RicheseBetrayalInvoice = { + kind: 'purchase', target: auction.bidder, buyer: auction.bidder, source: 'normal', + price: auction.bid, ownPayment: auction.bid - allyPayment, allyPayment, + donor: input.donor, originalRecipient: input.originalRecipient, + card: { ...auction.cards[auction.index] }, + }; + validateInvoice(input.players, invoice); + const buyer = input.players.find(player => player.id === invoice.buyer)!; + requireBetrayal(!invoice.donor || buyer.ally === invoice.donor && + input.players.find(player => player.id === invoice.donor)?.ally === buyer.id, + 'Richese Betrayal cannot substitute an unallied contributor.'); + for (const cards of [auction.cards, input.physicalCards]) { + requireBetrayal(Array.isArray(cards) && cards.every(validCard), 'Richese Betrayal needs physical source custody.'); + const matches = cards.filter(card => card.id === invoice.card.id); + requireBetrayal(matches.length === 1 && sameCard(matches[0], invoice.card), + 'Richese Betrayal has lost or duplicated its exact source card.'); + } + return invoice; +} + +/** Evaluate only after the physical Nexus has been committed. A normal-lot + * miss still records a used counter; it grants no earlier face inspection. */ +export function richeseBetrayalVetoesPurchase(invoice: RicheseBetrayalInvoice): boolean { + return invoice.kind === 'purchase' && + (invoice.source !== 'normal' || !!richeseCardDefinition(invoice.card)); +} + /** Public held-presence only: private Nexus identities never affect timing. */ export function requiredRicheseBetrayalResponders( players: readonly (NexusPlayer & { nexusHeld: boolean })[], diff --git a/game/reference.ts b/game/reference.ts index ec1578e7..0c30cdf1 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -4623,7 +4623,7 @@ export const RULE_TOPICS: RuleTopic[] = [ area: 'Implementation', status: 'Partial', detail: - "All 13 cards, saved typed custody, supported-deck setup, Emperor movement, native Arrakis shipment sources, revival destinations, low charity/free-revival benefits, Tleilax’s phase-start income penalty and high-Salusa free Sardaukar support are connected. World-to-world invasion, Guild transport from Arrakis and Guild native returns use exact source pools, funding and interception. Homeworld battles connect native bonuses, limited explosion casualties, native-only Traitors and Face Dancers, and Ixian substitution. Junction offers connect sponsored board and Homeworld routes, including own returns, foreign departures and their distinct payments. Caladan foresight, Wallach advisor counts, Ix stronghold movement and Richese token movement now respect native population. High Tupile blocks Worthless sales and Ambassador payouts, including after an interrupted Ghola revival. Kaitain paid discards share the closing Bidding opportunity with CHOAM; Ecaz poison income uses actual physical disposal and current native population. Mandatory winning-card disposal follows winner casualties. Supported low Kaitain and Junction payment receipts preserve the payer’s cost and use current native population; divergent allied rounding remains guarded. Giedi grants its once-per-phase bonus on actual positive desert receipts, including settled Ecaz shares. Low Grumman uses original public entry counts across Terror and competing-reaction checks. Richese High now connects actual paid reserve companions, one combined provisional invoice, original marker proof/responses, physical withdrawal and later reserve-capped reveal. Nexus Cunning retains that explicit paid group in its pair receipt, reserving paid counters before immediate materialization; original counter and spent-card outcomes remain. Ix High now returns physical Suboids for paid Cyborgs with provisional additional allowance and shortage handling, retaining typed free allocation, original invoice and free-event income. Southern and Tleilax preserve the exact eligible revival group through one optional placement. Provisional physical entry now opens native BG, Terror and Ambassador responses, retaining the original held income, independent counters and ordered overlap; a committed Sneak group has its own arrival source. Southern storm and Hidden Mobile Stronghold classification remain unresolved. Fresh occupation additionally connects bank and percentage receipts, completed Southern Collection, immediate sharing, Caladan inspection, displaced Ix pool, Richese card-only choice with native terms, real Giedi receiver choice and native protections. Fresh native CHOAM entries additionally connect original Tupile slots, actual normal-limit cleanup and restored low-query authority without resetting usage. Advanced retains sole-qualified ownership until last departure. Basic Tupile provisionally follows current sole control: observed departure, contest or native high population ends its slot, while a new activation waits for earlier normal-limit cleanup. Continuous turns do not stack slots or reset intelligence use. Exceptional Duke, concealed transport and full/public acceptance remain unfinished. Already purchased ordinary Karama now composes with immune Junction through explicit unused-price acknowledgment; original tariff/payees stay unchanged and only delivered transport consumes its grant.", + "All 13 cards, saved typed custody, supported-deck setup, Emperor movement, native Arrakis shipment sources, revival destinations, low charity/free-revival benefits, Tleilax’s phase-start income penalty and high-Salusa free Sardaukar support are connected. World-to-world invasion, Guild transport from Arrakis and Guild native returns use exact source pools, funding and interception. Homeworld battles connect native bonuses, limited explosion casualties, native-only Traitors and Face Dancers, and Ixian substitution. Junction offers connect sponsored board and Homeworld routes, including own returns, foreign departures and their distinct payments. Caladan foresight, Wallach advisor counts, Ix stronghold movement and Richese token movement now respect native population. High Tupile blocks Worthless sales and Ambassador payouts, including after an interrupted Ghola revival. Kaitain paid discards share the closing Bidding opportunity with CHOAM; Ecaz poison income uses actual physical disposal and current native population. Mandatory winning-card disposal follows winner casualties. Supported low Kaitain and Junction payment receipts preserve the payer’s cost and use current native population; divergent allied rounding remains guarded. Giedi grants its once-per-phase bonus on actual positive desert receipts, including settled Ecaz shares. Low Grumman uses original public entry counts across Terror and competing-reaction checks. Richese High now connects actual paid reserve companions, one combined provisional invoice, original marker proof/responses, physical withdrawal and later reserve-capped reveal. Nexus Cunning retains that explicit paid group in its pair receipt, reserving paid counters before immediate materialization; original counter and spent-card outcomes remain. Ix High now returns physical Suboids for paid Cyborgs with provisional additional allowance and shortage handling, retaining typed free allocation, original invoice and free-event income. Southern and Tleilax preserve the exact eligible revival group through one optional placement. Provisional physical entry now opens native BG, Terror and Ambassador responses, retaining the original held income, independent counters and ordered overlap; a committed Sneak group has its own arrival source. Southern placement provisionally retains the ordinary storm and HMS entry route; Tleilax retains the Ixian-only direct off-planet restriction. Fresh occupation additionally connects bank and percentage receipts, completed Southern Collection, immediate sharing, Caladan inspection, displaced Ix pool, Richese card-only choice with native terms, real Giedi receiver choice and native protections. Fresh native CHOAM entries additionally connect original Tupile slots, actual normal-limit cleanup and restored low-query authority without resetting usage. Advanced retains sole-qualified ownership until last departure. Basic Tupile provisionally follows current sole control: observed departure, contest or native high population ends its slot, while a new activation waits for earlier normal-limit cleanup. Continuous turns do not stack slots or reset intelligence use. Exceptional Duke, concealed transport and full/public acceptance remain unfinished. Already purchased ordinary Karama now composes with immune Junction through explicit unused-price acknowledgment; original tariff/payees stay unchanged and only delivered transport consumes its grant.", evidence: [ 'game/faction-module-profile.ts', 'game/reinforcements-homeworld.ts', @@ -5032,33 +5032,34 @@ export const RULE_TOPICS: RuleTopic[] = [ title: 'Richese Nexus Betrayal: veto purchase or divert sale payment', category: 'Advanced & expansions', coverage: 'Partial', - developmentStage: 'Verified', - summary: 'Before a supported Richese auction settles, an unallied rival may spend the physical Richese Nexus card to discard a public self-cache lot without payment, or keep another buyer’s purchase intact while sending the original sale payment to the Spice Bank.', + developmentStage: 'Prototyped', + summary: 'Before a supported Richese purchase settles, an unallied rival may spend the physical Richese Nexus to veto a Richese-family purchase or divert another buyer’s sale payment. A concealed ordinary lot uses a visibly provisional committed blind attempt; a miss still spends the Nexus.', steps: [ 'This is an explicit local Richese Betrayal preview, not a public expansion start. It supports fresh two-to-six-seat Basic or Advanced tables with CHOAM, Richese and otherwise classic factions, the physical CHOAM deck and Nexus alone. Other optional modules, expansion factions, Semuta and Kull previews remain outside this boundary; older games do not silently acquire the effect.', - 'To use either alternative, remain unallied and hold the singleton Richese Nexus card while another seat is native Richese. A successful Use discards that physical Nexus card once. It spends no ordinary Treachery card, hand slot, Karama or activation spice. Current own-card promises and committed-resource legality still apply.', + 'To use either alternative, remain unallied and hold the singleton Richese Nexus card while another seat is native Richese. Use commits that physical Nexus card once. It spends no ordinary Treachery card, hand slot, Karama or activation spice. Current own-card promises and committed-resource legality still apply.', 'The opportunity opens after a valid winning quote but before any purchase payment, source removal or hand delivery. You do not choose a different card, buyer, price or recipient: those terms are bound to the original auction.', 'Purchase veto applies to Richese winning its own public cache lot through Once Around or Silent bidding. The exact selected cache card enters ordinary Treachery discard instead of Richese’s hand. No buyer or ally spice is spent; no payment goes to Richese, Emperor or bank, and no purchase bonus is created. The original lot retires once.', + 'A positive Richese win in the ordinary auction also opens the neutral response before payment choice, even when the face is concealed. Under the provisional blind-attempt convention, commit your Richese Nexus first. If the reserved card is a Richese-family card, it is discarded without payment, delivery or purchase bonus. Otherwise the Nexus remains spent and the original payment choice continues. There is no pre-spend family check, inspection or refund. The exact discarded face stays private.', 'Sale diversion applies when another buyer owes a positive payment to Richese for a cache auction or an Advanced Black Market auction using normal, Once Around or Silent bidding. The same buyer receives the same physical card for the same price and own/authorized-ally split. That original payment goes to the bank, with zero sale income for Richese; it is not an extra charge, refund or second purchase.', 'A diverted sale still earns the original buyer consequences. In particular, an applicable Harkonnen purchase bonus continues once. Losing offers do not spend spice. A vetoed purchase, zero/free cache keep or unsold Black Market retention does not fabricate a bonus or a positive sale.', 'Your voluntary Nexus spend must preserve your own binding shipment promises. If the involuntary purchase veto makes an opponent’s promise impossible, the existing native reconciliation may release that promise; it does not pretend Richese acquired the vetoed card or paid the price.', 'All publicly possible responders receive the same neutral acknowledgement: unallied non-Richese seats with public held-Nexus presence. A seat may pass even if its secret card is irrelevant; only a privately eligible Richese-card holder sees Use. If no seat is publicly possible, settlement is automatic. This privacy-preserving application protocol is not publisher-prescribed timing or a general Nexus/Karama priority ruling.', - 'Pass spends nothing and keeps your Nexus card. Your recorded pass cannot be undone within this event. Once every required responder passes, the original quoted purchase settles once with its original payment recipient, card delivery and earned consequences. An accepted Use closes the opportunity with its one selected outcome.', - 'The panel shows the public Richese target, original buyer, cache/Black Market source and winning price. It does not show payer resources, ally balances, rival hands, secret Nexus identities or the saved private receipt. Read your own unavailable reason rather than guessing Use from a held-card face alone.', + 'Pass spends nothing and keeps your Nexus card. Your recorded pass cannot be undone within this event. Once every required responder passes, the original purchase resumes its native payment choice and settles once with its original recipient, card delivery and earned consequences. An accepted Use closes the opportunity with its one selected outcome.', + 'The panel shows the public Richese target, original buyer, cache/Black Market/ordinary source and winning price. It does not show payer resources, ally balances, rival hands, secret Nexus identities or the saved private receipt. Read your own unavailable reason rather than guessing Use from a held-card face alone.', 'A public cache face remains inspectable through existing entitled Card controls. A concealed Black Market face stays unknown unless you already have a separate inspection entitlement; a seller’s public claim is not its face. Nexus inspection shows printed reference text, not anybody’s hidden custody. This response grants no new card inspection.', - 'Normal-deck Richese self-purchases are outside the connected veto boundary because the Richese-family identity may be hidden. The private advanced special-Karama acquisition has its own activating card and once-use ordering and is also outside this boundary. Native Karama exceptions do not certify Nexus coverage of either producer.', + 'The normal-lot blind-attempt rule is online composition, not a printed general right to discard a Nexus card at any time. Its normal winning-bid boundary precedes the existing spice/Karama payment choice. Proactive immediate Karama acquisition and the private Advanced special-Karama acquisition retain their separate activating-card and once-use boundaries; this prototype does not certify those producers.', 'Existing cache-count, ordering and special-acquisition boundaries remain. Already-held Black Market paid retention supplies neither a cache acquisition nor seller income, so no veto/diversion invoice is invented. Unsupported counters or any-time overlays must not enter this saved purchase boundary. Finish a real response before underlying bidding or other actions can change its reserved card or funding.', 'Refreshing resumes the same event, passes and original auction continuation. Saved pending and completed history must retain exact physical source and once-only payment without rerolls, raw-action replay or requiring old cards to remain forever in discard. Stale or foreign event submissions reject rather than selecting another lot.', - 'All four AI difficulties use the same private legal Use/public Pass projection. This is minimal legal participation, not calibrated Richese strategy. Bounded engine, authenticated SQL, real table controls and refreshed phone-browser scenarios are verified locally; full Richese/Nexus games, wider compositions and deployed acceptance remain open.', + 'All four AI difficulties use the same private legal Use/public Pass projection and pass blind ordinary attempts while strategic play is deferred. Prior cache/Black Market SQL and phone-browser evidence does not certify the new normal-lot source. Full Richese/Nexus games, wider compositions and deployed acceptance remain open.', ], example: 'Richese wins a revealed cache lot for 4: Betrayal veto discards that card, charges nobody and delivers nothing. If Harkonnen instead buys it for 4, sale diversion charges the original four spice once, sends it to the bank, delivers the card and preserves the applicable Harkonnen bonus. If everyone passes, the original purchase settles unchanged: Richese’s self-buy pays Emperor/bank; the other buyer’s sale pays Richese.', related: ['nexus-cards', 'richese-cards', 'richese-acquisition', 'bidding', 'alliance-funding', 'privacy', 'faction-richese', 'implementation-checklist'], checklist: [ - { area: 'Implementation', status: 'Partial', detail: 'Bounded public self-cache veto and other-buyer cache/Black Market recipient override bind original source, invoice and continuation with independent pending/completed history. Hidden normal self-buys, special acquisition and combined responses remain excluded.', evidence: ['game/nexus-richese-betrayal.ts', 'game/engine.ts', 'game/auction-continuation-quote.ts'] }, + { area: 'Implementation', status: 'Partial', detail: 'Public self-cache veto, original ordinary winning-bid blind attempt and other-buyer cache/Black Market recipient override bind actual sources and continuations. Ordinary attempts commit Nexus before family outcome; success skips payment/acquisition and advances once, pass/miss resumes the original chooser. Proactive/special acquisition and wider responses remain unfinished.', evidence: ['game/nexus-richese-betrayal.ts', 'game/engine.ts', 'game/auction-continuation-quote.ts'] }, { area: 'Player controls', status: 'Partial', detail: 'Mounted preview controls separate public neutral pass/wait from private legal Use, retain existing entitled inspectors and suspend the underlying auction panel without revealing the unknown Black Market face.', evidence: ['components/nexus-richese-betrayal.tsx', 'components/game-table.tsx'] }, { area: 'AI', status: 'Partial', detail: 'All four minimal legal policies consume canonical own-view eligibility and acknowledge publicly required opportunities; full strategy and calibration remain unfinished.', evidence: ['game/bot-nexus-richese-betrayal.ts', 'game/bots.ts'] }, { area: 'Documentation', status: 'Partial', detail: 'Prepayment veto, same-invoice diversion, singleton cost, pass behavior, neutral protocol, entitled faces and excluded producer/counter boundaries are explained without full-family or publisher-timing claims.', evidence: ['docs/NEXUS_RICHESE_RULES.md', 'docs/NEXUS_CARD_RULES.md', 'docs/RULE_DECISIONS.md'] }, - { area: 'Verification', status: 'Partial', detail: '80 focused cases and 5,953 offline checks cover both alternatives/all-pass, exact custody/escrow/bonus, identity parity, four legal bot profiles, real table controls and authenticated SQLite restart/races/orphan rejection. Actual CLI/runtime and phone controls prove Basic veto, Advanced all-pass and cache/Black Market diversion with refresh. Local HTTP 51/55 retains three 503s and one timeout; full-family, wider-module and deployed acceptance remain open.', evidence: ['tests/nexus-richese-betrayal.test.ts', 'tests/nexus-richese-betrayal-engine.test.ts', 'tests/nexus-richese-betrayal-controls.test.tsx', 'tests/bot-nexus-richese-betrayal.test.ts', 'tests/nexus-richese-betrayal-recovery.test.ts', 'docs/NEXUS_RICHESE_RULES.md'] }, + { area: 'Verification', status: 'Partial', detail: 'New ordinary source has separate invoice, actual auction-continuation and connected controls/minimal-profile cases with a labelled controlled recycled-card fixture. Historical cache/Black Market full-suite, SQL and phone-browser evidence remains dated legacy scope; it does not prove normal-lot recovery or deployed acceptance. Full-family, broader combinations and final assurance remain open.', evidence: ['tests/nexus-richese-betrayal-normal.test.ts', 'tests/richese-normal-betrayal-engine.test.ts', 'tests/nexus-richese-betrayal-normal-controls.test.tsx', 'tests/fixture-richese-normal-betrayal.ts', 'tests/nexus-richese-betrayal-engine.test.ts', 'docs/NEXUS_RICHESE_RULES.md'] }, ], }, { diff --git a/tests/fixture-richese-normal-betrayal.ts b/tests/fixture-richese-normal-betrayal.ts new file mode 100644 index 00000000..b595cc70 --- /dev/null +++ b/tests/fixture-richese-normal-betrayal.ts @@ -0,0 +1,244 @@ +import assert from 'node:assert/strict'; +import { + createGame, initializeRicheseBetrayalGameForAudit, joinGame, newPlayer, type Action, type Game, +} from '../game/engine'; +import { validateNexusCards } from '../game/nexus-cards'; +import { richeseCardDefinition } from '../game/richese-cards'; +import { actTruthMovement } from './fixture-truth-movement'; +import { nextTruthMovementNativeEarlyStep } from './fixture-truth-movement-native-early'; +import { + richeseHomeworldShipmentNexusInventory, +} from './fixture-richese-homeworld-shipment'; + +export type RicheseNormalBetrayalStep = { actor: string; action: Action }; +export type RicheseNormalBetrayalTransition = { before: Game; steps: RicheseNormalBetrayalStep[]; after: Game }; +export const actRicheseNormalBetrayal = actTruthMovement; +export const richeseNormalBetrayalPlayer = (game: Game, id: string) => { + const player = game.players.find(player => player.id === id); assert.ok(player); return player; +}; +export const richeseNormalBetrayalClean = (game: Game) => !game.phaseOpening && !game.response && + !game.decision && !game.truthtrance && !game.pendingTreacheryDiscard && !game.pendingRicheseBetrayal; +export function richeseNormalBetrayalInventory(game: Game) { + const cards = [...game.deck, ...game.discard, ...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []), + ...game.players.flatMap(player => player.hand), + ...(game.auction?.cards.slice(game.auction.index + Number(game.currentAuctionSale?.origin === 'normal')) ?? [])]; + const ids = cards.map(card => card.id).sort(); + assert.equal(new Set(ids).size, ids.length, 'Every original main/cache face retains one physical location'); + return ids; +} +export const richeseNormalBetrayalNexusInventory = richeseHomeworldShipmentNexusInventory; + +/** Actual native controls only. The original blind counter, current payment + * chooser and closing Nexus draws stay caller-owned. */ +export function nextRicheseNormalBetrayal(game: Game): RicheseNormalBetrayalStep | null { + if (game.pendingRicheseBetrayal || game.truthtrance || + game.nexusCards?.phase?.stage === 'drawing') return null; + if (!game.response && !game.phaseOpening && !game.pendingTreacheryDiscard) { + if (game.decision?.kind === 'auctionPayment') return null; + if (game.phase === 5 && !game.decision) return { actor: game.active!, action: { type: 'endMovement' } }; + } + return nextTruthMovementNativeEarlyStep(game, { + position: 'first', method: 'onceAround', direction: 'clockwise', + keepUnbid: game.turn === 1 && game.richeseAuction?.cardId === 'richese-karama', + card: game.richeseCache?.find(card => card.id === 'richese-karama')?.id ?? game.richeseCache?.[0]?.id, + }); +} +export function advanceRicheseNormalBetrayal(game: Game, until: (state: Game) => boolean, + actions: RicheseNormalBetrayalStep[] = []): Game { + for (let count = 0; count < 1600; count++) { + if (until(game)) return game; + const next = nextRicheseNormalBetrayal(game); + assert.ok(next, `Supply the original Nexus/bid/payment control at ${game.turn}/${game.phase}/${game.decision?.kind}`); + game = actRicheseNormalBetrayal(game, next.actor, next.action, actions); + } + throw Error('The original Richese normal-purchase programme did not reach its finite boundary'); +} +export function passRicheseNormalBetrayal(game: Game, actions: RicheseNormalBetrayalStep[] = []): Game { + const event = game.pendingRicheseBetrayal?.receipt.event; assert.ok(event); + for (let count = 0; game.pendingRicheseBetrayal?.receipt.event === event && count < 12; count++) { + const receipt = game.pendingRicheseBetrayal.receipt; + const actor = receipt.required.find(id => !receipt.passed.includes(id)); assert.ok(actor); + game = actRicheseNormalBetrayal(game, actor, { type: 'richeseBetrayalPass', event }, actions); + } + assert.notEqual(game.pendingRicheseBetrayal?.receipt.event, event); return game; +} +/** Finish the original post-payment responses, stopping at the next quiet lot. + * This never chooses spice/Karama or acknowledges another Betrayal event. */ +export function finishRicheseNormalBetrayal(game: Game, actions: RicheseNormalBetrayalStep[] = []): Game { + return advanceRicheseNormalBetrayal(game, richeseNormalBetrayalClean, actions); +} +export function payRicheseNormalBetrayal(game: Game, fixture: { target: string; karama: string | null }, + karama = false, actions: RicheseNormalBetrayalStep[] = []): Game { + if (game.decision?.kind === 'auctionPayment') { + assert.equal(game.decision.player, fixture.target); + assert.ok(!karama || fixture.karama); + game = actRicheseNormalBetrayal(game, fixture.target, + { type: 'decision', karama, ...(karama ? { card: fixture.karama! } : {}) }, actions); + } + return finishRicheseNormalBetrayal(game, actions); +} + +/** Original audit admission, setup and two-turn chronology are genuine. + * A single LABELLED controlled recycle moves the first native cache discard + * to the still-unopened ordinary pool. No receipt, auction, resource or dealt + * card is installed; real normal bids and passes earn the new interruption. */ +export function createRicheseNormalBetrayalFixture(options: { + advanced?: boolean; richeseCard?: boolean; karama?: boolean; holderNexus?: 'richese' | 'guild'; allyPayment?: 0 | 2; +} = {}) { + const advanced = options.advanced ?? false, family = options.richeseCard ?? true; + const withKarama = options.karama ?? true, allyPayment = options.allyPayment ?? 0, price = 3; + const target = 'richese', holder = 'choam', emperor = 'emperor', atreides = 'atreides'; + const ally = allyPayment ? atreides : null, nexusFace = options.holderNexus ?? 'richese'; + let game = createGame('RICHESENORMALBETRAYAL', newPlayer(target, 'Richese', 'richese'), advanced, ['choam']); + joinGame(game, newPlayer(holder, 'CHOAM', 'choam')); + joinGame(game, newPlayer(emperor, 'Emperor', 'emperor')); + joinGame(game, newPlayer(atreides, 'Atreides', 'atreides')); + const initial = structuredClone(game), actions: RicheseNormalBetrayalStep[] = []; + const act = (actor: string, action: Action) => { game = actRicheseNormalBetrayal(game, actor, action, actions); }; + const reach = (until: (state: Game) => boolean) => { game = advanceRicheseNormalBetrayal(game, until, actions); }; + const transition = (run: () => void): RicheseNormalBetrayalTransition => { + const before = structuredClone(game), start = actions.length; run(); + return { before, steps: structuredClone(actions.slice(start)), after: structuredClone(game) }; + }; + act(game.host, { type: 'moduleOption', module: 'nexusCards', enabled: true }); + for (const seat of game.players) act(seat.id, { type: 'ready' }); + const lobby = structuredClone(game); + game = initializeRicheseBetrayalGameForAudit(game); + const initialization = { before: lobby, after: structuredClone(game) }, setup = structuredClone(game); + assert.equal(game.status, 'setup'); assert.equal(game.richeseBetrayalPreview, true); + assert.equal(game.deck.length, 35); assert.equal(game.richeseCache!.length, 10); + assert.ok(game.players.every(seat => !seat.hand.length)); + const originalInventory = richeseNormalBetrayalInventory(game); + const originalNexusInventory = richeseNormalBetrayalNexusInventory(game); + assert.equal(originalInventory.length, 45); assert.equal(originalNexusInventory.length, 12); + const karama = game.deck.find(card => card.effect === 'karama'); assert.ok(karama); + const ordinary = game.deck.filter(card => card.kind === 'projectile').slice(0, 4); assert.equal(ordinary.length, 4); + const selected = withKarama ? [karama, ...ordinary.slice(0, 3)] : ordinary; + const dealOrder = selected.map((card, position) => { + const from = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position); + [game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]]; + return { position, from, card: card.id }; + }); + const beforeStartingDeal = structuredClone(game); + reach(state => state.status === 'playing'); const afterSetup = structuredClone(game); + assert.deepEqual(game.players.flatMap(seat => seat.hand.map(card => card.id)), selected.map(card => card.id)); + const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort(); + const spiceOrder: { turn: number; position: number; from: number; card: Game['spiceDeck'][number] }[] = []; + const order = (worm: boolean, position: number) => { + const from = game.spiceDeck.findIndex((card, index) => index >= position && (worm + ? 'worm' in card && !card.greatMaker && !card.suppressed + : 'territory' in card && !card.discovery && card.territory !== 'arrakeen')); + assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0]; + game.spiceDeck.splice(position, 0, card); spiceOrder.push({ turn: game.turn, position, from, card: structuredClone(card) }); + }; + order(false, 0); if (advanced) order(false, 1); + reach(state => state.turn === 1 && state.phase === 2 && richeseNormalBetrayalClean(state)); + const firstNormalFrom = game.deck.findIndex(card => card.kind === 'shield'); assert.ok(firstNormalFrom >= 0); + const firstNormalCard = game.deck.splice(firstNormalFrom, 1)[0]; game.deck.unshift(firstNormalCard); + const firstNormalOrder = { position: 0, from: firstNormalFrom, card: firstNormalCard.id, + timing: 'turn1-before-bidding', source: 'unopened-main' }; + assert.deepEqual(richeseNormalBetrayalInventory(game), originalInventory); + reach(state => state.turn === 1 && state.phase === 3 && !!state.auction && richeseNormalBetrayalClean(state)); + assert.equal(game.auction!.cards[game.auction!.index].id, firstNormalCard.id); + assert.ok(richeseNormalBetrayalPlayer(game, target).hand.some(card => card.id === 'richese-karama')); + const beforeFirstPurchase = structuredClone(game), firstPurchaseStart = actions.length; + for (let count = 0; game.decision?.kind !== 'auctionPayment' && count < 16; count++) { + assert.ok(game.auction && !game.response && !game.pendingRicheseBetrayal); + const actor = game.auction.active; + act(actor, actor === target && game.auction.bidder !== target + ? { type: 'bid', amount: 1, allyPayment: 0 } : { type: 'passBid' }); + } + assert.equal(game.decision?.kind, 'auctionPayment'); assert.equal(game.decision?.player, target); + act(target, { type: 'decision', karama: true, card: 'richese-karama' }); + reach(richeseNormalBetrayalClean); + const firstPurchase: RicheseNormalBetrayalTransition = { + before: beforeFirstPurchase, steps: structuredClone(actions.slice(firstPurchaseStart)), after: structuredClone(game), + }; + reach(state => state.turn === 1 && state.phase === 4 && richeseNormalBetrayalClean(state)); + const afterFirstBidding = structuredClone(game); + const recycled = game.discard.find(card => card.id === 'richese-karama'); assert.ok(recycled); + assert.equal(game.richeseCache!.length, 9); + reach(state => state.turn === 2 && state.phase === 1 && richeseNormalBetrayalClean(state) && + !state.nexus && !state.spiceWindow && !state.spiceResolution && !state.spiceSequence && !state.ready.length); + const beforeWorm = structuredClone(game); order(true, 0); order(false, 1); if (advanced) order(false, 2); + const beforeNexus = structuredClone(game); + reach(state => state.nexus && richeseNormalBetrayalClean(state) && !state.spiceWindow && !state.spiceResolution); + const alliance = transition(() => { + const first = ally ? target : emperor; + act(first, { type: 'alliance', target: atreides }); act(atreides, { type: 'alliance', target: first }); + }); + reach(state => state.nexusCards?.phase?.stage === 'drawing' && richeseNormalBetrayalClean(state)); + const nexusDraws: RicheseNormalBetrayalTransition[] = [], nexusOrder: { actor: string; from: number; card: string }[] = []; + for (let count = 0; game.nexusCards?.phase?.stage === 'drawing' && count < 6; count++) { + const phase = game.nexusCards.phase, actor = phase.eligible.find(id => !phase.done.includes(id)); assert.ok(actor); + const face = actor === holder ? nexusFace : nexusFace === 'guild' ? 'fremen' : 'guild'; + const cards = game.nexusCards.cards!, from = cards.deck.indexOf(face); assert.ok(from >= 0); + assert.equal(cards.hands[actor], null); + cards.deck.unshift(cards.deck.splice(from, 1)[0]); nexusOrder.push({ actor, from, card: face }); + nexusDraws.push(transition(() => { act(actor, { type: 'nexusCardChoice', turn: game.turn, + card: null, choice: 'draw', ownRedraws: 0 }); })); + } + assert.equal(game.nexusCards!.cards!.hands[holder], nexusFace); + assert.equal(richeseNormalBetrayalPlayer(game, holder).ally, null); + reach(state => state.turn === 2 && state.phase === 2 && richeseNormalBetrayalClean(state)); + const beforeRecycle = structuredClone(game); + let card: string, recycledFrom: number; + if (family) { + recycledFrom = game.discard.findIndex(card => card.id === recycled.id); assert.ok(recycledFrom >= 0); + game.deck.unshift(game.discard.splice(recycledFrom, 1)[0]); card = recycled.id; + } else { + recycledFrom = game.deck.findIndex(card => card.kind === 'shield'); assert.ok(recycledFrom >= 0); + const ordinaryCard = game.deck.splice(recycledFrom, 1)[0]; game.deck.unshift(ordinaryCard); card = ordinaryCard.id; + } + const recycle = { before: beforeRecycle, after: structuredClone(game), card, + from: family ? 'discard' : 'unopened-main', fromIndex: recycledFrom, to: 'unopened-main-top', controlled: true }; + assert.equal(!!richeseCardDefinition(game.deck[0]), family); + assert.deepEqual(richeseNormalBetrayalInventory(game), originalInventory); + let funding: RicheseNormalBetrayalTransition | null = null; + reach(state => state.turn === 2 && state.phase === 3 && !!state.auction && richeseNormalBetrayalClean(state)); + assert.equal(game.auction!.cards[game.auction!.index].id, card); + if (ally) funding = transition(() => { act(ally, { type: 'pledgeAid', amount: allyPayment }); }); + const beforeBids = structuredClone(game), biddingStart = actions.length; + let beforeTrigger: Game | undefined, trigger: RicheseNormalBetrayalStep | undefined; + for (let count = 0; !game.pendingRicheseBetrayal && count < 16; count++) { + assert.ok(game.auction && !game.decision && !game.response, 'Native bids must open Betrayal before the payment chooser'); + const actor = game.auction.active; + const action: Action = actor === target && game.auction.bidder !== target + ? { type: 'bid', amount: price, allyPayment } : { type: 'passBid' }; + beforeTrigger = structuredClone(game); trigger = { actor, action }; + act(actor, action); + } + assert.ok(game.pendingRicheseBetrayal && beforeTrigger && trigger); + const event = game.pendingRicheseBetrayal.receipt.event, source = structuredClone(game); + assert.equal(game.pendingRicheseBetrayal.receipt.invoice.source, 'normal'); + assert.equal(game.pendingRicheseBetrayal.receipt.invoice.card.id, card); + assert.equal(game.auction!.bidder, target); assert.equal(game.auction!.bid, price); + assert.equal(game.decision, null); assert.ok(!game.currentAuctionSale); + assert.deepEqual(richeseNormalBetrayalInventory(game), originalInventory); + assert.deepEqual(richeseNormalBetrayalNexusInventory(game), originalNexusInventory); + validateNexusCards(game.nexusCards!.cards!, game.players); + return { + game, source, beforePaymentChoice: source, initial, lobby, initialization, setup, beforeStartingDeal, afterSetup, + firstPurchase, afterFirstBidding, beforeWorm, beforeNexus, alliance, nexusDraws, nexusOrder, + beforeRecycle, recycle, funding, beforeBids, beforeTrigger, trigger, + bidding: { before: beforeBids, steps: structuredClone(actions.slice(biddingStart)), after: source }, + target, buyer: target, richese: target, owner: target, holder, other: ally ? emperor : target, + emperor, atreides, ally, price, allyPayment, family, advanced, card, recycledCard: recycled.id, + karama: withKarama ? karama.id : null, holderNexus: nexusFace, event, + useAction: { type: 'richeseBetrayalUse', event } as Action, + passActions: game.pendingRicheseBetrayal.receipt.required.map(actor => ({ actor, + action: { type: 'richeseBetrayalPass', event } as Action })), + originalInventory, originalCards: originalInventory, originalNexusInventory, originalSpice, spiceOrder, dealOrder, + firstNormalOrder, + actions, steps: actions, sourceActionCount: actions.length, + staging: [ + 'Fresh original Richese Betrayal audit admission with Richese/CHOAM/Emperor/Atreides, original main35/cache10, all12 Nexus singletons and no other modules. Public Nexus lobby selection and readiness precede the existing explicit audit initializer; this is not a newly admitted public start. Only original unopened Treachery/Spice/Nexus positions and native scalar lotteries are selected for setup and the real worm/draw prefix. Original hands, native wallets, forces, clocks and all allowances follow actual setup/actions.', + 'Native first-turn cache auction selects Richese Karama; all four actual Once Around bidders pass, and Richese really keeps the unbid card. Cache decreases to nine. Before that Bidding phase deals the normal pool, select an actual still-unopened Shield at main-deck top, recorded separately as firstNormalOrder. Richese then really wins that first normal Shield lot for one and pays with the exact cache Karama at the original winning-bid payment chooser, putting the physical Richese card into normal discard without a spice debit. Thus karama:false does not acquire another Karama from its real purchase; karama:true retains its genuinely dealt starting one. This is the existing post-winning payment route, not proactive Karama purchase. Native Storm/Spice/Charity/auctions/Revival/movement/Mentat progress to a real second-turn worm. Emperor/Atreides actually ally by default; the aid branch instead earns Richese/Atreides alliance and an actual two-spice pledge. Unallied CHOAM actually draws its original Richese Nexus (or the explicitly selected irrelevant Guild singleton). No Nexus card is installed or duplicated.', + family + ? 'CONTROLLED RECYCLED ORDINARY SOURCE: before turn2 Bidding and before any normal auction/receipt exists, move the already-discarded original Richese Karama into the unopened main-deck top. Preserve every physical main/cache card and the genuinely decremented cache; this replaces a costly multi-turn exhaustion/reshuffle programme and is not claimed as earned deck recycling.' + : 'ORDINARY MISS CONTROL: before turn2 Bidding, select one actual still-unopened ordinary Shield at the main-deck top. The previously spent Richese Karama stays in discard. No family inference is used to manufacture the later reaction window.', + 'Native turn2 cache passes/declined retention complete before the ordinary pool opens. Richese really bids three (with actual aid two only when requested); other native bidders pass. Stop at the engine-created neutral normal-purchase Betrayal window before any spice/Karama payment choice. No auction, paused receipt, wallet, alliance, dealt hand, invoice or sale is installed.', + ], + }; +} +export type RicheseNormalBetrayalFixture = ReturnType; diff --git a/tests/homeworld-revival-destinations.test.ts b/tests/homeworld-revival-destinations.test.ts index 2b0bfd5c..b3c90ffd 100644 --- a/tests/homeworld-revival-destinations.test.ts +++ b/tests/homeworld-revival-destinations.test.ts @@ -77,7 +77,7 @@ void test('Tleilax Homeworld transfers preserve exact custody, permit own-world assert.deepEqual(g, allied); }); -void test('storm uses the selected actual sector and mobile stronghold pointer; its classification remains gated', () => { +void test('revival keeps storm and HMS route restrictions distinct, including the sheltered interior', () => { for (const faction of ['fremen', 'tleilaxu'] as const) { const { g, grant } = fixture(faction); g.storm = 10; @@ -85,11 +85,16 @@ void test('storm uses the selected actual sector and mobile stronghold pointer; g.players[0].forces['hidden_mobile_stronghold:0'] = 1; let choices = homeworldRevivalDestinations(g, 'p', grant); assert.match(choices.find((c) => c.id === 'arrakeen:10')!.blocked!, /storm/); - assert.match(choices.find((c) => c.id === 'hidden_mobile_stronghold:0')!.blocked!, /storm/); + const route = faction === 'fremen' ? /Force Movement/ : /Only Ixians/; + assert.match(choices.find((c) => c.id === 'hidden_mobile_stronghold:0')!.blocked!, route); + assert.doesNotMatch(choices.find((c) => c.id === 'hidden_mobile_stronghold:0')!.blocked!, /storm|await|ruling/); assert.equal(choices.find((c) => c.id === 'polar_sink:0')!.blocked, null); g.storm = 18; choices = homeworldRevivalDestinations(g, 'p', grant); - assert.match(choices.find((c) => c.id === 'hidden_mobile_stronghold:0')!.blocked!, /classification|ruling/); + assert.match(choices.find((c) => c.id === 'hidden_mobile_stronghold:0')!.blocked!, route); + const before = structuredClone(g); + assert.throws(() => quoteHomeworldRevivalDestination(g, 'p', grant, 'hidden_mobile_stronghold:0'), route); + assert.deepEqual(g, before); } }); diff --git a/tests/nexus-richese-betrayal-engine.test.ts b/tests/nexus-richese-betrayal-engine.test.ts index e1f73b2d..b454d958 100644 --- a/tests/nexus-richese-betrayal-engine.test.ts +++ b/tests/nexus-richese-betrayal-engine.test.ts @@ -155,7 +155,7 @@ void test('orphaned/corrupt source receipts fail before projection, normalizatio g => {delete g.pendingRicheseBetrayal;}, g => {delete g.richeseBetrayal;}, g => {g.richeseBetrayal!.current = null;}, - g => {g.pendingRicheseBetrayal!.quote.amount++;}, + g => {const frame=g.pendingRicheseBetrayal!;assert.ok('quote' in frame);frame.quote.amount++;}, g => {g.pendingRicheseBetrayal!.receipt.invoice.card.name = 'forged face';}, g => {player(g,f.buyer).spice--;}, g => {player(g,f.target).hand.splice(0,1);}, diff --git a/tests/nexus-richese-betrayal-normal-controls.test.tsx b/tests/nexus-richese-betrayal-normal-controls.test.tsx new file mode 100644 index 00000000..9f233a5b --- /dev/null +++ b/tests/nexus-richese-betrayal-normal-controls.test.tsx @@ -0,0 +1,152 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { registerHooks } from 'node:module'; +import { readFileSync } from 'node:fs'; +import { Children, createElement, isValidElement, type ReactNode } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { viewGame, type Action, type GameView } from '../game/engine'; +import { botNexusRicheseBetrayalActions } from '../game/bot-nexus-richese-betrayal'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { createRicheseBetrayalFixture } from './fixture-richese-betrayal'; + +const aliases = registerHooks({ + resolve(specifier, context, next) { + return next(specifier === 'next/image' ? 'vinext/shims/image' : specifier === 'next/link' ? 'vinext/shims/link' : specifier, context); + }, + load(url, context, next) { + if (!url.endsWith('.module.css')) return next(url, context); + const css = readFileSync(new URL(url), 'utf8'); + const classes = Object.fromEntries([...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map(match => [match[1], match[1]])); + return { format: 'module', source: `export default ${JSON.stringify(classes)}`, shortCircuit: true }; + }, +}); +const { NexusRicheseBetrayal } = await import('../components/nexus-richese-betrayal'); + +/** A normal-source view override isolates the connected controls and policy + * consumers. The engine suite supplies the actual ordinary purchase producer. */ +function projected(): GameView { + const fixture = createRicheseBetrayalFixture('purchase'); + const game = viewGame(fixture.game, fixture.holder); + assert.ok(game.richeseBetrayalReaction); + game.richeseBetrayalReaction.source = 'normal'; + return game; +} + +function markup(game: GameView, busy = false) { + return renderToStaticMarkup(createElement(NexusRicheseBetrayal, { game, busy, act() {} })); +} + +function click(node: ReactNode, prefix: string): boolean { + for (const child of Children.toArray(node)) { + if (!isValidElement<{ children?: ReactNode; onClick?: () => void }>(child)) continue; + if (typeof child.props.children === 'string' && child.props.children.startsWith(prefix) && child.props.onClick) { + child.props.onClick(); + return true; + } + if (click(child.props.children, prefix)) return true; + } + return false; +} + +function choose(game: GameView, prefix: string, busy = false): Action[] { + const actions: Action[] = []; + click(NexusRicheseBetrayal({ game, busy, act(action) { actions.push(action); } }), prefix); + return actions; +} + +void test('ordinary concealed purchases explain both outcomes and the provisional spend-first cost persistently', () => { + const game = projected(); + for (let render = 0; render < 2; render++) { + const html = markup(game); + assert.match(html, /concealed ordinary auction lot/); + assert.match(html, /does not disclose the card’s family or grant inspection before you spend/); + assert.match(html, /Provisional online rule: spend the Richese Nexus before learning whether this purchase can be stopped/); + assert.match(html, /If it is a Richese card, that card is discarded without payment or delivery/); + assert.match(html, /Otherwise your Nexus remains spent and the original purchase continues/); + assert.match(html, /Spend Richese Nexus · attempt to stop this purchase/); + assert.match(html, /Pass · allow the original purchase/); + assert.doesNotMatch(html, /public cache lot|buying its own cache card|concealed Black Market lot/); + } +}); + +void test('actual normal-source Spend and Pass callbacks send one unchanged event-bound action', () => { + const game = projected(), event = game.richeseBetrayalReaction!.event; + assert.deepEqual(choose(game, 'Spend Richese Nexus'), [{ type: 'richeseBetrayalUse', event }]); + assert.deepEqual(choose(game, 'Pass'), [{ type: 'richeseBetrayalPass', event }]); +}); + +void test('busy, blocked, passed and unentitled normal controls cannot commit the blind cost', () => { + const game = projected(), event = game.richeseBetrayalReaction!.event; + assert.match(markup(game, true), /disabled=""[^>]*>Spend Richese Nexus/); + assert.match(markup(game, true), /disabled=""[^>]*>Pass/); + assert.deepEqual(choose(game, 'Spend Richese Nexus', true), []); + assert.deepEqual(choose(game, 'Pass', true), []); + game.richeseBetrayalReaction!.blocked = 'Your accepted promise reserves this Nexus card.'; + assert.match(markup(game), /accepted promise reserves this Nexus card/); + assert.match(markup(game), /Provisional online rule/); + assert.deepEqual(choose(game, 'Spend Richese Nexus'), []); + assert.deepEqual(choose(game, 'Pass'), [{ type: 'richeseBetrayalPass', event }]); + game.richeseBetrayalReaction!.blocked = null; + game.richeseBetrayalReaction!.canUse = false; + assert.deepEqual(choose(game, 'Spend Richese Nexus'), []); + game.richeseBetrayalReaction!.canUse = true; + game.richeseBetrayalReaction!.hasPassed = true; + assert.match(markup(game), /You have passed/); + assert.deepEqual(choose(game, 'Spend Richese Nexus'), []); + assert.deepEqual(choose(game, 'Pass'), []); + game.richeseBetrayalReaction!.hasPassed = false; + game.richeseBetrayalReaction!.canPass = false; + assert.deepEqual(choose(game, 'Spend Richese Nexus'), []); + game.richeseBetrayalReaction!.canPass = true; + game.status = 'finished'; + assert.deepEqual(choose(game, 'Spend Richese Nexus'), []); + assert.deepEqual(choose(game, 'Pass'), []); +}); + +void test('the ordinary response never reads a face or rival resources to infer the hidden card family', () => { + const game = projected(); + Object.defineProperty(game, 'richeseAuction', { get() { throw new Error('public cache is unrelated to this ordinary purchase'); } }); + Object.defineProperty(game, 'auction', { get() { throw new Error('ordinary face is not response entitlement'); } }); + for (const player of game.players) + for (const field of ['hand', 'spice']) + Object.defineProperty(player, field, { get() { throw new Error(`unrelated ${field}`); } }); + assert.match(markup(game), /concealed ordinary auction lot/); + assert.deepEqual(choose(game, 'Spend Richese Nexus'), [{ type: 'richeseBetrayalUse', event: game.richeseBetrayalReaction!.event }]); +}); + +void test('every bot profile and autopilot passes a blind ordinary purchase even when Use is offered', () => { + for (const profile of DIFFICULTIES) { + const game = projected(), own = game.players.find(player => player.id === game.me)!; + own.bot = profile; + assert.equal(game.richeseBetrayalReaction!.canUse, true); + const expected = [{ type: 'richeseBetrayalPass', event: game.richeseBetrayalReaction!.event }]; + assert.deepEqual(botNexusRicheseBetrayalActions(game), expected); + assert.deepEqual(botActions(game), expected); + delete own.bot; + own.autopilot = profile; + assert.deepEqual(botNexusRicheseBetrayalActions(game), expected); + } +}); + +void test('minimal bot responses still obey current acknowledgement eligibility and preserve old-source Use', () => { + const game = projected(), own = game.players.find(player => player.id === game.me)!; + assert.deepEqual(botNexusRicheseBetrayalActions(game), []); + own.bot = 'Easy'; + game.richeseBetrayalReaction!.hasPassed = true; + assert.deepEqual(botNexusRicheseBetrayalActions(game), []); + game.richeseBetrayalReaction!.hasPassed = false; + game.richeseBetrayalReaction!.canPass = false; + assert.deepEqual(botNexusRicheseBetrayalActions(game), []); + for (const kind of ['purchase', 'sale'] as const) { + const fixture = createRicheseBetrayalFixture(kind); + const old = viewGame(fixture.game, fixture.holder); + old.players.find(player => player.id === old.me)!.bot = 'Easy'; + assert.deepEqual(botNexusRicheseBetrayalActions(old), [{ type: 'richeseBetrayalUse', event: old.richeseBetrayalReaction!.event }]); + assert.match(markup(old), /Use Richese Nexus/); + assert.doesNotMatch(markup(old), /Provisional online rule: spend the Richese Nexus/); + assert.match(markup(old), kind === 'purchase' ? /public cache lot/ : /concealed Black Market lot/); + } +}); + +aliases.deregister(); diff --git a/tests/nexus-richese-betrayal-normal.test.ts b/tests/nexus-richese-betrayal-normal.test.ts new file mode 100644 index 00000000..b1fa1642 --- /dev/null +++ b/tests/nexus-richese-betrayal-normal.test.ts @@ -0,0 +1,209 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { Card } from '../game/cards'; +import { NEXUS_FACTIONS, type NexusPlayer, type NexusState } from '../game/nexus-cards'; +import { richeseCards } from '../game/richese-cards'; +import { + createRicheseBetrayalReceipt, quoteRicheseNormalBetrayalInvoice, + requiredRicheseBetrayalResponders, richeseBetrayalEligibility, + richeseBetrayalEvent, richeseBetrayalVetoesPurchase, + validateRicheseBetrayalCursor, validateRicheseBetrayalReceipt, + RicheseBetrayalError, type RicheseBetrayalInvoice, +} from '../game/nexus-richese-betrayal'; + +const players: NexusPlayer[] = [ + { id: 'richese', faction: 'richese' }, + { id: 'holder', faction: 'fremen' }, + { id: 'emperor', faction: 'emperor' }, +]; +const ordinary: Card = { id: 'normal-weapon', name: 'Maula Pistol', kind: 'projectile' }; +const other: Card = { id: 'other-auction-card', name: 'Shield', kind: 'shield' }; +type NormalInput = Parameters[0]; + +function normalInput(card: Card = richeseCards()[0], allyPayment = 0): NormalInput { + return { + players: players.map(player => ({ ...player, ...(allyPayment ? + player.id === 'richese' ? { ally: 'emperor' } : + player.id === 'emperor' ? { ally: 'richese' } : {} : {}) })), + auction: { cards: [{ ...other }, { ...card }], index: 1, bid: 5, bidder: 'richese', + ...(allyPayment ? { allyPayment } : {}) }, + donor: allyPayment ? 'emperor' : null, + originalRecipient: 'emperor', physicalCards: [{ ...other }, { ...card }], + }; +} +function usedReceipt(invoice: RicheseBetrayalInvoice) { + return createRicheseBetrayalReceipt({ turn: 2, players }, { + event: richeseBetrayalEvent(2, 1, 'round-2', 'lot-2'), turn: 2, sequence: 1, + round: 'round-2', lot: 'lot-2', invoice, + required: ['holder'], passed: [], stage: 'used', actor: 'holder', nexusDiscardIndex: 0, + }); +} +function reject(input: NormalInput) { + const before = JSON.stringify(input); + assert.throws(() => quoteRicheseNormalBetrayalInvoice(input), RicheseBetrayalError); + assert.equal(JSON.stringify(input), before); +} + +void test('ordinary Richese purchases bind the same invoice for either concealed card family', () => { + for (const card of [richeseCards()[0], ordinary]) { + const input = normalInput(card, 2); + const before = JSON.stringify(input); + const invoice = quoteRicheseNormalBetrayalInvoice(input); + assert.deepEqual(invoice, { + kind: 'purchase', target: 'richese', buyer: 'richese', source: 'normal', + price: 5, ownPayment: 3, allyPayment: 2, donor: 'emperor', + originalRecipient: 'emperor', card, + }); + assert.equal(JSON.stringify(input), before); + assert.notEqual(invoice.card, input.auction.cards[1]); + assert.notEqual(invoice.card, input.physicalCards[1]); + invoice.card.name = 'Changed output'; + assert.equal(JSON.stringify(input), before); + } + const bank = normalInput(); + bank.players = bank.players.filter(player => player.id !== 'emperor'); + bank.originalRecipient = null; + assert.equal(quoteRicheseNormalBetrayalInvoice(bank).originalRecipient, null); +}); + +void test('normal source requires the actual native winner, a positive safe price and a current card index', () => { + for (const index of [-1, 2, 1.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1]) { + const input = normalInput(); + input.auction.index = index; + reject(input); + } + for (const bid of [0, -1, 1.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1]) { + const input = normalInput(); + input.auction.bid = bid; + reject(input); + } + for (const bidder of [null, 'holder', 'unknown']) { + const input = normalInput(); + input.auction.bidder = bidder; + reject(input); + } + const noCards = normalInput(); + noCards.auction.cards = []; + reject(noCards); + const wrongRecipient = normalInput(); + wrongRecipient.originalRecipient = 'holder'; + reject(wrongRecipient); + const missingEmperorPayment = normalInput(); + missingEmperorPayment.originalRecipient = null; + reject(missingEmperorPayment); +}); + +void test('normal source binds one exact physical card without manufacturing or substituting its face', () => { + const absent = normalInput(); + absent.physicalCards = absent.physicalCards.slice(0, -1); + reject(absent); + const duplicate = normalInput(); + duplicate.physicalCards = [...duplicate.physicalCards, { ...duplicate.auction.cards[1] }]; + reject(duplicate); + const duplicatedLot = normalInput(); + duplicatedLot.auction.cards.push({ ...duplicatedLot.auction.cards[1] }); + reject(duplicatedLot); + const substituted = normalInput(); + substituted.physicalCards[1].name = 'Different face with the same physical ID'; + reject(substituted); + const malformed = normalInput(); + malformed.auction.cards[1] = { ...ordinary, kind: 'invalid' } as unknown as Card; + reject(malformed); +}); + +void test('normal funding preserves the actual whole split and reciprocal donor', () => { + for (const allyPayment of [0, 2, 5]) { + const invoice = quoteRicheseNormalBetrayalInvoice(normalInput(ordinary, allyPayment)); + assert.equal(invoice.ownPayment, 5 - allyPayment); + assert.equal(invoice.allyPayment, allyPayment); + } + for (const allyPayment of [-1, 6, 0.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1]) { + const input = normalInput(); + input.auction.allyPayment = allyPayment; + reject(input); + } + const noDonor = normalInput(ordinary, 2); + noDonor.donor = null; + reject(noDonor); + const extraDonor = normalInput(); + extraDonor.donor = 'emperor'; + reject(extraDonor); + const unrelated = normalInput(ordinary, 2); + unrelated.donor = 'holder'; + reject(unrelated); + const unilateral = normalInput(ordinary, 2); + unilateral.players = unilateral.players.map(player => player.id === 'emperor' + ? { id: player.id, faction: player.faction } : player); + reject(unilateral); +}); + +void test('concealed family does not determine the responder set or physical Nexus eligibility', () => { + const cards: NexusState = { + version: 1, deck: NEXUS_FACTIONS.filter(card => card !== 'richese'), discard: [], + hands: { richese: null, holder: 'richese', emperor: null }, + }; + const before = JSON.stringify(cards); + for (const card of [richeseCards()[0], ordinary]) { + const input = normalInput(card); + quoteRicheseNormalBetrayalInvoice(input); + assert.deepEqual(requiredRicheseBetrayalResponders(input.players.map(player => ({ + ...player, nexusHeld: player.id === 'holder', + }))), ['holder']); + assert.equal(richeseBetrayalEligibility({ players: input.players, cards }, 'holder'), null); + } + assert.equal(JSON.stringify(cards), before); +}); + +void test('a committed normal miss is valid used history with the same physical Nexus cost', () => { + for (const [card, vetoes] of [[richeseCards()[0], true], [ordinary, false]] as const) { + const receipt = usedReceipt(quoteRicheseNormalBetrayalInvoice(normalInput(card))); + assert.equal(richeseBetrayalVetoesPurchase(receipt.invoice), vetoes); + assert.equal(receipt.stage, 'used'); + assert.equal(receipt.actor, 'holder'); + assert.equal(receipt.nexusDiscardIndex, 0); + validateRicheseBetrayalReceipt({ turn: 3, players }, JSON.parse(JSON.stringify(receipt))); + validateRicheseBetrayalCursor({ turn: 3, players }, { + sequence: 1, current: null, completed: [receipt], + }, null); + const wrongCost = { ...receipt, nexusDiscardIndex: null }; + assert.throws(() => validateRicheseBetrayalReceipt({ turn: 3, players }, wrongCost), RicheseBetrayalError); + } +}); + +void test('normal purchase history cannot become a normal sale or a hidden Black Market self-purchase', () => { + const invoice = quoteRicheseNormalBetrayalInvoice(normalInput(ordinary)); + const invalid: RicheseBetrayalInvoice[] = [ + { ...invoice, kind: 'sale', buyer: 'emperor', originalRecipient: 'richese' }, + { ...invoice, source: 'blackMarket' }, + { ...invoice, source: 'cache' }, + { ...invoice, source: 'unknown' as RicheseBetrayalInvoice['source'] }, + ]; + for (const changed of invalid) { + const before = JSON.stringify(changed); + assert.throws(() => usedReceipt(changed), RicheseBetrayalError); + assert.equal(JSON.stringify(changed), before); + } +}); + +void test('old cache and Black Market receipt signature bytes and outcome meanings stay unchanged', () => { + for (const source of ['cache', 'blackMarket'] as const) { + const invoice: RicheseBetrayalInvoice = source === 'cache' ? { + kind: 'purchase', target: 'richese', buyer: 'richese', source, + price: 5, ownPayment: 5, allyPayment: 0, donor: null, + originalRecipient: 'emperor', card: richeseCards()[0], + } : { + kind: 'sale', target: 'richese', buyer: 'emperor', source, + price: 5, ownPayment: 5, allyPayment: 0, donor: null, + originalRecipient: 'richese', card: { ...ordinary }, + }; + const receipt = usedReceipt(invoice); + const event = JSON.stringify(['nexusRicheseBetrayal', 2, 1, 'round-2', 'lot-2']); + assert.equal(receipt.signature, JSON.stringify([ + 'richeseBetrayalReceipt', 1, event, 2, 1, 'round-2', 'lot-2', + invoice.kind, 'richese', invoice.buyer, source, 5, 5, 0, null, invoice.originalRecipient, + [invoice.card.id, invoice.card.name, invoice.card.kind, invoice.card.effect ?? null], + ['holder'], [], 'used', 'holder', 0, + ])); + assert.equal(richeseBetrayalVetoesPurchase(receipt.invoice), source === 'cache'); + } +}); diff --git a/tests/nexus-richese-betrayal-recovery.test.ts b/tests/nexus-richese-betrayal-recovery.test.ts index 5d52b739..e08f522b 100644 --- a/tests/nexus-richese-betrayal-recovery.test.ts +++ b/tests/nexus-richese-betrayal-recovery.test.ts @@ -420,8 +420,8 @@ for (const kind of ['purchase', 'sale'] as const) { { name: 'changed payer split', change: g => { g.pendingRicheseBetrayal!.receipt.invoice.ownPayment++; } }, { name: 'changed physical face', change: g => { g.pendingRicheseBetrayal!.receipt.invoice.card.name += ' forged'; } }, { name: 'foreign buyer', change: g => { g.pendingRicheseBetrayal!.receipt.invoice.buyer = f.holder; } }, - { name: 'changed source quote', change: g => { g.pendingRicheseBetrayal!.quote.amount++; } }, - { name: 'changed original lot', change: g => { g.pendingRicheseBetrayal!.lot.cardId = g.deck[0].id; } }, + { name: 'changed source quote', change: g => { const frame=g.pendingRicheseBetrayal!;assert.ok('quote' in frame);frame.quote.amount++; } }, + { name: 'changed original lot', change: g => { const frame=g.pendingRicheseBetrayal!;assert.ok('lot' in frame);frame.lot.cardId = g.deck[0].id; } }, { name: 'foreign required acknowledgement', change: g => { g.pendingRicheseBetrayal!.receipt.required.push(f.target); } }, { name: 'forged pass', change: g => { g.pendingRicheseBetrayal!.receipt.passed.push(f.holder); } }, { name: 'lost exact source custody', change: g => { diff --git a/tests/richese-normal-betrayal-engine.test.ts b/tests/richese-normal-betrayal-engine.test.ts new file mode 100644 index 00000000..9bf1d021 --- /dev/null +++ b/tests/richese-normal-betrayal-engine.test.ts @@ -0,0 +1,191 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { applyAction, viewGame, type Action, type Game } from '../game/engine'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { + createRicheseNormalBetrayalFixture as fixture, + actRicheseNormalBetrayal as act, + passRicheseNormalBetrayal as pass, + payRicheseNormalBetrayal as pay, + finishRicheseNormalBetrayal as finish, + richeseNormalBetrayalPlayer as player, + richeseNormalBetrayalInventory as inventory, + richeseNormalBetrayalNexusInventory as nexusInventory, +} from './fixture-richese-normal-betrayal'; + +const wallets = (game: Game) => Object.fromEntries(game.players.map(player => [player.id, player.spice])); +function reject(game: Game, actor: string, action: Action) { + const before = structuredClone(game); + assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before); +} +function source(f: ReturnType) { + const receipt = f.game.pendingRicheseBetrayal!.receipt; + assert.equal(receipt.invoice.source, 'normal'); assert.equal(receipt.invoice.kind, 'purchase'); + assert.equal(receipt.invoice.buyer, f.target); assert.equal(receipt.invoice.price, 3); + assert.equal(receipt.invoice.card.id, f.card); + assert.equal(f.game.auction!.bidder, f.target); assert.equal(f.game.auction!.cards[f.game.auction!.index].id, f.card); + assert.equal(f.game.decision, null); assert.ok(!f.game.currentAuctionSale); + assert.equal(player(f.game, f.holder).ally, null); + assert.equal(f.game.nexusCards!.cards!.hands[f.holder], f.holderNexus); + assert.deepEqual(inventory(f.game), f.originalInventory); assert.deepEqual(nexusInventory(f.game), f.originalNexusInventory); + assert.equal(f.sourceActionCount, f.actions.length); + return receipt; +} + +for (const advanced of [false, true]) { + const mode = advanced ? 'Advanced' : 'Basic'; + + void test(`${mode} normal Richese-family self-purchase veto spends its actual Nexus and skips the unpaid card`, () => { + const f = fixture({ advanced }), receipt = source(f), before = wallets(f.game), index = f.game.auction!.index; + assert.equal(f.initialization.before.status, 'lobby'); assert.equal(f.afterSetup.status, 'playing'); + assert.equal(f.firstPurchase.before.richeseCache!.length, 9); + assert.equal(f.afterFirstBidding.discard.filter(card => card.id === f.recycledCard).length, 1); + assert.ok(f.firstPurchase.steps.some(step => step.actor === f.target && step.action.type === 'decision' && + step.action.karama === true && step.action.card === f.recycledCard)); + assert.equal(f.recycle.controlled, true); assert.equal(f.recycle.from, 'discard'); + assert.equal(f.beforeRecycle.pendingRicheseBetrayal, null); assert.ok(!f.beforeRecycle.auction); + const done = finish(act(f.game, f.holder, f.useAction)); + assert.deepEqual(wallets(done), before); + assert.equal(done.discard.filter(card => card.id === f.card).length, 1); + assert.equal(player(done, f.target).hand.some(card => card.id === f.card), false); + assert.equal(done.nexusCards!.cards!.hands[f.holder], null); + assert.equal(done.nexusCards!.cards!.discard.filter(card => card === 'richese').length, 1); + assert.equal(done.richeseBetrayal!.completed.at(-1)!.stage, 'used'); + assert.equal(done.richeseBetrayal!.completed.at(-1)!.event, receipt.event); + assert.equal(done.richeseBetrayal!.current, null); assert.ok(!done.currentAuctionSale); + assert.equal(done.auction!.index, index + 1); + assert.ok(player(done, f.target).hand.some(card => card.id === f.karama)); + assert.deepEqual(inventory(done), f.originalInventory); assert.deepEqual(nexusInventory(done), f.originalNexusInventory); + }); + + void test(`${mode} all normal acknowledgements keep the Nexus and restore the original spice-or-Karama payment choice`, () => { + const f = fixture({ advanced }), before = wallets(f.game); source(f); + const pending = pass(f.game); + assert.equal(pending.decision?.kind, 'auctionPayment'); assert.equal(pending.decision?.player, f.target); + assert.deepEqual(wallets(pending), before); + assert.equal(pending.nexusCards!.cards!.hands[f.holder], 'richese'); + assert.equal(player(pending, f.target).hand.some(card => card.id === f.card), false); + const done = pay(pending, f); + assert.equal(player(done, f.target).spice, before[f.target] - f.price); + assert.equal(player(done, f.emperor).spice, before[f.emperor] + f.price); + assert.equal(player(done, f.target).hand.filter(card => card.id === f.card).length, 1); + assert.equal(done.discard.some(card => card.id === f.card), false); + assert.equal(done.richeseBetrayal!.completed.at(-1)!.stage, 'passed'); + assert.equal(done.nexusCards!.cards!.hands[f.holder], 'richese'); + assert.deepEqual(inventory(done), f.originalInventory); assert.deepEqual(nexusInventory(done), f.originalNexusInventory); + }); + + void test(`${mode} ordinary-face MISS spends the blind Nexus then preserves one native purchase and payment`, () => { + const f = fixture({ advanced, richeseCard: false }), before = wallets(f.game); source(f); + assert.equal(viewGame(f.game, f.holder).richeseBetrayalReaction!.canUse, true); + const pending = act(f.game, f.holder, f.useAction); + assert.equal(pending.decision?.kind, 'auctionPayment'); assert.deepEqual(wallets(pending), before); + assert.equal(pending.nexusCards!.cards!.hands[f.holder], null); + assert.equal(player(pending, f.target).hand.some(card => card.id === f.card), false); + const done = pay(pending, f); + assert.equal(player(done, f.target).spice, before[f.target] - 3); + assert.equal(player(done, f.emperor).spice, before[f.emperor] + 3); + assert.equal(player(done, f.target).hand.filter(card => card.id === f.card).length, 1); + assert.equal(done.discard.some(card => card.id === f.card), false); + assert.equal(done.richeseBetrayal!.completed.at(-1)!.stage, 'used'); + assert.deepEqual(inventory(done), f.originalInventory); assert.deepEqual(nexusInventory(done), f.originalNexusInventory); + }); + + void test(`${mode} normal all-pass without a retained Karama follows the native automatic spice payment`, () => { + const f = fixture({ advanced, karama: false }), before = wallets(f.game); source(f); + assert.equal(player(f.game, f.target).hand.some(card => card.effect === 'karama'), false); + const done = finish(pass(f.game)); + assert.equal(done.decision, null); + assert.equal(player(done, f.target).spice, before[f.target] - 3); + assert.equal(player(done, f.emperor).spice, before[f.emperor] + 3); + assert.equal(player(done, f.target).hand.filter(card => card.id === f.card).length, 1); + assert.equal(done.nexusCards!.cards!.hands[f.holder], 'richese'); + assert.deepEqual(inventory(done), f.originalInventory); + }); + + void test(`${mode} original winning-bid Karama remains usable after the normal blind counter passes`, () => { + const f = fixture({ advanced }), before = wallets(f.game); assert.ok(f.karama); + const pending = pass(f.game); assert.equal(pending.decision?.kind, 'auctionPayment'); + const done = pay(pending, f, true); + assert.deepEqual(wallets(done), before); + assert.equal(done.discard.filter(card => card.id === f.karama).length, 1); + assert.equal(player(done, f.target).hand.filter(card => card.id === f.card).length, 1); + assert.equal(done.richeseBetrayal!.completed.at(-1)!.stage, 'passed'); + assert.equal(done.nexusCards!.cards!.hands[f.holder], 'richese'); + assert.deepEqual(inventory(done), f.originalInventory); + }); +} + +void test('genuine Richese ally pledge remains unused on a normal veto and pays its original split after a MISS', () => { + for (const family of [true, false]) { + const f = fixture({ advanced: true, allyPayment: 2, richeseCard: family }), receipt = source(f); assert.ok(f.ally && f.funding); + assert.equal(player(f.game, f.target).ally, f.ally); assert.equal(player(f.game, f.ally).ally, f.target); + assert.deepEqual(f.funding.steps, [{ actor: f.ally, action: { type: 'pledgeAid', amount: 2 } }]); + assert.equal(receipt.invoice.ownPayment, 1); assert.equal(receipt.invoice.allyPayment, 2); + assert.equal(receipt.invoice.donor, f.ally); + const before = wallets(f.game), credit = f.game.aid[f.ally].amount; + const pending = act(f.game, f.holder, f.useAction), done = family ? finish(pending) : pay(pending, f); + assert.equal(player(done, f.target).spice, before[f.target] - (family ? 0 : 1)); + assert.equal(player(done, f.ally).spice, before[f.ally]); + assert.equal(player(done, f.emperor).spice, before[f.emperor] + (family ? 0 : 3)); + assert.equal(done.aid[f.ally].amount, credit - (family ? 0 : 2)); + assert.equal(player(done, f.target).hand.some(card => card.id === f.card), !family); + assert.deepEqual(inventory(done), f.originalInventory); assert.deepEqual(nexusInventory(done), f.originalNexusInventory); + } +}); + +void test('ordinary self-purchase acknowledgement is neutral for either card family and an irrelevant actually drawn Nexus', () => { + const neutral: unknown[] = []; + for (const family of [true, false]) for (const holderNexus of ['richese', 'guild'] as const) { + const f = fixture({ richeseCard: family, holderNexus }); source(f); + const reaction = viewGame(f.game, f.holder).richeseBetrayalReaction!; + assert.equal(reaction.source, 'normal'); assert.equal(reaction.canPass, true); assert.equal(reaction.hasPassed, false); + assert.equal(reaction.canUse, holderNexus === 'richese'); + assert.equal(Object.hasOwn(reaction, 'card'), false); assert.equal(Object.hasOwn(reaction, 'cardId'), false); + assert.equal(reaction.event.includes(f.card), false); + neutral.push({ kind: reaction.kind, target: reaction.target, buyer: reaction.buyer, source: reaction.source, + price: reaction.price, canPass: reaction.canPass, hasPassed: reaction.hasPassed }); + const done = pay(pass(f.game), f); assert.equal(done.nexusCards!.cards!.hands[f.holder], holderNexus); + } + assert.ok(neutral.every(value => JSON.stringify(value) === JSON.stringify(neutral[0]))); +}); + +void test('all four minimal policies make legal normal-counter choices for a Richese face and an ordinary MISS', () => { + for (const family of [true, false]) { + const f = fixture({ richeseCard: family }); + for (const difficulty of DIFFICULTIES) { + const own = viewGame(f.game, f.holder); own.players.find(player => player.id === f.holder)!.bot = difficulty; + const action = botActions(own)[0]; assert.ok(action, difficulty); + assert.ok(action.type === 'richeseBetrayalPass' || action.type === 'richeseBetrayalUse', difficulty); + assert.equal(action.event, f.event); + let game = act(f.game, f.holder, action); + if (game.decision?.kind === 'auctionPayment') game = pay(game, f); + else game = finish(game); + assert.equal(game.nexusCards!.cards!.hands[f.holder], action.type === 'richeseBetrayalUse' ? null : 'richese'); + assert.deepEqual(inventory(game), f.originalInventory); + } + } +}); + +void test('a normal veto advances the real next lot without reopening its old acknowledgement or accepting stale use', () => { + const f = fixture(), originalIndex = f.game.auction!.index; + reject(f.game, f.target, f.useAction); + reject(f.game, f.holder, { ...f.useAction, event: `${f.event}:stale` }); + let game = finish(act(f.game, f.holder, f.useAction)); + assert.equal(game.auction!.index, originalIndex + 1); + const nextCard = game.auction!.cards[game.auction!.index].id; + for (let count = 0; !game.decision && count < 16; count++) { + assert.ok(game.auction); const actor = game.auction.active; + game = act(game, actor, actor === f.target && game.auction.bidder !== f.target + ? { type: 'bid', amount: 1, allyPayment: 0 } : { type: 'passBid' }); + assert.equal(game.pendingRicheseBetrayal, null); + } + assert.equal(game.decision?.kind, 'auctionPayment'); + assert.equal(game.richeseBetrayal!.completed.filter(receipt => receipt.event === f.event).length, 1); + reject(game, f.holder, f.useAction); + game = pay(game, f); + assert.equal(player(game, f.target).hand.filter(card => card.id === nextCard).length, 1); + assert.equal(game.discard.filter(card => card.id === f.card).length, 1); + assert.deepEqual(inventory(game), f.originalInventory); assert.deepEqual(nexusInventory(game), f.originalNexusInventory); +});