From 9c0000e469935d9f0fae1605358d5e6a2617be56 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Wed, 30 Sep 2026 23:18:32 +1000 Subject: [PATCH] Implement bounded Richese Nexus Betrayal auctions --- README.md | 8 + components/game-table.tsx | 27 +- components/nexus-richese-betrayal.tsx | 70 +++ docs/CURRENT_STATUS.md | 18 + docs/IMPLEMENTATION_STATUS.md | 47 ++ docs/NEXUS_CARD_RULES.md | 16 +- docs/NEXUS_RICHESE_RULES.md | 53 +- docs/RULE_DECISIONS.md | 1 + game/auction-continuation-quote.ts | 27 +- game/bot-nexus-richese-betrayal.ts | 15 + game/bots.ts | 2 + game/engine.ts | 258 ++++++++- game/nexus-richese-betrayal.ts | 242 +++++++++ game/reference.ts | 56 +- game/table-turn.ts | 3 +- tests/bot-nexus-richese-betrayal.test.ts | 101 ++++ tests/fixture-richese-betrayal.ts | 129 +++++ .../nexus-richese-betrayal-controls.test.tsx | 144 +++++ tests/nexus-richese-betrayal-engine.test.ts | 252 +++++++++ tests/nexus-richese-betrayal-recovery.test.ts | 494 ++++++++++++++++++ tests/nexus-richese-betrayal.test.ts | 237 +++++++++ tests/prototype-room.test.ts | 23 + tests/table-turn.test.ts | 15 + tools/prototype-room.ts | 7 + tools/start-prototype.ts | 2 +- 25 files changed, 2208 insertions(+), 39 deletions(-) create mode 100644 components/nexus-richese-betrayal.tsx create mode 100644 game/bot-nexus-richese-betrayal.ts create mode 100644 game/nexus-richese-betrayal.ts create mode 100644 tests/bot-nexus-richese-betrayal.test.ts create mode 100644 tests/fixture-richese-betrayal.ts create mode 100644 tests/nexus-richese-betrayal-controls.test.tsx create mode 100644 tests/nexus-richese-betrayal-engine.test.ts create mode 100644 tests/nexus-richese-betrayal-recovery.test.ts create mode 100644 tests/nexus-richese-betrayal.test.ts diff --git a/README.md b/README.md index ce3cf65b..04c221a4 100644 --- a/README.md +++ b/README.md @@ -142,6 +142,14 @@ counter; BG interception is before conversion. Unpayable overbid recovery remain guarded. See the [selected policy and boundaries](docs/CHOAM_KULL_SOURCE_UPDATE.md). This does not enable public expansion starts or certify full CHOAM. +For local **Richese Nexus Betrayal** testing, use `node --import tsx tools/start-prototype.ts --profile richese-betrayal` +on a fresh ready Basic/Advanced CHOAM/Richese-plus-classic lobby with the `choam` +deck and Nexus only. The same exact-version/private-backup safeguards apply. +The preview connects public self-cache purchase veto without payment and +other-buyer cache/Black Market sale diversion with unchanged delivery and price. +See the [cost, private timing, recovery evidence and remaining boundaries](docs/NEXUS_RICHESE_RULES.md#bounded-betrayal-auction-contract--30-september-2026). +This does not enable public expansion starts or certify the full Nexus family. + Above the board, **Treachery draw pile** and **Spice draw pile** show live card counts separately from hands, auction cards and discards. See [draw-pile counts](docs/DRAW_PILES.md). diff --git a/components/game-table.tsx b/components/game-table.tsx index a2d13dc4..227b8eec 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -78,6 +78,7 @@ import { SukGraduatePanel } from './suk-graduate'; import { NullentropyBox, NullentropySearch } from './nullentropy-box'; import { SemutaReaction } from './semuta-reaction'; import { ChoamKull } from './choam-kull'; +import { NexusRicheseBetrayal } from './nexus-richese-betrayal'; import { OrnithopterMovement } from './ornithopter-movement'; import { DiscoveryOrnithopterMovement } from './discovery-flight-movement'; import { PlanetologistMovement } from './planetologist-movement'; @@ -301,8 +302,8 @@ export function GameTable({ ); const reactionBusy = transportBusy || !!g.roomControl?.paused || !!g.roomControl?.closed || !!me.autopilot; - const busy = reactionBusy || g.automaticContinuationPending || !!g.semutaReaction || !!g.kullReaction; - const kullOwnsControls = !!g.kullReaction || !!g.kullCounterEvent; + const busy = reactionBusy || g.automaticContinuationPending || !!g.semutaReaction || !!g.kullReaction || !!g.richeseBetrayalReaction; + const reactionOwnsControls = !!g.kullReaction || !!g.kullCounterEvent || !!g.richeseBetrayalReaction; const canActivateKarama = (card: Card) => !g.karamaBlocked && canUseAsKaramaRole(g, me, card); const traitorBattle = g.battle; @@ -1008,6 +1009,8 @@ export function GameTable({ faction(g.players.find((p) => p.id === id)!.faction).name, ) .join(' + ') || 'No winner' + : g.richeseBetrayalReaction + ? 'Richese auction acknowledgement' : g.kullReaction ? 'CHOAM Karama response' : g.semutaReaction @@ -1035,6 +1038,14 @@ export function GameTable({ Preview rules and limits

)} + {g.richeseBetrayalPreview && ( +

+ Richese Nexus Betrayal development preview · public cache self-purchase + veto and ordinary other-buyer sale diversion only; not complete Richese + or certified Nexus rules.{' '} + Preview rules and limits +

+ )} {g.status !== 'lobby' && ( - {panel === 'hand' && kullOwnsControls ? ( + {panel === 'hand' && reactionOwnsControls ? ( Your Treachery hand is empty.

}> {(card) => (
diff --git a/components/nexus-richese-betrayal.tsx b/components/nexus-richese-betrayal.tsx new file mode 100644 index 00000000..31faf911 --- /dev/null +++ b/components/nexus-richese-betrayal.tsx @@ -0,0 +1,70 @@ +'use client'; + +import type { Action, GameView } from '@/game/engine'; +import { CardInspector, CardRules } from './card-inspector'; +import { NexusCardFace } from './nexus-cards'; +import { Button } from './ui/button'; + +export function NexusRicheseBetrayal({ game, act, busy }: { + game: GameView; + act: (action: Action) => void; + busy: boolean; +}) { + const reaction = game.richeseBetrayalReaction; + if (!reaction) return null; + const target = game.players.find((player) => player.id === reaction.target); + const buyer = game.players.find((player) => player.id === reaction.buyer); + const canRespond = reaction.canPass && !reaction.hasPassed; + const canUse = canRespond && reaction.canUse && !reaction.blocked; + const disabled = busy || game.status !== 'playing'; + // 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. + const card = reaction.source === 'cache' && game.richeseAuction?.source === 'cache' + ? game.richeseAuction.card + : null; + + return ( +
+

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' + ? ` ${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.`} +

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

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

} +

+ Every publicly possible unallied responder acknowledges this boundary, regardless of their secret Nexus identity. + Passing continues the original auction after all required acknowledgements; no payment or acquisition has happened yet.{' '} + Betrayal timing and preview limits +

+ {!canRespond ? ( +

{reaction.hasPassed ? 'You have passed. Waiting for the remaining acknowledgements.' : 'Waiting for the required acknowledgements.'}

+ ) : ( + <> + {reaction.blocked &&

{reaction.blocked}

} + + {canUse && ( +
+ {game.nexusCards?.card === 'richese' && } + +
+ )} + + )} +
+ ); +} diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index d40d4509..0aec6992 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -6,6 +6,24 @@ Expansion starts and full rules/publication acceptance remain gated.** ## Current checkpoint and work +Five implementation lanes now connect the bounded +[Richese Nexus Betrayal](NEXUS_RICHESE_RULES.md#bounded-betrayal-auction-contract--30-september-2026) +preview: original public self-cache purchase veto without payment, +and another buyer's cache/Black Market sale diversion to bank with +unchanged price, contributions, physical delivery and earned Harkonnen +bonus. Fresh `richese-betrayal` CLI entry requires paired CHOAM/Richese, +classic remaining seats, the CHOAM deck and Nexus alone. Public possible +responders acknowledge uniformly; only own canonical eligibility shows Use. +Independent review repaired escrow double-counting and post-bonus +exhausted-cache preflight. Types, lint, 5,953 offline tests, build and +80 focused cases pass. Genuine local CLI/runtime and phone controls prove +Basic veto, Advanced all-pass and Basic cache/Advanced Black Market +diversion, exact custody and refresh. Local Wrangler HTTP is **51/55**: +three POST 503s and one Advanced timeout, not green HTTP acceptance. +Proxy-free CI and live deployment are separate. Normal hidden self-buys, +private special acquisition, wider compositions and full-family completion +remain open; no public start or live deployment is claimed. + The explicit `kull` development profile now connects printed Kull Wahad interception in Basic/Advanced CHOAM-plus-classic rosters with the physical CHOAM/Ix deck. Five implementation lanes delivered diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 79a446ed..ed91ad4e 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,52 @@ # Dune implementation status +## 30 September 2026 — bounded Richese Nexus Betrayal auction integration + +Five implementation lanes delivered pure source/invoice/custody policy, +typed prepayment engine continuation, human controls and minimal legal bots, +authenticated SQLite recovery, and source-bound internal guidance. +Fresh `richese-betrayal` entry supports Basic/Advanced paired CHOAM/Richese +plus classic seats, the physical CHOAM deck and Nexus alone. + +A public cache auction won by native Richese may be vetoed before payment: +the exact intended card enters ordinary discard, nobody pays or acquires, +and the original lot retires once. Another buyer's positive cache or +concealed Black Market sale keeps the original price, authorized escrow +split, physical delivery and earned Harkonnen bonus; only its recipient +changes from Richese to bank. All-pass keeps the Nexus and settles the +unchanged original purchase. Public held-Nexus presence determines neutral +acknowledgement membership; actual identity affects only own private Use. +Independent pending/completed cursor history binds saved source ownership +and rejects orphaned frames before disclosure or SQL writes. + +Independent review found and repaired donor escrow double-counting and a +Harkonnen bonus path that skipped exhausted-cache preflight. Regressions +cover a seller funding its buyer with zero unpledged spice and rejection +of an unfinishable closing bid before any receipt, payment or delivery. +Existing wording/incidental assertions were removed rather than re-pinned. + +Verification: `npm run check` passes types, lint and **5,953 offline tests**; +`npm run build` passes. Eight focused files pass **80 cases**, including +both alternatives/all-pass, private timing parity, actual table controls, +four legal bot profiles, JSON/production SQLite restart, competing Use/Pass, +no-write rejections and later recyclable physical custody. Final independent +economy and persistence re-reviews found no remaining scoped defect. + +Actual prototype CLI started four owned disposable rooms from version 5 +to setup 6. Genuine setup/bids created typed opportunities; a separate +runtime smoke proves Basic/Advanced veto and cache/Black Market diversion. +At 390 pixels, real controls prove Basic veto, Advanced all-pass, Basic +cache diversion and refreshed Advanced Black Market diversion without an +unknown-face inspector. Saved outcomes retain the original completed event, +exact card custody and buyer/seller balances. No human games or seats were +reset or modified. + +Current built-worker local HTTP is **51/55**, with three POST 503s and one +Advanced-preview timeout; it is not green acceptance. Proxy-free container +CI and publication are recorded separately after the push. Full Richese +games, hidden normal-deck self-purchases, private special-Karama veto, +broader modules and live NAS acceptance remain open; no public gate changes. + ## 30 September 2026 — opt-in printed Kull Wahad integration Five implementation lanes supplied validated activation frames, diff --git a/docs/NEXUS_CARD_RULES.md b/docs/NEXUS_CARD_RULES.md index eea575ff..4f2a4f93 100644 --- a/docs/NEXUS_CARD_RULES.md +++ b/docs/NEXUS_CARD_RULES.md @@ -61,7 +61,7 @@ The private holder offer contains CHOAM's name; it includes the hand count only ## Effect integration boundaries -Later focused source contracts cover [Emperor strength, purchases and revival](NEXUS_EMPEROR_RULES.md), [Ixian purchased-card replacement](NEXUS_IXIAN_REPLACEMENT_RULES.md), and [Fremen worm and revival effects](NEXUS_FREMEN_RULES.md). These audits distinguish implemented work from remaining source/timing questions; the current runtime evidence is tracked in [implementation status](IMPLEMENTATION_STATUS.md). +Later focused source contracts cover [Emperor strength, purchases and revival](NEXUS_EMPEROR_RULES.md), [Ixian purchased-card replacement](NEXUS_IXIAN_REPLACEMENT_RULES.md), [Fremen worm and revival effects](NEXUS_FREMEN_RULES.md), and [Richese's separate shipment and auction panels](NEXUS_RICHESE_RULES.md). These audits distinguish implemented work from remaining source/timing questions; the current runtime evidence is tracked in [implementation status](IMPLEMENTATION_STATUS.md). The bounded Richese Betrayal wave retains its own **Planned** verification rather than inheriting earlier auction or shipment evidence. **Atreides is the first complete effect family to implement.** It has independent rulebook and physical-face evidence, and the engine already has private plan-element inspection. The Nexus effect needs its own cause and receipt rather than temporarily changing the inspecting faction. The second element must differ from the already inspected element. Preserve the original disclosed value if an allowed later battle edit occurs; follow the existing plan commitment rules instead of refreshing an old observation. The card specifies an element, not the entire hand or battle plan. A weapon/defense request uses actual played-card classification, including the published Reinforcements/Harass exclusions, rather than leaking a card merely because it occupies that slot. @@ -79,7 +79,7 @@ Later focused source contracts cover [Emperor strength, purchases and revival](N - No retrieved card or E3 FAQ gives a general rule that every Nexus effect is Karama-immune, or that playing one suspends all ordinary responses. Homeworld immunity on the preceding page is limited to Homeworld advantages/penalties. Audit cancellation of an underlying native advantage separately from cancellation of the Nexus card before enabling each family. - Duration is explicit for several panels but not all. Ixian Betrayal's scope over multiple auction lots, the optional continuation after canceling native Prescience, and exact multi-party cancellation priority require the corresponding existing faction timing contract. Do not make an entire-phase cancellation merely from the word “advantage.” - Emperor forced ally payment needs a precise invoice allocation when the ally has some funds and Emperor is short. Its own purchase-refund alternative also needs original payer contributions. Tleilaxu's combined leader/force revival requires an audit of partial selection and interaction with ordinary free rates; the card does not say that all five force revivals are free. -- Richese's two-No-Field effect needs its own physical counter and immediate-reveal transaction; the normal single-marker action cannot represent it by adding private values. Ecaz's Duke override needs explicit restoration/end-turn handling across capture, death and Ghola records. These are material implementation tasks, not reasons to invent substitute effects. +- Richese's two-No-Field effect has a bounded native Cunning shipment follow-up, but wider marker/module compositions remain separate. Its [bounded Betrayal auction contract](NEXUS_RICHESE_RULES.md#bounded-betrayal-auction-contract--30-september-2026) connects public self-cache veto and other-buyer sale diversion, not normal-deck hidden-family self-purchases or private special-Karama acquisition. Ecaz's Duke override needs explicit restoration/end-turn handling across capture, death and Ghola records. These are material implementation tasks, not reasons to invent substitute effects. - A complete twelve-card reference/inventory and common lifecycle are substantial first work, but do not alone make all thirty-six panels playable. Keep the public module release gated until every face's supported timing, secrecy, custody, bots, UI, rejection, multiplayer CAS and saved continuation paths are verified. If development supports only Atreides' family first, other faces must remain accurately represented and explicitly unavailable in the audit mode; they must not silently act as blanks in a released module. No user question was sent during this audit. No tournament rule, fan effect, public predeal reveal, extra Nexus identity or Discoveries prerequisite has been adopted. @@ -102,3 +102,15 @@ precommitment would remove the printed after-loss choice. The sources do not establish a generic Karama response to this borrowed Secret Ally effect, so none is invented. The native Moritani alliance retention response remains a separate power. + +## Richese Betrayal prepayment protocol + +The [printed Richese panel and bounded auction contract](NEXUS_RICHESE_RULES.md#bounded-betrayal-auction-contract--30-september-2026) distinguish two outcomes, not two costs. An unallied non-Richese holder spends the actual singleton Richese Nexus card while native Richese is seated. No Treachery slot/card or extra activation spice is spent. + +The public-cache self-purchase alternative discards the exact intended card **before** payment/delivery: nobody pays and no purchase bonus is fabricated. The other-buyer cache or Black Market sale alternative changes only the original positive invoice's recipient from Richese to bank: the original price/contributions, delivery and earned Harkonnen consequence remain once. Passing keeps the Nexus card; all required passes settle the unchanged original purchase once. Neither alternative refunds a completed sale or replays a raw purchase Action. + +To avoid identifying a secret card through timing, every unallied non-Richese seat with publicly held-Nexus presence acknowledges the same before-payment boundary. Required/pass membership derives from public roster/alliance/presence only. Only each privately eligible holder sees Use or its own blocked reason; irrelevant hidden identities do not change public event/auction fields. With no publicly possible responder, continuation is automatic. This is an **explicit application protocol**, not publisher timing authority or blanket Karama immunity. + +The reaction projects only public target/buyer/source/price/event plus own pass/use eligibility. Existing public cache inspection does not entitle anyone to the concealed Black Market face or to the Richese-family identity of a normal hidden lot; claims and printed catalog faces are not custody disclosures. Normal-deck self-purchases, special-Karama veto, unsupported counter/any-time overlays and broader modules remain outside this connected boundary. The private receipt owns exact source, invoice and continuation; independent pending/completed cursor evidence must reject missing/orphaned frames before read or SQL rather than auto-settling. + +The profile is explicit, fresh Basic/Advanced CHOAM/Richese plus classic seats, Nexus and the physical CHOAM deck only; it does not upgrade earlier games or open ordinary expansion starts. The dedicated internal `nexus-richese-betrayal` checklist records bounded engine, authenticated SQLite, bot, real controls and refreshed phone-browser evidence, with broader verification **Partial**. Shipment Cunning, ordinary auction, native special-Karama and a complete twelve-face catalog are not substitute verification. Full-family, combined-module and deployed acceptance remain open. diff --git a/docs/NEXUS_RICHESE_RULES.md b/docs/NEXUS_RICHESE_RULES.md index cd559094..873fd8e1 100644 --- a/docs/NEXUS_RICHESE_RULES.md +++ b/docs/NEXUS_RICHESE_RULES.md @@ -1,6 +1,6 @@ # Richese Nexus source contract -Audited 10 September 2026. This separates the ordinary Secret Ally shipment from native Cunning's two-marker operation. It does not certify either implementation or open the Nexus release gate. See [common Nexus rules](NEXUS_CARD_RULES.md), [No-Field custody](RICHESE_NO_FIELD_RULES.md), and [allied No-Field distinctions](RICHESE_ALLIED_SHIPMENT_AUDIT.md). +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). ## Printed effects and source status @@ -8,6 +8,8 @@ The Richese panel was freshly read from the [original photographed GF9 cards](ht Common eligibility requires **Richese absent**, not Guild absent, for Secret Ally. A native Guild player may hold it. A holder cannot retain a Nexus card when entering an alliance, and play consumes the card. E3 contains no Richese-specific Nexus FAQ. [GF9 E3 p.11 and p.16](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=11) +The photographed Betrayal panel has two alternatives: stop Richese buying a Richese Treachery Card by discarding that intended card without payment, **or** redirect the payment for a card Richese sells to the Spice Bank. Both spend the same physical Richese Nexus card; they are not two uses or a generic cancellation of Richese income. The panel's source is the [original GF9 component photograph](https://boardgamegeek.com/image/7767032/dune-ecaz-and-moritani), semantically transcribed in `game/nexus-card-reference.ts`. Auction origin, payment routing and still-earned purchase consequences compose with [GF9 E2 pp.4–6 and 10–12](https://www.gf9games.com/dune/wp-content/uploads/2021/11/CHOAM-Rulebook-low-res.pdf), as audited in the auction contract; no tournament amendment supplies authority. + Official indexed excerpts, the original component photograph, and cached E3 publisher-authored text were inspected. Direct retrieval of the official base/November FAQ PDFs returned 403. The older local base/E2 cache paths named by historical documents are no longer present in this workspace; those historical claims were checked against fresh publisher excerpts where used below. ## Secret Ally: ordinary shipment composition @@ -66,7 +68,7 @@ The connected path is deliberately narrower than every possible E2 composition: The card is discarded at declaration. The native No-Field Karama cancellation precedes settlement and preserves the two tokens, reserves, spice and ordinary shipment allowance but not the card. Advanced Guild special prevention also leaves the physical shipment unpaid and unplaced while consuming the attempted shipment allowance and the card. Otherwise the pair is one arrival, costs the ordinary one-marker price, immediately reveals one chosen denomination and places up to that value of actual normal forces from current reserves in the destination. Only the other token remains concealed, publicly counting as one force; it draws from then-current reserves on its later native reveal. The immediate group plus the concealed marker count as actual presence for subsequent territory-entry and board logic, not two independent shipments. -An owner-only control distinguishes ordinary No-Field, Smuggler and Nexus pair choices. All four bot profiles have a minimal legal pair candidate. The pending frame binds both physical identities, owner, token event, destination and one-marker price through JSON and in-memory SQLite CAS recovery. Public logs disclose the immediate faceup value/actual force count after settlement; the rival projection contains no concealed token identity or owner-only offer. Signatures detect partial saved-frame corruption, not arbitrary save forgery. Combined modules, allied No-Fields, high-Richese additional-force effects, Betrayal, later faceup display mechanics and full Nexus acceptance remain open. +An owner-only control distinguishes ordinary No-Field, Smuggler and Nexus pair choices. All four bot profiles have a minimal legal pair candidate. The pending frame binds both physical identities, owner, token event, destination and one-marker price through JSON and in-memory SQLite CAS recovery. Public logs disclose the immediate faceup value/actual force count after settlement; the rival projection contains no concealed token identity or owner-only offer. Signatures detect partial saved-frame corruption, not arbitrary save forgery. Combined modules, allied No-Fields, high-Richese additional-force effects, later faceup display mechanics and full Nexus acceptance remain open. Betrayal now has its separate bounded auction contract below; the Cunning evidence does not verify that path. ## Existing integration points and verification @@ -75,3 +77,50 @@ An owner-only control distinguishes ordinary No-Field, Smuggler and Nexus pair c `game/homeworld-shipment.ts` and the Guild Homeworld adapter own non-Arrakis routes; they should not be bypassed with a forged static board destination. `game/richese-no-field.ts` owns physical markers and should not be initialized for absent-faction Secret Ally. Required focused evidence: 1/5 actual-force shipments with unchanged price; normal/elite mixtures; Guild present/absent/native-holder and active bank-rate payment; Fremen retained geography/free price; native Homeworld source custody; occupancy/storm rejection before costs; Guild allowance/prevention; real BG/Terror/Ambassador continuations using actual counts; all-seat projections; and persisted pending/complete/stopped histories with concurrent and rejected-action immutability. Complete Cunning and broader module acceptance remain separate work. + +## Bounded Betrayal auction contract — 30 September 2026 + +### Entry and actual cost + +The explicit local `richese-betrayal` profile is for genuine fresh Basic or Advanced games with CHOAM and Richese, two to six seats, and otherwise classic factions. It selects the physical CHOAM Treachery deck and Nexus module, with no other optional modules; Ixians, Tleilaxu, Ecaz, Moritani, Semuta and Kull previews are outside this boundary. `initializeRicheseBetrayalGameForAudit(state: Game): Game` is the engine admission seam. Existing games and other preview profiles do not silently gain this behavior. Ordinary public expansion starts and full Nexus/Richese certification remain closed. + +Use requires an **unallied** holder whose actual native faction is not Richese, native Richese seated, and the one physical Richese Nexus card in that holder's separate custody. Spending it moves that singleton to Nexus discard once. No ordinary Treachery card, Karama, hand slot or extra spice is the activation cost. Current own-card promises and committed-resource legality still apply before acceptance; merely possessing the face is not sufficient. Passing keeps the Nexus card and spends nothing. + +### Prepayment veto versus recipient override + +| Alternative | Connected trigger | Physical card and spice result | +| --- | --- | --- | +| Purchase veto | Richese wins its own **public cache** lot through Once Around or Silent bidding, before debit or acquisition | Move the exact selected cache card to ordinary Treachery discard, not Richese's hand, cache return or out-of-game removal. Do not debit the buyer or ally, and do not pay Richese, Emperor or bank. No purchase was made, so no Harkonnen purchase bonus is created. Retire the original lot/round continuation once. | +| Sale diversion | Another buyer wins a cache lot or concealed Advanced Black Market lot (normal, Once Around or Silent), with a positive payment otherwise due to Richese | Keep the original winner, price, own/authorized-ally contributions and physical delivery. Route that same payment to the bank instead of Richese; Richese receives zero sale income. The buyer is charged only once, not a second bank fee. Existing earned buyer consequences, including the Harkonnen bonus when applicable, continue once. | + +Richese buying its own cache normally pays Emperor/bank. That invoice is the purchase-veto alternative, **not** a sale payment owed to Richese. A zero result/free cache keep or unsold Black Market retention is not a positive sale. Existing exhausted-cache/ordering guards, positive Black Market seller-bid guards and special-acquisition guards remain; this panel does not settle their source questions. + +Normal-deck Richese self-purchases can conceal whether the physical card belongs to the Richese family. They are **not connected** by this first boundary. The private advanced special-Karama cache acquisition has a separate activating card and once-per-game ordering and is also **not a connected veto trigger**. Native statements about whether Karama can cancel that special power do not establish whether Nexus Betrayal applies to it. Do not certify either producer by borrowing an unrelated native exception. + +The interruption belongs inside `settleRicheseLot`, after native responses and any entitled Atreides peek have finished and the sold quote is valid, but before `payWithAlly`, source removal or hand delivery. Source/recipient facts come from that original quote, not a client-selected target, card, amount or recipient. Unsupported counter overlays or any-time effects that the receipt cannot resume must be prevented before entry, not suppressed after money moves. A diversion is a recipient override, not a refund, re-purchase or replay of bidding. + +The current preflight requires a playing Bidding-stage Richese lot with no ordinary auction/sale, response, decision, phase opening, active Truthtrance, Karama, Nullentropy/discard, Kull/CHOAM conversion or market, Ixian allied replacement, Richese gift/purchase-income/Technology, or pending Nexus traitor exchange overlay. The original quote proves exact budget, representable sale/bonus continuation, exhausted-cache guards and binding shipment-completion feasibility before the pause. While pending, only the exact reaction choices and bot advancement are admitted; another any-time effect cannot mutate the reserved transaction. Spending the holder's Nexus must preserve that holder's own binding shipment promises. An involuntary purchase veto does not assume the target acquired the card or spent the price; opponent promises made impossible by that prevention use existing native reconciliation/release. These are bounded continuation guards, not blanket publisher immunity. The cache veto is a direct ordinary discard; the profile does not compose it with Semuta. + +### Neutral application protocol and privacy + +The pause is uniform for all **publicly possible** responders: other-faction unallied seats with publicly visible held-Nexus presence. Required/pass IDs depend only on roster, alliances and that presence, never on a secret held identity. If no seat is publicly possible, the original purchase automatically continues. Otherwise each required seat may pass, including a holder of an irrelevant Nexus card. Only the privately eligible Richese-card holder sees Use. Once all required seats pass, settle the original quoted purchase once; an accepted Use commits its one alternative and closes that opportunity. + +This neutral acknowledgement is an explicit application protocol to preserve concealed custody, **not** printed publisher timing or a general ruling about simultaneous Nexus/Karama priority. A holder-only pause would reveal a secret card. Replacing a relevant hidden card with an irrelevant hidden card must not change the public pause, event or auction terms. + +`GameView.richeseBetrayalPreview` identifies the opt-in profile. `GameView.richeseBetrayalReaction` is null or `{ event, kind, target, buyer, source, price, canPass, hasPassed, canUse, blocked }`, with `kind: 'purchase' | 'sale'` and `source: 'cache' | 'blackMarket'`. Every seat sees the same public event, kind, Richese target, original buyer, source and price. Purchase requires cache and buyer equal to target; sale requires another buyer. `canPass` and `hasPassed` concern only the viewer's public acknowledgement; `canUse` and `blocked` concern only that viewer's actual private eligibility. + +The reaction contains no target physical card, hidden Black Market face, payer spice, ally balances, opponent hand, private Nexus identity, raw source frame, signature or deck order. Public cache faces remain available through existing entitled inspectors. A Black Market claim is not the unknown face; the new reaction grants no new inspection permission. Existing Atreides or owner entitlement remains separate. Public Nexus face reference is a catalog, not proof that anyone holds that face. + +Clients submit exactly `{ type: 'richeseBetrayalPass', event }` or `{ type: 'richeseBetrayalUse', event }`. The server checks actor/event and exact keys and supplies the original target/card/price/recipient from private state. Human controls and all four minimal legal bot policies consume the same projected eligibility; no consumer guesses Use from physical custody alone. Pending acknowledgement replaces underlying auction actions rather than letting them charge or advance the lot early. + +### Saved continuation and verification status + +A typed private frame/receipt binds the original round, lot, physical card, Richese owner, buyer, price/funding, source custody and continuation ownership. Independent pending/completed cursor evidence prevents deletion or orphaning of that frame from automatically settling a purchase. Reconnect resumes the exact opportunity, recorded passes and committed outcome without rerolling, replaying a raw Action, rolling back a full Game or echoing payment. Intrinsic committed history must remain valid after the Nexus or purchased/discarded Treachery card subsequently moves; it must not require an old card to stay in discard forever. + +The implemented state names are `Game.pendingRicheseBetrayal` for `{ receipt, lot, quote, signature }` and independent `Game.richeseBetrayal` for `{ sequence, current, completed }`. The receipt carries event/turn/sequence, original round/lot, private invoice, public-required IDs, recorded passes, `pending | passed | used` stage, actor and Nexus-discard index. Its invoice includes the original recipient, price, own/ally contribution split, donor and private physical card. The source signature binds live original custody/funding/continuation; historical completed receipts keep intrinsic evidence rather than asserting present discard location. These deterministic signatures detect inconsistency, not arbitrary save forgery, and are never part of the player reaction projection. + +Implementation responsibilities are `game/nexus-richese-betrayal.ts` for pure invoice/source/eligibility and receipt invariants; `game/engine.ts` and `game/auction-continuation-quote.ts` for settlement, projection and finalizer fences; `components/nexus-richese-betrayal.tsx` and `game/bot-nexus-richese-betrayal.ts` for real private Use/public pass/wait consumers. The internal player topic is `nexus-richese-betrayal` in `game/reference.ts`; player navigation remains internal-only. + +**Verification: Partial, with the bounded runtime verified locally.** TypeScript, lint, all 5,953 offline tests and the app build pass; eight focused files pass 80 cases. The five dedicated suites cover purchase veto/all-pass, cache and concealed Black Market diversion, exact Nexus/card/spice/escrow custody, native Harkonnen bonus and cancellation, private identity parity, rejected-action immutability, all four legal bot profiles, real table controls and authenticated SQLite restart/concurrency/orphan rejection. Independent economy and persistence reviews exposed an erroneous second donor debit and a post-bonus exhausted-cache deadlock; both are fixed with regressions. Preflight consumes already pledged escrow and validates the deterministic next boundary before reserving an opportunity. + +Actual `richese-betrayal` CLI entry, source runtime settlement and 390-pixel browser controls were exercised in disposable QA rooms: Basic purchase Use discards without debit/delivery; Advanced purchase Pass preserves the Nexus and performs the original purchase; Basic cache and Advanced Black Market Use send the six-spice payment to bank once while delivering the exact card. Refresh retains each completed event and physical custody; the Black Market holder sees no unknown face inspector. No existing human game or seat was changed. Local Wrangler HTTP passes 51/55: three POST 503s and one Advanced-preview timeout prevent a green local HTTP claim. Proxy-free container CI and live NAS evidence are separate; no deployed acceptance or full Richese/Nexus completion is claimed. diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 8267eb95..42c46ca8 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -35,6 +35,7 @@ Updated 30 September 2026. This is the authoritative navigation index for existi | Nexus lifecycle | Twelve physical cards, phase draw/replacement and alliance forfeiture; native Cunning, absent-faction Secret Ally and Betrayal have separate effects. No blanket Karama immunity. | [Common source contract](NEXUS_CARD_RULES.md), [lifecycle runtime](NEXUS_CARD_RUNTIME.md) | | Nexus faction effects | Preserve physical custody, original faction identity and durable effect receipts; use each printed family instead of borrowing another faction's rules by analogy. | [Atreides](NEXUS_ATREIDES_RULES.md), [Harkonnen](NEXUS_HARKONNEN_RULES.md), [BG](NEXUS_BENE_GESSERIT_RULES.md), [Emperor](NEXUS_EMPEROR_RULES.md), [Fremen](NEXUS_FREMEN_RULES.md), [Guild](NEXUS_GUILD_RULES.md) | | Expansion Nexus families | Source audit coverage does not imply every panel is playable; consult later runtime records in the status log. | [Ixian Cunning](NEXUS_IXIAN_CUNNING_RULES.md), [Ixian replacement](NEXUS_IXIAN_REPLACEMENT_RULES.md), [Tleilaxu](NEXUS_TLEILAXU_RULES.md), [CHOAM](NEXUS_CHOAM_RULES.md), [Richese](NEXUS_RICHESE_RULES.md), [Ecaz](NEXUS_ECAZ_RULES.md), [Moritani](NEXUS_MORITANI_RULES.md) | +| Richese Nexus Betrayal auctions | Pause at a valid original prepayment quote. Public self-cache veto discards the exact card without debit/acquisition; another buyer's positive cache/Black Market invoice keeps price/contributions/delivery and earned Harkonnen bonus while overriding the recipient to bank. All publicly possible unallied other-faction held-Nexus seats acknowledge uniformly; private identity controls only own Use. This neutral protocol is an application choice, not publisher timing. Normal hidden-family self-purchases, special-Karama veto and unsupported overlays remain excluded; no native Karama exception certifies them. Bounded engine/SQL/bot/phone scenarios are verified locally; broader verification remains Partial and public starts/full Nexus gates stay closed. | [Printed face, profile, API and saved continuation](NEXUS_RICHESE_RULES.md#bounded-betrayal-auction-contract--30-september-2026), [common privacy protocol](NEXUS_CARD_RULES.md#richese-betrayal-prepayment-protocol), [auction source boundaries](RICHESE_AUCTION_RULES.md) | | CHOAM Secret Ally | Absent-faction Collection trade spends one actual Worthless card and the Nexus for two bank spice. It is separate from native Cunning and Auditor; the battle inspection alternative remains missing. | [Prototype contract](NEXUS_CHOAM_SECRET_ALLY.md) | | Sapho movement | At a clean boundary, first reorders only remaining unstarted turns; completed turns stay completed. Advanced Guild must have finished or be absent. Last retains existing protection after Guild. | [Existing source contract](JUICE_OF_SAPHO_SOURCE_UPDATE.md#movement-firstlast), [runtime](JUICE_OF_SAPHO_RUNTIME.md) | | Basic CHOAM / Leader Skills | Compose existing ordinary skills with native sales and own-leader Ghola revival. Finish the private optional replacement before the exact suspended sale resumes. The free revival remains completed if the resumed sale is canceled. Basic uses five ordinary leaders and 35 Treachery Cards; Auditor remains Advanced and ineligible for skills. | [Integration and source contracts](CHOAM_LEADER_SKILLS.md) | diff --git a/game/auction-continuation-quote.ts b/game/auction-continuation-quote.ts index e9682d65..a23f23a6 100644 --- a/game/auction-continuation-quote.ts +++ b/game/auction-continuation-quote.ts @@ -263,6 +263,9 @@ function calculate( sale = { ...s, legacy: false }; cardId = lot.cardId; } + requireAuction(sale.recipient === undefined || + (sale.recipient === 'bank' && sale.origin !== 'normal' && sale.seller !== sale.winner && sale.amount > 0), + 'A bank recipient override requires a positive Richese sale to another buyer.'); requireAuction( count(sale.amount) && typeof sale.free === 'boolean', 'The paid auction amount or free flag is invalid.', @@ -342,17 +345,19 @@ function calculate( ): AuctionContinuationQuote => ({ sale, steps, next }); if (stage === 'sale') { if (sale.origin !== 'normal' && sale.seller !== winner.id) { - const seller = seated(sale.seller!)!; - requireAuction( - count(seller.spice) && count(seller.spice + sale.amount), - 'The seller credit would overflow its current balance.', - ); - steps.push({ - kind: 'sellerCredit', - player: seller.id, - amount: sale.amount, - balance: seller.spice + sale.amount, - }); + if (sale.recipient !== 'bank') { + const seller = seated(sale.seller!)!; + requireAuction( + count(seller.spice) && count(seller.spice + sale.amount), + 'The seller credit would overflow its current balance.', + ); + steps.push({ + kind: 'sellerCredit', + player: seller.id, + amount: sale.amount, + balance: seller.spice + sale.amount, + }); + } } else { const emperor = input.players.find((p) => p.faction === 'emperor'); if (!sale.free && emperor && emperor.id !== winner.id) diff --git a/game/bot-nexus-richese-betrayal.ts b/game/bot-nexus-richese-betrayal.ts new file mode 100644 index 00000000..6d6de40b --- /dev/null +++ b/game/bot-nexus-richese-betrayal.ts @@ -0,0 +1,15 @@ +import type { Action, GameView } from './engine'; + +/** All profiles obey the same seat-private legality, including promised cards + * and committed budgets. Held-card hints cannot override the projected offer. */ +export function botNexusRicheseBetrayalActions(game: GameView): Action[] { + const reaction = game.richeseBetrayalReaction; + if (game.status !== 'playing' || !reaction?.event || + !reaction.canPass || reaction.hasPassed) return []; + const own = game.players.find((player) => player.id === game.me); + if (!(own?.bot ?? own?.autopilot)) return []; + return [{ + type: reaction.canUse && !reaction.blocked ? 'richeseBetrayalUse' : 'richeseBetrayalPass', + event: reaction.event, + }]; +} diff --git a/game/bots.ts b/game/bots.ts index 3631aae3..9781cc89 100644 --- a/game/bots.ts +++ b/game/bots.ts @@ -103,6 +103,7 @@ import { presenceAt } from './force-presence'; import { botArrivalBlock, botWormRideArrivalBlock } from './bot-arrival'; import { botSemutaActions } from './bot-semuta'; import { botChoamKullActions, botChoamKullCounterActions } from './bot-choam-kull'; +import { botNexusRicheseBetrayalActions } from './bot-nexus-richese-betrayal'; import { validateCohortSelection, type OrnithopterMode } from './ornithopter'; import { fighterCount, isAdvisor } from './advisors'; import { casualtyOptions, maxCombatDial, maxCombatSupport, type CombatForces } from './combat'; @@ -3826,6 +3827,7 @@ function standaloneGholaAction(g: GameView, ordinary: Action[]): Action | null { /** Obligations apply across policy branches, including choosing to move first. */ export function botActions(g: GameView): Action[] { + if (g.richeseBetrayalReaction) return botNexusRicheseBetrayalActions(g); if (g.kullReaction) return botChoamKullActions(g); if (g.kullCounterEvent) return botChoamKullCounterActions(g); diff --git a/game/engine.ts b/game/engine.ts index 6340d4f5..b46a8c40 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -414,6 +414,7 @@ import { quoteRicheseSettlement, requireRicheseDeclarationCache, RicheseSettlementError, + type RicheseSettlementQuote, } from './richese-settlement'; import { validateAmbassadors, @@ -465,6 +466,7 @@ import { reserveExtortion, collectExtortion, answerExtortion, validateExtortionS import { STORM_START_SECTOR, PLAYER_CIRCLE_SECTORS } from './player-positions'; import { cashInCards } from './choam-karama'; import { kullBlocksKarama, activeKullRestrictions, validateKullPhaseRestrictions, validateKullAttemptStamp, kullNativeCostCards, kullCounterCards, type KullPhaseRestriction } from './choam-kull'; +import { quoteRicheseBetrayalInvoice, 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'; @@ -1079,6 +1081,15 @@ type RicheseAllyOffer = { payer: string; }; export type Game = { + /** Explicit bounded Nexus/Richese auction profile; never inferred for old saves. */ + richeseBetrayalPreview?: boolean; + richeseBetrayal?: RicheseBetrayalCursor; + pendingRicheseBetrayal?: { + receipt: RicheseBetrayalReceipt; + lot: RicheseAuction; + quote: Extract; + signature: string; + } | null; /** Explicit development profile; never enabled by a public start or player action. */ kullPreview?: boolean; kullSequence?: number; @@ -1445,6 +1456,8 @@ export type Game = { free: boolean; origin: 'normal' | 'cache' | 'blackMarket'; seller: string | null; + /** Original seller remains the source owner when the invoice is sent to bank. */ + recipient?: 'bank'; } | null; pendingAmbassador?: { revivalEvent?: string; @@ -8088,6 +8101,19 @@ export function initializePairedNexusGameForAudit(state: Game): Game { return initializeSetupGameForAudit(state, false, true, false, false, false, false, true); } +/** Genuine opt-in auction source interruption; ordinary/public starts stay gated. */ +export function initializeRicheseBetrayalGameForAudit(state: Game): Game { + requireRule(!state.richeseBetrayalPreview && !state.richeseBetrayal && !state.pendingRicheseBetrayal && + richeseBetrayalModeSupported(state) && + state.nexusCards?.cards === null && state.nexusCards.phase === null, + 'Richese Betrayal needs a fresh CHOAM/Richese lobby, the CHOAM deck and only Nexus Cards.'); + const g = initializeSetupGameForAudit(state, false, true, false, false, false, false, true); + g.richeseBetrayalPreview = true; + g.richeseBetrayal = {sequence:0,current:null,completed:[]}; + g.pendingRicheseBetrayal = null; + return g; +} + /** Offline-only all-expansion Nexus composition, optionally with Homeworlds; not a public start gate. */ export function initializeCombinedNexusGameForAudit(state: Game, homeworlds = false): Game { requireRule(state.expansions.length === 3 && @@ -9379,6 +9405,188 @@ function finishRicheseLot(g: Game) { nextPhase(g); } } +function richeseBetrayalModeSupported(g: Game): boolean { + return g.expansions.length === 1 && g.expansions[0] === 'choam' && + g.players.length >= 2 && g.players.length <= 6 && + g.players.some(p => p.faction === 'richese') && g.players.some(p => p.faction === 'choam') && + g.players.every(p => RICHESE_CUNNING_ROSTER.has(p.faction)) && + !!g.nexusCards && !g.homeworlds && !g.leaderSkills && !g.discoveryEnabled && + !g.ecazTreachery && !g.sandtrout && !g.techTokens && !g.strongholdCards && + !g.semutaPreview && !g.kullPreview; +} +function richeseBetrayalResponders(g: Game): string[] { + return requiredRicheseBetrayalResponders(g.players.map(p => ({ + id:p.id,faction:p.faction,ally:p.ally,nexusHeld:!!g.nexusCards?.cards?.hands[p.id], + }))); +} +function richeseBetrayalSourceSignature(g: Game, frame: NonNullable): string { + 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), + funding:g.richeseFunding,aid:g.aid,cache:g.richeseCache,offered:g.richeseOfferedCard, + 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, + peek:g.richesePeekKnown,claim:g.richeseClaim, + }); +} +function richeseBetrayalInvoice(g: Game, quote: Extract) { + const lot = g.richeseAuction!; + return nexusRule(() => quoteRicheseBetrayalInvoice({ + players:g.players,lot,quote,donor:quote.allyPayment ? getPlayer(g,quote.winner).ally : null, + originalRecipient:quote.winner === lot.owner ? byFaction(g,'emperor')?.id ?? null : lot.owner, + sourceCards:lot.source === 'cache' ? g.richeseCache! : getPlayer(g,lot.owner).hand, + physicalCards:physicalTreacheryCards(g), + })); +} +function richeseBetrayalSourcePreflight(g: Game, quote: Extract): void { + requireRule(richeseBetrayalModeSupported(g) && g.status === 'playing' && g.phase === 3 && + g.richeseBidding?.stage === 'lot' && g.richeseBidding.turn === g.turn && + !g.auction && !g.currentAuctionSale && !g.response && !g.decision && !g.phaseOpening && + !g.truthtrance && !g.pendingKarama && !g.pendingNullentropy && !g.pendingTreacheryDiscard && + !g.pendingKull && !g.pendingChoamWorthless && !g.choamMarket && !g.pendingIxAlly && + !g.pendingRicheseGift && !g.pendingRichesePurchaseIncome && !g.pendingIxRicheseTechnology && + !g.biddingEnd && !g.bureaucratPayments?.pending && + !pendingNexusTraitors(g), + 'Richese Betrayal requires a clean ordinary Richese lot before payment, without another effect overlay.'); + const lot = g.richeseAuction!, donor = quote.allyPayment ? getPlayer(g,quote.winner).ally : null; + const players = g.players.map(p => ({ + ...p, + spice:p.spice - (p.id === quote.winner ? quote.amount - quote.allyPayment : 0), + hand:[...(lot.source === 'blackMarket' && p.id === lot.owner + ? p.hand.filter(c => c.id !== quote.card.id) : p.hand),...(p.id === quote.winner ? [quote.card] : [])], + })); + const projected:Game = { + ...g,players,richeseCache:lot.source === 'cache' ? g.richeseCache!.filter(c => c.id !== quote.card.id) : g.richeseCache, + aid:donor ? {...g.aid,[donor]:{...g.aid[donor],amount:g.aid[donor].amount - quote.allyPayment}} : g.aid, + currentAuctionSale:{winner:quote.winner,amount:quote.amount,free:false,origin:lot.source,seller:lot.owner}, + }; + // Both original and redirected settlement must retire this same lot. This + // includes native exhausted-cache and Harkonnen continuation fences. + currentAuctionContinuationQuote(projected,{kind:'sale',free:false}); + currentAuctionContinuationQuote(projected,{kind:'bonus'}); + currentAuctionContinuationQuote(projected,{kind:'next'}); + for (const p of players) { + const promises = liveShipmentPromises(g.shipmentPromises ?? [],p.id,g.turn); + requireRule(!promises.length || !!findShipmentCompletion(projected,p,promises), + 'This settlement would make a committed shipment answer impossible.'); + } +} +function richeseBetrayalUseBlock(g: Game, owner: string): string | null { + const frame = g.pendingRicheseBetrayal; + if (!frame || !frame.receipt.required.includes(owner) || frame.receipt.passed.includes(owner)) + return 'This seat has no current Richese Betrayal acknowledgement.'; + const blocked = nexusRule(() => richeseBetrayalEligibility({players:g.players,cards:g.nexusCards!.cards!},owner)); + if (blocked) return blocked; + const p = getPlayer(g,owner), promises = liveShipmentPromises(g.shipmentPromises ?? [],owner,g.turn); + if (promises.length) { + const projected:Game = {...g,nexusCards:{...g.nexusCards!, + cards:nexusRule(() => discardNexusCard(g.nexusCards!.cards!,owner,g.players))}}; + if (!findShipmentCompletion(projected,p,promises)) + return 'Spending this Nexus card would make your committed shipment answer impossible.'; + } + return null; +} +function richeseBetrayalIntegrity(g: Game): void { + const cursor = g.richeseBetrayal, frame = g.pendingRicheseBetrayal; + if (!g.richeseBetrayalPreview) { + requireRule(!cursor && !frame,'Richese Betrayal lost its explicit preview profile.'); + requireRule(!g.currentAuctionSale?.recipient,'The bank override lost its Richese Betrayal receipt.'); + return; + } + requireRule(richeseBetrayalModeSupported(g) && cursor,'The Richese Betrayal profile lost its supported inventory or cursor.'); + 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.'); + } + const sale = g.currentAuctionSale; + const completed = cursor.completed.at(-1); + if (completed && completed.lot === g.richeseAuction?.event) { + requireRule(g.status === 'playing' && g.phase === 3 && completed.turn === g.turn && + completed.round === g.richeseBidding?.event && + !(completed.stage === 'used' && completed.invoice.kind === 'purchase') && + sale && sale.winner === completed.invoice.buyer && sale.seller === completed.invoice.target && + sale.origin === completed.invoice.source && sale.amount === completed.invoice.price && sale.free === false && + sale.recipient === (completed.stage === 'used' ? 'bank' : undefined), + 'The completed Richese reaction lost or reopened its original paid-sale continuation.'); + const responses = savedNoFieldResponses(g).filter(response => + response.kind === 'emperorIncome' || response.kind === 'harkonnenBonus'); + requireRule(responses.length === 1,'The completed Richese sale lost its native income or bonus continuation.'); + currentAuctionContinuationQuote(g,{kind:'cancel',response:responses[0]}); + } + if (sale?.recipient) { + const receipt = cursor.completed.at(-1); + requireRule(sale.recipient === 'bank' && receipt?.stage === 'used' && receipt.invoice.kind === 'sale' && + receipt.lot === g.richeseAuction?.event && receipt.round === g.richeseBidding?.event && + receipt.invoice.buyer === sale.winner && receipt.invoice.target === sale.seller && + receipt.invoice.price === sale.amount && receipt.invoice.source === sale.origin, + 'The diverted auction invoice lost its committed source receipt.'); + } +} +function beginRicheseBetrayal(g: Game, quote: Extract): boolean { + if (!g.richeseBetrayalPreview) return false; + richeseBetrayalSourcePreflight(g,quote); + const required = richeseBetrayalResponders(g); + if (!required.length) return false; + const cursor = g.richeseBetrayal!, lot = g.richeseAuction!, round = g.richeseBidding!; + const sequence = cursor.sequence + 1; + const receipt = nexusRule(() => createRicheseBetrayalReceipt(g,{ + event:richeseBetrayalEvent(g.turn,sequence,round.event,lot.event),turn:g.turn,sequence, + round:round.event,lot:lot.event,invoice:richeseBetrayalInvoice(g,quote), + required,passed:[],stage:'pending',actor:null,nexusDiscardIndex:null, + })); + const frame:NonNullable = {receipt,lot:structuredClone(lot),quote:structuredClone(quote),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) && + action.event === receipt.event && Object.keys(action).every(key => ['type','event'].includes(key)) && + receipt.required.includes(p.id) && !receipt.passed.includes(p.id), + 'Choose your exact current Richese Betrayal acknowledgement.'); + const use = action.type === 'richeseBetrayalUse'; + if (use) { + const blocked = richeseBetrayalUseBlock(g,p.id); + requireRule(!blocked,blocked ?? 'This Nexus card cannot be used.'); + } + const passed = use ? receipt.passed : [...receipt.passed,p.id]; + const stage = use ? 'used' : passed.length === receipt.required.length ? 'passed' : 'pending'; + const next = nexusRule(() => createRicheseBetrayalReceipt(g,{ + ...receipt,passed,stage,actor:use ? p.id : null, + nexusDiscardIndex:use ? g.nexusCards!.cards!.discard.length : null, + })); + if (stage === 'pending') {frame.receipt = next;return;} + if (use) g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!,p.id,g.players)); + g.richeseBetrayal!.completed.push(next); + g.richeseBetrayal!.current = null; + g.pendingRicheseBetrayal = null; + if (use && receipt.invoice.kind === 'purchase') { + const card = frame.quote.card; + g.richeseCache = g.richeseCache!.filter(c => c.id !== card.id); + g.discard.push(card); + g.richeseFunding = {}; + log(g,`${p.name} spent Richese Nexus Betrayal to discard ${card.name} before Richese could buy it. No purchase payment or acquisition occurred.`); + finishRicheseLot(g); + } else { + if (use) log(g,`${p.name} spent Richese Nexus Betrayal: the ${receipt.invoice.price}-spice Richese sale payment goes to the Spice Bank instead.`); + settleRicheseSoldLot(g,frame.quote,use); + } +} function settleRicheseLot(g: Game) { const lot = g.richeseAuction!; const quote = richeseSettlementQuote(g); @@ -9405,8 +9613,12 @@ function settleRicheseLot(g: Game) { } else richeseDecision(g, 'richeseUnbid'); return; } - const winner = getPlayer(g, quote.winner), - card = quote.card; + if (beginRicheseBetrayal(g,quote)) return; + settleRicheseSoldLot(g,quote,false); +} +function settleRicheseSoldLot(g: Game, quote: Extract, bank: boolean) { + const lot = g.richeseAuction!, owner = getPlayer(g,lot.owner); + const winner = getPlayer(g, quote.winner), card = quote.card; payWithAlly(g, winner, quote.amount, quote.allyPayment); g.richeseFunding = {}; if (lot.source === 'cache') @@ -9419,6 +9631,7 @@ function settleRicheseLot(g: Game) { free: false, origin: lot.source, seller: owner.id, + ...(bank ? {recipient:'bank' as const} : {}), ...stampAuctionBureaucrat(g,winner,quote.amount,quote.allyPayment,owner.id), }; log( @@ -17416,6 +17629,7 @@ function marketGholaIntegrity(g: Game) { nexusGuildSecretIntegrity(g); nexusRicheseIntegrity(g); richesePairIntegrity(g); + richeseBetrayalIntegrity(g); nexusGuildCunningIntegrity(g); traitorDeclarationIntegrity(g); nexusInspectionIntegrity(g); @@ -18055,7 +18269,7 @@ function reconcileShipmentPromises( g: Game, voluntary?: { actor: string; action: Action }, ) { - if (g.pendingKull) return; + if (g.pendingKull || g.pendingRicheseBetrayal) return; const kept: ShipmentPromise[] = []; for (const promise of g.shipmentPromises ?? []) { if (promise.turn !== g.turn || promise.released || promise.fulfilled) @@ -18118,7 +18332,7 @@ function reconcileBattlePromises( g: Game, voluntary?: { actor: string; action: Action }, ) { - if (g.pendingKull) return; + if (g.pendingKull || g.pendingRicheseBetrayal) return; const b = g.battle; if ( !b || @@ -24479,6 +24693,7 @@ function normalizeCardNames(g: Game) { ]); } export function applyAction(state: Game, id: string, action: Action): Game { + richeseBetrayalIntegrity(state); kullIntegrity(state); advancedPreviewIntegrity(state); harkonnenExchangeIntegrity(state); @@ -24562,6 +24777,7 @@ export function applyAction(state: Game, id: string, action: Action): Game { return normalizeAutomaticGame(state); } const g = applyActionInner(state, id, action); + if (g.pendingRicheseBetrayal) {richeseBetrayalIntegrity(g);return g;} if (g.pendingKull) { settleAutomaticContinuations(g); if (g.pendingKull) { kullIntegrity(g); return g; } @@ -24677,6 +24893,7 @@ export function applyAction(state: Game, id: string, action: Action): Game { nexusEcazInquiryIntegrity(g); nexusEcazDukeIntegrity(g); richesePairIntegrity(g); + richeseBetrayalIntegrity(g); marketGholaIntegrity(g); homeworldRule(() => homeworldGameIntegrity(g)); homeworldBattleLossIntegrity(g); @@ -24691,6 +24908,7 @@ export function applyAction(state: Game, id: string, action: Action): Game { return g; } function finishActionContinuations(g: Game) { + if (g.pendingRicheseBetrayal) return; if (g.pendingKull) return; finishLeaderSkillCustody(g); finishMandatorySkillVisibility(g); @@ -24753,10 +24971,12 @@ function finishActionContinuations(g: Game) { finishMoritaniPlacement(g); } function settleAutomaticContinuations(g: Game) { + if (g.pendingRicheseBetrayal) return; if (g.pendingKull?.stage === 'offer') return; if (pendingNexusTraitors(g)) return; if (g.pendingNullentropy) return; for (let iteration = 0; iteration < 128; iteration++) { + if (g.pendingRicheseBetrayal) return; if (g.truthtrance || g.phaseOpening || g.status === 'finished') return; const response = g.response; const before = JSON.stringify(g); @@ -24778,6 +24998,7 @@ function settleAutomaticContinuations(g: Game) { finishResponse(g, false); } else if (!finishAutomaticDecision(g)) return; finishActionContinuations(g); + if (g.pendingRicheseBetrayal) return; // Opposing/automatic consequences can release a now-impossible promise; // they are not the original actor voluntarily spending a promised resource. reconcileBattlePromises(g); @@ -24793,6 +25014,7 @@ function settleAutomaticContinuations(g: Game) { } /** Internal authoritative continuation. Callers must persist with their usual CAS fence. */ export function normalizeAutomaticGame(state: Game): Game { + richeseBetrayalIntegrity(state); kullIntegrity(state); advancedPreviewIntegrity(state); harkonnenExchangeIntegrity(state); @@ -24826,6 +25048,7 @@ export function normalizeAutomaticGame(state: Game): Game { ecazCollectionIntegrity(state); ecazAllianceIntegrity(state); const g = structuredClone(state); + if (g.pendingRicheseBetrayal) return g; if (g.pendingKull?.stage === 'offer') return g; ornithopterIntegrity(g); lateDefenseIntegrity(g); @@ -24853,6 +25076,7 @@ export function normalizeAutomaticGame(state: Game): Game { nexusEcazInquiryIntegrity(g); nexusEcazDukeIntegrity(g); richesePairIntegrity(g); + richeseBetrayalIntegrity(g); marketGholaIntegrity(g); homeworldRule(() => homeworldGameIntegrity(g)); homeworldBattleLossIntegrity(g); @@ -24875,6 +25099,12 @@ function applyActionInner( ): Game { ecazTreacheryIntegrity(state); const g = structuredClone(state); + if (g.pendingRicheseBetrayal) { + const p = getPlayer(g,id); + if (action?.type === 'advanceBots') return g; + decideRicheseBetrayal(g,p,action); + return g; + } if (g.biddingEnd && action.type !== 'advanceBots' && !(action.type === 'biddingEnd' && action.mode === 'ready')) g.biddingEnd.ready = []; @@ -24937,6 +25167,8 @@ function applyActionInner( ); requireRule(g.status !== 'finished', 'This game has ended.'); const t = action.type; + requireRule(t !== 'richeseBetrayalPass' && t !== 'richeseBetrayalUse', + 'This Richese Betrayal opportunity is no longer pending.'); if (g.pendingKull) { if (t === 'advanceBots') return g; if (g.pendingKull.stage === 'offer') { @@ -28695,6 +28927,7 @@ function applyActionInner( throw new RuleError('That action is not available.'); } export function viewGame(state: Game, id: string) { + richeseBetrayalIntegrity(state); kullIntegrity(state); advancedPreviewIntegrity(state); harkonnenExchangeIntegrity(state); @@ -29196,6 +29429,17 @@ export function viewGame(state: Game, id: string) { ? g.stormDials : null, me: id, + richeseBetrayalPreview: !!g.richeseBetrayalPreview, + richeseBetrayalReaction: g.pendingRicheseBetrayal + ? (() => { + const receipt = g.pendingRicheseBetrayal.receipt, invoice = receipt.invoice; + const hasPassed = receipt.passed.includes(id); + const canPass = receipt.required.includes(id) && !hasPassed; + const blocked = richeseBetrayalUseBlock(g,id); + return {event:receipt.event,kind:invoice.kind,target:invoice.target,buyer:invoice.buyer, + source:invoice.source,price:invoice.price,canPass,hasPassed,canUse:canPass && !blocked,blocked}; + })() + : null, kullPreview: !!g.kullPreview, kullCounterEvent: g.pendingKull?.stage === 'counter' ? g.pendingKull.event : null, karamaBlocked: g.kullPreview && kullBlocksKarama(g.kullRestrictions, g.turn, g.phase, id) @@ -29334,7 +29578,7 @@ export function viewGame(state: Game, id: string) { g.moritaniRetention.keep, ) ?? null) : null, - decision: g.pendingKull?.stage === 'offer' ? null : + decision: g.pendingRicheseBetrayal || g.pendingKull?.stage === 'offer' ? null : g.decision?.kind === 'capturedLeader' && !g.leaderSkills?.assignments.some((a) => a.leader === (g.decision as Extract).leader) && ![g.decision.player, g.decision.controller ?? g.decision.owner].includes( @@ -29346,7 +29590,7 @@ export function viewGame(state: Game, id: string) { : g.decision?.kind === 'guildShipment' && g.decision.noFieldSkillProof !== undefined ? { ...g.decision, noFieldSkillProof: undefined } : (g.decision ?? null), - response: g.pendingKull?.stage === 'offer' ? null : g.response + response: g.pendingRicheseBetrayal || g.pendingKull?.stage === 'offer' ? null : g.response ? { ...g.response, ...(g.response.bureaucratPayment ? {bureaucratPayment:undefined,bureaucratPaymentEvent:undefined} : {}), @@ -29370,7 +29614,7 @@ export function viewGame(state: Game, id: string) { } : null, paymentIncome: currentFactionPayment(g), - responseControls: g.pendingKull?.stage === 'offer' ? undefined : g.response + responseControls: g.pendingRicheseBetrayal || g.pendingKull?.stage === 'offer' ? undefined : g.response ? { cancelCards: responseCancelCards(g, me, g.response), hasPassed: g.response.passed.includes(id), diff --git a/game/nexus-richese-betrayal.ts b/game/nexus-richese-betrayal.ts new file mode 100644 index 00000000..1ba5e1e2 --- /dev/null +++ b/game/nexus-richese-betrayal.ts @@ -0,0 +1,242 @@ +import type { Card } from './cards'; +import { nexusCardMode, validateNexusCards, validateNexusPlayers, type NexusPlayer, type NexusState } from './nexus-cards'; +import type { RicheseAuction } from './richese-auction'; +import { richeseCardDefinition } from './richese-cards'; +import type { RicheseSettlementQuote } from './richese-settlement'; + +export type RicheseBetrayalInvoice = { + kind: 'purchase' | 'sale'; + target: string; + buyer: string; + source: 'cache' | 'blackMarket'; + price: number; + ownPayment: number; + allyPayment: number; + donor: string | null; + /** The original invoice recipient; null means the Spice Bank. */ + originalRecipient: string | null; + /** Private physical evidence, never a public reaction field. */ + card: Card; +}; +export type RicheseBetrayalContext = { + turn: number; + players: readonly NexusPlayer[]; +}; +export type RicheseBetrayalReceipt = { + version: 1; + event: string; + turn: number; + sequence: number; + round: string; + lot: string; + invoice: RicheseBetrayalInvoice; + required: string[]; + passed: string[]; + stage: 'pending' | 'passed' | 'used'; + actor: string | null; + nexusDiscardIndex: number | null; + signature: string; +}; +export type RicheseBetrayalCursor = { + sequence: number; + current: string | null; + completed: RicheseBetrayalReceipt[]; +}; +export class RicheseBetrayalError extends Error { + constructor(message: string) { + super(message); + this.name = 'RicheseBetrayalError'; + } +} +function requireBetrayal(value: unknown, message: string): asserts value { + if (!value) throw new RicheseBetrayalError(message); +} +const plain = (value: unknown): value is Record => + !!value && typeof value === 'object' && !Array.isArray(value) && + [Object.prototype, null].includes(Object.getPrototypeOf(value)); +const exact = (value: unknown, keys: readonly string[]): boolean => + plain(value) && Object.keys(value).length === keys.length && keys.every(key => Object.hasOwn(value, key)); +const text = (value: unknown): value is string => + typeof value === 'string' && value.trim().length > 0 && + !['__proto__', 'constructor', 'prototype'].includes(value); +const whole = (value: unknown): value is number => + typeof value === 'number' && Number.isSafeInteger(value) && value >= 0; +const ids = (value: unknown): value is string[] => + Array.isArray(value) && value.every(text) && new Set(value).size === value.length; +const cardKinds: Record = { + projectile: true, poison: true, lasgun: true, shield: true, snooper: true, + poisonBlade: true, shieldSnooper: true, weirdingWay: true, chemistry: true, + poisonTooth: true, artillery: true, worthless: true, hero: true, special: true, +}; +function validCard(card: Card): boolean { + return plain(card) && exact(card, card.effect === undefined ? ['id', 'name', 'kind'] : ['id', 'name', 'kind', 'effect']) && + text(card.id) && text(card.name) && Object.hasOwn(cardKinds, card.kind) && + (card.effect === undefined || text(card.effect)); +} +function sameCard(a: Card, b: Card): boolean { + return a.id === b.id && a.name === b.name && a.kind === b.kind && a.effect === b.effect; +} +function validatePlayers(players: readonly NexusPlayer[]): void { + try { validateNexusPlayers(players); } + catch (error) { throw new RicheseBetrayalError(error instanceof Error ? error.message : 'Invalid Richese Betrayal roster.'); } + requireBetrayal(players.some(p => p.faction === 'richese'), 'Richese Betrayal requires the native Richese seat.'); +} +function validateInvoice(players: readonly NexusPlayer[], invoice: RicheseBetrayalInvoice): void { + requireBetrayal(exact(invoice, ['kind', 'target', 'buyer', 'source', 'price', 'ownPayment', 'allyPayment', 'donor', 'originalRecipient', 'card']), + 'Richese Betrayal has an invalid original invoice.'); + 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) && + 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) && + invoice.originalRecipient === (players.find(p => p.faction === 'emperor')?.id ?? null), + 'Richese self-veto requires a real public cache purchase paid to Emperor or bank.'); + } else { + requireBetrayal(invoice.kind === 'sale' && 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.'); + } +} + +/** Quote the original invoice only. Veto pays nobody; diversion changes the + * recipient to the bank, never the buyer, price, source or contribution split. */ +export function quoteRicheseBetrayalInvoice(input: { + players: readonly NexusPlayer[]; + lot: Readonly; + quote: Extract; + donor: string | null; + originalRecipient: string | null; + sourceCards: readonly Card[]; + physicalCards: readonly Card[]; +}): RicheseBetrayalInvoice { + requireBetrayal(plain(input), 'Richese Betrayal needs a source invoice.'); + validatePlayers(input.players); + const { lot, quote } = input; + requireBetrayal(plain(lot) && plain(quote) && quote.kind === 'sold' && text(lot.event) && + lot.outcome?.kind === 'sold' && lot.outcome.winner === quote.winner && lot.outcome.amount === quote.amount && + lot.cardId === quote.card?.id && ['normal', 'onceAround', 'silent'].includes(lot.method) && + !(lot.source === 'cache' && lot.method === 'normal'), 'Richese Betrayal needs the original ordinary sold lot.'); + const invoice: RicheseBetrayalInvoice = { + kind: quote.winner === lot.owner ? 'purchase' : 'sale', + target: lot.owner, buyer: quote.winner, source: lot.source, + price: quote.amount, ownPayment: quote.ownPayment, allyPayment: quote.allyPayment, + donor: input.donor, originalRecipient: input.originalRecipient, + card: { ...quote.card }, + }; + validateInvoice(input.players, invoice); + const buyer = input.players.find(p => p.id === invoice.buyer)!; + requireBetrayal(!invoice.donor || (buyer.ally === invoice.donor && + input.players.find(p => p.id === invoice.donor)?.ally === buyer.id), + 'Richese Betrayal cannot substitute an unallied contributor.'); + for (const cards of [input.sourceCards, 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; +} + +/** Public held-presence only: private Nexus identities never affect timing. */ +export function requiredRicheseBetrayalResponders( + players: readonly (NexusPlayer & { nexusHeld: boolean })[], +): string[] { + validatePlayers(players); + requireBetrayal(players.every(p => typeof p.nexusHeld === 'boolean'), 'Richese Betrayal needs public Nexus held-presence.'); + return players.filter(p => p.faction !== 'richese' && !p.ally && p.nexusHeld).map(p => p.id); +} + +/** No printed spice fee. The physical singleton Richese Nexus is the cost; + * runtime separately preflights the unchanged purchase promises and budget. */ +export function richeseBetrayalEligibility( + input: { players: readonly NexusPlayer[]; cards: NexusState }, owner: string, +): string | null { + validatePlayers(input.players); + try { validateNexusCards(input.cards, input.players); } + catch (error) { throw new RicheseBetrayalError(error instanceof Error ? error.message : 'Invalid physical Nexus custody.'); } + const seat = input.players.find(p => p.id === owner); + if (!seat) return 'Choose a seated Richese Nexus holder.'; + if (seat.ally) return 'An allied player cannot play a Nexus card.'; + if (input.cards.hands[owner] !== 'richese' || + nexusCardMode('richese', seat.faction, input.players.map(p => p.faction)) !== 'betrayal') + return 'Hold the real Richese Nexus card as another faction to use Betrayal.'; + return null; +} + +export function richeseBetrayalEvent(turn: number, sequence: number, round: string, lot: string): string { + return JSON.stringify(['nexusRicheseBetrayal', turn, sequence, round, lot]); +} +function signature(receipt: RicheseBetrayalReceipt): string { + const i = receipt.invoice; + return JSON.stringify(['richeseBetrayalReceipt', receipt.version, receipt.event, receipt.turn, + receipt.sequence, receipt.round, receipt.lot, i.kind, i.target, i.buyer, i.source, + i.price, i.ownPayment, i.allyPayment, i.donor, i.originalRecipient, + [i.card.id, i.card.name, i.card.kind, i.card.effect ?? null], + receipt.required, receipt.passed, receipt.stage, receipt.actor, receipt.nexusDiscardIndex]); +} + +/** Intrinsic historical evidence, independent of current card/discard custody + * and later alliances. Signatures detect inconsistency, not arbitrary forgery. */ +export function validateRicheseBetrayalReceipt(context: RicheseBetrayalContext, receipt: RicheseBetrayalReceipt): void { + requireBetrayal(plain(context) && whole(context.turn) && context.turn > 0, 'Richese Betrayal needs a valid current turn.'); + validatePlayers(context.players); + requireBetrayal(exact(receipt, ['version', 'event', 'turn', 'sequence', 'round', 'lot', 'invoice', 'required', 'passed', 'stage', 'actor', 'nexusDiscardIndex', 'signature']) && + receipt.version === 1 && whole(receipt.turn) && receipt.turn > 0 && receipt.turn <= context.turn && + whole(receipt.sequence) && receipt.sequence > 0 && text(receipt.round) && text(receipt.lot) && + receipt.event === richeseBetrayalEvent(receipt.turn, receipt.sequence, receipt.round, receipt.lot) && + ids(receipt.required) && receipt.required.length > 0 && ids(receipt.passed) && + receipt.required.every(id => context.players.some(p => p.id === id && p.faction !== 'richese')) && + receipt.passed.every(id => receipt.required.includes(id)), 'Richese Betrayal has lost its original event or acknowledgements.'); + validateInvoice(context.players, receipt.invoice); + if (receipt.stage === 'used') { + requireBetrayal(text(receipt.actor) && receipt.required.includes(receipt.actor) && !receipt.passed.includes(receipt.actor) && + whole(receipt.nexusDiscardIndex), 'Richese Betrayal has lost its one physical Nexus spend.'); + } else { + requireBetrayal((receipt.stage === 'pending' || receipt.stage === 'passed') && receipt.actor === null && receipt.nexusDiscardIndex === null && + (receipt.stage === 'passed' ? receipt.passed.length === receipt.required.length : + receipt.turn === context.turn && receipt.passed.length < receipt.required.length), + 'Richese Betrayal has an invalid pending or all-pass lifecycle.'); + } + requireBetrayal(receipt.signature === signature(receipt), 'Richese Betrayal receipt has changed its original invoice.'); +} +export function createRicheseBetrayalReceipt( + context: RicheseBetrayalContext, + input: Omit, +): RicheseBetrayalReceipt { + const receipt: RicheseBetrayalReceipt = { + ...input, version: 1, invoice: { ...input.invoice, card: { ...input.invoice.card } }, + required: [...input.required], passed: [...input.passed], signature: '', + }; + receipt.signature = signature(receipt); + validateRicheseBetrayalReceipt(context, receipt); + return receipt; +} + +/** The independent cursor makes missing/orphaned private frames fail closed. */ +export function validateRicheseBetrayalCursor( + context: RicheseBetrayalContext, cursor: RicheseBetrayalCursor, pending: RicheseBetrayalReceipt | null, +): void { + requireBetrayal(exact(cursor, ['sequence', 'current', 'completed']) && whole(cursor.sequence) && + (cursor.current === null || text(cursor.current)) && Array.isArray(cursor.completed), + 'Richese Betrayal has an invalid continuation cursor.'); + requireBetrayal(cursor.completed.length === cursor.sequence - (pending ? 1 : 0), + 'Richese Betrayal has lost a completed or pending continuation.'); + for (const [index, receipt] of cursor.completed.entries()) { + validateRicheseBetrayalReceipt(context, receipt); + requireBetrayal(receipt.stage !== 'pending' && receipt.sequence === index + 1, + 'Richese Betrayal history must contain each completed opportunity once.'); + } + requireBetrayal(new Set(cursor.completed.map(r => r.event)).size === cursor.completed.length, + 'Richese Betrayal has duplicate completed events.'); + if (pending) { + validateRicheseBetrayalReceipt(context, pending); + requireBetrayal(pending.stage === 'pending' && pending.sequence === cursor.sequence && cursor.current === pending.event, + 'Richese Betrayal has lost ownership of its original pending continuation.'); + } else requireBetrayal(cursor.current === null, 'Richese Betrayal has an orphaned continuation cursor.'); +} diff --git a/game/reference.ts b/game/reference.ts index c6590098..fbc3b1e8 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -195,7 +195,7 @@ export const RULE_TOPICS: RuleTopic[] = [ summary: 'Track implementation, player controls, AI, documentation and verification separately for each reviewed feature.', steps: [ - 'The linked topics contain a five-part checklist. Implemented describes only the stated feature boundary; Partial identifies remaining work and Planned means it is not active.', + 'The linked topics contain a five-part checklist. Implemented describes only the stated feature boundary; Partial identifies remaining work. Planned means that facet is not complete: for Verification it means evidence is still pending, not that a connected preview is inactive.', 'Verification entries identify focused regression suites. Passing those checks does not certify every interaction, browser journey or complete expansion game.', 'Development stages distinguish Missing functions, working Prototypes, Integrated functions, Verified behavior and Polished presentation. A prototype stage does not certify complete rules coverage; check the stated limits and evidence.', 'This checklist is being extended across the rules. Features without a checklist still have a topic-level coverage label and are not implicitly complete.', @@ -211,6 +211,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'truthtrance-spice', 'ecaz-ambassadors', 'richese-cards', + 'nexus-richese-betrayal', 'richese-acquisition', 'richese-gift', 'distrans-transfer', @@ -411,6 +412,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'Development tables support the cache auction first or last, with Once Around or Silent bidding. Advanced Black Market offers a hand card using normal, Once Around or Silent bidding; other players may buy it, and an unbid or canceled offer returns to the seller.', 'The controls show the card only to entitled viewers, accept a public Black Market claim, and let bidders choose their own and pledged ally spice. Silent bids remain private until all eligible players submit. A donor cannot reduce pledged funding during unfinished Silent bidding, and a Black Market card reserved for sale cannot be played elsewhere. Atreides inspection, applicable Harkonnen bonus draws and Ixian allied replacement follow the sale’s origin.', 'Other buyers pay Richese for a cache or Black Market sale. Richese buying its own cache card pays the Emperor or bank. Only the winner’s committed funding is collected; losing Silent bids do not spend their spice. An unbid cache card may be kept for free if the hand has room, or removed from the game.', + 'The opt-in Richese Nexus Betrayal auction preview adds a neutral before-payment acknowledgement. A legal rival holder may veto a public self-cache purchase or send another buyer’s sale payment to the bank. See its separate guide and bounded verification evidence; normal hidden self-purchases and special-Karama acquisition are not connected triggers.', 'A player whose hand becomes full is automatically passed, or submits zero in an unfinished Silent lot. A full-hand owner’s unbid cache card is removed automatically. If every hand is full, preparing the normal pool does not draw and lose unseen cards.', 'AI supports cache choices and all three offered bidding methods using its own projected information. It currently skips the optional Black Market prelude. A seller in an already offered Black Market lot passes or submits zero.', 'Some unresolved rules are explicitly blocked: changing the normal card count after cache-auction cancellation, exhausted-cache arithmetic, positive Black Market self-bids, and advanced Ixian Technology substitution on special lots. These are implementation guards while the rules are resolved, not claims of official prohibitions.', @@ -420,6 +422,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'choam-modules', 'faction-richese', 'richese-acquisition', + 'nexus-richese-betrayal', 'richese-gift', 'nullentropy-search', 'juice-of-sapho', @@ -557,6 +560,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'In advanced development tables, Richese can use its special Karama once per game. Select an available Karama in your hand and one eligible card from your own cache. The activating Karama is discarded; the chosen card enters your hand secretly.', 'This is a purchase for Richese itself. Pay three of your available spice to Emperor when present, otherwise the bank. The general Emperor purchase-income rule supplies this payment destination. An ally receives no Harkonnen bonus or Ixian replacement from this direct cache acquisition.', 'A Karama cannot cancel the special purchase itself. Emperor income has a separate response: canceling that income does not undo the purchase or restore the spent special power.', + 'The separate Richese Nexus Betrayal preview connects ordinary public self-cache auctions, not this private special acquisition. The native Karama cancellation exception above is not authority for deciding whether Nexus Betrayal applies to a special purchase.', 'The private hand panel provides both card selectors and enlarged inspection. It shows the payment recipient and the server’s current availability reason. A reserved auction card cannot be taken from that lot. Spending pledged or already committed funds is not allowed.', 'Full-hand activation is guarded pending a ruling on when the spent Karama leaves the hand. Buying the final cached card is also guarded pending the empty-cache auction rule. Pending interactions may need to finish first. These development limits are not claimed as printed prohibitions.', 'All four AI profiles can choose the canonical Richese Karama when the projection allows it and enough spice remains. They leave other cache effects to future implementation and give pending responses, decisions and Truthtrance priority.', @@ -567,6 +571,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'richese-gift', 'nullentropy-search', 'special-karama', + 'nexus-richese-betrayal', 'card-richese-karama', 'implementation-checklist', ], @@ -4044,6 +4049,40 @@ export const RULE_TOPICS: RuleTopic[] = [ { area: 'Verification', status: 'Partial', detail: 'Focused Basic/Advanced rules, controls, bots, JSON, browser owner/rival projection and SQLite restart/CAS cover stale cross-lot offers, hand/custody rejection and duplicate requests; broader module acceptance remains open.', evidence: ['tests/nexus-emperor-secret-ally.test.ts', 'tests/nexus-emperor-secret-ally-recovery.test.ts', 'tests/nexus-emperor-purchase-engine.test.ts', 'tests/nexus-emperor-purchase-recovery.test.ts', 'tests/nexus-emperor-secret-ally-purchase-controls.test.ts'] }, ], }, + { + id: 'nexus-richese-betrayal', + 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.', + 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.', + '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.', + '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.', + '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.', + 'Existing exhausted-cache, cache-ordering, positive Black Market seller-bid and special-acquisition guards remain. Unsupported counters or any-time overlays must not enter this saved purchase boundary. Finish the 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.', + ], + 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: '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'] }, + ], + }, { id: 'nexus-cards', title: 'Nexus Cards', @@ -4091,18 +4130,19 @@ export const RULE_TOPICS: RuleTopic[] = [ 'When Ecaz is absent, an unallied holder may spend its Nexus card at a quiet play boundary to ask whether any one player, including themself, holds one of the holder’s native faction leaders as an ordinary Traitor Card. The owner receives a private yes/no answer saved at the moment of use; no name or count is disclosed. Dead or captured native leaders count, but foreign Gholas, Duke Vidal, Cheap Hero and Face Dancers do not. Native-leader scope and private-only audience are explicit product interpretations, not publisher rulings; exceptional controlled-leader and public-disclosure combinations remain open.', 'When Richese is absent, its Secret Ally replaces your ordinary reserve shipment with up to five physical forces charged as one. Select the explicit shipment checkbox; forces, elite identity, destination restrictions, Homeworld reserve sources and normal movement remain unchanged. Fremen remain free within their normal reinforcement range; Guild and an active Karama rate retain their existing tariff and payment routing. This is not a concealed No-Field or an extra shipment.', 'Native Richese Cunning has a bounded two-No-Field shipment in genuine Richese/CHOAM Nexus games without other optional modules. Choose distinct unused tokens: one reveals immediately into reserve-capped public forces, the other remains concealed; pay for one marker and spend the Nexus. The previous shipment token cannot be either selection; the concealed token becomes next last-used. Karama or Guild special prevention preserves physical tokens and unpaid spice, with the ordinary stopped-shipment allowance policy. Other module combinations and the full faceup-component display remain gated.', + 'Richese Betrayal has a separate opt-in auction preview. Before payment, an unallied rival may spend its physical Richese Nexus card to discard a public self-cache win without any purchase debit, or redirect another buyer’s cache/Black Market payment to the bank while preserving delivery and earned bonus. Every publicly possible holder acknowledges the same opportunity; private Use eligibility cannot determine public timing. Normal hidden self-purchases and private special-Karama acquisitions are not connected. See the dedicated guide; its verification remains Planned.', 'Guild Cunning is declared when finishing your ordinary Shipment and Movement turn. It spends the Nexus and opens one Karama response. Allowance grants a second native shipment at normal Guild prices, including supported cross-shipment, return and Homeworld routes. Cancellation leaves the completed first turn intact. Only an unused Hajr move can follow the second shipment; no ordinary movement or third move is granted. The physical Ornithopter card combination remains unavailable.', 'With Guild absent, its Secret Ally is an explicit optional source for one ordinary shipment at Guild prices. It supports physical reserve shipment, cross-shipment and return to abstract reserves, plus half-price independently legal world-to-world shipment. Fremen may retain free reinforcement or choose the paid card route beyond its normal range, outside the storm. Native faction, typed sources and normal movement stay unchanged. Homeworld reserve return awaits a ruling; arbitrary foreign-world transport and concealed No-Fields are not granted.', 'Cunning spends its Nexus card and uses the native Mentat placement opportunity. A Karama response precedes placement; cancellation leaves the token where it was and the Nexus spent. Later Terror entry still uses the existing one-token stack selection and supported effect rules. If Extortion was revealed this turn, its bank award and payment choices follow that placement response; this does not implement Atomics or complete Moritani games.', 'Hidden reactive Betrayal timing remains unresolved for other effects. Complete module games are unfinished, so public module starts remain disabled.', ], - related: ['ecaz-modules', 'spice-blow', 'alliance-funding', 'choam-worthless', 'choam-kull'], + related: ['ecaz-modules', 'spice-blow', 'alliance-funding', 'choam-worthless', 'choam-kull', 'nexus-richese-betrayal'], checklist: [ - { area: 'Implementation', status: 'Partial', detail: 'Physical lifecycle, Atreides inspections, Harkonnen exchanges/declarations, Face Dancers, Suboids, advisors, native Voice, Emperor Sardaukar and Betrayal, bounded Fremen revival/Cunning, CHOAM effects, Moritani Cunning/Betrayal, Ecaz Betrayal, native Duke Cunning and private Secret Ally inquiry, Richese shipment, and bounded Emperor bank-auction purchase are integrated. Ecaz Cunning transfers the existing Duke at a quiet Battle boundary with a real card and distinct turn-end expiry. Ecaz inquiry scope/audience and Emperor purchase timing are provisional. Borrowed Voice, exceptional Duke custody, other reactions, Tleilaxu revival, Fremen worm protection and combined Cunning interactions remain unfinished.', evidence: ['game/nexus-cards.ts', 'game/nexus-card-phase.ts', 'game/nexus-traitor-exchange.ts', 'game/traitor-declarations.ts', 'game/nexus-face-dancers.ts', 'game/nexus-suboids.ts', 'game/nexus-advisors.ts', 'game/nexus-sardaukar-options.ts', 'game/nexus-fremen-revival.ts', 'game/nexus-fremen-cunning.ts', 'game/nexus-ecaz-betrayal.ts', 'game/nexus-ecaz-inquiry.ts', 'game/nexus-ecaz-duke.ts', 'game/duke-vidal.ts', 'game/nexus-emperor-secret-ally.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal.ts', 'game/nexus-richese-options.ts', 'game/nexus-guild-cunning-options.ts', 'game/nexus-guild-secret-ally-options.ts', 'game/engine.ts'] }, - { area: 'Player controls', status: 'Partial', detail: 'Private lifecycle, inspections, exchanges, Tleilaxu refresh, Ixian activation, advisor selection, temporary Sardaukar and CHOAM effects, Moritani Betrayal, Ecaz Betrayal and owner-only Secret Ally inquiry, native Duke Cunning, Richese shipment, Emperor purchase and Fremen Cunning controls are connected. Remaining effect controls are unfinished.', evidence: ['components/nexus-cards.tsx', 'components/nexus-traitors.tsx', 'components/nexus-tleilaxu.tsx', 'components/nexus-suboids.tsx', 'components/nexus-advisors.tsx', 'components/nexus-sardaukar.tsx', 'components/choam-power-cost.tsx', 'components/moritani-terror.tsx', 'components/nexus-moritani-betrayal.tsx', 'components/nexus-ecaz-betrayal.tsx', 'components/nexus-ecaz-inquiry.tsx', 'components/nexus-ecaz-duke.tsx', 'components/game-table.tsx'] }, - { area: 'AI', status: 'Partial', detail: 'All four profiles choose legal actions through private offers, including Moritani/Ecaz Betrayal, Ecaz Secret Ally inquiry and native Duke Cunning, Fremen Cunning and Emperor purchase. Complete module strategy and calibration remain unfinished.', evidence: ['game/nexus-card-options.ts', 'game/nexus-traitor-options.ts', 'game/nexus-tleilaxu-options.ts', 'game/nexus-suboid-options.ts', 'game/nexus-advisor-options.ts', 'game/nexus-sardaukar-options.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal-options.ts', 'game/nexus-ecaz-betrayal-options.ts', 'game/nexus-ecaz-inquiry-options.ts', 'game/nexus-ecaz-duke-options.ts', 'game/nexus-richese-options.ts', 'game/nexus-guild-cunning-options.ts', 'game/bots.ts'] }, - { area: 'Documentation', status: 'Partial', detail: 'Common rules, twelve printed faces and bounded runtime contracts retain explicit unresolved Ecaz Duke capture/Tanks/Ghola and inquiry audience/scope, Fremen nested Cunning, Great Maker, combined modules, fresh advisors, storm and borrowed Voice.', evidence: ['docs/NEXUS_CARD_RULES.md', 'docs/NEXUS_ATREIDES_RULES.md', 'docs/NEXUS_HARKONNEN_RULES.md', 'docs/NEXUS_TLEILAXU_RULES.md', 'docs/NEXUS_IXIAN_CUNNING_RULES.md', 'docs/NEXUS_CUNNING_RUNTIME.md', 'docs/NEXUS_BENE_GESSERIT_RULES.md', 'docs/NEXUS_ADVISOR_RUNTIME.md', 'docs/NEXUS_EMPEROR_RULES.md', 'docs/NEXUS_SARDAUKAR_RUNTIME.md', 'docs/NEXUS_FREMEN_RULES.md', 'docs/NEXUS_ECAZ_RULES.md', 'docs/NEXUS_CHOAM_RULES.md', 'docs/NEXUS_CHOAM_RUNTIME.md', 'docs/NEXUS_MORITANI_RULES.md', 'docs/NEXUS_MORITANI_RUNTIME.md', 'docs/NEXUS_RICHESE_RUNTIME.md', 'docs/NEXUS_GUILD_CUNNING_RUNTIME.md'] }, - { area: 'Verification', status: 'Partial', detail: 'Physical custody, commitments, controls, legal bots, Basic/Advanced and in-memory SQLite recovery regressions include native Ecaz Duke override and private Secret Ally inquiry. A read-only local browser fixture showed the owner-only Duke choice and no rival offer; no room was written. Full module games, exceptional Duke custody and combined interactions remain separate.', evidence: ['tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts', 'tests/nexus-fremen-cunning.test.ts', 'tests/nexus-fremen-cunning-recovery.test.ts', 'tests/nexus-fremen-cunning-summon.test.ts', 'tests/nexus-fremen-cunning-summon-recovery.test.ts', 'tests/nexus-ecaz-betrayal-quotes.test.ts', 'tests/nexus-ecaz-betrayal.test.ts', 'tests/nexus-ecaz-betrayal-recovery.test.ts', 'tests/nexus-ecaz-inquiry.test.ts', 'tests/nexus-ecaz-duke-quote.test.ts', 'tests/nexus-ecaz-duke-engine.test.ts', 'tests/nexus-ecaz-duke-controls.test.ts', 'tests/nexus-ecaz-duke-recovery.test.ts', 'tests/nexus-traitor-recovery.test.ts', 'tests/nexus-face-dancer-engine.test.ts', 'tests/nexus-suboids-engine.test.ts', 'tests/normal-free-support.test.ts', 'tests/nexus-suboid-controls.test.ts', 'tests/nexus-advisor-controls.test.ts', 'tests/nexus-advisor-bots.test.ts', 'tests/nexus-sardaukar-controls.test.ts', 'tests/nexus-sardaukar-bots.test.ts', 'tests/nexus-choam-controls.test.ts', 'tests/nexus-choam-bots.test.ts', 'tests/nexus-moritani-controls.test.ts', 'tests/nexus-moritani-bots.test.ts', 'tests/nexus-moritani-betrayal.test.ts', 'tests/nexus-richese-controls.test.ts', 'tests/nexus-richese-bots.test.ts', 'tests/nexus-guild-cunning-controls.test.ts', 'tests/nexus-guild-cunning-bots.test.ts'] }, + { area: 'Implementation', status: 'Partial', detail: 'Physical lifecycle, Atreides inspections, Harkonnen exchanges/declarations, Face Dancers, Suboids, advisors, native Voice, Emperor Sardaukar and Betrayal, bounded Fremen revival/Cunning, CHOAM effects, Moritani Cunning/Betrayal, Ecaz Betrayal, native Duke Cunning and private Secret Ally inquiry, Richese shipment and bounded auction Betrayal, and bounded Emperor bank-auction purchase are integrated. Ecaz Cunning transfers the existing Duke at a quiet Battle boundary with a real card and distinct turn-end expiry. Ecaz inquiry scope/audience and Emperor purchase timing are provisional. Borrowed Voice, exceptional Duke custody, other reactions, Tleilaxu revival, Fremen worm protection and combined Cunning interactions remain unfinished.', evidence: ['game/nexus-cards.ts', 'game/nexus-card-phase.ts', 'game/nexus-traitor-exchange.ts', 'game/traitor-declarations.ts', 'game/nexus-face-dancers.ts', 'game/nexus-suboids.ts', 'game/nexus-advisors.ts', 'game/nexus-sardaukar-options.ts', 'game/nexus-fremen-revival.ts', 'game/nexus-fremen-cunning.ts', 'game/nexus-ecaz-betrayal.ts', 'game/nexus-ecaz-inquiry.ts', 'game/nexus-ecaz-duke.ts', 'game/duke-vidal.ts', 'game/nexus-emperor-secret-ally.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal.ts', 'game/nexus-richese-options.ts', 'game/nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/nexus-guild-secret-ally-options.ts', 'game/engine.ts'] }, + { area: 'Player controls', status: 'Partial', detail: 'Private lifecycle, inspections, exchanges, Tleilaxu refresh, Ixian activation, advisor selection, temporary Sardaukar and CHOAM effects, Moritani Betrayal, Ecaz Betrayal and owner-only Secret Ally inquiry, native Duke Cunning, Richese shipment and bounded auction Betrayal, Emperor purchase and Fremen Cunning controls are connected. Remaining effect controls are unfinished.', evidence: ['components/nexus-cards.tsx', 'components/nexus-traitors.tsx', 'components/nexus-tleilaxu.tsx', 'components/nexus-suboids.tsx', 'components/nexus-advisors.tsx', 'components/nexus-sardaukar.tsx', 'components/choam-power-cost.tsx', 'components/moritani-terror.tsx', 'components/nexus-moritani-betrayal.tsx', 'components/nexus-ecaz-betrayal.tsx', 'components/nexus-ecaz-inquiry.tsx', 'components/nexus-ecaz-duke.tsx', 'components/nexus-richese-betrayal.tsx', 'components/game-table.tsx'] }, + { area: 'AI', status: 'Partial', detail: 'All four profiles choose legal actions through private offers, including Moritani/Ecaz Betrayal, Ecaz Secret Ally inquiry and native Duke Cunning, Fremen Cunning, Emperor purchase and bounded Richese auction Betrayal. Complete module strategy and calibration remain unfinished.', evidence: ['game/nexus-card-options.ts', 'game/nexus-traitor-options.ts', 'game/nexus-tleilaxu-options.ts', 'game/nexus-suboid-options.ts', 'game/nexus-advisor-options.ts', 'game/nexus-sardaukar-options.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal-options.ts', 'game/nexus-ecaz-betrayal-options.ts', 'game/nexus-ecaz-inquiry-options.ts', 'game/nexus-ecaz-duke-options.ts', 'game/nexus-richese-options.ts', 'game/bot-nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/bots.ts'] }, + { area: 'Documentation', status: 'Partial', detail: 'Common rules, twelve printed faces and bounded runtime contracts retain explicit unresolved Ecaz Duke capture/Tanks/Ghola and inquiry audience/scope, Fremen nested Cunning, Great Maker, combined modules, fresh advisors, storm and borrowed Voice. Richese auction Betrayal has a separate cost/prepayment/privacy contract and excluded-producer boundaries.', evidence: ['docs/NEXUS_CARD_RULES.md', 'docs/NEXUS_ATREIDES_RULES.md', 'docs/NEXUS_HARKONNEN_RULES.md', 'docs/NEXUS_TLEILAXU_RULES.md', 'docs/NEXUS_IXIAN_CUNNING_RULES.md', 'docs/NEXUS_CUNNING_RUNTIME.md', 'docs/NEXUS_BENE_GESSERIT_RULES.md', 'docs/NEXUS_ADVISOR_RUNTIME.md', 'docs/NEXUS_EMPEROR_RULES.md', 'docs/NEXUS_SARDAUKAR_RUNTIME.md', 'docs/NEXUS_FREMEN_RULES.md', 'docs/NEXUS_ECAZ_RULES.md', 'docs/NEXUS_CHOAM_RULES.md', 'docs/NEXUS_CHOAM_RUNTIME.md', 'docs/NEXUS_MORITANI_RULES.md', 'docs/NEXUS_MORITANI_RUNTIME.md', 'docs/NEXUS_RICHESE_RUNTIME.md', 'docs/NEXUS_RICHESE_RULES.md', 'docs/NEXUS_GUILD_CUNNING_RUNTIME.md'] }, + { area: 'Verification', status: 'Partial', detail: 'Physical custody, commitments, controls, legal bots, Basic/Advanced and in-memory SQLite recovery regressions include native Ecaz Duke override and private Secret Ally inquiry. A read-only local browser fixture showed the owner-only Duke choice and no rival offer; no room was written. Bounded Richese auction Betrayal additionally has local CLI/runtime, authenticated SQLite and refreshed phone Use/Pass evidence in owned disposable rooms. Full module games, exceptional Duke custody, combined interactions and deployed acceptance remain separate.', evidence: ['tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts', 'tests/nexus-fremen-cunning.test.ts', 'tests/nexus-fremen-cunning-recovery.test.ts', 'tests/nexus-fremen-cunning-summon.test.ts', 'tests/nexus-fremen-cunning-summon-recovery.test.ts', 'tests/nexus-ecaz-betrayal-quotes.test.ts', 'tests/nexus-ecaz-betrayal.test.ts', 'tests/nexus-ecaz-betrayal-recovery.test.ts', 'tests/nexus-ecaz-inquiry.test.ts', 'tests/nexus-ecaz-duke-quote.test.ts', 'tests/nexus-ecaz-duke-engine.test.ts', 'tests/nexus-ecaz-duke-controls.test.ts', 'tests/nexus-ecaz-duke-recovery.test.ts', 'tests/nexus-traitor-recovery.test.ts', 'tests/nexus-face-dancer-engine.test.ts', 'tests/nexus-suboids-engine.test.ts', 'tests/normal-free-support.test.ts', 'tests/nexus-suboid-controls.test.ts', 'tests/nexus-advisor-controls.test.ts', 'tests/nexus-advisor-bots.test.ts', 'tests/nexus-sardaukar-controls.test.ts', 'tests/nexus-sardaukar-bots.test.ts', 'tests/nexus-choam-controls.test.ts', 'tests/nexus-choam-bots.test.ts', 'tests/nexus-moritani-controls.test.ts', 'tests/nexus-moritani-bots.test.ts', 'tests/nexus-moritani-betrayal.test.ts', 'tests/nexus-richese-controls.test.ts', 'tests/nexus-richese-bots.test.ts', 'tests/nexus-richese-betrayal-engine.test.ts', 'tests/nexus-richese-betrayal-recovery.test.ts', 'tests/nexus-guild-cunning-controls.test.ts', 'tests/nexus-guild-cunning-bots.test.ts'] }, ], }, { @@ -4375,7 +4415,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'advanced-combat', ...(id === 'ix-modules' ? ['tech-tokens'] : []), ...(id === 'choam-modules' ? ['stronghold-cards', 'leader-skills'] : []), - ...(id === 'choam-modules' ? ['richese-cards'] : []), + ...(id === 'choam-modules' ? ['richese-cards', 'nexus-richese-betrayal'] : []), ...(id === 'ecaz-modules' ? [ 'faction-moritani', diff --git a/game/table-turn.ts b/game/table-turn.ts index e84c50a4..ea77b93f 100644 --- a/game/table-turn.ts +++ b/game/table-turn.ts @@ -12,12 +12,13 @@ type TurnView = Pick< | 'phaseOpening' | 'automaticContinuationPending' > & - Partial>; + Partial>; /** The single current decision owner for the table banner and seat highlight. * Shared response windows have no single acting seat. Only public view fields * participate; this does not decide which gameplay actions are legal. */ export function tableActionOwner(g: TurnView): string | null { + if (g.richeseBetrayalReaction) return null; if (g.kullReaction && g.status === 'playing') return g.players.some(p => p.id === g.kullReaction!.player) ? g.kullReaction.player : null; diff --git a/tests/bot-nexus-richese-betrayal.test.ts b/tests/bot-nexus-richese-betrayal.test.ts new file mode 100644 index 00000000..0e957cd5 --- /dev/null +++ b/tests/bot-nexus-richese-betrayal.test.ts @@ -0,0 +1,101 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { applyAction, viewGame } from '../game/engine'; +import type { Game } from '../game/engine'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { createRicheseBetrayalFixture } from './fixture-richese-betrayal'; + +function saved(game: Game): Game { + return JSON.parse(JSON.stringify(game)); +} + +void test('all four profiles commit the saved purchase veto and sale diversion from their private projection', () => { + for (const profile of DIFFICULTIES) { + for (const kind of ['purchase', 'sale'] as const) { + const fixture = createRicheseBetrayalFixture(kind); + fixture.game.players.find(player => player.id === fixture.holder)!.bot = profile; + const game = saved(fixture.game); + const own = viewGame(game, fixture.holder); + // No rival hand or resources may be needed to choose the response. + for (const rival of own.players) { + if (rival.id !== own.me) { + delete rival.hand; + delete rival.spice; + } + } + const reaction = own.richeseBetrayalReaction!; + assert.equal(reaction.canUse, true); + const actions = botActions(own); + assert.deepEqual(actions, [{ type: 'richeseBetrayalUse', event: reaction.event }]); + const balances = game.players.map(player => player.spice); + const buyerCount = game.players.find(player => player.id === fixture.buyer)!.hand.length; + const result = saved(applyAction(game, fixture.holder, actions[0])); + assert.equal(viewGame(result, fixture.holder).richeseBetrayalReaction, null); + assert.ok(result.nexusCards?.cards?.discard.includes('richese')); + if (kind === 'purchase') { + assert.deepEqual(result.players.map(player => player.spice), balances); + assert.equal(result.players.find(player => player.id === fixture.buyer)!.hand.length, buyerCount); + assert.ok(result.discard.some(card => card.id === fixture.card)); + } else { + assert.equal(result.players.find(player => player.id === fixture.target)!.spice, + game.players.find(player => player.id === fixture.target)!.spice); + assert.equal(result.players.find(player => player.id === fixture.buyer)!.spice, + game.players.find(player => player.id === fixture.buyer)!.spice - reaction.price); + assert.ok(result.players.find(player => player.id === fixture.buyer)!.hand.some(card => card.id === fixture.card)); + } + } + } +}); + +void test('all profiles pass publicly possible nonholder boundaries through saved original settlement', () => { + for (const profile of DIFFICULTIES) { + for (const kind of ['purchase', 'sale'] as const) { + const fixture = createRicheseBetrayalFixture(kind, { holderNexus: 'choam' }); + for (const player of fixture.game.players) player.bot = profile; + let game = saved(fixture.game); + const original = viewGame(game, fixture.holder).richeseBetrayalReaction!; + assert.equal(original.canUse, false); + for (let acknowledgements = 0; acknowledgements < game.players.length; acknowledgements++) { + const responder = game.players.find(player => { + const reaction = viewGame(game, player.id).richeseBetrayalReaction; + return reaction?.canPass && !reaction.hasPassed; + }); + if (!responder) break; + const view = viewGame(game, responder.id); + const actions = botActions(view); + assert.deepEqual(actions, [{ type: 'richeseBetrayalPass', event: original.event }]); + game = saved(applyAction(game, responder.id, actions[0])); + if (viewGame(game, responder.id).richeseBetrayalReaction) { + assert.deepEqual(botActions(viewGame(game, responder.id)), []); + } + } + assert.equal(viewGame(game, fixture.holder).richeseBetrayalReaction, null); + assert.ok(game.players.find(player => player.id === fixture.buyer)!.hand.some(card => card.id === fixture.card)); + assert.equal(game.players.find(player => player.id === fixture.buyer)!.spice, + fixture.game.players.find(player => player.id === fixture.buyer)!.spice - original.price); + assert.equal(game.nexusCards?.cards?.hands[fixture.holder], 'choam'); + } + } +}); + +void test('projected restrictions defeat physical Richese Nexus hints without exposing underlying bot actions', () => { + for (const profile of DIFFICULTIES) { + const fixture = createRicheseBetrayalFixture('purchase'); + fixture.game.players.find(player => player.id === fixture.holder)!.bot = profile; + const view = viewGame(saved(fixture.game), fixture.holder); + assert.equal(view.nexusCards?.card, 'richese'); + const reaction = view.richeseBetrayalReaction!; + reaction.canUse = false; + reaction.blocked = 'The committed promise prevents spending this Nexus card.'; + assert.deepEqual(botActions(view), [{ type: 'richeseBetrayalPass', event: reaction.event }]); + reaction.hasPassed = true; + assert.deepEqual(botActions(view), []); + reaction.hasPassed = false; + reaction.canPass = false; + assert.deepEqual(botActions(view), []); + reaction.canPass = true; + view.status = 'finished'; + assert.deepEqual(botActions(view), []); + } +}); diff --git a/tests/fixture-richese-betrayal.ts b/tests/fixture-richese-betrayal.ts new file mode 100644 index 00000000..a9a6ba25 --- /dev/null +++ b/tests/fixture-richese-betrayal.ts @@ -0,0 +1,129 @@ +import assert from 'node:assert/strict'; +import { + applyAction, createGame, initializeRicheseBetrayalGameForAudit, joinGame, + newPlayer, viewGame, type Game, +} from '../game/engine'; +import { botActions } from '../game/bots'; +import { drawNexusCard, type NexusState } from '../game/nexus-cards'; +import { orderNexusSpice } from './fixture-nexus-cards'; + +export const RICHESE_BETRAYAL_FIXTURE_FACTIONS = ['richese','choam','harkonnen','fremen'] as const; +export type RicheseBetrayalFixtureOptions = { + seatIds?: string[]; + source?: 'cache' | 'blackMarket'; + advanced?: boolean; + holderNexus?: 'richese' | 'choam'; + allyPayment?: number; + allyIsSeller?: boolean; + allySpice?: number; + beforeFinalBid?: (g: Game) => void; + bonusCounter?: boolean; +}; +export type RicheseBetrayalFixture = { + game: Game; + holder: string; + other: string; + target: string; + buyer: string; + card: string; + event: string; + ally: string | null; + counterCard: string | null; +}; +function step(g: Game): Game { + for (const p of g.players) { + const v = viewGame(g,p.id); + v.players.find(seat => seat.id === p.id)!.bot = 'Easy'; + const action = botActions(v)[0]; + if (action) return applyAction(g,p.id,action); + } + throw new Error(`Richese Betrayal setup stalled at ${g.status}/${g.phase}/${g.decision?.kind}`); +} +function holdNexus(g: Game, owner: string, card: 'richese' | 'choam' | 'guild') { + const cards = g.nexusCards!.cards!; + assert.ok(cards.deck.includes(card)); + const ordered: NexusState = {...cards,deck:[card,...cards.deck.filter(c => c !== card)]}; + g.nexusCards!.cards = drawNexusCard(ordered,owner,g.players,() => 0); +} +/** Genuine setup and completed Spice phase, conserved physical Nexus draws and + * real declaration/bid actions. Arrange resources only before the auction, never + * rewrite a paused receipt. Seat IDs are fixed before setup for authenticated SQL. */ +export function createRicheseBetrayalFixture( + kind: 'purchase' | 'sale', options: RicheseBetrayalFixtureOptions = {}, +): RicheseBetrayalFixture { + const ids = options.seatIds ?? ['richese','holder','buyer','other']; + assert.equal(ids.length,4); + const [target,holder,harkonnen,other] = ids; + const source = options.source ?? (kind === 'purchase' ? 'cache' : 'blackMarket'); + assert.ok(kind !== 'purchase' || source === 'cache'); + const advanced = options.advanced ?? source === 'blackMarket'; + assert.ok(source !== 'blackMarket' || advanced); + let g = createGame('RICHESEBETRAYAL',newPlayer(target,'Richese','richese'),advanced,['choam']); + for (let i = 1; i < ids.length; i++) + joinGame(g,newPlayer(ids[i],RICHESE_BETRAYAL_FIXTURE_FACTIONS[i],RICHESE_BETRAYAL_FIXTURE_FACTIONS[i])); + g.nexusCards = {cards:null,phase:null}; + for (const p of g.players) g = applyAction(g,p.id,{type:'ready'}); + g = initializeRicheseBetrayalGameForAudit(g); + for (let i = 0; g.status === 'setup' && i < 100; i++) g = step(g); + assert.equal(g.status,'playing'); + for (const p of g.players) {g.deck.push(...p.hand.splice(0));p.spice = 20;} + for (let i = 0; g.phase === 0 && i < 100; i++) g = step(g); + assert.equal(g.phase,1); + orderNexusSpice(g,['land','land']); + for (let i = 0; g.phase === 1 && i < 100; i++) g = step(g); + assert.equal(g.phase,2); + assert.equal(g.nexusCards!.phase!.stage,'complete'); + holdNexus(g,holder,options.holderNexus ?? 'richese'); + const contribution = options.allyPayment ?? 0; + const buyer = kind === 'purchase' ? target : harkonnen; + let ally: string | null = null; + if (contribution) { + assert.equal(kind,'sale'); + assert.ok(contribution > 0 && contribution <= 6); + ally = options.allyIsSeller ? target : other; + g.players.find(p => p.id === harkonnen)!.ally = ally; + g.players.find(p => p.id === ally)!.ally = harkonnen; + } else holdNexus(g,other,'guild'); + const card = source === 'cache' ? g.richeseCache![0].id : (() => { + const index = g.deck.findIndex(c => c.kind === 'shield'); + assert.ok(index >= 0); + const selected = g.deck.splice(index,1)[0]; + g.players.find(p => p.id === target)!.hand.push(selected); + return selected.id; + })(); + for (const id of g.order) g = applyAction(g,id,{type:'ready'}); + while (g.decision?.kind === 'choamMarket') + g = applyAction(g,g.decision.player,{type:'decision',done:true}); + assert.equal(g.phase,3); + if (source === 'cache') { + if (g.decision?.kind === 'richeseBlackMarket') + g = applyAction(g,target,{type:'decision',event:g.richeseBidding!.event,decline:true}); + g = applyAction(g,target,{type:'decision',event:g.richeseBidding!.event,position:'first'}); + } + assert.equal(g.decision?.kind,source === 'cache' ? 'richeseCache' : 'richeseBlackMarket'); + g = applyAction(g,target,{type:'decision',event:g.richeseBidding!.event,card,method:'onceAround',direction:'clockwise'}); + assert.equal(g.response,null); + let counterCard: string | null = null; + if (options.bonusCounter) { + const index = g.deck.findIndex(c => c.effect === 'karama'); + assert.ok(index >= 0); + const counter = g.deck.splice(index,1)[0]; + g.players.find(p => p.id === other)!.hand.push(counter); + counterCard = counter.id; + } + if (ally) { + if (options.allySpice !== undefined) g.players.find(p => p.id === ally)!.spice = options.allySpice; + g = applyAction(g,ally,{type:'pledgeAid',amount:contribution}); + } + for (let i = 0; !g.pendingRicheseBetrayal && i < 10; i++) { + const lot = g.richeseAuction!; + assert.ok(lot && !lot.outcome && lot.active); + if (lot.acted.length === lot.order.length - 1) options.beforeFinalBid?.(g); + g = applyAction(g,lot.active,{type:'richeseBid',event:lot.event, + amount:lot.active === buyer ? 6 : kind === 'purchase' && !lot.bidder ? 1 : null, + ...(lot.active === buyer ? {allyPayment:contribution} : {})}); + } + assert.ok(g.pendingRicheseBetrayal); + assert.equal(g.pendingRicheseBetrayal.receipt.invoice.kind,kind); + return {game:g,holder,other,target,buyer,card,event:g.pendingRicheseBetrayal.receipt.event,ally,counterCard}; +} diff --git a/tests/nexus-richese-betrayal-controls.test.tsx b/tests/nexus-richese-betrayal-controls.test.tsx new file mode 100644 index 00000000..4a3c9f31 --- /dev/null +++ b/tests/nexus-richese-betrayal-controls.test.tsx @@ -0,0 +1,144 @@ +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 } from 'react'; +import type { ReactNode, ComponentProps } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { applyAction, viewGame } from '../game/engine'; +import type { Action, Game, GameView } from '../game/engine'; +import { createRicheseBetrayalFixture } from './fixture-richese-betrayal'; +import { DEFAULT_ROOM_CONTROL } from '../lib/room-control'; + +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 }; + }, +}); +// Static imports load Next and CSS before these Node-only SSR alias hooks. +const { NexusRicheseBetrayal } = await import('../components/nexus-richese-betrayal'); +const { GameTable } = await import('../components/game-table'); + +function markup(game: GameView, busy = false): string { + return renderToStaticMarkup(createElement(NexusRicheseBetrayal, { game, act() {}, busy })); +} + +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; +} + +void test('only the canonical private holder can use while public possible nonholders acknowledge the same auction', () => { + const holderFixture = createRicheseBetrayalFixture('purchase'); + const nonholderFixture = createRicheseBetrayalFixture('purchase', { holderNexus: 'choam' }); + const holder = viewGame(JSON.parse(JSON.stringify(holderFixture.game)), holderFixture.holder); + const nonholder = viewGame(JSON.parse(JSON.stringify(nonholderFixture.game)), nonholderFixture.holder); + assert.match(markup(holder), /]*>Use Richese Nexus/); + assert.match(markup(holder), /Inspect Richese Nexus card/); + assert.match(markup(nonholder), /]*>Pass/); + assert.doesNotMatch(markup(nonholder), /]*>Use Richese Nexus|Inspect Richese Nexus card/); + const outsider = viewGame(holderFixture.game, holderFixture.target); + assert.doesNotMatch(markup(outsider), /]*>(?:Pass|Use Richese Nexus)/); + assert.match(markup(outsider), /Waiting for the required acknowledgements/); +}); + +void test('concealed Black Market receipt never exposes another hand, spice, or physical sale face', () => { + const fixture = createRicheseBetrayalFixture('sale'); + const view = viewGame(JSON.parse(JSON.stringify(fixture.game)), fixture.holder); + const face = fixture.game.players.find(player => player.id === fixture.target)!.hand.find(card => card.id === fixture.card)!; + for (const rival of view.players) { + if (rival.id !== view.me) { + delete rival.hand; + delete rival.spice; + } + } + const html = markup(view); + assert.match(html, /concealed Black Market lot/); + assert.match(html, /same payment to the Spice Bank/); + assert.doesNotMatch(html, new RegExp(face.id)); + assert.doesNotMatch(html, new RegExp(face.name)); +}); + +void test('busy and blocked reactions cannot spend a cost, but blocked responders may pass', () => { + const fixture = createRicheseBetrayalFixture('purchase'); + const view = viewGame(fixture.game, fixture.holder); + const busy = markup(view, true); + assert.match(busy, /disabled=""[^>]*>Pass/); + assert.match(busy, /disabled=""[^>]*>Use Richese Nexus/); + view.richeseBetrayalReaction!.blocked = 'An existing promise reserves this card.'; + assert.doesNotMatch(markup(view), /]*>Use Richese Nexus/); + assert.match(markup(view), /]*>Pass/); +}); + +void test('human event-bound Use and Pass handlers settle real saved purchases and sales, not an underlying bid', () => { + for (const kind of ['purchase', 'sale'] as const) { + for (const use of [true, false]) { + const fixture = createRicheseBetrayalFixture(kind); + let game: Game = JSON.parse(JSON.stringify(fixture.game)); + const own = viewGame(game, fixture.holder); + let selected: Action | undefined; + const tree = NexusRicheseBetrayal({ game: own, act(action) { selected = action; }, busy: false }); + assert.equal(click(tree, use ? 'Use Richese Nexus' : 'Pass'), true); + assert.ok(selected); + game = applyAction(game, fixture.holder, selected); + if (!use) { + for (const player of game.players) { + const view = viewGame(game, player.id); + const offer = view.richeseBetrayalReaction; + if (offer?.canPass && !offer.hasPassed) { + game = applyAction(game, player.id, { type: 'richeseBetrayalPass', event: offer.event }); + } + } + } + assert.equal(viewGame(game, fixture.holder).richeseBetrayalReaction, null); + const buyer = game.players.find(player => player.id === fixture.buyer)!; + const vetoed = use && kind === 'purchase'; + assert.equal(buyer.hand.some(card => card.id === fixture.card), !vetoed); + assert.equal(buyer.spice, fixture.game.players.find(player => player.id === fixture.buyer)!.spice - (vetoed ? 0 : own.richeseBetrayalReaction!.price)); + if (kind === 'sale') { + assert.equal(game.players.find(player => player.id === fixture.target)!.spice, + fixture.game.players.find(player => player.id === fixture.target)!.spice + (use ? 0 : own.richeseBetrayalReaction!.price)); + } + } + } +}); + +void test('the actual table owns auction and voluntary controls through reservation, pause, transport and autopilot', () => { + const fixture = createRicheseBetrayalFixture('purchase'); + const own = viewGame(fixture.game, fixture.holder); + const table = (game: ComponentProps['game'], busy = false) => renderToStaticMarkup(createElement(GameTable, { + game, send: async () => {}, busy, onExit() {}, + })); + const html = table(own); + assert.match(html, /aria-label="Richese Nexus Betrayal response"/); + assert.match(html, /Richese Nexus Betrayal development preview/); + assert.match(html, /href="\/rules\?topic=nexus-richese-betrayal#nexus-richese-betrayal"/); + assert.doesNotMatch(html, /aria-label="Richese auction"|]*>[^<]*(?:Submit bid|Bid |Cash in|Play Karama|Make a bribe)/i); + assert.match(table(own, true), /disabled=""[^>]*>Use Richese Nexus/); + for (const restriction of ['paused', 'closed', 'autopilot'] as const) { + const restricted: ComponentProps['game'] = structuredClone(own); + if (restriction === 'autopilot') restricted.players.find(player => player.id === own.me)!.autopilot = 'Easy'; + else restricted.roomControl = { ...DEFAULT_ROOM_CONTROL, [restriction]: true }; + assert.match(table(restricted), /disabled=""[^>]*>Use Richese Nexus/); + } + own.richeseBetrayalReaction = null; + own.richeseBetrayalPreview = false; + const ordinary = table(own); + assert.match(ordinary, /aria-label="Richese auction"/); + assert.doesNotMatch(ordinary, /Richese Nexus Betrayal development preview|aria-label="Richese Nexus Betrayal response"/); +}); + +aliases.deregister(); diff --git a/tests/nexus-richese-betrayal-engine.test.ts b/tests/nexus-richese-betrayal-engine.test.ts new file mode 100644 index 00000000..3710185b --- /dev/null +++ b/tests/nexus-richese-betrayal-engine.test.ts @@ -0,0 +1,252 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + applyAction, createGame, initializeRicheseBetrayalGameForAudit, joinGame, + newPlayer, normalizeAutomaticGame, viewGame, type Action, type Game, +} from '../game/engine'; +import { validateNexusCards } from '../game/nexus-cards'; +import { createRicheseBetrayalFixture } from './fixture-richese-betrayal'; + +const reload = (g: Game): Game => JSON.parse(JSON.stringify(g)); +const player = (g: Game,id: string) => g.players.find(p => p.id === id)!; +function reject(g: Game,id: string,action: Action) { + const before = JSON.stringify(g); + assert.throws(() => applyAction(g,id,action)); + assert.equal(JSON.stringify(g),before); +} +function passes(g: Game): Game { + const receipt = g.pendingRicheseBetrayal!.receipt; + for (const id of receipt.required) + g = applyAction(g,id,{type:'richeseBetrayalPass',event:receipt.event}); + return g; +} +function census(g: Game): string[] { + return [...g.deck,...g.discard,...(g.richeseCache ?? []),...(g.richeseRemoved ?? []), + ...g.players.flatMap(p => p.hand), + ...(g.auction?.cards.slice(g.auction.index + Number(g.currentAuctionSale?.origin === 'normal')) ?? [])] + .map(c => c.id).sort(); +} + +void test('Basic and Advanced cache veto spends one Nexus, discards exact target and retires the original lot without any purchase debit', () => { + for (const advanced of [false,true]) { + const f = createRicheseBetrayalFixture('purchase',{advanced}); + const before = reload(f.game), receipt = before.pendingRicheseBetrayal!.receipt; + assert.equal(player(before,f.target).hand.some(c => c.id === f.card),false); + const done = applyAction(reload(before),f.holder,{type:'richeseBetrayalUse',event:f.event}); + assert.deepEqual(done.players.map(p => p.spice),before.players.map(p => p.spice)); + assert.equal(done.discard.filter(c => c.id === f.card).length,1); + assert.equal(done.richeseCache!.some(c => c.id === f.card),false); + assert.equal(player(done,f.target).hand.some(c => c.id === f.card),false); + assert.equal(done.nexusCards!.cards!.hands[f.holder],null); + assert.equal(done.nexusCards!.cards!.discard[receipt.nexusDiscardIndex ?? before.nexusCards!.cards!.discard.length],'richese'); + assert.equal(done.richeseBetrayal!.completed[0].stage,'used'); + assert.equal(done.richeseBetrayal!.current,null); + assert.equal(done.richeseAuction,null); + assert.equal(done.richeseBidding!.stage,'normal'); + assert.deepEqual(census(done),census(before)); + validateNexusCards(done.nexusCards!.cards!,done.players); + const normalized = normalizeAutomaticGame(reload(done)); + assert.deepEqual(normalized,done); + reject(done,f.holder,{type:'richeseBetrayalUse',event:f.event}); + } +}); + +void test('all acknowledgements resume the original cache purchase exactly once, keeping the Nexus card', () => { + const f = createRicheseBetrayalFixture('purchase'); + const before = reload(f.game); + const done = passes(reload(before)); + assert.equal(player(done,f.target).spice,player(before,f.target).spice - 6); + assert.equal(player(done,f.target).hand.filter(c => c.id === f.card).length,1); + assert.equal(done.discard.some(c => c.id === f.card),false); + assert.equal(done.nexusCards!.cards!.hands[f.holder],'richese'); + assert.equal(done.richeseBetrayal!.completed[0].stage,'passed'); + assert.deepEqual(census(done),census(before)); + assert.deepEqual(normalizeAutomaticGame(reload(done)),done); + reject(done,f.holder,{type:'richeseBetrayalPass',event:f.event}); +}); + +void test('cache and concealed Black Market diversion preserve original ally split, delivery and earned Harkonnen bonus without seller income', () => { + for (const source of ['cache','blackMarket'] as const) { + const f = createRicheseBetrayalFixture('sale',{source,allyPayment:2}); + const before = reload(f.game), bonus = before.deck[0].id; + assert.equal(before.pendingRicheseBetrayal!.receipt.invoice.ownPayment,4); + assert.equal(before.pendingRicheseBetrayal!.receipt.invoice.donor,f.ally); + const done = applyAction(reload(before),f.holder,{type:'richeseBetrayalUse',event:f.event}); + assert.equal(player(done,f.buyer).spice,player(before,f.buyer).spice - 4); + assert.equal(player(done,f.ally!).spice,player(before,f.ally!).spice); + assert.equal(done.aid[f.ally!].amount,before.aid[f.ally!].amount - 2); + assert.equal(player(done,f.target).spice,player(before,f.target).spice); + assert.deepEqual(player(done,f.buyer).hand.map(c => c.id).sort(),[f.card,bonus].sort()); + assert.equal(done.richeseAuction,null); + assert.equal(done.richeseBidding!.stage,source === 'cache' ? 'normal' : 'declaration'); + assert.equal(done.richeseBidding!.blackMarketSold,source === 'blackMarket'); + assert.deepEqual(census(done),census(before)); + assert.deepEqual(normalizeAutomaticGame(reload(done)),done); + reject(done,f.holder,{type:'richeseBetrayalUse',event:f.event}); + } +}); + +void test('all-pass Black Market sale pays original seller and draws one native bonus, never a second debit', () => { + const f = createRicheseBetrayalFixture('sale',{allyPayment:2}); + const before = reload(f.game), bonus = before.deck[0].id; + const done = passes(reload(before)); + assert.equal(player(done,f.target).spice,player(before,f.target).spice + 6); + assert.equal(player(done,f.buyer).spice,player(before,f.buyer).spice - 4); + assert.equal(player(done,f.ally!).spice,player(before,f.ally!).spice); + assert.equal(done.aid[f.ally!].amount,before.aid[f.ally!].amount - 2); + assert.deepEqual(player(done,f.buyer).hand.map(c => c.id).sort(),[f.card,bonus].sort()); + assert.equal(done.nexusCards!.cards!.hands[f.holder],'richese'); + assert.deepEqual(normalizeAutomaticGame(reload(done)),done); +}); + +void test('public neutral timing is independent of Nexus identity and concealed source remains unentitled', () => { + const f = createRicheseBetrayalFixture('sale'); + const relevant = reload(f.game), irrelevant = reload(f.game); + const cards = irrelevant.nexusCards!.cards!, index = cards.deck.indexOf('choam'); + assert.ok(index >= 0); + cards.deck[index] = 'richese';cards.hands[f.holder] = 'choam'; + for (const p of relevant.players) { + const a = viewGame(relevant,p.id), b = viewGame(irrelevant,p.id); + const publicReaction = (v: typeof a) => { + const r = v.richeseBetrayalReaction!; + return {event:r.event,kind:r.kind,target:r.target,buyer:r.buyer,source:r.source,price:r.price,canPass:r.canPass,hasPassed:r.hasPassed}; + }; + assert.deepEqual(publicReaction(a),publicReaction(b)); + assert.equal(a.decision,null);assert.equal(a.response,null); + assert.equal(Object.hasOwn(a,'pendingRicheseBetrayal'),false); + assert.equal(Object.hasOwn(a,'richeseBetrayal'),false); + if (p.id !== f.target) { + assert.equal(a.richeseAuction!.card,null); + assert.equal(a.richeseAuction!.cardId,null); + assert.equal(JSON.stringify(a).includes(`"${f.card}"`),false); + } + } + assert.equal(viewGame(relevant,f.holder).richeseBetrayalReaction!.canUse,true); + assert.equal(viewGame(irrelevant,f.holder).richeseBetrayalReaction!.canUse,false); + assert.equal(viewGame(relevant,f.other).richeseBetrayalReaction!.canPass,true); + assert.equal(viewGame(relevant,f.other).richeseBetrayalReaction!.canUse,false); +}); + +void test('wrong actor/event/payload and all underlying actions reject immutably; pass relinquishes this exact use opportunity', () => { + const f = createRicheseBetrayalFixture('purchase'); + for (const [id,action] of [ + [f.target,{type:'richeseBetrayalUse',event:f.event}], + [f.other,{type:'richeseBetrayalUse',event:f.event}], + [f.holder,{type:'richeseBetrayalUse',event:'stale'}], + [f.holder,{type:'richeseBetrayalUse',event:f.event,card:f.card}], + [f.holder,{type:'ready'}], + [f.holder,{type:'nexusChoamBetrayal'}], + ] as [string,Action][]) reject(f.game,id,action); + const passed = applyAction(reload(f.game),f.holder,{type:'richeseBetrayalPass',event:f.event}); + assert.equal(viewGame(passed,f.holder).richeseBetrayalReaction!.hasPassed,true); + assert.equal(viewGame(passed,f.holder).richeseBetrayalReaction!.canUse,false); + reject(passed,f.holder,{type:'richeseBetrayalUse',event:f.event}); + assert.deepEqual(normalizeAutomaticGame(reload(passed)),passed); +}); + +void test('orphaned/corrupt source receipts fail before projection, normalization or payment', () => { + const f = createRicheseBetrayalFixture('sale'); + const corruptions: ((g: Game) => void)[] = [ + g => {delete g.pendingRicheseBetrayal;}, + g => {delete g.richeseBetrayal;}, + g => {g.richeseBetrayal!.current = null;}, + g => {g.pendingRicheseBetrayal!.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);}, + ]; + for (const mutate of corruptions) { + const bad = reload(f.game);mutate(bad); + const before = JSON.stringify(bad); + for (const p of bad.players) assert.throws(() => viewGame(bad,p.id)); + assert.throws(() => normalizeAutomaticGame(bad)); + reject(bad,f.holder,{type:'richeseBetrayalUse',event:f.event}); + assert.equal(JSON.stringify(bad),before); + } +}); + +void test('initializer admits only explicit fresh CHOAM/Richese Nexus composition and does not bypass public starts', () => { + function lobby() { + let g = createGame('RICHESEADMISSION',newPlayer('r','Richese','richese'),false,['choam']); + joinGame(g,newPlayer('c','CHOAM','choam')); + g.nexusCards = {cards:null,phase:null}; + for (const p of g.players) g = applyAction(g,p.id,{type:'ready'}); + return g; + } + for (const mutate of [ + (g: Game) => {g.nexusCards = undefined;}, + (g: Game) => {g.expansions.push('ix');}, + (g: Game) => {g.sandtrout = true;}, + (g: Game) => {g.kullPreview = true;}, + (g: Game) => {g.semutaPreview = true;}, + (g: Game) => {g.players[1].faction = 'ixians';}, + ]) { + const bad = lobby();mutate(bad); + const before = JSON.stringify(bad); + assert.throws(() => initializeRicheseBetrayalGameForAudit(bad)); + assert.equal(JSON.stringify(bad),before); + } + const fresh = lobby(), initialized = initializeRicheseBetrayalGameForAudit(fresh); + assert.equal(initialized.richeseBetrayalPreview,true); + assert.deepEqual(initialized.richeseBetrayal,{sequence:0,current:null,completed:[]}); + reject(fresh,'r',{type:'start'}); +}); + +void test('diversion keeps native bonus cancellation and paid-sale ownership through JSON restore without replaying payment', () => { + const f = createRicheseBetrayalFixture('sale',{bonusCounter:true}); + const before = reload(f.game); + const paid = applyAction(reload(before),f.holder,{type:'richeseBetrayalUse',event:f.event}); + assert.equal(paid.response?.kind,'harkonnenBonus'); + assert.equal(paid.currentAuctionSale!.recipient,'bank'); + assert.equal(player(paid,f.target).spice,player(before,f.target).spice); + assert.equal(player(paid,f.buyer).spice,player(before,f.buyer).spice - 6); + assert.deepEqual(player(paid,f.buyer).hand.map(c => c.id),[f.card]); + assert.equal(viewGame(paid,f.other).richeseBetrayalReaction,null); + assert.deepEqual(normalizeAutomaticGame(reload(paid)),paid); + for (const mutate of [ + (g: Game) => {g.currentAuctionSale = null;}, + (g: Game) => {delete g.currentAuctionSale!.recipient;}, + (g: Game) => {g.response = null;}, + ]) { + const bad = reload(paid);mutate(bad); + for (const p of bad.players) assert.throws(() => viewGame(bad,p.id)); + assert.throws(() => normalizeAutomaticGame(bad)); + reject(bad,f.other,{type:'card',card:f.counterCard!,mode:'cancel'}); + } + const done = applyAction(reload(paid),f.other,{type:'card',card:f.counterCard!,mode:'cancel'}); + assert.equal(done.richeseAuction,null); + assert.equal(done.richeseBidding!.stage,'declaration'); + assert.equal(player(done,f.target).spice,player(before,f.target).spice); + assert.equal(player(done,f.buyer).spice,player(before,f.buyer).spice - 6); + assert.deepEqual(player(done,f.buyer).hand.map(c => c.id),[f.card]); + assert.equal(done.discard.filter(c => c.id === f.counterCard).length,1); + assert.equal(done.richeseBetrayal!.completed.length,1); + assert.deepEqual(census(done),census(before)); +}); + +void test('Richese can fund its buyer from escrow with no unpledged spice, with or without diversion', () => { + for (const source of ['cache','blackMarket'] as const) for (const use of [false,true]) { + const f = createRicheseBetrayalFixture('sale',{source,allyPayment:2,allyIsSeller:true,allySpice:2}); + assert.equal(player(f.game,f.target).spice,0); + assert.equal(f.game.aid[f.target].amount,2); + const done = use ? applyAction(reload(f.game),f.holder,{type:'richeseBetrayalUse',event:f.event}) : passes(reload(f.game)); + assert.equal(player(done,f.buyer).spice,player(f.game,f.buyer).spice - 4); + assert.equal(player(done,f.target).spice,use ? 0 : 6); + assert.equal(done.aid[f.target].amount,0); + assert.deepEqual(census(done),census(f.game)); + assert.deepEqual(normalizeAutomaticGame(reload(done)),done); + } +}); + +void test('an exhausted cache rejects a Black Market closing bid before saving an unfinishable Harkonnen acknowledgement', () => { + let before: Game | null = null, serialized = ''; + assert.throws(() => createRicheseBetrayalFixture('sale',{source:'blackMarket',beforeFinalBid(g) { + (g.richeseRemoved ??= []).push(...g.richeseCache!.splice(0)); + before = g;serialized = JSON.stringify(g); + }}), /cache/i); + assert.ok(before); + assert.equal(JSON.stringify(before),serialized); + assert.equal((before as Game).pendingRicheseBetrayal,null); + assert.equal((before as Game).richeseBetrayal!.current,null); + assert.equal((before as Game).richeseAuction!.outcome,null); +}); diff --git a/tests/nexus-richese-betrayal-recovery.test.ts b/tests/nexus-richese-betrayal-recovery.test.ts new file mode 100644 index 00000000..ef0e9d1d --- /dev/null +++ b/tests/nexus-richese-betrayal-recovery.test.ts @@ -0,0 +1,494 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { DatabaseSync, SQLOutputValue } from 'node:sqlite'; +import { viewGame, type Action, type Game } from '../game/engine'; +import { NEXUS_FACTIONS, validateNexusCards } from '../game/nexus-cards'; +import type { RoomsClock, SeatAuth } from '../db/rooms'; +import type * as Rooms from '../db/rooms'; +import { unitStore } from './fixture-nexus-room-store'; +import { + createRicheseBetrayalFixture, + RICHESE_BETRAYAL_FIXTURE_FACTIONS, + type RicheseBetrayalFixture, + type RicheseBetrayalFixtureOptions, +} from './fixture-richese-betrayal'; + +const clock: RoomsClock = { now: () => 10000, sleep: async () => {} }; +type Kind = 'purchase' | 'sale'; +type Options = RicheseBetrayalFixtureOptions; +const plain = (value: T): T => JSON.parse(JSON.stringify(value)); +const player = (g: Game, id: string) => { + const found = g.players.find(p => p.id === id); + assert.ok(found); + return found; +}; + +function physical(g: Game) { + const ids = [ + ...g.deck, ...g.discard, ...(g.richeseCache ?? []), + ...(g.richeseRemoved ?? []), ...g.players.flatMap(p => p.hand), + ...(g.auction?.cards.slice(g.auction.index) ?? []), + ].map(card => card.id).sort(); + assert.equal(new Set(ids).size, ids.length, 'physical Treachery custody is unique'); + return ids; +} +function nexusInventory(g: Game) { + assert.ok(g.nexusCards?.cards); + const cards = g.nexusCards.cards; + validateNexusCards(cards, g.players); + assert.deepEqual([ + ...cards.deck, ...cards.discard, + ...Object.values(cards.hands).filter(card => card !== null), + ].sort(), [...NEXUS_FACTIONS].sort()); +} +function credentials(sqlite: DatabaseSync) { + return { + seats: sqlite.prepare('SELECT * FROM seats ORDER BY room_code,player_id,token_hash').all(), + keys: sqlite.prepare('SELECT * FROM seat_recovery_keys ORDER BY room_code,player_id').all(), + receipts: sqlite.prepare('SELECT * FROM seat_recovery_receipts ORDER BY room_code,player_id,operation_hash').all(), + }; +} + +async function fixture(t: test.TestContext, kind: Kind, options: Options = {}): Promise { + const store = unitStore(); + t.after(() => store.sqlite.close()); + const [host, ...rest] = RICHESE_BETRAYAL_FIXTURE_FACTIONS; + const made = await store.rooms.createRoom('Richese Betrayal SQL', host, options.advanced ?? true, ['choam']); + const code = made.view.code; + const tokens = [made.token]; + for (const faction of rest) + tokens.push((await store.rooms.joinRoom(code, faction, faction)).token!); + const auths = await Promise.all(tokens.map(token => store.rooms.authenticate(code, token))); + const seatIds = auths.map(auth => auth.playerId); + const madeGame = createRicheseBetrayalFixture(kind, { ...options, seatIds }); + const initial = madeGame.game; + initial.code = code; + initial.version = (await store.rooms.readRoom(code)).version; + const save = (g: Game) => store.sqlite.prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?') + .run(JSON.stringify(g), g.version, code); + save(initial); + const unrelated = await store.rooms.createRoom('Unrelated preserved room', 'atreides', false, []); + const unrelatedRow = store.sqlite.prepare('SELECT * FROM rooms WHERE code = ?').get(unrelated.view.code); + const credentialRows = credentials(store.sqlite); + const stock = physical(initial); + nexusInventory(initial); + store.writes.length = 0; + return { ...store, ...madeGame, code, tokens, auths, initial, save, stock, credentialRows, + unrelatedCode: unrelated.view.code, unrelatedToken: unrelated.token, unrelatedRow }; +} +type SqlRow = Record; +interface Fixture extends RicheseBetrayalFixture { + code: string; + tokens: string[]; + auths: SeatAuth[]; + initial: Game; + save(g: Game): void; + stock: string[]; + credentialRows: { seats: SqlRow[]; keys: SqlRow[]; receipts: SqlRow[] }; + unrelatedCode: string; + unrelatedToken: string; + unrelatedRow: SqlRow | undefined; + sqlite: DatabaseSync; + restart(): typeof Rooms; + hooks: { beforeWrite?: () => Promise }; + writes: { expected: number; changes: number }[]; +} +const row = (f: Fixture) => f.sqlite.prepare('SELECT * FROM rooms WHERE code = ?').get(f.code); +function protectedRows(f: Fixture) { + assert.deepEqual(credentials(f.sqlite), f.credentialRows); + assert.deepEqual(f.sqlite.prepare('SELECT * FROM rooms WHERE code = ?').get(f.unrelatedCode), f.unrelatedRow); +} +function inventory(f: Fixture, g: Game) { + assert.deepEqual(physical(g), f.stock); + nexusInventory(g); + protectedRows(f); +} +async function authenticate(f: Fixture, id: string) { + const index = f.auths.findIndex(auth => auth.playerId === id); + assert.ok(index >= 0); + const auth = await f.restart().authenticate(f.code, f.tokens[index]); + // Authorization is the credential's values, not its realm/prototype or brand. + assert.equal(auth.playerId, id); + assert.equal(auth.tokenHash, f.auths[index].tokenHash); + return auth; +} +async function act(f: Fixture, id: string, action: Action) { + const rooms = f.restart(); + const current = await rooms.readRoom(f.code); + await rooms.act(f.code, await authenticate(f, id), current.version, action, clock); + return f.restart().readRoom(f.code); +} +const pass = (event: string): Action => ({ type: 'richeseBetrayalPass', event }); +const use = (event: string): Action => ({ type: 'richeseBetrayalUse', event }); +function publicReaction(g: Game, id: string) { + const reaction = viewGame(g, id).richeseBetrayalReaction; + assert.ok(reaction); + const { event, kind, target, buyer, source, price } = reaction; + return { event, kind, target, buyer, source, price }; +} + +async function restored(f: Fixture, g: Game) { + const before = row(f); + const writeCount = f.writes.length; + for (const [index, token] of f.tokens.entries()) { + const rooms = f.restart(); + const auth = await rooms.authenticate(f.code, token); + assert.equal(auth.playerId, f.auths[index].playerId); + assert.equal(auth.tokenHash, f.auths[index].tokenHash); + const view = await rooms.readSeatView(f.code, auth); + assert.deepEqual(plain(view), plain(viewGame(g, auth.playerId))); + assert.equal(view.richeseBetrayalPreview, true); + for (const key of ['pendingRicheseBetrayal', 'richeseBetrayal']) + assert.equal(Object.hasOwn(view, key), false, 'receipt/cursor stays private'); + for (const rival of view.players.filter(p => p.id !== auth.playerId)) { + assert.equal(Object.hasOwn(rival, 'hand'), false); + assert.equal(Object.hasOwn(rival, 'traitors'), false); + assert.equal(Object.hasOwn(rival, 'spice'), false); + } + if (view.richeseBetrayalReaction) { + assert.deepEqual(publicReaction(g, auth.playerId), publicReaction(g, f.holder)); + assert.deepEqual(Object.keys(view.richeseBetrayalReaction).sort(), [ + 'event', 'kind', 'target', 'buyer', 'source', 'price', + 'canPass', 'hasPassed', 'canUse', 'blocked', + ].sort()); + assert.equal(view.richeseBetrayalReaction.canUse, + auth.playerId === f.holder && g.nexusCards!.cards!.hands[f.holder] === 'richese' && + !view.richeseBetrayalReaction.hasPassed); + if (view.richeseBetrayalReaction.source === 'blackMarket' && auth.playerId !== f.target) { + assert.equal(view.richeseAuction?.card ?? null, null); + assert.equal(JSON.stringify(view).includes(f.card), false, 'concealed lot identity stays unentitled'); + } + } + } + await Promise.all([f.restart().continueRoomAutomatic(f.code, clock), + f.restart().continueRoomAutomatic(f.code, clock)]); + assert.deepEqual(row(f), before, 'refresh/recovery cannot acknowledge a human opportunity'); + assert.equal(f.writes.length, writeCount); + inventory(f, g); +} +async function passOthers(f: Fixture, g: Game) { + for (const p of g.players) { + if (p.id === f.holder) continue; + const reaction = viewGame(g, p.id).richeseBetrayalReaction; + if (reaction?.canPass) g = await act(f, p.id, pass(reaction.event)); + } + assert.equal(viewGame(g, f.holder).richeseBetrayalReaction?.canPass, true); + return g; +} +async function allPass(f: Fixture, g: Game) { + g = await passOthers(f, g); + await restored(f, g); + return act(f, f.holder, pass(f.event)); +} +function spentOnce(f: Fixture, g: Game) { + assert.equal(g.nexusCards!.cards!.hands[f.holder], null); + assert.equal(g.nexusCards!.cards!.discard.filter(card => card === 'richese').length, 1); + assert.equal(g.richeseBetrayal!.completed.length, 1); + assert.equal(g.richeseBetrayal!.completed[0].stage, 'used'); + assert.equal(viewGame(g, f.holder).richeseBetrayalReaction, null); +} +function vetoed(f: Fixture, g: Game) { + spentOnce(f, g); + assert.deepEqual(g.players.map(p => [p.id, p.spice]), f.initial.players.map(p => [p.id, p.spice])); + for (const p of g.players) assert.deepEqual(p.hand, player(f.initial, p.id).hand); + assert.equal(g.discard.filter(card => card.id === f.card).length, 1); + assert.equal(g.richeseCache!.some(card => card.id === f.card), false); + assert.notEqual(g.richeseAuction?.event, f.initial.richeseAuction!.event); + inventory(f, g); +} +function sold(f: Fixture, g: Game, diverted: boolean, allyPayment = 0) { + const invoice = publicReaction(f.initial, f.holder); + assert.equal(player(g, f.buyer).spice, player(f.initial, f.buyer).spice - invoice.price + allyPayment); + assert.equal(player(g, f.target).spice, player(f.initial, f.target).spice + (diverted ? 0 : invoice.price)); + if (allyPayment) { + const ally = player(f.initial, f.buyer).ally!; + assert.equal(player(g, ally).spice, player(f.initial, ally).spice); + assert.equal(g.aid[ally].amount, f.initial.aid[ally].amount - allyPayment); + } + for (const p of g.players) { + if ([f.buyer, f.target, player(f.initial, f.buyer).ally].includes(p.id)) continue; + assert.equal(p.spice, player(f.initial, p.id).spice); + } + const total = (game: Game) => game.players.reduce((sum, p) => sum + p.spice, 0) + + Object.values(game.aid).reduce((sum, credit) => sum + credit.amount, 0); + assert.equal(total(g), total(f.initial) - (diverted ? invoice.price : 0), + 'bank receives the original payment, not a second charge'); + const buyer = player(g, f.buyer); + const bonus = f.initial.deck[0].id; + assert.equal(buyer.faction, 'harkonnen'); + assert.deepEqual(buyer.hand.map(card => card.id).sort(), + [...player(f.initial, f.buyer).hand.map(card => card.id), f.card, bonus].sort()); + assert.equal(g.deck.some(card => card.id === bonus), false); + assert.equal(player(g, f.target).hand.some(card => card.id === f.card), false); + assert.notEqual(g.richeseAuction?.event, f.initial.richeseAuction!.event); + inventory(f, g); +} + +for (const advanced of [false, true]) { + void test(`SQLite saved ${advanced ? 'Advanced' : 'Basic'} cache veto discards its exact lot without debit or acquisition`, async t => { + const f = await fixture(t, 'purchase', { advanced }); + await restored(f, f.initial); + assert.equal(player(f.initial, f.target).hand.some(card => card.id === f.card), false); + assert.equal(f.initial.richeseCache!.some(card => card.id === f.card), true); + const done = await act(f, f.holder, use(f.event)); + assert.equal(done.version, f.initial.version + 1); + vetoed(f, done); + await restored(f, done); + const before = row(f), writes = f.writes.length; + await assert.rejects(f.restart().act(f.code, await authenticate(f, f.holder), done.version, use(f.event), clock)); + assert.deepEqual(row(f), before); + assert.equal(f.writes.length, writes); + }); +} + +for (const scenario of [ + { source: 'cache', advanced: false, allyPayment: 0 }, + { source: 'cache', advanced: true, allyPayment: 2 }, + { source: 'blackMarket', advanced: true, allyPayment: 2 }, +] as const) { + void test(`SQLite saved ${scenario.advanced ? 'Advanced' : 'Basic'} ${scenario.source} sale diversion preserves original payers, card and one Harkonnen bonus`, async t => { + const f = await fixture(t, 'sale', scenario); + await restored(f, f.initial); + const done = await act(f, f.holder, use(f.event)); + spentOnce(f, done); + sold(f, done, true, scenario.allyPayment); + await restored(f, done); + const before = row(f), writes = f.writes.length; + for (const action of [use(f.event), pass(f.event)]) + await assert.rejects(f.restart().act(f.code, await authenticate(f, f.holder), done.version, action, clock)); + assert.deepEqual(row(f), before); + assert.equal(f.writes.length, writes); + }); +} + +for (const kind of ['purchase', 'sale'] as const) { + void test(`SQLite saved all-pass ${kind} performs the original quoted settlement once without consuming Nexus`, async t => { + const f = await fixture(t, kind); + const invoice = publicReaction(f.initial, f.holder); + const done = await allPass(f, f.initial); + assert.equal(done.nexusCards!.cards!.hands[f.holder], 'richese'); + assert.equal(done.nexusCards!.cards!.discard.includes('richese'), false); + assert.equal(viewGame(done, f.holder).richeseBetrayalReaction, null); + assert.equal(done.richeseBetrayal!.completed.length, 1); + assert.equal(done.richeseBetrayal!.completed[0].stage, 'passed'); + if (kind === 'sale') sold(f, done, false); + else { + assert.equal(player(done, f.target).spice, player(f.initial, f.target).spice - invoice.price); + assert.deepEqual(player(done, f.target).hand.map(card => card.id).sort(), + [...player(f.initial, f.target).hand.map(card => card.id), f.card].sort()); + assert.equal(done.discard.some(card => card.id === f.card), false); + assert.equal(done.richeseCache!.some(card => card.id === f.card), false); + for (const p of done.players.filter(p => p.id !== f.target)) + assert.equal(p.spice, player(f.initial, p.id).spice); + inventory(f, done); + } + await restored(f, done); + const before = row(f), writes = f.writes.length; + await assert.rejects(f.restart().act(f.code, await authenticate(f, f.holder), done.version, pass(f.event), clock)); + assert.deepEqual(row(f), before); + assert.equal(f.writes.length, writes); + }); +} + +for (const kind of ['purchase', 'sale'] as const) { + void test(`SQLite competing final pass/use ${kind} CAS commits one original settlement after restart`, async t => { + const f = await fixture(t, kind); + const pending = await passOthers(f, f.initial); + await restored(f, pending); + f.writes.length = 0; + let arrivals = 0; + let release!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + f.hooks.beforeWrite = async () => { + if (++arrivals === 2) release(); + await gate; + }; + let results: PromiseSettledResult[]; + try { + const auth = await authenticate(f, f.holder); + results = await Promise.allSettled([pass(f.event), use(f.event)].map(action => + f.restart().act(f.code, auth, pending.version, action, clock).finally(release))); + } finally { + delete f.hooks.beforeWrite; + } + assert.equal(arrivals, 2); + assert.equal(results.filter(result => result.status === 'fulfilled').length, 1); + assert.equal(results.filter(result => result.status === 'rejected').length, 1); + assert.deepEqual(f.writes.map(write => write.changes).sort((a,b) => a - b), [0, 1]); + const done = await f.restart().readRoom(f.code); + assert.equal(done.version, pending.version + 1); + const used = results[1].status === 'fulfilled'; + assert.equal(done.nexusCards!.cards!.hands[f.holder], used ? null : 'richese'); + assert.equal(done.richeseBetrayal!.completed.length, 1); + assert.equal(done.richeseBetrayal!.completed[0].stage, used ? 'used' : 'passed'); + if (used) spentOnce(f, done); + if (kind === 'sale') sold(f, done, used); + else if (used) vetoed(f, done); + else { + assert.equal(player(done, f.target).spice, + player(f.initial, f.target).spice - publicReaction(f.initial, f.holder).price); + assert.equal(player(done, f.target).hand.filter(card => card.id === f.card).length, 1); + } + await restored(f, done); + }); +} + +void test('SQLite stale, foreign-seat, forged invoice and wrong-event reactions reject before writes and preserve real credentials', async t => { + const f = await fixture(t, 'purchase'); + const rooms = f.restart(); + const before = row(f); + const holder = await authenticate(f, f.holder); + const foreign = await rooms.authenticate(f.unrelatedCode, f.unrelatedToken); + await assert.rejects(rooms.authenticate(f.code, f.unrelatedToken)); + await assert.rejects(rooms.readSeatView(f.code, foreign)); + await assert.rejects(rooms.readSeatView(f.code, { playerId: f.holder, tokenHash: foreign.tokenHash })); + const requests: { auth: SeatAuth; version: number; action: Action }[] = [ + { auth: holder, version: f.initial.version - 1, action: use(f.event) }, + { auth: foreign, version: f.initial.version, action: use(f.event) }, + { auth: await authenticate(f, f.target), version: f.initial.version, action: use(f.event) }, + { auth: holder, version: f.initial.version, action: use(`${f.event}:stale`) }, + { auth: holder, version: f.initial.version, action: pass(`${f.event}:stale`) }, + { auth: holder, version: f.initial.version, + action: { ...use(f.event), card: f.card, price: 0, recipient: f.holder } as Action }, + ]; + for (const request of requests) { + await assert.rejects(rooms.act(f.code, request.auth, request.version, request.action, clock)); + assert.deepEqual(row(f), before); + assert.equal(f.writes.length, 0); + protectedRows(f); + } + const pending = await passOthers(f, f.initial); + const saved = row(f), writes = f.writes.length; + if (pending.version !== f.initial.version) + await assert.rejects(rooms.act(f.code, holder, f.initial.version, use(f.event), clock)); + const otherReaction = viewGame(pending, f.other).richeseBetrayalReaction; + if (otherReaction?.hasPassed) + await assert.rejects(rooms.act(f.code, await authenticate(f, f.other), pending.version, pass(f.event), clock)); + assert.deepEqual(row(f), saved); + assert.equal(f.writes.length, writes); + await restored(f, pending); +}); + +void test('SQLite neutral acknowledgement and all-seat public invoice survive hidden relevant/nonrelevant Nexus substitution', async t => { + const f = await fixture(t, 'sale'); + const irrelevant = structuredClone(f.initial); + const cards = irrelevant.nexusCards!.cards!; + const replacement = cards.deck.indexOf('choam'); + assert.ok(replacement >= 0); + assert.equal(cards.hands[f.holder], 'richese'); + cards.hands[f.holder] = 'choam'; + cards.deck[replacement] = 'richese'; + f.save(irrelevant); + f.writes.length = 0; + await restored(f, irrelevant); + for (const auth of f.auths) { + const original = viewGame(f.initial, auth.playerId).richeseBetrayalReaction!; + const changed = (await f.restart().readSeatView(f.code, + await authenticate(f, auth.playerId))).richeseBetrayalReaction!; + assert.deepEqual(publicReaction(irrelevant, auth.playerId), publicReaction(f.initial, auth.playerId)); + assert.equal(changed.canPass, original.canPass); + assert.equal(changed.hasPassed, original.hasPassed); + assert.equal(changed.canUse, false); + if (auth.playerId !== f.holder) assert.deepEqual(plain(changed), plain(original)); + } + const saved = row(f); + await assert.rejects(f.restart().act(f.code, await authenticate(f, f.holder), + irrelevant.version, use(f.event), clock)); + assert.deepEqual(row(f), saved); + assert.equal(f.writes.length, 0); + const done = await allPass(f, irrelevant); + sold(f, done, false); + assert.equal(done.nexusCards!.cards!.hands[f.holder], 'choam'); + await restored(f, done); +}); + +for (const kind of ['purchase', 'sale'] as const) { + void test(`SQLite corrupt/orphaned saved ${kind} receipts reject all-seat disclosure and settlement before writes`, async t => { + const f = await fixture(t, kind); + const mutations: { name: string; change(this: void, g: Game): void }[] = [ + { name: 'deleted frame with independent pending cursor', change: g => { delete g.pendingRicheseBetrayal; } }, + { name: 'deleted cursor with orphan frame', change: g => { delete g.richeseBetrayal; } }, + { name: 'detached cursor event', change: g => { g.richeseBetrayal!.current = null; } }, + { name: 'lost pending sequence', change: g => { g.richeseBetrayal!.sequence = 0; } }, + { name: 'changed public price', change: g => { g.pendingRicheseBetrayal!.receipt.invoice.price++; } }, + { 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: '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 => { + if (kind === 'purchase') { + const index = g.richeseCache!.findIndex(card => card.id === f.card); + assert.ok(index >= 0); + player(g, f.other).hand.push(...g.richeseCache!.splice(index, 1)); + } else { + const owner = player(g, f.target); + const index = owner.hand.findIndex(card => card.id === f.card); + assert.ok(index >= 0); + player(g, f.other).hand.push(...owner.hand.splice(index, 1)); + } + } }, + ]; + for (const { name, change } of mutations) { + const corrupt = structuredClone(f.initial); + change(corrupt); + f.save(corrupt); + f.writes.length = 0; + const saved = row(f); + for (const auth of f.auths) + await assert.rejects(f.restart().readSeatView(f.code, + await authenticate(f, auth.playerId)), name); + for (const action of [pass(f.event), use(f.event)]) + await assert.rejects(f.restart().act(f.code, await authenticate(f, f.holder), + corrupt.version, action, clock), name); + await Promise.allSettled([f.restart().continueRoomAutomatic(f.code, clock)]); + assert.deepEqual(row(f), saved, name); + assert.equal(f.writes.length, 0, name); + protectedRows(f); + } + }); +} + +void test('SQLite completed veto history survives later physical card movement but not deleted or corrupted completion evidence', async t => { + const f = await fixture(t, 'purchase'); + const done = await act(f, f.holder, use(f.event)); + vetoed(f, done); + const later = structuredClone(done); + // Model later component recycling without recreating or copying a component. + const treacheryIndex = later.discard.findIndex(card => card.id === f.card); + assert.ok(treacheryIndex >= 0); + later.deck.push(...later.discard.splice(treacheryIndex, 1)); + const cards = later.nexusCards!.cards!; + const nexusIndex = cards.discard.indexOf('richese'); + assert.ok(nexusIndex >= 0); + cards.deck.push(...cards.discard.splice(nexusIndex, 1)); + f.save(later); + f.writes.length = 0; + await restored(f, later); + assert.equal(later.richeseBetrayal!.completed[0].stage, 'used'); + const saved = row(f); + await assert.rejects(f.restart().act(f.code, await authenticate(f, f.holder), + later.version, use(f.event), clock)); + assert.deepEqual(row(f), saved); + assert.equal(f.writes.length, 0); + for (const change of [ + (g: Game) => { g.richeseBetrayal!.completed = []; }, + (g: Game) => { g.richeseBetrayal!.completed[0].invoice.price++; }, + (g: Game) => { delete g.richeseBetrayal; }, + ]) { + const corrupt = structuredClone(later); + change(corrupt); + f.save(corrupt); + f.writes.length = 0; + const before = row(f); + for (const auth of f.auths) + await assert.rejects(f.restart().readSeatView(f.code, await authenticate(f, auth.playerId))); + await assert.rejects(f.restart().act(f.code, await authenticate(f, f.holder), + corrupt.version, use(f.event), clock)); + assert.deepEqual(row(f), before); + assert.equal(f.writes.length, 0); + protectedRows(f); + } +}); diff --git a/tests/nexus-richese-betrayal.test.ts b/tests/nexus-richese-betrayal.test.ts new file mode 100644 index 00000000..58d4825a --- /dev/null +++ b/tests/nexus-richese-betrayal.test.ts @@ -0,0 +1,237 @@ +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 { createRicheseAuction, submitRicheseBid } from '../game/richese-auction'; +import { richeseCards } from '../game/richese-cards'; +import { quoteRicheseSettlement } from '../game/richese-settlement'; +import { + createRicheseBetrayalReceipt, quoteRicheseBetrayalInvoice, + requiredRicheseBetrayalResponders, richeseBetrayalEligibility, + richeseBetrayalEvent, validateRicheseBetrayalCursor, validateRicheseBetrayalReceipt, + RicheseBetrayalError, + type RicheseBetrayalReceipt, +} from '../game/nexus-richese-betrayal'; + +const players: NexusPlayer[] = [ + { id: 'seller', faction: 'richese' }, + { id: 'buyer', faction: 'atreides' }, + { id: 'holder', faction: 'fremen' }, + { id: 'emperor', faction: 'emperor' }, +]; +const context = { turn: 2, players }; + +function soldInput(source: 'cache' | 'blackMarket' = 'cache', self = false, allyPayment = 0) { + const card: Card = source === 'cache' ? richeseCards()[0] : { id: 'base-weapon', name: 'Maula Pistol', kind: 'projectile' }; + const winner = self ? 'seller' : 'buyer'; + let lot = createRicheseAuction({ + event: 'lot-2', cardId: card.id, owner: 'seller', source, method: 'silent', + eligible: ['buyer', 'seller'], order: ['buyer', 'seller'], tieOrder: ['buyer', 'seller'], + }); + lot = submitRicheseBid(lot, { event: lot.event, actor: 'buyer', amount: self ? 0 : 5 }, 5); + lot = submitRicheseBid(lot, { event: lot.event, actor: 'seller', amount: self ? 5 : 0 }, 5); + const roster = allyPayment ? players.map(p => ({ ...p, + ...(p.id === winner ? { ally: 'emperor' } : p.id === 'emperor' ? { ally: winner } : {}), + })) : players; + const quote = quoteRicheseSettlement({ + lot, owner: { id: 'seller', handCount: 0, handLimit: 4 }, + winner: { id: winner, handCount: 0, handLimit: 4, spice: 5 - allyPayment }, + card, funding: { amount: 5, allyPayment, donor: allyPayment ? 'emperor' : null }, + allyCredit: { amount: allyPayment }, + }); + assert.equal(quote.kind, 'sold'); + if (quote.kind !== 'sold') throw new Error('Fixture must produce a funded sold invoice.'); + return { players: roster, lot, quote, donor: allyPayment ? 'emperor' : null, + originalRecipient: (self ? 'emperor' : 'seller') as string | null, sourceCards: [card], physicalCards: [card] }; +} +function receipt(stage: 'pending' | 'passed' | 'used' = 'used'): RicheseBetrayalReceipt { + return createRicheseBetrayalReceipt(context, { + event: richeseBetrayalEvent(2, 1, 'round-2', 'lot-2'), turn: 2, sequence: 1, + round: 'round-2', lot: 'lot-2', invoice: quoteRicheseBetrayalInvoice(soldInput()), + required: ['holder', 'buyer'], passed: stage === 'passed' ? ['holder', 'buyer'] : ['buyer'], + stage, actor: stage === 'used' ? 'holder' : null, nexusDiscardIndex: stage === 'used' ? 0 : null, + }); +} +function cardsFor(holderCard: typeof NEXUS_FACTIONS[number]): NexusState { + return { version: 1, deck: NEXUS_FACTIONS.filter(card => card !== holderCard), discard: [], + hands: { seller: null, buyer: null, holder: holderCard, emperor: null } }; +} + +void test('public self-cache invoice remains distinct from an income-bearing sale', () => { + const self = quoteRicheseBetrayalInvoice(soldInput('cache', true)); + assert.equal(self.kind, 'purchase'); + assert.equal(self.buyer, self.target); + assert.equal(self.originalRecipient, 'emperor'); + const sale = quoteRicheseBetrayalInvoice(soldInput('cache', false, 3)); + assert.equal(sale.kind, 'sale'); + assert.equal(sale.originalRecipient, sale.target); + assert.equal(sale.price, 5); + assert.equal(sale.ownPayment, 2); + assert.equal(sale.allyPayment, 3); + assert.equal(sale.donor, 'emperor'); + const bank = soldInput('cache', true); + bank.players = bank.players.filter(p => p.id !== 'emperor'); + bank.originalRecipient = null; + assert.equal(quoteRicheseBetrayalInvoice(bank).originalRecipient, null); +}); + +void test('concealed Black Market sale needs no Richese-family face but cannot become a self-purchase', () => { + const input = soldInput('blackMarket'); + const invoice = quoteRicheseBetrayalInvoice(input); + assert.equal(invoice.source, 'blackMarket'); + assert.equal(invoice.kind, 'sale'); + assert.equal(invoice.card.id, 'base-weapon'); + const self = structuredClone(input); + self.lot.outcome = { kind: 'sold', winner: 'seller', amount: 5 }; + self.quote.winner = 'seller'; + assert.throws(() => quoteRicheseBetrayalInvoice(self), RicheseBetrayalError); +}); + +void test('quote rejects changed custody, substituted face, wrong invoice recipient or noncanonical cache', () => { + const input = soldInput(); + const reject = (patch: Partial) => { + const changed = { ...structuredClone(input), ...patch }; + const before = JSON.stringify(changed); + assert.throws(() => quoteRicheseBetrayalInvoice(changed), RicheseBetrayalError); + assert.equal(JSON.stringify(changed), before); + }; + reject({ sourceCards: [] }); + reject({ physicalCards: [] }); + reject({ physicalCards: [input.quote.card, input.quote.card] }); + reject({ sourceCards: [{ ...input.quote.card, name: 'Wrong face' }] }); + reject({ originalRecipient: 'emperor' }); + reject({ donor: 'holder' }); + reject({ quote: { ...input.quote, ownPayment: 4 } }); + reject({ quote: { ...input.quote, amount: 6 } }); + const counterfeit = { ...input.quote.card, effect: 'counterfeit' }; + reject({ quote: { ...input.quote, card: counterfeit }, sourceCards: [counterfeit], physicalCards: [counterfeit] }); + reject({ lot: { ...input.lot, method: 'normal' } }); +}); + +void test('exact contribution split binds a real reciprocal ally, not an unrelated funding donor', () => { + const input = soldInput('blackMarket', false, 5); + const invoice = quoteRicheseBetrayalInvoice(input); + assert.equal(invoice.ownPayment, 0); + assert.equal(invoice.allyPayment, invoice.price); + const unrelated = { ...input, donor: 'holder' }; + assert.throws(() => quoteRicheseBetrayalInvoice(unrelated), /unallied contributor/); + const overspend = { ...input, quote: { ...input.quote, ownPayment: 0, allyPayment: 6 } }; + assert.throws(() => quoteRicheseBetrayalInvoice(overspend), RicheseBetrayalError); + const fractional = { ...input, quote: { ...input.quote, ownPayment: 0.5, allyPayment: 4.5 } }; + assert.throws(() => quoteRicheseBetrayalInvoice(fractional), RicheseBetrayalError); +}); + +void test('price boundaries preserve a whole positive invoice without overflow or zero-result reinterpretation', () => { + for (const price of [1, Number.MAX_SAFE_INTEGER]) { + const input = soldInput(); + input.lot.outcome = { kind: 'sold', winner: 'buyer', amount: price }; + input.quote.amount = price; + input.quote.ownPayment = price; + const invoice = quoteRicheseBetrayalInvoice(input); + assert.equal(invoice.price, price); + assert.equal(invoice.ownPayment, price); + } + for (const price of [0, -1, 1.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1]) { + const input = soldInput(); + input.lot.outcome = { kind: 'sold', winner: 'buyer', amount: price }; + input.quote.amount = price; + input.quote.ownPayment = price; + assert.throws(() => quoteRicheseBetrayalInvoice(input), RicheseBetrayalError); + } +}); + +void test('neutral responders depend on public held-presence and alliances, not private identity', () => { + const publicSeats = players.map(p => ({ ...p, nexusHeld: p.id === 'holder' || p.id === 'seller' })); + assert.deepEqual(requiredRicheseBetrayalResponders(publicSeats), ['holder']); + const relevant = cardsFor('richese'); + const unrelated = cardsFor('guild'); + assert.equal(richeseBetrayalEligibility({ players, cards: relevant }, 'holder'), null); + assert.match(richeseBetrayalEligibility({ players, cards: unrelated }, 'holder')!, /real Richese Nexus/); + // Both secret variants have identical held-presence and therefore neutral timing. + for (const cards of [relevant, unrelated]) { + assert.deepEqual(requiredRicheseBetrayalResponders(players.map(p => ({ ...p, nexusHeld: cards.hands[p.id] !== null }))), ['holder']); + } + const allied = players.map(p => ({ ...p, + ...(p.id === 'holder' ? { ally: 'buyer' } : p.id === 'buyer' ? { ally: 'holder' } : {}), + })); + assert.deepEqual(requiredRicheseBetrayalResponders(allied.map(p => ({ ...p, nexusHeld: true }))), ['emperor']); + assert.match(richeseBetrayalEligibility({ players: allied, cards: relevant }, 'holder')!, /allied player/); + const native = cardsFor('richese'); + native.hands.holder = null; + native.hands.seller = 'richese'; + assert.match(richeseBetrayalEligibility({ players, cards: native }, 'seller')!, /another faction/); +}); + +void test('physical singleton Nexus custody and actual native roster determine eligibility', () => { + const cards = cardsFor('richese'); + const duplicate = structuredClone(cards); + duplicate.deck.push('richese'); + const before = JSON.stringify(duplicate); + assert.throws(() => richeseBetrayalEligibility({ players, cards: duplicate }, 'holder'), RicheseBetrayalError); + assert.equal(JSON.stringify(duplicate), before); + assert.throws(() => richeseBetrayalEligibility({ players: players.filter(p => p.id !== 'seller'), cards }, 'holder'), /native Richese/); +}); + +void test('completed history survives subsequent card custody and alliances, but preserves original invoice', () => { + const record = receipt(); + const later = { turn: 8, players: players.map(p => ({ ...p, + ...(p.id === 'holder' ? { ally: 'buyer' } : p.id === 'buyer' ? { ally: 'holder' } : {}), + })) }; + // No current Treachery or Nexus zone is an input to intrinsic history validation. + validateRicheseBetrayalReceipt(later, JSON.parse(JSON.stringify(record)) as RicheseBetrayalReceipt); + assert.equal(record.invoice.originalRecipient, 'seller'); + assert.equal(record.invoice.price, 5); + const changed = structuredClone(record); + changed.invoice.originalRecipient = null; + assert.throws(() => validateRicheseBetrayalReceipt(later, changed), RicheseBetrayalError); + assert.throws(() => createRicheseBetrayalReceipt(later, { ...record, invoice: changed.invoice }), RicheseBetrayalError); +}); + +void test('receipt construction detaches accepted physical and acknowledgement evidence', () => { + const input = receipt('pending'); + const saved = createRicheseBetrayalReceipt(context, input); + input.invoice.card.name = 'Moved evidence'; + input.invoice.price = 99; + input.required.push('emperor'); + input.passed.length = 0; + assert.equal(saved.invoice.card.name, 'Ornithopter'); + assert.equal(saved.invoice.price, 5); + assert.deepEqual(saved.required, ['holder', 'buyer']); + assert.deepEqual(saved.passed, ['buyer']); +}); + +void test('lifecycle rejects extra keys, impossible histories, stale pending and a double spend', () => { + const record = receipt(); + const invalid = (patch: Partial) => { + const input = { ...structuredClone(record), ...patch }; + const before = JSON.stringify(input); + assert.throws(() => createRicheseBetrayalReceipt(context, input), RicheseBetrayalError); + assert.equal(JSON.stringify(input), before); + }; + invalid({ actor: 'buyer' }); + invalid({ actor: 'seller' }); + invalid({ nexusDiscardIndex: -1 }); + invalid({ sequence: 0 }); + invalid({ turn: 3 }); + invalid({ required: ['holder', 'holder'] }); + invalid({ passed: ['emperor'] }); + invalid({ stage: 'passed', actor: null, nexusDiscardIndex: null }); + invalid({ stage: 'pending', actor: null, nexusDiscardIndex: null, passed: ['holder', 'buyer'] }); + const extra = { ...record, rawAction: { type: 'bid' } }; + assert.throws(() => createRicheseBetrayalReceipt(context, extra), RicheseBetrayalError); + assert.throws(() => validateRicheseBetrayalReceipt({ ...context, turn: 3 }, receipt('pending')), RicheseBetrayalError); +}); + +void test('independent cursor fails closed for deleted, orphaned, replayed or missing completed frames', () => { + const pending = receipt('pending'); + const open = { sequence: 1, current: pending.event, completed: [] }; + validateRicheseBetrayalCursor(context, open, pending); + assert.throws(() => validateRicheseBetrayalCursor(context, open, null), RicheseBetrayalError); + assert.throws(() => validateRicheseBetrayalCursor(context, { ...open, current: null }, pending), RicheseBetrayalError); + const completed = receipt('passed'); + const closed = { sequence: 1, current: null, completed: [completed] }; + validateRicheseBetrayalCursor(context, closed, null); + assert.throws(() => validateRicheseBetrayalCursor(context, { ...closed, completed: [] }, null), RicheseBetrayalError); + assert.throws(() => validateRicheseBetrayalCursor(context, { ...closed, completed: [pending] }, null), RicheseBetrayalError); + assert.throws(() => validateRicheseBetrayalCursor(context, { sequence: 2, current: null, completed: [completed, completed] }, null), RicheseBetrayalError); +}); diff --git a/tests/prototype-room.test.ts b/tests/prototype-room.test.ts index 3a4ab552..8777e621 100644 --- a/tests/prototype-room.test.ts +++ b/tests/prototype-room.test.ts @@ -278,3 +278,26 @@ void test('Kull preview starts a fresh ready CHOAM lobby once and preserves othe assert.deepEqual(db.prepare('SELECT * FROM seats').all(), seats); assert.deepEqual(db.prepare('SELECT * FROM rooms WHERE code=?').get('KEEPME00'), other); }); + +void test('Richese Betrayal profile initializes one fresh paired Nexus game and preserves seats and other saves', (t) => { + const { db } = fixture(t); + const game = createGame('PROTOTYP', newPlayer('r', 'Richese', 'richese'), false, ['choam']); + joinGame(game, newPlayer('c', 'CHOAM', 'choam')); + for (const p of game.players) p.ready = true; + db.prepare('UPDATE rooms SET state=? WHERE code=?').run(JSON.stringify(game), game.code); + const seats = db.prepare('SELECT * FROM seats').all(); + const other = db.prepare('SELECT * FROM rooms WHERE code=?').get('KEEPME00'); + const result = startPrototypeRoom(db, game.code, 7, 'richese-betrayal'); + const saved = JSON.parse(db.prepare('SELECT state FROM rooms WHERE code=?').get(game.code)!.state as string) as Game; + assert.equal(result.status, 'setup'); + assert.equal(saved.richeseBetrayalPreview, true); + assert.equal(saved.nexusCards!.cards!.deck.length, 12); + assert.equal(new Set(saved.nexusCards!.cards!.deck).size, 12); + assert.equal(saved.richeseCache?.length, 10); + const rows = db.prepare('SELECT * FROM rooms ORDER BY code').all(); + assert.throws(() => startPrototypeRoom(db, game.code, 7, 'richese-betrayal')); + assert.throws(() => startPrototypeRoom(db, game.code, 8, 'richese-betrayal')); + assert.deepEqual(db.prepare('SELECT * FROM rooms ORDER BY code').all(), rows); + assert.deepEqual(db.prepare('SELECT * FROM seats').all(), seats); + assert.deepEqual(db.prepare('SELECT * FROM rooms WHERE code=?').get('KEEPME00'), other); +}); diff --git a/tests/table-turn.test.ts b/tests/table-turn.test.ts index ed37eefb..b2734829 100644 --- a/tests/table-turn.test.ts +++ b/tests/table-turn.test.ts @@ -10,6 +10,7 @@ import { } from '../game/engine'; import { tableActionOwner } from '../game/table-turn'; import { choamKullGame, kullShipmentAttempt } from './fixture-choam-kull'; +import { createRicheseBetrayalFixture } from './fixture-richese-betrayal'; function battle(advanced = false) { let g = createGame( @@ -220,3 +221,17 @@ void test('real Kull offer gives CHOAM public decision ownership before the susp assert.equal(counter.response?.kind, 'choamWorthless'); assert.deepEqual(owners(counter), counter.players.map(() => null)); }); + +void test('neutral Richese Betrayal acknowledgement preempts an underlying buyer decision in every seat view', () => { + for (const kind of ['purchase', 'sale'] as const) { + const fixture = createRicheseBetrayalFixture(kind); + for (const player of fixture.game.players) { + const view = viewGame(fixture.game, player.id); + assert.ok(view.richeseBetrayalReaction); + assert.equal(tableActionOwner({ + ...view, active: fixture.buyer, + decision: { kind: 'auctionPayment', player: fixture.buyer }, + }), null); + } + } +}); diff --git a/tools/prototype-room.ts b/tools/prototype-room.ts index 765894e1..dbc976b2 100644 --- a/tools/prototype-room.ts +++ b/tools/prototype-room.ts @@ -9,6 +9,7 @@ import { initializeEcazTreacheryGameForAudit, initializeSemutaGameForAudit, initializeKullGameForAudit, + initializeRicheseBetrayalGameForAudit, viewGame, type Game, } from '../game/engine'; @@ -23,6 +24,7 @@ export const PROTOTYPE_PROFILES = [ 'ecaz-treachery', 'semuta', 'kull', + 'richese-betrayal', ] as const; export type PrototypeProfile = (typeof PROTOTYPE_PROFILES)[number]; export function isPrototypeProfile(value: string): value is PrototypeProfile { @@ -73,6 +75,11 @@ export function startPrototypeRoom( ? initializeSemutaGameForAudit(initial) : profile === 'kull' ? initializeKullGameForAudit(initial) + : profile === 'richese-betrayal' + ? initializeRicheseBetrayalGameForAudit({ + ...initial, + nexusCards: initial.nexusCards ?? { cards: null, phase: null }, + }) : profile === 'factions' ? initializeFactionExpansionsGameForAudit(initial) : profile === 'nexus' diff --git a/tools/start-prototype.ts b/tools/start-prototype.ts index c7a1f394..af73dcd4 100644 --- a/tools/start-prototype.ts +++ b/tools/start-prototype.ts @@ -20,7 +20,7 @@ async function main() { }); if (values.help) { console.log( - 'Usage: node --import tsx tools/start-prototype.ts --profile ix|discovery|leader-skills|factions|nexus|moritani-assassinate|ecaz-treachery|semuta|kull --db PATH --room CODE --version NUMBER --out /private/new-directory\nStarts only a fresh ready lobby for the selected development prototype. Nexus enables the existing private Nexus setup. Moritani-assassinate explicitly opts into the bounded Advanced assassination preview. Semuta opts a fresh Richese roster into bounded clean-discard reactions; an included Ix deck supplies physical Thumper without opening public starts. Kull opts a fresh eligible CHOAM roster into the user-selected Karama interception preview. Backs up all rooms first, preserves sessions and existing games, and rejects stale versions. Normal game-start and publication gates remain closed.', + 'Usage: node --import tsx tools/start-prototype.ts --profile ix|discovery|leader-skills|factions|nexus|moritani-assassinate|ecaz-treachery|semuta|kull|richese-betrayal --db PATH --room CODE --version NUMBER --out /private/new-directory\nStarts only a fresh ready lobby for the selected development prototype. Nexus enables the existing private Nexus setup. Moritani-assassinate explicitly opts into the bounded Advanced assassination preview. Semuta opts a fresh Richese roster into bounded clean-discard reactions; an included Ix deck supplies physical Thumper without opening public starts. Kull opts a fresh eligible CHOAM roster into the user-selected Karama interception preview. Richese-betrayal opts a fresh paired CHOAM/Richese Nexus roster into cache-purchase veto and sale-bank diversion. Backs up all rooms first, preserves sessions and existing games, and rejects stale versions. Normal game-start and publication gates remain closed.', ); return; }