diff --git a/components/game-table.tsx b/components/game-table.tsx index 35d5dd62..7bf3a0e9 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -45,6 +45,7 @@ import { NexusMoritaniBetrayal } from './nexus-moritani-betrayal'; import { buildRevision, buildRevisionLabel } from '@/lib/build-revision'; import { NexusEcazBetrayal } from './nexus-ecaz-betrayal'; import { NexusFremenBetrayal } from './nexus-fremen-betrayal'; +import { NexusFremenRideBetrayal } from './nexus-fremen-ride-betrayal'; import { NexusEcazInquiry } from './nexus-ecaz-inquiry'; import { NexusEcazDuke } from './nexus-ecaz-duke'; import { NexusTleilaxu } from './nexus-tleilaxu'; @@ -347,8 +348,8 @@ export function GameTable({ const harkonnenBetrayalOwnsControls = !!g.nexusHarkonnenBetrayalReaction && !g.response && !g.decision && !g.truthtrance && !g.phaseOpening && !g.automaticContinuationPending && !g.nexusCards?.waiting.length && !g.nexusTraitors?.pending; - const busy = reactionBusy || g.automaticContinuationPending || !!g.semutaReaction || !!g.kullReaction || !!g.richeseBetrayalReaction || !!g.guildBetrayalReaction || !!g.nexusIxianReplacement || ixianBetrayalOwnsControls || harkonnenBetrayalOwnsControls; - const reactionOwnsControls = !!g.kullReaction || !!g.kullCounterEvent || !!g.richeseBetrayalReaction || !!g.guildBetrayalReaction || !!g.nexusIxianReplacement || ixianBetrayalOwnsControls || harkonnenBetrayalOwnsControls; + const busy = reactionBusy || !!g.nexusFremenRideBetrayal || g.automaticContinuationPending || !!g.semutaReaction || !!g.kullReaction || !!g.richeseBetrayalReaction || !!g.guildBetrayalReaction || !!g.nexusIxianReplacement || ixianBetrayalOwnsControls || harkonnenBetrayalOwnsControls; + const reactionOwnsControls = !!g.nexusFremenRideBetrayal || !!g.kullReaction || !!g.kullCounterEvent || !!g.richeseBetrayalReaction || !!g.guildBetrayalReaction || !!g.nexusIxianReplacement || ixianBetrayalOwnsControls || harkonnenBetrayalOwnsControls; const canSpendKarama = (card: Card, purpose: KullPurpose) => !kullPurposeBlock(g, purpose) && canUseAsKaramaRole(g, me, card); const canActivateKarama = (card: Card) => canSpendKarama(card, kullSpecialPurpose(me.faction)); @@ -1974,6 +1975,7 @@ export function GameTable({ {g.battle?.revealed && Compare revealed battle plans} + {!reactionOwnsControls && g.semutaReaction && } diff --git a/components/nexus-fremen-betrayal.tsx b/components/nexus-fremen-betrayal.tsx index 1c6f6c0c..cf981f78 100644 --- a/components/nexus-fremen-betrayal.tsx +++ b/components/nexus-fremen-betrayal.tsx @@ -18,7 +18,7 @@ export function NexusFremenBetrayal({ game, act, busy }: Props) {

{nexusCardReference('fremen').betrayal}

{fremen &&

{offer.mode === 'worm' - ? `Declare before the first spice blow: ${fremen.name} cannot take ordinary or Cunning worm rides this turn. Worm destruction, survival, protection and Nexus still resolve.` + ? `Declare before the first spice blow, or respond at an actual before-ride opportunity: ${fremen.name} cannot take remaining ordinary or Cunning worm rides this turn. Worm destruction, survival, protection and Nexus still resolve.` : `This use suppresses ${fremen.name}’s native two-territory movement advantage for the rest of this turn. Independent ornithopters remain available.`}

} {offer.mode === 'worm' && game.discoveries &&

diff --git a/components/nexus-fremen-ride-betrayal.tsx b/components/nexus-fremen-ride-betrayal.tsx new file mode 100644 index 00000000..06149b64 --- /dev/null +++ b/components/nexus-fremen-ride-betrayal.tsx @@ -0,0 +1,74 @@ +'use client'; + +import type { Action, GameView } from '@/game/engine'; +import { territory } from '@/game/board'; +import { Button } from './ui/button'; + +export function NexusFremenRideBetrayal({ game, act, busy }: { + game: GameView; + act: (action: Action) => void; + busy: boolean; +}) { + const reaction = game.nexusFremenRideBetrayal; + if (!reaction) return null; + const source = reaction.source; + const rider = game.players.find(player => player.id === source.player)?.name ?? 'The rider'; + const responder = game.players.find(player => player.id === reaction.player)?.name ?? 'The current responder'; + const hasPassed = reaction.passed.includes(game.me); + const canRespond = reaction.player === game.me && !hasPassed; + const disabled = busy || game.status !== 'playing'; + return ( +

+

Fremen Nexus Betrayal · before a ride

+

+ {source.kind === 'wormRide' + ? `${rider} has a worm ride from ${territory(source.territory).name} waiting.` + : source.kind === 'nexusFremenCunningRide' + ? `${rider} has a Fremen Nexus Cunning ride waiting.` + : `${rider} has a Great Maker ride waiting.`} +

+

+ This before-ride response is offered without revealing anyone’s secret + Nexus identity. Continue to allow the original ride choices, or use an + eligible Fremen Nexus to prevent the remaining rides this turn. +

+

+ The worm, Spice Blow and any Nexus that have already resolved stay + resolved. Prevention does not move or destroy forces. Previous payments + and effects are not repeated. +

+ {!canRespond ? ( +

+ {hasPassed + ? 'You have continued. Waiting for the remaining ride responses.' + : `Waiting for ${responder} to respond before the ride.`} +

+ ) : ( +
+ + {reaction.canUse && ( + + )} +
+ )} +
+ ); +} diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index cc3f8bae..06a50ab7 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -16,6 +16,39 @@ open mode gates. ## Current checkpoint and work +**Reactive Fremen Nexus worm prevention — 26 October. Prototyped / Partial.** +Fremen Nexus Betrayal now responds at actual unmade ordinary/Great Maker +rides, after the original completed effects. Uniform public Nexus-back holders +acknowledge in storm order; only the actual holder privately gets Use. All-pass +restores the same native decision; spending suppresses remaining rides without +replaying destruction, protection, spice, votes or Nexus. Known public-discard +Cunning adds no pointless pause. Existing predeclarations, mode-bound/legacy +receipts, roster guards and public mode gates remain. Priority is provisional. + +Three disjoint pure/native-source/control lanes and root integration pass +**15 files,118/118**, types/lint/build and **six native engine/GameTable SSR/ +actual reaction-callback journeys** on unchanged source. Thirty-one new cases +cover neutral membership/source/order, spend/pass, typed native riding, stale/ +foreign/underlying denial and all four minimal profiles. Basic ordinary88, +Advanced ordinary92 and Advanced Maker90-action sources retain all33 original +Treachery and all12 Nexus cards. Actual turn2 Nexus/alliance/physical draw and +free Hagga2 reinforcement precede the real turn3 appearance; no played hand, +force, wallet, clock, worm or card receipt is installed. Pure/control contract +fixtures remain explicit controlled supplements. + +Final frozen v3 report/logs: +`/home/quintine/.local/share/dune/checkpoints/20261026-fremen-reactive-betrayal-proof-v3/`; +journeys/HTML: adjacent `20261026-fremen-reactive-betrayal-runtime/`. +Full rules/combinations and final assurance remain open. No saved-room/D1/ +server, reset/restart, deployment or recovery operation occurs. DOM/hydration, +browser/mobile and exact-revision deployed acceptance remain due. +[Source and selected priority](NEXUS_FREMEN_RULES.md#reactive-before-ride-betrayal--26-october-2026). + +Next bounded card-function gap: Box still rejects paid search while a discarded +shipping Karama has a provisional Guild refund claim. Existing recovered-cost +provenance supplies a related policy; see [the Box contract](NULLENTROPY_BOX_ENGINE_AUDIT.md) +and `validateNullentropyBegin` before choosing that composition. + **Occupied Ecaz Duke grant — 26 October. Prototyped / Partial.** The printed occupied Ecaz benefit now grants the living shared Duke to its qualified occupier or reciprocal ally. A sole recipient receives him automatically; @@ -45,11 +78,6 @@ room/D1/server, reset/restart, deployment or recovery operation occurs. DOM/ hydration, browser/mobile and exact-revision deployed acceptance remain due. [Source and provisional boundaries](HOMEWORLD_ECAZ_DUKE.md#source-and-selected-ordinary-lifecycle--26-october-2026). -Next concrete content gap found in this bounded lookup: Fremen Nexus Betrayal -cannot react before an already queued worm ride; its original pre-first-blow -fence remains. See [the current contract](NEXUS_FREMEN_RULES.md) and -`game/nexus-fremen-betrayal.ts`. Broad source reconciliation is not certified. - **Native early Truthtrance promises — 26 October. Prototyped / Partial.** Bare registered native factions with selected decks can answer current-turn shipment and physical ground-movement questions in Basic/Advanced phases0–5. diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 02b51af2..a0908bf3 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,41 @@ # Dune implementation status +## 26 October 2026 — Reactive Fremen Nexus worm prevention + +**Prototyped / Partial.** Fremen Nexus Betrayal now responds at actual unmade ordinary/Great Maker +rides, after the original completed effects. Uniform public Nexus-back holders +acknowledge in storm order; only the actual holder privately gets Use. All-pass +restores the same native decision; spending suppresses remaining rides without +replaying destruction, protection, spice, votes or Nexus. Known public-discard +Cunning adds no pointless pause. Existing predeclarations, mode-bound/legacy +receipts, roster guards and public mode gates remain. Priority is provisional. + +Three disjoint pure/native-source/control lanes and root integration pass +**15 files,118/118**, types/lint/build and **six native engine/GameTable SSR/ +actual reaction-callback journeys** on unchanged source. Thirty-one new cases +cover neutral membership/source/order, spend/pass, typed native riding, stale/ +foreign/underlying denial and all four minimal profiles. Basic ordinary88, +Advanced ordinary92 and Advanced Maker90-action sources retain all33 original +Treachery and all12 Nexus cards. Actual turn2 Nexus/alliance/physical draw and +free Hagga2 reinforcement precede the real turn3 appearance; no played hand, +force, wallet, clock, worm or card receipt is installed. Pure/control contract +fixtures remain explicit controlled supplements. + +Integration adds the new decision to existing exhaustive shape/dispatch maps +and retains the original Great Maker/Cunning decision under the signed wrapper. +Feedback corrects a nullable test access and native Emperor5/Fremen3 elite +invariants. Two old unspent Maker baselines explicitly Continue rather than let +the new generic bot policy spend the held counter; production minimal Use stays. +Completed Game receipts remain while public controls clear. Final v3 is complete +and frozen, preserving original Maker votes and all-pass native riding. +Final frozen v3 report/logs: +`/home/quintine/.local/share/dune/checkpoints/20261026-fremen-reactive-betrayal-proof-v3/`; +journeys/HTML: adjacent `20261026-fremen-reactive-betrayal-runtime/`. +Full rules/combinations and final assurance remain open. No saved-room/D1/ +server, reset/restart, deployment or recovery operation occurs. DOM/hydration, +browser/mobile and exact-revision deployed acceptance remain due. +[Source and selected priority](NEXUS_FREMEN_RULES.md#reactive-before-ride-betrayal--26-october-2026). + ## 26 October 2026 — Occupied Ecaz Duke grant **Prototyped / Partial.** The printed occupied Ecaz benefit now grants the living shared Duke to its diff --git a/docs/NEXUS_FREMEN_RULES.md b/docs/NEXUS_FREMEN_RULES.md index ca390404..5ccc6b44 100644 --- a/docs/NEXUS_FREMEN_RULES.md +++ b/docs/NEXUS_FREMEN_RULES.md @@ -2,6 +2,85 @@ Primary-source and integration audit, 10 September 2026, with dated Secret Ally revival, protection and native Cunning prototypes. These bounded paths do not certify complete Fremen/Nexus effects or open a release gate. The [common Nexus rules](NEXUS_CARD_RULES.md) apply. +## Reactive before-ride Betrayal — 26 October 2026 + +**Prototyped / Partial.** The printed Fremen +Nexus Betrayal suppresses worm riding for the turn; it does not print the older +prototype's pre-first-blow-only limitation. Under the existing recommended-policy +authority, an actual native ordinary or Great Maker reserve-ride decision now +has a neutral before-transfer opportunity. The original appearance, protection, +destruction, spice and completed votes/Nexus stay resolved; remaining native +steps continue once. This is selected online priority, not a publisher worked +example or permission to stop an already committed ride. + +Public participation uses unallied non-Fremen seats with any held Nexus back, +in actual storm order. Membership/event never uses a secret card face. Only the +current actual Fremen-card holder privately sees Use. Others see Continue and +normal waiting. If the physical Fremen card is publicly discarded, no window +is needed; a valid pending Cunning ride currently retains exactly that card +in discard. Its own operation is not duplicated to manufacture a counter. + +A signed server frame holds the exact original native decision. Continue keeps +all cards and physical groups; all-pass restores that same decision, queue and +native choices. Use validates the real card, closes the same source, spends +through the original mode-bound turn receipt and suppresses only unmade rides. +Native Great Maker completion retains original votes/Spice/Nexus. Earlier +six-field movement receipts and ordinary predeclarations remain unchanged. +A later physical redraw cannot spend a second alternative in the same turn. + +The persistent panel owns current controls until acknowledgment/Use finishes. +It has no timeout; busy and waiting seats cannot submit an underlying ride. +All four minimal profiles use only projected event and private legal Use; +no strategy or difficulty calibration is added. The native roster/module guard +is unchanged; this does not open combinations, publication or complete modes. + +Three disjoint pure/native-source/control lanes and root integration pass +**15 files,118/118**, types/lint/build and **six native engine/GameTable SSR/ +actual reaction-callback journeys** on unchanged source. Thirty-one new cases +cover neutral membership/source/order, spend/pass, typed native riding, stale/ +foreign/underlying denial and all four minimal profiles. Basic ordinary88, +Advanced ordinary92 and Advanced Maker90-action sources retain all33 original +Treachery and all12 Nexus cards. Actual turn2 Nexus/alliance/physical draw and +free Hagga2 reinforcement precede the real turn3 appearance; no played hand, +force, wallet, clock, worm or card receipt is installed. Pure/control contract +fixtures remain explicit controlled supplements. + +The [native producer](../tests/fixture-nexus-fremen-reactive-betrayal.ts) starts +fresh Fremen/Emperor/Guild, Nexus only, with Discovery additionally for approved +Advanced Maker. An actual turn2 worm/replacement Hagga, Fremen/Emperor alliance, +original Guild singleton draw and real free2 Hagga reinforcement earn the source. +Advanced uses one actual Fedaykin. The later worm and applicable Maker votes +finish before the current original ride is held. No advance Betrayal replaces +the reactive use. All original card stock, pools and selected module entry stay. + +Ten [native engine cases](../tests/nexus-fremen-reactive-betrayal-engine.test.ts) +cover all three sources spending/passing, real ordinary2 (one Fedaykin in +Advanced) or Maker reserve1/Fedaykin1 Polar rides, wrong actors/stale events/underlying replies and twelve +existing-profile proposals. Fourteen +[pure cases](../tests/nexus-fremen-ride-betrayal-quote.test.ts) label controlled +membership, original parent/order, held-back versus hidden-face changes, signed +completion and known-discard Cunning skip. Seven +[control cases](../tests/nexus-fremen-ride-betrayal-controls.test.tsx) label their +projected UI contract. Actual new callbacks, waiting/busy controls and native +continued riding are exercised separately through GameTable SSR in six journeys. + +Feedback adds the decision to existing exhaustive maps, retains suspended +native Maker validation, fixes a nullable test access and native elite totals, +and distinguishes authoritative completed receipts from cleared player controls. +Old unspent Maker programmes now explicitly Continue through the new opportunity; +they still prove the original reserve ride, later ordinary ride and second-pile +Cunning offer. +Final v3 binds2955 files to +`9ecfb427dbcc95f2db9be104d5b285e58eb3d9e0a9d7a3453516508181cb8099` on base `78bcf22bca0fae7b5d514ff6e649012ba6ace6e0`. +Final frozen v3 report/logs: +`/home/quintine/.local/share/dune/checkpoints/20261026-fremen-reactive-betrayal-proof-v3/`; +journeys/HTML: adjacent `20261026-fremen-reactive-betrayal-runtime/`. + +Full rules/combinations and final assurance remain open. No saved-room/D1/ +server, reset/restart, deployment or recovery operation occurs. DOM/hydration, +browser/mobile and exact-revision deployed acceptance remain due. + + ## Native modules, remote riding and Great Maker — 18 October 2026 **Prototyped / Partial.** Native Fremen Nexus diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index a4e7031b..5a080a6b 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1,5 +1,22 @@ # Rule decision index +## Fremen Betrayal at an actual pending ride — 26 October 2026 + +The printed turn-long worm-riding alternative now has an executable reactive +window, superseding the earlier advance-only product fence. Under the 14 October +choose-recommended authority, pause at actual native ordinary/Great Maker ride +creation, after the original completed effects. All unallied non-Fremen seats +with a public Nexus back acknowledge in storm order regardless of hidden face; +only the actual Fremen holder may spend. A publicly discarded Fremen singleton +adds no pause, including its existing pending Cunning ride. All-pass restores +the exact unmade native decision. Use spends once through the existing mode- +bound receipt, suppresses remaining rides and advances original continuation; +completed destruction, spice, votes, protection and Nexus are not replayed. +This neutral priority is visibly provisional online composition, not a new +publisher post-appearance timing ruling. Predeclarations, old receipts and mode +admission stay unchanged. +[Current source and boundaries](NEXUS_FREMEN_RULES.md#reactive-before-ride-betrayal--26-october-2026). + ## Occupied Ecaz living Duke grant — 26 October 2026 Connect the printed occupied-face living Duke grant through existing stable diff --git a/game/admin-seat-ai-shape.ts b/game/admin-seat-ai-shape.ts index b7bb6059..ac83f9da 100644 --- a/game/admin-seat-ai-shape.ts +++ b/game/admin-seat-ai-shape.ts @@ -110,6 +110,7 @@ const decisions = { wormRide: true, nexusFremenCunningOffer: true, nexusFremenProtection: true, + nexusFremenRideBetrayal: true, nexusEmperorPayment: true, nexusEmperorSellerPurchase: true, nexusEmperorAmbassadorPurchase: true, diff --git a/game/bots.ts b/game/bots.ts index dfaff12b..8852a238 100644 --- a/game/bots.ts +++ b/game/bots.ts @@ -2752,6 +2752,11 @@ function policyActions(g: GameView): Action[] { if (d.kind === 'jacurutuDial') return g.jacurutuDial?.choices.map((_,allocation) => ({type:'decision',event:d.event,allocation})) ?? []; if (d.kind === 'greatMakerVote' || d.kind === 'greatMakerRide') return greatMakerBotActions(g); + if (d.kind === 'nexusFremenRideBetrayal') { + const reaction = g.nexusFremenRideBetrayal; + return reaction && reaction.player === me.id + ? [{ type: 'decision', event: reaction.event, use: reaction.canUse }] : []; + } if (d.kind === 'nexusFremenProtection') { const offer = g.nexusFremenProtection; return offer && !offer.blocked diff --git a/game/engine.ts b/game/engine.ts index 937fc1d0..a609b71a 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -94,6 +94,7 @@ import { createNexusMoritani, validateNexusMoritani, quoteNexusMoritaniPlacement import { moritaniBetrayalOffer } from './nexus-moritani-betrayal'; import { ecazBetrayalOffer, quoteEcazBetrayal, validateEcazBetrayalSnapshot, type EcazBetrayalSnapshot } from './nexus-ecaz-betrayal'; import { fremenBetrayalOffer, fremenBetrayalSignature, validateFremenBetrayal, type FremenNexusBetrayal, type StoredFremenNexusBetrayal } from './nexus-fremen-betrayal'; +import { createFremenRideBetrayal, fremenRideBetrayalCurrentOwner, validateFremenRideBetrayal, passFremenRideBetrayal, useFremenRideBetrayal, type FremenRideBetrayalParent, type FremenRideBetrayalFrame } from './nexus-fremen-ride-betrayal'; import { atomicsShipmentBlocked, quoteMoritaniAtomics, type AtomicsAftermath, type AtomicsQuote } from './moritani-atomics'; import { nexusMoritaniRecordSignature, terrorLocationAllowed, terrorEntryLocationAllowed } from './terror-location'; import { CHOAM_NEXUS_EFFECTS, createNexusChoam, validateNexusChoam, type NexusChoamEffect, type NexusChoamReceipt } from './nexus-choam'; @@ -1059,6 +1060,7 @@ export type Decision = | { kind: 'wormPlacement'; player: string } | { kind: 'wormProtection'; player: string; territory: string; ally: string } | { kind: 'nexusFremenProtection'; player: string; event: string } + | { kind: 'nexusFremenRideBetrayal'; player: string; event: string } | { kind: 'discoveryDiscard'; player: string; event: string } | { kind: 'discoveryEntry'; player: string; event: string } | { kind: 'ecologicalStorm'; player: string; event: string } @@ -2189,6 +2191,8 @@ export type Game = { nexusEcazBetrayalHistory?: { snapshot: EcazBetrayalSnapshot; stage: 'pending' | 'complete' }[]; /** Spent Fremen card receipts include the earlier movement-only saved form. */ nexusFremenBetrayalHistory?: StoredFremenNexusBetrayal[]; + /** Neutral public queue holding one exact unmade native ride. */ + nexusFremenRideBetrayal?: FremenRideBetrayalFrame | null; /** One actual Sardaukar battle advantage suppressed by a spent Emperor card. */ nexusEmperorBetrayalHistory?: { event: string; turn: number; territory: string; owner: string; target: string }[]; /** Original native Voice canceled by a spent Bene Gesserit Nexus card. */ @@ -2803,6 +2807,65 @@ function playNexusEmperorBetrayal(g: Game, p: Player, action: Action) { log(g, `${p.name} spent Emperor Nexus Betrayal to suppress ${getPlayer(g, record.target).name}'s Sardaukar strength in this battle. Actual starred counters remain Sardaukar.`, { faction: p.faction, name: 'Emperor Nexus Betrayal' }); } +function fremenRideBetrayalIntegrity(g: Game) { + const frame = g.nexusFremenRideBetrayal; + if (!frame) { + requireRule(g.decision?.kind !== 'nexusFremenRideBetrayal', 'The pending ride has lost its original Betrayal opportunity.'); + return; + } + nexusRule(() => validateFremenRideBetrayal(g, frame)); + requireRule(frame.stage === 'choose' || g.decision?.kind !== 'nexusFremenRideBetrayal', + 'The completed before-ride opportunity retained its old controls.'); +} +function offerFremenRideBetrayal(g: Game) { + const decision = g.decision; + if (!decision || !['wormRide', 'nexusFremenCunningRide', 'greatMakerRide'].includes(decision.kind)) return; + requireRule(g.nexusFremenRideBetrayal?.stage !== 'choose', 'Finish the original before-ride Betrayal opportunity first.'); + const frame = nexusRule(() => createFremenRideBetrayal(g, decision as FremenRideBetrayalParent, crypto.randomUUID())); + if (!frame) return; + g.nexusFremenRideBetrayal = frame; + g.decision = { kind: 'nexusFremenRideBetrayal', player: fremenRideBetrayalCurrentOwner(frame)!, event: frame.event }; +} +function fremenRideBetrayalView(g: Game, viewer: string) { + const frame = g.nexusFremenRideBetrayal; + if (!frame || frame.stage !== 'choose') return null; + const player = fremenRideBetrayalCurrentOwner(frame)!; + const own = player === viewer ? fremenBetrayalOffer(g, viewer) : null; + return { event: frame.event, player, source: structuredClone(frame.source), + canUse: !!own && !own.blocked, passed: [...frame.passed] }; +} +function decideFremenRideBetrayal(g: Game, p: Player, action: Action) { + const frame = g.nexusFremenRideBetrayal; + requireRule(frame?.stage === 'choose' && g.decision?.kind === 'nexusFremenRideBetrayal' && + g.decision.player === p.id && typeof action.event === 'string' && typeof action.use === 'boolean' && + Object.keys(action).sort().join(',') === 'event,type,use', + 'Choose your current before-ride Betrayal opportunity.'); + const resolved = nexusRule(() => action.use + ? useFremenRideBetrayal(g, frame, p.id, action.event as string) + : passFremenRideBetrayal(g, frame, p.id, action.event as string)); + if (action.use) { + const offer = fremenBetrayalOffer(g, p.id); + requireRule(offer && !offer.blocked && offer.mode === 'worm', 'Use your held Fremen Nexus card for this original ride opportunity.'); + playNexusFremenBetrayal(g, p, { type: 'nexusFremenBetrayal', event: offer.event }); + } + g.nexusFremenRideBetrayal = resolved; + if (resolved.stage === 'choose') { + g.decision = { kind: 'nexusFremenRideBetrayal', player: fremenRideBetrayalCurrentOwner(resolved)!, event: resolved.event }; + return; + } + if (!action.use) { + g.decision = structuredClone(frame.source); + return; + } + // Suppress only unmade transfers. Original worm destruction, votes, spice + // and Nexus state have already settled and are never replayed here. + g.decision = null; + g.wormRides = []; + for (const ride of g.nexusFremenCunningRides ?? []) + if (ride.occurrence.turn === g.turn && ride.stage !== 'complete') ride.stage = 'complete'; + if (frame.source.kind === 'greatMakerRide') completeGreatMaker(g); + else nextWormRide(g); +} function nexusFremenBetrayalIntegrity(g: Game) { const history = g.nexusFremenBetrayalHistory; const fremen = byFaction(g, 'fremen'); @@ -2859,7 +2922,9 @@ function playNexusFremenBetrayal(g: Game, p: Player, action: Action) { else fremen.fremenNexusMovementBlockedTurn = g.turn; (g.nexusFremenBetrayalHistory ??= []).push(record); log(g, offer.mode === 'worm' - ? `${p.name} spent Fremen Nexus Betrayal before the first blow to suppress Fremen worm riding for this turn. Worm appearances and protection still resolve; ordinary and remote rides do not.` + ? g.nexusFremenRideBetrayal?.stage === 'choose' + ? `${p.name} spent Fremen Nexus Betrayal before this unmade ride to suppress remaining worm rides this turn. The resolved worm, spice, votes and Nexus remain.` + : `${p.name} spent Fremen Nexus Betrayal before the first blow to suppress Fremen worm riding for this turn. Worm appearances and protection still resolve; ordinary and remote rides do not.` : `${p.name} spent Fremen Nexus Betrayal to suppress the Fremen two-territory movement advantage this turn. Independent ornithopters and movement abilities remain available.`, { faction: p.faction, name: 'Fremen Nexus Betrayal' }); } @@ -3229,8 +3294,9 @@ function nexusFremenCunningIntegrity(g: Game) { occurrence.turn === g.turn && g.nexusCards!.cards!.discard.includes('fremen'), 'A pending Fremen ride has lost its spent physical card.'); if (stage === 'select') { + const held = g.nexusFremenRideBetrayal?.stage === 'choose' ? g.nexusFremenRideBetrayal.source : null; const decision = g.decision?.kind === 'nexusFremenCunningRide' - ? g.decision : suspended?.decision; + ? g.decision : suspended?.decision?.kind === 'nexusFremenCunningRide' ? suspended.decision : held; requireRule(decision?.kind === 'nexusFremenCunningRide' && decision.player === authorization.owner && decision.event === occurrence.event, @@ -13039,7 +13105,8 @@ function stampGreatMaker(g: Game) { function greatMakerIntegrity(g: Game) { const frame = g.greatMaker, continuation = g.pendingTreacheryDiscard?.continuation; const controls = [g, g.pendingExchange, g.pendingNullentropy?.resume, g.pendingRicheseGift?.resume, - g.pendingRichesePurchaseIncome?.resume, continuation && 'resume' in continuation ? continuation.resume : null]; + g.pendingRichesePurchaseIncome?.resume, continuation && 'resume' in continuation ? continuation.resume : null, + g.nexusFremenRideBetrayal?.stage === 'choose' ? { decision: g.nexusFremenRideBetrayal.source, response: null } : null]; const owned = controls.filter(control => control?.decision?.kind === 'greatMakerVote' || control?.decision?.kind === 'greatMakerRide'); if (!frame) { requireRule(owned.length === 0, 'The Great Maker has lost its original encounter.'); @@ -13110,6 +13177,7 @@ function decideGreatMakerVote(g: Game, p: Player, action: Action) { if (fremen && fremen.reserves > 0 && fremen.fremenNexusWormBlockedTurn !== g.turn) { frame.stage = 'ride'; stampGreatMaker(g); g.decision = {kind:'greatMakerRide', player:fremen.id, event:frame.event}; + offerFremenRideBetrayal(g); } else completeGreatMaker(g); } function decideGreatMakerRide(g: Game, p: Player, action: Action) { @@ -13278,6 +13346,7 @@ function nextWormRide(g: Game) { ) ) { g.decision = { kind: 'wormRide', player: fremen.id, territory: t }; + offerFremenRideBetrayal(g); return; } } @@ -13298,6 +13367,7 @@ function nextWormRide(g: Game) { ride.stage = 'select'; g.decision = { kind: 'nexusFremenCunningRide', player: ride.authorization.owner, event: ride.occurrence.event }; + offerFremenRideBetrayal(g); return; } g.nexus = false; @@ -25505,6 +25575,7 @@ function marketGholaIntegrity(g: Game) { nexusEmperorSecretIntegrity(g); nexusEmperorBetrayalIntegrity(g); nexusFremenBetrayalIntegrity(g); + fremenRideBetrayalIntegrity(g); nexusBgBetrayalIntegrity(g); nexusBorrowedVoiceIntegrity(g); nexusFremenRevivalIntegrity(g); @@ -34874,6 +34945,11 @@ function applyActionInner( ); requireRule(g.status !== 'finished', 'This game has ended.'); const t = action.type; + if (g.nexusFremenRideBetrayal?.stage === 'choose') { + if (t === 'advanceBots') return g; + requireRule(t === 'decision', 'Finish the current before-ride Betrayal opportunity first.'); + decideFremenRideBetrayal(g, p, action); return g; + } // Mandatory movement reactions and other seats' actions do not close the // Advanced owner's immediate Hajr opportunity. Its next voluntary action does. if (g.advanced && p.hajrWindow && !g.response && !g.decision && !g.truthtrance && @@ -35347,6 +35423,8 @@ function applyActionInner( } if (g.decision) { const decision = g.decision; + requireRule(decision.kind !== 'nexusFremenRideBetrayal', + 'The before-ride response needs its original active wrapper.'); requireRule(decision.kind !== 'nexusFremenProtection', 'Use only the original worm protection event with Use or Pass.'); requireRule(decision.kind !== 'nexusEmperorPayment', @@ -39252,8 +39330,9 @@ export function viewGame(state: Game, id: string) { g.grummanCollection?.stage === 'waiting' && grummanCollectionAutomatic(g)), nexusEcazBetrayal: ecazBetrayalOffer(g, id, g.grummanCollection?.stage === 'waiting' && grummanCollectionAutomatic(g)), - nexusFremenBetrayal: fremenBetrayalOffer(g, id, + nexusFremenBetrayal: g.nexusFremenRideBetrayal?.stage === 'choose' ? null : fremenBetrayalOffer(g, id, g.grummanCollection?.stage === 'waiting' && grummanCollectionAutomatic(g)), + nexusFremenRideBetrayal: fremenRideBetrayalView(g, id), nexusRichese: nexusRicheseOffer(g,id), nexusRicheseCunning: nexusRicheseCunningOffer(g, id), nexusGuildSecretAlly: nexusGuildSecretAllyOffer(g,id), diff --git a/game/nexus-fremen-betrayal.ts b/game/nexus-fremen-betrayal.ts index 61f57a0a..2ce135e4 100644 --- a/game/nexus-fremen-betrayal.ts +++ b/game/nexus-fremen-betrayal.ts @@ -1,6 +1,7 @@ import type { Game } from './engine'; import { nexusCleanPlayBlocked } from './nexus-play-boundary'; import { fremenNexusModeSupported } from './fremen-nexus-profile'; +import { fremenRideBetrayalCurrentOwner } from './nexus-fremen-ride-betrayal'; export type FremenNexusBetrayal = { event: string; @@ -26,8 +27,8 @@ export const fremenBetrayalEvent = ( export const fremenBetrayalSignature = (record: FremenNexusBetrayal) => JSON.stringify({ ...record, signature: undefined }); -/** Clean advance declarations avoid a holder-dependent worm interruption - * while preserving both mutually exclusive printed alternatives. */ +/** Existing advance declarations and an engine-owned neutral before-ride + * opportunity preserve both mutually exclusive printed alternatives. */ export function fremenBetrayalOffer( g: Game, owner: string, automaticPending = false, ): FremenBetrayalOffer | null { @@ -36,23 +37,27 @@ export function fremenBetrayalOffer( if (!holder || !fremen || g.nexusCards?.cards?.hands[owner] !== 'fremen' || holder.faction === 'fremen') return null; const mode = g.phase === 1 ? 'worm' : 'movement'; + const reaction = g.nexusFremenRideBetrayal; + const reactive = reaction?.stage === 'choose' && g.decision?.kind === 'nexusFremenRideBetrayal' && + g.decision.event === reaction.event && g.decision.player === owner && + fremenRideBetrayalCurrentOwner(reaction) === owner; let blocked: string | null = null; if (holder.ally) blocked = 'An allied player cannot use a Nexus card.'; else if (g.status !== 'playing' || (g.phase !== 1 && g.phase !== 5)) - blocked = 'Use Fremen Betrayal before the first Spice Blow or before Fremen movement.'; + blocked = 'Use Fremen Betrayal before the first Spice Blow, at an original before-ride opportunity, or before Fremen movement.'; else if (!fremenNexusModeSupported(g)) blocked = 'This Fremen Nexus roster or module prerequisite is unavailable.'; - else if (nexusCleanPlayBlocked(g, automaticPending)) + else if (!reactive && nexusCleanPlayBlocked(g, automaticPending)) blocked = 'Finish the current interaction before using Fremen Betrayal.'; else if (g.nexusFremenBetrayalHistory?.some(record => record.turn === g.turn)) blocked = 'Fremen Betrayal already spent its one physical card this turn.'; - else if (mode === 'worm') { + else if (mode === 'worm' && !reactive) { if (g.spiceSequence || g.spiceResolution || g.spiceWindow || g.nexus || g.wormRides.length || g.summonedBeforeBlow || g.nexusFremenCunningOffer || g.nexusFremenCunningRides?.some(row => row.stage !== 'complete')) blocked = 'Declare worm-riding prevention before the first worm or spice blow this turn.'; - } else if (!g.movementRemaining?.includes(fremen.id) || fremen.moved !== 0 || - fremen.fremenNexusMovementBlockedTurn === g.turn) + } else if (mode === 'movement' && (!g.movementRemaining?.includes(fremen.id) || fremen.moved !== 0 || + fremen.fremenNexusMovementBlockedTurn === g.turn)) blocked = 'Fremen have already moved or completed their turn.'; return { event: fremenBetrayalEvent(g.turn, owner, fremen.id, mode), blocked, target: fremen.id, mode }; diff --git a/game/nexus-fremen-ride-betrayal.ts b/game/nexus-fremen-ride-betrayal.ts new file mode 100644 index 00000000..1a133608 --- /dev/null +++ b/game/nexus-fremen-ride-betrayal.ts @@ -0,0 +1,169 @@ +import { TERRITORIES, splitLocation } from './board'; +import type { Game } from './engine'; +import { fremenNexusModeSupported } from './fremen-nexus-profile'; +import { validateGreatMaker } from './great-maker'; +import { validateNexusCards } from './nexus-cards'; +import { quoteFremenCunningRide } from './nexus-fremen-cunning'; + +export type FremenRideBetrayalParent = + | { kind: 'wormRide'; player: string; territory: string } + | { kind: 'nexusFremenCunningRide' | 'greatMakerRide'; player: string; event: string }; +export type FremenRideBetrayalFrame = { + event: string; + turn: number; + source: FremenRideBetrayalParent; + stage: 'choose' | 'complete'; + order: string[]; + passed: string[]; + usedBy: string | null; + signature: string; +}; +export class FremenRideBetrayalError extends Error { + constructor(message: string) { super(message); this.name = 'FremenRideBetrayalError'; } +} +function requireRide(condition: unknown, message: string): asserts condition { + if (!condition) throw new FremenRideBetrayalError(message); +} +const text = (value: unknown): value is string => typeof value === 'string' && value.trim().length > 0; +const exact = (value: unknown, keys: readonly string[]): boolean => !!value && typeof value === 'object' && + !Array.isArray(value) && Object.keys(value).sort().join(',') === [...keys].sort().join(','); +const ids = (value: unknown): value is string[] => Array.isArray(value) && value.every(text) && + new Set(value).size === value.length; +const equal = (a: readonly string[], b: readonly string[]) => a.length === b.length && a.every((id, i) => id === b[i]); + +function validateParent(parent: FremenRideBetrayalParent): void { + requireRide(parent && text(parent.player) && + (parent.kind === 'wormRide' + ? exact(parent, ['kind', 'player', 'territory']) && TERRITORIES.some(t => t.id === parent.territory) + : ['nexusFremenCunningRide', 'greatMakerRide'].includes(parent.kind) && + exact(parent, ['kind', 'player', 'event']) && text(parent.event)), + 'Fremen ride Betrayal needs one exact original native ride decision.'); +} +function parentKey(parent: FremenRideBetrayalParent) { + return [parent.kind, parent.player, parent.kind === 'wormRide' ? parent.territory : parent.event]; +} +function signature(frame: Omit): string { + return JSON.stringify(['nexusFremenRideBetrayal', frame.event, frame.turn, parentKey(frame.source), + frame.stage, frame.order, frame.passed, frame.usedBy]); +} +function seal(frame: Omit): FremenRideBetrayalFrame { + return { ...frame, signature: signature(frame) }; +} +function validateFrame(frame: FremenRideBetrayalFrame): void { + requireRide(exact(frame, ['event', 'turn', 'source', 'stage', 'order', 'passed', 'usedBy', 'signature']) && + text(frame.event) && Number.isSafeInteger(frame.turn) && frame.turn > 0 && + ['choose', 'complete'].includes(frame.stage) && ids(frame.order) && frame.order.length > 0 && + ids(frame.passed) && frame.passed.length <= frame.order.length && + equal(frame.passed, frame.order.slice(0, frame.passed.length)), + 'The original Fremen ride Betrayal acknowledgement progress is invalid.'); + validateParent(frame.source); + requireRide(frame.stage === 'choose' + ? frame.usedBy === null && frame.passed.length < frame.order.length + : frame.usedBy === null + ? frame.passed.length === frame.order.length + : text(frame.usedBy) && frame.usedBy === frame.order[frame.passed.length], + 'Fremen ride Betrayal must finish by all original passes or one current responder.'); + requireRide(frame.signature === signature(frame), 'Fremen ride Betrayal lost its original parent or progress.'); +} + +/** Public participation only: no card face enters membership, order or event. */ +function publicOrder(g: Game): string[] { + requireRide(ids(g.order) && g.order.length === g.players.length && + g.players.every(p => g.order.includes(p.id)), 'Fremen ride Betrayal needs the complete original storm order.'); + return g.order.filter(id => { + const p = g.players.find(p => p.id === id)!; + return p.faction !== 'fremen' && !p.ally && Boolean(g.nexusCards!.cards!.hands[id]); + }); +} +function available(g: Game): boolean { + return g.status === 'playing' && g.phase === 1 && fremenNexusModeSupported(g) && + !g.nexusCards!.cards!.discard.includes('fremen') && + !g.players.some(p => p.faction === 'fremen' && p.fremenNexusWormBlockedTurn === g.turn); +} +function validateNativeParent(g: Game, parent: FremenRideBetrayalParent): void { + const native = g.players.find(p => p.id === parent.player && p.faction === 'fremen'); + requireRide(native, 'Only the actual Fremen native ride can open this Betrayal opportunity.'); + if (parent.kind === 'wormRide') { + // nextWormRide already shifted this territory out of the remaining queue. + requireRide(Object.entries(native.forces).some(([key, count]) => { + const at = splitLocation(key); + return count > 0 && at.territory === parent.territory && at.sector !== g.storm; + }), 'The original ordinary worm ride has no remaining clear Fremen source.'); + } else if (parent.kind === 'greatMakerRide') { + requireRide(g.greatMaker?.stage === 'ride' && g.greatMaker.event === parent.event && + g.greatMaker.turn === g.turn && native.reserves > 0, + 'The original Great Maker reserve ride has changed.'); + try { validateGreatMaker(g.greatMaker, g.turn, g.players); } + catch (error) { throw new FremenRideBetrayalError(error instanceof Error ? error.message : 'Invalid Great Maker ride.'); } + } else { + const row = g.nexusFremenCunningRides?.find(row => row.stage === 'select' && + row.occurrence.event === parent.event && row.occurrence.turn === g.turn && row.authorization.owner === native.id); + requireRide(row && quoteFremenCunningRide(g, native.id, row.occurrence, row.authorization), + 'The original selected Fremen Cunning ride has changed.'); + } +} + +/** Called only at the original native decision's creation. Public discard and + * public card backs decide whether anyone could respond. A valid Cunning ride + * currently keeps the Fremen card in discard, so it needs no neutral pause. */ +export function createFremenRideBetrayal(g: Game, parent: FremenRideBetrayalParent, + event: string): FremenRideBetrayalFrame | null { + if (!available(g)) return null; + requireRide(Number.isSafeInteger(g.turn) && g.turn > 0, 'Fremen ride Betrayal needs its original positive turn.'); + validateNexusCards(g.nexusCards!.cards!, g.players); + const order = publicOrder(g); if (!order.length) return null; + validateParent(parent); + requireRide(text(event) && g.decision && exact(g.decision, parent.kind === 'wormRide' + ? ['kind', 'player', 'territory'] : ['kind', 'player', 'event']) && + JSON.stringify(parentKey(g.decision as FremenRideBetrayalParent)) === JSON.stringify(parentKey(parent)), + 'Open Betrayal only before the current original Fremen ride decision.'); + validateNativeParent(g, parent); + return seal({ event, turn: g.turn, source: structuredClone(parent), stage: 'choose', order, passed: [], usedBy: null }); +} + +export function fremenRideBetrayalCurrentOwner(frame: FremenRideBetrayalFrame): string | null { + validateFrame(frame); return frame.stage === 'choose' ? frame.order[frame.passed.length] : null; +} + +/** Active validation accepts the exact original producer before wrapping, or + * the exact published wrapper owner/event. Completed records are detached + * signed progress: native continuation may already have consumed its parent. */ +export function validateFremenRideBetrayal(g: Game, frame: FremenRideBetrayalFrame): void { + validateFrame(frame); + requireRide(Number.isSafeInteger(g.turn) && g.turn >= frame.turn && + g.players.some(p => p.id === frame.source.player && p.faction === 'fremen') && + frame.order.every(id => g.players.some(p => p.id === id && p.faction !== 'fremen')), + 'Fremen ride Betrayal lost its original turn or native roster.'); + if (frame.stage === 'complete') return; + requireRide(g.turn === frame.turn && available(g), 'This original Fremen ride Betrayal opportunity has expired.'); + validateNexusCards(g.nexusCards!.cards!, g.players); + requireRide(equal(frame.order, publicOrder(g)), 'The original public Fremen ride Betrayal responders changed.'); + const decision = g.decision; + const original = decision && exact(decision, frame.source.kind === 'wormRide' + ? ['kind', 'player', 'territory'] : ['kind', 'player', 'event']) && + JSON.stringify(parentKey(decision as FremenRideBetrayalParent)) === JSON.stringify(parentKey(frame.source)); + const wrapper = exact(decision, ['kind', 'player', 'event']) && + decision?.kind === 'nexusFremenRideBetrayal' && decision.event === frame.event && + decision.player === fremenRideBetrayalCurrentOwner(frame); + requireRide(original || wrapper, 'Fremen ride Betrayal is not holding its exact original decision or current wrapper.'); + validateNativeParent(g, frame.source); +} + +function current(g: Game, frame: FremenRideBetrayalFrame, actor: string, event: string): void { + validateFremenRideBetrayal(g, frame); + requireRide(frame.stage === 'choose' && event === frame.event && fremenRideBetrayalCurrentOwner(frame) === actor, + 'Acknowledge only your current original Fremen ride Betrayal opportunity.'); +} +export function passFremenRideBetrayal(g: Game, frame: FremenRideBetrayalFrame, + actor: string, event: string): FremenRideBetrayalFrame { + current(g, frame, actor, event); + const passed = [...frame.passed, actor]; + return seal({ ...structuredClone(frame), passed, stage: passed.length === frame.order.length ? 'complete' : 'choose' }); +} +/** Eligibility of the actual Fremen face is deliberately the engine's private + * responsibility. Finish this frame BEFORE spending that physical card. */ +export function useFremenRideBetrayal(g: Game, frame: FremenRideBetrayalFrame, + actor: string, event: string): FremenRideBetrayalFrame { + current(g, frame, actor, event); + return seal({ ...structuredClone(frame), stage: 'complete', usedBy: actor }); +} diff --git a/game/reference.ts b/game/reference.ts index abdd4dd4..aa7c06d0 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -5337,6 +5337,29 @@ export const RULE_TOPICS: RuleTopic[] = [ {area:'Verification',status:'Partial',detail:"Fresh classic core base33 or selected36 optional14 initializes the original actual-Harkonnen source without a card-variant dependency. Genuine base33 all14 Use reaches one real Mentat draw; earlier independent Harass, Pass and allied-counter cases remain. Ten combined consumer cases and four legal policies pass. Other modes, universal timing and deferred assurance remain open.",evidence:['tests/classic-nexus-core.test.ts','tests/fixture-classic-nexus-core.ts','game/faction-module-profile.ts','tests/classic-independent-nexus-reactions.test.ts','tests/fixture-classic-independent-nexus-reactions.ts','tests/fixture-nexus-harkonnen-betrayal.ts', 'tests/nexus-harkonnen-betrayal.test.ts', 'tests/nexus-harkonnen-betrayal-runtime.test.ts', 'tests/nexus-harkonnen-betrayal-controls.test.tsx', 'tests/bot-nexus-harkonnen-betrayal.test.ts', 'tests/nexus-harkonnen-betrayal-recovery.test.ts', 'tests/prototype-room.test.ts', 'tests/admin-directory.test.ts']}, ], }, + { + id: 'nexus-fremen-ride-betrayal', + title: 'Fremen Nexus: prevent an impending worm ride', + category: 'Advanced & expansions', + coverage: 'Partial', + developmentStage: 'Prototyped', + summary: 'Respond after an actual worm appears and before its native ride transfers forces.', + steps: [ + 'At an actual ordinary or Great Maker reserve ride, unallied non-Fremen seats with any public Nexus back respond in storm order. The opportunity does not reveal the hidden face. A publicly discarded Fremen card creates no needless pause; its valid pending Cunning ride already keeps that singleton in discard.', + 'The current responder may Continue to retain every card and allow original ride choices. Only an actual held Fremen Nexus permits the private prevention option. After all Continue replies, the original unmade decision is restored unchanged.', + 'Use spends the one original card and prevents remaining ordinary, native Cunning and Great Maker rides this turn under the existing source composition. It moves or kills no group and does not repeat worm destruction, protection, spice, votes or Nexus. Already committed transfers stay committed.', + 'The public response frame retains its original event, source and ordered acknowledgments. Controls persist until a reply; underlying gameplay waits. Existing clean predeclarations, movement alternative and legacy receipts remain. A redrawn card cannot spend another alternative in the same turn.', + 'The selected neutral priority is visibly provisional software composition. Existing native module admission, complete-mode gates and final assurance remain separate.', + ], + related: ['nexus-cards', 'spice-blow', 'implementation-checklist'], + checklist: [ + { area: 'Implementation', status: 'Partial', detail: 'Neutral original-source wrapper, signed progress, private real-card use, exact all-pass restore and native no-transfer prevention connect ordinary and Great Maker decisions.', evidence: ['game/nexus-fremen-ride-betrayal.ts', 'game/nexus-fremen-betrayal.ts', 'game/engine.ts'] }, + { area: 'Player controls', status: 'Partial', detail: 'Persistent current-responder Continue/private Use, public waiting and busy suppression connect through GameTable.', evidence: ['components/nexus-fremen-ride-betrayal.tsx', 'components/game-table.tsx'] }, + { area: 'AI', status: 'Partial', detail: 'All four profiles submit the actual projected current event and legal private Use/Continue. Strategic refinement remains deferred.', evidence: ['game/bots.ts'] }, + { area: 'Documentation', status: 'Partial', detail: 'Printed effect, selected neutral priority, preserved native effects and earlier timing boundary are distinguished.', evidence: ['docs/NEXUS_FREMEN_RULES.md', 'docs/RULE_DECISIONS.md'] }, + { area: 'Verification', status: 'Partial', detail: 'Controlled pure/controls cases accompany genuine ordinary Basic/Advanced and Advanced Maker sources. Current frozen counts and limits are in the canonical source contract; complete modes and final assurance stay open.', evidence: ['tests/nexus-fremen-ride-betrayal-quote.test.ts', 'tests/nexus-fremen-ride-betrayal-controls.test.tsx', 'tests/nexus-fremen-reactive-betrayal-engine.test.ts', 'tests/fixture-nexus-fremen-reactive-betrayal.ts'] }, + ], + }, { id: 'nexus-cards', title: 'Nexus Cards', @@ -5401,7 +5424,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'With actual Advanced Stronghold Cards, Arrakeen bank support changes the real payer debit, not personal income. Emperor Cunning temporary Sardaukar remain physically ordinary counters and retain ordinary Tanks custody. Original losing Moritani retention precedes mandatory original-winner Tech, then CHOAM inspection excludes used opposing cards even if retained. Ordinary city Collection remains separate; none of these windows repeats the original support, card cost or casualties.', 'Ecaz Betrayal is available to an unallied holder with Ecaz seated and reciprocally allied in a shared Arrakis territory. At the opening of Shipment and Movement, choose one such territory; the ally returns its entire normal and starred force group from every sector there to reserves, leaving Ecaz forces in place. The Nexus card is spent before a Karama response. Cancellation preserves the board but not the card. Original native and selected-independent Nexus profiles retain World, training and phase modules. Advisors return as native counters; a concealed No-Field provisionally returns face down without materializing counters. The full unspent opening remains available after an actual Guild first/wait or Sapho reorder.', 'Ecaz Cunning lets an unallied native holder spend its physical Nexus at an original quiet boundary in any phase to take the existing Duke for this turn, including living control, original captured custody or the set-aside disc in Tanks. Finish committed auctions, Battle, responses, Ambassador and other physical continuations first; no chooser or phase is stolen. New version3 records bind actual phase and custody; legacy ordinary/version2 Battle history remains strict. Tanks preserve deaths and independently clear old use without spice or ordinary allowance; living already-used Duke gains no fresh use. Standalone no-Skills Ecaz/Advanced Harkonnen connects original capture and provisional permanent liberation; one battle or unused current-turn expiry sets Duke aside, never back to the captor. Actual Ecaz Ghola returns set aside before separate Cunning; foreign-Ghola revival remains forbidden. Roster/module and shared-training guards remain.', - 'Fremen Betrayal has two advance-declaration alternatives for an unallied holder in an original registered Nexus table with its selected modules. Before the first Spice Blow or worm appearance, spend the physical card to prevent Fremen ordinary and Cunning worm rides for the turn; worm survival, destruction and Nexus still resolve. This clean timing is a product boundary, not an official post-appearance priority ruling. Instead, before Fremen move during Shipment and Movement, spend it to suppress their native two-territory advantage for that turn while ordinary movement and independent ornithopters remain. Only one alternative can be used even if the card is recycled later that turn. Owner controls show the currently available choice; Great Maker reserve riding is also prevented under the explicit source composition, while its original votes, casualties and Nexus continue. Reactive post-worm timing and complete-combination acceptance remain separate.', + 'Fremen Betrayal has two advance-declaration alternatives for an unallied holder in an original registered Nexus table with its selected modules. Before the first Spice Blow or worm appearance, spend the physical card to prevent Fremen ordinary and Cunning worm rides for the turn; worm survival, destruction and Nexus still resolve. Actual ordinary/Great Maker ride creation now adds a neutral public Nexus-back response in storm order. All-pass restores the same native choice; private actual Fremen-card Use suppresses remaining rides without replaying completed effects. This timing is provisional online composition, not an official post-appearance priority ruling. Instead, before Fremen move during Shipment and Movement, spend it to suppress their native two-territory advantage for that turn while ordinary movement and independent ornithopters remain. Only one alternative can be used even if the card is recycled later that turn. Owner controls show the currently available choice; Great Maker reserve riding is also prevented under the explicit source composition, while its original votes, casualties and Nexus continue. Existing predeclarations and legacy receipts remain; complete-combination acceptance stays open.', '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. Forces, elite identity, destination restrictions, native Homeworld sources and movement remain unchanged. Fremen retain free reinforcement. Native Guild rate cancellation changes the one-counter sand bill from1 to2 without canceling the Nexus source; an already canceled rate is reflected in the quote. Unfunded declared Nexus stays spent without refund/reusable coupon while ordinary shipment remains available under the selected affordability policy. An independently purchased ordinary price remains separate. This is no concealed marker or extra shipment.', 'Native Richese Cunning uses the original signed two-No-Field shipment in supported single/paired E2 or selected mixed Ix+CHOAM native Nexus tables with classics, optional Skills/Homeworlds/Discovery/Tech/Advanced Strongholds. Select two distinct unused physical tokens: one reveals immediately into reserve-capped forces, one remains concealed; pay one marker invoice and spend the Nexus. The previous shipment token cannot be either choice, and the concealed one becomes last-used. Native/Guild response and actual payer/counter sources remain; a nested destination must be revealed. Prevention keeps physical tokens and unpaid spice, after any printed card cost. Sandtrout, arbitrary companions, shared allocation and other pending combinations retain their guards.', diff --git a/game/richese-cancellation.ts b/game/richese-cancellation.ts index 06197910..c50e0d70 100644 --- a/game/richese-cancellation.ts +++ b/game/richese-cancellation.ts @@ -91,6 +91,7 @@ const DECISIONS = { wormRide: true, nexusFremenCunningOffer: true, nexusFremenProtection: true, + nexusFremenRideBetrayal: true, nexusEmperorPayment: true, nexusEmperorSellerPurchase: true, nexusEmperorAmbassadorPurchase: true, diff --git a/tests/fixture-fremen-betrayal-modules.ts b/tests/fixture-fremen-betrayal-modules.ts index dfc068d3..977e9665 100644 --- a/tests/fixture-fremen-betrayal-modules.ts +++ b/tests/fixture-fremen-betrayal-modules.ts @@ -214,6 +214,10 @@ export function finishFremenBetrayalWorm(fixture: FremenBetrayalModulesFixture, } else if (!game.response && !game.phaseOpening && decision?.kind === 'nexusFremenCunningOffer') { cunningOffers.push(structuredClone(game)); step = { actor: decision.player, action: { type: 'decision', event: decision.event, accept: false } }; + } else if (!game.response && !game.phaseOpening && decision?.kind === 'nexusFremenRideBetrayal') { + // This unspent control deliberately permits its original native ride; + // do not let a generic bot policy spend the held prevention card. + step = { actor: decision.player, action: { type: 'decision', event: decision.event, use: false } }; } else if (!game.response && !game.phaseOpening && decision?.kind === 'greatMakerVote') { step = { actor: decision.player, action: { type: 'decision', event: decision.event, yes: true } }; votes.push(structuredClone(step)); diff --git a/tests/fixture-nexus-fremen-reactive-betrayal.ts b/tests/fixture-nexus-fremen-reactive-betrayal.ts new file mode 100644 index 00000000..a762c55c --- /dev/null +++ b/tests/fixture-nexus-fremen-reactive-betrayal.ts @@ -0,0 +1,204 @@ +import assert from 'node:assert/strict'; +import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine'; +import { baseDeck } from '../game/cards'; +import { botActions } from '../game/bots'; +import { greatMakerRideAction } from '../game/great-maker-options'; +import { NEXUS_FACTIONS, validateNexusCards } from '../game/nexus-cards'; +import { actFremenBetrayalModules, fremenBetrayalModulesClean, + type FremenBetrayalModulesStep } from './fixture-fremen-betrayal-modules'; +import { nextE3IndependentDukeStep } from './fixture-e3-independent-nexus-duke'; +import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus'; + +export type NexusFremenReactiveBetrayalStep = FremenBetrayalModulesStep; +export type NexusFremenReactiveBetrayalOptions = { + advanced?: boolean; + kind?: 'ordinary' | 'greatMaker'; +}; +export const actNexusFremenReactiveBetrayal = actFremenBetrayalModules; +export const nexusFremenReactiveBetrayalClean = fremenBetrayalModulesClean; +export function nexusFremenReactiveBetrayalPlayer(game: Game, id: string) { + const player = game.players.find(player => player.id === id); + assert.ok(player, `The original ${id} seat must remain present`); + return player; +} +export function nexusFremenReactiveBetrayalPhysical(game: Game) { + return game.players.map(player => ({ id: player.id, forces: structuredClone(player.forces), + reserves: player.reserves, tanks: player.tanks, spice: player.spice, + elites: structuredClone(player.elites), shipped: player.shipped, moved: player.moved })); +} +export function nexusFremenReactiveBetrayalInventory(game: Game) { + assert.ok(game.nexusCards?.cards); + validateNexusCards(game.nexusCards.cards, game.players); + const nexus = [...game.nexusCards.cards.deck, ...game.nexusCards.cards.discard, + ...Object.values(game.nexusCards.cards.hands).filter(card => card !== null)].sort(); + assert.deepEqual(nexus, [...NEXUS_FACTIONS].sort()); + const treachery = [...game.deck, ...game.discard, ...(game.auction?.cards.slice(game.auction.index) ?? []), + ...game.players.flatMap(player => player.hand)].map(card => card.id).sort(); + assert.deepEqual(treachery, baseDeck().map(card => card.id).sort()); + for (const player of game.players) { + assert.equal(player.reserves + player.tanks + Object.values(player.forces).reduce((sum, n) => sum + n, 0), 20); + if (player.elites) assert.equal(player.elites.reserves + player.elites.tanks + + Object.values(player.elites.forces).reduce((sum, n) => sum + n, 0), player.faction === 'emperor' ? 5 : 3); + } + return { nexus, treachery }; +} + +/** Only live native choices are answered. Great Maker votes deliberately earn + * its reserve ride; a later unused ride is declined by the ordinary driver. */ +export function nextNexusFremenReactiveBetrayalStep(game: Game): NexusFremenReactiveBetrayalStep | null { + if (!game.response && !game.phaseOpening && !game.pendingTreacheryDiscard) { + const decision = game.decision; + if (decision?.kind === 'nexusFremenRideBetrayal') return { actor: decision.player, + action: { type: 'decision', event: decision.event, use: false } }; + if (decision?.kind === 'greatMakerVote') return { actor: decision.player, + action: { type: 'decision', event: decision.event, yes: true } }; + if (decision?.kind === 'nexusFremenCunningOffer') return { actor: decision.player, + action: { type: 'decision', event: decision.event, accept: false } }; + } + return nextE3IndependentDukeStep(game); +} +export function advanceNexusFremenReactiveBetrayal(game: Game, until: (state: Game) => boolean, + actions: NexusFremenReactiveBetrayalStep[] = []): Game { + for (let count = 0; count < 1800; count++) { + if (until(game)) return game; + assert.notEqual(game.status, 'finished', 'The native reactive-worm source ended before its boundary'); + const step = nextNexusFremenReactiveBetrayalStep(game); + assert.ok(step, `The actual ${game.turn}/${game.phase}/${game.decision?.kind} needs an owned native action`); + game = actNexusFremenReactiveBetrayal(game, step.actor, step.action, actions); + } + throw Error('The native reactive-worm source did not reach its finite boundary'); +} + +/** Select only still-unopened physical Spice Cards, as in the original native + * Fremen modules programme. No discard, encounter or force is installed. */ +function orderSpice(game: Game, names: readonly string[]) { + const cards = names.map(name => { + const at = game.spiceDeck.findIndex(card => name === 'worm' + ? 'worm' in card && !card.greatMaker && !card.thumper && !card.suppressed + : name === 'maker' ? 'worm' in card && !!card.greatMaker + : 'territory' in card && !card.discovery && card.territory === name); + assert.ok(at >= 0, `The original ${name} card must still be unopened`); + return game.spiceDeck.splice(at, 1)[0]; + }); + game.spiceDeck.unshift(...cards); +} + +/** Genuine classic Nexus setup, real turn-two alliance/draw, real free Fremen + * reinforcement, and the following natural worm. The Discovery variant uses + * the already-approved Advanced Great Maker programme; neither mode is opened + * by altering a played game. Only unopened deck/scalar positions are selected. */ +export function createNexusFremenReactiveBetrayalFixture(options: NexusFremenReactiveBetrayalOptions = {}) { + const kind = options.kind ?? 'ordinary', advanced = options.advanced ?? kind === 'greatMaker'; + assert.ok(kind !== 'greatMaker' || advanced, 'This original Great Maker source uses its approved Advanced Discovery start'); + let game = withClassicDiscoveryNexusLottery(() => createGame('REACTIVEFREMENBETRAYAL', + newPlayer('fremen', 'Fremen', 'fremen'), advanced, [])); + joinGame(game, newPlayer('emperor', 'Emperor', 'emperor')); + joinGame(game, newPlayer('guild', 'Guild', 'guild')); + const actions: NexusFremenReactiveBetrayalStep[] = []; + const act = (actor: string, action: Action) => { + game = actNexusFremenReactiveBetrayal(game, actor, action, actions); + }; + const reach = (until: (state: Game) => boolean) => { + game = advanceNexusFremenReactiveBetrayal(game, until, actions); + }; + act(game.host, { type: 'moduleOption', module: 'nexusCards', enabled: true }); + if (kind === 'greatMaker') act(game.host, { type: 'moduleOption', module: 'discoveries', enabled: true }); + for (const player of game.players) act(player.id, { type: 'ready' }); + const initial = structuredClone(game); + act(game.host, advanced ? { type: 'start', advancedPreview: true } : { type: 'start' }); + const setup = structuredClone(game); + for (let count = 0; game.status === 'setup' && count < 120; count++) { + let step: NexusFremenReactiveBetrayalStep | undefined; + if (game.setupStage === 'forces' && nexusFremenReactiveBetrayalPlayer(game, 'fremen').reserves === 20) + step = { actor: 'fremen', action: { type: 'fremenSetup', placements: { 'sietch_tabr:14': 10 }, + ...(advanced ? { elitePlacements: { 'sietch_tabr:14': 1 } } : {}) } }; + else for (const player of game.players) { + const view = viewGame(game, player.id); + view.players.find(seat => seat.id === player.id)!.bot = 'Easy'; + const action = botActions(view)[0]; + if (action) { step = { actor: player.id, action }; break; } + } + assert.ok(step, 'Original private setup must expose a legal native next action'); + act(step.actor, step.action); + } + assert.equal(game.status, 'playing'); + const afterSetup = structuredClone(game), originalInventory = nexusFremenReactiveBetrayalInventory(game); + orderSpice(game, ['broken_land', ...(advanced ? ['south_mesa'] : [])]); + reach(state => state.turn === 2 && state.phase === 1 && nexusFremenReactiveBetrayalClean(state)); + const afterFirstTurn = structuredClone(game); + orderSpice(game, ['worm', 'hagga_basin', ...(advanced ? ['red_chasm'] : [])]); + reach(state => state.nexus && !state.spiceWindow && !state.spiceResolution && nexusFremenReactiveBetrayalClean(state)); + const beforeAlliance = structuredClone(game); + act('fremen', { type: 'alliance', target: 'emperor' }); + act('emperor', { type: 'alliance', target: 'fremen' }); + reach(state => state.nexusCards?.phase?.stage === 'drawing' && nexusFremenReactiveBetrayalClean(state)); + const cards = game.nexusCards!.cards!, at = cards.deck.indexOf('fremen'); + assert.ok(at >= 0, 'The Fremen Nexus singleton must remain in the original undrawn deck'); + cards.deck.unshift(cards.deck.splice(at, 1)[0]); + const beforeDraw = structuredClone(game), draw: NexusFremenReactiveBetrayalStep = { actor: 'guild', + action: { type: 'nexusCardChoice', turn: game.turn, card: cards.hands.guild, choice: 'draw', ownRedraws: 0 } }; + act(draw.actor, draw.action); + const afterDraw = structuredClone(game); + assert.equal(afterDraw.nexusCards!.cards!.hands.guild, 'fremen'); + reach(state => state.turn === 2 && state.phase === 5 && state.active === 'fremen' && nexusFremenReactiveBetrayalClean(state)); + const beforeShipment = structuredClone(game), shipment: NexusFremenReactiveBetrayalStep = { actor: 'fremen', + action: { type: 'ship', territory: 'hagga_basin', sector: 12, amount: 2, elite: advanced ? 1 : 0, allyPayment: 0 } }; + act(shipment.actor, shipment.action); + reach(nexusFremenReactiveBetrayalClean); + const afterShipment = structuredClone(game); + reach(state => state.turn === 3 && state.phase === 1 && nexusFremenReactiveBetrayalClean(state)); + const beforeAppearance = structuredClone(game); + orderSpice(game, [kind === 'greatMaker' ? 'maker' : 'worm', 'habbanya_erg', ...(advanced ? ['oh_gap'] : [])]); + reach(state => state.decision?.kind === 'nexusFremenRideBetrayal'); + const frame = game.nexusFremenRideBetrayal; + assert.ok(frame && game.decision?.kind === 'nexusFremenRideBetrayal'); + assert.equal(game.decision.player, 'guild'); + assert.equal(frame.source.kind, kind === 'greatMaker' ? 'greatMakerRide' : 'wormRide'); + assert.equal(nexusFremenReactiveBetrayalPlayer(game, 'guild').ally, null); + assert.equal(nexusFremenReactiveBetrayalPlayer(game, 'fremen').forces['hagga_basin:12'], 2); + const action: Action = { type: 'decision', event: game.decision.event, use: true }; + return { game, kind, advanced, holder: 'guild', rider: 'fremen', action, + initial, setup, afterSetup, afterFirstTurn, beforeAlliance, beforeDraw, draw, afterDraw, + beforeShipment, shipment, afterShipment, beforeAppearance, + originalFrame: structuredClone(frame), originalDecision: structuredClone(frame.source), + originalPhysical: nexusFremenReactiveBetrayalPhysical(game), originalInventory, + actions, sourceActionCount: actions.length, + staging: ['Only original unopened Spice/Nexus positions and scalar setup lotteries are selected. All setup, alliance, draw, reinforcement, worm, Nexus and Great Maker votes are native actions.'] }; +} +export type NexusFremenReactiveBetrayalFixture = ReturnType; + +export function passNexusFremenReactiveBetrayal(game: Game, + actions: NexusFremenReactiveBetrayalStep[] = []): Game { + for (let count = 0; game.decision?.kind === 'nexusFremenRideBetrayal' && count < 7; count++) { + const decision = game.decision; + game = actNexusFremenReactiveBetrayal(game, decision.player, + { type: 'decision', event: decision.event, use: false }, actions); + } + assert.notEqual(game.decision?.kind, 'nexusFremenRideBetrayal'); + return game; +} +export function useNexusFremenReactiveBetrayal(fixture: NexusFremenReactiveBetrayalFixture, + game = structuredClone(fixture.game), actions: NexusFremenReactiveBetrayalStep[] = []) { + return actNexusFremenReactiveBetrayal(game, fixture.holder, fixture.action, actions); +} +export function rideNexusFremenReactiveBetrayal(fixture: NexusFremenReactiveBetrayalFixture, + game: Game, actions: NexusFremenReactiveBetrayalStep[] = []) { + let action: Action; + if (fixture.kind === 'greatMaker') { + const native = greatMakerRideAction(viewGame(game, fixture.rider), 'polar_sink', 0, 1, 1); + assert.ok(native, 'The restored native Great Maker control must supply its original typed reserve ride'); + action = native; + } else { + assert.equal(game.decision?.kind, 'wormRide'); + action = { type: 'decision', accept: true, territory: 'polar_sink', sector: 0, + forces: { 'hagga_basin:12': 2 }, ...(fixture.advanced ? { eliteForces: { 'hagga_basin:12': 1 } } : {}) }; + } + const before = structuredClone(game); + game = actNexusFremenReactiveBetrayal(game, fixture.rider, action, actions); + return { game, before, action, after: structuredClone(game) }; +} +export function finishNexusFremenReactiveBetrayal(fixture: NexusFremenReactiveBetrayalFixture, + game: Game, actions: NexusFremenReactiveBetrayalStep[] = []) { + return advanceNexusFremenReactiveBetrayal(game, state => state.turn === fixture.game.turn && + state.phase === 2 && nexusFremenReactiveBetrayalClean(state), actions); +} diff --git a/tests/nexus-fremen-reactive-betrayal-engine.test.ts b/tests/nexus-fremen-reactive-betrayal-engine.test.ts new file mode 100644 index 00000000..0fe7e970 --- /dev/null +++ b/tests/nexus-fremen-reactive-betrayal-engine.test.ts @@ -0,0 +1,179 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { viewGame, type Action, type Game } from '../game/engine'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { + actNexusFremenReactiveBetrayal, createNexusFremenReactiveBetrayalFixture, + finishNexusFremenReactiveBetrayal, nexusFremenReactiveBetrayalInventory, + nexusFremenReactiveBetrayalPhysical, nexusFremenReactiveBetrayalPlayer as player, + passNexusFremenReactiveBetrayal, rideNexusFremenReactiveBetrayal, + useNexusFremenReactiveBetrayal, type NexusFremenReactiveBetrayalFixture, +} from './fixture-nexus-fremen-reactive-betrayal'; + +function rejected(game: Game, actor: string, action: Action) { + const before = structuredClone(game); + assert.throws(() => actNexusFremenReactiveBetrayal(game, actor, action)); + assert.deepEqual(game, before); +} +function completedReceipt(game: Game, f: NexusFremenReactiveBetrayalFixture, usedBy: string | null) { + const receipt = game.nexusFremenRideBetrayal; + assert.ok(receipt); + assert.equal(receipt.stage, 'complete'); + assert.equal(receipt.usedBy, usedBy); + assert.equal(receipt.event, f.originalFrame.event); + assert.equal(receipt.turn, f.originalFrame.turn); + assert.deepEqual(receipt.source, f.originalDecision); + assert.deepEqual(receipt.order, f.originalFrame.order); + assert.ok(receipt.signature); + assert.notEqual(receipt.signature, f.originalFrame.signature); + if (usedBy === null) assert.deepEqual(receipt.passed, receipt.order); + for (const seat of game.players) + assert.equal(viewGame(game, seat.id).nexusFremenRideBetrayal ?? null, null); +} +function source(f: NexusFremenReactiveBetrayalFixture) { + assert.equal(f.game.status, 'playing'); + assert.equal(f.game.turn, 3); + assert.equal(f.game.phase, 1); + assert.equal(f.game.response ?? null, null); + assert.equal(f.game.phaseOpening ?? null, null); + assert.ok(f.game.decision?.kind === 'nexusFremenRideBetrayal'); + assert.equal(f.game.decision.player, f.holder); + assert.equal(f.beforeDraw.nexusCards!.cards!.hands[f.holder], null); + assert.equal(f.afterDraw.nexusCards!.cards!.hands[f.holder], 'fremen'); + assert.equal(f.game.nexusCards!.cards!.hands[f.holder], 'fremen'); + assert.equal(player(f.game, f.holder).ally, null); + assert.equal(player(f.game, f.rider).ally, 'emperor'); + assert.equal(player(f.game, 'emperor').ally, f.rider); + assert.equal(f.draw.action.type, 'nexusCardChoice'); + assert.equal(f.shipment.action.type, 'ship'); + assert.equal(f.shipment.action.amount, 2); + assert.equal(player(f.afterShipment, f.rider).reserves, player(f.beforeShipment, f.rider).reserves - 2); + assert.equal(player(f.afterShipment, f.rider).spice, player(f.beforeShipment, f.rider).spice); + assert.equal(player(f.game, f.rider).forces['hagga_basin:12'], 2); + assert.deepEqual(player(f.game, f.rider).forces, player(f.beforeAppearance, f.rider).forces, + 'The real worm and neutral counter window have not transported the native survivors'); + assert.equal(f.game.nexusCards!.phase!.occurred, true); + assert.ok(f.actions.some(step => step.actor === f.rider && step.action.type === 'alliance')); + assert.ok(f.actions.some(step => step.actor === f.holder && step.action.type === 'nexusCardChoice' && step.action.choice === 'draw')); + assert.ok(!f.actions.some(step => step.action.type === 'nexusFremenBetrayal'), 'No advance Betrayal substitutes for the reactive source'); + assert.equal(f.sourceActionCount, f.actions.length); + assert.deepEqual(nexusFremenReactiveBetrayalInventory(f.game), f.originalInventory); + assert.equal(f.game.homeworlds ?? null, null); + assert.equal(f.game.leaderSkills ?? null, null); + assert.equal(f.game.techTokens ?? null, null); + if (f.kind === 'greatMaker') { + assert.ok(f.game.discoveries); + assert.equal(f.originalDecision.kind, 'greatMakerRide'); + assert.equal(f.game.greatMaker!.stage, 'ride'); + assert.deepEqual(f.game.greatMaker!.votes.map(vote => vote.yes), f.game.order.map(() => true)); + } else { + assert.equal(f.game.discoveries ?? null, null); + assert.deepEqual(f.originalDecision, { kind: 'wormRide', player: f.rider, territory: 'hagga_basin' }); + assert.ok(f.actions.some(step => step.action.type === 'ready'), 'Actual native readiness completes the Nexus before its pending ride'); + } +} + +const variants = [ + { kind: 'ordinary', advanced: false, label: 'Basic ordinary' }, + { kind: 'ordinary', advanced: true, label: 'Advanced ordinary' }, + { kind: 'greatMaker', advanced: true, label: 'Advanced Great Maker' }, +] as const; + +for (const variant of variants) { + void test(`genuine ${variant.label} reactive Fremen Betrayal spends the native draw and stops only the unmade ride`, () => { + const f = createNexusFremenReactiveBetrayalFixture(variant); + source(f); + const physical = nexusFremenReactiveBetrayalPhysical(f.game); + let game = useNexusFremenReactiveBetrayal(f); + completedReceipt(game, f, f.holder); + assert.equal(game.nexusCards!.cards!.hands[f.holder], null); + assert.equal(game.nexusCards!.cards!.discard.filter(card => card === 'fremen').length, 1); + assert.equal(player(game, f.rider).fremenNexusWormBlockedTurn, game.turn); + assert.deepEqual(nexusFremenReactiveBetrayalPhysical(game), physical, + 'The response spends its original Nexus card without moving or killing surviving riders'); + assert.equal(game.nexusFremenBetrayalHistory!.at(-1)!.mode, 'worm'); + assert.equal(game.nexusFremenBetrayalHistory!.at(-1)!.owner, f.holder); + assert.deepEqual(game.wormRides, []); + rejected(game, f.holder, f.action); + game = finishNexusFremenReactiveBetrayal(f, game); + assert.equal(game.phase, 2); + assert.equal(game.nexusCards!.phase!.stage, 'complete'); + assert.equal(player(game, f.rider).forces['hagga_basin:12'], 2); + assert.equal(player(game, f.rider).forces['polar_sink:0'] ?? 0, 0); + assert.equal(player(game, f.rider).reserves, player(f.game, f.rider).reserves); + assert.equal(game.decision?.kind === 'wormRide' || game.decision?.kind === 'greatMakerRide', false); + assert.equal(game.nexusFremenBetrayalHistory!.filter(row => row.turn === f.game.turn).length, 1); + assert.deepEqual(nexusFremenReactiveBetrayalInventory(game), f.originalInventory); + }); + + void test(`genuine ${variant.label} all-pass restores the exact native ride and actually transports its original counters`, () => { + const f = createNexusFremenReactiveBetrayalFixture(variant); + source(f); + const queue = structuredClone(f.game.wormRides), maker = structuredClone(f.game.greatMaker); + const passed = passNexusFremenReactiveBetrayal(structuredClone(f.game)); + completedReceipt(passed, f, null); + assert.deepEqual(passed.decision, f.originalDecision); + assert.deepEqual(passed.wormRides, queue); + assert.deepEqual(passed.greatMaker, maker); + assert.deepEqual(nexusFremenReactiveBetrayalPhysical(passed), f.originalPhysical); + assert.equal(passed.nexusCards!.cards!.hands[f.holder], 'fremen'); + assert.equal(passed.nexusFremenBetrayalHistory?.length ?? 0, 0); + const ride = rideNexusFremenReactiveBetrayal(f, passed); + const before = player(ride.before, f.rider), after = player(ride.after, f.rider); + const amount = f.kind === 'greatMaker' ? 1 : 2; + assert.equal(after.forces['polar_sink:0'], amount); + assert.equal(after.spice, before.spice); + assert.equal(after.shipped, before.shipped); + assert.equal(after.moved, before.moved); + if (f.kind === 'greatMaker') { + assert.equal(after.reserves, before.reserves - 1); + assert.equal(after.elites!.reserves, before.elites!.reserves - 1); + assert.equal(after.forces['hagga_basin:12'], 2); + } else { + assert.equal(after.reserves, before.reserves); + assert.equal(after.forces['hagga_basin:12'] ?? 0, 0); + } + if (f.advanced) assert.equal(after.elites!.forces['polar_sink:0'], 1); + const game = finishNexusFremenReactiveBetrayal(f, ride.game); + assert.equal(game.phase, 2); + assert.equal(game.nexusCards!.phase!.stage, 'complete'); + assert.equal(game.nexusCards!.cards!.hands[f.holder], 'fremen'); + assert.equal(game.nexusFremenBetrayalHistory?.length ?? 0, 0); + assert.equal(player(game, f.rider).forces['polar_sink:0'], amount, 'Only the original accepted ride moved these counters'); + assert.deepEqual(nexusFremenReactiveBetrayalInventory(game), f.originalInventory); + }); + + void test(`genuine ${variant.label} reactive window rejects foreign, stale and underlying ride replies without spending`, () => { + const f = createNexusFremenReactiveBetrayalFixture(variant); + rejected(f.game, f.rider, f.action); + rejected(f.game, 'emperor', f.action); + rejected(f.game, f.holder, { ...f.action, event: 'stale' }); + rejected(f.game, f.holder, { type: 'decision', event: f.action.event, use: 'yes' }); + rejected(f.game, f.rider, f.kind === 'greatMaker' + ? { type: 'decision', event: f.game.greatMaker!.event, accept: false } + : { type: 'decision', accept: false }); + assert.equal(f.game.nexusCards!.cards!.hands[f.holder], 'fremen'); + assert.deepEqual(nexusFremenReactiveBetrayalPhysical(f.game), f.originalPhysical); + assert.deepEqual(nexusFremenReactiveBetrayalInventory(f.game), f.originalInventory); + }); +} + +void test('four minimal profiles answer the genuine reactive Fremen ride window with an exact legal native decision', () => { + for (const variant of variants) { + const f = createNexusFremenReactiveBetrayalFixture(variant); + for (const difficulty of DIFFICULTIES) { + const view = viewGame(f.game, f.holder); + view.players.find(seat => seat.id === f.holder)!.bot = difficulty; + const action = botActions(view)[0]; + assert.ok(action, `${variant.label}/${difficulty}`); + assert.equal(action.type, 'decision'); + assert.equal(action.event, f.action.event); + assert.equal(typeof action.use, 'boolean'); + const game = actNexusFremenReactiveBetrayal(structuredClone(f.game), f.holder, action); + assert.notEqual(game.decision?.kind, 'nexusFremenRideBetrayal'); + assert.deepEqual(nexusFremenReactiveBetrayalPhysical(game), f.originalPhysical); + assert.deepEqual(nexusFremenReactiveBetrayalInventory(game), f.originalInventory); + } + } +}); diff --git a/tests/nexus-fremen-ride-betrayal-controls.test.tsx b/tests/nexus-fremen-ride-betrayal-controls.test.tsx new file mode 100644 index 00000000..20b35c5a --- /dev/null +++ b/tests/nexus-fremen-ride-betrayal-controls.test.tsx @@ -0,0 +1,115 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { Children, createElement, isValidElement, type ReactNode } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { NexusFremenRideBetrayal } from '../components/nexus-fremen-ride-betrayal'; +import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine'; + +type Source = NonNullable['source']; +// Controlled projection fixtures isolate the actual hook-free callbacks. The +// separate native source suite earns and restores the worm/Cunning/Maker ride; +// these controls tests install no played Game custody or pending decision. +function projected(kind: Source['kind'] = 'wormRide'): GameView { + const game = createGame('NEXUSFREMENRIDECONTROLS', newPlayer('a', 'Atreides', 'atreides'), false); + joinGame(game, newPlayer('f', 'Fremen', 'fremen')); + joinGame(game, newPlayer('g', 'Guild', 'guild')); + const view = viewGame(game, 'a'); view.status = 'playing'; view.phase = 1; + const source: Source = kind === 'wormRide' + ? { kind, player: 'f', territory: 'hagga_basin' } + : { kind, player: 'f', event: 'controlled-original-ride-event' }; + view.nexusFremenRideBetrayal = { + event: 'controlled-before-ride-event', player: 'a', source, canUse: true, passed: [], + }; + return view; +} +function markup(game: GameView, busy = false) { + return renderToStaticMarkup(createElement(NexusFremenRideBetrayal, { game, busy, act() {} })); +} +function click(node: ReactNode, choice: 'continue' | 'prevent'): boolean { + for (const child of Children.toArray(node)) { + if (!isValidElement<{ + children?: ReactNode; onClick?: () => void; 'data-testid'?: string; + }>(child)) continue; + if (child.props['data-testid'] === `nexus-fremen-ride-${choice}` && child.props.onClick) { + child.props.onClick(); return true; + } + if (click(child.props.children, choice)) return true; + } + return false; +} +function choose(game: GameView, choice: 'continue' | 'prevent', busy = false): Action[] { + const actions: Action[] = []; + click(NexusFremenRideBetrayal({ game, busy, act(action) { actions.push(action); } }), choice); + return actions; +} + +void test('the current responder sends exact before-ride Continue and prevention actions for all three sources', () => { + for (const kind of ['wormRide', 'nexusFremenCunningRide', 'greatMakerRide'] as const) { + const game = projected(kind), event = game.nexusFremenRideBetrayal!.event; + assert.deepEqual(choose(game, 'continue'), [{ type: 'decision', event, use: false }]); + assert.deepEqual(choose(game, 'prevent'), [{ type: 'decision', event, use: true }]); + assert.equal(Object.keys(choose(game, 'prevent')[0]).sort().join(','), 'event,type,use'); + assert.doesNotMatch(markup(game), /controlled-original-ride-event/); + } +}); + +void test('the neutral panel names each public ride source without a secret card face', () => { + assert.match(markup(projected()), /Fremen has a worm ride from Hagga Basin waiting/); + assert.match(markup(projected('nexusFremenCunningRide')), /Fremen has a Fremen Nexus Cunning ride waiting/); + assert.match(markup(projected('greatMakerRide')), /Fremen has a Great Maker ride waiting/); + assert.match(markup(projected()), /aria-label="Fremen Nexus before-ride Betrayal"/); +}); + +void test('a projected no-Use responder still has the actual Continue callback', () => { + const game = projected(); game.nexusFremenRideBetrayal!.canUse = false; + const html = markup(game); + assert.match(html, /Continue · allow rides/); assert.doesNotMatch(html, /data-testid="nexus-fremen-ride-prevent"/); + assert.deepEqual(choose(game, 'continue'), [{ type: 'decision', event: 'controlled-before-ride-event', use: false }]); + assert.deepEqual(choose(game, 'prevent'), []); + assert.match(html, /without revealing anyone’s secret Nexus identity/); +}); + +void test('waiting and already-passed viewers retain the public guide without another response button', () => { + const waiting = projected(); waiting.me = 'g'; + assert.match(markup(waiting), /Waiting for Atreides to respond before the ride/); + assert.doesNotMatch(markup(waiting), / { + const game = projected(); + assert.deepEqual(choose(game, 'continue', true), []); assert.deepEqual(choose(game, 'prevent', true), []); + assert.equal((markup(game, true).match(/]*\sdisabled=""/g) ?? []).length, 2); + assert.match(markup(game, true), /The worm, Spice Blow and any Nexus that have already resolved stay resolved/); + game.status = 'finished'; + assert.deepEqual(choose(game, 'continue'), []); assert.deepEqual(choose(game, 'prevent'), []); +}); + +void test('the persistent guide preserves completed effects and describes prevention without moving forces or replaying payment', () => { + const game = projected(); + for (let render = 0; render < 3; render++) { + const html = markup(game); + assert.match(html, /Continue to allow the original ride choices/); + assert.match(html, /prevent the remaining rides this turn/); + assert.match(html, /The worm, Spice Blow and any Nexus that have already resolved stay resolved/); + assert.match(html, /Prevention does not move or destroy forces/); + assert.match(html, /Previous payments and effects are not repeated/); + assert.doesNotMatch(html, /countdown|automatically continue|seconds remaining|Confirm|Acknowledge/); + } +}); + +void test('a missing reaction renders nothing and private hands or wallets cannot determine Use', () => { + const absent = projected(); absent.nexusFremenRideBetrayal = null; + assert.equal(markup(absent), ''); assert.deepEqual(choose(absent, 'continue'), []); + const game = projected(); + Object.defineProperty(game, 'nexusCards', { get() { throw Error('The response control inspected a private Nexus face'); } }); + for (const player of game.players) for (const field of ['hand', 'spice', 'forces', 'elites', 'traitors']) + Object.defineProperty(player, field, { get() { throw Error(`The response control read ${field}`); } }); + assert.match(markup(game), /Use Fremen Nexus · prevent remaining rides/); + assert.deepEqual(choose(game, 'prevent'), [{ type: 'decision', event: 'controlled-before-ride-event', use: true }]); +}); diff --git a/tests/nexus-fremen-ride-betrayal-quote.test.ts b/tests/nexus-fremen-ride-betrayal-quote.test.ts new file mode 100644 index 00000000..dc4bedca --- /dev/null +++ b/tests/nexus-fremen-ride-betrayal-quote.test.ts @@ -0,0 +1,210 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createGame, joinGame, newPlayer, type Game } from '../game/engine'; +import { createNexusCards, discardNexusCard, validateNexusCards } from '../game/nexus-cards'; +import type { FactionId } from '../game/catalog'; +import { greatMakerSignature } from '../game/great-maker'; +import { quoteFremenCunningRide } from '../game/nexus-fremen-cunning'; +import { + createFremenRideBetrayal as create, fremenRideBetrayalCurrentOwner as owner, + passFremenRideBetrayal as pass, useFremenRideBetrayal as use, + validateFremenRideBetrayal as validate, FremenRideBetrayalError, + type FremenRideBetrayalFrame as Frame, type FremenRideBetrayalParent as Parent, +} from '../game/nexus-fremen-ride-betrayal'; + +const EVENT = '4b673e18-f130-4133-b93d-f044ecae3370'; +const nativeParent: Parent = { kind: 'wormRide', player: 'fremen', territory: 'hagga_basin' }; +/** Explicitly controlled pure producer inputs, not a native acquisition or + * appearance programme. The real twelve singleton cards are conserved. */ +function position(advanced = false): Game { + const g = createGame('FREMENRIDEQUOTES', newPlayer('fremen', 'Fremen', 'fremen'), advanced); + for (const faction of ['atreides', 'emperor', 'guild'] as const) joinGame(g, newPlayer(faction, faction, faction)); + g.status = 'playing'; g.turn = 2; g.phase = 1; g.storm = 1; + g.order = ['emperor', 'fremen', 'atreides', 'guild']; + g.nexusCards = { cards: createNexusCards(g.players, () => 0.9), phase: null }; + g.players.find(p => p.id === 'fremen')!.forces = { 'hagga_basin:13': 2 }; + g.players.find(p => p.id === 'fremen')!.reserves = 18; + g.spice = { 'oh_gap:10': 6 }; g.wormRides = ['false_wall_south']; + g.decision = structuredClone(nativeParent); + give(g, 'emperor', 'fremen'); give(g, 'atreides', 'richese'); + return g; +} +function give(g: Game, actor: string, face: FactionId) { + const cards = g.nexusCards!.cards!; + const old = cards.hands[actor]; if (old) cards.deck.push(old); + const index = cards.deck.indexOf(face); assert.ok(index >= 0, 'Only a real singleton Nexus card can move to this pure hand'); + cards.deck.splice(index, 1); cards.hands[actor] = face; validateNexusCards(cards, g.players); +} +function wrapper(g: Game, frame: Frame) { + const actor = owner(frame); assert.ok(actor); + g.decision = { kind: 'nexusFremenRideBetrayal', player: actor, event: frame.event }; +} +function open(g: Game, parent: Parent = nativeParent): Frame { + g.decision = structuredClone(parent); + const frame = create(g, parent, EVENT); assert.ok(frame); return frame; +} +function maker(g: Game): Parent { + g.greatMaker = { arrival: null, event: 'original-maker-encounter', turn: g.turn, pile: 0, index: 0, + territory: null, stage: 'ride', order: [...g.order], + votes: g.order.map(player => ({ player, yes: true })), ridesBefore: [], signature: '' }; + g.greatMaker.signature = greatMakerSignature(g.greatMaker); + return { kind: 'greatMakerRide', player: 'fremen', event: g.greatMaker.event }; +} +function resign(frame: Frame): void { + frame.signature = JSON.stringify(['nexusFremenRideBetrayal', frame.event, frame.turn, + [frame.source.kind, frame.source.player, frame.source.kind === 'wormRide' ? frame.source.territory : frame.source.event], + frame.stage, frame.order, frame.passed, frame.usedBy]); +} + +for (const advanced of [false, true]) { + const band = advanced ? 'Advanced' : 'Basic'; + void test(`${band} before-ride Betrayal uses public Nexus backs and original storm order without moving anything`, () => { + const g = position(advanced), before = structuredClone(g), frame = open(g); + assert.deepEqual(frame.order, ['emperor', 'atreides']); assert.deepEqual(frame.passed, []); + assert.equal(owner(frame), 'emperor'); assert.equal(frame.stage, 'choose'); assert.equal(frame.usedBy, null); + assert.deepEqual(frame.source, nativeParent); assert.equal(frame.event, EVENT); validate(g, frame); + assert.deepEqual(g, before); assert.notStrictEqual(frame.source, g.decision); + const hidden = structuredClone(g); + give(hidden, 'emperor', 'ixians'); give(hidden, 'atreides', 'harkonnen'); + const other = create(hidden, nativeParent, EVENT); assert.deepEqual(other, frame, 'Different hidden faces cannot change the public pause'); + give(hidden, 'guild', 'fremen'); + assert.deepEqual(create(hidden, nativeParent, EVENT)!.order, ['emperor', 'atreides', 'guild']); + }); + + void test(`${band} every original acknowledgement passes once and returns the exact source decision`, () => { + const g = position(advanced); let frame = open(g); const before = structuredClone(g); + wrapper(g, frame); validate(g, frame); + frame = pass(g, frame, 'emperor', EVENT); assert.equal(owner(frame), 'atreides'); + assert.deepEqual(frame.passed, ['emperor']); wrapper(g, frame); validate(g, frame); + frame = pass(g, frame, 'atreides', EVENT); + assert.equal(frame.stage, 'complete'); assert.equal(owner(frame), null); assert.equal(frame.usedBy, null); + assert.deepEqual(frame.passed, frame.order); assert.deepEqual(frame.source, before.decision); + g.decision = structuredClone(frame.source); validate(g, frame); + assert.deepEqual(g, before, 'All-pass bookkeeping does not replay payment, spice, destruction or a ride'); + assert.throws(() => pass(g, frame, 'atreides', EVENT), FremenRideBetrayalError); + const saved: Frame = JSON.parse(JSON.stringify(frame)); validate(g, saved); assert.deepEqual(saved.source, nativeParent); + }); + + void test(`${band} a current responder completes Use before the engine spends its actual Fremen card`, () => { + const g = position(advanced), frame = open(g); wrapper(g, frame); + const before = structuredClone(g), used = use(g, frame, 'emperor', EVENT); + assert.equal(used.stage, 'complete'); assert.equal(used.usedBy, 'emperor'); assert.deepEqual(used.passed, []); + assert.equal(owner(used), null); assert.deepEqual(g, before, 'The pure wrapper spends no card and changes no physical state'); + assert.equal(g.nexusCards!.cards!.hands.emperor, 'fremen'); + g.nexusCards!.cards = discardNexusCard(g.nexusCards!.cards!, 'emperor', g.players); + g.players.find(p => p.id === 'fremen')!.fremenNexusWormBlockedTurn = g.turn; + g.decision = null; g.wormRides = []; validate(g, used); + assert.equal(g.nexusCards!.cards!.discard.filter(card => card === 'fremen').length, 1); + assert.throws(() => use(g, used, 'emperor', EVENT), FremenRideBetrayalError); + }); +} + +void test('only unallied non-Fremen seats with a publicly present Nexus back participate', () => { + const g = position(); give(g, 'guild', 'ecaz'); + g.players.find(p => p.id === 'emperor')!.ally = 'guild'; + g.players.find(p => p.id === 'guild')!.ally = 'emperor'; + assert.deepEqual(open(g).order, ['atreides']); + const none = position(); + none.nexusCards!.cards = discardNexusCard(none.nexusCards!.cards!, 'atreides', none.players); + // Fremen can hold an irrelevant physical card, but is not a Betrayal responder. + give(none, 'fremen', 'tleilaxu'); + none.nexusCards!.cards!.deck.push(none.nexusCards!.cards!.hands.emperor!); + none.nexusCards!.cards!.hands.emperor = null; + validateNexusCards(none.nexusCards!.cards!, none.players); + assert.equal(create(none, nativeParent, EVENT), null); +}); + +void test('public Fremen discard, an earlier prevention marker or unsupported profile creates no pointless pause', () => { + const discarded = position(); + discarded.nexusCards!.cards = discardNexusCard(discarded.nexusCards!.cards!, 'emperor', discarded.players); + assert.equal(create(discarded, nativeParent, EVENT), null); + const blocked = position(); blocked.players.find(p => p.id === 'fremen')!.fremenNexusWormBlockedTurn = blocked.turn; + assert.equal(create(blocked, nativeParent, EVENT), null); + const wrongPhase = position(); wrongPhase.phase = 5; assert.equal(create(wrongPhase, nativeParent, EVENT), null); + const unsupported = position(); unsupported.strongholdCards = { owners: { + arrakeen: null, carthag: null, habbanya_ridge_sietch: null, sietch_tabr: null, tueks_sietch: null, + hidden_mobile_stronghold: null, + }, claimedTurn: 0 }; + assert.equal(create(unsupported, nativeParent, EVENT), null, 'Basic Strongholds are outside the existing native profile'); +}); + +void test('current actor, exact event, ordered prefix and published wrapper identity remain authoritative', () => { + const g = position(), frame = open(g); wrapper(g, frame); const before = structuredClone(g); + assert.throws(() => pass(g, frame, 'atreides', EVENT), FremenRideBetrayalError); + assert.throws(() => use(g, frame, 'fremen', EVENT), FremenRideBetrayalError); + assert.throws(() => pass(g, frame, 'emperor', 'stale-event'), FremenRideBetrayalError); + const advanced = pass(g, frame, 'emperor', EVENT); + assert.throws(() => validate(g, advanced), FremenRideBetrayalError, 'Old wrapper owner cannot own the next acknowledgement'); + wrapper(g, advanced); validate(g, advanced); + const wrong = structuredClone(frame); wrong.passed = ['atreides']; resign(wrong); + assert.throws(() => validate(g, wrong), FremenRideBetrayalError); + assert.deepEqual(g.players, before.players); assert.deepEqual(g.nexusCards, before.nexusCards); +}); + +void test('creation and active validation bind the actual ordinary source after it has left the pending ride queue', () => { + const g = position(); assert.equal(g.wormRides.includes('hagga_basin'), false); + const frame = open(g); wrapper(g, frame); validate(g, frame); + const foreign = structuredClone(g); foreign.decision = { ...nativeParent, player: 'atreides' }; + assert.throws(() => create(foreign, foreign.decision as Parent, EVENT), FremenRideBetrayalError); + const empty = structuredClone(g); empty.players.find(p => p.id === 'fremen')!.forces = {}; + assert.throws(() => validate(empty, frame), FremenRideBetrayalError); + const storm = structuredClone(g); storm.storm = 13; assert.throws(() => validate(storm, frame), FremenRideBetrayalError); + const cleared = structuredClone(g); cleared.decision = null; + assert.throws(() => validate(cleared, frame), FremenRideBetrayalError); + const other = structuredClone(frame); other.source = { ...nativeParent, territory: 'oh_gap' }; + assert.throws(() => validate(g, other), FremenRideBetrayalError); +}); + +void test('an actual Great Maker ride frame retains its event, native reserve source and completed votes', () => { + const g = position(true), parent = maker(g), frame = open(g, parent), before = structuredClone(g.greatMaker); + wrapper(g, frame); validate(g, frame); + const passed = pass(g, frame, 'emperor', EVENT); wrapper(g, passed); + const completed = pass(g, passed, 'atreides', EVENT); + assert.deepEqual(completed.source, parent); assert.deepEqual(g.greatMaker, before); + const stale = structuredClone(g); stale.greatMaker!.stage = 'complete'; + assert.throws(() => validate(stale, passed), FremenRideBetrayalError); + const other = structuredClone(g); other.greatMaker!.event = 'different-maker'; + assert.throws(() => validate(other, passed), FremenRideBetrayalError); + const empty = structuredClone(g); empty.players.find(p => p.id === 'fremen')!.reserves = 0; + assert.throws(() => validate(empty, passed), FremenRideBetrayalError); +}); + +void test('valid pending Cunning already exposes the sole Fremen card in discard and skips the neutral window', () => { + const g = position(); + // Conserved pure producer input: its original Cunning cost is already spent, + // so no opposing seat can simultaneously hold a second Fremen card. + give(g, 'emperor', 'ecaz'); give(g, 'fremen', 'fremen'); + g.nexusCards!.cards = discardNexusCard(g.nexusCards!.cards!, 'fremen', g.players); + const occurrence = { event: 'original-cunning-appearance', turn: g.turn, territory: 'oh_gap', + origin: 'natural' as const, pile: 0 as const, initiallyEmpty: true }; + const authorization = { event: occurrence.event, turn: g.turn, owner: 'fremen' }; + g.nexusFremenCunningRides = [{ occurrence, authorization, stage: 'select' }]; + const parent: Parent = { kind: 'nexusFremenCunningRide', player: 'fremen', event: occurrence.event }; + g.decision = parent; + assert.ok(quoteFremenCunningRide(g, 'fremen', occurrence, authorization)); + assert.equal(create(g, parent, EVENT), null); validateNexusCards(g.nexusCards!.cards!, g.players); + assert.equal(g.nexusCards!.cards!.discard.filter(card => card === 'fremen').length, 1); +}); + +void test('changed public order, lost Nexus back, alliance or turn cannot continue an old active acknowledgement', () => { + const g = position(), frame = open(g); wrapper(g, frame); + const reordered = structuredClone(g); reordered.order.reverse(); + assert.throws(() => validate(reordered, frame), FremenRideBetrayalError); + const absent = structuredClone(g); + absent.nexusCards!.cards = discardNexusCard(absent.nexusCards!.cards!, 'atreides', absent.players); + assert.throws(() => validate(absent, frame), FremenRideBetrayalError); + const allied = structuredClone(g); allied.players.find(p => p.id === 'atreides')!.ally = 'guild'; + allied.players.find(p => p.id === 'guild')!.ally = 'atreides'; + assert.throws(() => validate(allied, frame), FremenRideBetrayalError); + const later = structuredClone(g); later.turn++; assert.throws(() => validate(later, frame), FremenRideBetrayalError); +}); + +void test('signed completed progress is detached from later native physical continuation', () => { + const g = position(), frame = open(g); wrapper(g, frame); + const completed = use(g, frame, 'emperor', EVENT), saved: Frame = JSON.parse(JSON.stringify(completed)); + g.nexusCards!.cards = discardNexusCard(g.nexusCards!.cards!, 'emperor', g.players); + g.decision = null; g.phase = 2; g.wormRides = []; g.turn++; + validate(g, saved); assert.equal(owner(saved), null); assert.deepEqual(saved.source, nativeParent); + const changed = structuredClone(saved); changed.source = { ...nativeParent, territory: 'oh_gap' }; + assert.throws(() => validate(g, changed), FremenRideBetrayalError); +});