import { validAmbassadorResume } from './ambassador-resume'; import { ecazOccupancyRelation } from './ecaz-occupy'; import { territoryEntryBlock, strongholdPathBlocked } from './occupancy'; import { validateGuildAmbassadorArrivalContext, GuildAmbassadorContinuationError, } from './guild-ambassador-continuation'; import { quoteGuildAmbassadorAdvisor, quoteGuildAmbassadorShipment, guildAmbassadorShipments, GuildAmbassadorShipmentError, type GuildAmbassadorShipment, } from './guild-ambassador'; import { quoteFremenAmbassadorMove, fremenAmbassadorMovement, FremenAmbassadorMoveError, type FremenAmbassadorMove, } from './fremen-ambassador'; import { quoteEcazDukeAcquisition, ecazDukeAcquisitionBlock, EcazDukeAcquisitionError, } from './ecaz-duke-acquisition'; import { quotePlacementCancellation, PlacementCancellationError, } from './placement-cancellation'; import { quotePhaseResources, PhaseResourceError, type PhaseResourceQuote, } from './phase-resource-quote'; import { quoteVictory, VictoryQuoteError, type VictoryQuote, } from './victory-quote'; import { createSpiceAllocation, quoteSpiceAllocation, SpiceAllocationError, type AllocationState, type SpiceAllocationAction, type SpiceAllocationReceipt, } from './ecaz-spice-allocation'; import { strongholdProgress, VictoryProgressError, type StrongholdProgress, } from './victory-progress'; import { quoteMovementPhaseStart, quoteAdvisorBattleOffer, MovementPhaseQuoteError, type MovementPhaseQuote, } from './movement-phase-quote'; import { quoteRevivalResume, RevivalResumeError, type RevivalResumeQuote, } from './revival-resume'; import { quoteChoamWorthlessCancellation, ChoamWorthlessCancellationError, } from './choam-worthless-cancellation'; import { choamStormTerritories, quoteChoamStormOffer, ChoamStormQuoteError, type ChoamStormQuote, } from './choam-storm-quote'; import { quoteAuctionContinuation, AuctionContinuationError, type AuctionContinuationOperation, type AuctionContinuationQuote, } from './auction-continuation-quote'; import { quoteNormalAuctionPeek, quoteIxTechnologyCancellation, IxTechnologyCancellationError, } from './ix-technology-cancellation'; import { quoteIxAuctionDraw, IxAuctionDrawError, } from './ix-auction-draw-quote'; import { quoteMoritaniAllianceCancellation, MoritaniAllianceCancellationError, } from './moritani-alliance-cancellation'; import { quoteRicheseCancellation, RicheseCancellationError, } from './richese-cancellation'; import { quoteIxSubstitutionCancellation, IxSubstitutionCancellationError, } from './ix-substitution-cancellation'; import { quoteCombatResponse, isCombatResponseKind, CombatResponseQuoteError, type CombatResponseOperation, type CombatResponseQuote, } from './combat-response-quote'; import { quoteNoFieldCancellation, NoFieldCancellationError, } from './karama-no-field-cancellation'; import { validateTerminalCancellation, TerminalCancellationError, } from './terminal-cancellation'; import { quoteBattleAftermath, BattleAftermathQuoteError, type AftermathCancellation, } from './battle-aftermath-quote'; import { quoteBattleBoard, quoteSpiceCollection, quoteBattlePhaseAdvance, quoteBattleBoardContinuation, BoardResolutionError, type AdvisorRelease, } from './board-resolution-quote'; import { quoteMovementCancellation, validateAmbassadorRelocationContext, MovementCancellationError, } from './karama-movement-cancellation'; import { quoteChoamSaleCancellation, ChoamSaleCancellationError, } from './choam-sale-cancellation'; import { validateBattleForceLoss, ForceLossPreflightError, } from './force-loss-preflight'; import { quoteBattleResolution, BattleResolutionQuoteError, type ResolutionCombatant, } from './battle-resolution-quote'; import { quoteRevivalCancellation, RevivalCancellationError, } from './revival-cancellation'; import { validateStormTraversal, validateStormCancellation, validateWormCancellation, validateWormDevouring, DisasterPreflightError, } from './disaster-preflight'; import { quoteCompletedMovementArrival, MovementArrivalError, } from './karama-movement-preflight'; import { validateBattleCleanupContext, preflightMoritaniRetentionCancellation, KaramaBattlePreflightError, } from './karama-battle-preflight'; import { canceledResponseSource, KaramaContextError } from './karama-context'; import { validateSavedShipmentQuestion } from './truthtrance'; import { reorderOrderedOpportunity, protectOrderedOpportunityLast, validateOrderedOpportunity, type OrderedOpportunity, } from './ordered-opportunity'; import { matchesShipment, liveShipmentPromises, type ShipmentClaim, type ShipmentPromise, } from './shipment-promises'; import { portableSnooperPlanBlock } from './portable-snooper'; import { createAuditorLeader, isAuditorLeader, auditCount, sampleAuditCards, } from './choam-auditor'; import { stoneBurnerPlanBlock } from './stone-burner'; import { STRONGHOLD_CARDS, createStrongholdCards, strongholdBenefit, strongholdControllers, settleStrongholdCards, strongholdSupportCost, type StrongholdId, type StrongholdState, } from './stronghold-cards'; import { residualPoisonCandidates, residualPoisonDeath, } from './residual-poison'; import { createRemainingCohort, validateCohortSelection, type MovementCohort, type OrnithopterMode, } from './ornithopter'; import { eligibleNullentropyCards, resolveNullentropyBox, } from './nullentropy-box'; import { transferDistrans } from './distrans'; import type { FreshDiscardBatch } from './semuta-drug'; import { prepareRicheseGift, transferRicheseGift, type RicheseGiftIntent, } from './richese-gift'; import { richeseCards, richeseCardDefinition } from './richese-cards'; import { createRicheseNoField, deployRicheseNoField, moveRicheseNoField, revealRicheseNoField, shipAlliedRicheseNoField, projectRicheseNoField, type RicheseNoField, type NoFieldRevealCause, } from './richese-no-field'; import { presenceAt, presenceByLocation } from './force-presence'; import { createRicheseAuction, submitRicheseBid, projectRicheseAuction, reorderRicheseBidder, type RicheseAuction, } from './richese-auction'; import { richeseOwnCommitment, richeseAllyCommitment, validateRicheseFunding, type RicheseFunding, } from './richese-funding'; import { quoteRicheseSettlement, requireRicheseDeclarationCache, RicheseSettlementError, } from './richese-settlement'; import { validateAmbassadors, createAmbassadors, placeAmbassador, triggerAmbassador, destroyAmbassador, replenishAmbassadors, copiedAmbassadorEffects, canTriggerAmbassador, type AmbassadorEffect, type AmbassadorState, } from './ecaz-ambassadors'; import { createDukeVidal, acquireDuke, consumeDuke, expireDuke, DUKE_VIDAL_ID, type DukeState, } from './duke-vidal'; import { retentionReservesCard, type MoritaniRetention, } from './moritani-retention'; import { ordinaryCardAvailability } from './card-availability'; import { ecazDukeRevivalBlock } from './ecaz-duke-revival'; import { reserveShipmentCost, guildShipmentCost, guildShipmentIncome, shipmentPaymentBounds, } from './shipment-price'; import { TERROR_DEFINITIONS, createTerrorState, placeTerror, projectTerror, revealTerror, returnTerror, type TerrorKind, type TerrorState, } from './moritani-terror'; import { STORM_START_SECTOR, PLAYER_CIRCLE_SECTORS } from './player-positions'; import { cashInCards } from './choam-karama'; import { saleOptions, quoteSale, type ChoamMarket } from './choam-market'; import { charityAmount, charityPayer, charityMultiplier, type Inflation, } from './charity'; import { PlanClaimError, respectsBattlePromises, type PlanClaim, type BattlePromise, } from './battle-promises'; import { resolveTruthAction, validateTruthQuestionReceipt, truthFactAnswer, TruthError, type TruthWindow, type TruthRecord, type TruthAnswer, } from './truthtrance'; import { controlsLeader, nativeAvailable } from './leader-control'; import { newRevivalRules, eliteRevivalRemaining, paidForceRevivalCost, revivalPrevented, forceRevivalLimit, revivalDiscount, forceRevivalQuote, forceRevivalRemaining, freeRevivalRemaining, leaderRevivalOptions, type RevivalRules, type PendingRevival, } from './revival'; import { traitorDeck, matchingTraitor, CHEAP_HERO_TRAITOR } from './traitors'; import { isWeaponCard, isStoneBurner, isDefenseCard, isPortableSnooper, VOICE_KINDS, validBattleCardPair, defaultVoiceMatch, playedVoiceMatch, } from './battle-cards'; import { TECH_TOKENS, createTechTokens, ownedTech, type TechId, type TechState, } from './tech-tokens'; import { canUseAsKarama } from './karama'; import { isAdvisor, fighterCount, settleAdvisors, arrivalAsAdvisor, } from './advisors'; import { casualtyOptions, type Casualties, type CombatForces } from './combat'; import { FACTIONS, faction, type FactionId } from './catalog'; import { treacheryDeck, normalizeLegacyCardNames, leaders, spiceDeck, type Card, type Leader, type SpiceCard, } from './cards'; import { TERRITORIES, MOBILE_STRONGHOLD, gameTerritories, gameDistance, mobileRouteDistance, FREMEN_START, territory, location, splitLocation, distance, validLocation, } from './board'; export type Player = { id: string; name: string; bot?: 'Easy' | 'Medium' | 'Hard' | 'Brutal'; autopilot?: 'Easy' | 'Medium' | 'Hard' | 'Brutal'; faction: FactionId; ready: boolean; spice: number; reserves: number; tanks: number; forces: Record; noField?: RicheseNoField; noFieldEvent?: string; noFieldBlockedTurn?: number; advisors?: Record; advisorSetup?: boolean; charityTurn?: number; specialKaramaUsed?: boolean; gholaBlocked?: Record; ixMovementBlocked?: { turn: number; move: number }; fremenMovementBlocked?: { turn: number; move: number }; elites?: { reserves: number; tanks: number; forces: Record; revived: number; }; hand: Card[]; leaders: Leader[]; traitors: string[]; faceDancers?: { leader: string; revealed: boolean }[]; faceDancerReplacedTurn?: number; revealedTraitors?: string[]; traitorChoices: string[]; ally: string | null; /** Formation turn grants the printed next-turn co-occupation departure deadline. */ allySinceTurn?: number; bribes: number; revived: number; /** Actual free normal force revivals; absent when an older phase's history is unknown. */ freeForcesRevived?: number; leaderRevived: boolean; revivalCycle: number; shipped: boolean; moved: number; battleLosses: number; kwisatz?: { dead: boolean; usedAt?: string; revivalCycle?: number }; prediction?: { faction: FactionId; turn: number }; }; export type Plan = { dial: number; leader: string | null; weapon: string | null; defense: string | null; support: number; allyPayment?: number; kwisatz?: boolean; }; export type PlanField = Exclude< keyof Plan, 'support' | 'kwisatz' | 'allyPayment' >; export type Battle = { event?: string; strongholdCopy?: StrongholdId; preLeader?: { event: string; ready: string[]; closed: boolean }; /** Public marker participation persists after a zero-token reveal. */ noFieldPlayers?: string[]; truthPromises?: BattlePromise[]; poisonTooth?: Record; stoneBurner?: Record; /** Supplemental revealed defense; never rewrites the original sealed plan. */ lateDefense?: Record; fullPlan?: { owner: string; target: string }; fullPlanOffered?: boolean; eliteBlocked?: string[]; fremenSupportBlocked?: boolean; choamAidBlocked?: boolean; powerChecks?: { kind: 'kwisatz' | 'eliteStrength' | 'fremenSupport' | 'choamBattleAid'; owner: string; }[]; kwisatzBlocked?: boolean; prepared?: boolean; territory: string; attacker: string; defender: string; plans: Record; revealed: boolean; traitorCalls: Record; preparation?: { kind: 'voice' | 'prescience' | 'prescienceAnswer'; owner: string; beneficiary: string; }; prescience?: { player: string; field: PlanField; value?: string | number | null; }; voice?: { target: string; kind: Card['kind'] | 'stoneBurner'; must: boolean }; }; export type Auction = { peekKnown?: boolean; cards: Card[]; index: number; bid: number; bidder: string | null; active: string; passed: string[]; opener: number; allyPayment?: number; }; export type Decision = | { kind: 'choamAudit'; player: string; event: string } | { kind: 'choamAuditPayment'; player: string; event: string } | { kind: 'strongholdCopy'; player: string; event: string; choices: StrongholdId[]; } | { kind: 'richeseBlackMarket'; player: string } | { kind: 'richeseDeclaration'; player: string } | { kind: 'richeseCache'; player: string } | { kind: 'richeseUnbid'; player: string } | { kind: 'richeseAllyShipment'; player: string; owner: string } | { kind: 'richeseAllyOpportunity'; player: string; recipient: string } | { kind: 'ecazAmbassador'; player: string } | { kind: 'moritaniTerror'; player: string; entrant: string; territory: string; } | { kind: 'moritaniSetup'; player: string } | { kind: 'moritaniPlacement'; player: string } | { kind: 'ecazPlacement'; player: string } | { kind: 'ecazSpice'; player: string } | { kind: 'choamStorm'; player: string; territories: { territory: string; amount: number; sectors: number[] }[]; protected: string[]; } | { kind: 'choamMovement'; player: string; mover: string; territory: string; sector: number; amount: number; } | { kind: 'choamMentat'; player: string } | { kind: 'choamFreeRevival'; player: string; recipient: string } | { kind: 'choamBattleFunding'; player: string } | { kind: 'choamMarket'; player: string } | { kind: 'choamTradeReply'; player: string } | { kind: 'choamTradeConfirm'; player: string } | { kind: 'ixSetup'; player: string } | { kind: 'ixAuction'; player: string } | { kind: 'ixTechnology'; player: string } | { kind: 'ixAllyCard'; player: string } | { kind: 'mobileStronghold'; player: string; placement: boolean } | { kind: 'ixSubstitution'; player: string; territory: string; losses: Record; } | { kind: 'revivalStop'; player: string; recipient: string; revival: 'forces' | 'leader'; } | { kind: 'faceDance'; player: string; winner: string; leader: string | null; identity: string | null; territory: string; } | { kind: 'techToken'; player: string; loser: string; choices: TechId[] } | { kind: 'poisonTooth'; player: string } | { kind: 'stoneBurner'; player: string; event: string } | { kind: 'fullPlanOffer'; player: string } | { kind: 'fullPlanRead'; player: string; target: string } | { kind: 'guildShipment'; player: string; shipper: string; territory: string; sector: number; amount: number; } | { kind: 'nullentropy'; player: string } | { kind: 'handExchange'; player: string; target: string; count: number } | { kind: 'battleLosses'; player: string; territory: string; options: Casualties[]; cards: string[]; } | { kind: 'captureOffer'; player: string; loser: string; territory: string } | { kind: 'capturedLeader'; player: string; owner: string; leader: string; controller?: string; } | { kind: 'guildTiming'; player: string; next: string; following: string } | { kind: 'auctionPayment'; player: string } | { kind: 'advisor'; player: string; shipment: string; destination?: string; ambassadorEvent?: string; } | { kind: 'intrusion'; player: string; territory: string; wormRide?: boolean; ambassadorEvent?: string; followup?: { shipment: string; destination: string }; } | { kind: 'advisorBattle'; player: string; territories: string[] } | { kind: 'stormLosses'; player: string; key: string; amount: number; minElite: number; maxElite: number; resume: 'storm' | 'shipment'; } | { kind: 'wormPlacement'; player: string } | { kind: 'wormProtection'; player: string; territory: string; ally: string } | { kind: 'wormRide'; player: string; territory: string } | { kind: 'moritaniRetention'; owner: string; player: string; territory: string; cards: string[]; } | { kind: 'battleCards'; player: string; territory: string; cards: string[] }; export type ResponseWindow = { source?: 'ambassador'; intent?: string; advisors?: boolean; advisorResume?: 'wormRide' | 'declaration' | 'ambassador'; advisorAmbassadorEvent?: string; advisorRemaining?: string[]; advisorFollowup?: { shipment: string; destination: string }; amount?: number; elite?: number; recipient?: string; location?: string; resume?: 'storm' | 'shipment'; take?: boolean; kind: | 'moritaniPlacement' | 'ecazPlacement' | 'ecazCollection' | 'moritaniAlliance' | 'moritaniDuke' | 'moritaniRetention' | 'voice' | 'prescience' | 'advisor' | 'emperorIncome' | 'richesePurchaseIncome' | 'richeseGift' | 'richeseNoField' | 'harkonnenBonus' | 'harkonnenTraitor' | 'guildIncome' | 'emperorGift' | 'emperorRevival' | 'stormPeek' | 'stormProtection' | 'wormPlacement' | 'wormSurvival' | 'wormAllyProtection' | 'atreidesAuction' | 'atreidesSpice' | 'guildTiming' | 'kwisatz' | 'capture' | 'eliteStrength' | 'fremenSupport' | 'advisorFlip' | 'bgCharity' | 'choamCharity' | 'choamInflation' | 'choamSale' | 'choamWorthless' | 'choamBattleIncome' | 'choamAudit' | 'choamBattleAid' | 'worthlessKarama' | 'faceDancerReplacement' | 'choamRevival' | 'revivalLimit' | 'revivalDiscount' | 'earlyRevival' | 'revivalIncome' | 'foreignGhola' | 'ixSubstitution' | 'ixMovement' | 'fremenMovement' | 'mobileStronghold' | 'richeseAuction' | 'richeseBlackMarket' | 'ixAuction' | 'ixTechnology' | 'ixAllyCard'; owner: string; passed: string[]; }; type KaramaUse = | { kind: 'cancel'; response: ResponseWindow } | { kind: 'shipment'; recipient: string; card?: string } | { kind: 'purchase' } | { kind: 'auctionPayment' }; type PendingShipment = { source?: 'ambassador'; ambassadorEvent?: string; /** Absent in legacy rooms; new declarations cannot resume in a later turn. */ turn?: number; player: string; territory: string; sector: number; amount: number; elite: number; cost: number; allyPayment: number; advisors: boolean; noField?: { tokenId: string; event: string }; alliedNoField?: RicheseAllyOffer; }; type RicheseAllyOffer = { event: string; owner: string; recipient: string; tokenId: string; tokenEvent: string; territory: string; sector: number; payer: string; }; export type Game = { treacheryDiscardSequence?: number; resolvedTreacheryDiscardSequence?: number; /** Committed discard receipts are evidence, not additional card custody. */ pendingTreacheryDiscard?: { sequence: number; batch: FreshDiscardBatch; continuation: | { kind: 'ambassador'; entry: NonNullable } | { kind: 'nullentropyDiscard'; player: string; searchEvent: string; box: string; selected: Card; finalDiscardIds: string[]; finalDiscardSignature: string; resume: NonNullable['resume']; parentSignature: string; } | { kind: 'ordinaryCardDiscard'; player: string; card: string; effect: 'hajr' | 'ghola' | 'harvester' | 'weather' | 'atomics'; resume: NonNullable['resume']; stateSignature: string; } | { kind: 'truthtranceDiscard'; consumed: { player: string; card: string }; historyIndex: number; record: TruthRecord; remaining: TruthWindow | null; resume: NonNullable['resume']; promise: | { kind: 'shipment'; index: number; value: ShipmentPromise } | { kind: 'battle'; index: number; value: BattlePromise } | null; parentSignature: string; } | { kind: 'ornithopterDiscard'; source: 'move' | 'end'; flight: NonNullable; movement: CompletedMovement | null; resume: NonNullable['resume']; stateSignature: string; } | { kind: 'terrorDiscard'; source: 'sabotage' | 'robberyOverflow'; owner: string; entry: NonNullable; discardedHandSize: number; } | { kind: 'battleResolved'; event: string; result: 'normal' | 'traitor' | 'mutualTraitors' | 'explosion'; combatants: string[]; territory: string; winner: string | null; cards: string[]; casualties?: { forces: CombatForces; dial: number; support: number; options: Casualties[]; }; } | { kind: 'battleCleanup'; event: string; combatants: string[]; territory: string; player: string; source: 'winner' | 'moritani'; kept: string[]; played: string[]; retention?: MoritaniRetention; } | { kind: 'ixAllyCard'; player: string; card: string; free: boolean; sale: NonNullable; auctionIndex: number | null; auctionEvent: string | null; }; } | null; ornithopter?: { event: string; player: string; card: Card; turn: number; mode: OrnithopterMode; startingMove: number; completed: number; cohort?: MovementCohort; } | null; pendingNullentropy?: { event: string; player: string; box: string; turn: number; phase: number; discardIds: string[]; discardSignature: string; resume: { response: Game['response']; decision: Game['decision']; pendingKarama: Game['pendingKarama']; phaseOpening: Game['phaseOpening']; }; } | null; pendingRicheseGift?: { event: string; intent: RicheseGiftIntent; turn: number; phase: number; resume: { response: Game['response']; decision: Game['decision']; pendingKarama: Game['pendingKarama']; phaseOpening: Game['phaseOpening']; }; } | null; richeseGiftBlocked?: { turn: number; phase: number; cards: string[] }; richeseAllyOffer?: RicheseAllyOffer | null; richeseAllyOpportunity?: { turn: number; recipient: string }; richeseAllyBlocked?: { turn: number; recipient: string }; richeseAllyDeclined?: { turn: number; recipient: string }; richeseCache?: Card[]; richeseRemoved?: Card[]; richeseBidding?: { owner: string; event: string; turn: number; stage: | 'blackMarketOffer' | 'declaration' | 'cacheOffer' | 'lot' | 'normal' | 'complete'; position: 'first' | 'last' | null; normalCount: number | null; blackMarketSold: boolean; cacheCanceled: boolean; opener?: number; } | null; richeseAuction?: RicheseAuction | null; richeseFunding?: RicheseFunding; richesePeekKnown?: boolean; richeseOfferedCard?: Card | null; richeseClaim?: string | null; currentAuctionSale?: { winner: string; amount: number; free: boolean; origin: 'normal' | 'cache' | 'blackMarket'; seller: string | null; } | null; pendingAmbassador?: { event: string; owner: string; entrant: string; token: string; territory: string; sector: number; turn: number; phase: number; stage: | 'offer' | 'copy' | 'cards' | 'income' | 'bonus' | 'move' | 'ship' | 'arrival'; relocation?: { order: FremenAmbassadorMove; next: 'flip' | 'intrusion' | 'terror' | 'finish'; }; shipmentReceipt?: { order: GuildAmbassadorShipment; next: 'intrusion' | 'terror' | 'accompany' | 'advisor' | 'finish'; advisorArrival?: { player: string; territory: string; sector: number; amount: 1; elite: 0; }; }; purchaseReceipt?: { buyer: string; card: string; amount: 3; emperor: string | null; stage: 'income' | 'bonus'; }; beneficiary?: string; effect?: AmbassadorEffect; copyChoices: AmbassadorEffect[]; resume: 'none' | 'wormRide'; wormRider?: string; guildAdvisorOrigin?: { event: string; player: string; territory: string; sector: number; }; } | null; ambassadorInsights?: { event: string; viewer: string; target: string; turn: number; effect: 'atreides' | 'harkonnen'; cards: Card[]; traitor: string | null; }[]; ecazAmbassadors?: AmbassadorState; ecazPlacementTurn?: number; pendingEcazPlacement?: { token: string; territory: string; turn: number; cost: number; } | null; dukeVidal?: DukeState; dukeAcquisitionTurn?: number; pendingTerrorEntry?: { token: string; entrant: string; territory: string; sector: number; amount: number; elite: number; cause: | 'shipment' | 'movement' | 'guildTransport' | 'advisor' | 'wormRide' | 'ambassador'; turn: number; phase: number; stage: | 'offer' | 'robbery' | 'discard' | 'gift' | 'sneakAttack' | 'allianceResponse' | 'allianceReply'; allianceBlocked?: boolean; resume: 'none' | 'wormRide' | 'ambassador'; ambassadorEvent?: string; } | null; moritaniTerror?: TerrorState; pendingMoritaniPlacement?: { token: string; territory: string; turn: number; } | null; pendingChoamMove?: MovementOrder | null; choamBaliset?: { turn: number; player: string; territory: string }[]; choamMentatPending?: boolean; choamMovement?: { turn: number; bonus: number }; choamWorthlessBlocked?: { turn: number; phase: number; cards: string[] }; pendingChoamWorthless?: { owner: string; card: string; effect: 'kulon' | 'laLaLa' | 'gamont' | 'baliset' | 'jubba'; storm?: boolean; movement?: boolean; location?: string; elite?: number; mentat?: boolean; noFieldEvent?: string; target?: string; revival: boolean; } | null; pendingChoamBattleIncome?: { owner: string; amount: number } | null; pendingAuditor?: { event: string; owner: string; opponent: string; territory: string; turn: number; survived: boolean; usedCards: string[]; stage: 'offer' | 'response' | 'payment'; } | null; auditorInsight?: { event: string; viewer: string; target: string; turn: number; cards: Card[]; } | null; choamMarket?: ChoamMarket | null; choamTradeTurn?: number; inflation?: Inflation | null; inflationUsed?: boolean; inflationAttemptTurn?: number; choamCharity?: { turn: number; canceled: boolean }; ecazCollection?: { turn: number; event: string; stage: 'response' | 'allocation' | 'complete'; canceled: boolean; allocation: AllocationState | null; settled: SpiceAllocationReceipt[]; sourceSpice: Record; boardSignature: string; }; truthtrance?: TruthWindow | null; truthHistory?: TruthRecord[]; shipmentPromises?: ShipmentPromise[]; ixSetupCards?: Card[] | null; ixAuction?: { count: number; cards: Card[] } | null; ixAuctionKnown?: { turn: number; cards: Card[] } | null; ixTechnologyTurn?: number; pendingIxTechnology?: { card: string } | null; pendingIxAlly?: { player: string; card: string; free: boolean } | null; mobileStronghold?: { location: string | null } | null; pendingMobileMove?: { player: string; route: string[]; collect: boolean; } | null; revivalRules?: RevivalRules; revivalRequests?: Record< string, { leader: string; price: number | null; declined?: boolean } >; revivalFreeIncome?: Record; pendingRevival?: PendingRevival | null; pendingIxMove?: MovementOrder | null; pendingFremenMove?: { turn: number; move: number; order: MovementOrder; } | null; pendingIxSubstitution?: { player: string; territory: string; losses: Record; cards: string[]; sources?: Record; recover?: Record; } | null; revivalPrevention?: { player: string; turn: number }; traitorReserve?: string[]; pendingFaceDance?: { player: string; winner: string; leader: string | null; identity: string | null; territory: string; } | null; phaseOpening?: { passed: string[]; initialize: boolean } | null; sandtrout?: boolean; techTokens?: TechState | null; strongholdCards?: StrongholdState | null; pendingTech?: { player: string; loser: string; choices: TechId[] } | null; summonedBeforeBlow?: boolean; summonedNexusBeforeRides?: boolean; summonedWorm?: { territory: string; resume: Pick< Game, | 'response' | 'decision' | 'pendingKarama' | 'spiceWindow' | 'spiceResolution' | 'spiceSequence' | 'nexus' | 'wormRides' | 'ready' >; } | null; pendingShipment?: PendingShipment | null; pendingExchange?: { response: ResponseWindow | null; decision: Decision | null; } | null; /** Purchase is already committed; only the Emperor's receipt remains pending. */ pendingRichesePurchaseIncome?: { owner: string; turn: number; phase: number; resume: Pick; } | null; pendingKarama?: { owner: string; use: KaramaUse; /** New conversions bind their original power, shipment or lot. Absent in legacy saves. */ opportunity?: { kind: 'cancel' | 'shipment' | 'auction'; signature: string; }; } | null; schema: 1; botsPending?: boolean; botNextActionAt?: number; code: string; version: number; host: string; status: 'lobby' | 'setup' | 'playing' | 'finished'; /** Absent on legacy rooms whose starting cards were already dealt. */ setupStage?: 'prediction' | 'traitors' | 'forces'; advanced: boolean; expansions: string[]; players: Player[]; /** Fixed printed player circles; absent only in rooms created before this rule fix. */ playerPositions?: Record; turn: number; phase: number; storm: number; order: string[]; active: string | null; movementRemaining: string[] | null; /** Juice of Sapho's last position lasts only through this combined-turn queue. */ saphoMovementLast?: { event: string; turn: number; player: string } | null; guildTimingGranted: boolean; guildTimingLocked: boolean; ready: string[]; deck: Card[]; discard: Card[]; spiceDeck: SpiceCard[]; spicePeekKnown: boolean; spiceDiscard: SpiceCard[][]; spice: Record; log: { seq: number; text: string; automatic?: { faction: FactionId; name: string }; }[]; stormDials: Record; stormPending: number | null; stormCard: number | null; stormCardKnown: boolean; stormResolution: { from: number; distance: number; traversed: number; pending: string[]; protected?: boolean; choamProtected?: string[]; } | null; karamaShipping: { player: string; owner: string; card?: string } | null; stormDialers: string[]; lastBattle: string[]; lastBattleContext?: { event: string; turn: number; territory: string; combatants: string[]; winner: string | null; result: 'normal' | 'traitor' | 'mutualTraitors' | 'explosion' | 'legacy'; } | null; auction: Auction | null; battle: Battle | null; nexus: boolean; allianceOffers: Record; winner: string[]; shieldWallDestroyed: boolean; spiceWindow: { territory: string; sector: number; amount: number; harvested: boolean; harvesters?: number; harvesterClosed?: boolean; } | null; hajr: string[]; decision: Decision | null; pendingCapture: { player: string; loser: string; territory: string } | null; moritaniRetention?: MoritaniRetention | null; response: ResponseWindow | null; wormRides: string[]; wormPlacementCanceledTurn: number; spiceResolution: { skipped: SpiceCard[] } | null; spiceSequence: { pile: 0 | 1; skipped: SpiceCard[] } | null; freeRevival: string[]; emperorExtra: Record; aid: Record; }; export class RuleError extends Error {} function stringField(value: unknown) { requireRule(typeof value === 'string', 'Expected a string.'); return value; } function requireRule(condition: unknown, message: string): asserts condition { if (!condition) throw new RuleError(message); } export function integer( value: unknown, min: number, max: number, label = 'Amount', ): number { requireRule( typeof value === 'number' && Number.isSafeInteger(value) && value >= min && value <= max, `${label} must be an integer from ${min} to ${max}.`, ); return value; } const random = () => crypto.getRandomValues(new Uint32Array(1))[0] / 4294967296; export function shuffle(items: T[], rng = random): T[] { const a = [...items]; for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(rng() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } return a; } const log = ( g: Game, text: string, automatic?: { faction: FactionId; name: string }, ) => { g.log.push({ seq: (g.log.at(-1)?.seq ?? 0) + 1, text, ...(automatic ? { automatic } : {}), }); if (g.log.length > 250) g.log.shift(); }; const getPlayer = (g: Game, id: string) => { const p = g.players.find((p) => p.id === id); requireRule(p, 'You are not seated at this table.'); return p; }; const byFaction = (g: Game, f: string) => g.players.find((p) => p.faction === f); const at = presenceAt; function noFieldRule(operation: () => T): T { try { return operation(); } catch (error) { throw new RuleError( error instanceof Error ? error.message : 'Invalid No-Field operation.', ); } } function revealPlayerNoField(g: Game, p: Player, cause: NoFieldRevealCause) { requireRule( p.faction === 'richese' && p.noField?.deployed, 'No concealed No-Field is deployed.', ); const result = noFieldRule(() => revealRicheseNoField(p.noField!, { tokenId: p.noField!.deployed!.tokenId, reserves: p.reserves, cause, }), ); requireRule( result.controller === p.id, 'This concealed token has an invalid controller.', ); const cohort = g.ornithopter?.player === p.id ? g.ornithopter.cohort : undefined; if ( cohort?.noField?.tokenId === p.noField!.deployed!.tokenId && cohort.noField.event === p.noFieldEvent ) { const key = location(result.location.territory, result.location.sector); cohort.forces[key] = (cohort.forces[key] ?? 0) + result.forces; delete cohort.noField; } p.noField = result.state; p.noFieldEvent = crypto.randomUUID(); p.reserves -= result.forces; if (result.forces) place(p, result.location.territory, result.location.sector, result.forces); log( g, `${p.name} revealed the ${result.value} No-Field in ${territory(result.location.territory).name} (${cause}) and placed ${result.forces} physical forces from reserves.${result.forces < result.value ? ' Only the remaining reserves could be placed.' : ''}`, { faction: 'richese', name: 'No-Field revealed' }, ); return result; } function noFieldShipBlock(g: Game, p: Player): string | null { if (g.status !== 'playing' || g.phase !== 5 || g.active !== p.id || p.shipped) return 'Use a No-Field during your shipment, before movement.'; if (g.truthtrance || g.phaseOpening || g.response || g.decision) return 'Resolve the pending decision before declaring a No-Field shipment.'; if (p.noFieldBlockedTurn === g.turn) return 'Karama prevented No-Field use for this shipment opportunity. Normal shipment remains available.'; if (!p.noField || !p.noFieldEvent) return 'No-Field inventory is not initialized in this saved table.'; if (p.noField.deployed) return 'Reveal the existing No-Field before shipping another.'; return null; } function noFieldRevealBlock(g: Game, p: Player): string | null { if (g.status !== 'playing' || g.phase >= 6) return 'Voluntary No-Field reveal is available before the Battle phase.'; if (!p.noField?.deployed) return 'No concealed No-Field is deployed.'; if (g.truthtrance || g.phaseOpening || g.response || g.decision) return 'Resolve the pending interaction before revealing the No-Field.'; return null; } function noFieldAllyOfferBlock( g: Game, owner: Player, pending = false, ): string | null { const ally = g.players.find((p) => p.id === owner.ally); if (owner.faction !== 'richese' || !ally || ally.ally !== owner.id) return 'Richese must have a current mutual ally.'; if ( g.status !== 'playing' || g.phase !== 5 || g.active !== ally.id || ally.shipped ) return 'Offer the token during your ally’s unused shipment turn.'; if ( !pending && (g.truthtrance || g.phaseOpening || g.response || (g.decision && !( g.decision.kind === 'richeseAllyOpportunity' && g.decision.player === owner.id ))) ) return 'Resolve the pending interaction before offering allied shipment.'; if (!owner.noField || !owner.noFieldEvent) return 'No-Field inventory is not initialized.'; if (ally.faction === 'fremen') return 'Fremen reserves are on the planet; this ability ships off-planet reserves.'; if (ally.faction === 'guild' || g.karamaShipping?.player === ally.id) return 'Allied No-Field pricing with Guild or Karama discounts awaits a ruling.'; if ( g.richeseAllyBlocked?.turn === g.turn && g.richeseAllyBlocked.recipient === ally.id ) return 'Karama prevented No-Field use for this allied shipment opportunity.'; return null; } function alliedNoFieldQuote( g: Game, offer: RicheseAllyOffer, checkFunds = true, ) { const owner = getPlayer(g, offer.owner), recipient = getPlayer(g, offer.recipient); const blocked = noFieldAllyOfferBlock(g, owner, true); requireRule(!blocked, blocked ?? 'Allied No-Field shipment is unavailable.'); requireRule( owner.ally === recipient.id && owner.noFieldEvent === offer.tokenEvent, 'This allied No-Field offer is stale.', ); requireRule( offer.payer === owner.id || offer.payer === recipient.id || offer.payer === 'both', 'Choose an ally payer or split a two-spice shipment equally.', ); requireRule( offer.territory !== MOBILE_STRONGHOLD && validLocation(offer.territory, offer.sector), 'Choose a printed planet sector for the allied No-Field shipment.', ); // Test the compulsory old-marker reveal without exposing or mutating it before acceptance. const trial = structuredClone(g), trialOwner = getPlayer(trial, owner.id), trialRecipient = getPlayer(trial, recipient.id); if (trialOwner.noField!.deployed) revealPlayerNoField(trial, trialOwner, 'beforeAlly'); const materialized = noFieldRule(() => shipAlliedRicheseNoField( trialOwner.noField!, { tokenId: offer.tokenId, controller: recipient.id, location: { territory: offer.territory, sector: offer.sector }, }, trialRecipient.reserves, ), ); const advisors = arrivalAsAdvisor(trial, trialRecipient, offer.territory); allowedEntry( trial, trialRecipient, offer.territory, offer.sector, false, advisors, ); const cost = territory(offer.territory).type === 'stronghold' ? 1 : 2; requireRule( offer.payer !== 'both' || cost === 2, 'An equal split requires the two-spice shipment price.', ); const ownerPayment = offer.payer === owner.id ? cost : offer.payer === 'both' ? 1 : 0; const recipientPayment = cost - ownerPayment; // The supplier may authorize only its own spending; recipient funding stays private until consent. requireRule( uncommittedSpice(g, owner) >= ownerPayment, 'Richese needs enough uncommitted spice for its payment.', ); if (checkFunds) requireRule( uncommittedSpice(g, recipient) >= recipientPayment, 'The recipient needs enough uncommitted spice for its payment.', ); const elites = recipient.elites?.reserves ?? 0; return { cost, ownerPayment, recipientPayment, amount: materialized.forces, value: materialized.value, advisors, eliteMin: Math.max(0, materialized.forces - (recipient.reserves - elites)), eliteMax: Math.min(elites, materialized.forces), }; } function giftReserved(g: Game, owner: string, card: string) { return ( g.pendingRicheseGift?.intent.owner === owner && g.pendingRicheseGift.intent.cardId === card ); } function nullentropyContext(g: Game) { const trial = structuredClone(g), pending = trial.pendingNullentropy; if (pending) { trial.response = pending.resume.response; trial.decision = pending.resume.decision; trial.pendingKarama = pending.resume.pendingKarama; trial.phaseOpening = pending.resume.phaseOpening; trial.pendingNullentropy = null; } return trial; } function nullentropyIntegrity(g: Game): string | null { const pending = g.pendingNullentropy; if ( !pending || pending.turn !== g.turn || pending.phase !== g.phase || g.decision?.kind !== 'nullentropy' || g.decision.player !== pending.player || g.response || g.phaseOpening || g.pendingKarama ) return 'The saved private search is inconsistent; restore its saved table state before continuing.'; const owner = g.players.find((p) => p.id === pending.player); if ( !owner || owner.hand.filter( (c) => c.id === pending.box && richeseCardDefinition(c)?.card.effect === 'nullentropyBox', ).length !== 1 || JSON.stringify(g.discard) !== pending.discardSignature || JSON.stringify(g.discard.map((c) => c.id)) !== JSON.stringify(pending.discardIds) ) return 'The saved search card custody changed; restore its saved table state before continuing.'; return null; } function validateNullentropyBegin(g: Game, owner: Player, cardId: string) { requireRule( g.status === 'playing', 'Nullentropy Box is available during an active game.', ); requireRule( !g.pendingNullentropy, 'Finish the paid Nullentropy Box search first.', ); requireRule( !g.truthtrance, 'Resolve the active Truthtrance question before searching the discard.', ); const card = owner.hand.find((c) => c.id === cardId); requireRule( card && richeseCardDefinition(card)?.card.effect === 'nullentropyBox', 'Use the canonical Nullentropy Box physically in your hand.', ); const reserved = transferCardBlock(g, owner, card); requireRule(!reserved, reserved ?? 'The Box is already committed.'); requireRule( owner.hand.length < handLimit(owner), 'Full-hand Nullentropy Box use is unresolved; the current guard requires a pre-existing free hand slot.', ); const guild = byFaction(g, 'guild'); requireRule( !( g.karamaShipping?.card && guild && !guild.specialKaramaUsed && g.discard.some((c) => c.id === g.karamaShipping?.card) ), 'Searching while a shipping Karama has a provisional Guild refund claim awaits resolution.', ); // A completed purchase has already paid its bid; it is no longer a reservation. const funding = structuredClone(g); if (funding.currentAuctionSale) { funding.auction = null; funding.richeseAuction = null; } requireRule( uncommittedSpice(funding, getPlayer(funding, owner.id)) >= 2, 'You need two uncommitted spice to pay the bank for this search.', ); const paid = structuredClone(g); getPlayer(paid, owner.id).spice -= 2; reconcileBattlePromises(paid, { actor: owner.id, action: { type: 'card', card: cardId }, }); let candidates: Card[]; try { candidates = eligibleNullentropyCards(g.discard); } catch (error) { throw new RuleError( error instanceof Error ? error.message : 'Invalid discard custody.', ); } requireRule( candidates.length > 0, 'An empty or only-Box discard search is unsupported; no spice has been paid.', ); return { card, candidates }; } function finishNullentropy( g: Game, player: string, event: string, cardId: string, ) { const pending = g.pendingNullentropy; requireRule( pending && pending.player === player, 'Only the paid search owner can select its card.', ); requireRule(pending.event === event, 'This private search event is stale.'); const invalid = nullentropyIntegrity(g); requireRule(!invalid, invalid ?? 'The search cannot continue.'); const owner = getPlayer(g, player), context = nullentropyContext(g); const remaining = g.discard.filter((c) => c.id !== cardId); let preview: ReturnType; try { preview = resolveNullentropyBox( owner.hand, pending.box, g.discard, cardId, remaining.map((c) => c.id), handLimit(owner), ); } catch (error) { throw new RuleError( error instanceof Error ? error.message : 'Invalid private search selection.', ); } validateTransferCompletion( context, getPlayer(context, player), getPlayer(context, player), preview.ownerHand, preview.ownerHand, { type: 'card', card: pending.box }, ); // All rejection paths precede this single server shuffle. The paid record and // final shuffled order are each persisted by the room's normal CAS fence. const order = shuffle(remaining).map((c) => c.id); const result = resolveNullentropyBox( owner.hand, pending.box, g.discard, cardId, order, handLimit(owner), ); owner.hand = result.ownerHand; g.discard = result.discard; g.pendingNullentropy = null; g.decision = null; log( g, `${owner.name} recovered one private card with Nullentropy Box, shuffled the remaining discard pile, and placed the used Box on top. The bank was paid when the search began.`, { faction: owner.faction, name: 'Nullentropy Box' }, ); stageTreacheryDiscard( g, 'nullentropyBox', [{ card: result.discard.at(-1)!, discardedBy: player, publicFace: true }], { kind: 'nullentropyDiscard', player, searchEvent: event, box: pending.box, selected: result.selected, finalDiscardIds: g.discard.map((c) => c.id), finalDiscardSignature: JSON.stringify(g.discard), resume: pending.resume, parentSignature: nullentropyParentSignature(g, pending.resume, event), }, ); } function beginNullentropy(g: Game, owner: Player, cardId: string) { const { candidates } = validateNullentropyBegin(g, owner, cardId); owner.spice -= 2; const event = crypto.randomUUID(); g.pendingNullentropy = { event, player: owner.id, box: cardId, turn: g.turn, phase: g.phase, discardIds: g.discard.map((c) => c.id), discardSignature: JSON.stringify(g.discard), resume: { response: g.response, decision: g.decision, pendingKarama: g.pendingKarama, phaseOpening: g.phaseOpening, }, }; g.response = null; g.pendingKarama = null; g.phaseOpening = null; g.decision = { kind: 'nullentropy', player: owner.id }; log( g, `${owner.name} paid two spice to the bank to privately search the discard pile with Nullentropy Box. The interrupted decision waits for this search.`, ); if (candidates.length === 1) finishNullentropy(g, owner.id, event, candidates[0].id); } function nullentropyView(g: Game, owner: Player) { const pending = g.pendingNullentropy; const card = owner.hand.find( (c) => richeseCardDefinition(c)?.card.effect === 'nullentropyBox', ); if (!card) return null; if (pending?.player === owner.id) { const blocked = nullentropyIntegrity(g); return { card, blocked, search: blocked ? null : { event: pending.event, cards: eligibleNullentropyCards(g.discard).sort( (a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id), ), }, }; } let blocked: string | null = null; try { validateNullentropyBegin(g, owner, card.id); } catch (error) { if (!(error instanceof RuleError)) throw error; blocked = error.message; } return { card, blocked, search: null }; } function savedTransferResponses(g: Game) { const canceled = (pending: Game['pendingKarama']) => pending?.use.kind === 'cancel' ? pending.use.response : null; return [ g.response, canceled(g.pendingKarama), g.pendingExchange?.response, g.pendingRicheseGift?.resume.response, canceled(g.pendingRicheseGift?.resume.pendingKarama), ]; } function transferCardBlock(g: Game, owner: Player, card: Card) { if ( g.pendingNullentropy?.player === owner.id && g.pendingNullentropy.box === card.id ) return 'This Box is reserved for the paid private search.'; const reserved = ambassadorDiscardBlock(g, owner, card); if (reserved) return reserved; if ( g.richeseAuction?.source === 'blackMarket' && !g.currentAuctionSale && g.richeseAuction.owner === owner.id && g.richeseAuction.cardId === card.id ) return 'The card offered for Black Market must remain available.'; if ( g.pendingIxAlly?.player === owner.id && g.pendingIxAlly.card === card.id && savedTransferResponses(g).some( (response) => response?.kind === 'ixAllyCard', ) ) return 'The card committed to Ixian replacement must remain available.'; return null; } function validateTransferCompletion( g: Game, owner: Player, recipient: Player, ownerHand: Card[], recipientHand: Card[], action: Action, ) { const pendingKaramaPurchase = savedKaramaConversions(g).some( (pending) => pending.owner === recipient.id && ['purchase', 'auctionPayment'].includes(pending.use.kind), ); const lot = g.richeseAuction; const richeseReserved = lot && (lot.outcome ? lot.outcome.kind === 'sold' && lot.outcome.winner === recipient.id : lot.method === 'silent' ? Object.hasOwn(lot.sealed, recipient.id) : lot.bidder === recipient.id); const auctionSlot = Number( !g.currentAuctionSale && !!( g.auction?.bidder === recipient.id || pendingKaramaPurchase || richeseReserved ), ); if (recipientHand.length >= handLimit(recipient) && !g.currentAuctionSale) { requireRule( g.auction?.bidder !== recipient.id, 'Leave room for the recipient’s committed auction purchase.', ); requireRule( !pendingKaramaPurchase, 'Leave room for the recipient’s pending Karama auction purchase.', ); requireRule( !richeseReserved, 'Leave room until the recipient’s auction commitment is resolved; sealed submissions reserve the slot without revealing their bid.', ); } const trial = structuredClone(g), donor = getPlayer(trial, owner.id); donor.hand = ownerHand; getPlayer(trial, recipient.id).hand = recipientHand; const decisions = [ g.decision, g.pendingExchange?.decision, g.pendingRicheseGift?.resume.decision, ]; for (const exchange of decisions) if (exchange?.kind === 'handExchange') { requireRule( exchange.player !== owner.id || ownerHand.length >= exchange.count, 'Keep enough cards to complete your committed hand exchange.', ); requireRule( exchange.target !== recipient.id || recipientHand.length + exchange.count + auctionSlot <= handLimit(recipient), 'Leave room for the cards the recipient must receive from the committed hand exchange and any pending auction purchase.', ); } const pending = trial.pendingAmbassador; if (pending?.beneficiary === owner.id) { const entrant = getPlayer(trial, pending.entrant); if (pending.stage === 'cards' && pending.effect === 'ixians') requireRule( !ambassadorEffectBlock(trial, 'ixians', donor, entrant), 'Keep a discardable card for the committed Ixian Ambassador exchange.', ); if (pending.stage === 'copy') requireRule( pending.copyChoices.some( (effect) => !ambassadorEffectBlock(trial, effect, donor, entrant), ), 'Keep an available effect for the committed Ambassador copy.', ); } reconcileBattlePromises(trial, { actor: owner.id, action }); } function distransBlock(g: Game, owner: Player, card: Card): string | null { if (g.pendingNullentropy) return 'Finish the paid Nullentropy Box search first.'; if (g.status !== 'playing') return 'Distrans is available during an active game.'; if (g.truthtrance) return 'Resolve the active Truthtrance question before changing hands.'; const reserved = transferCardBlock(g, owner, card); if (reserved) return reserved; // The face excludes a bid, not the complete Bidding phase. A live unpaid lot // remains guarded pending the requested ruling; already-paid income is separate. if ((g.auction || g.richeseAuction) && !g.currentAuctionSale) return 'Distrans during an unresolved auction lot awaits a ruling. It remains available outside that bid.'; return null; } function validateDistrans( g: Game, owner: Player, cardId: string, recipientId: string, giveId: string, ) { const card = owner.hand.find((c) => c.id === cardId); requireRule( card && richeseCardDefinition(card)?.card.effect === 'distrans', 'Play the canonical Distrans physically in your hand.', ); const blocked = distransBlock(g, owner, card); requireRule(!blocked, blocked ?? 'Distrans is unavailable.'); const recipient = getPlayer(g, recipientId); let result: ReturnType; try { result = transferDistrans( owner, recipient, cardId, giveId, handLimit(recipient), ); } catch (error) { throw new RuleError( error instanceof Error ? error.message : 'Invalid Distrans transfer.', ); } const reserved = transferCardBlock(g, owner, result.transferred); requireRule(!reserved, reserved ?? 'This card is already committed.'); validateTransferCompletion( g, owner, recipient, result.ownerHand, result.recipientHand, { type: 'card', card: cardId, target: recipientId, give: giveId }, ); return { recipient, result }; } function distransView(g: Game, owner: Player) { const card = owner.hand.find( (c) => richeseCardDefinition(c)?.card.effect === 'distrans', ); if (!card) return null; const blocked = distransBlock(g, owner, card); const choices = g.players .filter((p) => p.id !== owner.id) .map((recipient) => { const cards: Card[] = [], unavailable: { card: Card; reason: string }[] = []; const targetBlock = blocked ?? (recipient.hand.length >= handLimit(recipient) ? 'The recipient’s Treachery hand is full.' : null); for (const given of owner.hand.filter((c) => c.id !== card.id)) { if (targetBlock) { unavailable.push({ card: given, reason: targetBlock }); continue; } try { validateDistrans(g, owner, card.id, recipient.id, given.id); cards.push(given); } catch (error) { if (!(error instanceof RuleError)) throw error; unavailable.push({ card: given, reason: error.message }); } } return { recipient: recipient.id, blocked: targetBlock, cards, unavailable, }; }); return { card, blocked, choices }; } function richeseGiftBlock(g: Game, owner: Player): string | null { if (g.pendingNullentropy) return 'Finish the paid Nullentropy Box search first.'; if (g.status !== 'playing') return 'Richese gifts are available during an active game.'; if (owner.faction !== 'richese') return 'Only Richese can give a Richese card to its ally.'; if (g.truthtrance) return 'Resolve the active Truthtrance question before changing hands.'; if (g.pendingRicheseGift) return 'Resolve the pending Richese gift first.'; const ally = g.players.find((p) => p.id === owner.ally); if (!ally || ally.ally !== owner.id) return 'You need a current mutual ally.'; if (ally.hand.length >= handLimit(ally)) return 'Your ally’s Treachery hand is full.'; return null; } function richeseGiftContext(g: Game) { const context = structuredClone(g), pending = context.pendingRicheseGift; if (pending) { context.response = pending.resume.response; context.decision = pending.resume.decision; context.pendingKarama = pending.resume.pendingKarama; context.phaseOpening = pending.resume.phaseOpening; context.pendingRicheseGift = null; } return context; } function validateRicheseGift(g: Game, owner: Player, cardId: string) { const blocked = richeseGiftBlock(g, owner); requireRule(!blocked, blocked ?? 'This gift is unavailable.'); const recipient = getPlayer(g, owner.ally!); let intent: RicheseGiftIntent; try { intent = prepareRicheseGift(owner, recipient, cardId, handLimit(recipient)); } catch (error) { throw new RuleError( error instanceof Error ? error.message : 'Invalid Richese gift.', ); } const card = owner.hand.find((c) => c.id === cardId)!; const reservation = transferCardBlock(g, owner, card); requireRule(!reservation, reservation ?? 'This card is already committed.'); requireRule( !( g.richeseGiftBlocked?.turn === g.turn && g.richeseGiftBlocked.phase === g.phase && g.richeseGiftBlocked.cards.includes(cardId) ), 'Retrying this canceled card’s gift in the same phase awaits a ruling.', ); const resulting = transferRicheseGift( owner, recipient, intent, handLimit(recipient), ); validateTransferCompletion( g, owner, recipient, resulting.ownerHand, resulting.recipientHand, { type: 'richeseGift', card: cardId }, ); return { intent, resulting }; } function richeseGiftView(g: Game, viewer: Player) { const owner = byFaction(g, 'richese'); if (!owner) return null; const pending = g.pendingRicheseGift; if (viewer.id !== owner.id && pending?.intent.recipient !== viewer.id) return null; const mine = viewer.id === owner.id; const blocked = richeseGiftBlock(g, owner); const cards: Card[] = [], unavailable: { card: Card; reason: string }[] = []; if (mine) for (const card of owner.hand.filter((card) => richeseCardDefinition(card), )) { if (blocked) { unavailable.push({ card, reason: blocked }); continue; } try { validateRicheseGift(g, owner, card.id); cards.push(card); } catch (error) { if (!(error instanceof RuleError)) throw error; unavailable.push({ card, reason: error.message }); } } return { owner: owner.id, recipient: owner.ally, blocked, cards, unavailable, pending: pending ? { event: pending.event, owner: pending.intent.owner, recipient: pending.intent.recipient, card: getPlayer(g, pending.intent.owner).hand.find( (c) => c.id === pending.intent.cardId, ) ?? null, } : null, }; } export const handLimit = (p: Pick) => p.faction === 'harkonnen' ? 8 : p.faction === 'choam' ? 5 : 4; export function newPlayer(id: string, name: string, f: FactionId): Player { requireRule( name.trim().length > 0 && name.trim().length <= 32, 'Enter a name of 1–32 characters.', ); requireRule( FACTIONS.some((x) => x.id === f), 'Choose a valid faction.', ); return { id, name: name.trim(), faction: f, ready: false, spice: 0, reserves: 20, tanks: 0, forces: {}, ...(f === 'ixians' ? { elites: { reserves: 7, tanks: 0, forces: {}, revived: 0 } } : {}), hand: [], leaders: leaders(f), traitors: [], traitorChoices: [], ally: null, bribes: 0, revived: 0, freeForcesRevived: 0, leaderRevived: false, revivalCycle: 0, shipped: false, moved: 0, battleLosses: 0, }; } export function createGame( code: string, host: Player, advanced = false, expansions: string[] = [], ): Game { requireRule( expansions.every((x) => ['ix', 'choam', 'ecaz'].includes(x)), 'Unknown expansion.', ); return { schema: 1, code, version: 0, host: host.id, status: 'lobby', advanced, expansions: [...new Set(expansions)], players: [host], playerPositions: { [host.id]: 1 }, turn: 1, phase: 0, storm: STORM_START_SECTOR, order: [], active: null, movementRemaining: null, guildTimingGranted: false, guildTimingLocked: false, ready: [], deck: [], discard: [], spiceDeck: [], spicePeekKnown: false, spiceDiscard: [[], []], spice: {}, log: [{ seq: 1, text: `${host.name} opened the table.` }], stormDials: {}, stormPending: null, stormCard: null, stormCardKnown: false, stormResolution: null, karamaShipping: null, stormDialers: [], lastBattle: [], auction: null, battle: null, nexus: false, allianceOffers: {}, winner: [], shieldWallDestroyed: false, spiceWindow: null, hajr: [], decision: null, pendingCapture: null, response: null, wormRides: [], wormPlacementCanceledTurn: 0, spiceResolution: null, spiceSequence: null, freeRevival: [], emperorExtra: {}, aid: {}, }; } export function joinGame(g: Game, p: Player) { requireRule(g.status === 'lobby', 'This game has already started.'); requireRule(g.players.length < 6, 'This table has six players.'); requireRule(!g.players.some((x) => x.id === p.id), 'Already seated.'); requireRule( !g.players.some((x) => x.faction === p.faction), 'That faction is already taken.', ); requireRule( faction(p.faction).expansion === 'base' || g.expansions.includes(faction(p.faction).expansion), 'The host has not enabled that expansion.', ); g.players.push(p); g.playerPositions = normalizedPlayerPositions(g); g.players.forEach((x) => (x.ready = false)); log(g, `${p.name} joined as ${faction(p.faction).name}.`); } function draw(g: Game) { requireRule( !g.pendingTreacheryDiscard, 'Finish the committed discard continuation before drawing cards.', ); requireRule( !g.pendingNullentropy, 'Finish the paid Nullentropy Box search before drawing cards.', ); if (!g.deck.length) { g.deck = shuffle(g.discard); g.discard = []; } return g.deck.shift(); } function discard(g: Game, p: Player, id: string) { requireRule( !g.pendingTreacheryDiscard, 'Finish the committed discard continuation before another discard.', ); requireRule( !g.pendingNullentropy, 'Finish the paid Nullentropy Box search before changing the discard pile.', ); requireRule( !giftReserved(g, p.id, id), 'This card is reserved for the pending Richese gift.', ); const c = p.hand.find((c) => c.id === id); requireRule(c, 'Card is not in your hand.'); g.discard.push(c); p.hand = p.hand.filter((c) => c.id !== id); return c; } /** A receipt of suspended controls and live pending obligations; never restored * as game state. Player resources, hands, log and seat control are excluded. */ function nullentropyParentSignature( g: Game, resume: NonNullable['resume'], searchEvent: string, ) { const parents = Object.fromEntries( Object.entries(g) .filter( ([key, value]) => key.startsWith('pending') && ![ 'pendingTreacheryDiscard', 'pendingNullentropy', 'pendingKarama', ].includes(key) && value != null, ) .sort(([a], [b]) => a.localeCompare(b)), ); return JSON.stringify({ searchEvent, resume, parents }); } type OrdinaryDiscardContinuation = Extract< NonNullable['continuation'], { kind: 'ordinaryCardDiscard' } >; /** Bind completed effects, not a replacement Game. Seat control and private hands * may change independently of these outcomes; neither is restored on recovery. */ function ordinaryDiscardSignature(g: Game, next: OrdinaryDiscardContinuation) { return JSON.stringify({ player: next.player, card: next.card, effect: next.effect, controls: nullentropyParentSignature( g, next.resume, `ordinary:${next.card}`, ), phase: g.phase, turn: g.turn, advanced: g.advanced, expansions: g.expansions, active: g.active, order: g.order, ready: g.ready, movementRemaining: g.movementRemaining, hajr: g.hajr, ornithopter: g.ornithopter, karamaShipping: g.karamaShipping, storm: g.storm, stormPending: g.stormPending, stormDials: g.stormDials, stormResolution: g.stormResolution, shieldWallDestroyed: g.shieldWallDestroyed, spice: g.spice, spiceWindow: g.spiceWindow, spiceResolution: g.spiceResolution, spiceSequence: g.spiceSequence, battle: g.battle, shipmentPromises: g.shipmentPromises, techTokens: g.techTokens, dukeVidal: g.dukeVidal, revivalFreeIncome: g.revivalFreeIncome, players: g.players.map((p) => ({ id: p.id, faction: p.faction, spice: p.spice, forces: p.forces, reserves: p.reserves, tanks: p.tanks, elites: p.elites, advisors: p.advisors, moved: p.moved, shipped: p.shipped, revived: p.revived, revivalCycle: p.revivalCycle, leaderRevived: p.leaderRevived, leaders: p.leaders, kwisatz: p.kwisatz, })), }); } function stageOrdinaryCardDiscard(g: Game, p: Player, card: Card) { const resume = { response: g.response, decision: g.decision, pendingKarama: g.pendingKarama, phaseOpening: g.phaseOpening, }; g.response = null; g.decision = null; g.pendingKarama = null; g.phaseOpening = null; const next: OrdinaryDiscardContinuation = { kind: 'ordinaryCardDiscard', player: p.id, card: card.id, effect: card.effect as OrdinaryDiscardContinuation['effect'], resume, stateSignature: '', }; next.stateSignature = ordinaryDiscardSignature(g, next); stageTreacheryDiscard( g, `ordinary:${next.effect}`, [{ card, discardedBy: p.id, publicFace: true }], next, ); } type TruthDiscardContinuation = Extract< NonNullable['continuation'], { kind: 'truthtranceDiscard' } >; function truthtranceDiscardSignature(g: Game, next: TruthDiscardContinuation) { return JSON.stringify({ consumed: next.consumed, historyIndex: next.historyIndex, record: next.record, remaining: next.remaining, promise: next.promise, controls: nullentropyParentSignature( g, next.resume, `truth:${next.historyIndex}`, ), history: g.truthHistory, battle: g.battle, shipmentPromises: g.shipmentPromises, active: g.active, order: g.order, status: g.status, setupStage: g.setupStage, auction: g.auction, richeseAuction: g.richeseAuction, currentAuctionSale: g.currentAuctionSale, ornithopter: g.ornithopter, spiceWindow: g.spiceWindow, spiceResolution: g.spiceResolution, wormRides: g.wormRides, summonedWorm: g.summonedWorm, }); } /** Truth validation, promise binding and the public answer have already happened. */ function finishTruthtranceAnswer(g: Game) { const window = g.truthtrance!; const consumed = window.queue[0]; const historyIndex = g.truthHistory!.length - 1; const record = g.truthHistory![historyIndex]; const card = discard(g, getPlayer(g, consumed.player), consumed.card); let promise: TruthDiscardContinuation['promise'] = null; if (record.question.kind === 'shipment') promise = { kind: 'shipment', index: g.shipmentPromises!.length - 1, value: g.shipmentPromises!.at(-1)!, }; else if ( record.question.kind === 'battlePlan' && g.battle && !g.battle.revealed && !g.battle.plans[record.question.target] ) promise = { kind: 'battle', index: g.battle.truthPromises!.length - 1, value: g.battle.truthPromises!.at(-1)!, }; const resume = { response: g.response, decision: g.decision, pendingKarama: g.pendingKarama, phaseOpening: g.phaseOpening, }; const remaining: TruthWindow | null = window.queue.length > 1 ? { stage: 'ask', queue: window.queue.slice(1), passed: window.passed, question: null, } : null; g.truthtrance = null; g.response = null; g.decision = null; g.pendingKarama = null; g.phaseOpening = null; const continuation: TruthDiscardContinuation = { kind: 'truthtranceDiscard', consumed, historyIndex, record, remaining, resume, promise, parentSignature: '', }; continuation.parentSignature = truthtranceDiscardSignature(g, continuation); stageTreacheryDiscard( g, 'truthtrance', [{ card, discardedBy: consumed.player, publicFace: true }], continuation, ); } /** Bind the committed movement and its remaining arrival/turn obligations. * This receipt is never restored over live state and excludes seat AI controls. */ function ornithopterDiscardSignature( g: Game, next: Extract< NonNullable['continuation'], { kind: 'ornithopterDiscard' } >, ) { return JSON.stringify({ source: next.source, flight: next.flight, movement: next.movement, controls: nullentropyParentSignature(g, next.resume, next.flight.event), active: g.active, order: g.order, movementRemaining: g.movementRemaining, guildTimingGranted: g.guildTimingGranted, guildTimingLocked: g.guildTimingLocked, saphoMovementLast: g.saphoMovementLast, karamaShipping: g.karamaShipping, ambassadors: g.ecazAmbassadors, terror: g.moritaniTerror, players: g.players.map((p) => ({ id: p.id, faction: p.faction, ally: p.ally, allySinceTurn: p.allySinceTurn, forces: p.forces, elites: p.elites, reserves: p.reserves, tanks: p.tanks, advisors: p.advisors, noField: p.noField, noFieldEvent: p.noFieldEvent, moved: p.moved, shipped: p.shipped, })), }); } function suspendedControlsIntegrity( g: Game, resume: NonNullable['resume'], ) { const seated = (id: string) => g.players.some((p) => p.id === id); const passes = (ids: string[]) => Array.isArray(ids) && new Set(ids).size === ids.length && ids.every(seated); const response = (r: ResponseWindow | null | undefined) => !r || (typeof r.kind === 'string' && seated(r.owner) && passes(r.passed)); requireRule( resume && typeof resume === 'object' && !Array.isArray(resume) && Object.keys(resume).every((key) => ['response', 'decision', 'pendingKarama', 'phaseOpening'].includes(key), ) && response(resume.response) && (!resume.decision || (resume.decision.kind !== 'nullentropy' && typeof resume.decision.kind === 'string' && seated(resume.decision.player))) && (!resume.phaseOpening || (typeof resume.phaseOpening.initialize === 'boolean' && passes(resume.phaseOpening.passed))) && (!resume.pendingKarama || (seated(resume.pendingKarama.owner) && resume.pendingKarama.use && ['cancel', 'shipment', 'purchase', 'auctionPayment'].includes( resume.pendingKarama.use.kind, ) && (resume.pendingKarama.use.kind !== 'cancel' || response(resume.pendingKarama.use.response)))) && (resume.response?.kind !== 'worthlessKarama' || !!resume.pendingKarama), 'The completed discard does not match its suspended controls and obligations.', ); // Validate restored controls without advancing or replaying their effects. const context = structuredClone(g); context.pendingTreacheryDiscard = null; Object.assign(context, structuredClone(resume)); karamaConversionIntegrity(context); shipmentPromiseIntegrity(context); saphoMovementIntegrity(context); ornithopterIntegrity(context); lateDefenseIntegrity(context); stoneBurnerIntegrity(context); strongholdIntegrity(context); auditorIntegrity(context); } function treacheryDiscardIntegrity(g: Game) { const pending = g.pendingTreacheryDiscard; const sequence = g.treacheryDiscardSequence ?? 0; const resolved = g.resolvedTreacheryDiscardSequence ?? 0; requireRule( Number.isSafeInteger(sequence) && sequence >= 0 && Number.isSafeInteger(resolved) && resolved >= 0 && sequence === resolved + (pending ? 1 : 0), 'The saved discard sequence is inconsistent or already resolved.', ); if (!pending) return; const { batch, continuation } = pending; const box = continuation?.kind === 'nullentropyDiscard'; const truth = continuation?.kind === 'truthtranceDiscard'; const ambassadorTerror = continuation?.kind === 'terrorDiscard' && continuation.entry.cause === 'ambassador'; requireRule( (g.status === 'playing' || (truth && g.status === 'setup')) && pending.sequence === sequence && batch && batch.event === `discard:${g.turn}:${g.phase}:${sequence}` && batch.turn === g.turn && batch.phase === g.phase && Array.isArray(batch.entries) && batch.entries.length > 0 && !g.response && !g.decision && !g.truthtrance && !g.phaseOpening && (!g.pendingRicheseGift || box || truth) && !g.pendingKarama && !g.pendingNullentropy && (!g.pendingAmbassador || box || truth || ambassadorTerror) && (!g.pendingIxAlly || box || truth), 'The saved discard does not match its current continuation.', ); const ids = new Set(); // Auction prefixes and Ix knowledge are receipts, not additional custody. const outside = [ ...g.players.flatMap((p) => p.hand), ...g.deck, ...(g.richeseCache ?? []), ...(g.richeseRemoved ?? []), ...(g.ixSetupCards ?? []), ...(g.ixAuction?.cards ?? []), ...(g.ornithopter ? [g.ornithopter.card] : []), ...(g.auction?.cards.slice( g.auction.index + (g.currentAuctionSale ? 1 : 0), ) ?? []), ]; for (const entry of Array.from(batch.entries)) { const card = entry?.card; const actual = card && g.discard.filter((c) => c.id === card.id); requireRule( entry && card && typeof card.id === 'string' && card.id.length > 0 && !ids.has(card.id) && typeof entry.publicFace === 'boolean' && g.players.some((p) => p.id === entry.discardedBy) && actual?.length === 1 && actual[0].name === card.name && actual[0].kind === card.kind && actual[0].effect === card.effect && !outside.some((c) => c.id === card.id), 'The fresh discard receipt has conflicting physical custody.', ); ids.add(card.id); } if (continuation?.kind === 'nullentropyDiscard') { const c = continuation; const owner = g.players.find((p) => p.id === c.player); const selected = c.selected && owner?.hand.filter((card) => card.id === c.selected.id); requireRule( owner && typeof c.searchEvent === 'string' && c.searchEvent.length > 0 && batch.cause === 'nullentropyBox' && batch.entries.length === 1 && batch.entries[0].discardedBy === c.player && batch.entries[0].publicFace && batch.entries[0].card.id === c.box && richeseCardDefinition(batch.entries[0].card)?.card.effect === 'nullentropyBox' && g.discard.at(-1)?.id === c.box && Array.isArray(c.finalDiscardIds) && new Set(c.finalDiscardIds).size === c.finalDiscardIds.length && JSON.stringify(c.finalDiscardIds) === JSON.stringify(g.discard.map((card) => card.id)) && c.finalDiscardSignature === JSON.stringify(g.discard) && selected?.length === 1 && c.selected.effect !== 'nullentropyBox' && c.selected.name !== 'Nullentropy Box' && (['id', 'name', 'kind', 'effect'] as const).every( (key) => selected[0][key] === c.selected[key], ) && outside.filter((card) => card.id === c.selected.id).length === 1 && !g.discard.some((card) => card.id === c.selected.id) && g.discard.every( (card) => !outside.some((other) => other.id === card.id), ), 'The completed Box search has inconsistent selected-card or shuffled-pile custody.', ); requireRule( c.parentSignature === nullentropyParentSignature(g, c.resume, c.searchEvent), 'The completed Box search does not match its suspended controls and obligations.', ); suspendedControlsIntegrity(g, c.resume); } else if (continuation?.kind === 'ordinaryCardDiscard') { const c = continuation, entry = batch.entries[0]; const owner = g.players.find((p) => p.id === c.player); requireRule( owner && ['hajr', 'ghola', 'harvester', 'weather', 'atomics'].includes( c.effect, ) && batch.cause === `ordinary:${c.effect}` && batch.entries.length === 1 && entry.publicFace && entry.discardedBy === c.player && entry.card.id === c.card && entry.card.kind === 'special' && entry.card.effect === c.effect && c.stateSignature === ordinaryDiscardSignature(g, c), 'The consumed ordinary card no longer matches its completed effect.', ); suspendedControlsIntegrity(g, c.resume); requireRule( !c.resume.decision && !c.resume.pendingKarama && !c.resume.phaseOpening, 'The ordinary card has an unexpected suspended decision.', ); const income = c.resume.response, tleilaxu = byFaction(g, 'tleilaxu'); requireRule( c.effect === 'ghola' && tleilaxu ? income?.kind === 'revivalIncome' && income.owner === tleilaxu.id && income.recipient === c.player && income.amount === 1 && income.passed.length === 0 : !income, 'The consumed card no longer matches its pending revival income.', ); requireRule( c.effect === 'hajr' ? g.phase === 5 && g.active === c.player && g.hajr.filter((id) => id === c.player).length === 1 : c.effect === 'weather' ? g.phase === 0 && Number.isSafeInteger(g.stormPending) && g.stormPending! >= 0 && g.stormPending! <= 10 && g.ready.length === 0 : c.effect === 'atomics' ? g.phase === 0 && g.shieldWallDestroyed && g.ready.length === 0 && g.players.every( (p) => !Object.entries(p.forces).some( ([key, n]) => key.startsWith('shield_wall:') && n > 0, ), ) : c.effect === 'harvester' ? g.phase === 1 && !!g.spiceWindow?.harvested && Number.isSafeInteger(g.spiceWindow.harvesters) && g.spiceWindow.harvesters! > 0 && g.ready.length === 0 : true, 'The completed ordinary-card outcome has changed.', ); } else if (continuation?.kind === 'truthtranceDiscard') { const c = continuation, entry = batch.entries[0], record = c.record; requireRule( c.consumed && typeof c.consumed.player === 'string' && batch.cause === 'truthtrance' && batch.entries.length === 1 && entry.publicFace && entry.card.effect === 'truthtrance' && entry.card.id === c.consumed.card && entry.discardedBy === c.consumed.player && record && record.asker === c.consumed.player && record.turn === g.turn && record.phase === g.phase && ['yes', 'no'].includes(record.answer) && Array.isArray(g.truthHistory) && Number.isSafeInteger(c.historyIndex) && c.historyIndex >= 0 && c.historyIndex === g.truthHistory.length - 1 && JSON.stringify(g.truthHistory[c.historyIndex]) === JSON.stringify(record), 'The consumed Truthtrance does not match its completed answer history.', ); const remaining = c.remaining; requireRule( remaining === null || (remaining && remaining.stage === 'ask' && remaining.question === null && Array.isArray(remaining.queue) && remaining.queue.length > 0 && new Set(remaining.queue.map((e) => e?.card)).size === remaining.queue.length && remaining.queue.every( (e) => e && e.card !== c.consumed.card && typeof e.card === 'string' && e.card.length > 0 && typeof e.player === 'string' && g.players .find((p) => p.id === e.player) ?.hand.filter( (card) => card.id === e.card && card.effect === 'truthtrance', ).length === 1 && outside.filter((card) => card.id === e.card).length === 1 && !g.discard.some((card) => card.id === e.card), ) && Array.isArray(remaining.passed) && new Set(remaining.passed).size === remaining.passed.length && remaining.passed.every((id) => g.players.some((p) => p.id === id))), 'The remaining Truthtrance queue no longer matches its held cards and priority.', ); requireRule( c.parentSignature === truthtranceDiscardSignature(g, c), 'The completed Truthtrance no longer matches its recorded promises and parent.', ); suspendedControlsIntegrity(g, c.resume); const context = structuredClone(g); context.pendingTreacheryDiscard = null; Object.assign(context, structuredClone(c.resume)); try { validateTruthQuestionReceipt(context, c.consumed.player, record.question); } catch (error) { if (error instanceof TruthError || error instanceof PlanClaimError) throw new RuleError(error.message); throw error; } const q = record.question, promise = c.promise; const expectedShipment = q.kind === 'shipment'; const expectedBattle = q.kind === 'battlePlan' && g.battle && !g.battle.revealed && !g.battle.plans[q.target]; if (expectedShipment || expectedBattle) { const actual = expectedShipment ? g.shipmentPromises : g.battle?.truthPromises; requireRule( promise && promise.value && typeof promise.value === 'object' && promise.kind === (expectedShipment ? 'shipment' : 'battle') && Array.isArray(actual) && Number.isSafeInteger(promise.index) && promise.index >= 0 && promise.index === (actual?.length ?? 0) - 1 && JSON.stringify(promise.value) === JSON.stringify(actual?.[promise.index]) && promise.value.player === q.target && promise.value.asker === record.asker && promise.value.answer === (record.answer === 'yes') && !promise.value.released, 'The completed Truthtrance is missing its newly bound promise.', ); if (promise.kind === 'shipment') requireRule( q.kind === 'shipment' && promise.value.turn === g.turn && !promise.value.fulfilled && promise.value.territory === q.territory && promise.value.minimum === q.minimum, 'The completed Truthtrance shipment promise differs from its answer.', ); else requireRule( q.kind === 'battlePlan' && JSON.stringify(promise.value.claim) === JSON.stringify(q.claim), 'The completed Truthtrance battle promise differs from its answer.', ); } else requireRule( promise === null, 'This Truthtrance answer did not create a new promise.', ); } else if (continuation?.kind === 'ornithopterDiscard') { const c = continuation, flight = c.flight; const owner = flight && g.players.find((p) => p.id === flight.player); const total = flight?.mode === 'twoGroups' ? 2 : 1; requireRule( owner && g.phase === 5 && g.active === owner.id && !g.ornithopter && !g.pendingChoamMove && !g.pendingIxMove && !g.pendingFremenMove && !g.pendingTerrorEntry && !g.pendingExchange && !g.battle && flight.turn === g.turn && typeof flight.event === 'string' && flight.event.length > 0 && ['range3', 'twoGroups'].includes(flight.mode) && Number.isSafeInteger(flight.startingMove) && flight.startingMove >= 0 && Number.isSafeInteger(flight.completed) && flight.completed >= 0 && owner.moved === flight.startingMove + flight.completed && ['move', 'end'].includes(c.source) && (c.source === 'move' ? flight.completed === total : flight.completed < total) && batch.cause === `ornithopter:${c.source}` && batch.entries.length === 1 && batch.entries[0].publicFace && batch.entries[0].discardedBy === owner.id && flight.card && richeseCardDefinition(flight.card)?.card.effect === 'ornithopter' && (['id', 'name', 'kind', 'effect'] as const).every( (key) => flight.card[key] === batch.entries[0].card[key], ), 'The retired Ornithopter does not match its completed flight and discard.', ); if (c.source === 'move') { const m = c.movement; requireRule( m && m.player === owner.id && owner.shipped && gameTerritories(g).some((t) => t.id === m.origin) && validLocation(m.to, m.sector) && Number.isSafeInteger(m.total) && m.total > 0 && typeof m.noField === 'boolean' && Number.isSafeInteger(m.elite) && m.elite >= 0 && m.elite <= m.total - (m.noField ? 1 : 0) && (owner.forces[location(m.to, m.sector)] ?? 0) >= m.total - (m.noField ? 1 : 0) && (owner.elites?.forces[location(m.to, m.sector)] ?? 0) >= m.elite && (!m.noField || (owner.noField?.deployed?.location.territory === m.to && owner.noField.deployed.location.sector === m.sector)), 'The retired Ornithopter arrival differs from its committed movement.', ); } else requireRule( c.movement === null, 'An ended Ornithopter has no arrival to replay.', ); requireRule( c.stateSignature === ornithopterDiscardSignature(g, c), 'The retired Ornithopter no longer matches its saved movement obligations.', ); suspendedControlsIntegrity(g, c.resume); } else if (continuation?.kind === 'ambassador') { const entry = continuation.entry; const owner = g.players.find((p) => p.id === entry?.owner); const beneficiary = g.players.find((p) => p.id === entry?.beneficiary); requireRule( entry && entry.turn === g.turn && entry.phase === g.phase && typeof entry.event === 'string' && entry.event.length > 0 && entry.stage === 'cards' && ['choam', 'ixians'].includes(entry.effect!) && ['none', 'wormRide'].includes(entry.resume) && validAmbassadorResume(g, entry) && TERRITORIES.some( (t) => t.id === entry.territory && t.type === 'stronghold' && t.sectors.includes(entry.sector), ) && owner?.faction === 'ecaz' && beneficiary && (beneficiary.id === owner.id || (beneficiary.ally === owner.id && owner.ally === beneficiary.id)) && g.players.some((p) => p.id === entry.entrant) && g.ecazAmbassadors?.tokens.some( (token) => token.id === entry.token && ((token.effect === entry.effect && token.zone === 'used') || (token.effect === 'beneGesserit' && token.zone === 'removed' && Array.isArray(entry.copyChoices) && entry.copyChoices.includes(entry.effect!))), ) && batch.cause === `ambassador:${entry.effect}` && batch.entries.every( (c) => c.discardedBy === entry.beneficiary && !c.publicFace, ) && (entry.effect !== 'ixians' || batch.entries.length === 1), 'The saved Ambassador discard continuation is inconsistent.', ); } else if (continuation?.kind === 'terrorDiscard') { const { entry, owner: ownerId, source, discardedHandSize } = continuation; const owner = g.players.find((p) => p.id === ownerId); const entrant = g.players.find((p) => p.id === entry?.entrant); const tokens = g.moritaniTerror?.tokens.filter( (t) => t.id === entry?.token, ); if (entry?.cause === 'ambassador') ambassadorRelocationIntegrity(g); const sabotage = source === 'sabotage'; const discarder = sabotage ? entrant : owner; requireRule( !g.pendingTerrorEntry && !g.pendingExchange && !g.summonedWorm && entry && entry.turn === g.turn && entry.phase === g.phase && ['sabotage', 'robberyOverflow'].includes(source) && owner?.faction === 'moritani' && entrant && entrant.id !== owner.id && TERRITORIES.some( (t) => t.id === entry.territory && t.type === 'stronghold' && t.sectors.includes(entry.sector), ) && [ 'shipment', 'movement', 'guildTransport', 'advisor', 'wormRide', 'ambassador', ].includes(entry.cause) && ['none', 'wormRide', 'ambassador'].includes(entry.resume) && (entry.cause === 'ambassador' ? ambassadorTerrorEntryMatches(g, entry) : entry.cause === 'wormRide' ? entry.resume === 'wormRide' && g.phase === 1 && entrant.faction === 'fremen' : entry.resume === 'none') && Number.isSafeInteger(entry.amount) && entry.amount >= 0 && Number.isSafeInteger(entry.elite) && entry.elite >= 0 && entry.elite <= entry.amount && tokens?.length === 1 && tokens[0].kind === (sabotage ? 'sabotage' : 'robbery') && tokens[0].status === 'removed' && tokens[0].location === null && entry.stage === (sabotage ? 'offer' : 'discard') && batch.cause === `terror:${source}` && batch.entries.length === 1 && !batch.entries[0].publicFace && discarder && batch.entries[0].discardedBy === discarder?.id && Number.isSafeInteger(discardedHandSize) && discardedHandSize > 0 && discardedHandSize === discarder.hand.length + 1 && (sabotage || discardedHandSize > handLimit(owner)), 'The saved Terror discard does not match its consumed entry and hand.', ); } else if (continuation?.kind === 'ixAllyCard') { const sale = g.currentAuctionSale; const buyer = g.players.find((p) => p.id === continuation.player); const ixians = byFaction(g, 'ixians'); const lot = g.richeseAuction; requireRule( g.phase === 3 && sale && continuation.sale && Number.isSafeInteger(sale.amount) && sale.amount >= 0 && typeof sale.free === 'boolean' && ['normal', 'blackMarket'].includes(sale.origin) && buyer && ixians && buyer.ally === ixians.id && ixians.ally === buyer.id && (['winner', 'amount', 'free', 'origin', 'seller'] as const).every( (key) => sale[key] === continuation.sale[key], ) && continuation.player === sale.winner && continuation.free === sale.free && continuation.auctionIndex === (g.auction?.index ?? null) && continuation.auctionEvent === (g.richeseAuction?.event ?? null) && batch.cause === 'ixAllyCard' && batch.entries.length === 1 && batch.entries[0].card.id === continuation.card && batch.entries[0].discardedBy === continuation.player && batch.entries[0].publicFace === false && (sale.origin === 'normal' ? sale.seller === null && g.auction?.bidder === buyer.id && g.auction.bid === sale.amount && g.auction.cards[g.auction.index]?.id === continuation.card : !sale.free && lot?.source === 'blackMarket' && lot.owner === sale.seller && g.players.some((p) => p.id === sale.seller && p.id !== buyer.id) && lot.outcome?.kind === 'sold' && lot.outcome.winner === buyer.id && lot.outcome.amount === sale.amount && lot.cardId === continuation.card), 'The saved Ixian replacement no longer matches its paid auction.', ); } else if ( continuation?.kind === 'battleResolved' || continuation?.kind === 'battleCleanup' ) { const c = continuation; const context = g.lastBattleContext; requireRule( g.phase === 6 && !g.battle && context && context.turn === g.turn && typeof context.event === 'string' && context.event.length > 0 && c.event === context.event && c.territory === context.territory && JSON.stringify(c.combatants) === JSON.stringify(context.combatants) && Array.isArray(c.combatants) && c.combatants.length === 2 && new Set(c.combatants).size === 2 && JSON.stringify(c.combatants) === JSON.stringify(g.lastBattle) && c.combatants.every((id) => g.players.some((p) => p.id === id)) && gameTerritories(g).some((t) => t.id === c.territory) && batch.entries.every( (e) => e.publicFace && c.combatants.includes(e.discardedBy), ), 'The saved battle discard does not match its resolved combatants.', ); // Projection must validate the private Auditor owner as strictly as actions. auditorIntegrity(g); const loser = c.combatants.find((id) => id !== context.winner); requireRule( (!g.pendingAuditor || (g.pendingAuditor.stage === 'offer' && g.pendingAuditor.event === context.event && g.pendingAuditor.territory === c.territory)) && (!g.pendingCapture || (g.advanced && g.pendingCapture.player === context.winner && g.pendingCapture.loser === loser && g.pendingCapture.territory === c.territory && g.players.find((p) => p.id === context.winner)?.faction === 'harkonnen')) && (!g.pendingTech || (g.pendingTech.player === context.winner && g.pendingTech.loser === loser && g.pendingTech.choices.length > 0 && new Set(g.pendingTech.choices).size === g.pendingTech.choices.length && g.pendingTech.choices.every((id) => ownedTech(g.techTokens, loser!).includes(id), ))) && (!g.pendingFaceDance || (g.pendingFaceDance.winner === context.winner && g.pendingFaceDance.territory === c.territory && g.players.find((p) => p.id === g.pendingFaceDance!.player) ?.faction === 'tleilaxu')) && (!g.pendingChoamBattleIncome || (Number.isSafeInteger(g.pendingChoamBattleIncome.amount) && g.pendingChoamBattleIncome.amount > 0 && g.players.find((p) => p.id === g.pendingChoamBattleIncome!.owner) ?.faction === 'choam')), 'The saved battle discard has inconsistent remaining battle effects.', ); const held = c.kind === 'battleResolved' ? c.cards : c.kept; const owner = c.kind === 'battleResolved' ? c.winner : c.player; requireRule( Array.isArray(held) && new Set(held).size === held.length && (owner === null ? held.length === 0 : c.combatants.includes(owner)) && held.every( (id) => typeof id === 'string' && !ids.has(id) && outside.filter((card) => card.id === id).length === 1 && !g.discard.some((card) => card.id === id) && g.players .find((p) => p.id === owner) ?.hand.some((card) => card.id === id), ), 'The cards kept for the battle continuation are inconsistent.', ); if (c.kind === 'battleCleanup') { requireRule( ['winner', 'moritani'].includes(c.source) && batch.cause === `battle:${c.source}` && Array.isArray(c.played) && new Set(c.played).size === c.played.length && c.played.length === c.kept.length + batch.entries.length && c.played.every( (id) => typeof id === 'string' && (ids.has(id) || c.kept.includes(id)), ) && (c.source === 'winner' ? c.player === context.winner : c.player !== context.winner) && batch.entries.every((e) => e.discardedBy === c.player) && (c.source !== 'moritani' || (!g.moritaniRetention && c.kept.length <= 1)), 'The saved battle card cleanup is inconsistent.', ); if (c.source === 'moritani') { const r = c.retention; const owner = g.players.find((p) => p.id === r?.owner); const loser = g.players.find((p) => p.id === c.player); requireRule( r && r.turn === g.turn && r.territory === c.territory && r.player === c.player && owner?.faction === 'moritani' && owner.ally === c.player && loser?.ally === owner.id && ['choose', 'response'].includes(r.stage) && JSON.stringify(r.played) === JSON.stringify(c.played) && Array.isArray(r.eligible) && new Set(r.eligible).size === r.eligible.length && r.eligible.every((id) => r.played.includes(id)) && c.kept.every((id) => r.eligible.includes(id)), 'The consumed Moritani retention record is inconsistent.', ); } } else { requireRule( batch.cause === 'battle:mandatory' && c.result === context.result && c.winner === context.winner && ['normal', 'traitor', 'mutualTraitors', 'explosion'].includes( c.result, ) && (c.winner === null) === ['mutualTraitors', 'explosion'].includes(c.result) && !!c.casualties === (c.result === 'normal' && (g.advanced || g.players.find((p) => p.id === c.winner)?.faction === 'ixians')), 'The mandatory battle discard cause is inconsistent.', ); if (c.casualties) { const { forces, dial, support, options } = c.casualties; const winner = g.players.find((p) => p.id === c.winner); const total = winner ? Object.entries(winner.forces).reduce( (n, [key, count]) => n + (splitLocation(key).territory === c.territory ? count : 0), 0, ) : 0; const elites = winner ? Object.entries(winner.elites?.forces ?? {}).reduce( (n, [key, count]) => n + (splitLocation(key).territory === c.territory ? count : 0), 0, ) : 0; requireRule( winner && forces && Number.isSafeInteger(forces.normal) && forces.normal >= 0 && Number.isSafeInteger(forces.elite) && forces.elite >= 0 && [1, 2].includes(forces.eliteStrength) && typeof forces.freeSupport === 'boolean' && (forces.normalFixedHalf === undefined || typeof forces.normalFixedHalf === 'boolean') && forces.normal === total - elites && forces.elite === elites && Array.isArray(options) && options.length > 0 && JSON.stringify(options) === JSON.stringify(casualtyOptions(forces, dial, support)), 'The saved battle casualties do not match the committed dial, support and effective forces.', ); } } } else throw new RuleError('Unknown saved discard continuation.'); } function stageTreacheryDiscard( g: Game, cause: string, entries: FreshDiscardBatch['entries'], continuation: NonNullable['continuation'], ) { requireRule(!g.pendingTreacheryDiscard, 'Finish the prior discard first.'); treacheryDiscardIntegrity(g); const sequence = (g.treacheryDiscardSequence ?? 0) + 1; g.treacheryDiscardSequence = sequence; g.pendingTreacheryDiscard = { sequence, batch: { event: `discard:${g.turn}:${g.phase}:${sequence}`, turn: g.turn, phase: g.phase, cause, entries: structuredClone(entries), }, continuation: structuredClone(continuation), }; treacheryDiscardIntegrity(g); } /** No reaction policy is selected here. Semuta activation remains unavailable. * The existing exchanges resume automatically from their exact committed stage. */ function finishTreacheryDiscard(g: Game) { treacheryDiscardIntegrity(g); const pending = g.pendingTreacheryDiscard; if (!pending) return; const next = pending.continuation; // Retire before any suffix can draw or create the next semantic discard. g.resolvedTreacheryDiscardSequence = pending.sequence; g.pendingTreacheryDiscard = null; if (next.kind === 'ambassador') { const p = getPlayer(g, next.entry.beneficiary!); if (next.entry.effect === 'ixians') { const card = draw(g); requireRule(card, 'No replacement card is available.'); p.hand.push(card); log( g, `${p.name} discarded one Treachery card and drew its replacement through the Ixian Ambassador. The replacement remains private.`, { faction: p.faction, name: 'Ixian Ambassador' }, ); } // CHOAM's selected-count payout was already committed with its discard. g.pendingAmbassador = next.entry; finishAmbassador(g); } else if (next.kind === 'nullentropyDiscard') { g.response = next.resume.response; g.decision = next.resume.decision; g.pendingKarama = next.resume.pendingKarama; g.phaseOpening = next.resume.phaseOpening; } else if (next.kind === 'ordinaryCardDiscard') { g.response = next.resume.response; g.decision = next.resume.decision; g.pendingKarama = next.resume.pendingKarama; g.phaseOpening = next.resume.phaseOpening; } else if (next.kind === 'truthtranceDiscard') { g.response = next.resume.response; g.decision = next.resume.decision; g.pendingKarama = next.resume.pendingKarama; g.phaseOpening = next.resume.phaseOpening; g.truthtrance = next.remaining; } else if (next.kind === 'ornithopterDiscard') { g.response = next.resume.response; g.decision = next.resume.decision; g.pendingKarama = next.resume.pendingKarama; g.phaseOpening = next.resume.phaseOpening; if (next.source === 'move') finishMovedGroup(g, next.movement!); else finishMovementTurn(g, next.flight.player); } else if (next.kind === 'ixAllyCard') { const buyer = getPlayer(g, next.player); const card = draw(g); if (card) buyer.hand.push(card); log( g, `${buyer.name} discarded the purchased card and drew a replacement using the Ixian alliance.`, ); continueAuctionSale(g, next.free); } else if (next.kind === 'battleResolved') { continueResolvedBattle(g, next); } else if (next.kind === 'terrorDiscard') { g.pendingTerrorEntry = next.entry; continueTerrorDiscard(g, next.source); } else { finishBattle(g); } } function place(p: Player, t: string, s: number, n: number, elite = 0) { const k = location(t, s); p.forces[k] = (p.forces[k] ?? 0) + n; if (p.elites && elite) p.elites.forces[k] = (p.elites.forces[k] ?? 0) + elite; } function kill( g: Game, p: Player, key: string, n: number, battle = false, elite?: number, ) { const amount = Math.min(n, p.forces[key] ?? 0); if (p.elites) { const available = p.elites.forces[key] ?? 0; const lost = elite ?? Math.max(0, amount - ((p.forces[key] ?? 0) - available)); requireRule( lost >= 0 && lost <= available && lost <= amount && amount - lost <= (p.forces[key] ?? 0) - available, 'Invalid elite casualties.', ); p.elites.forces[key] = available - lost; if (!p.elites.forces[key]) delete p.elites.forces[key]; p.elites.tanks += lost; } p.forces[key] = (p.forces[key] ?? 0) - amount; if (!p.forces[key]) delete p.forces[key]; p.tanks += amount; if (battle) p.battleLosses += amount; } function killTerritory( g: Game, p: Player, t: string, n = Infinity, battle = false, noFieldCause?: NoFieldRevealCause, ) { if (p.noField?.deployed?.location.territory === t) { requireRule( noFieldCause, 'This removal of a concealed No-Field awaits its specific reveal and casualty rules.', ); revealPlayerNoField(g, p, noFieldCause); } for (const key of Object.keys(p.forces)) { if (splitLocation(key).territory !== t) continue; const lost = Math.min(n, p.forces[key]); kill(g, p, key, lost, battle); n -= lost; } } function combatForces( g: Game, p: Player, t: string, opponent: Player, ): CombatForces { const marker = p.noField?.deployed; const prospective = marker?.location.territory === t ? Math.min( p.reserves, p.noField!.tokens.find((token) => token.id === marker.tokenId)!.value, ) : 0; const physical = Object.entries(p.forces).reduce( (n, [key, count]) => n + (splitLocation(key).territory === t ? count : 0), 0, ); const elite = Object.entries(p.elites?.forces ?? {}) .filter(([key]) => splitLocation(key).territory === t) .reduce((sum, [, n]) => sum + n, 0); return { normal: physical + prospective - elite, normalFixedHalf: p.faction === 'ixians', elite, eliteStrength: (g.battle?.eliteBlocked?.includes(p.id) && !(g.advanced && p.faction === 'ixians')) || (p.faction === 'emperor' && opponent.faction === 'fremen') ? 1 : 2, freeSupport: !g.advanced || (p.faction === 'fremen' && !g.battle?.fremenSupportBlocked), }; } function takeBattleLosses(g: Game, p: Player, t: string, losses: Casualties) { const lostCyborgs: Record = {}; let normal = losses.normal, elite = losses.elite; for (const key of Object.keys(p.forces).filter( (key) => splitLocation(key).territory === t, )) { const e = Math.min(elite, p.elites?.forces[key] ?? 0); const n = Math.min(normal, p.forces[key] - (p.elites?.forces[key] ?? 0)); kill(g, p, key, n + e, true, e); if (e) lostCyborgs[key] = e; normal -= n; elite -= e; } requireRule( normal === 0 && elite === 0, 'The selected casualties are not available.', ); return lostCyborgs; } function start(g: Game) { requireRule(g.players.length >= 2, 'At least two players are needed.'); requireRule( g.players.every((p) => p.ready), 'Every player must be ready.', ); requireRule(!g.advanced, 'Advanced rules are still being implemented.'); requireRule( g.players.every((p) => faction(p.faction).expansion === 'base'), 'Expansion factions are still being implemented.', ); requireRule( g.expansions.length === 0, 'Expansion rules are still being implemented.', ); initializeSetup(g); } function initializeSetup(g: Game) { const choam = byFaction(g, 'choam'); if (choam && g.advanced && !choam.leaders.some(isAuditorLeader)) choam.leaders.push(createAuditorLeader()); if (g.strongholdCards) { requireRule(g.advanced, 'Stronghold Cards require Advanced rules.'); g.strongholdCards = createStrongholdCards(); } if (g.techTokens) { requireRule( g.players.length >= 3, 'Tech tokens currently require at least three players.', ); g.techTokens = createTechTokens(g.players); } if (byFaction(g, 'richese')) { g.richeseCache = richeseCards(); g.richeseRemoved = []; } g.playerPositions = normalizedPlayerPositions(g); g.storm = STORM_START_SECTOR; stormOrder(g); g.stormDialers = [g.order[0], g.order.at(-1)!]; g.lastBattle = [...g.stormDialers]; g.deck = shuffle(treacheryDeck(g.expansions)); g.spiceDeck = shuffle(spiceDeck(g.expansions.includes('ix'))); g.sandtrout = false; g.status = 'setup'; g.setupStage = 'prediction'; g.ready = []; log( g, byFaction(g, 'beneGesserit') ? 'Player positions are fixed. Bene Gesserit must lock its prediction before private cards are dealt.' : 'Player positions are fixed. Private traitor selection comes before force placement and starting Treachery Cards.', ); } function initializeStartingForces(g: Game) { for (const p of g.players) { p.spice = faction(p.faction).spice; if (p.faction === 'richese') { p.noField = createRicheseNoField([ crypto.randomUUID(), crypto.randomUUID(), crypto.randomUUID(), ]); p.noFieldEvent = crypto.randomUUID(); } if (p.faction === 'moritani') g.moritaniTerror = createTerrorState(random); if (p.faction === 'ecaz') g.ecazAmbassadors = createAmbassadors(random); if (p.faction === 'moritani' || p.faction === 'ecaz') g.dukeVidal ??= createDukeVidal(); delete p.elites; if (p.faction === 'ixians') { p.elites = { reserves: 4, tanks: 0, forces: {}, revived: 0 }; place(p, MOBILE_STRONGHOLD, 0, 6, 3); p.reserves -= 6; g.mobileStronghold = { location: null }; } if (g.advanced && ['emperor', 'fremen'].includes(p.faction)) p.elites = { reserves: p.faction === 'emperor' ? 5 : 3, tanks: 0, forces: {}, revived: 0, }; const pos: Record = { atreides: ['arrakeen', 10, 10], harkonnen: ['carthag', 11, 10], guild: ['tueks_sietch', 5, 5], beneGesserit: ['polar_sink', 0, 1], }; const t = pos[p.faction]; if (t && !(g.advanced && p.faction === 'beneGesserit')) { place(p, ...t); p.reserves -= t[2]; } } } function dealStartingTreachery(g: Game) { const ixians = byFaction(g, 'ixians'); if (ixians) { g.ixSetupCards = []; for (const _player of g.players) { const card = draw(g); if (card) g.ixSetupCards.push(card); } g.decision = { kind: 'ixSetup', player: ixians.id }; } else { for (const p of g.players) { const quantity = p.faction === 'harkonnen' ? 2 : 1; for (let i = 0; i < quantity; i++) { const card = draw(g); if (card) p.hand.push(card); } log( g, `${p.name} received ${quantity} starting Treachery Card${quantity === 1 ? '' : 's'} after force placement.`, { faction: p.faction, name: 'Starting cards' }, ); } } if (ixians) log( g, 'Starting force placement is complete. Ixians must choose their starting card before the remaining cards are dealt.', ); } function setupPending(g: Game): string[] { if (g.status !== 'setup' || !g.setupStage) return []; if (g.setupStage === 'prediction') { const bg = byFaction(g, 'beneGesserit'); return bg && !bg.prediction ? [bg.id] : []; } if (g.setupStage === 'traitors') return g.players .filter((p) => p.traitorChoices.length > 0) .map((p) => p.id); const fremen = byFaction(g, 'fremen'); if (fremen?.reserves === 20) return [fremen.id]; const bg = byFaction(g, 'beneGesserit'); return g.advanced && bg && !bg.advisorSetup ? [bg.id] : []; } function advanceSetup(g: Game) { if (!g.setupStage || g.status !== 'setup') return; if (g.setupStage === 'prediction') { if (setupPending(g).length) return; const traitors = shuffle( traitorDeck(g.players, g.expansions.includes('ix')), ); for (const p of g.players) { p.traitorChoices = p.faction === 'tleilaxu' ? [] : traitors.splice(0, 4); if (p.faction === 'harkonnen') { p.traitors = p.traitorChoices; p.traitorChoices = []; } } g.traitorReserve = traitors; g.setupStage = 'traitors'; log( g, 'Traitor Cards were dealt privately. Choose traitors before starting force placement; Treachery Cards remain undealt.', ); } if (g.setupStage === 'traitors') { if (setupPending(g).length) return; initializeStartingForces(g); g.setupStage = 'forces'; log( g, 'Traitor choices are complete. Starting spice and fixed forces are placed; complete faction force placement before receiving Treachery Cards.', ); } if (g.setupStage === 'forces' && !setupPending(g).length) { delete g.setupStage; dealStartingTreachery(g); } } /** Offline-only test seam. No player action or room API dispatches this function. */ export function initializeBaseGameForAudit(state: Game): Game { const g = structuredClone(state); requireRule( g.status === 'lobby' && !g.setupStage && g.turn === 1 && g.phase === 0, 'The audit initializer requires a fresh lobby.', ); requireRule( g.players.length >= 2 && g.players.length <= 6 && new Set(g.players.map((p) => p.id)).size === g.players.length && new Set(g.players.map((p) => p.faction)).size === g.players.length && g.players.some((p) => p.id === g.host) && g.players.every((p) => p.id && p.ready), 'The audit initializer requires two through six distinct ready players and an existing host.', ); requireRule( g.expansions.length === 0 && !g.techTokens && !g.strongholdCards && g.players.every((p) => FACTIONS.some((f) => f.id === p.faction && f.expansion === 'base'), ), 'The audit initializer supports base factions without expansions or optional modules.', ); requireRule( !g.deck.length && !g.discard.length && !g.spiceDeck.length && !g.spiceDiscard.some((pile) => pile.length) && !g.traitorReserve?.length && !g.decision && !g.response && !g.battle && !g.winner.length && g.players.every( (p) => !p.hand.length && !p.traitors.length && !p.traitorChoices.length && Object.keys(p.forces).length === 0 && p.reserves === 20 && p.tanks === 0 && p.spice === 0 && p.moved === 0 && !p.shipped && !p.prediction && !p.advisorSetup && !p.elites, ), 'The audit initializer cannot redeal or replace existing game pieces.', ); requireRule( Object.keys(g.playerPositions ?? {}).every((id) => g.players.some((p) => p.id === id), ), 'Player circles must belong to seated players.', ); normalizedPlayerPositions(g); initializeSetup(g); return normalizeAutomaticGame(g); } function otherSetupComplete(g: Game) { return ( !g.setupStage && !g.ixSetupCards && g.players.every( (p) => (p.faction === 'tleilaxu' || p.traitors.length > 0) && (p.faction !== 'fremen' || p.reserves === 10) && (p.faction !== 'beneGesserit' || (p.prediction && (!g.advanced || p.advisorSetup))), ) ); } function setupComplete(g: Game) { return ( otherSetupComplete(g) && !g.players.some((p) => p.faction === 'moritani' && p.reserves === 20) ); } function finishMoritaniPlacement(g: Game) { if (!byFaction(g, 'choam')) victory(g); } function finishSetup(g: Game) { const tleilaxu = byFaction(g, 'tleilaxu'); if (tleilaxu) { const held = new Set(g.players.flatMap((player) => player.traitors)); g.traitorReserve = shuffle( traitorDeck(g.players, g.expansions.includes('ix')).filter( (card) => !held.has(card), ), ); tleilaxu.faceDancers = g.traitorReserve .splice(0, 3) .map((leader) => ({ leader, revealed: false })); } g.status = 'playing'; log(g, 'Setup complete. The first storm awaits two secret dials.'); openPhase(g, false); } /** Pure legacy/default projection. Existing occupied circles never move on join. */ function normalizedPlayerPositions(g: Game): Record { const positions: Record = {}; const occupied = new Set(); for (const p of g.players) { const position = g.playerPositions?.[p.id]; if (position === undefined) continue; requireRule( Number.isInteger(position) && position >= 1 && position <= 6 && !occupied.has(position), 'Player circles must be distinct positions from 1 to 6.', ); positions[p.id] = position; occupied.add(position); } for (const p of g.players) { if (positions[p.id] !== undefined) continue; const position = [1, 2, 3, 4, 5, 6].find( (candidate) => !occupied.has(candidate), ); requireRule(position !== undefined, 'There are only six player circles.'); positions[p.id] = position; occupied.add(position); } return positions; } function stormOrder(g: Game) { g.playerPositions = normalizedPlayerPositions(g); // A marker already under the storm has been reached; the next occupied // marker ahead counterclockwise goes first. Empty circles are skipped. const distance = (id: string) => (PLAYER_CIRCLE_SECTORS[g.playerPositions![id] - 1] - g.storm + 18) % 18 || 18; g.order = g.players .map((p) => p.id) .sort((a, b) => distance(a) - distance(b)); } function stormExposed(g: Game, key: string) { const t = territory(splitLocation(key).territory); return ( (t.type === 'sand' && t.id !== 'imperial_basin') || (g.shieldWallDestroyed && ['arrakeen', 'carthag', 'imperial_basin'].includes(t.id)) ); } function moveStorm(g: Game, n: number) { g.stormResolution = { from: g.storm, distance: n, traversed: 0, pending: [] }; offerChoamStorm(g); } function choamStormOptions(g: Game) { return choamStormTerritories(g); } function currentChoamStormQuote(g: Game) { try { return quoteChoamStormOffer(g); } catch (error) { if (error instanceof ChoamStormQuoteError) throw new RuleError(error.message); throw error; } } function commitChoamStormOffer(g: Game, quote: ChoamStormQuote) { if (quote.kind === 'decision') g.decision = quote.decision; else beginStormProtection(g); } function offerChoamStorm(g: Game) { commitChoamStormOffer(g, currentChoamStormQuote(g)); } function beginStormProtection(g: Game) { const n = g.stormResolution!.distance; const fremen = byFaction(g, 'fremen'); const crossed = new Set( Array.from({ length: n }, (_, i) => ((g.storm + i) % 18) + 1), ); if ( g.advanced && fremen && Object.keys(fremen.forces).some( (key) => stormExposed(g, key) && crossed.has(splitLocation(key).sector), ) ) { g.response = { kind: 'stormProtection', owner: fremen.id, passed: [], resume: 'storm', }; } else continueStorm(g); } function stormCasualties( g: Game, p: Player, key: string, count: number, elite: number, resume: 'storm' | 'shipment', ) { const amount = Math.ceil(count / 2), minElite = Math.max(0, amount - (count - elite)), maxElite = Math.min(amount, elite); if (minElite < maxElite) g.decision = { kind: 'stormLosses', player: p.id, key, amount, minElite, maxElite, resume, }; else kill(g, p, key, amount, false, minElite); } function continueStorm(g: Game) { disasterPreflight(() => validateStormTraversal(g)); normalizedPlayerPositions(g); const resolution = g.stormResolution!; const fremen = byFaction(g, 'fremen'); while (true) { while (resolution.pending.length) { const key = resolution.pending.shift()!; if (fremen && (fremen.forces[key] ?? 0) > 0) { stormCasualties( g, fremen, key, fremen.forces[key], fremen.elites?.forces[key] ?? 0, 'storm', ); if (g.decision) return; } } if (resolution.traversed === resolution.distance) break; resolution.traversed++; const s = ((resolution.from - 1 + resolution.traversed) % 18) + 1; if (g.shieldWallDestroyed) for (const city of ['arrakeen', 'carthag']) if (territory(city).sectors.includes(s)) returnAmbassadorsIn(g, city, 'the storm crossed the exposed city'); for (const p of g.players) { const marker = p.noField?.deployed?.location; if (marker && marker.sector === s) { const t = territory(marker.territory); if ( (t.type === 'sand' && t.id !== 'imperial_basin') || (g.shieldWallDestroyed && ['arrakeen', 'carthag', 'imperial_basin'].includes(t.id)) ) revealPlayerNoField(g, p, 'storm'); } for (const key of Object.keys(p.forces)) { const loc = splitLocation(key), t = territory(loc.territory); const exposed = (t.type === 'sand' && t.id !== 'imperial_basin') || (g.shieldWallDestroyed && ['arrakeen', 'carthag', 'imperial_basin'].includes(t.id)); if (loc.sector === s && exposed) { if ( p.faction === 'choam' && resolution.choamProtected?.includes(t.id) ) continue; if ( g.advanced && p.faction === 'fremen' && resolution.protected !== false ) resolution.pending.push(key); else kill(g, p, key, p.forces[key]); } } } for (const key of Object.keys(g.spice)) if (splitLocation(key).sector === s) delete g.spice[key]; } g.storm = ((resolution.from - 1 + resolution.distance) % 18) + 1; stormOrder(g); if (g.turn === 1) assignRemainingTech(g); log(g, `Storm moved ${resolution.distance} sectors to sector ${g.storm}.`); g.stormDials = {}; g.stormPending = null; g.stormResolution = null; const ixians = byFaction(g, 'ixians'); if (ixians && g.mobileStronghold && !g.mobileStronghold.location) { g.decision = { kind: 'mobileStronghold', player: ixians.id, placement: true, }; return; } finishStorm(g); } function finishStorm(g: Game) { if (!openChoamMarket(g, 'storm')) advanceAfterStorm(g); } function advanceAfterStorm(g: Game) { const fremen = byFaction(g, 'fremen'); g.phase = 1; g.ready = []; if (g.advanced && fremen) { g.stormCard = shuffle([1, 2, 3, 4, 5, 6])[0]; g.stormCardKnown = false; g.response = { kind: 'stormPeek', owner: fremen.id, passed: [] }; } openPhase(g, false); } function refillSpice(g: Game) { if (!g.spiceDeck.length) { g.spiceDeck = shuffle( g.spiceDiscard .flat() .filter((c) => !('worm' in c && c.thumper)) .map((c) => ('worm' in c ? { worm: true as const } : c)), ); g.spiceDiscard = [[], []]; } } function drawSpice(g: Game) { refillSpice(g); return g.spiceDeck.shift(); } function devour( g: Game, t: string, protectedAlly?: string, protectFremen = true, ) { disasterPreflight(() => validateWormDevouring(g, t, protectedAlly, protectFremen), ); for (const p of g.players) if ((!protectFremen || p.faction !== 'fremen') && p.id !== protectedAlly) killTerritory(g, p, t, Infinity, false, 'worm'); for (const k of Object.keys(g.spice)) if (splitLocation(k).territory === t) delete g.spice[k]; const fremen = byFaction(g, 'fremen'); if (fremen && at(fremen, t)) g.wormRides.push(t); log(g, `Shai-Hulud appeared in ${territory(t).name}.`); } function beginWorm(g: Game, t: string) { const fremen = byFaction(g, 'fremen'); if (fremen?.ally && at(getPlayer(g, fremen.ally), t)) { g.decision = { kind: 'wormProtection', player: fremen.id, territory: t, ally: fremen.ally, }; } else wormSurvival(g, t); } function wormSurvival(g: Game, t: string, protectedAlly?: string) { const fremen = byFaction(g, 'fremen'); if (fremen && at(fremen, t)) { g.response = { kind: 'wormSurvival', owner: fremen.id, location: t, recipient: protectedAlly, passed: [], }; } else { devour(g, t, protectedAlly); afterWorm(g); } } function afterWorm(g: Game) { const pending = g.summonedWorm; if (!pending) { continueSpice(g); return; } const rides = g.wormRides; Object.assign(g, pending.resume); g.summonedWorm = null; g.wormRides = [...g.wormRides, ...rides]; g.ready = []; if (g.spiceWindow?.territory === pending.territory) { g.spiceWindow.harvested = true; g.spiceWindow.harvesterClosed = true; } const beforeBlow = !g.spiceSequence && !g.spiceResolution && !g.spiceWindow && !pending.resume.nexus; if (beforeBlow) { g.summonedBeforeBlow = true; g.nexus = false; } else g.nexus = true; // A summon during an already active ride returns that unmade ride to the // queue and opens negotiations before it. An intrusion already caused by a // completed ride resolves before opening the newly triggered Nexus. if (g.decision?.kind === 'wormRide') { g.wormRides.unshift(g.decision.territory); g.decision = null; } else if ( g.pendingAmbassador?.resume === 'wormRide' || g.pendingTerrorEntry?.resume === 'wormRide' || (g.pendingKarama?.use.kind === 'cancel' && g.pendingKarama.use.response.kind === 'advisorFlip' && g.pendingKarama.use.response.advisorResume === 'wormRide') || (g.decision?.kind === 'intrusion' && g.decision.wormRide) || (g.response?.kind === 'advisorFlip' && g.response.advisorResume === 'wormRide') ) g.summonedNexusBeforeRides = true; log( g, 'The summoned worm has resolved. Its Nexus occurs after the spice blow.', ); } function blowSpice(g: Game, thumper = false) { g.nexus = !!g.summonedBeforeBlow; if (!g.summonedBeforeBlow) g.wormRides = []; g.summonedBeforeBlow = false; g.spiceResolution = { skipped: [] }; g.spiceSequence = { pile: 0, skipped: [] }; continueSpice(g, thumper ? { worm: true, thumper: true } : undefined); } function continueSpice(g: Game, injected?: SpiceCard) { g.spiceSequence ??= { pile: 0, skipped: [...(g.spiceResolution?.skipped ?? [])], }; const sequence = g.spiceSequence; let previous = g.spiceDiscard[sequence.pile].findLast( (c) => !('sandtrout' in c) && !('worm' in c && c.suppressed), ); let doubleNext = false; let card = injected ?? drawSpice(g); while (card && !('territory' in card)) { if ('sandtrout' in card) { g.sandtrout = true; for (const p of g.players) p.ally = null; for (const [donor, credit] of Object.entries(g.aid)) getPlayer(g, donor).spice += credit.amount; g.aid = {}; g.allianceOffers = {}; log( g, 'Sandtrout broke all alliances and waits for the next Shai-Hulud.', ); doubleNext = false; card = drawSpice(g); continue; } doubleNext = false; if (g.turn === 1) { if (!card.thumper) sequence.skipped.push(card); } else if (g.sandtrout) { g.sandtrout = false; g.spiceDiscard[sequence.pile].push( { sandtrout: true }, { ...card, suppressed: true }, ); doubleNext = true; log( g, 'Sandtrout suppressed Shai-Hulud. Only an immediate territory replacement receives double spice.', ); } else { g.nexus = true; g.spiceDiscard[sequence.pile].push(card); if (previous && 'worm' in previous && g.advanced) { const fremen = byFaction(g, 'fremen'); if (fremen && g.wormPlacementCanceledTurn !== g.turn) { g.decision = { kind: 'wormPlacement', player: fremen.id }; return; } } if (previous && 'territory' in previous) { beginWorm(g, previous.territory); return; } previous = card; } card = drawSpice(g); } if (card && 'territory' in card) { g.spiceDiscard[sequence.pile].push(card); const amount = card.amount * (doubleNext ? 2 : 1); g.spiceWindow = { ...card, amount, harvested: false }; if (card.sector !== g.storm) { const k = location(card.territory, card.sector); g.spice[k] = (g.spice[k] ?? 0) + amount; log(g, `${amount} spice appeared in ${territory(card.territory).name}.`); } else log(g, 'The spice blow was lost to the storm.'); } g.spiceResolution = null; g.ready = []; if (g.spiceWindow) return; finishSpiceWindow(g); } function finishSpiceWindow(g: Game) { g.spiceWindow = null; if (g.nexus) { g.ready = []; log(g, 'Nexus: alliances may be formed or broken.'); } else finishSpicePass(g); } function finishSpicePass(g: Game) { if (g.advanced && g.spiceSequence?.pile === 0) { g.spiceSequence.pile = 1; g.spiceResolution = { skipped: [] }; g.ready = []; continueSpice(g); } else { if (g.spiceSequence?.skipped.length) g.spiceDeck = shuffle([...g.spiceDeck, ...g.spiceSequence.skipped]); g.spiceSequence = null; nextPhase(g); } } function nextWormRide(g: Game) { if (g.summonedNexusBeforeRides) { g.summonedNexusBeforeRides = false; g.nexus = true; g.ready = []; return; } const fremen = byFaction(g, 'fremen'); while (g.wormRides.length) { const t = g.wormRides.shift()!; if ( fremen && Object.entries(fremen.forces).some( ([key, count]) => count > 0 && splitLocation(key).territory === t && splitLocation(key).sector !== g.storm, ) ) { g.decision = { kind: 'wormRide', player: fremen.id, territory: t }; return; } } g.nexus = false; finishSpicePass(g); } function auctionEligible(g: Game) { return g.order.filter( (id) => getPlayer(g, id).hand.length < handLimit(getPlayer(g, id)), ); } function richeseDecision( g: Game, kind: | 'richeseBlackMarket' | 'richeseDeclaration' | 'richeseCache' | 'richeseUnbid', ) { const round = g.richeseBidding!; round.event = crypto.randomUUID(); g.decision = { kind, player: round.owner }; g.active = round.owner; } function beginRicheseBidding(g: Game) { const owner = byFaction(g, 'richese')!; requireRule( g.richeseCache, 'This saved Richese game needs its verified cache inventory.', ); g.richeseBidding = { owner: owner.id, event: '', turn: g.turn, stage: g.advanced && owner.hand.length ? 'blackMarketOffer' : 'declaration', position: null, normalCount: null, blackMarketSold: false, cacheCanceled: false, }; g.auction = null; g.richeseAuction = null; g.richeseFunding = {}; if (g.richeseBidding.stage === 'blackMarketOffer') richeseDecision(g, 'richeseBlackMarket'); else richeseDeclaration(g); } function richeseDeclaration(g: Game) { richeseDeclarationCache(g); const round = g.richeseBidding!; round.stage = 'declaration'; round.normalCount = Math.max( 0, auctionEligible(g).length - 1 - Number(round.blackMarketSold), ); richeseDecision(g, 'richeseDeclaration'); } function prepareRicheseNormal(g: Game) { g.richeseBidding!.stage = 'normal'; setAuction(g); } function offerRicheseCache(g: Game) { g.richeseBidding!.stage = 'cacheOffer'; richeseDecision(g, 'richeseCache'); } function finishNormalBidding(g: Game) { const round = g.richeseBidding; if (round?.turn === g.turn && round.stage === 'normal') { if (round.position === 'last' && !round.cacheCanceled) { offerRicheseCache(g); return; } round.stage = 'complete'; } nextPhase(g); } function richeseLotCard(g: Game): Card | undefined { const lot = g.richeseAuction; if (!lot) return undefined; const cards = lot.source === 'cache' ? g.richeseCache : getPlayer(g, lot.owner).hand; return cards?.find((card) => card.id === lot.cardId); } function richeseDeclarationCache(g: Game) { try { requireRicheseDeclarationCache(g.richeseCache?.length); } catch (error) { if (error instanceof RicheseSettlementError) throw new RuleError(error.message); throw error; } } function richeseSettlementQuote( g: Game, lot = g.richeseAuction!, spendingOwner?: string, ) { const owner = getPlayer(g, lot.owner); const winner = lot.outcome?.kind === 'sold' ? getPlayer(g, lot.outcome.winner) : null; const seat = (p: Player) => ({ id: p.id, handCount: p.hand.length - Number(p.id === spendingOwner), handLimit: handLimit(p), }); try { return quoteRicheseSettlement({ lot, owner: seat(owner), winner: winner ? { ...seat(winner), spice: winner.spice } : null, card: richeseLotCard(g), funding: winner ? g.richeseFunding?.[winner.id] : null, allyCredit: winner ? aidFor(g, winner) : null, }); } catch (error) { if (error instanceof RicheseSettlementError) throw new RuleError(error.message); throw error; } } function finishRicheseLot(g: Game) { const lot = g.richeseAuction!; const round = g.richeseBidding!; if (lot.source === 'blackMarket') { richeseDeclarationCache(g); round.blackMarketSold = lot.outcome?.kind === 'sold'; if (lot.method === 'normal') { const opener = lot.order[0]; round.opener = (g.order.indexOf(opener) + 1) % g.order.length; } } g.richeseAuction = null; g.richeseFunding = {}; g.richesePeekKnown = false; g.richeseClaim = null; g.richeseOfferedCard = null; g.currentAuctionSale = null; if (lot.source === 'blackMarket') richeseDeclaration(g); else if (round.position === 'first') prepareRicheseNormal(g); else { round.stage = 'complete'; nextPhase(g); } } function settleRicheseLot(g: Game) { const lot = g.richeseAuction!; const quote = richeseSettlementQuote(g); if (quote.kind === 'open') return; const owner = getPlayer(g, lot.owner); if (quote.kind !== 'sold') { g.richeseFunding = {}; if (quote.kind === 'retainBlackMarket') { log( g, `${owner.name} retained the unbid Black Market card. No sale occurred, so it does not reduce the normal auction pool.`, ); finishRicheseLot(g); } else if (quote.kind === 'removeCache') { const card = quote.card; g.richeseCache = g.richeseCache!.filter((c) => c.id !== card.id); (g.richeseRemoved ??= []).push(card); log( g, `${owner.name} removed ${card.name} from the game after no spice was bid; a full hand prevents keeping it.`, { faction: 'richese', name: 'Unbid cache card' }, ); finishRicheseLot(g); } else richeseDecision(g, 'richeseUnbid'); return; } const winner = getPlayer(g, quote.winner), card = quote.card; payWithAlly(g, winner, quote.amount, quote.allyPayment); g.richeseFunding = {}; if (lot.source === 'cache') g.richeseCache = g.richeseCache!.filter((c) => c.id !== card.id); else owner.hand = owner.hand.filter((c) => c.id !== card.id); winner.hand.push(card); g.currentAuctionSale = { winner: winner.id, amount: quote.amount, free: false, origin: lot.source, seller: owner.id, }; log( g, `${winner.name} bought ${lot.source === 'cache' ? card.name : 'the concealed Black Market card'} for ${quote.amount} spice. The funded bid was paid automatically.`, { faction: winner.faction, name: 'Auction payment' }, ); const ixians = byFaction(g, 'ixians'); if (lot.source === 'blackMarket' && ixians && winner.ally === ixians.id) { g.pendingIxAlly = { player: winner.id, card: card.id, free: false }; g.decision = { kind: 'ixAllyCard', player: winner.id }; } else continueAuctionSale(g, false); } function richeseOfferBlock(g: Game): string | null { const ixians = byFaction(g, 'ixians'); return g.advanced && ixians?.hand.length && g.ixTechnologyTurn !== g.turn ? 'Ixian Technology on Richese lots is awaiting a ruling on replacement-card custody.' : null; } function beginRicheseLot( g: Game, source: 'cache' | 'blackMarket', action: Action, ) { const round = g.richeseBidding!, owner = getPlayer(g, round.owner); const card = (source === 'cache' ? g.richeseCache : owner.hand)?.find( (c) => c.id === action.card, ); requireRule(card, 'Choose an available card from the correct collection.'); requireRule( typeof action.method === 'string' && ['normal', 'onceAround', 'silent'].includes(action.method), 'Choose an auction method.', ); requireRule( source !== 'cache' || action.method !== 'normal', 'A Richese cache lot uses Once Around or Silent.', ); requireRule( action.direction === undefined || (typeof action.direction === 'string' && ['clockwise', 'counterclockwise'].includes(action.direction)), 'Choose a physical bidding direction.', ); requireRule( action.method !== 'onceAround' || action.direction !== undefined, 'Choose a physical bidding direction.', ); const blocked = richeseOfferBlock(g); requireRule(!blocked, blocked ?? 'This Richese lot is unavailable.'); const positions = normalizedPlayerPositions(g); let order = [...g.order]; if (action.method === 'onceAround') { order.sort((a, b) => positions[a] - positions[b]); if (action.direction === 'clockwise') order.reverse(); const at = order.indexOf(owner.id); order = [...order.slice(at + 1), ...order.slice(0, at + 1)]; } const lot = createRicheseAuction({ event: crypto.randomUUID(), cardId: card.id, source, owner: owner.id, method: action.method as RicheseAuction['method'], eligible: auctionEligible(g), order, tieOrder: g.order, }); g.richeseAuction = lot; g.richeseOfferedCard = structuredClone(card); g.richeseFunding = {}; g.richesePeekKnown = false; g.richeseClaim = source === 'blackMarket' && typeof action.claim === 'string' ? action.claim.trim().slice(0, 300) : null; round.stage = 'lot'; g.active = lot.active; log( g, source === 'cache' ? `${owner.name} revealed ${card.name} for a ${lot.method === 'silent' ? 'Silent' : 'Once Around'} cache auction.` : `${owner.name} offered a concealed hand card on the Black Market using ${lot.method === 'normal' ? 'normal bidding' : lot.method === 'silent' ? 'Silent bidding' : 'Once Around bidding'}.${g.richeseClaim ? ` Seller’s unverified claim: “${g.richeseClaim}”` : ''}`, ); if (source === 'blackMarket') g.response = { kind: 'richeseBlackMarket', owner: owner.id, passed: [] }; else settleRicheseLot(g); } function decideRichese(g: Game, p: Player, decision: Decision, action: Action) { const round = g.richeseBidding; requireRule( g.phase === 3 && round && round.owner === p.id && action.event === round.event, 'This Richese bidding decision has expired.', ); if (decision.kind === 'richeseBlackMarket') { requireRule( round.stage === 'blackMarketOffer' && g.advanced, 'Black Market is not available now.', ); if (action.decline === true) richeseDeclaration(g); else beginRicheseLot(g, 'blackMarket', action); } else if (decision.kind === 'richeseDeclaration') { requireRule( round.stage === 'declaration' && typeof action.position === 'string' && ['first', 'last'].includes(action.position), 'Announce whether the cache auction will be first or last.', ); round.position = action.position as 'first' | 'last'; log( g, `${p.name} announced the Richese cache auction ${round.position}, before normal pool preparation. ${round.normalCount} normal lots are scheduled after the cache reduction${round.blackMarketSold ? ' and completed Black Market sale' : ''}.`, ); g.response = { kind: 'richeseAuction', owner: p.id, passed: [] }; } else if (decision.kind === 'richeseCache') { requireRule( round.stage === 'cacheOffer', 'The cache offer is not available now.', ); beginRicheseLot(g, 'cache', action); } else if (decision.kind === 'richeseUnbid') { requireRule( g.richeseAuction?.source === 'cache' && g.richeseAuction.outcome?.kind === 'unbid', 'There is no unbid cache card to resolve.', ); requireRule( typeof action.keep === 'boolean', 'Keep the card or remove it from the game.', ); const card = richeseLotCard(g)!; requireRule( !action.keep || p.hand.length < handLimit(p), 'There is no room in your hand.', ); g.richeseCache = g.richeseCache!.filter((c) => c.id !== card.id); if (action.keep) p.hand.push(card); else (g.richeseRemoved ??= []).push(card); log( g, `${p.name} ${action.keep ? 'kept' : 'permanently removed'} ${card.name} after all bids were zero. No purchase bonus applies.`, ); finishRicheseLot(g); } } function setAuction(g: Game, cards?: Card[]) { const richese = byFaction(g, 'richese'); if (!cards && richese && g.richeseBidding?.turn !== g.turn) { beginRicheseBidding(g); return; } const eligible = g.order.filter((id) => { const p = getPlayer(g, id); return p.hand.length < handLimit(p); }); if (!eligible.length) { if (cards?.length) g.deck = [...cards, ...g.deck]; g.auction = null; finishNormalBidding(g); return; } const count = g.richeseBidding?.turn === g.turn ? (g.richeseBidding.normalCount ?? eligible.length) : eligible.length; const ixians = byFaction(g, 'ixians'); if (!cards && ixians && eligible.length && count) { g.auction = null; g.active = null; g.ixAuction = { count, cards: [] }; g.ixAuctionKnown = null; g.response = { kind: 'ixAuction', owner: ixians.id, passed: [] }; return; } if (!cards) { cards = []; for (let i = 0; i < count; i++) { const c = draw(g); if (c) cards.push(c); } } if (!eligible.length || !cards.length) { g.auction = null; finishNormalBidding(g); return; } const start = g.richeseBidding?.opener ?? 0; const opener = [...g.order.slice(start), ...g.order.slice(0, start)].find( (id) => eligible.includes(id), )!; g.auction = { cards, index: 0, bid: 0, bidder: null, active: opener, passed: [], opener: g.order.indexOf(opener), }; g.active = opener; offerAuctionTechnology(g); } function offerAuctionTechnology(g: Game) { g.auction!.peekKnown = false; const ixians = byFaction(g, 'ixians'); if ( g.advanced && ixians && ixians.hand.length && g.ixTechnologyTurn !== g.turn ) g.decision = { kind: 'ixTechnology', player: ixians.id }; else offerAuctionPeek(g); } function auctionTechnologyQuote(run: () => T): T { try { return run(); } catch (error) { if (error instanceof IxTechnologyCancellationError) throw new RuleError(error.message); throw error; } } function offerAuctionPeek(g: Game) { const quote = auctionTechnologyQuote(() => quoteNormalAuctionPeek(g)); g.auction!.peekKnown = quote.peekKnown; g.response = quote.response; } function aidFor(g: Game, p: Player) { return p.ally && g.aid[p.ally]?.recipient === p.id ? g.aid[p.ally] : undefined; } function battleAidFor(g: Game, p: Player) { const donor = p.ally ? g.players.find((other) => other.id === p.ally) : undefined; return g.advanced && g.phase === 6 && donor?.faction === 'choam' && donor.ally === p.id && !g.battle?.choamAidBlocked ? aidFor(g, p) : undefined; } function strongholdEffect(g: Game, player: string): StrongholdId | null { return g.advanced && g.battle ? strongholdBenefit( g.strongholdCards, player, g.battle.territory, g.battle.strongholdCopy, ) : null; } function battleSupportCost(g: Game, p: Player, support: number) { return strongholdSupportCost(strongholdEffect(g, p.id), support); } function battleSupportBudget(g: Game, p: Player) { return ( p.spice + (battleAidFor(g, p)?.amount ?? 0) + (strongholdEffect(g, p.id) === 'arrakeen' ? 2 : 0) ); } function battleTieWinner(g: Game) { const b = g.battle!; return strongholdEffect(g, b.defender) === 'habbanya_ridge_sietch' ? b.defender : b.attacker; } function strongholdCopyChoices(g: Game, player: string): StrongholdId[] { const controllers = strongholdControllers( g.players, !!g.mobileStronghold?.location, ); return STRONGHOLD_CARDS.filter( (card) => card.id !== MOBILE_STRONGHOLD && controllers[card.id] === player, ).map((card) => card.id); } function beginBattlePowers(g: Game) { const b = g.battle!; const choam = byFaction(g, 'choam'); if ( g.advanced && choam?.ally && [b.attacker, b.defender].includes(choam.ally) && getPlayer(g, choam.ally).ally === choam.id ) g.decision = { kind: 'choamBattleFunding', player: choam.id }; else { battlePreparation(g, 'voice'); combatResponses(g); } } function beginStrongholdBattle(g: Game) { const b = g.battle!; const owner = g.strongholdCards?.owners.hidden_mobile_stronghold; if ( g.advanced && b.territory === MOBILE_STRONGHOLD && owner && [b.attacker, b.defender].includes(owner) ) { const choices = strongholdCopyChoices(g, owner); if (choices.length > 1) { g.decision = { kind: 'strongholdCopy', player: owner, event: b.event!, choices, }; return; } if (choices.length === 1) { b.strongholdCopy = choices[0]; log( g, `${getPlayer(g, owner).name}'s mobile Stronghold Card copies ${STRONGHOLD_CARDS.find((card) => card.id === choices[0])!.name}, their only other controlled stronghold.`, { faction: getPlayer(g, owner).faction, name: 'Stronghold advantage' }, ); } } beginBattlePowers(g); } function strongholdIntegrity(g: Game) { if (!g.strongholdCards) { requireRule( !g.battle?.strongholdCopy && g.decision?.kind !== 'strongholdCopy', 'A copied Stronghold advantage requires its module.', ); return; } const state = g.strongholdCards; requireRule( g.advanced && Number.isSafeInteger(state.claimedTurn) && state.claimedTurn >= 0 && state.claimedTurn <= g.turn && !!state.owners && Object.keys(state.owners).length === STRONGHOLD_CARDS.length && STRONGHOLD_CARDS.every( (card) => state.owners[card.id] === null || g.players.some((p) => p.id === state.owners[card.id]), ), 'The saved Advanced Stronghold Card ownership is invalid.', ); if (g.decision?.kind === 'strongholdCopy') { const decision = g.decision, b = g.battle; const choices = strongholdCopyChoices(g, decision.player); requireRule( g.status === 'playing' && g.phase === 6 && !!b && b.territory === MOBILE_STRONGHOLD && decision.event === b.event && state.owners.hidden_mobile_stronghold === decision.player && [b.attacker, b.defender].includes(decision.player) && !b.strongholdCopy && Object.keys(b.plans).length === 0 && Array.isArray(decision.choices) && choices.length > 1 && decision.choices.length === choices.length && new Set(decision.choices).size === choices.length && choices.every((choice) => decision.choices.includes(choice)), 'The saved Stronghold choice must match this battle and current control.', ); } if (g.battle?.strongholdCopy) { const b = g.battle, owner = state.owners.hidden_mobile_stronghold; requireRule( !!b.event && b.territory === MOBILE_STRONGHOLD && !!owner && [b.attacker, b.defender].includes(owner) && b.strongholdCopy !== MOBILE_STRONGHOLD && STRONGHOLD_CARDS.some((card) => card.id === b.strongholdCopy), 'The saved copied Stronghold advantage does not belong to this battle.', ); } } function settleStrongholdOwnership(g: Game, quoted?: StrongholdState) { if (!g.strongholdCards || g.strongholdCards.claimedTurn === g.turn) return; requireRule( g.advanced && g.phase === 8, 'Stronghold Cards settle at the end of an Advanced turn.', ); settleAdvisors(g); const before = g.strongholdCards; g.strongholdCards = quoted ?? settleStrongholdCards( before, g.turn, strongholdControllers(g.players, !!g.mobileStronghold?.location), ); for (const card of STRONGHOLD_CARDS) { const owner = g.strongholdCards.owners[card.id]; if (owner === before.owners[card.id]) continue; log( g, owner ? `${getPlayer(g, owner).name} claimed the ${card.name} Stronghold Card for the next turn.` : `${card.name} has no controlling faction; its Stronghold Card was set aside.`, owner ? { faction: getPlayer(g, owner).faction, name: 'Stronghold control' } : undefined, ); } } function pledgeAid(g: Game, p: Player, requested: unknown) { requireRule( p.ally && getPlayer(g, p.ally).ally === p.id, 'Choose a current mutual ally.', ); const current = g.aid[p.id]?.amount ?? 0; requireRule( !( g.richeseAuction?.method === 'silent' && !g.richeseAuction.outcome && Object.hasOwn(g.richeseAuction.sealed, p.ally) && typeof requested === 'number' && requested < current ), 'Ally funding cannot be reduced until the submitted Silent bids are revealed.', ); const committed = g.phase === 6 ? (g.battle?.plans[p.ally]?.allyPayment ?? 0) : g.auction?.bidder === p.ally ? (g.auction.allyPayment ?? 0) : 0; const amount = integer( requested, g.richeseAuction?.method === 'silent' && !g.richeseAuction.outcome && Object.hasOwn(g.richeseAuction.sealed, p.ally) ? current : Math.max( committed, richeseAllyCommitment(g.richeseAuction, g.richeseFunding ?? {}, p.id), ), uncommittedSpice(g, p) + current, 'Ally funding', ); p.spice += current - amount; g.aid[p.id] = { recipient: p.ally, amount }; log(g, `${p.name} updated the spice available to their ally this phase.`); } function uncommittedSpice(g: Game, p: Player) { return ( p.spice - richeseOwnCommitment(g.richeseAuction, g.richeseFunding ?? {}, p.id) - (g.auction?.bidder === p.id ? g.auction.bid - (g.auction.allyPayment ?? 0) : 0) - (battleSupportCost(g, p, g.battle?.plans[p.id]?.support ?? 0) - (g.battle?.plans[p.id]?.allyPayment ?? 0)) ); } function contribution(g: Game, p: Player, cost: number, requested?: unknown) { const available = aidFor(g, p)?.amount ?? 0; const { minimum, maximum } = shipmentPaymentBounds(cost, p.spice, available); return integer(requested ?? minimum, minimum, maximum, 'Ally payment'); } function payWithAlly(g: Game, p: Player, cost: number, allyPayment: number) { const credit = aidFor(g, p); requireRule( p.spice >= cost - allyPayment && (!allyPayment || (credit && credit.amount >= allyPayment)), 'The payment is no longer funded.', ); p.spice -= cost - allyPayment; if (credit) credit.amount -= allyPayment; } function auctionNext(g: Game) { const a = g.auction!; const eligible = g.order.filter((id) => { const p = getPlayer(g, id); return p.hand.length < handLimit(p); }); const others = eligible.filter((id) => id !== a.bidder); if (others.every((id) => a.passed.includes(id))) { if (!a.bidder) { g.deck = [...a.cards.slice(a.index), ...g.deck]; g.auction = null; finishNormalBidding(g); return; } g.decision = { kind: 'auctionPayment', player: a.bidder }; g.active = a.bidder; recoverAuctionPayment(g, getPlayer(g, a.bidder)); return; } let i = g.order.indexOf(a.active); do { i = (i + 1) % g.order.length; } while (!eligible.includes(g.order[i]) || g.order[i] === a.bidder); a.active = g.order[i]; g.active = a.active; } function settleAuction(g: Game, free = false, automatic = false) { const a = g.auction!; const winner = getPlayer(g, a.bidder!); if (!free) payWithAlly(g, winner, a.bid, a.allyPayment ?? 0); winner.hand.push(a.cards[a.index]); g.currentAuctionSale = { winner: winner.id, amount: a.bid, free, origin: 'normal', seller: null, }; log( g, `${faction(winner.faction).name} won a treachery card for ${free ? 'a Karama' : `${a.bid} spice`}.${automatic ? ' The declared spice payment was the only available payment method.' : ''}`, automatic ? { faction: winner.faction, name: 'Auction payment' } : undefined, ); const ixians = byFaction(g, 'ixians'); if (ixians && winner.ally === ixians.id) { g.pendingIxAlly = { player: winner.id, card: a.cards[a.index].id, free }; g.decision = { kind: 'ixAllyCard', player: winner.id }; } else continueAuctionSale(g, free); } function currentAuctionContinuationQuote( g: Game, operation: AuctionContinuationOperation, spending?: { player: string; card: string }, ) { try { return quoteAuctionContinuation( { status: g.status, phase: g.phase, turn: g.turn, advanced: g.advanced, order: g.order, players: g.players.map((p) => ({ id: p.id, faction: p.faction, ally: p.ally, spice: p.spice, hand: p.hand, handLimit: handLimit(p), })), auction: g.auction, sale: g.currentAuctionSale, physicalCards: physicalTreacheryCards(g), pendingIxAlly: g.pendingIxAlly, ixTechnologyTurn: g.ixTechnologyTurn, richeseAuction: g.richeseAuction, richeseBidding: g.richeseBidding, richeseCacheCount: g.richeseCache?.length, }, operation, spending, ); } catch (error) { if (error instanceof AuctionContinuationError) throw new RuleError(error.message); throw error; } } function validateAuctionContinuationCancellation( g: Game, response: ResponseWindow, spending?: { player: string; card: string }, ) { if (response.source === 'ambassador') { validateAmbassadorPurchaseResponse(g, response); return null; } if (['ixAllyCard', 'emperorIncome', 'harkonnenBonus'].includes(response.kind)) return currentAuctionContinuationQuote( g, { kind: 'cancel', response }, spending, ); return null; } function commitAuctionContinuation(g: Game, quote: AuctionContinuationQuote) { for (const step of quote.steps) { if (step.kind === 'clearIxAlly') g.pendingIxAlly = null; else { const seller = getPlayer(g, step.player); seller.spice = step.balance; log( g, `${seller.name} collected ${step.amount} spice from the ${quote.sale.origin === 'cache' ? 'Richese cache' : 'Black Market'} sale.`, { faction: 'richese', name: 'Auction income' }, ); } } const next = quote.next; if (next.kind === 'response') g.response = next.response; else if (next.kind === 'richeseEnd') finishRicheseLot(g); else { g.currentAuctionSale = null; if (next.kind === 'normalEnd') { g.deck = [...next.returned, ...g.deck]; g.auction = null; finishNormalBidding(g); } else { g.auction = next.auction; g.active = next.active; if (next.offer?.kind === 'ixTechnology') g.decision = next.offer; else if (next.offer) g.response = next.offer; } } } function continueAuctionSale(g: Game, free: boolean) { commitAuctionContinuation( g, currentAuctionContinuationQuote(g, { kind: 'sale', free }), ); } function auctionBonus(g: Game) { commitAuctionContinuation( g, currentAuctionContinuationQuote(g, { kind: 'bonus' }), ); } function nextAuction(g: Game) { commitAuctionContinuation( g, currentAuctionContinuationQuote(g, { kind: 'next' }), ); } /** Physical custody only: promise feasibility remains conservatively checked at commit. */ function karamaSpendingBlock(g: Game, p: Player, card: Card): string | null { if (g.battle?.lateDefense?.[p.id] === card.id) return 'This Portable Snooper is already played and reserved for battle cleanup.'; if (giftReserved(g, p.id, card.id)) return 'This card is reserved for the pending Richese gift.'; if ( !p.hand.some((held) => held.id === card.id) || !canUseAsKarama(g.advanced, p.faction, card) ) return 'Choose a Karama in your hand.'; if (retentionReservesCard(g, p.id, card.id)) return 'A card awaiting Moritani alliance battle cleanup cannot be spent as Karama.'; const plan = g.battle?.plans[p.id]; if (plan && [plan.weapon, plan.defense, plan.leader].includes(card.id)) return 'A sealed battle card cannot be spent as Karama.'; const insight = g.battle?.prescience; if (insight && insight.player !== p.id && insight.value === card.id) return 'The card committed to prescience must remain available.'; if (card.kind === 'worthless' && g.pendingKarama) return 'A Worthless conversion is already pending.'; return null; } function karamaCard(g: Game, p: Player, id?: unknown) { // Explicit selections retain the specific custody error from spendKarama. const available = p.hand.filter( (c) => canUseAsKarama(g.advanced, p.faction, c) && (id === undefined ? !karamaSpendingBlock(g, p, c) : c.id === id), ); return available.find((c) => c.effect === 'karama') ?? available[0]; } function responseCancelCards( g: Game, p: Player, response: ResponseWindow, ): string[] { if (p.id === response.owner) return []; // Do not simulate cancellation here: it can execute RNG and continuations. // A physically spendable card whose Truthtrance feasibility is not proven // remains a possible choice; the authoritative action still checks promises. return p.hand .filter((card) => !karamaSpendingBlock(g, p, card)) .map((card) => card.id); } /** The same conversion can be suspended by an independent private interaction. */ function savedKaramaContexts(g: Game) { const continuation = g.pendingTreacheryDiscard?.continuation; return [ { pendingKarama: g.pendingKarama, response: g.pendingExchange?.response ?? g.response, }, g.pendingRicheseGift?.resume, g.pendingNullentropy?.resume, g.pendingRichesePurchaseIncome?.resume, g.summonedWorm?.resume, continuation && 'resume' in continuation ? continuation.resume : null, ].flatMap((context) => context?.pendingKarama ? [ { pending: context.pendingKarama, response: context.response ?? g.pendingExchange?.response, }, ] : [], ); } function savedKaramaConversions(g: Game) { return savedKaramaContexts(g).map((context) => context.pending); } function karamaCanceledSource(g: Game, response: ResponseWindow) { try { return canceledResponseSource(g, response); } catch (error) { if (error instanceof KaramaContextError) throw new RuleError(error.message); throw error; } } /** Locate a conversion behind the controls saved before a summoned worm. * Only explicit parent links count; unrelated live overlays are not parents. */ function controlsContainKarama( g: Game, controls: | Pick | null | undefined, pending: NonNullable, seen = new Set(), ): boolean { if (!controls || seen.has(controls)) return false; seen.add(controls); if (controls.pendingKarama === pending) return true; const parent = controls.response?.kind === 'richeseGift' ? g.pendingRicheseGift?.resume : controls.response?.kind === 'richesePurchaseIncome' ? g.pendingRichesePurchaseIncome?.resume : controls.decision?.kind === 'nullentropy' ? g.pendingNullentropy?.resume : controls.decision?.kind === 'handExchange' ? g.pendingExchange : null; return controlsContainKarama(g, parent, pending, seen); } function karamaSourceGame( g: Game, pending: NonNullable, ) { const worm = g.summonedWorm; return worm && controlsContainKarama(g, worm.resume, pending) ? { ...g, ...worm.resume, summonedWorm: null } : g; } /** Bind the original opportunity, not hands, balances, response passes or seat control. */ function karamaOpportunitySignature( g: Game, pending: NonNullable, ) { g = karamaSourceGame(g, pending); const use = pending.use; requireRule( use && ['cancel', 'shipment', 'purchase', 'auctionPayment'].includes(use.kind), 'The saved Karama conversion does not match its original opportunity.', ); const opportunity = use.kind === 'cancel' ? karamaCanceledSource(g, use.response) : use.kind === 'shipment' ? { active: g.active, recipient: use.recipient, shipped: getPlayer(g, use.recipient).shipped, rate: g.karamaShipping ?? null, movementRemaining: g.movementRemaining ?? null, } : { auction: g.auction, order: g.order, richeseAuction: g.richeseAuction ?? null, sale: g.currentAuctionSale ?? null, }; return JSON.stringify({ owner: pending.owner, use, status: g.status, advanced: g.advanced, turn: g.turn, phase: g.phase, opportunity, }); } function karamaConversionIntegrity(g: Game) { for (const { pending, response } of savedKaramaContexts(g)) { if (pending.opportunity === undefined) continue; requireRule( pending.opportunity && pending.opportunity.kind === (pending.use?.kind === 'cancel' ? 'cancel' : pending.use?.kind === 'shipment' ? 'shipment' : 'auction') && typeof pending.opportunity.signature === 'string' && response?.kind === 'worthlessKarama' && response.owner === pending.owner && g.advanced && getPlayer(g, pending.owner).faction === 'beneGesserit' && pending.opportunity.signature === karamaOpportunitySignature(g, pending), 'The saved Karama conversion no longer matches its original opportunity.', ); if (pending.opportunity.kind === 'auction') normalKaramaAuction( g, getPlayer(g, pending.owner), pending.use.kind === 'auctionPayment', ); } } /** Only live ownership zones; knowledge receipts and old auction prefixes are aliases. */ function physicalTreacheryCards(g: Game) { return [ ...g.players.flatMap((player) => player.hand), ...g.deck, ...g.discard, ...(g.richeseCache ?? []), ...(g.richeseRemoved ?? []), ...(g.ixSetupCards ?? []), ...(g.ixAuction?.cards ?? []), ...(g.ornithopter ? [g.ornithopter.card] : []), ...(g.auction?.cards.slice(g.auction.index) ?? []), ]; } /** Pure normal-lot prerequisites; free payment deliberately does not require spice. */ function normalKaramaAuction( g: Game, p: Player, payment: boolean, spendingCard = false, ) { const a = g.auction; const seated = (id: string) => g.players.some((player) => player.id === id); requireRule( g.status === 'playing' && g.phase === 3 && a && !g.richeseAuction && !g.currentAuctionSale && Array.isArray(a.cards) && Number.isSafeInteger(a.index) && a.index >= 0 && a.index < a.cards.length && a.cards[a.index]?.id && Number.isSafeInteger(a.bid) && a.bid >= 0 && (a.bidder === null || seated(a.bidder)) && seated(a.active) && g.order.length > 0 && new Set(g.order).size === g.order.length && g.order.every(seated) && Number.isSafeInteger(a.opener) && a.opener >= 0 && a.opener < g.order.length, 'This Karama purchase needs its original unresolved normal auction lot.', ); requireRule( !payment || a.bidder === p.id, 'Only the original winning bidder may choose the auction payment.', ); requireRule( p.hand.length - Number(payment && spendingCard) < handLimit(p), 'Leave room for the committed Karama auction purchase.', ); const card = a.cards[a.index]; const zones = physicalTreacheryCards(g); requireRule( zones.filter((candidate) => candidate.id === card.id).length === 1, 'The reserved normal auction card has conflicting physical custody.', ); } /** Preflight known pre-effect rejection paths without executing a response or drawing. */ function revivalCancellationQuote(g: Game, response: ResponseWindow) { if ( ![ 'choamRevival', 'revivalLimit', 'revivalDiscount', 'earlyRevival', 'foreignGhola', ].includes(response.kind) ) return null; try { return quoteRevivalCancellation(g, response); } catch (error) { if (error instanceof RevivalCancellationError) throw new RuleError(error.message); throw error; } } function movementCancellationQuote(g: Game, response: ResponseWindow) { try { return quoteMovementCancellation(g, response); } catch (error) { if (error instanceof MovementCancellationError) throw new RuleError(error.message); throw error; } } function currentIxAuctionDrawQuote( g: Game, response: ResponseWindow, canceled: boolean, spendingCard?: Card, ) { try { return quoteIxAuctionDraw( { ...g, physicalCards: physicalTreacheryCards(g) }, response, canceled, spendingCard, ); } catch (error) { if (error instanceof IxAuctionDrawError) throw new RuleError(error.message); throw error; } } function ixSubstitutionCancellationQuote( g: Game, response: ResponseWindow, spending?: { player: string; card: string }, ) { if (response.kind !== 'ixSubstitution') return null; try { const quote = quoteIxSubstitutionCancellation( { ...g, territories: gameTerritories(g), physicalCards: physicalTreacheryCards(g), }, response, )!; if (!quote.decision) currentBattleAftermathQuote(g, undefined, spending); return quote; } catch (error) { if (error instanceof IxSubstitutionCancellationError) throw new RuleError(error.message); throw error; } } function choamSaleCancellationQuote(g: Game, response: ResponseWindow) { try { return quoteChoamSaleCancellation(g, response); } catch (error) { if (error instanceof ChoamSaleCancellationError) throw new RuleError(error.message); throw error; } } function noFieldCancellationQuote(g: Game, response: ResponseWindow) { if (response.kind !== 'richeseNoField') return null; try { return quoteNoFieldCancellation(g, response); } catch (error) { if (error instanceof NoFieldCancellationError) throw new RuleError(error.message); throw error; } } function terminalCancellationQuote(g: Game, response: ResponseWindow) { try { return validateTerminalCancellation(g, response); } catch (error) { if (error instanceof TerminalCancellationError) throw new RuleError(error.message); throw error; } } function disasterPreflight(run: () => void) { try { run(); } catch (error) { if (error instanceof DisasterPreflightError) throw new RuleError(error.message); throw error; } } function validateDisasterCancellation(g: Game, response: ResponseWindow) { if (response.kind === 'stormProtection') { disasterPreflight(() => validateStormCancellation(g, response)); if (response.resume === 'storm') normalizedPlayerPositions(g); } else if ( ['wormPlacement', 'wormAllyProtection', 'wormSurvival'].includes( response.kind, ) ) disasterPreflight(() => validateWormCancellation(g, response)); } function validateKaramaUse( g: Game, p: Player, use: KaramaUse, spendingCard?: Card, ) { if (use.kind === 'cancel') { requireRule( use.response && use.response.owner !== p.id, 'Karama cancels another faction’s power.', ); karamaCanceledSource(g, use.response); validateAuctionContinuationCancellation( g, use.response, spendingCard ? { player: p.id, card: spendingCard.id } : undefined, ); currentRicheseCancellationQuote(g, use.response); auctionTechnologyQuote(() => quoteIxTechnologyCancellation(g, use.response), ); if (use.response.kind === 'ixAuction') currentIxAuctionDrawQuote(g, use.response, true, spendingCard); moritaniAllianceCancellationQuote(g, use.response); if (isCombatResponseKind(use.response.kind)) currentCombatResponseQuote(g, { kind: 'response', response: { kind: use.response.kind, owner: use.response.owner }, canceled: true, }); requireRule( use.response.kind !== 'richeseAuction', 'The canceled Richese auction count is awaiting an official ruling or an explicit table interpretation.', ); if ( use.response.kind === 'richeseBlackMarket' || (use.response.kind === 'atreidesAuction' && g.richeseAuction) ) { const lot = g.richeseAuction, round = g.richeseBidding; requireRule( g.phase === 3 && lot && round && round.turn === g.turn && round.stage === 'lot' && round.owner === lot.owner && (use.response.kind === 'richeseBlackMarket' ? lot.source === 'blackMarket' && lot.owner === use.response.owner : getPlayer(g, use.response.owner).faction === 'atreides'), 'This canceled power needs its original Richese auction opportunity.', ); const target = use.response.kind === 'richeseBlackMarket' ? { ...lot, outcome: { kind: 'unbid' as const } } : lot; const quote = richeseSettlementQuote( g, target, spendingCard ? p.id : undefined, ); if (lot.source === 'blackMarket' && quote.kind !== 'open') richeseDeclarationCache(g); } validateDisasterCancellation(g, use.response); revivalCancellationQuote(g, use.response); movementCancellationQuote(g, use.response); choamSaleCancellationQuote(g, use.response); noFieldCancellationQuote(g, use.response); terminalCancellationQuote(g, use.response); ixSubstitutionCancellationQuote( g, use.response, spendingCard ? { player: p.id, card: spendingCard.id } : undefined, ); if (use.response.kind === 'harkonnenTraitor') { const b = g.battle, owner = getPlayer(g, use.response.owner); requireRule( g.phase === 6 && b?.revealed && owner.faction === 'harkonnen' && ![b.attacker, b.defender].includes(owner.id) && traitorVoters(g, b).includes(owner.id) && b.traitorCalls[owner.id] === true, 'The canceled allied traitor call needs its original revealed battle.', ); if (traitorVoters(g, b).every((id) => b.traitorCalls[id] !== undefined)) currentBattleResolutionQuote(g, owner.id); } if (use.response.kind === 'moritaniRetention') validateMoritaniCancellation(g, use.response.owner); validateAftermathCancellation( g, use.response, spendingCard ? { player: p.id, card: spendingCard.id } : undefined, ); currentChoamWorthlessCancellationQuote(g, use.response); currentPlacementCancellationQuote(g, use.response); if (use.response.kind === 'ecazCollection') currentEcazCollectionQuote(g, use.response, true); // Canceling a Worthless conversion restores its old use; it does not execute // that use. In particular, a legacy unsupported count attempt is cancelable. } else if (use.kind === 'shipment') { const recipient = getPlayer(g, use.recipient); requireRule( g.status === 'playing' && g.phase === 5 && g.active === recipient.id && !recipient.shipped && !g.karamaShipping, 'Choose the active player before their unused Karama shipment.', ); } else normalKaramaAuction(g, p, use.kind === 'auctionPayment', !!spendingCard); } /** Owner-promise proof only. A false result means this suffix remains outside * this bounded preview and must retain its existing atomic action boundary. */ function assertKaramaPromiseFeasibility( g: Game, p: Player, use: KaramaUse, costCard?: Card, ): boolean { const battlePromises = g.battle && !g.battle.revealed && !g.battle.plans[p.id] ? (g.battle.truthPromises ?? []).filter( (promise) => promise.player === p.id && !promise.released, ) : []; const shipmentPromises = liveShipmentPromises( g.shipmentPromises ?? [], p.id, g.turn, ); if (!battlePromises.length && !shipmentPromises.length) return true; const projected = structuredClone(g), owner = getPlayer(projected, p.id); if (costCard) owner.hand = owner.hand.filter((card) => card.id !== costCard.id); if (use.kind === 'shipment') { projected.karamaShipping = { player: use.recipient, owner: p.id, card: costCard?.id ?? use.card, }; } else if (use.kind === 'cancel') { const b = projected.battle; switch (use.response.kind) { case 'voice': if (!b) return false; delete b.voice; break; case 'prescience': if (!b) return false; delete b.prescience; delete b.preparation; break; case 'eliteStrength': if (!b) return false; (b.eliteBlocked ??= []).push(use.response.owner); break; case 'fremenSupport': if (!b) return false; b.fremenSupportBlocked = true; break; case 'kwisatz': if (!b) return false; b.kwisatzBlocked = true; break; case 'choamBattleAid': if (!b) return false; b.choamAidBlocked = true; break; case 'emperorGift': // Cancellation transfers no spice and changes no owner's plan inputs. break; default: return false; } } else return false; // The intended result is after this response, not a replay of its suffix. projected.response = null; projected.pendingKarama = null; if (battlePromises.length) requireRule( !!findReachableBattlePlan(projected, owner, { promises: battlePromises }), 'You cannot voluntarily make your Truthtrance battle promise impossible.', ); if (shipmentPromises.length) requireRule( !!findShipmentCompletion(projected, owner, shipmentPromises), 'You cannot voluntarily make your Truthtrance shipment answer impossible.', ); return true; } function spendKarama(g: Game, p: Player, card: Card, use: KaramaUse) { const blocked = karamaSpendingBlock(g, p, card); requireRule(!blocked, blocked ?? 'This card cannot be spent as Karama.'); validateKaramaUse(g, p, use, card); assertKaramaPromiseFeasibility(g, p, use, card); discard(g, p, card.id); if (use.kind === 'shipment') use = { ...use, card: card.id }; if (card.kind === 'worthless') { requireRule(!g.pendingKarama, 'A Worthless conversion is already pending.'); g.pendingKarama = { owner: p.id, use }; g.pendingKarama.opportunity = { kind: use.kind === 'cancel' ? 'cancel' : use.kind === 'shipment' ? 'shipment' : 'auction', signature: karamaOpportunitySignature(g, g.pendingKarama), }; g.response = { kind: 'worthlessKarama', owner: p.id, passed: [], intent: use.kind === 'cancel' ? `Cancel a power used by ${getPlayer(g, use.response.owner).name}.` : use.kind === 'shipment' ? `Give ${getPlayer(g, use.recipient).name} Guild shipment rates paid to the bank.` : use.kind === 'purchase' ? 'Take the current auction card without paying spice.' : 'Pay the winning auction bid without spending spice.', }; log( g, `${p.name} used ${card.name} as Karama; the faction power awaits responses.`, ); } else completeKarama(g, p, use); } function completeKarama(g: Game, p: Player, use: KaramaUse) { validateKaramaUse(g, p, use); if (use.kind === 'cancel') { g.response = use.response; finishResponse(g, true); log(g, `${p.name} used Karama to cancel ${use.response.kind}.`); } else if (use.kind === 'shipment') { g.karamaShipping = { player: use.recipient, owner: p.id, card: use.card }; log( g, `${p.name} used Karama for ${getPlayer(g, use.recipient).name}’s shipment at Guild rates, paid to the bank.`, ); } else { if (use.kind === 'purchase') g.auction!.bidder = p.id; settleAuction(g, true); } } function auctionSpicePaymentFunded(g: Game, p: Player) { const a = g.auction; if (!a || a.bidder !== p.id) return false; const allyPayment = a.allyPayment ?? 0; return ( Number.isInteger(a.bid) && a.bid >= 0 && Number.isInteger(allyPayment) && allyPayment >= 0 && allyPayment <= a.bid && p.spice >= a.bid - allyPayment && (!allyPayment || (aidFor(g, p)?.amount ?? 0) >= allyPayment) ); } function recordFullPlanInspection(g: Game, owner: string) { const p = getPlayer(g, owner); log( g, `${p.name} can now inspect the committed battle plan privately. The plan remains available until both plans are revealed.`, { faction: p.faction, name: 'Full plan inspection' }, ); } function finishAutomaticDecision(g: Game): boolean { const decision = g.decision; const market = g.choamMarket; if ( decision?.kind === 'ecazAmbassador' && g.pendingAmbassador?.stage === 'ship' && g.pendingAmbassador.effect === 'guild' && decision.player === g.pendingAmbassador.beneficiary && !g.response && !g.pendingKarama && !g.pendingRicheseGift && !g.pendingExchange && !g.summonedWorm ) { const { beneficiary } = currentGuildAmbassador(g); const choices = guildAmbassadorRule(() => guildAmbassadorShipments(g, beneficiary.id), ); if (!choices.maximum || !choices.destinations.length) { g.decision = null; log( g, `${beneficiary.name} has no available physical Guild Ambassador shipment. The committed trigger finishes automatically.`, ); finishAmbassador(g); return true; } } if ( decision?.kind === 'ecazAmbassador' && g.pendingAmbassador?.stage === 'move' && g.pendingAmbassador.effect === 'fremen' && decision.player === g.pendingAmbassador.beneficiary && !g.response && !g.pendingKarama && !g.pendingRicheseGift && !g.pendingExchange && !g.summonedWorm && !fremenAmbassadorMovement(g, decision.player).sources.length ) { currentFremenAmbassador(g); g.decision = null; offerAmbassadorRelocation(g); return true; } if ( decision?.kind === 'choamMarket' && market?.owner === decision.player && g.status === 'playing' && g.phase === 3 && !g.response && !g.truthtrance && !g.phaseOpening && !g.pendingNullentropy && !g.pendingRicheseGift && !g.pendingKarama && !market.sale && !market.trade && getPlayer(g, market.owner).hand.length === 0 && g.players.every( (p) => p.id === market.owner || (p.hand.length < 2 && !( p.faction === 'richese' && p.ally === market.owner && getPlayer(g, market.owner).ally === p.id && p.hand.length > 0 )), ) ) { // Hand counts are public only in Bidding. Nonempty hands may prepare sales, while another // two-card hand could use Distrans or a Richese ally could give one card. log( g, `${getPlayer(g, market.owner).name} has no cards and no incoming card exchange is available. The end-of-phase market finishes automatically.`, { faction: 'choam', name: 'Market complete' }, ); finishChoamMarket(g); return true; } if (decision?.kind === 'choamAudit' && currentAuditCount(g) === 0) { finishAuditor(g, 'empty'); return true; } if ( decision?.kind === 'choamAuditPayment' && (currentAuditCount(g) === 0 || getPlayer(g, decision.player).spice < currentAuditCount(g)) ) { finishAuditor(g, 'inspect'); return true; } const lot = g.richeseAuction; const richese = byFaction(g, 'richese'); if ( !decision && richese && !noFieldAllyOfferBlock(g, richese) && !( g.richeseAllyOpportunity?.turn === g.turn && g.richeseAllyOpportunity.recipient === richese.ally ) ) { g.richeseAllyOpportunity = { turn: g.turn, recipient: richese.ally! }; g.decision = { kind: 'richeseAllyOpportunity', player: richese.id, recipient: richese.ally!, }; log( g, `${richese.name} may offer a No-Field before their ally’s shipment. The ally’s turn is held for this choice.`, ); return true; } if (g.phase === 3 && !decision && lot && !lot.outcome) { const full = lot.method === 'silent' ? lot.eligible.find( (id) => !Object.hasOwn(lot.sealed, id) && getPlayer(g, id).hand.length >= handLimit(getPlayer(g, id)), ) : lot.active && getPlayer(g, lot.active).hand.length >= handLimit(getPlayer(g, lot.active)) ? lot.active : null; if (full) { g.richeseAuction = submitRicheseBid( lot, { event: lot.event, actor: full, amount: lot.method === 'silent' ? 0 : null, }, 0, ); if (lot.method === 'silent') (g.richeseFunding ??= {})[full] = { amount: 0, allyPayment: 0, donor: null, }; g.active = g.richeseAuction.active; const player = getPlayer(g, full); log( g, `${player.name} automatically ${lot.method === 'silent' ? 'submitted zero' : 'passed'} because their hand became full before bidding.`, { faction: player.faction, name: 'Full hand: no bid' }, ); if (lot.method === 'silent' && g.richeseAuction.outcome) log( g, `Silent bids revealed together: ${g.richeseAuction.tieOrder.map((id) => `${getPlayer(g, id).name} ${g.richeseAuction!.sealed[id]}`).join('; ')}. Ties are resolved in storm order.`, ); settleRicheseLot(g); return true; } } if ( g.phase === 3 && decision?.kind === 'richeseUnbid' && lot?.source === 'cache' && lot.outcome?.kind === 'unbid' && getPlayer(g, lot.owner).hand.length >= handLimit(getPlayer(g, lot.owner)) ) { g.decision = null; settleRicheseLot(g); return true; } if (decision?.kind === 'fullPlanRead') { const b = g.battle; requireRule( b && !b.revealed && b.fullPlan?.owner === decision.player && b.fullPlan.target === decision.target && b.plans[decision.target], 'The saved inspection does not match a committed battle plan.', ); g.decision = null; recordFullPlanInspection(g, decision.player); return true; } if ( decision?.kind === 'auctionPayment' && g.status === 'playing' && g.phase === 3 ) { const p = getPlayer(g, decision.player); // The ally split was committed with the bid. Never silently select a new // split or consume a card on behalf of a player, including a BG conversion. if ( auctionSpicePaymentFunded(g, p) && !p.hand.some((card) => !karamaSpendingBlock(g, p, card)) ) { g.decision = null; settleAuction(g, false, true); return true; } } return false; } function recoverAuctionPayment(g: Game, p: Player) { const a = g.auction!; if (a.bid <= p.spice + (aidFor(g, p)?.amount ?? 0) || karamaCard(g, p)) { g.decision = { kind: 'auctionPayment', player: p.id }; return; } // Provisional recovery for an unfunded canceled payment; primary-source // confirmation is still required before advanced tables are enabled. g.decision = null; a.bid = 0; a.bidder = null; a.allyPayment = 0; a.passed = []; while ( getPlayer(g, g.order[a.opener]).hand.length >= handLimit(getPlayer(g, g.order[a.opener])) ) a.opener = (a.opener + 1) % g.order.length; a.active = g.order[a.opener]; g.active = a.active; log( g, 'The winning bid is no longer funded. Bidding restarts for the same unrevealed card.', ); } function occupancyRule(quote: () => T): T { try { return quote(); } catch (error) { throw new RuleError( error instanceof Error ? error.message : 'Invalid territory occupancy.', ); } } function sharesEcazOccupation(g: Game, p: Player, to: string) { return ( !!p.ally && occupancyRule( () => ecazOccupancyRelation(g.players, p.id, p.ally!, { kind: 'territory', id: to, }) === 'ecazAlliance', ) ); } function allowedEntry( g: Game, p: Player, t: string, s: number, stormShipment = false, advisors = false, ) { requireRule( t !== MOBILE_STRONGHOLD || g.mobileStronghold?.location, 'The mobile stronghold has not been placed.', ); requireRule( validLocation(t, s), 'Choose a sector belonging to this territory.', ); requireRule( s === 0 || s !== g.storm || stormShipment, 'That sector is in storm.', ); const blocked = occupancyRule(() => territoryEntryBlock(g.players, p.id, t, advisors), ); requireRule(!blocked, blocked ?? 'This territory cannot be entered.'); } function eliteChoice( total: number, available: number, eliteAvailable: number, value: unknown, ) { const minimum = Math.max(0, total - (available - eliteAvailable)); return integer( value ?? minimum, minimum, Math.min(total, eliteAvailable), 'Elite forces', ); } function forceGroup(p: Player, action: Action) { let entries: [string, unknown][]; if (action.forces !== undefined) { requireRule( action.forces && typeof action.forces === 'object' && !Array.isArray(action.forces), 'Choose a force group from one territory.', ); entries = Object.entries(action.forces); } else entries = [[stringField(action.from), action.amount]]; const group: [string, number][] = []; const eliteGroup: Record = {}; let origin: string | undefined; let noField: { tokenId: string; event: string; from: string } | undefined; if (action.noField !== undefined) { const marker = p.noField?.deployed; requireRule( p.faction === 'richese' && marker && marker.tokenId === action.noField && p.noFieldEvent === action.event, 'This concealed No-Field movement selection is stale.', ); origin = marker.location.territory; noField = { tokenId: marker.tokenId, event: p.noFieldEvent!, from: location(marker.location.territory, marker.location.sector), }; } for (const [key, value] of entries) { const src = splitLocation(key); requireRule( validLocation(src.territory, src.sector), 'Invalid source sector.', ); const n = integer(value, 0, p.forces[key] ?? 0, 'Forces'); if (!n) continue; requireRule( !origin || origin === src.territory, 'A force group must come from one territory.', ); origin = src.territory; group.push([key, n]); const selectedElite = action.forces !== undefined ? (action.eliteForces as Record | undefined)?.[key] : action.elite; eliteGroup[key] = eliteChoice( n, p.forces[key], p.elites?.forces[key] ?? 0, selectedElite, ); } requireRule( group.length > 0 || noField, 'Choose at least one force or the deployed No-Field.', ); return { group, eliteGroup, elite: Object.values(eliteGroup).reduce((a, b) => a + b, 0), origin: origin!, total: group.reduce((n, [, count]) => n + count, 0) + (noField ? 1 : 0), noField, sourceKeys: [ ...group.map(([key]) => key), ...(noField ? [noField.from] : []), ], }; } function movesAllowed(g: Game, p: Player) { const flight = g.ornithopter; return flight?.player === p.id && flight.turn === g.turn ? flight.startingMove + (flight.mode === 'twoGroups' ? 2 : 1) : g.hajr.includes(p.id) ? 2 : 1; } function ornithopterBlock( g: Game, p: Player, mode: OrnithopterMode, ): string | null { if (g.status !== 'playing' || g.phase !== 5 || g.active !== p.id) return 'Use Ornithopter during your own Shipment and Movement turn.'; if (g.truthtrance || g.response || g.decision || g.phaseOpening) return 'Resolve the current interaction before declaring a movement.'; if (g.ornithopter) return 'Finish the already played Ornithopter movement first.'; if (p.moved || g.hajr.includes(p.id)) return 'Ornithopter combined with prior moves or Hajr awaits its timing ruling.'; if ( mode === 'range3' && p.faction === 'choam' && g.choamMovement?.turn === g.turn && g.choamMovement.bonus ) return 'Kulon combined with the fixed three-territory card range awaits a ruling.'; return null; } function ornithopterIntegrity(g: Game) { const flight = g.ornithopter; if (!flight) return; const owner = getPlayer(g, flight.player); requireRule( g.status === 'playing' && g.phase === 5 && g.active === owner.id && flight.turn === g.turn && ['range3', 'twoGroups'].includes(flight.mode) && Number.isSafeInteger(flight.completed) && flight.completed >= 0 && flight.completed < (flight.mode === 'twoGroups' ? 2 : 1) && owner.moved === flight.startingMove + flight.completed && richeseCardDefinition(flight.card)?.card.effect === 'ornithopter' && ![ ...g.players.flatMap((p) => p.hand), ...g.deck, ...g.discard, ...(g.richeseCache ?? []), ].some((c) => c.id === flight.card.id), 'The played Ornithopter custody or movement event is inconsistent; restore the saved movement before continuing.', ); if (flight.mode === 'twoGroups' && flight.completed === 1) { requireRule( flight.cohort, 'The original unmoved Ornithopter cohort is missing; restore the saved movement before continuing.', ); noFieldRule(() => validateCohortSelection( flight.cohort!, owner.forces, owner.elites?.forces ?? {}, {}, {}, ), ); } } function finishOrnithopter( g: Game, source: 'move' | 'end', movement: CompletedMovement | null = null, ) { const flight = g.ornithopter; if (!flight) return; requireRule( !g.pendingNullentropy, 'Finish the paid search before completing the movement card.', ); g.discard.push(flight.card); g.ornithopter = null; log( g, `${getPlayer(g, flight.player).name} finished Ornithopter after ${flight.completed} group move${flight.completed === 1 ? '' : 's'} and discarded the played card.`, { faction: getPlayer(g, flight.player).faction, name: 'Ornithopter' }, ); // completeMove validated arrival with a pure quote before moving any forces. // The completed discard resumes that arrival without speculative effect replay. const resume = { response: g.response, decision: g.decision, pendingKarama: g.pendingKarama, phaseOpening: g.phaseOpening, }; g.response = null; g.decision = null; g.pendingKarama = null; g.phaseOpening = null; const continuation: Extract< NonNullable['continuation'], { kind: 'ornithopterDiscard' } > = { kind: 'ornithopterDiscard', source, flight, movement, resume, stateSignature: '', }; continuation.stateSignature = ornithopterDiscardSignature(g, continuation); stageTreacheryDiscard( g, `ornithopter:${source}`, [{ card: flight.card, discardedBy: flight.player, publicFace: true }], continuation, ); } function ornithopterView(g: Game, p: Player) { const flight = g.ornithopter?.player === p.id ? g.ornithopter : null; const card = flight?.card ?? p.hand.find((c) => richeseCardDefinition(c)?.card.effect === 'ornithopter'); if (!card) return null; const modes = (['range3', 'twoGroups'] as const).map((mode) => ({ mode, blocked: ornithopterBlock(g, p, mode) ?? transferCardBlock(g, p, card), })); return { card, blocked: flight ? null : modes.every((m) => m.blocked) ? modes[0].blocked : null, modes, active: flight ? { event: flight.event, mode: flight.mode, completed: flight.completed, remaining: (flight.mode === 'twoGroups' ? 2 : 1) - flight.completed, cohort: flight.cohort, } : null, }; } function movementOrderRange(g: Game, p: Player, move: MovementOrder) { return move.ornithopterRange ? 3 : movementRange(g, p, move.elite); } function validateFlightSelection(g: Game, p: Player, move: MovementOrder) { const flight = g.ornithopter; requireRule( !flight || flight.player !== p.id || move.ornithopterEvent === flight.event, 'Use the current Ornithopter movement event.', ); if (!move.ornithopterEvent) return; requireRule( flight?.player === p.id && flight.event === move.ornithopterEvent && move.ornithopterRange === (flight.mode === 'range3'), 'This Ornithopter movement event is stale.', ); if (flight.completed && flight.cohort) noFieldRule(() => validateCohortSelection( flight.cohort!, p.forces, p.elites?.forces ?? {}, Object.fromEntries(move.group), move.eliteGroup, move.noField, ), ); } function movementRange(g: Game, p: Player, elite: number) { const base = fighterCount(p, 'arrakeen') || fighterCount(p, 'carthag') ? 3 : (p.faction === 'fremen' && !( p.fremenMovementBlocked?.turn === g.turn && p.fremenMovementBlocked.move === p.moved )) || (p.faction === 'ixians' && elite > 0 && !( p.ixMovementBlocked?.turn === g.turn && p.ixMovementBlocked.move === p.moved )) ? 2 : 1; return ( base + (p.faction === 'choam' && g.choamMovement?.turn === g.turn ? g.choamMovement.bonus : 0) ); } function balisetPrevents(g: Game, player: string, origin: string, to: string) { const choam = byFaction(g, 'choam'); return ( g.phase === 5 && origin !== to && !!choam && at(choam, to) > 0 && !!g.choamBaliset?.some( (b) => b.turn === g.turn && b.player === player && b.territory === to, ) ); } function offerChoamMovement(g: Game, move: MovementOrder) { const choam = byFaction(g, 'choam'); if ( g.phase === 5 && choam && move.player !== choam.id && move.origin !== move.to && at(choam, move.to) > 0 ) { g.pendingChoamMove = move; g.decision = { kind: 'choamMovement', player: choam.id, mover: move.player, territory: move.to, sector: move.sector, amount: move.total, }; } else if (move.source === 'ambassador') commitAmbassadorRelocation(g, move); else completeMove(g, move); } function validateMovementOrder(g: Game, p: Player, move: MovementOrder) { if (move.source === 'ambassador') { const { beneficiary } = currentFremenAmbassador(g, move.ambassadorEvent); requireRule( beneficiary.id === p.id && move.player === p.id && move.ambassadorEvent, 'The Ambassador move belongs to another beneficiary.', ); const current = quoteAmbassadorRelocation(g, { type: 'decision', event: move.ambassadorEvent, forces: Object.fromEntries(move.group), eliteForces: move.eliteGroup, territory: move.to, sector: move.sector, fighters: move.wantsFighters, ...(move.noField ? { noField: { tokenId: move.noField.tokenId, event: move.noField.event, }, } : {}), }); const { source: _source, ambassadorEvent: _event, ...declared } = move; requireRule( JSON.stringify(current) === JSON.stringify(declared), 'The declared Ambassador relocation no longer matches its current forces and stance.', ); return; } requireRule( g.phase === 5 && g.active === p.id && p.moved < movesAllowed(g, p), 'The movement is no longer available.', ); validateFlightSelection(g, p, move); const selected = forceGroup(p, { type: 'move', forces: Object.fromEntries(move.group), eliteForces: move.eliteGroup, ...(move.noField ? { noField: move.noField.tokenId, event: move.noField.event } : {}), }); requireRule( selected.origin === move.origin && selected.total === move.total && selected.elite === move.elite && selected.group.length === move.group.length, 'The declared force group no longer matches its movement.', ); requireRule( !balisetPrevents(g, p.id, move.origin, move.to), 'Baliset prevents that movement.', ); allowedEntry( g, p, move.to, move.sector, false, move.advisors && !move.wantsFighters, ); requireRule( [ ...move.group.map(([key]) => key), ...(move.noField ? [move.noField.from] : []), ].every( (key) => gameDistance(g, key, location(move.to, move.sector), (k) => pathBlocked(g, p, k, isAdvisor(p, move.origin)), ) <= movementOrderRange(g, p, move), ), 'The movement route is no longer available.', ); } function pendingFremenMovement(g: Game, response: ResponseWindow) { const pending = g.pendingFremenMove; requireRule( pending && response.kind === 'fremenMovement' && pending.order.player === response.owner && pending.turn === g.turn, 'This Fremen movement response is no longer current.', ); const p = getPlayer(g, pending.order.player); requireRule( p.faction === 'fremen' && pending.move === p.moved && response.location === location(pending.order.to, pending.order.sector) && response.amount === pending.order.total, 'This Fremen movement declaration is no longer current.', ); validateMovementOrder(g, p, pending.order); return pending; } function resumeChoamMovement(g: Game) { const move = g.pendingChoamMove!; if (move.source === 'ambassador') currentFremenAmbassador(g, move.ambassadorEvent); g.pendingChoamMove = null; const p = getPlayer(g, move.player); try { validateMovementOrder(g, p, move); } catch (error) { if (!(error instanceof RuleError)) throw error; log( g, `${p.name}'s declared movement is no longer legal. No movement was spent.`, ); if (move.source === 'ambassador') offerAmbassadorRelocation(g); return; } if (move.source === 'ambassador') commitAmbassadorRelocation(g, move); else completeMove(g, move); } function validateMovementArrival( g: Game, move: MovementOrder, cancelingChoam = false, ) { try { return quoteCompletedMovementArrival({ advanced: g.advanced, players: g.players, order: move, ambassadors: g.ecazAmbassadors?.tokens ?? [], terror: g.moritaniTerror?.tokens ?? [], flight: g.ornithopter, controls: { response: !cancelingChoam && !!g.response, decision: !!g.decision, pendingTerror: !!g.pendingTerrorEntry, pendingAmbassador: !!g.pendingAmbassador, paidBox: !!g.pendingNullentropy, }, }); } catch (error) { if (error instanceof MovementArrivalError) throw new RuleError(error.message); throw error; } } function completeMove(g: Game, move: MovementOrder) { const { group, eliteGroup, elite, origin, total: n, to, sector: s, advisors, wantsFighters, lockedTurn, player: id, } = move; const p = getPlayer(g, id); validateFlightSelection(g, p, move); validateMovementArrival(g, move); const flight = g.ornithopter; if ( flight?.player === id && flight.mode === 'twoGroups' && flight.completed === 0 ) { const marker = p.noField?.deployed; flight.cohort = noFieldRule(() => createRemainingCohort( p.forces, p.elites?.forces ?? {}, Object.fromEntries(group), eliteGroup, marker && p.noFieldEvent ? { tokenId: marker.tokenId, event: p.noFieldEvent, from: location(marker.location.territory, marker.location.sector), } : undefined, move.noField, ), ); } if (move.noField) { requireRule( p.noField && p.noFieldEvent === move.noField.event, 'This No-Field movement is no longer current.', ); p.noField = noFieldRule(() => moveRicheseNoField(p.noField!, move.noField!.tokenId, { territory: to, sector: s, }), ); p.noFieldEvent = crypto.randomUUID(); } removeGroup(p, group, eliteGroup); if (n > (move.noField ? 1 : 0)) place(p, to, s, n - (move.noField ? 1 : 0), elite); if (advisors) (p.advisors ??= {})[to] = { lockedTurn: Math.max(lockedTurn ?? 0, p.advisors?.[to]?.lockedTurn ?? 0) || undefined, }; else if (p.advisors) delete p.advisors[to]; if (wantsFighters) g.response = { kind: 'advisorFlip', owner: id, location: to, advisors: false, passed: [], }; finishShipmentPromises(g, p, null); p.shipped = true; p.moved++; const completed: CompletedMovement = { player: id, origin, total: n, to, sector: s, elite, noField: !!move.noField, }; if (flight?.player === id) { flight.completed++; if (flight.completed === (flight.mode === 'twoGroups' ? 2 : 1)) { finishOrnithopter(g, 'move', completed); return; } } finishMovedGroup(g, completed); } /** Forces and movement counters are already committed; only arrival remains. */ function finishMovedGroup(g: Game, move: CompletedMovement) { const { player: id, origin, total: n, to, sector: s, elite } = move; const p = getPlayer(g, id); g.karamaShipping = null; log( g, move.noField ? `${p.name} moved a concealed No-Field${n > 1 ? ` with ${n - 1} physical forces` : ''} from ${territory(origin).name} to ${territory(to).name}. Its value remains concealed.` : `${p.name} moved ${n} forces from ${territory(origin).name} to ${territory(to).name}.`, ); intrusion(g, p, to); if (origin !== to) openTerritoryEntry(g, p, to, s, n, elite, 'movement'); } /** A played movement card is already retired before queue advancement. */ function finishMovementTurn(g: Game, id: string) { const p = getPlayer(g, id); g.karamaShipping = null; g.movementRemaining ??= g.order.slice(g.order.indexOf(id)); const shared = p.ally ? gameTerritories(g).filter( (terr) => terr.type !== 'polar' && at(p, terr.id) && at(getPlayer(g, p.ally!), terr.id) && sharesEcazOccupation(g, p, terr.id), ) : []; if (shared.length) log( g, `${p.name} and ${getPlayer(g, p.ally!).name} remain together in ${shared.map((t) => t.name).join(', ')} under Ecaz Occupy. No allied separation losses apply.`, { faction: 'ecaz', name: 'Occupy' }, ); if (p.ally) for (const terr of gameTerritories(g)) if ( terr.type !== 'polar' && at(p, terr.id) && at(getPlayer(g, p.ally), terr.id) && !sharesEcazOccupation(g, p, terr.id) && !g.movementRemaining.includes(p.ally) && !( p.allySinceTurn === g.turn && getPlayer(g, p.ally).allySinceTurn === g.turn ) ) killTerritory(g, p, terr.id); g.movementRemaining = g.movementRemaining.filter((other) => other !== id); if (g.saphoMovementLast?.player === id) g.saphoMovementLast = null; movementTurn(g); } const implementedAmbassadorEffects: readonly AmbassadorEffect[] = [ 'ecaz', 'atreides', 'harkonnen', 'emperor', 'choam', 'ixians', 'beneGesserit', 'richese', 'fremen', 'guild', ]; function returnAmbassadorsIn(g: Game, to: string, reason: string) { for (const token of g.ecazAmbassadors?.tokens.filter( (t) => t.zone === 'placed' && t.location === to, ) ?? []) { g.ecazAmbassadors = destroyAmbassador(g.ecazAmbassadors!, token.id); log( g, `The ${faction(token.effect).name} Ambassador returned from ${territory(to).name} to Ecaz’s supply because ${reason}. This was destruction, not a triggered effect, and does not advance the five-token cycle.`, ); } } function ambassadorDiscardBlock(g: Game, p: Player, card: Card) { if (g.battle?.lateDefense?.[p.id] === card.id) return 'This Portable Snooper is already played and reserved for battle cleanup.'; if (giftReserved(g, p.id, card.id)) return 'This card is reserved for the pending Richese gift.'; if ( g.truthtrance?.queue.some( (entry) => entry.player === p.id && entry.card === card.id, ) ) return 'This card is already committed to Truthtrance.'; if (retentionReservesCard(g, p.id, card.id)) return 'This card is reserved for Moritani battle cleanup.'; const plan = g.battle?.plans[p.id]; if (plan && [plan.weapon, plan.defense, plan.leader].includes(card.id)) return 'This card is committed to a sealed battle plan.'; const insight = g.battle?.prescience; if (insight && insight.player !== p.id && insight.value === card.id) return 'This card is committed to prescience.'; return null; } function ambassadorEffectBlock( g: Game, effect: AmbassadorEffect, beneficiary: Player, entrant: Player, ): string | null { if (!implementedAmbassadorEffects.includes(effect)) return 'This Ambassador effect is still being implemented.'; if (effect === 'ecaz') { const owner = byFaction(g, 'ecaz'); if (!owner || beneficiary.id !== owner.id) return 'Assigning the Ecaz Ambassador effect to an ally is still being implemented.'; if (g.players.some((p) => p.leaders.some((l) => l.id === DUKE_VIDAL_ID))) return 'Duke Vidal is unavailable for this acquisition.'; return ecazDukeAcquisitionBlock(g, owner.id); } if ( effect === 'ixians' && !beneficiary.hand.some( (card) => !ambassadorDiscardBlock(g, beneficiary, card), ) ) return 'The beneficiary needs a card they can discard.'; if ( effect === 'harkonnen' && !(entrant.faction === 'tleilaxu' ? entrant.faceDancers?.length : entrant.traitors.length) ) return 'The entrant has no held Traitor Card to inspect.'; return null; } /** The original entry is committed once. Competing arrival ordering remains gated. */ function openTerritoryEntry( g: Game, entrant: Player, to: string, sector: number, amount: number, elite: number, cause: NonNullable['cause'], resume: 'none' | 'wormRide' = 'none', ) { const owner = byFaction(g, 'ecaz'); const token = g.ecazAmbassadors?.tokens.find( (t) => t.zone === 'placed' && t.location === to, ); if ( !owner || !token || !canTriggerAmbassador({ owner: owner.id, ally: owner.ally, entrant: entrant.id, entrantFaction: entrant.faction, advisors: isAdvisor(entrant, to), effect: token.effect, }) ) return openTerrorEntry( g, entrant, to, sector, amount, elite, cause, resume, ); const moritani = byFaction(g, 'moritani'); const terror = moritani && entrant.id !== moritani.id && entrant.id !== moritani.ally && g.moritaniTerror?.tokens.some( (t) => t.status === 'placed' && t.location === to, ); requireRule( !g.response && !g.decision && !g.pendingTerrorEntry && !g.pendingAmbassador && !terror, 'Ambassadors combined with another arrival reaction are still being implemented. This entry has not been committed.', ); g.pendingAmbassador = { event: `ambassador:${g.turn}:${g.phase}:${(g.log.at(-1)?.seq ?? 0) + 1}`, owner: owner.id, entrant: entrant.id, token: token.id, territory: to, sector, turn: g.turn, phase: g.phase, stage: 'offer', resume, ...(resume === 'wormRide' ? { wormRider: entrant.id } : {}), copyChoices: token.effect === 'beneGesserit' ? copiedAmbassadorEffects(g.ecazAmbassadors!) : [], }; g.decision = { kind: 'ecazAmbassador', player: owner.id }; log( g, `${owner.name} may trigger the ${faction(token.effect).name} Ambassador because ${entrant.name} entered ${territory(to).name}. The entrant’s remaining actions wait for this opportunity.`, ); return true; } function finishAmbassador(g: Game, deferResume = false) { const pending = g.pendingAmbassador!; if ( pending.beneficiary && pending.effect !== 'ecaz' && g.ecazAmbassadors!.cohort.every((id) => g.ecazAmbassadors!.tokens.some( (t) => t.id === id && (t.zone === 'used' || t.zone === 'removed'), ), ) ) { g.ecazAmbassadors = replenishAmbassadors(g.ecazAmbassadors!, random); log( g, 'All five random Ambassadors have been triggered. Ecaz drew a new supply of five; the removed Bene Gesserit token stays out of the game.', ); } g.pendingAmbassador = null; if (!deferResume && pending.resume === 'wormRide') nextWormRide(g); } function currentFremenAmbassador(g: Game, event?: string, completed = false) { const entry = g.pendingAmbassador; const owner = entry && g.players.find((p) => p.id === entry.owner); const beneficiary = entry && g.players.find((p) => p.id === entry.beneficiary); const token = g.ecazAmbassadors?.tokens.find((t) => t.id === entry?.token); requireRule( entry && owner?.faction === 'ecaz' && beneficiary && entry.effect === 'fremen' && entry.turn === g.turn && entry.phase === g.phase && (g.phase === 1 || g.phase === 5) && (event === undefined || entry.event === event) && (completed ? entry.stage === 'arrival' && entry.relocation : entry.stage === 'move') && (completed || beneficiary.id === owner.id || (owner.ally === beneficiary.id && beneficiary.ally === owner.id)) && token && ((token.effect === 'fremen' && token.zone === 'used') || (token.effect === 'beneGesserit' && token.zone === 'removed' && entry.copyChoices.includes('fremen'))), 'This Fremen Ambassador relocation is no longer current.', ); return { entry, beneficiary }; } /** Check historical arrival receipts without replaying movement or inspecting concealed values. */ function ambassadorRelocationIntegrity(g: Game) { const entry = g.pendingAmbassador; if (entry) requireRule( validAmbassadorResume(g, entry), 'This Ambassador has lost its original arrival continuation.', ); if (g.pendingShipment?.source === 'ambassador') requireRule( entry?.effect === 'guild' && entry.stage === 'ship', 'This pending shipment has no matching Guild Ambassador.', ); const continuation = g.pendingTreacheryDiscard?.continuation; const contexts = [ g, g.pendingExchange, g.pendingNullentropy?.resume, g.pendingRicheseGift?.resume, g.pendingRichesePurchaseIncome?.resume, g.summonedWorm?.resume, continuation && 'resume' in continuation ? continuation.resume : null, ]; const responses = contexts.flatMap((context) => context?.response ? [context.response] : [], ); for (const { pending } of savedKaramaContexts(g)) if (pending.use.kind === 'cancel') responses.push(pending.use.response); const decisions = contexts.flatMap((context) => context?.decision ? [context.decision] : [], ); const terror = g.pendingTerrorEntry ?? (continuation?.kind === 'terrorDiscard' ? continuation.entry : null); const relatedResponses = responses.filter( (response) => response.advisorResume === 'ambassador' || response.advisorAmbassadorEvent !== undefined, ); const relatedDecisions = decisions.filter( (decision) => decision.kind === 'intrusion' && decision.ambassadorEvent !== undefined, ); const advisorDecisions = decisions.filter( (decision) => decision.kind === 'advisor' && decision.ambassadorEvent !== undefined, ); const relatedTerror = terror?.cause === 'ambassador' || terror?.resume === 'ambassador' || terror?.ambassadorEvent !== undefined; if (entry?.effect === 'guild' && entry.stage === 'arrival') { const next = entry.shipmentReceipt?.next; requireRule( next === 'terror' || next === 'accompany' || next === 'advisor' || next === 'finish', 'The saved Guild Ambassador arrival has an invalid continuation stage.', ); const { order } = guildAmbassadorArrival(g, entry.event, next); for (const response of relatedResponses) { requireRule( response.advisorResume === 'ambassador' && response.advisorAmbassadorEvent === entry.event, 'The saved advisor reaction has a different Guild Ambassador parent.', ); try { if (response.kind === 'advisorFlip') quoteMovementCancellation(g, response); else if (response.kind === 'advisor') validateTerminalCancellation(g, response); else throw new RuleError( 'The saved Guild Ambassador advisor response is invalid.', ); } catch (error) { if ( error instanceof MovementCancellationError || error instanceof TerminalCancellationError ) throw new RuleError(error.message); throw error; } } for (const decision of relatedDecisions) requireRule( decision.kind === 'intrusion' && next === 'terror' && decision.ambassadorEvent === entry.event && decision.territory === order.territory && getPlayer(g, decision.player).faction === 'beneGesserit' && decision.player !== order.player && !decision.followup && !decision.wormRide, 'This saved Intrusion does not match its Guild Ambassador arrival.', ); for (const decision of advisorDecisions) requireRule( decision.kind === 'advisor' && next === 'advisor' && decision.ambassadorEvent === entry.event && decision.shipment === order.player && decision.destination === location(order.territory, order.sector) && getPlayer(g, decision.player).faction === 'beneGesserit' && decision.player !== order.player && getPlayer(g, order.player).faction !== 'fremen', 'This saved accompaniment does not match its Guild Ambassador shipment.', ); if (relatedTerror) requireRule( terror && ambassadorTerrorEntryMatches(g, terror), 'This saved Terror does not match its Guild Ambassador arrival.', ); requireRule( next === 'accompany' || next === 'finish' ? relatedTerror : relatedResponses.length + relatedDecisions.length + advisorDecisions.length > 0, 'The saved Guild Ambassador shipment has lost its pending arrival reaction.', ); return; } if (entry?.stage !== 'arrival') { requireRule( !relatedResponses.length && !relatedDecisions.length && !advisorDecisions.length && !relatedTerror, 'This saved arrival reaction has no completed Ambassador relocation.', ); if (entry?.stage === 'move') currentFremenAmbassador(g); if (entry?.stage === 'ship') { const { beneficiary } = currentGuildAmbassador(g); const selections = decisions.filter( (decision) => decision.kind === 'ecazAmbassador', ); const stops = decisions.filter( (decision) => decision.kind === 'guildShipment', ); if (g.pendingShipment) { requireRule( g.pendingShipment.source === 'ambassador' && g.phase === 5 && !selections.length && stops.length > 0, 'The pending Guild Ambassador shipment has lost its shipment decision.', ); for (const stop of stops) validateGuildShipmentDecision(g, stop); } else requireRule( !stops.length && selections.length > 0 && selections.every((decision) => decision.player === beneficiary.id), 'The Guild Ambassador has lost its beneficiary shipment selection.', ); } return; } requireRule( !advisorDecisions.length, 'This Ambassador relocation cannot own an accompaniment decision.', ); const next = entry.relocation?.next; requireRule( next === 'intrusion' || next === 'terror' || next === 'finish', 'The saved Ambassador arrival has an invalid continuation stage.', ); try { const order = validateAmbassadorRelocationContext(g, entry.event, next); for (const response of relatedResponses) { requireRule( response.kind === 'advisorFlip' && response.advisorResume === 'ambassador', 'This saved Ambassador advisor reaction is invalid.', ); quoteMovementCancellation(g, response); } for (const decision of relatedDecisions) { requireRule( decision.kind === 'intrusion' && next === 'terror' && decision.ambassadorEvent === entry.event && decision.territory === order.to && order.origin !== order.to && decision.player !== order.player && getPlayer(g, decision.player).faction === 'beneGesserit' && !decision.wormRide && !decision.followup, 'This saved Intrusion does not match its Ambassador arrival.', ); } if (relatedTerror) requireRule( terror && next === 'finish' && terror.cause === 'ambassador' && terror.resume === 'ambassador' && terror.ambassadorEvent === entry.event && terror.entrant === order.player && terror.turn === g.turn && terror.phase === g.phase && terror.territory === order.to && terror.sector === order.sector && terror.amount === order.total && terror.elite === order.elite && order.origin !== order.to, 'This saved Terror does not match its Ambassador arrival.', ); requireRule( next === 'finish' ? relatedTerror : relatedResponses.length + relatedDecisions.length > 0, 'The saved Ambassador arrival has lost its pending reaction.', ); } catch (error) { if (error instanceof MovementCancellationError) throw new RuleError(error.message); throw error; } } function guildAmbassadorArrival( g: Game, event: string, next: NonNullable< NonNullable['shipmentReceipt'] >['next'], ) { try { return validateGuildAmbassadorArrivalContext(g, event, next); } catch (error) { if (error instanceof GuildAmbassadorContinuationError) throw new RuleError(error.message); throw error; } } function ambassadorTerrorEntryMatches( g: Game, child: NonNullable, ) { const parent = g.pendingAmbassador; if ( !parent || parent.stage !== 'arrival' || child.resume !== 'ambassador' || child.cause !== 'ambassador' || child.ambassadorEvent !== parent.event || child.turn !== g.turn || child.phase !== g.phase ) return false; if (parent.effect === 'guild') { const receipt = parent.shipmentReceipt; const order = receipt?.next === 'accompany' ? receipt.order : receipt?.next === 'finish' ? receipt.advisorArrival : null; return ( !!order && child.entrant === order.player && child.territory === order.territory && child.sector === order.sector && child.amount === order.amount && child.elite === order.elite ); } const receipt = parent.relocation, order = receipt?.order; return ( parent.effect === 'fremen' && receipt?.next === 'finish' && !!order && child.entrant === order.player && child.territory === order.to && child.sector === order.sector && child.amount === order.total && child.elite === order.elite ); } function currentGuildAmbassador(g: Game, event?: string) { const entry = g.pendingAmbassador; const owner = entry && g.players.find((p) => p.id === entry.owner); const beneficiary = entry && g.players.find((p) => p.id === entry.beneficiary); const token = g.ecazAmbassadors?.tokens.find( (token) => token.id === entry?.token, ); const entrant = entry && g.players.find((p) => p.id === entry.entrant); requireRule(g.ecazAmbassadors, 'This shipment has no Ambassador inventory.'); try { validateAmbassadors(g.ecazAmbassadors); } catch { throw new RuleError('This shipment has invalid Ambassador custody.'); } requireRule( g.status === 'playing' && entry && owner?.faction === 'ecaz' && beneficiary && entrant && entrant.id !== owner.id && entrant.id !== beneficiary.id && validLocation(entry.territory, entry.sector) && territory(entry.territory).type === 'stronghold' && validAmbassadorResume(g, entry) && entry.relocation === undefined && entry.shipmentReceipt === undefined && entry.purchaseReceipt === undefined && token?.effect !== entrant.faction && entry.effect === 'guild' && entry.stage === 'ship' && entry.turn === g.turn && entry.phase === g.phase && (g.phase === 1 || g.phase === 5) && (event === undefined || event === entry.event) && (beneficiary.id === owner.id || (owner.ally === beneficiary.id && beneficiary.ally === owner.id)) && token && ((token.effect === 'guild' && token.zone === 'used') || (token.effect === 'beneGesserit' && token.zone === 'removed' && entry.copyChoices.includes('guild') && !g.ecazAmbassadors.cohort.some((id) => g.ecazAmbassadors!.tokens.some( (t) => t.id === id && t.effect === 'guild', ), ))), 'This Guild Ambassador shipment is no longer current.', ); return { entry, beneficiary }; } function guildAmbassadorRule(calculate: () => T): T { try { return calculate(); } catch (error) { if (error instanceof GuildAmbassadorShipmentError) throw new RuleError(error.message); throw error; } } function quoteAmbassadorShipment(g: Game, action: Action) { const { beneficiary } = currentGuildAmbassador(g, stringField(action.event)); return guildAmbassadorRule(() => quoteGuildAmbassadorShipment(g, beneficiary.id, action), ); } function offerAmbassadorShipment(g: Game) { const entry = g.pendingAmbassador!; entry.stage = 'ship'; const { beneficiary } = currentGuildAmbassador(g); const choices = guildAmbassadorRule(() => guildAmbassadorShipments(g, beneficiary.id), ); if (choices.maximum && choices.destinations.length) g.decision = { kind: 'ecazAmbassador', player: beneficiary.id }; else { log( g, `${beneficiary.name} has no legal reserve shipment through the Guild Ambassador. No forces or ordinary shipment allowance changed; the trigger remains committed.`, ); finishAmbassador(g); } } function validateAmbassadorShipmentArrival( g: Game, order: GuildAmbassadorShipment, ) { const blocked = ambassadorRelocationArrivalBlock( g, order.player, 'reserves', order.territory, ); requireRule(!blocked, blocked ?? 'This shipment arrival is unavailable.'); } function validateAmbassadorShipmentOrder(g: Game, shipment: PendingShipment) { const { entry, beneficiary } = currentGuildAmbassador( g, shipment.ambassadorEvent, ); requireRule( shipment.source === 'ambassador' && shipment.ambassadorEvent === entry.event && shipment.turn === g.turn && shipment.player === beneficiary.id && !shipment.noField && !shipment.alliedNoField, 'This declaration does not match its independent Guild Ambassador shipment.', ); const order = quoteAmbassadorShipment(g, { type: 'decision', event: entry.event, amount: shipment.amount, elite: shipment.elite, territory: shipment.territory, sector: shipment.sector, }); requireRule( shipment.cost === 0 && shipment.allyPayment === 0 && shipment.advisors === order.advisors, 'The free shipment declaration has an invalid price or arrival stance.', ); validateAmbassadorShipmentArrival(g, order); return order; } function commitAmbassadorShipment(g: Game, shipment: PendingShipment) { const order = validateAmbassadorShipmentOrder(g, shipment); const { entry, beneficiary: p } = currentGuildAmbassador( g, shipment.ambassadorEvent, ); p.reserves -= order.amount; if (p.elites) p.elites.reserves -= order.elite; place(p, order.territory, order.sector, order.amount, order.elite); if (order.advisors) (p.advisors ??= {})[order.territory] ??= {}; if (p.faction !== 'fremen') techIncome(g, 'heighliners', p); entry.stage = 'arrival'; entry.shipmentReceipt = { order, next: 'intrusion' }; log( g, `${p.name} shipped ${order.amount} physical forces from reserves to ${territory(order.territory).name}, sector ${order.sector}, for free through the Guild Ambassador. Their ordinary shipment and movement allowances are unchanged.`, { faction: p.faction, name: 'Guild Ambassador shipment' }, ); continueGuildAmbassadorShipment(g, entry.event); } function continueAmbassadorArrival(g: Game, event: string) { if (g.pendingAmbassador?.effect === 'guild') continueGuildAmbassadorShipment(g, event); else continueAmbassadorRelocation(g, event); } function continueGuildAmbassadorShipment(g: Game, event: string) { const entry = g.pendingAmbassador; requireRule( entry?.event === event && entry.stage === 'arrival' && entry.effect === 'guild' && entry.shipmentReceipt && entry.turn === g.turn && entry.phase === g.phase, 'This Guild Ambassador arrival is no longer current.', ); const receipt = entry.shipmentReceipt; const { order } = guildAmbassadorArrival(g, event, receipt.next); const p = getPlayer(g, order.player); if (receipt.next === 'intrusion') { receipt.next = 'terror'; if (intrusion(g, p, order.territory, { ambassadorEvent: event })) return; } if (receipt.next === 'terror') { receipt.next = 'accompany'; if ( openTerrorEntry( g, p, order.territory, order.sector, order.amount, order.elite, 'ambassador', 'ambassador', event, ) ) return; } if (receipt.next === 'accompany') { receipt.next = 'advisor'; const bg = byFaction(g, 'beneGesserit'); if (p.faction !== 'fremen' && bg && bg.id !== p.id && bg.reserves > 0) { g.decision = { kind: 'advisor', player: bg.id, shipment: p.id, destination: location(order.territory, order.sector), ambassadorEvent: event, }; return; } } finishAmbassador(g); } function guildAdvisorAmbassador( g: Game, quote: ReturnType, ) { const owner = byFaction(g, 'ecaz'); const token = g.ecazAmbassadors?.tokens.find( (token) => token.zone === 'placed' && token.location === quote.territory, ); const bg = getPlayer(g, quote.player); return ( !!owner && !!token && canTriggerAmbassador({ owner: owner.id, ally: owner.ally, entrant: bg.id, entrantFaction: bg.faction, advisors: quote.advisors, effect: token.effect, }) ); } function validateGuildAdvisorEntry( g: Game, quote: ReturnType, ) { if (!guildAdvisorAmbassador(g, quote)) return; const moritani = byFaction(g, 'moritani'), bg = getPlayer(g, quote.player); const terror = moritani && moritani.id !== bg.id && moritani.ally !== bg.id && g.moritaniTerror?.tokens.some( (t) => t.status === 'placed' && t.location === quote.territory, ); requireRule( !terror, 'Ambassadors combined with Terror on the same arrival are still being implemented. Choose another accompaniment destination or decline.', ); } function guildAdvisorChoices(g: Game, player: string) { const decision = g.decision; if ( decision?.kind !== 'advisor' || decision.player !== player || !decision.ambassadorEvent ) return null; const { order } = guildAmbassadorArrival( g, decision.ambassadorEvent, 'advisor', ); const candidates = [ ...(g.advanced ? territory(order.territory).sectors.map((sector) => ({ accompany: true, territory: order.territory, sector, })) : []), { accompany: false, territory: 'polar_sink', sector: 0 }, ]; return { choices: candidates.map((choice) => { try { const quoted = guildAmbassadorRule(() => quoteGuildAmbassadorAdvisor(g, player, order, { type: 'decision', ...choice, }), ); validateGuildAdvisorEntry(g, quoted); return { ...choice, blocked: null as string | null }; } catch (error) { if (!(error instanceof RuleError)) throw error; return { ...choice, blocked: error.message }; } }), }; } function finishGuildAmbassadorAdvisor( g: Game, response: ResponseWindow, canceled: boolean, ) { const event = response.advisorAmbassadorEvent; requireRule( typeof event === 'string', 'The accompanying advisor has lost its shipment event.', ); const { order } = guildAmbassadorArrival(g, event, 'advisor'); // Cancellation checks the committed source, not resources or destinations that // can change during a reaction to the declared faction power. try { validateTerminalCancellation(g, response); } catch (error) { if (error instanceof TerminalCancellationError) throw new RuleError(error.message); throw error; } if (canceled) { log( g, 'Karama prevented Bene Gesserit accompaniment to the Guild Ambassador shipment. The original shipment remains committed.', ); finishAmbassador(g); return; } const at = splitLocation(response.location!); const quote = guildAmbassadorRule(() => quoteGuildAmbassadorAdvisor(g, response.owner, order, { type: 'decision', accompany: at.territory !== 'polar_sink', sector: at.sector, territory: at.territory, }), ); validateGuildAdvisorEntry(g, quote); const bg = getPlayer(g, quote.player); bg.reserves--; place(bg, quote.territory, quote.sector, 1); techIncome(g, 'heighliners', bg); if (quote.advisors) (bg.advisors ??= {})[quote.territory] = { lockedTurn: g.turn }; const receipt = g.pendingAmbassador!.shipmentReceipt!; receipt.next = 'finish'; receipt.advisorArrival = { player: bg.id, territory: quote.territory, sector: quote.sector, amount: 1, elite: 0, }; log( g, `${bg.name} sent one reserve force to ${territory(quote.territory).name}, sector ${quote.sector}, for free after the Guild Ambassador shipment.`, { faction: bg.faction, name: 'Spiritual Advisors' }, ); if (guildAdvisorAmbassador(g, quote)) { const parent = g.pendingAmbassador!; const resume = parent.resume; const wormRider = parent.wormRider ?? (resume === 'wormRide' ? parent.entrant : undefined); // Accompaniment is the final suffix: transfer its outer continuation rather // than replaying or retaining the completed Guild shipment. finishAmbassador(g, true); requireRule( openTerritoryEntry( g, bg, quote.territory, quote.sector, 1, 0, 'advisor', resume, ), 'The accompanying fighter has lost its Ambassador opportunity.', ); g.pendingAmbassador!.wormRider = wormRider; g.pendingAmbassador!.guildAdvisorOrigin = { event, player: order.player, territory: order.territory, sector: order.sector, }; } else if ( !openTerrorEntry( g, bg, quote.territory, quote.sector, 1, 0, 'ambassador', 'ambassador', event, ) ) finishAmbassador(g); } function quoteAmbassadorRelocation(g: Game, action: Action) { const { beneficiary } = currentFremenAmbassador(g, stringField(action.event)); try { return quoteFremenAmbassadorMove(g, beneficiary.id, action); } catch (error) { if (error instanceof FremenAmbassadorMoveError) throw new RuleError(error.message); throw error; } } function offerAmbassadorRelocation(g: Game) { const entry = g.pendingAmbassador!; entry.stage = 'move'; const { beneficiary } = currentFremenAmbassador(g); if (fremenAmbassadorMovement(g, beneficiary.id).sources.length) { g.decision = { kind: 'ecazAmbassador', player: beneficiary.id }; } else { log( g, `${beneficiary.name} has no legal Fremen Ambassador relocation. No forces or ordinary movement allowances changed; the trigger remains committed.`, ); finishAmbassador(g); } } function ambassadorRelocationArrivalBlock( g: Game, player: string, origin: string, to: string, ): string | null { const mover = getPlayer(g, player); if (origin === to) return null; const bg = byFaction(g, 'beneGesserit'); const moritani = byFaction(g, 'moritani'); const intrudes = g.advanced && bg && bg.id !== mover.id && fighterCount(bg, to) > 0; const terror = moritani && mover.id !== moritani.id && mover.id !== moritani.ally && g.moritaniTerror?.tokens.some( (token) => token.status === 'placed' && token.location === to, ); return intrudes && terror ? 'The order of simultaneous Intrusion and Terror is awaiting a table interpretation. This relocation has not been committed.' : null; } function validateAmbassadorRelocationArrival( g: Game, move: FremenAmbassadorMove, ) { const blocked = ambassadorRelocationArrivalBlock( g, move.player, move.origin, move.to, ); requireRule(!blocked, blocked ?? 'This arrival is unavailable.'); } function ambassadorRelocationMovement(g: Game, player: string) { return { sources: fremenAmbassadorMovement(g, player).sources.map((source) => ({ ...source, destinations: source.destinations.map((destination) => ({ ...destination, blocked: ambassadorRelocationArrivalBlock( g, player, source.territory, destination.territory, ), })), })), }; } function commitAmbassadorRelocation(g: Game, move: FremenAmbassadorMove) { const { entry, beneficiary: p } = currentFremenAmbassador(g); validateAmbassadorRelocationArrival(g, move); if (move.noField) { requireRule( p.noField && p.noFieldEvent === move.noField.event, 'The concealed marker selection is no longer current.', ); p.noField = noFieldRule(() => moveRicheseNoField(p.noField!, move.noField!.tokenId, { territory: move.to, sector: move.sector, }), ); p.noFieldEvent = crypto.randomUUID(); } removeGroup(p, move.group, move.eliteGroup); const physical = move.total - (move.noField ? 1 : 0); if (physical) place(p, move.to, move.sector, physical, move.elite); if (move.advisors) (p.advisors ??= {})[move.to] = { lockedTurn: Math.max( move.lockedTurn ?? 0, p.advisors?.[move.to]?.lockedTurn ?? 0, ) || undefined, }; else if (p.advisors) delete p.advisors[move.to]; entry.stage = 'arrival'; entry.relocation = { order: move, next: 'flip' }; log( g, `${p.name} relocated ${physical} physical forces${move.noField ? ' and a concealed No-Field' : ''} from ${territory(move.origin).name} to ${territory(move.to).name}, sector ${move.sector}, through the Fremen Ambassador. Their ordinary shipment and movement allowances are unchanged.`, { faction: p.faction, name: 'Fremen Ambassador relocation' }, ); continueAmbassadorRelocation(g, entry.event); } function continueAmbassadorRelocation(g: Game, event: string) { const { entry, beneficiary: p } = currentFremenAmbassador(g, event, true); const receipt = entry.relocation!, move = receipt.order; if (receipt.next !== 'flip') { try { validateAmbassadorRelocationContext(g, event, receipt.next); } catch (error) { if (error instanceof MovementCancellationError) throw new RuleError(error.message); throw error; } } if (receipt.next === 'flip') { receipt.next = 'intrusion'; if (move.wantsFighters) { g.response = { kind: 'advisorFlip', owner: p.id, location: move.to, advisors: false, passed: [], advisorResume: 'ambassador', advisorAmbassadorEvent: event, }; return; } } if (receipt.next === 'intrusion') { receipt.next = 'terror'; if ( move.origin !== move.to && intrusion(g, p, move.to, { ambassadorEvent: event }) ) return; } if (receipt.next === 'terror') { receipt.next = 'finish'; if ( move.origin !== move.to && openTerrorEntry( g, p, move.to, move.sector, move.total, move.elite, 'ambassador', 'ambassador', event, ) ) return; } finishAmbassador(g); } /** These are consequences of a completed purchase, never an auction lot. */ function validateAmbassadorPurchaseResponse(g: Game, response: ResponseWindow) { const entry = g.pendingAmbassador; const receipt = entry?.purchaseReceipt; const token = g.ecazAmbassadors?.tokens.find((t) => t.id === entry?.token); requireRule( response.source === 'ambassador' && (response.kind === 'emperorIncome' || response.kind === 'harkonnenBonus') && g.status === 'playing' && entry && receipt && entry.turn === g.turn && entry.phase === g.phase && (g.phase === 1 || g.phase === 5) && response.intent === entry.event && entry.effect === 'richese' && entry.beneficiary === receipt.buyer && receipt.amount === 3 && typeof receipt.card === 'string' && !!receipt.card && entry.stage === receipt.stage && byFaction(g, 'ecaz')?.id === entry.owner && g.players.some((p) => p.id === entry.entrant) && token && ((token.effect === 'richese' && token.zone === 'used') || (token.effect === 'beneGesserit' && token.zone === 'removed' && entry.copyChoices.includes('richese'))), 'This Ambassador purchase continuation is no longer current.', ); const buyer = getPlayer(g, receipt.buyer); const emperor = byFaction(g, 'emperor'); requireRule( receipt.emperor === (emperor && emperor.id !== buyer.id ? emperor.id : null), 'This Ambassador purchase has an invalid income recipient.', ); requireRule( response.kind === 'emperorIncome' ? entry.stage === 'income' && response.owner === receipt.emperor && response.amount === 3 : entry.stage === 'bonus' && response.owner === buyer.id && buyer.faction === 'harkonnen', 'This faction benefit does not belong to the current Ambassador purchase.', ); return { entry, receipt, buyer }; } function ambassadorPurchaseBonus(g: Game) { const entry = g.pendingAmbassador!; const receipt = entry.purchaseReceipt!; const buyer = getPlayer(g, receipt.buyer); if (buyer.faction === 'harkonnen' && buyer.hand.length < handLimit(buyer)) { entry.stage = receipt.stage = 'bonus'; g.response = { kind: 'harkonnenBonus', source: 'ambassador', intent: entry.event, owner: buyer.id, passed: [], }; } else finishAmbassador(g); } function finishAmbassadorPurchaseResponse( g: Game, response: ResponseWindow, canceled: boolean, ) { const { receipt, buyer } = validateAmbassadorPurchaseResponse(g, response); if (response.kind === 'emperorIncome') { if (!canceled) getPlayer(g, response.owner).spice += receipt.amount; log( g, canceled ? 'Karama prevented Emperor income from the Richese Ambassador purchase. The three-spice payment stays in the bank; the buyer keeps the card.' : `${getPlayer(g, response.owner).name} collected the three-spice Richese Ambassador purchase payment through Emperor income.`, { faction: 'emperor', name: canceled ? 'Purchase income prevented' : 'Card purchase income', }, ); ambassadorPurchaseBonus(g); } else { const bonus = !canceled && buyer.hand.length < handLimit(buyer) ? draw(g) : undefined; if (bonus) buyer.hand.push(bonus); log( g, canceled ? 'Karama prevented the Harkonnen bonus card from the Richese Ambassador purchase. The paid card remains with its buyer.' : bonus ? `${buyer.name} drew a private Harkonnen bonus Treachery Card after the Richese Ambassador purchase.` : `${buyer.name} received no Harkonnen bonus card because their hand has no room or the deck has no available card.`, { faction: 'harkonnen', name: canceled ? 'Bonus card prevented' : 'Bonus treachery card', }, ); finishAmbassador(g); } } function resolveAmbassadorEffect(g: Game) { const entry = g.pendingAmbassador!; const p = getPlayer(g, entry.beneficiary!); const entrant = getPlayer(g, entry.entrant); const effect = entry.effect!; requireRule( effect !== 'ecaz', 'Choose the Ecaz Ambassador’s direct Duke acquisition from its current offer.', ); const blocked = ambassadorEffectBlock(g, effect, p, entrant); requireRule(!blocked, blocked ?? 'This Ambassador effect is unavailable.'); if (effect === 'beneGesserit') { entry.stage = 'copy'; g.decision = { kind: 'ecazAmbassador', player: p.id }; return; } if (effect === 'guild') { offerAmbassadorShipment(g); return; } if (effect === 'fremen') { offerAmbassadorRelocation(g); return; } if (effect === 'richese') { // Resolve after commitment so Ecaz cannot probe its ally's private funds. if ( p.spice < 3 || p.hand.length >= handLimit(p) || (!g.deck.length && !g.discard.length) ) { log( g, `${p.name} received no card through the Richese Ambassador. No spice was charged; the trigger remains committed.`, ); finishAmbassador(g); return; } const card = draw(g); requireRule( card, 'No Treachery Card is available for the Ambassador purchase.', ); p.spice -= 3; p.hand.push(card); const emperor = byFaction(g, 'emperor'); entry.purchaseReceipt = { buyer: p.id, card: card.id, amount: 3, emperor: emperor && emperor.id !== p.id ? emperor.id : null, stage: 'income', }; log( g, `${p.name} paid 3 spice and drew the top Treachery Card through the Richese Ambassador. Its identity remains private.`, { faction: p.faction, name: 'Richese Ambassador purchase' }, ); if (entry.purchaseReceipt.emperor) { entry.stage = 'income'; g.response = { kind: 'emperorIncome', source: 'ambassador', intent: entry.event, owner: entry.purchaseReceipt.emperor, amount: 3, passed: [], }; } else ambassadorPurchaseBonus(g); return; } if ( effect === 'ixians' || (effect === 'choam' && p.hand.some((c) => !ambassadorDiscardBlock(g, p, c))) ) { entry.stage = 'cards'; g.decision = { kind: 'ecazAmbassador', player: p.id }; return; } if (effect === 'emperor') { p.spice += 5; log( g, `${p.name} gained 5 spice from the Spice Bank through the Emperor Ambassador.`, { faction: p.faction, name: 'Emperor Ambassador' }, ); } else if (effect === 'atreides' || effect === 'harkonnen') { const held = entrant.faction === 'tleilaxu' ? (entrant.faceDancers ?? []).map((c) => c.leader) : entrant.traitors; (g.ambassadorInsights ??= []).push({ event: entry.event, viewer: p.id, target: entrant.id, turn: g.turn, effect, cards: effect === 'atreides' ? structuredClone(entrant.hand) : [], traitor: effect === 'harkonnen' ? held[Math.floor(random() * held.length)] : null, }); log( g, `${p.name} privately inspected ${effect === 'atreides' ? `${entrant.name}’s current Treachery hand` : `one random Traitor Card held by ${entrant.name}`} through the ${faction(effect).name} Ambassador. This records only what was held at this moment.`, { faction: p.faction, name: `${faction(effect).name} Ambassador` }, ); } else if (effect === 'choam') { log( g, `${p.name} had no available Treachery cards to discard through the CHOAM Ambassador and gained no spice.`, ); } finishAmbassador(g); } function decideAmbassador(g: Game, p: Player, action: Action) { const entry = g.pendingAmbassador; requireRule( entry && entry.turn === g.turn && entry.phase === g.phase && action.event === entry.event, 'This Ambassador opportunity is no longer current.', ); const owner = getPlayer(g, entry.owner), entrant = getPlayer(g, entry.entrant); requireRule( p.id === (entry.stage === 'offer' ? owner.id : entry.beneficiary), 'The Ambassador choice belongs to another player.', ); if (entry.stage === 'offer') { const token = g.ecazAmbassadors?.tokens.find((t) => t.id === entry.token); requireRule( token?.zone === 'placed' && token.location === entry.territory, 'The Ambassador is no longer in that stronghold.', ); if (action.decline === true) { log( g, `${owner.name} left the ${faction(token.effect).name} Ambassador in ${territory(entry.territory).name} without triggering it.`, ); finishAmbassador(g); return; } requireRule( action.trigger === true, 'Trigger the Ambassador or leave it in place.', ); const beneficiary = getPlayer(g, stringField(action.beneficiary)); requireRule( beneficiary.id === owner.id || (beneficiary.id === owner.ally && beneficiary.ally === owner.id), 'Choose Ecaz or its current ally as beneficiary.', ); const blocked = ambassadorEffectBlock( g, token.effect, beneficiary, entrant, ); requireRule(!blocked, blocked ?? 'This effect is unavailable.'); if (token.effect === 'beneGesserit') requireRule( entry.copyChoices.some( (effect) => !ambassadorEffectBlock(g, effect, beneficiary, entrant), ), 'No copied effect is currently available.', ); let acquiredDuke: DukeState | null = null; if (token.effect === 'ecaz') { requireRule( action.choice === 'duke' && beneficiary.id === owner.id, 'Choose the Ecaz Ambassador’s direct acquisition of Duke Vidal for Ecaz.', ); requireRule( canTriggerAmbassador({ owner: owner.id, ally: owner.ally, entrant: entrant.id, entrantFaction: entrant.faction, advisors: isAdvisor(entrant, entry.territory), effect: token.effect, }), 'This entrant cannot trigger the Ecaz Ambassador.', ); try { acquiredDuke = quoteEcazDukeAcquisition(g, owner.id); } catch (error) { if (error instanceof EcazDukeAcquisitionError) throw new RuleError(error.message); throw error; } } entry.beneficiary = beneficiary.id; entry.effect = token.effect; g.ecazAmbassadors = triggerAmbassador(g.ecazAmbassadors!, token.id); log( g, `${owner.name} triggered the ${faction(token.effect).name} Ambassador for ${beneficiary.name}. ${token.effect === 'ecaz' ? 'The reusable Ecaz token returned to supply; the five random Ambassadors are unchanged.' : token.effect === 'beneGesserit' ? 'The Bene Gesserit token was permanently removed.' : 'The token was set aside until the five random Ambassadors have all been triggered.'}`, ); if (acquiredDuke) { const previous = g.dukeVidal!.controller; g.dukeVidal = acquiredDuke; log( g, `${owner.name} acquired Duke Prad Vidal${previous ? ` from ${getPlayer(g, previous).name}` : ''} through the Ecaz Ambassador. Ecaz keeps the shared Duke until battle use or Moritani acquisition; the entrant’s remaining actions resume.`, { faction: owner.faction, name: 'Ecaz Ambassador — Duke Vidal' }, ); finishAmbassador(g); } else resolveAmbassadorEffect(g); } else if (entry.stage === 'copy') { const effect = stringField(action.effect) as AmbassadorEffect; requireRule( entry.copyChoices.includes(effect), 'Copy an Ambassador outside the original supply for this cycle.', ); entry.effect = effect; resolveAmbassadorEffect(g); } else if (entry.stage === 'ship') { currentGuildAmbassador(g, stringField(action.event)); if (action.amount === 0) { requireRule( action.noField === undefined && action.alliedNoField === undefined, 'Choose physical reserve forces for this shipment.', ); log( g, `${p.name} chose to send no reserve forces through the Guild Ambassador. The trigger remains committed and their ordinary shipment is unchanged.`, ); finishAmbassador(g); } else { const order = quoteAmbassadorShipment(g, action); validateAmbassadorShipmentArrival(g, order); offerShipment(g, { ...order, turn: g.turn, source: 'ambassador', ambassadorEvent: entry.event, }); } } else if (entry.stage === 'move') { const move = quoteAmbassadorRelocation(g, action); validateAmbassadorRelocationArrival(g, move); offerChoamMovement(g, { ...move, source: 'ambassador', ambassadorEvent: entry.event, }); } else { requireRule( entry.effect === 'choam' || entry.effect === 'ixians', 'This Ambassador has no card selection.', ); requireRule( Array.isArray(action.cards) && action.cards.every((id) => typeof id === 'string') && new Set(action.cards).size === action.cards.length, 'Choose distinct owned Treachery cards.', ); const ids = action.cards as string[]; requireRule( entry.effect !== 'ixians' || ids.length === 1, 'Discard exactly one card for the Ixian Ambassador.', ); for (const id of ids) { const card = p.hand.find((c) => c.id === id); requireRule(card, 'Choose a card in your own hand.'); const blocked = ambassadorDiscardBlock(g, p, card); requireRule(!blocked, blocked ?? 'This card cannot be discarded.'); } const entries = ids.map((id) => ({ card: discard(g, p, id), discardedBy: p.id, publicFace: false, })); if (entry.effect === 'choam') { p.spice += ids.length * 3; log( g, `${p.name} discarded ${ids.length} Treachery cards and gained ${ids.length * 3} bank spice through the CHOAM Ambassador.`, ids.length ? { faction: p.faction, name: 'CHOAM Ambassador' } : undefined, ); } if (!entries.length) finishAmbassador(g); else { g.pendingAmbassador = null; stageTreacheryDiscard(g, `ambassador:${entry.effect}`, entries, { kind: 'ambassador', entry, }); } } } /** Entry is already paid and committed; a reaction must never replay its original action. */ function openTerrorEntry( g: Game, entrant: Player, to: string, sector: number, amount: number, elite: number, cause: NonNullable['cause'], resume: 'none' | 'wormRide' | 'ambassador' = 'none', ambassadorEvent?: string, ) { const moritani = byFaction(g, 'moritani'); const token = g.moritaniTerror?.tokens.find( (t) => t.status === 'placed' && t.location === to, ); if ( !moritani || !token || entrant.id === moritani.id || entrant.id === moritani.ally ) return false; requireRule(!g.pendingTerrorEntry, 'Resolve the pending Terror entry first.'); requireRule( !g.response && !g.decision, 'Terror combined with another arrival reaction is still being implemented. This entry has not been committed.', ); g.pendingTerrorEntry = { token: token.id, entrant: entrant.id, territory: to, sector, amount, elite, cause, turn: g.turn, phase: g.phase, stage: 'offer', resume, ...(ambassadorEvent ? { ambassadorEvent } : {}), }; g.decision = { kind: 'moritaniTerror', player: moritani.id, entrant: entrant.id, territory: to, }; log( g, `${moritani.name} may reveal a Terror token after ${entrant.name} entered ${territory(to).name}.`, ); return true; } function finishTerrorEntry(g: Game) { const pending = g.pendingTerrorEntry!; g.pendingTerrorEntry = null; if (pending.resume === 'wormRide') nextWormRide(g); else if (pending.resume === 'ambassador') continueAmbassadorArrival(g, pending.ambassadorEvent!); } /** The token, random victim/draw, discard and arrival have already committed. */ function continueTerrorDiscard( g: Game, source: 'sabotage' | 'robberyOverflow', ) { const entry = g.pendingTerrorEntry!; const owner = byFaction(g, 'moritani')!; const needsChoice = source === 'sabotage' ? owner.hand.length > 0 : owner.hand.length > handLimit(owner); if (needsChoice) { entry.stage = source === 'sabotage' ? 'gift' : 'discard'; g.decision = { kind: 'moritaniTerror', player: owner.id, entrant: entry.entrant, territory: entry.territory, }; } else finishTerrorEntry(g); } /** Keep unsupported custody out of the random draw, without leaking concealed captures. */ function terrorRevealBlocked( g: Game, entry: NonNullable, kind: TerrorKind, ): string | null { if (kind === 'robbery' || kind === 'sabotage' || kind === 'sneakAttack') return null; if (kind !== 'assassination') return 'This Terror effect is still being implemented.'; if (g.advanced && byFaction(g, 'harkonnen')) return 'Assassination with advanced Harkonnen capture is still being implemented.'; const entrant = getPlayer(g, entry.entrant); if (g.dukeVidal?.controller === entrant.id) return 'Assassination with Duke Vidal in the leader pool is still being implemented.'; const catalog = leaders(entrant.faction); if ( catalog.length !== 5 || entrant.leaders.length !== catalog.length || new Set(entrant.leaders.map((leader) => leader.id)).size !== catalog.length || entrant.leaders.some( (leader) => !catalog.some( (native) => native.id === leader.id && native.faction === leader.faction && native.strength === leader.strength, ), ) ) return 'Assassination requires a verified ordinary leader pool; this pool is still being implemented.'; if ( entrant.leaders.some((leader) => leader.capturedBy || leader.gholaBy) || g.players.some( (other) => other.id !== entrant.id && other.leaders.some((leader) => controlsLeader(entrant, leader)), ) ) return 'Assassination with captured or foreign ghola leaders is still being implemented.'; if (!entrant.leaders.some((leader) => !leader.dead)) return 'Assassination with an empty living leader pool needs a verified ruling.'; return null; } function sneakAttackOptions( g: Game, entry: NonNullable, ) { const owner = byFaction(g, 'moritani')!; const maximum = Math.min(5, owner.reserves); let blocked: string | null = null; try { allowedEntry(g, owner, entry.territory, entry.sector); } catch (error) { if (!(error instanceof RuleError)) throw error; blocked = error.message; } const bg = byFaction(g, 'beneGesserit'); if ( !blocked && bg && (bg.reserves > 0 || (g.advanced && fighterCount(bg, entry.territory) > 0)) ) blocked = 'Sneak Attack combined with Bene Gesserit arrival reactions is still being implemented.'; if (!blocked && g.phase === 5 && g.techTokens) blocked = 'Sneak Attack combined with Heighliners technology is still being implemented.'; return { maximum, blocked }; } function terrorAllianceBlocked( g: Game, entry: NonNullable, kind: TerrorKind, ): string | null { if (entry.allianceBlocked) return 'Karama prevented this alliance opportunity; you may still reveal the token or leave it hidden.'; const entrant = getPlayer(g, entry.entrant); const owner = byFaction(g, 'moritani')!; if (entrant.faction === 'ecaz') return 'Enemy of My Enemy cannot be offered to Ecaz.'; if (entrant.id === owner.id || entrant.id === owner.ally) return 'Enemy of My Enemy requires an opposing entrant.'; if (terrorRevealBlocked(g, entry, kind)) return 'This offer is not yet available because its mandatory effect on refusal is still being implemented.'; return null; } /** Return only unspent escrow for the pairs this special alliance breaks. */ function formTerrorAlliance(g: Game, owner: Player, entrant: Player) { const changed = new Set([owner.id, entrant.id]); for (const member of [owner, entrant]) { if (member.ally) getPlayer(g, member.ally).ally = null; member.ally = null; } for (const [donor, credit] of Object.entries(g.aid)) { if (changed.has(donor) || changed.has(credit.recipient)) { getPlayer(g, donor).spice += credit.amount; delete g.aid[donor]; } } for (const [from, to] of Object.entries(g.allianceOffers)) if (changed.has(from) || changed.has(to)) delete g.allianceOffers[from]; owner.ally = entrant.id; entrant.ally = owner.id; owner.allySinceTurn = entrant.allySinceTurn = g.turn; g.ready = []; } function decideTerror(g: Game, p: Player, action: Action) { const entry = g.pendingTerrorEntry; requireRule( entry && entry.turn === g.turn && entry.phase === g.phase && (entry.stage === 'allianceReply' ? p.id === entry.entrant : p.faction === 'moritani'), 'This Terror entry opportunity is no longer current.', ); const token = g.moritaniTerror?.tokens.find((t) => t.id === entry.token); requireRule(token, 'The entry’s Terror token is unavailable.'); const entrant = getPlayer(g, entry.entrant); const owner = byFaction(g, 'moritani')!; const reopen = () => { g.decision = { kind: 'moritaniTerror', player: owner.id, entrant: entrant.id, territory: entry.territory, }; }; if (entry.stage === 'allianceReply') { requireRule( token.status === 'placed' && token.location === entry.territory, 'The offered alliance no longer has a hidden Terror token.', ); requireRule( typeof action.accept === 'boolean', 'Accept or refuse the alliance offer.', ); if (action.accept) { const blocked = terrorAllianceBlocked(g, entry, token.kind); requireRule(!blocked, blocked ?? 'This alliance is unavailable.'); formTerrorAlliance(g, owner, entrant); g.moritaniTerror = returnTerror(g.moritaniTerror!, token.id, random); log( g, `${owner.name} and ${entrant.name} formed an alliance through Enemy of My Enemy; their former alliances ended and the Terror token returned hidden to supply.`, ); finishTerrorEntry(g); } else { log( g, `${entrant.name} refused Enemy of My Enemy; the Terror token must be revealed.`, ); entry.stage = 'offer'; decideTerror(g, owner, { type: 'decision', reveal: true }); } return; } if (entry.stage === 'offer') { requireRule( token.status === 'placed' && token.location === entry.territory, 'The hidden Terror token is no longer in that stronghold.', ); if (action.alliance === true) { const blocked = terrorAllianceBlocked(g, entry, token.kind); requireRule(!blocked, blocked ?? 'This alliance offer is unavailable.'); entry.stage = 'allianceResponse'; g.response = { kind: 'moritaniAlliance', owner: owner.id, passed: [] }; log( g, `${owner.name} declared Enemy of My Enemy toward ${entrant.name}.`, ); return; } if (action.decline === true) { log(g, `${p.name} left the Terror token hidden.`); finishTerrorEntry(g); return; } requireRule( action.reveal === true, 'Reveal the Terror token or leave it hidden.', ); const blocked = terrorRevealBlocked(g, entry, token.kind); requireRule(!blocked, blocked ?? 'This Terror effect is unavailable.'); g.moritaniTerror = revealTerror(g.moritaniTerror!, token.id); log( g, `${p.name} revealed ${TERROR_DEFINITIONS[token.kind].name} in ${territory(entry.territory).name}.`, ); if (token.kind === 'robbery') { entry.stage = 'robbery'; reopen(); } else if (token.kind === 'assassination') { const victim = shuffle( entrant.leaders.filter((leader) => !leader.dead), )[0]; victim.dead = true; victim.deaths++; delete victim.usedAt; p.spice += victim.strength; log( g, `${victim.name} was assassinated; ${p.name} received ${victim.strength} spice.`, ); finishTerrorEntry(g); } else if (token.kind === 'sneakAttack') { entry.stage = 'sneakAttack'; reopen(); } else { const victimCard = shuffle(entrant.hand)[0]; if (victimCard) { const discardedHandSize = entrant.hand.length; const card = discard(g, entrant, victimCard.id); log( g, `${entrant.name} discarded a random Treachery card to Sabotage.`, ); g.pendingTerrorEntry = null; stageTreacheryDiscard( g, 'terror:sabotage', [{ card, discardedBy: entrant.id, publicFace: false }], { kind: 'terrorDiscard', source: 'sabotage', owner: owner.id, entry, discardedHandSize, }, ); } else { continueTerrorDiscard(g, 'sabotage'); } } return; } requireRule( token.status === 'removed', 'Reveal the Terror token before resolving its effect.', ); if (entry.stage === 'sneakAttack') { requireRule( token.kind === 'sneakAttack', 'This is not a Sneak Attack opportunity.', ); const options = sneakAttackOptions(g, entry); const amount = integer( action.amount, 0, options.maximum, 'Sneak Attack forces', ); requireRule( action.sector === undefined || action.sector === entry.sector, 'Sneak Attack must enter the recorded sector.', ); if (amount > 0) { requireRule( !options.blocked, options.blocked ?? 'This arrival is unavailable.', ); p.reserves -= amount; place(p, entry.territory, entry.sector, amount); log( g, `${p.name} sent ${amount} reserves into ${territory(entry.territory).name} through Sneak Attack.`, ); } else log(g, `${p.name} sent no forces through Sneak Attack.`); finishTerrorEntry(g); } else if (entry.stage === 'robbery') { requireRule( token.kind === 'robbery' && (action.choice === 'spice' || action.choice === 'card'), 'Choose stolen spice or the top Treachery card.', ); if (action.choice === 'spice') { const amount = Math.ceil(entrant.spice / 2); entrant.spice -= amount; p.spice += amount; log( g, `${p.name} stole half of ${entrant.name}’s available spice, rounded up.`, ); } else { const card = draw(g); if (card) p.hand.push(card); log( g, card ? `${p.name} drew a Treachery card through Robbery.` : 'No Treachery card remained for Robbery.', ); if (p.hand.length > handLimit(p)) { entry.stage = 'discard'; reopen(); return; } } finishTerrorEntry(g); } else if (entry.stage === 'discard') { requireRule( token.kind === 'robbery' && p.hand.length > handLimit(p), 'There is no excess Robbery card to discard.', ); const discardedHandSize = p.hand.length; const card = discard(g, p, stringField(action.card)); log(g, `${p.name} discarded a card after the Robbery draw.`); g.pendingTerrorEntry = null; stageTreacheryDiscard( g, 'terror:robberyOverflow', [{ card, discardedBy: p.id, publicFace: false }], { kind: 'terrorDiscard', source: 'robberyOverflow', owner: owner.id, entry, discardedHandSize, }, ); } else { requireRule( token.kind === 'sabotage', 'This is not a Sabotage gift opportunity.', ); if (action.decline !== true) { const card = p.hand.find((c) => c.id === action.card); requireRule(card, 'Choose a card from your own hand to give.'); requireRule( entrant.hand.length < handLimit(entrant), 'This recipient’s exceptional hand overflow is still being implemented. You may decline the gift.', ); p.hand = p.hand.filter((c) => c.id !== card.id); entrant.hand.push(card); log(g, `${p.name} gave ${entrant.name} a Treachery card after Sabotage.`); } else log(g, `${p.name} declined the optional Sabotage gift.`); finishTerrorEntry(g); } } function removeGroup( p: Player, group: [string, number][], eliteGroup: Record = {}, ) { for (const [key, n] of group) { if (p.elites) { p.elites.forces[key] = (p.elites.forces[key] ?? 0) - (eliteGroup[key] ?? 0); if (!p.elites.forces[key]) delete p.elites.forces[key]; } p.forces[key] -= n; if (!p.forces[key]) delete p.forces[key]; } } function pathBlocked(g: Game, p: Player, key: string, advisors = false) { const l = splitLocation(key); if (l.sector && l.sector === g.storm) return true; return occupancyRule(() => strongholdPathBlocked(g.players, p.id, l.territory, advisors), ); } function boardResolution(quote: () => T): T { try { return quote(); } catch (error) { if (error instanceof BoardResolutionError) throw new RuleError(error.message); throw error; } } function applyAdvisorReleases(g: Game, released: AdvisorRelease[]) { for (const entry of released) delete getPlayer(g, entry.player).advisors?.[entry.territory]; } export function battles(g: Game) { const quote = boardResolution(() => quoteBattleBoard(g)); applyAdvisorReleases(g, quote.released); return quote.battles; } /** Evaluate only at the committed end of movement, using the normal battle geometry. */ function offerMoritaniDuke(g: Game) { const owner = byFaction(g, 'moritani'); if (!owner || g.dukeAcquisitionTurn === g.turn) return false; g.dukeAcquisitionTurn = g.turn; g.dukeVidal ??= createDukeVidal(); const duke = g.dukeVidal; if (duke.leader.dead || duke.controller === owner.id) return false; if ( duke.leader.capturedBy || duke.leader.gholaBy || (g.advanced && byFaction(g, 'harkonnen')) ) { log( g, 'Duke Vidal acquisition with exceptional capture or ghola custody is still being implemented.', ); return false; } const strongholds = new Set( battles(g) .filter( (battle) => [battle.attacker, battle.defender].includes(owner.id) && territory(battle.territory).type === 'stronghold' && getPlayer( g, battle.attacker === owner.id ? battle.defender : battle.attacker, ).faction !== 'ecaz', ) .map((battle) => battle.territory), ); if (strongholds.size < 2) return false; g.response = { kind: 'moritaniDuke', owner: owner.id, passed: [] }; log( g, `${owner.name} qualifies to acquire Duke Vidal through battles in ${strongholds.size} strongholds.`, ); return true; } type SaphoOption = { scope: 'onceAround' | 'movement'; event: string; mode: 'first' | 'last'; }; function saphoMovementIntegrity(g: Game) { const last = g.saphoMovementLast; if (last === undefined || last === null) return; requireRule( typeof last === 'object' && !Array.isArray(last) && g.status === 'playing' && g.phase === 5 && last.turn === g.turn && last.event === `movement:${g.turn}` && typeof last.player === 'string' && g.order.includes(last.player), 'The saved Juice of Sapho movement scope is invalid.', ); const remaining = g.movementRemaining; requireRule(remaining, 'The saved Juice of Sapho movement queue is missing.'); validateOrderedOpportunity({ event: last.event, eligible: g.order, completed: g.order.filter((id) => !remaining.includes(id)), remaining, current: remaining[0] ?? null, currentStarted: false, protectedLast: last.player, }); } function saphoCleanWindow(g: Game) { return ( g.status === 'playing' && !g.truthtrance && !g.phaseOpening && !g.response && !g.decision && !g.pendingNullentropy && !g.pendingRicheseGift && !g.pendingExchange && !g.pendingKarama && !g.pendingShipment && !g.pendingAmbassador && !g.pendingTerrorEntry && !g.pendingFremenMove && !g.pendingIxMove && !g.pendingChoamMove && !g.richeseAllyOffer && !g.currentAuctionSale ); } function saphoMovementQueue(g: Game): OrderedOpportunity | null { let remaining = g.movementRemaining; if ( g.phase !== 5 || !remaining?.length || !g.active || !remaining.includes(g.active) ) return null; const active = getPlayer(g, g.active); // The Guild can already have claimed the next entire turn without rewriting // the physical queue. Preserve that granted current turn in this local scope. if (g.active !== remaining[0]) { if (!g.advanced || active.faction !== 'guild' || !g.guildTimingGranted) return null; remaining = [g.active, ...remaining.filter((id) => id !== g.active)]; } // A paid/reserved movement preparation also starts the combined turn. const started = active.shipped || active.moved > 0 || g.hajr.includes(active.id) || !!g.karamaShipping || !!g.ornithopter || (g.choamMovement?.turn === g.turn && active.faction === 'choam'); const state: OrderedOpportunity = { event: `movement:${g.turn}`, eligible: g.order, completed: g.order.filter((id) => !remaining.includes(id)), remaining, current: g.active, currentStarted: !!started, protectedLast: g.saphoMovementLast?.player ?? null, }; validateOrderedOpportunity(state); return state; } /** Own custody and public finite scopes only; no opponent hand scan. */ function saphoOptions(g: Game, p: Player): SaphoOption[] { if (!saphoCleanWindow(g)) return []; const card = p.hand.find((c) => c.id === 'richese-juice-of-sapho'); if ( !card || card.effect !== 'juiceOfSapho' || !richeseCardDefinition(card) || p.hand.filter((c) => c.id === card.id).length !== 1 || transferCardBlock(g, p, card) || giftReserved(g, p.id, card.id) ) return []; const options: SaphoOption[] = []; const lot = g.richeseAuction; if ( g.phase === 3 && lot?.method === 'onceAround' && !lot.outcome && g.order.includes(p.id) && p.hand.length <= handLimit(p) && !lot.acted.includes(p.id) ) { const remaining = lot.order.filter((id) => !lot.acted.includes(id)); if (!lot.acted.length && remaining[0] !== p.id) options.push({ scope: 'onceAround', event: lot.event, mode: 'first' }); if (remaining.at(-1) !== p.id) options.push({ scope: 'onceAround', event: lot.event, mode: 'last' }); } const queue = saphoMovementQueue(g); if ( queue && !queue.currentStarted && queue.remaining.includes(p.id) && !g.saphoMovementLast ) { if ( !queue.completed.length && !g.players.some((other) => other.shipped || other.moved > 0) && !(g.advanced && byFaction(g, 'guild')) && queue.current !== p.id ) options.push({ scope: 'movement', event: queue.event, mode: 'first' }); if (queue.remaining.at(-1) !== p.id) options.push({ scope: 'movement', event: queue.event, mode: 'last' }); } return options; } function playSapho(g: Game, p: Player, action: Action) { requireRule( Object.keys(action).every((key) => ['type', 'card', 'scope', 'event', 'mode'].includes(key), ), 'Choose only Juice of Sapho, one supported scope, its event and first or last.', ); const option = saphoOptions(g, p).find( (choice) => choice.event === action.event && choice.scope === action.scope && choice.mode === action.mode, ); requireRule( option, 'This Juice of Sapho opportunity is unavailable, already completed or expired.', ); if (option.scope === 'onceAround') { const lot = g.richeseAuction!; // Discarding this card frees a slot even when a full hand excluded its holder // at opening. Only this still-open finite lot gains that unacted bidder. if (!lot.eligible.includes(p.id)) { lot.eligible.push(p.id); lot.order.push(p.id); lot.tieOrder = g.order.filter((id) => lot.eligible.includes(id)); } g.richeseAuction = reorderRicheseBidder( lot, option.event, p.id, option.mode, ); g.active = g.richeseAuction.active; discard(g, p, 'richese-juice-of-sapho'); log( g, `${p.name} discarded Juice of Sapho to bid ${option.mode} in this Once Around auction. Completed bids and their funding remain committed; no bidder receives another bid.`, { faction: p.faction, name: 'Juice of Sapho' }, ); settleRicheseLot(g); } else { const queue = saphoMovementQueue(g)!; const next = option.mode === 'last' ? protectOrderedOpportunityLast(queue, { event: option.event, player: p.id, }) : reorderOrderedOpportunity(queue, { event: option.event, player: p.id, position: 'first', }); g.movementRemaining = [...next.remaining]; if (option.mode === 'last') g.saphoMovementLast = { event: option.event, turn: g.turn, player: p.id }; discard(g, p, 'richese-juice-of-sapho'); log( g, `${p.name} discarded Juice of Sapho to take the ${option.mode} remaining combined shipment and movement turn${option.mode === 'last' ? ', including after the Spacing Guild' : ''}. Completed turns and physical storm order are unchanged.`, { faction: p.faction, name: 'Juice of Sapho' }, ); // Preserve the Guild decision already settled for an unchanged current actor. if (g.active !== g.movementRemaining[0]) movementTurn(g); } } function movementTurn(g: Game) { const remaining = g.movementRemaining!; if (!remaining.length) { if (offerMoritaniDuke(g)) return; nextPhase(g); return; } const guild = byFaction(g, 'guild'); if ( g.advanced && guild && !g.guildTimingLocked && remaining.includes(guild.id) && guild.id !== g.saphoMovementLast?.player && remaining.some( (id) => id !== guild.id && id !== g.saphoMovementLast?.player, ) ) { g.active = null; g.decision = { kind: 'guildTiming', player: guild.id, next: remaining[0], following: remaining.find((id) => id !== guild.id)!, }; } else g.active = remaining[0]; } function chooseGuildTiming(g: Game, take: boolean) { const guild = byFaction(g, 'guild')!; if (take) g.active = guild.id; else { const rest = g.movementRemaining!.filter((id) => id !== guild.id); const protectedLast = g.saphoMovementLast?.player; rest.splice( protectedLast ? rest.indexOf(protectedLast) : rest.length, 0, guild.id, ); g.movementRemaining = rest; g.active = g.movementRemaining[0]; } } function movementPhaseQuote(calculate: () => T): T { try { return calculate(); } catch (error) { if (error instanceof MovementPhaseQuoteError) throw new RuleError(error.message); throw error; } } function advisorBattleOptions(g: Game) { const quote = movementPhaseQuote(() => quoteAdvisorBattleOffer(g)); applyAdvisorReleases(g, quote.released); return quote.territories; } function commitMovementPhaseStart(g: Game, quote: MovementPhaseQuote) { if (quote.kind === 'opening') { g.phaseOpening = quote.phaseOpening; return; } g.movementRemaining = quote.remaining; g.saphoMovementLast = null; g.guildTimingGranted = false; g.guildTimingLocked = false; g.spicePeekKnown = false; if (quote.spice.owner) { if (quote.spice.refill) refillSpice(g); if (quote.spice.hasCards) g.response = { kind: 'atreidesSpice', owner: quote.spice.owner, passed: [], }; } for (const p of g.players) { p.shipped = false; p.moved = 0; } applyAdvisorReleases(g, quote.released); g.decision = quote.decision; g.active = quote.active; } function beginAdvisorBattle(g: Game, remaining = advisorBattleOptions(g)) { const bg = byFaction(g, 'beneGesserit'); if (bg && remaining.length) g.decision = { kind: 'advisorBattle', player: bg.id, territories: remaining, }; else movementTurn(g); } function finishAdvisorReaction( g: Game, details: { advisorResume?: 'wormRide' | 'declaration' | 'ambassador'; advisorAmbassadorEvent?: string; advisorRemaining?: string[]; advisorFollowup?: { shipment: string; destination: string }; }, ) { const bg = byFaction(g, 'beneGesserit')!; if (details.advisorFollowup && bg.reserves > 0) g.decision = { kind: 'advisor', player: bg.id, ...details.advisorFollowup }; else if (details.advisorResume === 'wormRide') nextWormRide(g); else if (details.advisorResume === 'declaration') beginAdvisorBattle(g, details.advisorRemaining ?? []); else if (details.advisorResume === 'ambassador') continueAmbassadorArrival(g, details.advisorAmbassadorEvent!); } function intrusion( g: Game, mover: Player, t: string, options: { wormRide?: boolean; ambassadorEvent?: string; followup?: { shipment: string; destination: string }; } = {}, ) { const bg = byFaction(g, 'beneGesserit'); if (g.advanced && bg && bg.id !== mover.id && fighterCount(bg, t)) { g.decision = { kind: 'intrusion', player: bg.id, territory: t, ...options }; return true; } return false; } function assignRemainingTech(g: Game) { if (!g.techTokens) return; const unassigned = shuffle( TECH_TOKENS.filter((t) => !g.techTokens![t.id].owner), ); const recipients = g.order.filter( (id) => !ownedTech(g.techTokens, id).length, ); for (const token of unassigned) { const id = recipients.shift(); if (!id) break; g.techTokens[token.id].owner = id; log( g, `${getPlayer(g, id).name} received ${token.name} after the first storm.`, ); } } function techIncome(g: Game, id: TechId, actor: Player) { if (!g.techTokens) return; const rule = TECH_TOKENS.find((t) => t.id === id)!; const token = g.techTokens[id]; if ( g.phase !== rule.phase || actor.faction === rule.except || !token.owner || token.triggeredTurn === g.turn ) return; token.triggeredTurn = g.turn; token.spice = ownedTech(g.techTokens, token.owner).length; log( g, `${rule.name} accrued ${token.spice} spice for ${getPlayer(g, token.owner).name}, payable at phase end.`, ); } function currentPhaseResources(g: Game) { try { return quotePhaseResources(g); } catch (error) { if (error instanceof PhaseResourceError) throw new RuleError(error.message); throw error; } } function commitPhaseResources(g: Game, quote: PhaseResourceQuote) { for (const credit of quote.credits) { const owner = getPlayer(g, credit.player); owner.spice = credit.balance; if (credit.kind === 'tech') log( g, `${owner.name} collected ${credit.amount} spice from ${TECH_TOKENS.find((t) => t.id === credit.token)!.name}.`, ); } for (const id of quote.tokenResets) g.techTokens![id].spice = 0; if (quote.clearAid) g.aid = {}; } function transferTech(g: Game, id: TechId, winner: string) { g.techTokens![id].owner = winner; log( g, `${getPlayer(g, winner).name} took ${TECH_TOKENS.find((t) => t.id === id)!.name} after winning the battle.`, ); } function openChoamMarket(g: Game, resume: ChoamMarket['resume']) { const choam = byFaction(g, 'choam'); if (g.status !== 'playing' || !choam) return false; g.choamMarket = { owner: choam.id, resume, blocked: [] }; g.decision = { kind: 'choamMarket', player: choam.id }; return true; } function resumeChoamMarket(g: Game) { g.decision = { kind: 'choamMarket', player: g.choamMarket!.owner }; } function finishChoamMarket(g: Game) { const resume = g.choamMarket!.resume; g.choamMarket = null; g.decision = null; if (resume === 'storm') advanceAfterStorm(g); else advancePhase(g); } function nextPhase(g: Game) { if (!openChoamMarket(g, 'phase')) advancePhase(g); } function advancePhase(g: Game) { if (g.phase === 6) boardResolution(() => quoteBattlePhaseAdvance(g)); commitPhaseResources(g, currentPhaseResources(g)); g.ready = []; g.active = null; const choam = byFaction(g, 'choam'); if (g.phase === 8 && choam) { g.choamMentatPending = true; g.decision = { kind: 'choamMentat', player: choam.id }; return; } const ecaz = byFaction(g, 'ecaz'); if (g.phase === 4 && ecaz && g.ecazPlacementTurn !== g.turn) { g.ecazAmbassadors ??= createAmbassadors(random); g.decision = { kind: 'ecazPlacement', player: ecaz.id }; return; } completePhase(g); } function ambassadorPlacementQuote( g: Game, owner: Player, token: string, to: string, ) { const destination = TERRITORIES.find((t) => t.id === to); requireRule(g.ecazAmbassadors, 'No Ambassador supply is available.'); try { return placeAmbassador(g.ecazAmbassadors, token, { turn: g.turn, availableSpice: owner.spice, destination: { id: to, stronghold: destination?.type === 'stronghold', allowed: !!destination, inStorm: !destination || destination.sectors.includes(g.storm), }, }); } catch (error) { throw new RuleError( error instanceof Error ? error.message : 'Invalid Ambassador placement.', ); } } function finishEcazPlacement(g: Game) { g.ecazPlacementTurn = g.turn; g.pendingEcazPlacement = null; g.decision = null; completePhase(g); } function completePhase(g: Game) { if (g.phase === 8) settleStrongholdOwnership(g); g.phase++; if (g.phase === 9) { if (g.dukeVidal) g.dukeVidal = expireDuke(g.dukeVidal, g.turn); g.turn++; g.phase = 0; } openPhase(g); } /** Open the same public window for every Ix table, independently of who holds Amal. */ function openPhase(g: Game, initialize = true) { if (g.expansions.includes('ix')) g.phaseOpening = { passed: [], initialize }; else if (initialize) beginPhase(g); } function beginPhase(g: Game) { if (g.phase === 0) g.auditorInsight = null; if (g.phase === 2 && g.choamCharity?.turn !== g.turn) { const choam = byFaction(g, 'choam'); if (choam && charityMultiplier(g) === 0) { g.choamCharity = { turn: g.turn, canceled: false }; log( g, 'Inflation cancels all charity this turn, including CHOAM income.', ); } else if (choam) { g.response = { kind: 'choamCharity', owner: choam.id, passed: [] }; return; } } if (g.phase === 4) { g.revivalRules = newRevivalRules(); g.revivalRequests = {}; g.freeRevival = []; g.emperorExtra = {}; for (const p of g.players) { p.revived = 0; p.freeForcesRevived = 0; p.leaderRevived = false; } } if (g.phase === 3) setAuction(g); if (g.phase === 5) { commitMovementPhaseStart( g, movementPhaseQuote(() => quoteMovementPhaseStart(g)), ); } if (g.phase === 6) { const b = battles(g); if (b.length) g.active = b[0].attacker; else { nextPhase(g); return; } } if (g.phase === 7) { collect(g); } if (g.phase === 8) { if (g.inflation && g.inflation.updatedTurn < g.turn) { if (g.inflation.flipped) { g.inflation = null; log(g, 'The Inflation token was removed from the game.'); } else { g.inflation.side = g.inflation.side === 'double' ? 'cancel' : 'double'; g.inflation.flipped = true; g.inflation.updatedTurn = g.turn; log( g, `Inflation flipped to ${g.inflation.side} for next turn’s charity.`, ); } } for (const p of g.players) { p.spice += p.bribes; p.bribes = 0; } const moritani = byFaction(g, 'moritani'); if ( moritani && g.moritaniTerror && g.moritaniTerror.placementTurn !== g.turn ) { g.decision = { kind: 'moritaniPlacement', player: moritani.id }; } else if (!byFaction(g, 'choam')) victory(g); } if (g.phase === 0 && g.turn > 1) { const ixians = byFaction(g, 'ixians'); if ( g.mobileStronghold?.location && ixians && at(ixians, MOBILE_STRONGHOLD) ) { g.decision = { kind: 'mobileStronghold', player: ixians.id, placement: false, }; return; } beginStormTurn(g); } } /** Transfer actual charity only after its applicable power response. */ function payCharity(g: Game, player: Player, amount: number) { const payer = charityPayer(g); // The published charity rule does not specify insolvency after intervening // spending. Fail atomically while this edge case remains under audit; never // mint a bank subsidy or create a negative spice balance silently. requireRule( !payer || payer.spice >= amount, 'CHOAM cannot fund this charity claim.', ); if (payer) payer.spice -= amount; player.spice += amount; techIncome(g, 'production', player); } function beginStormTurn(g: Game) { if (g.dukeVidal) delete g.dukeVidal.leader.usedAt; for (const p of g.players) if (p.elites) p.elites.revived = 0; g.stormDialers = g.lastBattle; if (g.advanced && byFaction(g, 'fremen')) { g.stormDialers = []; g.stormPending = g.stormCard ?? shuffle([1, 2, 3, 4, 5, 6])[0]; g.stormCard = null; g.stormCardKnown = false; log(g, `Storm card revealed: ${g.stormPending} sectors.`); } for (const p of g.players) { for (const l of p.leaders) { delete l.usedAt; if (l.concealed) delete l.concealed.usedAt; } if (p.kwisatz) delete p.kwisatz.usedAt; } g.hajr = []; g.nexus = false; log(g, `Turn ${g.turn} begins.`); } /** Board identity excludes private balances and cards, which independent effects may change. */ function collectionBoardSignature(g: Game) { return JSON.stringify({ advanced: g.advanced, storm: g.storm, order: g.order, mobile: g.mobileStronghold ?? null, players: g.players.map((p) => ({ id: p.id, faction: p.faction, ally: p.ally, forces: p.forces, advisors: p.advisors ?? null, elites: p.elites?.forces ?? null, marker: p.noField?.deployed?.location ?? null, })), }); } function collectionAllocation(work: () => T): T { try { return work(); } catch (error) { if (error instanceof SpiceAllocationError) throw new RuleError(error.message); throw error; } } function ecazCollectionIntegrity(g: Game) { const pending = g.ecazCollection; if (!pending) return; requireRule( Number.isSafeInteger(pending.turn) && pending.turn >= 1 && pending.turn <= g.turn && typeof pending.event === 'string' && pending.event.length > 0 && typeof pending.canceled === 'boolean' && Array.isArray(pending.settled), 'The saved collection event is invalid.', ); if (pending.stage === 'complete') { requireRule( pending.allocation === null, 'Completed collection cannot retain unresolved shared spice.', ); return; } const continuation = g.pendingTreacheryDiscard?.continuation; const controls: ( | { response?: ResponseWindow | null; decision?: Decision | null; pendingKarama?: Game['pendingKarama']; } | null | undefined )[] = [ g, g.pendingExchange, g.pendingRicheseGift?.resume, g.pendingNullentropy?.resume, g.pendingRichesePurchaseIncome?.resume, g.summonedWorm?.resume, continuation && 'resume' in continuation ? continuation.resume : null, ].filter(Boolean); requireRule( g.status === 'playing' && g.phase === 7 && pending.turn === g.turn && typeof pending.event === 'string' && pending.event.length > 0 && typeof pending.canceled === 'boolean' && ['response', 'allocation'].includes(pending.stage) && pending.boardSignature === collectionBoardSignature(g), 'Shared collection must retain its original turn and collecting forces.', ); const quote = boardResolution(() => quoteSpiceCollection( { ...g, players: g.players.map((p) => ({ ...p, spice: 0 })), spice: pending.sourceSpice, }, pending.canceled, ), ); if (pending.stage === 'response') { requireRule( !pending.canceled && pending.settled.length === 0 && pending.allocation === null && quote.collectionBonus && JSON.stringify(g.spice) === JSON.stringify(pending.sourceSpice), 'Ecaz Collection needs its original uncollected stronghold-income opportunity.', ); requireRule( controls.some((control) => { const karama = control!.pendingKarama; const response = control!.response?.kind === 'worthlessKarama' && karama?.use.kind === 'cancel' ? karama.use.response : control!.response; return ( response?.kind === 'ecazCollection' && response.owner === quote.collectionBonus!.owner ); }), 'The saved Ecaz Collection response has lost its decision owner.', ); } else { const allocation = pending.allocation; requireRule( allocation && JSON.stringify(allocation.lots) === JSON.stringify(quote.shared) && JSON.stringify(g.spice) === JSON.stringify(quote.spice), 'Shared collection escrow no longer matches its original desert deposits.', ); requireRule( controls.some( (control) => control!.decision?.kind === 'ecazSpice' && control!.decision.player === allocation.player, ), 'The saved shared-spice allocation has lost its decision owner.', ); requireRule( pending.settled.length === allocation.index && pending.settled.every((receipt, index) => { const lot = allocation.lots[index]; return ( receipt && lot && receipt.territory === lot.territory && receipt.ecaz === lot.ecaz && receipt.ally === lot.ally && ['agreement', 'equal'].includes(receipt.method) && Number.isSafeInteger(receipt.ecazAmount) && receipt.ecazAmount >= 0 && Number.isSafeInteger(receipt.allyAmount) && receipt.allyAmount >= 0 && receipt.ecazAmount + receipt.allyAmount === lot.amount && (receipt.method !== 'equal' || receipt.ecazAmount === Math.floor(lot.amount / 2)) ); }), 'Shared collection receipts do not match the remaining allocation.', ); collectionAllocation(() => quoteSpiceAllocation(allocation, allocation.player, { kind: 'equal' }), ); } } function currentEcazCollectionQuote( g: Game, response: ResponseWindow, canceled: boolean, ) { ecazCollectionIntegrity({ ...g, response, pendingKarama: null }); const pending = g.ecazCollection; requireRule( response.kind === 'ecazCollection' && pending?.stage === 'response' && pending.turn === g.turn, 'This Ecaz Collection response is no longer pending.', ); const quote = boardResolution(() => quoteSpiceCollection(g, canceled)); requireRule( quote.collectionBonus?.owner === response.owner, 'Ecaz Collection requires the original allied stronghold occupation.', ); return quote; } function commitCollection( g: Game, quote: ReturnType, canceled = false, ) { const sourceSpice = { ...g.spice }; applyAdvisorReleases(g, quote.released); g.spice = quote.spice; for (const receipt of quote.receipts) { const p = getPlayer(g, receipt.player); p.spice = receipt.balance; if (receipt.strongholds) log( g, `${faction(p.faction).name} received ${receipt.strongholds} spice from strongholds.`, ); if (receipt.collected) log( g, `${faction(p.faction).name} collected ${receipt.collected} spice.`, ); } const allocation = collectionAllocation(() => createSpiceAllocation(quote.shared), ); if ( quote.collectionBonus || allocation || g.ecazCollection?.turn === g.turn ) { g.ecazCollection = { turn: g.turn, event: g.ecazCollection?.turn === g.turn ? g.ecazCollection.event : crypto.randomUUID(), stage: allocation ? 'allocation' : 'complete', canceled, allocation, settled: [], sourceSpice, boardSignature: collectionBoardSignature(g), }; } if (quote.collectionBonus && !canceled) log( g, `Ecaz Collection paid both allies their full income in ${quote.collectionBonus.strongholds.map((id) => territory(id).name).join(', ')}.`, { faction: 'ecaz', name: 'Collection' }, ); if (allocation) { g.decision = { kind: 'ecazSpice', player: allocation.player }; log( g, `Ecaz and its ally collected ${quote.shared.reduce((sum, lot) => sum + lot.amount, 0)} shared desert spice. Allocate each territory by agreement, or split it equally with any odd spice going to the ally.`, ); } } function collect(g: Game) { if (g.ecazCollection?.turn === g.turn) { ecazCollectionIntegrity(g); return; } const quote = boardResolution(() => quoteSpiceCollection(g)); if (quote.collectionBonus) { applyAdvisorReleases(g, quote.released); g.ecazCollection = { turn: g.turn, event: crypto.randomUUID(), stage: 'response', canceled: false, allocation: null, settled: [], sourceSpice: { ...g.spice }, boardSignature: collectionBoardSignature(g), }; g.response = { kind: 'ecazCollection', owner: quote.collectionBonus.owner, passed: [], }; return; } commitCollection(g, quote); } function decideSharedSpice(g: Game, id: string, action: Action) { ecazCollectionIntegrity(g); const pending = g.ecazCollection; requireRule( pending?.stage === 'allocation' && pending.allocation && action.event === pending.event, 'Choose the current shared-spice allocation.', ); const result = collectionAllocation(() => quoteSpiceAllocation( pending.allocation!, id, action.allocation as SpiceAllocationAction, ), ); const receipt = result.receipt; if (receipt) { for (const [owner, amount] of [ [receipt.ecaz, receipt.ecazAmount], [receipt.ally, receipt.allyAmount], ] as const) { const p = getPlayer(g, owner); requireRule( Number.isSafeInteger(p.spice) && p.spice >= 0 && Number.isSafeInteger(p.spice + amount) && p.spice + amount >= 0, 'Shared collection would overflow the spice balance.', ); } pending.settled.push(receipt); getPlayer(g, receipt.ecaz).spice += receipt.ecazAmount; getPlayer(g, receipt.ally).spice += receipt.allyAmount; log( g, `${territory(receipt.territory).name} shared collection: Ecaz received ${receipt.ecazAmount} spice and ${faction(getPlayer(g, receipt.ally).faction).name} received ${receipt.allyAmount}, ${receipt.method === 'agreement' ? 'by agreement' : 'using the equal split with any odd spice going to the ally'}.`, { faction: 'ecaz', name: 'Shared collection' }, ); } else log( g, `${getPlayer(g, id).name} proposed a split of the shared spice in ${territory(pending.allocation.lots[pending.allocation.index].territory).name}. The other ally may accept, counter or use the equal split.`, ); if (result.next) { pending.allocation = result.next; g.decision = { kind: 'ecazSpice', player: result.next.player }; } else { pending.stage = 'complete'; pending.allocation = null; g.decision = null; } } function victory(g: Game, quote: VictoryQuote = currentVictoryQuote(g)) { applyAdvisorReleases(g, quote.released); g.winner = quote.winner; g.status = quote.status; if (g.winner.length) { g.status = 'finished'; const occupy = strongholdProgress(g).progress.find( (row) => row.occupyTarget !== null && row.jointlyOccupied.length >= row.occupyTarget && row.members.length === g.winner.length && row.members.every((id) => g.winner.includes(id)), ); log( g, `${g.winner.map((id) => faction(getPlayer(g, id).faction).name).join(' and ')} won the game.${occupy ? ` Ecaz Occupy: both allies occupy ${occupy.jointlyOccupied.map((id) => territory(id).name).join(', ')} without opposing fighters, meeting the three-stronghold alliance target.` : ''}`, ); } else if (g.status === 'finished') log(g, 'The tenth turn ended without a winner.'); if (quote.strongholds) settleStrongholdOwnership(g, quote.strongholds); } function cardOf(p: Player, id: string | null) { return p.hand.find((c) => c.id === id); } function validateResidualPoison( g: Game, p: Player, cardId: string, targetId: string, event: string, ) { const b = g.battle; requireRule( g.status === 'playing' && g.phase === 6 && b && !b.revealed && [b.attacker, b.defender].includes(p.id), 'Use Residual Poison against your opponent in the current battle.', ); requireRule( b.event && b.event === event, 'This battle opportunity is stale or predates the saved card timing.', ); requireRule( targetId === (b.attacker === p.id ? b.defender : b.attacker), 'Residual Poison targets your actual opposing combatant.', ); requireRule( !Object.keys(b.plans).length && !(b.prescience?.field === 'leader' && 'value' in b.prescience), 'Residual Poison must be played before either combatant commits a leader.', ); requireRule( !g.truthtrance && !g.response && !g.decision && !g.phaseOpening, 'Resolve the current interaction before playing Residual Poison.', ); const card = p.hand.find((c) => c.id === cardId); requireRule( card && richeseCardDefinition(card)?.card.effect === 'residualPoison', 'Choose the canonical Residual Poison in your hand.', ); const reserved = transferCardBlock(g, p, card); requireRule(!reserved, reserved ?? 'The card is committed.'); // Guard on public configuration, never on whether a particular hidden captive exists. requireRule( !(g.advanced && byFaction(g, 'harkonnen')), 'Residual Poison with advanced Harkonnen capture awaits the secret-captive disclosure ruling.', ); const target = getPlayer(g, targetId), controlled = controlledLeaders(g, target); requireRule( !controlled.some( (l) => l.concealed || (l.capturedBy && l.gholaBy) || (l.id === DUKE_VIDAL_ID && (l.capturedBy || l.gholaBy)), ), 'This exceptional leader custody is not yet supported for Residual Poison.', ); const candidates = noFieldRule(() => residualPoisonCandidates(controlled, b.territory), ); requireRule( candidates.length > 0, 'The opposing combatant has no available physical leader for this card.', ); for (const candidate of candidates) noFieldRule(() => residualPoisonDeath(candidate)); const hand = p.hand.filter((c) => c.id !== card.id); validateTransferCompletion(g, p, p, hand, hand, { type: 'card', card: card.id, }); return { b, card, target, candidates }; } function playResidualPoison( g: Game, p: Player, cardId: string, targetId: string, event: string, ) { const { b, card, target, candidates } = validateResidualPoison( g, p, cardId, targetId, event, ); const selected = shuffle(candidates)[0]; const victim = controlledLeaders(g, target).find( (l) => l.id === selected.id, )!; const dead = residualPoisonDeath(victim); Object.assign(victim, dead); delete victim.usedAt; if (victim.capturedBy) { delete victim.capturedBy; delete victim.concealed; } if (victim.id === DUKE_VIDAL_ID && g.dukeVidal) g.dukeVidal = consumeDuke(g.dukeVidal); const harkonnen = byFaction(g, 'harkonnen'); if (harkonnen) returnCaptives(g, harkonnen); discard(g, p, card.id); log( g, `${p.name} played Residual Poison against ${target.name}. ${victim.name} was randomly selected from the available leaders and sent to the Tanks. No spice is awarded, no forces are lost, and battle preparation continues.`, { faction: p.faction, name: 'Residual Poison' }, ); // Opposing involuntary death may release an impossible answer; it never rerolls the victim. reconcileBattlePromises(g); const insight = b.prescience; if (insight && 'value' in insight && insight.field !== 'leader') { const answering = getPlayer( g, insight.player === b.attacker ? b.defender : b.attacker, ); if (!feasiblePrescience(g, answering, insight.field, insight.value)) { delete insight.value; b.preparation = { kind: 'prescienceAnswer', owner: answering.id, beneficiary: insight.player, }; log( g, 'The prior inspected element no longer permits a legal battle plan after the leader death. Its owner must answer that same element again; the earlier private value is not revealed.', ); } } if (b.preLeader && !b.preLeader.closed) b.preLeader.ready = b.preLeader.ready.filter((id) => id !== p.id); } function residualPoisonView(g: Game, p: Player) { const card = p.hand.find( (c) => richeseCardDefinition(c)?.card.effect === 'residualPoison', ); if (!card) return null; const b = g.battle, target = b && [b.attacker, b.defender].includes(p.id) ? b.attacker === p.id ? b.defender : b.attacker : null; let blocked: string | null = null; try { validateResidualPoison(g, p, card.id, target ?? '', b?.event ?? ''); } catch (error) { if (!(error instanceof RuleError)) throw error; blocked = error.message; } return { card, event: b?.event ?? null, target, blocked }; } function controlledLeaders(g: Game, p: Player) { const native = g.players.flatMap((owner) => owner.leaders.filter((l) => controlsLeader(p, l)), ); const duke = g.dukeVidal; if ( duke && duke.controller === p.id && !duke.leader.capturedBy && !duke.leader.gholaBy && !(g.advanced && byFaction(g, 'harkonnen')) ) native.push(duke.leader); return native; } function returnCaptives(g: Game, captor: Player) { if (captor.leaders.some((l) => !l.dead)) return; let returned = 0; for (const owner of g.players) for (const l of owner.leaders) if (l.capturedBy === captor.id) { delete l.capturedBy; delete l.concealed; returned++; } if (returned) log( g, `${captor.name} returned ${returned} captive leaders because all native leaders are dead.`, ); } function captureCandidates(g: Game, loser: string, t: string) { return controlledLeaders(g, getPlayer(g, loser)).filter( (l) => !isAuditorLeader(l) && !l.dead && (!l.usedAt || l.usedAt === t), ); } function projectLeader(g: Game, l: Leader, viewer: string): Leader { const { concealed, capturedBy, ...visible } = l; const owner = g.players.find((p) => p.faction === l.faction); if ( concealed && ![concealed.controller ?? owner?.id, concealed.captor].includes(viewer) && !(getPlayer(g, viewer).faction === 'tleilaxu' && l.dead && !l.capturedBy) ) { const result = { ...visible, dead: concealed.dead, deaths: concealed.deaths, }; delete result.usedAt; if (concealed.usedAt) result.usedAt = concealed.usedAt; return result; } return { ...visible, ...(capturedBy ? { capturedBy } : {}) }; } /** Public alternatives deliberately never read a foreign No-Field token value. */ function stonePublicPools( g: Game, p: Player, opponent: Player, ): CombatForces[] { const t = g.battle!.territory; const hiddenMarker = p.noField?.deployed?.location.territory === t; const pool = combatForces( g, hiddenMarker ? { ...p, noField: undefined } : p, t, opponent, ); return hiddenMarker ? Array.from({ length: 6 }, (_, n) => ({ ...pool, normal: pool.normal + n, })) : [pool]; } function stoneTimingBlock(g: Game): string | null { return g.expansions.includes('ix') ? 'Stone Burner with the Ix expansion is awaiting a ruling on its simultaneous revealed choice with Poison Tooth. This implementation boundary applies before either hidden plan is submitted.' : null; } function stonePlanBlock( g: Game, p: Player, dial: number, support: number, ): string | null { const b = g.battle!; const opponent = getPlayer(g, b.attacker === p.id ? b.defender : b.attacker); return ( stoneTimingBlock(g) ?? stonePublicPools(g, opponent, p) .map((pool) => stoneBurnerPlanBlock( combatForces(g, p, b.territory, opponent), dial, support, pool, b.attacker === p.id ? 'attacker' : 'defender', battleTieWinner(g) === b.attacker ? 'attacker' : 'defender', ), ) .find(Boolean) ?? null ); } function stoneCompulsionBlock(g: Game, target: Player): string | null { const b = g.battle!; const other = getPlayer( g, b.attacker === target.id ? b.defender : b.attacker, ); const timing = stoneTimingBlock(g); if (timing) return timing; const opponents = stonePublicPools(g, other, target); for (const own of stonePublicPools(g, target, other)) { const supported = Array.from( { length: (own.normal + own.elite * own.eliteStrength) * 2 + 1 }, (_, n) => n / 2, ).some( (dial) => casualtyOptions(own, dial, 0).length && opponents.every( (opponent) => !stoneBurnerPlanBlock( own, dial, 0, opponent, b.attacker === target.id ? 'attacker' : 'defender', battleTieWinner(g) === b.attacker ? 'attacker' : 'defender', ), ), ); if (!supported) return 'Compelling Stone Burner cannot guarantee a supported zero-spice plan across the public force possibilities; the combined allocation timing is unresolved.'; } return null; } function validatePlan( g: Game, p: Player, input: Record, promises = g.battle?.truthPromises ?? [], ): Plan { const b = g.battle!; const opponent = getPlayer(g, b.attacker === p.id ? b.defender : b.attacker); const forces = combatForces(g, p, b.territory, opponent); const typedForces = g.advanced || p.faction === 'ixians'; const dial = typedForces ? Number(input.dial) : integer(input.dial, 0, forces.normal + forces.elite, 'Forces dialed'); const support = g.advanced ? integer(input.support ?? 0, 0, battleSupportBudget(g, p), 'Spice support') : 0; requireRule( !typedForces || (typeof input.dial === 'number' && casualtyOptions(forces, dial, support).length > 0), 'Dial and spice must match a legal force commitment.', ); const allyPayment = integer( input.allyPayment ?? Math.max(0, battleSupportCost(g, p, support) - p.spice), Math.max(0, battleSupportCost(g, p, support) - p.spice), Math.min(battleSupportCost(g, p, support), battleAidFor(g, p)?.amount ?? 0), 'Battle ally payment', ); const plan: Plan = { ...(allyPayment ? { allyPayment } : {}), dial, leader: typeof input.leader === 'string' && input.leader ? input.leader : null, weapon: typeof input.weapon === 'string' && input.weapon ? input.weapon : null, defense: typeof input.defense === 'string' && input.defense ? input.defense : null, support, }; requireRule( input.kwisatz === undefined || typeof input.kwisatz === 'boolean', 'Choose whether to use the Kwisatz Haderach.', ); if (input.kwisatz) { requireRule( g.advanced && p.faction === 'atreides' && p.battleLosses >= 7 && !p.kwisatz?.dead && (!p.kwisatz?.usedAt || p.kwisatz.usedAt === b.territory) && !b.kwisatzBlocked, 'Kwisatz Haderach is not available in this battle.', ); requireRule( plan.leader, 'Kwisatz Haderach must accompany a leader or Cheap Hero.', ); plan.kwisatz = true; } const available = controlledLeaders(g, p).filter( (l) => !l.dead && (!l.usedAt || l.usedAt === b.territory), ); const l = available.find((l) => l.id === plan.leader); const hero = cardOf(p, plan.leader)?.kind === 'hero'; requireRule( !plan.leader || l || hero, 'That leader is not available for this battle.', ); requireRule( plan.leader || (!available.length && (!p.hand.some((c) => c.kind === 'hero') || (b.voice?.target === p.id && !b.voice.must && b.voice.kind === 'hero'))), 'You must use an available leader or cheap hero.', ); const w = cardOf(p, plan.weapon), d = cardOf(p, plan.defense); requireRule( !plan.weapon || (w && isWeaponCard(w)), 'Choose a weapon or worthless card.', ); requireRule( !plan.defense || (d && isDefenseCard(d)), 'Choose a defense or worthless card.', ); requireRule( !plan.weapon || plan.weapon !== plan.defense, 'One card cannot fill both slots.', ); requireRule( plan.leader || (!plan.weapon && !plan.defense), 'Without a leader, no battle cards can be played.', ); requireRule( validBattleCardPair(w, d), 'Chemistry as a weapon needs another defense; Weirding Way as a defense needs another weapon.', ); if (isStoneBurner(w)) { const blocked = stonePlanBlock(g, p, dial, support); requireRule( !blocked, blocked ?? 'Stone Burner cannot resolve this commitment.', ); } if (b.voice?.target === p.id) { const v = b.voice; const canPlayCards = available.length > 0 || p.hand.some((c) => c.kind === 'hero'); const has = canPlayCards && p.hand.some((c) => defaultVoiceMatch(c, v.kind)); const used = playedVoiceMatch(cardOf(p, plan.leader), 'leader', v.kind) || playedVoiceMatch(w, 'weapon', v.kind) || playedVoiceMatch(d, 'defense', v.kind); requireRule( v.must ? !has || used : !used, 'Your battle plan must comply with the Voice.', ); } const prescience = b.prescience; if (prescience && prescience.player !== p.id && 'value' in prescience) requireRule( plan[prescience.field] === prescience.value, 'The element revealed to Atreides must remain unchanged.', ); requireRule( respectsBattlePromises( promises, p.id, { ...plan, kwisatz: !!plan.kwisatz }, p.hand, ), 'Your battle plan must honor your Truthtrance answers.', ); return plan; } function currentCombatResponseQuote( g: Game, operation: CombatResponseOperation, ) { try { return quoteCombatResponse( { status: g.status, phase: g.phase, advanced: g.advanced, territoryIds: gameTerritories(g).map((t) => t.id), players: g.players.map((p) => ({ id: p.id, faction: p.faction, ally: p.ally, specialKaramaUsed: p.specialKaramaUsed, })), battle: g.battle, }, operation, ); } catch (error) { if (error instanceof CombatResponseQuoteError) throw new RuleError(error.message); throw error; } } function commitCombatResponseQuote(g: Game, quote: CombatResponseQuote) { const b = g.battle!; if (quote.powerChecks !== undefined) b.powerChecks = quote.powerChecks; const patch = quote.patches; if (patch.eliteBlocked !== undefined) b.eliteBlocked = patch.eliteBlocked; if (patch.fremenSupportBlocked !== undefined) b.fremenSupportBlocked = patch.fremenSupportBlocked; if (patch.kwisatzBlocked !== undefined) b.kwisatzBlocked = patch.kwisatzBlocked; if (patch.choamAidBlocked !== undefined) b.choamAidBlocked = patch.choamAidBlocked; if (patch.deleteVoice) delete b.voice; if (patch.deletePrescience) delete b.prescience; if (patch.deletePreparation) delete b.preparation; if (patch.fullPlanOffered) b.fullPlanOffered = true; if (quote.response) g.response = quote.response; if (quote.decision) g.decision = quote.decision; } function nextCombatResponse(g: Game) { commitCombatResponseQuote(g, currentCombatResponseQuote(g, { kind: 'next' })); } function combatResponses(g: Game) { const b = g.battle!; b.powerChecks = []; for (const id of [b.attacker, b.defender]) { const p = getPlayer(g, id), other = getPlayer(g, id === b.attacker ? b.defender : b.attacker); if (g.advanced && p.faction === 'atreides') b.powerChecks.push({ kind: 'kwisatz', owner: id }); if ( ((g.advanced && ['emperor', 'fremen'].includes(p.faction)) || (!g.advanced && p.faction === 'ixians')) && !(p.faction === 'emperor' && other.faction === 'fremen') && combatForces(g, p, b.territory, other).elite > 0 ) b.powerChecks.push({ kind: 'eliteStrength', owner: id }); if (g.advanced && p.faction === 'fremen') b.powerChecks.push({ kind: 'fremenSupport', owner: id }); } const choam = byFaction(g, 'choam'); if ( g.advanced && choam?.ally && [b.attacker, b.defender].includes(choam.ally) && getPlayer(g, choam.ally).ally === choam.id ) b.powerChecks.push({ kind: 'choamBattleAid', owner: choam.id }); nextCombatResponse(g); } function finishBattlePreparation(g: Game) { commitCombatResponseQuote( g, currentCombatResponseQuote(g, { kind: 'finishPreparation' }), ); } function battlePreparation(g: Game, kind: 'voice' | 'prescience') { const b = g.battle!; const owner = byFaction(g, kind === 'voice' ? 'beneGesserit' : 'atreides'); const combatants = [b.attacker, b.defender]; const beneficiary = owner && (combatants.includes(owner.id) ? owner.id : owner.ally && combatants.includes(owner.ally) ? owner.ally : null); if (owner && beneficiary) b.preparation = { kind, owner: owner.id, beneficiary }; else if (kind === 'voice') battlePreparation(g, 'prescience'); else finishBattlePreparation(g); } function normalizeBattle(g: Game) { const b = g.battle; if (!b || b.prepared || b.revealed) return; b.prepared = true; if (b.prescience && !('value' in b.prescience)) { const target = b.prescience.player === b.attacker ? b.defender : b.attacker; const priorPlan = b.plans[target]; if (priorPlan) b.prescience.value = priorPlan[b.prescience.field]; else b.preparation = { kind: 'prescienceAnswer', owner: target, beneficiary: b.prescience.player, }; } else if (!Object.keys(b.plans).length) battlePreparation(g, b.voice ? 'prescience' : 'voice'); } /** Ghola follows current control for ordinary leaders, but Duke's printed * revival right belongs exclusively to Ecaz, regardless of temporary custody. */ function gholaLeaders(g: Game, p: Player) { return controlledLeaders(g, p).filter( (l) => l.dead && !l.capturedBy && (l.id !== DUKE_VIDAL_ID || (p.faction === 'ecaz' && !ecazDukeRevivalBlock(g, p.id))), ); } function gholaOptions(g: Game, p: Player) { const cards = p.hand .filter( (c) => c.effect === 'ghola' && !( g.richeseAuction?.source === 'blackMarket' && !g.currentAuctionSale && g.richeseAuction.owner === p.id && g.richeseAuction.cardId === c.id ), ) .map((c) => c.id); const card = p.hand.find((c) => cards.includes(c.id)); const timing = card ? ordinaryCardAvailability( { ...g, me: p.id, players: [p], }, card.id, ) : null; const leaders = gholaLeaders(g, p).map(({ id, name, strength }) => ({ id, name, strength, })); const kwisatz = !!(g.advanced && p.faction === 'atreides' && p.kwisatz?.dead); const eliteRemaining = Math.min( p.elites?.tanks ?? 0, eliteRevivalRemaining(p), ); const maxForces = Math.min( 5, p.tanks - (p.elites?.tanks ?? 0) + eliteRemaining, ); const reason = !card ? 'No Ghola card is available in your hand.' : g.pendingTreacheryDiscard ? 'Finish the current card disposal first.' : g.pendingNullentropy ? 'Finish the paid Nullentropy Box search first.' : timing && !timing.available ? timing.reason : !leaders.length && !kwisatz && maxForces <= 0 ? 'No eligible leader or force is available in your Tanks.' : null; return { cards, leaders, kwisatz, maxForces, eliteRemaining, available: reason === null, reason, }; } function applyGholaEffect(g: Game, p: Player, action: Action) { if (action.leader === 'kwisatz') { requireRule( g.advanced && p.faction === 'atreides' && p.kwisatz?.dead, 'Kwisatz Haderach is not in the tanks.', ); p.kwisatz.dead = false; delete p.kwisatz.usedAt; p.kwisatz.revivalCycle = Math.max(p.revivalCycle, p.kwisatz.revivalCycle ?? 1) + 1; log( g, `${p.name} revived Kwisatz Haderach with Ghola for use again this turn. No spice or normal leader-revival allowance was spent.`, ); } else if (action.leader) { requireRule( action.leader !== DUKE_VIDAL_ID || p.faction === 'ecaz', 'Only Ecaz may revive Duke Vidal, including with Ghola.', ); const l = gholaLeaders(g, p).find((l) => l.id === action.leader); requireRule(l?.dead && !l.capturedBy, 'Choose a dead leader in your pool.'); l.dead = false; delete l.concealed; // GF9 November 2020 FAQ p.9 permits a Ghola return in another battle // this turn. Death history remains; the earlier battle location does not. delete l.usedAt; log( g, `${p.name} revived ${l.name} with Ghola. The leader may fight again this turn; no spice or normal leader-revival allowance was spent.`, ); } else { const n = integer( action.amount ?? Math.min(5, p.tanks), 1, Math.min(5, p.tanks), 'Forces', ); const elite = eliteChoice(n, p.tanks, p.elites?.tanks ?? 0, action.elite); requireRule( elite <= eliteRevivalRemaining(p), 'Only one elite force may be revived per turn.', ); p.tanks -= n; p.reserves += n; if (p.elites) { p.elites.tanks -= elite; p.elites.reserves += elite; p.elites.revived += elite; } log( g, `${p.name} revived ${n} forces${elite ? `, including ${elite} elite force${elite === 1 ? '' : 's'}` : ''} with Ghola. They returned to reserves for free without using the normal force-revival allowance.`, ); } techIncome(g, 'axlotl', p); collectRevivalIncome(g, p, 0, false, true); } /** Search complete legal plans without learning or consulting the opponent's sealed choices. */ function findLegalBattlePlan( g: Game, p: Player, options: { promises?: BattlePromise[]; prescience?: { field: PlanField; value: unknown }; } = {}, ): Plan | null { const b = g.battle!; const promises = options.promises ?? b.truthPromises ?? []; const fixed = options.prescience; const accepts = (plan: Partial) => respectsBattlePromises(promises, p.id, plan, p.hand) && (!fixed || plan[fixed.field] === undefined || plan[fixed.field] === fixed.value); if (b.plans[p.id]) { const sealed = { ...b.plans[p.id], kwisatz: !!b.plans[p.id].kwisatz }; return accepts(sealed) ? sealed : null; } const opponent = getPlayer(g, b.attacker === p.id ? b.defender : b.attacker); const forces = combatForces(g, p, b.territory, opponent); const typed = g.advanced || p.faction === 'ixians'; const maxDial = typed ? forces.normal * (forces.normalFixedHalf ? 0.5 : 1) + forces.elite * forces.eliteStrength : forces.normal + forces.elite; const numerical: { dial: number; support: number }[] = []; for (let dial = 0; dial <= maxDial; dial += typed ? 0.5 : 1) for ( let support = 0; support <= (g.advanced && !forces.freeSupport ? Math.min(battleSupportBudget(g, p), forces.normal + forces.elite) : 0); support++ ) { const n = { dial, support }; if ( accepts(n) && (!typed || casualtyOptions(forces, dial, support).length) ) numerical.push(n); } if (!numerical.length) return null; const leaderIds = [ null, ...controlledLeaders(g, p) .filter((l) => !l.dead && (!l.usedAt || l.usedAt === b.territory)) .map((l) => l.id), ...p.hand.filter((c) => c.kind === 'hero').map((c) => c.id), ]; const weapons = [null, ...p.hand.filter(isWeaponCard).map((c) => c.id)]; const defenses = [ null, ...p.hand.filter((c) => isDefenseCard(c)).map((c) => c.id), ]; for (const leader of leaderIds) { if (!accepts({ leader })) continue; for (const weapon of weapons) { if (!accepts({ leader, weapon })) continue; for (const defense of defenses) { if ( !accepts({ leader, weapon, defense }) || !validBattleCardPair(cardOf(p, weapon), cardOf(p, defense)) ) continue; for (const kwisatz of [false, true]) { const partial = { leader, weapon, defense, kwisatz }; if (!accepts(partial)) continue; for (const n of numerical) { const candidate = { ...partial, ...n }; if (!accepts(candidate)) continue; try { return validatePlan(g, p, candidate, promises); } catch (error) { if (!(error instanceof RuleError)) throw error; } } } } } } return null; } type BattleCompletion = { plan: Plan; actions: Action[]; waitingForIncome: boolean; }; function gholaPreparationActions(g: Game, p: Player): Action[] { const card = p.hand.find((c) => c.effect === 'ghola'); if (!card) return []; const actions: Action[] = gholaLeaders(g, p) .sort((a, b) => b.strength - a.strength) .map((l) => ({ type: 'card', card: card.id, leader: l.id })); if (g.advanced && p.faction === 'atreides' && p.kwisatz?.dead) actions.push({ type: 'card', card: card.id, leader: 'kwisatz' }); for (let amount = 1; amount <= Math.min(5, p.tanks); amount++) for ( let elite = Math.max(0, amount - (p.tanks - (p.elites?.tanks ?? 0))); elite <= Math.min(amount, p.elites?.tanks ?? 0, eliteRevivalRemaining(p)); elite++ ) actions.push({ type: 'card', card: card.id, amount, elite }); return actions; } function cashInPreparationActions(g: Game, p: Player): Action[] { if (p.faction !== 'choam' || !g.advanced || p.specialKaramaUsed) return []; const available = cashInCards(g, p); const actions: Action[] = []; for (const card of available.filter((c) => c.effect === 'karama')) { const others = available.filter((c) => c.id !== card.id); for (let mask = 1; mask < 2 ** others.length; mask++) actions.push({ type: 'card', mode: 'special', card: card.id, cards: others.filter((_, i) => mask & (2 ** i)).map((c) => c.id), }); } return actions; } /** Pending income can still be allowed; it is never counted as already paid. */ function pendingBattleRevivalIncome(g: Game, p: Player): number { const r = g.response?.kind === 'revivalIncome' ? g.response : g.pendingKarama?.use.kind === 'cancel' ? g.pendingKarama.use.response : null; return r?.kind === 'revivalIncome' && r.owner === p.id ? (r.amount ?? 0) : 0; } function findReachableBattlePlan( g: Game, p: Player, options: Parameters[2] = {}, ): BattleCompletion | null { const immediate = findLegalBattlePlan(g, p, options); if (immediate) return { plan: immediate, actions: [], waitingForIncome: false }; if (g.battle!.revealed || g.battle!.plans[p.id]) return null; const initial = structuredClone(g); const player = getPlayer(initial, p.id); const pending = pendingBattleRevivalIncome(initial, player); if (pending) player.spice += pending; // The real table must finish its current window before it can execute a preparation action. initial.response = null; initial.pendingKarama = null; // Search after Truthtrance resolves, retaining every existing or proposed // battle promise. This does not release a promise on the real table. initial.truthtrance = null; const queue: { state: Game; actions: Action[] }[] = [ { state: initial, actions: [] }, ]; for (let index = 0; index < queue.length; index++) { const node = queue[index], actor = getPlayer(node.state, p.id); if (node.actions.length || pending) { const plan = findLegalBattlePlan(node.state, actor, options); if (plan) return { plan, actions: node.actions, waitingForIncome: pending > 0 }; } for (const action of [ ...gholaPreparationActions(node.state, actor), ...cashInPreparationActions(node.state, actor), ]) { const trial = structuredClone(node.state), target = getPlayer(trial, p.id); try { if (action.mode === 'special') specialKarama(trial, target, action); else { applyGholaEffect(trial, target, action); discard(trial, target, action.card as string); } // Cancellation remains a real response later; a possible allowed branch proves reachability. target.spice += pendingBattleRevivalIncome(trial, target); trial.response = null; trial.pendingKarama = null; queue.push({ state: trial, actions: [...node.actions, action] }); } catch (error) { if (!(error instanceof RuleError)) throw error; } } } return null; } function shipmentPromiseIntegrity(g: Game) { try { validateSavedShipmentQuestion(g); } catch (error) { if (error instanceof TruthError) throw new RuleError(error.message); throw error; } const promises = g.shipmentPromises; requireRule( promises === undefined || Array.isArray(promises), 'The saved shipment promises are invalid.', ); for (const promise of promises ?? []) { requireRule( promise && typeof promise === 'object' && Number.isSafeInteger(promise.turn) && promise.turn >= 1 && promise.turn <= g.turn && g.players.some((p) => p.id === promise.player) && g.players.some( (p) => p.id === promise.asker && p.id !== promise.player, ) && TERRITORIES.some((t) => t.id === promise.territory) && Number.isSafeInteger(promise.minimum) && promise.minimum >= 1 && promise.minimum <= 20 && typeof promise.answer === 'boolean' && (promise.released === undefined || typeof promise.released === 'boolean') && (promise.fulfilled === undefined || typeof promise.fulfilled === 'boolean') && !(promise.released && promise.fulfilled), 'The saved shipment promise is invalid; restore its table state before continuing.', ); if (promise.turn === g.turn && !promise.released && !promise.fulfilled) requireRule( !g.advanced && !g.expansions.length && g.status === 'playing' && g.phase === 5 && g.active === promise.player && !getPlayer(g, promise.player).shipped, 'The saved shipment promise does not belong to this unused Basic shipment opportunity.', ); } } /** Search owned preparation and canonical shipment quotes, never the AI's scored shortlist. */ function findShipmentCompletion( state: Game, owner: Player, promises = liveShipmentPromises( state.shipmentPromises ?? [], owner.id, state.turn, ), ): { actions: Action[] } | null { if ( state.status !== 'playing' || state.phase !== 5 || state.active !== owner.id || owner.shipped ) return null; if (!promises.some((p) => p.answer)) return { actions: [{ type: 'endMovement' }] }; const initial = structuredClone(state); initial.truthtrance = null; // Test choices after the interrupt, while leaving the real continuation intact. // Every actual action still revalidates its timing, quote and physical custody. initial.response = null; initial.decision = null; initial.phaseOpening = null; initial.shipmentPromises = []; const queue: { game: Game; actions: Action[] }[] = [ { game: initial, actions: [] }, ]; const visited = new Set(); for (let index = 0; index < queue.length; index++) { const { game: g, actions } = queue[index]; const p = getPlayer(g, owner.id); const signature = JSON.stringify([ p.spice, p.reserves, p.tanks, p.hand.map((c) => c.id), g.aid, g.karamaShipping, ]); if (visited.has(signature)) continue; visited.add(signature); const destination = promises.find((p) => p.answer)!.territory; for (let amount = 1; amount <= p.reserves; amount++) { if ( !promises.every( (p) => matchesShipment(p, { territory: destination, amount }) === p.answer, ) ) continue; for (const sector of territory(destination).sectors) { const cost = reserveShipmentCost( { faction: p.faction, halfRate: p.faction === 'guild' || byFaction(g, 'guild')?.id === p.ally || g.karamaShipping?.player === p.id, }, territory(destination).type, amount, ); try { const allyPayment = contribution(g, p, cost); const elite = eliteChoice( amount, p.reserves, p.elites?.reserves ?? 0, undefined, ); validatePhysicalShipment(g, { turn: g.turn, player: p.id, territory: destination, sector, amount, elite, cost, allyPayment, advisors: arrivalAsAdvisor(g, p, destination), }); return { actions: [ ...actions, { type: 'ship', territory: destination, sector, amount, elite, allyPayment, }, ], }; } catch (error) { if (!(error instanceof RuleError)) throw error; } if (p.faction === 'fremen' && byFaction(g, 'guild')?.id === p.ally) { const action: Action = { type: 'guildShip', from: 'reserves', territory: destination, sector, amount, }; try { applyActionInner(g, p.id, action); return { actions: [...actions, action] }; } catch (error) { if (!(error instanceof RuleError)) throw error; } } } } // These Basic preparations are deterministic and use only the respondent's // own cards and recoverable escrow. More reserves/lower cost cannot remove // a previously available choice of physical shipment count. const preparations: Action[] = []; if (g.aid[p.id]?.amount > 0) preparations.push({ type: 'pledgeAid', amount: 0 }); const karama = p.hand.find((c) => canUseAsKarama(g.advanced, p.faction, c)); if (karama && !g.karamaShipping) preparations.push({ type: 'card', card: karama.id, mode: 'shipment', target: p.id, }); const ghola = p.hand.find((c) => c.effect === 'ghola'); if (ghola && p.tanks > 0) preparations.push({ type: 'card', card: ghola.id, amount: Math.min(5, p.tanks), }); for (const action of preparations) { try { const next = applyActionInner(g, p.id, action, 'shipmentPreparation'); queue.push({ game: next, actions: [...actions, action] }); } catch (error) { if (!(error instanceof RuleError)) throw error; } } } return null; } function shipmentTruthAnswers( g: Game, p: Player, claim: ShipmentClaim, ): TruthAnswer[] { const prior = liveShipmentPromises(g.shipmentPromises ?? [], p.id, g.turn); const answers: TruthAnswer[] = []; for (const answer of [true, false]) { const promises = [ ...prior, { ...claim, turn: g.turn, player: p.id, asker: '', answer }, ]; if (findShipmentCompletion(g, p, promises)) answers.push(answer ? 'yes' : 'no'); } return answers.length ? answers : ['unknown']; } function bindShipmentTruth( g: Game, p: Player, claim: ShipmentClaim, answer: boolean, asker: string, ) { (g.shipmentPromises ??= []).push({ turn: g.turn, player: p.id, asker, territory: claim.territory, minimum: claim.minimum, answer, }); } function checkShipmentPromises( g: Game, p: Player, shipment: { territory: string; amount: number } | null, ) { requireRule( liveShipmentPromises(g.shipmentPromises ?? [], p.id, g.turn).every( (promise) => matchesShipment(promise, shipment) === promise.answer, ), 'Honor your Truthtrance shipment answer before using this shipment opportunity.', ); } function finishShipmentPromises( g: Game, p: Player, shipment: { territory: string; amount: number } | null, ) { checkShipmentPromises(g, p, shipment); for (const promise of liveShipmentPromises( g.shipmentPromises ?? [], p.id, g.turn, )) promise.fulfilled = true; } function reconcileShipmentPromises( g: Game, voluntary?: { actor: string; action: Action }, ) { const kept: ShipmentPromise[] = []; for (const promise of g.shipmentPromises ?? []) { if (promise.turn !== g.turn || promise.released || promise.fulfilled) continue; const p = getPlayer(g, promise.player); if ( findShipmentCompletion(g, p, [ ...kept.filter((old) => old.player === p.id), promise, ]) ) kept.push(promise); else { requireRule( !voluntary || voluntary.actor !== p.id || !['card', 'bribe', 'pledgeAid'].includes(voluntary.action.type), 'You cannot voluntarily make your Truthtrance shipment answer impossible.', ); promise.released = true; log( g, `${p.name} can no longer fulfill a Truthtrance shipment answer; that answer is no longer binding.`, ); } } } function battleTruthAnswers( g: Game, p: Player, claim: PlanClaim, ): TruthAnswer[] { const promises = g.battle?.truthPromises ?? []; const answers: TruthAnswer[] = []; for (const answer of [true, false]) if ( findReachableBattlePlan(g, p, { promises: [...promises, { player: p.id, asker: '', claim, answer }], }) ) answers.push(answer ? 'yes' : 'no'); return answers.length ? answers : ['unknown']; } function bindBattleTruth( g: Game, p: Player, claim: PlanClaim, answer: boolean, asker: string, ) { if (g.battle && !g.battle.revealed && !g.battle.plans[p.id]) (g.battle.truthPromises ??= []).push({ player: p.id, asker, claim, answer, }); } function reconcileBattlePromises( g: Game, voluntary?: { actor: string; action: Action }, ) { const b = g.battle; if ( !b || b.revealed || !b.truthPromises?.length || g.response?.kind === 'voice' ) return; const kept: BattlePromise[] = []; for (const promise of b.truthPromises) { if (promise.released || b.plans[promise.player]) { kept.push(promise); continue; } const p = getPlayer(g, promise.player); if (findReachableBattlePlan(g, p, { promises: [...kept, promise] })) kept.push(promise); else { requireRule( !voluntary || p.id !== voluntary.actor || !['card', 'bribe', 'pledgeAid', 'richeseGift'].includes( voluntary.action.type, ), 'You cannot voluntarily make your Truthtrance battle promise impossible.', ); promise.released = true; log( g, `${p.name} can no longer fulfill a Truthtrance battle answer; that answer is no longer binding.`, ); } } } function feasiblePrescience( g: Game, p: Player, field: PlanField, value: unknown, ) { if ( g.battle?.truthPromises?.some( (promise) => !promise.released && promise.player === p.id, ) ) return !!findReachableBattlePlan(g, p, { prescience: { field, value } }); // Validate the revealed element without committing any other plan components. // A legal completion must exist, including compliance with the preceding Voice. const b = g.battle!; const leaderIds: (string | null)[] = [ null, ...controlledLeaders(g, p) .filter((l) => !l.dead && (!l.usedAt || l.usedAt === b.territory)) .map((l) => l.id), ...p.hand.filter((c) => c.kind === 'hero').map((c) => c.id), ]; const weapons: (string | null)[] = [ null, ...p.hand.filter(isWeaponCard).map((c) => c.id), ]; const defenses: (string | null)[] = [ null, ...p.hand.filter((c) => isDefenseCard(c)).map((c) => c.id), ]; for (const leader of field === 'leader' ? [value] : leaderIds) for (const weapon of field === 'weapon' ? [value] : weapons) for (const defense of field === 'defense' ? [value] : defenses) { const maxSupport = g.advanced && field === 'dial' ? Math.min(battleSupportBudget(g, p), at(p, b.territory)) : 0; for (let support = 0; support <= maxSupport; support++) { try { const plan = validatePlan(g, p, { leader, weapon, defense, support, dial: field === 'dial' ? value : 0, }); if (plan[field] === value) return true; } catch (error) { if (!(error instanceof RuleError)) throw error; } } } return !!findReachableBattlePlan(g, p, { prescience: { field, value } }); } function lateDefenseIntegrity(g: Game) { const b = g.battle; if (!b?.lateDefense) return; requireRule( g.phase === 6 && b.revealed && !!b.event && !Array.isArray(b.lateDefense) && typeof b.lateDefense === 'object', 'The saved late-defense battle context is invalid.', ); for (const [id, cardId] of Object.entries(b.lateDefense)) { requireRule( [b.attacker, b.defender].includes(id) && typeof cardId === 'string' && !!b.plans[id], 'The saved late defense has an invalid combatant.', ); const owner = getPlayer(g, id), card = owner.hand.find((c) => c.id === cardId); requireRule( card && isPortableSnooper(card) && owner.hand.filter((c) => c.id === cardId).length === 1, 'The played Portable Snooper is missing from its reserved hand.', ); requireRule( !g.players.some( (p) => p.id !== id && p.hand.some((c) => c.id === cardId), ) && ![...g.deck, ...g.discard, ...(g.richeseCache ?? [])].some( (c) => c.id === cardId, ), 'The played Portable Snooper has conflicting physical custody.', ); const blocked = portableSnooperPlanBlock( b.plans[id], owner.hand, card, b.voice?.target === id ? b.voice : undefined, ); requireRule( !blocked, blocked ?? 'The saved late defense does not fit the revealed plan.', ); } } function validatePortableSnooper( g: Game, owner: Player, cardId: string, event: string, ) { const b = g.battle; requireRule( g.status === 'playing' && g.phase === 6 && b?.revealed && [b.attacker, b.defender].includes(owner.id), 'Late Portable Snooper is available after both battle plans are revealed.', ); requireRule( !!b.event && b.event === event, 'Use the current revealed battle event; legacy battles without one are not reopened.', ); requireRule( b.traitorCalls[owner.id] === undefined, 'Your battle decisions are already submitted; the late-defense opportunity has closed.', ); requireRule( !g.truthtrance && !g.decision && !g.response && !g.phaseOpening && !g.pendingNullentropy, 'Finish the current interaction before adding a late defense.', ); requireRule( !b.lateDefense?.[owner.id], 'You have already added Portable Snooper to this battle.', ); const card = owner.hand.find((c) => c.id === cardId); requireRule( card && isPortableSnooper(card), 'Choose the canonical Portable Snooper in your hand.', ); const reserved = transferCardBlock(g, owner, card); requireRule(!reserved, reserved ?? 'The card is already committed.'); const blocked = portableSnooperPlanBlock( b.plans[owner.id], owner.hand, card, b.voice?.target === owner.id ? b.voice : undefined, ); requireRule( !blocked, blocked ?? 'Portable Snooper cannot be added to this plan.', ); const trial = structuredClone(g); (trial.battle!.lateDefense ??= {})[owner.id] = card.id; lateDefenseIntegrity(trial); return { b, card }; } function portableSnooperView(g: Game, owner: Player) { const card = owner.hand.find(isPortableSnooper); if (!card) return null; let blocked: string | null = null; try { validatePortableSnooper(g, owner, card.id, g.battle?.event ?? ''); } catch (error) { if (!(error instanceof RuleError)) throw error; blocked = error.message; } return { card, event: g.battle?.event ?? null, blocked }; } function stoneBurnerIntegrity(g: Game) { const b = g.battle; if (!b) return; if (b.stoneBurner !== undefined) { requireRule( g.phase === 6 && b.revealed && !!b.event && !!b.stoneBurner && typeof b.stoneBurner === 'object' && !Array.isArray(b.stoneBurner), 'The saved Stone Burner choice context is invalid.', ); for (const [id, mode] of Object.entries(b.stoneBurner)) requireRule( [b.attacker, b.defender].includes(id) && ['kill', 'ignore'].includes(mode) && isStoneBurner(cardOf(getPlayer(g, id), b.plans[id]?.weapon)), 'The saved Stone Burner choice has an invalid owner, mode, or missing canonical weapon.', ); } for (const id of [b.attacker, b.defender]) { if (b.plans[id]?.weapon !== 'richese-stone-burner') continue; const owner = getPlayer(g, id), card = cardOf(owner, b.plans[id].weapon); requireRule( isStoneBurner(card), 'The committed canonical Stone Burner weapon is missing from its reserved hand.', ); requireRule( g.players.reduce( (n, p) => n + p.hand.filter((c) => c.id === card!.id).length, 0, ) === 1 && ![ ...g.deck, ...g.discard, ...(g.richeseCache ?? []), ...(g.auction?.cards ?? []), ...(g.ixSetupCards ?? []), ].some((c) => c.id === card!.id), 'The committed Stone Burner physical card is duplicated.', ); } } function nextRevealedDecision(g: Game) { const b = g.battle!; const stoneOwner = [b.attacker, b.defender].find( (id) => isStoneBurner(cardOf(getPlayer(g, id), b.plans[id].weapon)) && !b.stoneBurner?.[id], ); if (stoneOwner) { requireRule(!!b.event, 'The Stone Burner battle event is missing.'); g.decision = { kind: 'stoneBurner', player: stoneOwner, event: b.event }; return; } const player = g.order.find( (id) => [b.attacker, b.defender].includes(id) && cardOf(getPlayer(g, id), b.plans[id].weapon)?.kind === 'poisonTooth' && b.poisonTooth?.[id] === undefined, ); if (player) g.decision = { kind: 'poisonTooth', player }; } function traitorVoters(g: Game, b: Battle) { const voters = [b.attacker, b.defender]; const harkonnen = byFaction(g, 'harkonnen'); if ( harkonnen?.ally && voters.includes(harkonnen.ally) && !voters.includes(harkonnen.id) ) voters.push(harkonnen.id); return voters; } function traitorBeneficiary(g: Game, b: Battle, id: string) { return [b.attacker, b.defender].includes(id) ? id : getPlayer(g, id).ally; } /** Prepare current server-only inputs once; the pure module cannot inspect Game or mutate it. */ function currentBattleResolutionQuote(g: Game, canceledVoter?: string) { const b = g.battle!; const combatant = (id: string, opponentId: string): ResolutionCombatant => { const p = getPlayer(g, id), opponent = getPlayer(g, opponentId); const plan = b.plans[id], aid = aidFor(g, p); return { id: p.id, faction: p.faction, ally: p.ally, spice: p.spice, hand: p.hand, plan, leader: controlledLeaders(g, p).find((l) => l.id === plan.leader), forces: combatForces(g, p, b.territory, opponent), stronghold: strongholdEffect(g, p.id), lateDefense: b.lateDefense?.[p.id], poisonTooth: b.poisonTooth?.[p.id], stoneMode: b.stoneBurner?.[p.id], ...(aid ? { aid: { donor: p.ally!, amount: aid.amount } } : {}), }; }; try { const quote = quoteBattleResolution({ advanced: g.advanced, turn: g.turn, territory: b.territory, attacker: combatant(b.attacker, b.defender), defender: combatant(b.defender, b.attacker), voters: traitorVoters(g, b).map((id) => ({ id, beneficiary: traitorBeneficiary(g, b, id)!, called: id === canceledVoter ? false : b.traitorCalls[id], traitors: getPlayer(g, id).traitors, })), participants: g.players.map((p) => ({ id: p.id, faction: p.faction, ally: p.ally, noFieldAtTerritory: p.noField?.deployed?.location.territory === b.territory, })), physicalCards: physicalTreacheryCards(g), pendingAuditorPresent: !!g.pendingAuditor, pendingRetentionPresent: !!g.moritaniRetention, }); for (const id of quote.destroyedArmies) validateBattleForceLoss(getPlayer(g, id), b.territory, Infinity); if (quote.winner && quote.result === 'normal') validateBattleForceLoss( getPlayer(g, quote.winner), b.territory, quote.basicWinnerLosses ?? Math.max(...quote.casualties!.options.map((c) => c.normal + c.elite)), ); return quote; } catch (error) { if ( error instanceof BattleResolutionQuoteError || error instanceof ForceLossPreflightError ) throw new RuleError(error.message); throw error; } } function resolveBattle(g: Game) { const b = g.battle!; const quote = currentBattleResolutionQuote(g); const a = getPlayer(g, b.attacker), d = getPlayer(g, b.defender), ap = b.plans[a.id], dp = b.plans[d.id]; const al = controlledLeaders(g, a).find((l) => l.id === ap.leader), dl = controlledLeaders(g, d).find((l) => l.id === dp.leader); const aw = cardOf(a, ap.weapon); const stoneResult = quote.stone; const ac = quote.attackerTraitor, dc = quote.defenderTraitor; for (const revelation of quote.revelations) { const holder = getPlayer(g, revelation.player), identity = revelation.identity; holder.revealedTraitors = [ ...new Set([...(holder.revealedTraitors ?? []), identity]), ]; const name = identity === CHEAP_HERO_TRAITOR ? 'Cheap Hero / Heroine' : (g.players.flatMap((p) => p.leaders).find((l) => l.id === identity) ?.name ?? identity); log(g, `${holder.name} revealed ${name} as a traitor.`); } const dead = (l: Leader | undefined) => { if (l) { l.dead = true; l.deaths++; delete l.usedAt; } }; const winner = quote.winner ? getPlayer(g, quote.winner) : undefined; const casualtyCommitment = quote.casualties ?? undefined; for (const payment of quote.payments) { const player = getPlayer(g, payment.player); player.spice -= payment.ownPayment; if (payment.allyPayment) aidFor(g, player)!.amount -= payment.allyPayment; if (payment.bankSupport) log( g, `${player.name}'s Stronghold Card paid ${payment.bankSupport} spice from the bank toward force support.`, { faction: player.faction, name: 'Stronghold support' }, ); } if (quote.choamIncome) g.pendingChoamBattleIncome = { ...quote.choamIncome }; const explosion = quote.explosion; if (ac && dc) { dead(al); dead(dl); killTerritory(g, a, b.territory, Infinity, true); killTerritory(g, d, b.territory, Infinity, true); log(g, 'Both leaders were traitors. Both armies were destroyed.'); } else if (ac || dc) { const loser = ac ? d : a; const l = ac ? dl : al; dead(l); winner!.spice += quote.bounty!.amount; killTerritory(g, loser, b.territory, Infinity, true); log( g, `${faction(winner!.faction).name} revealed a traitor and won without losses.`, ); } else if (explosion) { returnAmbassadorsIn( g, b.territory, 'a Lasgun–shield explosion destroyed it', ); for (const p of g.players) killTerritory(g, p, b.territory, Infinity, true); dead(al); dead(dl); for (const [p, plan] of [ [a, ap], [d, dp], ] as [Player, Plan][]) if (plan.kwisatz) { p.kwisatz ??= { dead: false }; p.kwisatz.dead = true; p.kwisatz.revivalCycle ??= 1; } for (const k of Object.keys(g.spice)) if (splitLocation(k).territory === b.territory) delete g.spice[k]; log( g, 'Lasgun and shield exploded. All forces and spice in the territory were destroyed.', ); } else { const ak = quote.leaderDeaths.attacker, dk = quote.leaderDeaths.defender; if (ak) dead(al); if (dk) dead(dl); const av = quote.scores!.attacker, dv = quote.scores!.defender; const loser = winner === a ? d : a; if (quote.bounty) winner!.spice += quote.bounty.amount; killTerritory(g, loser, b.territory, Infinity, true); if (quote.basicWinnerLosses !== null) killTerritory(g, winner!, b.territory, quote.basicWinnerLosses, true); log( g, stoneResult ? `${faction(winner!.faction).name} won in ${territory(b.territory).name} by Stone Burner’s undialed physical tokens (aggressor ${stoneResult.attacker.join(' or ')}, defender ${stoneResult.defender.join(' or ')}; ${strongholdEffect(g, battleTieWinner(g)) === 'habbanya_ridge_sietch' ? 'Habbanya Stronghold advantage wins ties' : 'aggressor wins ties'}). Leader strength and Kwisatz Haderach’s bonus do not affect this comparison; the winner loses its dialed forces normally.` : `${faction(winner!.faction).name} won in ${territory(b.territory).name} (${av}–${dv}${av === dv ? (strongholdEffect(g, winner!.id) === 'habbanya_ridge_sietch' ? ', Habbanya Stronghold advantage wins ties' : ', aggressor wins ties') : ''}).`, stoneResult ? { faction: (isStoneBurner(aw) ? a : d).faction, name: 'Stone Burner' } : undefined, ); } for (const income of quote.strongholdIncome) { const player = getPlayer(g, income.player); player.spice += income.amount; log( g, `${player.name} collected ${income.amount} spice from the bank for their ${strongholdEffect(g, player.id) === 'sietch_tabr' ? 'Sietch Tabr victory' : 'Worthless battle cards at Tuek’s Sietch'}.`, { faction: player.faction, name: 'Stronghold income' }, ); } const tleilaxu = byFaction(g, 'tleilaxu'); if (winner && tleilaxu && winner.id !== tleilaxu.id) { const winningPlan = winner.id === a.id ? ap : dp; g.pendingFaceDance = { player: tleilaxu.id, winner: winner.id, leader: winningPlan.leader, identity: cardOf(winner, winningPlan.leader)?.kind === 'hero' ? CHEAP_HERO_TRAITOR : winningPlan.leader, territory: b.territory, }; } if (quote.auditor) g.pendingAuditor = { ...quote.auditor, event: b.event ?? crypto.randomUUID(), }; if (quote.retention) g.moritaniRetention = structuredClone(quote.retention); const discarded: { card: Card; discardedBy: string; publicFace: boolean }[] = []; for (const [p, plan, l] of [ [a, ap, al], [d, dp, dl], ] as [Player, Plan, Leader | undefined][]) { if (l && !l.dead) l.usedAt = b.territory; if (l?.capturedBy === p.id) { delete l.capturedBy; delete l.concealed; } if (plan.kwisatz) { p.kwisatz ??= { dead: false }; p.kwisatz.usedAt = b.territory; } for (const entry of quote.discarded.filter( (entry) => entry.player === p.id, )) discarded.push({ card: discard(g, p, entry.card), discardedBy: p.id, publicFace: true, }); } const harkonnen = byFaction(g, 'harkonnen'); if (harkonnen) returnCaptives(g, harkonnen); if (g.advanced && winner?.faction === 'harkonnen') { const loser = winner.id === a.id ? d : a; if (captureCandidates(g, loser.id, b.territory).length) g.pendingCapture = { player: winner.id, loser: loser.id, territory: b.territory, }; } if (winner) { const loser = winner.id === a.id ? d : a; const choices = ownedTech(g.techTokens, loser.id); if (choices.length) g.pendingTech = { player: winner.id, loser: loser.id, choices }; } if (g.dukeVidal && [ap.leader, dp.leader].includes(DUKE_VIDAL_ID)) g.dukeVidal = consumeDuke(g.dukeVidal); g.lastBattle = [a.id, d.id]; g.lastBattleContext = { event: b.event ?? g.pendingAuditor?.event ?? crypto.randomUUID(), turn: g.turn, territory: b.territory, combatants: [...g.lastBattle], winner: winner?.id ?? null, result: quote.result, }; g.battle = null; const cards = [...quote.winnerCards]; const continuation: Extract< NonNullable['continuation'], { kind: 'battleResolved' } > = { kind: 'battleResolved', event: g.lastBattleContext.event, result: quote.result, combatants: [...g.lastBattle], territory: b.territory, winner: winner?.id ?? null, cards, ...(casualtyCommitment ? { casualties: casualtyCommitment } : {}), }; if (discarded.length) stageTreacheryDiscard(g, 'battle:mandatory', discarded, continuation); else continueResolvedBattle(g, continuation); } function continueResolvedBattle( g: Game, continuation: Extract< NonNullable['continuation'], { kind: 'battleResolved' } >, ) { const { cards, territory: to } = continuation; const winner = continuation.winner ? getPlayer(g, continuation.winner) : null; const losses = continuation.casualties?.options; if (winner && losses?.length === 1) { settleWinnerCasualties(g, winner, to, cards, losses[0], true); } else if (winner && losses) g.decision = { kind: 'battleLosses', player: winner.id, territory: to, options: losses, cards, }; else if (winner) finishWinner(g, winner, to, cards); else finishBattle(g); } function settleWinnerCasualties( g: Game, p: Player, to: string, cards: string[], choice: Casualties, automatic = false, ) { const losses = takeBattleLosses(g, p, to, choice); log( g, `${p.name} sent ${choice.normal} normal and ${choice.elite} elite forces from ${territory(to).name} to the Tanks. ${automatic ? 'This was the only legal casualty allocation for the revealed battle plan, so it was applied automatically.' : 'This applies the selected casualty allocation for the revealed battle plan.'}`, automatic ? { faction: p.faction, name: 'Battle casualties' } : undefined, ); const survivingSuboids = Object.entries(p.forces) .filter(([key]) => splitLocation(key).territory === to) .reduce( (sum, [key, count]) => sum + count - (p.elites?.forces[key] ?? 0), 0, ); if (p.faction === 'ixians' && choice.elite > 0 && survivingSuboids > 0) { g.pendingIxSubstitution = { player: p.id, territory: to, losses, cards }; g.decision = { kind: 'ixSubstitution', player: p.id, territory: to, losses, }; } else finishWinner(g, p, to, cards); } function finishWinner(g: Game, winner: Player, t: string, cards: string[]) { if (cards.length) g.decision = { kind: 'battleCards', player: winner.id, territory: t, cards, }; else finishBattle(g); } /** Pure current compulsory aftermath; no board or phase initialization is executed here. */ function currentBattleAftermathQuote( g: Game, cancel?: AftermathCancellation, spending?: { player: string; card: string }, ) { try { const quote = quoteBattleAftermath( { status: g.status, phase: g.phase, turn: g.turn, advanced: g.advanced, battlePresent: !!g.battle, lastBattle: g.lastBattle, territoryIds: gameTerritories(g).map((t) => t.id), context: g.lastBattleContext, players: g.players.map((p) => ({ id: p.id, faction: p.faction, hand: p.hand, leaders: p.leaders, })), leaderIds: [ ...new Set([ ...g.players.flatMap((p) => p.leaders.map((l) => l.id)), ...(g.dukeVidal ? [g.dukeVidal.leader.id] : []), ]), ], physicalCards: physicalTreacheryCards(g), techTokens: g.techTokens, pending: { retention: g.moritaniRetention, income: g.pendingChoamBattleIncome, tech: g.pendingTech, capture: g.pendingCapture, auditor: g.pendingAuditor, faceDance: g.pendingFaceDance, }, cancel, }, spending, ); if (quote.next.kind === 'board') boardResolution(() => quoteBattleBoardContinuation(g)); return quote; } catch (error) { if (error instanceof BattleAftermathQuoteError) throw new RuleError(error.message); throw error; } } function validateAftermathCancellation( g: Game, response: ResponseWindow, spending?: { player: string; card: string }, ) { if ( response.kind === 'capture' || response.kind === 'choamAudit' || response.kind === 'choamBattleIncome' ) return currentBattleAftermathQuote( g, { kind: response.kind, owner: response.owner, intent: response.intent, }, spending, ); } function finishBattle(g: Game) { const quote = currentBattleAftermathQuote(g); for (const step of quote.steps) { if (step.kind === 'transferTech') { transferTech(g, step.id, step.to); g.pendingTech = null; } else if (step.kind === 'emptyAuditor') { log( g, 'The Auditor has no eligible opposing hand cards to inspect. Cards used in this battle are excluded.', { faction: 'choam', name: 'Auditor: no eligible cards' }, ); g.pendingAuditor = null; } } const next = quote.next; if (next.kind === 'choamBattleIncome') { g.response = { ...next, passed: [] }; } else if (next.kind === 'choamAudit') { g.decision = { kind: next.kind, player: next.player, event: next.event }; } else if (next.kind !== 'board') { g.decision = { ...next }; } else { const remaining = battles(g); if (remaining.length) g.active = remaining[0].attacker; else nextPhase(g); } } function currentAuditCount(g: Game) { const pending = g.pendingAuditor; return pending ? auditCount( getPlayer(g, pending.opponent).hand, pending.usedCards, pending.survived, ) : 0; } function finishAuditor( g: Game, outcome: 'inspect' | 'pay' | 'decline' | 'cancel' | 'empty', ) { const pending = g.pendingAuditor; requireRule(pending, 'This Auditor opportunity has already ended.'); const owner = getPlayer(g, pending.owner), opponent = getPlayer(g, pending.opponent); const count = currentAuditCount(g); if (outcome === 'pay') { requireRule( count > 0 && opponent.spice >= count, 'Pay the full current audit cost or allow the inspection.', ); opponent.spice -= count; owner.spice += count; log( g, `${opponent.name} paid ${count} spice directly to ${owner.name} to cancel the entire Auditor inspection.`, ); } else if (outcome === 'inspect' && count) { const cards = sampleAuditCards( opponent.hand, pending.usedCards, pending.survived, random, ); g.auditorInsight = { event: pending.event, viewer: owner.id, target: opponent.id, turn: g.turn, cards, }; log( g, `${owner.name}'s ${pending.survived ? 'surviving' : 'killed'} Auditor privately inspected ${cards.length} random opposing hand card${cards.length === 1 ? '' : 's'}. Cards used in this battle were excluded; no cards changed hands.`, { faction: 'choam', name: 'Auditor inspection' }, ); } else { log( g, outcome === 'decline' ? `${owner.name} declined the Auditor inspection.` : outcome === 'cancel' ? 'Karama canceled the Auditor inspection; no cancellation payment was charged.' : 'The Auditor has no eligible opposing hand cards to inspect.', { faction: 'choam', name: outcome === 'cancel' ? 'Auditor canceled' : 'Auditor', }, ); } g.pendingAuditor = null; g.decision = null; finishBattle(g); } function auditorIntegrity(g: Game) { const pending = g.pendingAuditor; if (!pending) { requireRule( !['choamAudit', 'choamAuditPayment'].includes(g.decision?.kind ?? '') && g.response?.kind !== 'choamAudit', 'The Auditor continuation is missing.', ); return; } requireRule( g.advanced && g.status === 'playing' && g.phase === 6 && !g.battle && pending.turn === g.turn && typeof pending.event === 'string' && !!pending.event && pending.owner !== pending.opponent && g.lastBattle.length === 2 && g.lastBattle.includes(pending.owner) && g.lastBattle.includes(pending.opponent) && gameTerritories(g).some((t) => t.id === pending.territory) && g.players.some( (p) => p.id === pending.owner && p.faction === 'choam' && p.leaders.some(isAuditorLeader), ) && g.players.some((p) => p.id === pending.opponent) && typeof pending.survived === 'boolean' && ['offer', 'response', 'payment'].includes(pending.stage) && Array.isArray(pending.usedCards) && pending.usedCards.every((id) => typeof id === 'string') && new Set(pending.usedCards).size === pending.usedCards.length, 'The saved Auditor opportunity does not match this resolved battle.', ); // Modern saves retain an independent receipt of the resolved battle. Legacy // opportunities without that receipt still use the controls checked below. if (g.lastBattleContext) requireRule( g.lastBattleContext.event === pending.event && g.lastBattleContext.turn === pending.turn && g.lastBattleContext.territory === pending.territory && JSON.stringify(g.lastBattleContext.combatants) === JSON.stringify(g.lastBattle), 'The saved Auditor opportunity differs from its completed battle receipt.', ); // These overlays preserve the exact parent decision/response instead of ending it. const contexts = [ g, g.pendingNullentropy?.resume, g.pendingTreacheryDiscard?.continuation.kind === 'nullentropyDiscard' || g.pendingTreacheryDiscard?.continuation.kind === 'truthtranceDiscard' ? g.pendingTreacheryDiscard.continuation.resume : undefined, g.pendingRicheseGift?.resume, ].filter((c) => !!c); const decisions = contexts.flatMap((c) => (c.decision ? [c.decision] : [])); const responses = contexts.flatMap((c) => [ ...(c.response ? [c.response] : []), ...(c.pendingKarama?.use.kind === 'cancel' ? [c.pendingKarama.use.response] : []), ]); for (const decision of decisions.filter( (d) => d.kind === 'choamAudit' || d.kind === 'choamAuditPayment', )) { const offer = decision.kind === 'choamAudit'; requireRule( decision.event === pending.event && decision.player === (offer ? pending.owner : pending.opponent) && pending.stage === (offer ? 'offer' : 'payment'), 'This Auditor decision is stale.', ); } for (const response of responses.filter((r) => r.kind === 'choamAudit')) requireRule( response.owner === pending.owner && response.intent === pending.event && pending.stage === 'response', 'This Auditor cancellation window is stale.', ); requireRule( pending.stage === 'offer' || (pending.stage === 'response' && responses.some((r) => r.kind === 'choamAudit')) || (pending.stage === 'payment' && decisions.some((d) => d.kind === 'choamAuditPayment')), 'The saved Auditor response or payment continuation is missing.', ); } /** Older saved cleanup decisions predate the resolved battle receipt. Their * authenticated parent supplies the territory/winner; no battle is replayed. */ function battleCleanupInput(g: Game) { return { phase: g.phase, turn: g.turn, battlePresent: !!g.battle, lastBattle: g.lastBattle, playerIds: g.players.map((p) => p.id), territoryIds: gameTerritories(g).map((t) => t.id), context: g.lastBattleContext, }; } function battlePreflight(quote: () => T): T { try { return quote(); } catch (error) { if (error instanceof KaramaBattlePreflightError) throw new RuleError(error.message); throw error; } } function validateMoritaniCancellation(g: Game, responseOwner: string) { const pending = g.moritaniRetention; return battlePreflight(() => preflightMoritaniRetentionCancellation({ ...battleCleanupInput(g), responseOwner, pending, hand: g.players.find((p) => p.id === pending?.player)?.hand ?? [], physicalCards: physicalTreacheryCards(g), }), ); } function cleanupBattleContext(g: Game, to: string, winner: string | null) { const quote = battlePreflight(() => validateBattleCleanupContext({ ...battleCleanupInput(g), territory: to, winner, }), ); if (quote.kind === 'legacy') g.lastBattleContext = { ...quote.seed, event: g.pendingAuditor?.event ?? crypto.randomUUID(), }; return g.lastBattleContext!; } function finishMoritaniRetention(g: Game, keep: string | null) { const pending = g.moritaniRetention; if (keep === null && pending?.stage === 'response') validateMoritaniCancellation(g, pending.owner); requireRule( pending && pending.turn === g.turn && g.phase === 6, 'This alliance card cleanup is no longer current.', ); const player = getPlayer(g, pending.player); requireRule( keep === null || pending.eligible.includes(keep), 'Choose one eligible card from your played battle cards.', ); requireRule( pending.played.every((id) => player.hand.some((c) => c.id === id)), 'Cards committed to alliance cleanup must remain available.', ); const discarded = pending.played .filter((id) => id !== keep) .map((id) => ({ card: discard(g, player, id), discardedBy: player.id, publicFace: true, })); log( g, keep ? `${player.name} retained ${cardOf(player, keep)!.name} through the Moritani alliance.` : `${player.name} retained no battle card through the Moritani alliance.`, ); g.moritaniRetention = null; const context = discarded.length ? cleanupBattleContext( g, pending.territory, g.lastBattle.find((id) => id !== player.id) ?? null, ) : null; if (discarded.length) stageTreacheryDiscard(g, 'battle:moritani', discarded, { kind: 'battleCleanup', event: context!.event, combatants: [...g.lastBattle], territory: pending.territory, player: player.id, source: 'moritani', kept: keep ? [keep] : [], played: [...pending.played], retention: pending, }); else finishBattle(g); } function collectRevivalIncome( g: Game, actor: Player, paid: number, free = false, ghola = false, ) { const tleilaxu = byFaction(g, 'tleilaxu'); if (!tleilaxu) return; g.revivalFreeIncome ??= {}; const reward = ghola || (free && g.revivalFreeIncome[actor.id] !== g.turn); if (free && !ghola) g.revivalFreeIncome[actor.id] = g.turn; const amount = (actor.id === tleilaxu.id ? 0 : paid) + (reward ? 1 : 0); if (amount) g.response = { kind: 'revivalIncome', owner: tleilaxu.id, recipient: actor.id, amount, passed: [], }; } function currentRevivalResumeQuote(g: Game, stage: 'stop' | 'finish') { try { return quoteRevivalResume(g, stage); } catch (error) { if (error instanceof RevivalResumeError) throw new RuleError(error.message); throw error; } } function finishRevival( g: Game, quote: RevivalResumeQuote = currentRevivalResumeQuote(g, 'finish'), ) { const revival = quote.pending; g.pendingRevival = revival; const p = getPlayer(g, revival.player); if (quote.outcome === 'response') { revival.checks.shift(); g.response = quote.nextResponse!; return; } const payer = revival.payer ? getPlayer(g, revival.payer) : p; if (quote.outcome === 'fremenLimit') { g.pendingRevival = null; log( g, `${p.name}'s normal revival was not completed: the request exceeds their current force-revival allowance. No forces or spice changed.`, ); return; } if (quote.outcome === 'unfunded') { g.pendingRevival = null; log( g, `${p.name}'s revival was not completed because its current price cannot be paid.`, ); return; } payer.spice -= revival.cost; if (revival.kind === 'forces') { const n = revival.amount!, elite = revival.elite ?? 0; p.tanks -= n; p.reserves += n; if (revival.emperorExtra) g.emperorExtra[p.id] = (g.emperorExtra[p.id] ?? 0) + n; else { // Preserve unknown legacy usage instead of guessing how earlier paid returns were priced. if (p.freeForcesRevived !== undefined || p.revived === 0) p.freeForcesRevived = (p.freeForcesRevived ?? 0) + revival.free; p.revived += n; } if (p.elites) { p.elites.tanks -= elite; p.elites.reserves += elite; p.elites.revived += elite; } if (quote.techIncome) { const income = quote.techIncome; const token = g.techTokens![income.id]; token.triggeredTurn = income.triggeredTurn; token.spice = income.spice; const rule = TECH_TOKENS.find((t) => t.id === income.id)!; log( g, `${rule.name} accrued ${token.spice} spice for ${getPlayer(g, income.owner).name}, payable at phase end.`, ); } log(g, `${p.name} revived ${n} forces.`); } else { if (revival.kind === 'kwisatz') { p.kwisatz!.dead = false; p.kwisatz!.revivalCycle = Math.max(p.revivalCycle, p.kwisatz!.revivalCycle ?? 1) + 1; log(g, `${p.name} revived Kwisatz Haderach.`); } else { const leader = g.players .flatMap((owner) => owner.leaders) .find((l) => l.id === revival.leader)!; if (revival.kind === 'foreignGhola') leader.gholaBy = p.id; else delete leader.gholaBy; leader.dead = false; delete leader.concealed; log( g, `${p.name} revived ${leader.name}${revival.kind === 'foreignGhola' ? ' as a ghola' : ''}.`, ); } p.leaderRevived = true; if (g.revivalRequests) delete g.revivalRequests[p.id]; } g.pendingRevival = null; if (quote.freeIncome) { g.revivalFreeIncome ??= {}; g.revivalFreeIncome[quote.freeIncome.player] = quote.freeIncome.turn; } if (quote.nextResponse) g.response = quote.nextResponse; } function beginRevival(g: Game, revival: PendingRevival) { requireRule( !revivalPrevented(g, revival.player), 'Tleilaxu prevented this faction’s normal revivals for this turn.', ); g.pendingRevival = revival; const choam = byFaction(g, 'choam'); if ( choam && choam.id !== revival.player && revival.free > 0 && !revival.emperorExtra ) { g.decision = { kind: 'choamFreeRevival', player: choam.id, recipient: revival.player, }; return; } offerRevivalStop(g); } function offerRevivalStop( g: Game, quote: RevivalResumeQuote = currentRevivalResumeQuote(g, 'stop'), ) { if (quote.outcome === 'decision') { g.pendingRevival = quote.pending; g.decision = quote.nextDecision!; } else finishRevival(g, quote); } function currentChoamWorthlessCancellationQuote( g: Game, response: ResponseWindow, ) { try { const declaration = quoteChoamWorthlessCancellation(g, response); if (!declaration) return null; const storm = declaration.resume.kind === 'storm' ? currentChoamStormQuote(g) : null; const revival = declaration.resume.kind === 'revival' ? currentRevivalResumeQuote(g, 'stop') : null; if (declaration.resume.kind === 'movement') { const move = g.pendingChoamMove!; if (move.source === 'ambassador') currentFremenAmbassador(g, move.ambassadorEvent); let valid = true; try { validateMovementOrder(g, getPlayer(g, move.player), move); } catch (error) { if (!(error instanceof RuleError)) throw error; valid = false; } if (valid) { if (move.source === 'ambassador') validateAmbassadorRelocationArrival(g, move); else validateMovementArrival(g, move, true); } } return { declaration, storm, revival }; } catch (error) { if (error instanceof ChoamWorthlessCancellationError) throw new RuleError(error.message); throw error; } } function moritaniAllianceCancellationQuote(g: Game, response: ResponseWindow) { try { return quoteMoritaniAllianceCancellation(g, response); } catch (error) { if (error instanceof MoritaniAllianceCancellationError) throw new RuleError(error.message); throw error; } } function currentRicheseCancellationQuote(g: Game, response: ResponseWindow) { try { const quote = quoteRicheseCancellation(g, response); if (!quote) return null; // Only restore the controls saved by this producer; all other pending // records remain live. No normalization, response resolution or RNG. const shadow: Game = { ...g, ...quote.resume }; if (quote.kind === 'richeseGift') shadow.pendingRicheseGift = null; else shadow.pendingRichesePurchaseIncome = null; const controls = { response: shadow.response, decision: shadow.decision, pendingKarama: shadow.pendingKarama, phaseOpening: shadow.phaseOpening, }; suspendedControlsIntegrity(shadow, controls); if (shadow.response) { karamaCanceledSource(shadow, shadow.response); // These pure contracts validate the declared parent; their canceled // outcomes are not applied and no future response is answered here. terminalCancellationQuote(shadow, shadow.response); if (isCombatResponseKind(shadow.response.kind)) currentCombatResponseQuote(shadow, { kind: 'response', response: { kind: shadow.response.kind, owner: shadow.response.owner, }, canceled: false, }); } return quote; } catch (error) { if (error instanceof RicheseCancellationError) throw new RuleError(error.message); throw error; } } function currentVictoryQuote(g: Game) { try { return quoteVictory(g); } catch (error) { if (error instanceof VictoryQuoteError) throw new RuleError(error.message); throw error; } } function currentPlacementCancellationQuote(g: Game, response: ResponseWindow) { try { const receipt = quotePlacementCancellation(g, response); if (!receipt) return null; let finalVictory: VictoryQuote | null = null; if (receipt.kind === 'ecazPlacement') { // Revival settlement preceded the placement opportunity. Only phase5 // initialization remains; a real Ix opening defers its future effects. movementPhaseQuote(() => quoteMovementPhaseStart( { ...g, phase: 5 }, g.expansions.includes('ix'), ), ); } else if (receipt.kind === 'moritaniPlacement') { // Mentat opening income is already settled. CHOAM retains its own later // market/Mentat choice; otherwise this receipt reaches victory now. if (!byFaction(g, 'choam')) finalVictory = currentVictoryQuote(g); } else if (!byFaction(g, 'choam')) { const resources = currentPhaseResources(g); if (!g.expansions.includes('ix')) { const board = boardResolution(() => quoteBattleBoard(g)); if (!board.battles.length) { // No combat: the actual Battle initializer departs again to // collection. Project only settled public balances and empty aid. boardResolution(() => quoteBattlePhaseAdvance({ advanced: g.advanced, storm: g.storm, order: g.order, mobileStronghold: g.mobileStronghold, spice: g.spice, expansions: g.expansions, aid: {}, players: g.players.map((p) => ({ id: p.id, faction: p.faction, ally: p.ally, advisors: p.advisors, forces: p.forces, elites: p.elites, noField: p.noField, spice: resources.balances[p.id], })), }), ); } } } return { receipt, finalVictory }; } catch (error) { if (error instanceof PlacementCancellationError) throw new RuleError(error.message); throw error; } } function finishResponse(g: Game, canceled: boolean) { karamaConversionIntegrity(g); const response = g.response!; const ecazCollectionQuote = response.kind === 'ecazCollection' ? currentEcazCollectionQuote(g, response, canceled) : null; if (response.source === 'ambassador') validateAmbassadorPurchaseResponse(g, response); const placementCancellation = canceled ? currentPlacementCancellationQuote(g, response) : null; const choamCancellation = canceled ? currentChoamWorthlessCancellationQuote(g, response) : null; const auctionCancellation = canceled ? validateAuctionContinuationCancellation(g, response) : null; const technologyCancellation = canceled ? auctionTechnologyQuote(() => quoteIxTechnologyCancellation(g, response)) : null; const richeseCancellation = canceled ? currentRicheseCancellationQuote(g, response) : null; if (canceled) validateDisasterCancellation(g, response); if (canceled) validateAftermathCancellation(g, response); const terminal = canceled ? terminalCancellationQuote(g, response) : null; const allianceCancellation = canceled ? moritaniAllianceCancellationQuote(g, response) : null; g.response = null; if (ecazCollectionQuote) { if (canceled) log( g, 'Karama prevented Ecaz’s co-occupied stronghold income. Its ally collects normally; shared desert spice is unaffected.', ); commitCollection(g, ecazCollectionQuote, canceled); return; } if (response.source === 'ambassador') { finishAmbassadorPurchaseResponse(g, response, canceled); return; } if (choamCancellation) { const { declaration, storm, revival } = choamCancellation; g.pendingChoamWorthless = declaration.pendingChoamWorthless; g.choamWorthlessBlocked = declaration.blocked; log(g, 'CHOAM’s Worthless card effect was prevented for this phase.'); if (storm) commitChoamStormOffer(g, storm); else if (revival) offerRevivalStop(g, revival); else if (declaration.resume.kind === 'movement') resumeChoamMovement(g); else if (declaration.resume.kind === 'mentat') g.decision = declaration.resume.decision; return; } if (auctionCancellation) { if (response.kind === 'harkonnenBonus') log(g, 'Karama prevented the Harkonnen bonus treachery card.'); commitAuctionContinuation(g, auctionCancellation); return; } if (response.kind === 'richeseGift') { const pending = g.pendingRicheseGift; requireRule( pending && pending.intent.owner === response.owner && pending.turn === g.turn && pending.phase === g.phase, 'This Richese gift response is no longer current.', ); let transfer: ReturnType | null = null; if (!canceled) { const context = richeseGiftContext(g); try { const candidate = validateRicheseGift( context, getPlayer(context, pending.intent.owner), pending.intent.cardId, ); requireRule( candidate.intent.owner === pending.intent.owner && candidate.intent.recipient === pending.intent.recipient && candidate.intent.cardId === pending.intent.cardId, 'The original gift recipient is no longer your current ally.', ); transfer = candidate; } catch (error) { if (!(error instanceof RuleError)) throw error; log( g, 'The Richese gift became unavailable before transfer. No card moved; the interrupted decision resumes.', ); } } if (canceled) { requireRule( richeseCancellation?.kind === 'richeseGift', 'Missing canceled Richese gift.', ); g.richeseGiftBlocked = richeseCancellation.blocked; log( g, 'Karama prevented this Richese gift. The card remains in its owner’s hand; the interrupted decision resumes.', ); } else if (transfer) { getPlayer(g, pending.intent.owner).hand = transfer.resulting.ownerHand; getPlayer(g, pending.intent.recipient).hand = transfer.resulting.recipientHand; log( g, `${getPlayer(g, pending.intent.owner).name} gave their ally one Richese Treachery Card. The exact card is private, and this gift creates no auction income, bonus draw or replacement.`, { faction: 'richese', name: 'Allied card gift' }, ); } const resume = richeseCancellation?.kind === 'richeseGift' ? richeseCancellation.resume : pending.resume; g.pendingRicheseGift = null; g.response = resume.response; g.decision = resume.decision; g.pendingKarama = resume.pendingKarama; g.phaseOpening = resume.phaseOpening; } else if (response.kind === 'richeseNoField') { const quote = canceled ? noFieldCancellationQuote(g, response) : null; const shipment = g.pendingShipment; requireRule( shipment && ((shipment.noField && shipment.player === response.owner) || shipment.alliedNoField?.owner === response.owner), 'No current No-Field shipment is awaiting this response.', ); g.pendingShipment = null; if (canceled) { requireRule(quote, 'Missing canceled No-Field declaration.'); if (quote.kind === 'allied') g.richeseAllyBlocked = quote.blocked; else getPlayer(g, quote.player).noFieldBlockedTurn = quote.blockedTurn; log( g, 'Karama prevented No-Field use for this shipment opportunity. No spice, reserves or token history changed; normal shipment remains available.', ); } else offerShipment(g, shipment); } else if (response.kind === 'moritaniRetention') { const pending = g.moritaniRetention; requireRule( pending?.stage === 'response' && pending.owner === response.owner && pending.keep, 'No Moritani alliance retention is awaiting this response.', ); if (canceled) log(g, 'Karama prevented Moritani’s ally from retaining a battle card.'); finishMoritaniRetention(g, canceled ? null : pending.keep); } else if (response.kind === 'moritaniDuke') { requireRule( g.phase === 5 && g.dukeAcquisitionTurn === g.turn && g.dukeVidal, 'This Duke Vidal acquisition is no longer current.', ); if (!canceled) { g.dukeVidal = acquireDuke( g.dukeVidal, response.owner, g.turn, 'moritani', ); log( g, `${getPlayer(g, response.owner).name} acquired Duke Vidal for one battle this turn.`, ); } else log(g, 'Karama prevented Moritani from acquiring Duke Vidal this turn.'); nextPhase(g); } else if (response.kind === 'moritaniAlliance') { const entry = g.pendingTerrorEntry; requireRule( entry && entry.stage === 'allianceResponse' && entry.turn === g.turn && entry.phase === g.phase, 'This Enemy of My Enemy opportunity is no longer current.', ); if (canceled) { requireRule(allianceCancellation, 'Missing canceled Moritani alliance.'); g.pendingTerrorEntry = allianceCancellation.entry; g.decision = allianceCancellation.decision; } else { entry.stage = 'allianceReply'; g.decision = { kind: 'moritaniTerror', player: entry.entrant, entrant: entry.entrant, territory: entry.territory, }; } log( g, canceled ? 'Karama prevented Enemy of My Enemy; Moritani may still reveal the token.' : `${getPlayer(g, entry.entrant).name} may accept or refuse Moritani’s alliance offer.`, ); } else if (response.kind === 'ecazPlacement') { const pending = g.pendingEcazPlacement; requireRule( pending && pending.turn === g.turn && g.phase === 4 && getPlayer(g, response.owner).faction === 'ecaz' && g.ecazPlacementTurn !== g.turn, 'No Ambassador placement is pending for this turn.', ); const owner = getPlayer(g, response.owner); if (canceled) { requireRule( placementCancellation?.receipt.kind === 'ecazPlacement', 'Missing Ambassador denial receipt.', ); g.ecazAmbassadors = placementCancellation.receipt.ambassadors; log( g, `${owner.name}'s remaining Ambassador placements were canceled for this turn.`, ); finishEcazPlacement(g); } else { const quote = ambassadorPlacementQuote( g, owner, pending.token, pending.territory, ); requireRule( quote.cost === pending.cost, 'The Ambassador placement price changed.', ); owner.spice -= quote.cost; g.ecazAmbassadors = quote.state; g.pendingEcazPlacement = null; log( g, `${owner.name} placed the ${faction(quote.state.tokens.find((t) => t.id === pending.token)!.effect).name} Ambassador in ${territory(pending.territory).name} for ${quote.cost} spice. The next Ambassador placed this turn will cost ${quote.cost + 1} spice.`, { faction: owner.faction, name: 'Ambassador placement' }, ); g.decision = { kind: 'ecazPlacement', player: owner.id }; } } else if (response.kind === 'moritaniPlacement') { const pending = g.pendingMoritaniPlacement; requireRule( g.phase === 8 && pending?.turn === g.turn && g.moritaniTerror, 'The Terror placement opportunity is no longer current.', ); if (canceled) { requireRule( placementCancellation?.receipt.kind === 'moritaniPlacement', 'Missing Terror denial receipt.', ); g.moritaniTerror = placementCancellation.receipt.terror; log( g, 'Moritani’s Terror placement or relocation was prevented for this turn.', ); } else { try { g.moritaniTerror = placeTerror( g.moritaniTerror, pending.token, pending.territory, g.turn, ); } catch (error) { throw new RuleError( error instanceof Error ? error.message : 'Invalid Terror placement.', ); } log( g, `Moritani placed a hidden Terror token in ${territory(pending.territory).name}.`, ); } g.pendingMoritaniPlacement = null; if (placementCancellation?.finalVictory) victory(g, placementCancellation.finalVictory); else finishMoritaniPlacement(g); } else if (response.kind === 'choamWorthless') { const pending = g.pendingChoamWorthless!; g.pendingChoamWorthless = null; const choam = getPlayer(g, pending.owner); if ( choam.hand.some((c) => c.id === pending.card) && (pending.effect !== 'gamont' || gamontAvailable( g, pending.target!, pending.location!, pending.elite!, pending.noFieldEvent, )) && (pending.effect !== 'baliset' || at(choam, pending.location!) > 0) && (pending.effect !== 'jubba' || choamStormOptions(g).some((t) => t.territory === pending.location)) ) { discard(g, choam, pending.card); if (pending.effect === 'kulon') { g.choamMovement = { turn: g.turn, bonus: (g.choamMovement?.turn === g.turn ? g.choamMovement.bonus : 0) + 1, }; log( g, `${choam.name} used Kulon to move one extra territory this turn.`, ); } else if (pending.effect === 'jubba') { (g.stormResolution!.choamProtected ??= []).push(pending.location!); log( g, `${choam.name} protected its own forces in ${territory(pending.location!).name} for this storm movement with Jubba Cloak.`, ); } else if (pending.effect === 'baliset') { (g.choamBaliset ??= []).push({ turn: g.turn, player: pending.target!, territory: pending.location!, }); log( g, `${getPlayer(g, pending.target!).name} cannot move into CHOAM's ${territory(pending.location!).name} this phase; shipment remains possible.`, ); if (pending.movement) { const ambassador = g.pendingChoamMove?.source === 'ambassador'; if (ambassador) currentFremenAmbassador(g, g.pendingChoamMove!.ambassadorEvent); g.pendingChoamMove = null; log( g, 'The declared move was prevented without moving forces or spending a movement.', ); if (ambassador) offerAmbassadorRelocation(g); } } else if (pending.effect === 'gamont') { const target = getPlayer(g, pending.target!); if (pending.noFieldEvent) revealPlayerNoField(g, target, 'gamont'); const returned = (target.forces[pending.location!] ?? 0) > 0; if (returned) { removeGroup(target, [[pending.location!, 1]], { [pending.location!]: pending.elite!, }); target.reserves++; if (target.elites) target.elites.reserves += pending.elite!; } settleAdvisors(g); log( g, returned ? `${choam.name} sent one of ${target.name}’s forces to reserves with Trip to Gamont.` : `${choam.name} used Trip to Gamont to reveal ${target.name}’s No-Field. No force was present to return; the card is still used.`, ); } else { g.revivalRules ??= newRevivalRules(); (g.revivalRules.freeBlocked ??= []).push(pending.target!); log( g, `${getPlayer(g, pending.target!).name} cannot take free force revival this phase.`, ); if (pending.revival) { g.pendingRevival = null; log( g, 'The free revival request was stopped without moving forces or paying spice. Choose a paid revival if desired.', ); } } } else log( g, 'The declared card or force is no longer available; no Worthless effect occurs.', ); if (pending.storm && g.stormResolution) offerChoamStorm(g); if (pending.revival && g.pendingRevival) offerRevivalStop(g); if (pending.movement && g.pendingChoamMove) resumeChoamMovement(g); if (pending.mentat) g.decision = { kind: 'choamMentat', player: choam.id }; } else if (response.kind === 'richeseAuction') { requireRule( !canceled, 'The canceled Richese auction count is awaiting an official ruling or an explicit table interpretation.', ); if (g.richeseBidding!.position === 'first') offerRicheseCache(g); else prepareRicheseNormal(g); } else if (response.kind === 'richeseBlackMarket') { if (canceled) { g.richeseAuction!.outcome = { kind: 'unbid' }; settleRicheseLot(g); } else { const atreides = byFaction(g, 'atreides'); if (atreides) g.response = { kind: 'atreidesAuction', owner: atreides.id, passed: [], }; else settleRicheseLot(g); } } else if (response.kind === 'ixAuction') { const request = currentIxAuctionDrawQuote(g, response, canceled)!; const pending = g.ixAuction!; const cards: Card[] = []; for (let i = 0; i < request.drawCount; i++) { const card = draw(g); if (card) cards.push(card); } if (!canceled && cards.length > 1) { pending.cards = cards; g.decision = { kind: 'ixAuction', player: response.owner }; } else { g.ixAuction = null; setAuction(g, cards); } } else if (response.kind === 'ixTechnology') { if (technologyCancellation) { g.pendingIxTechnology = technologyCancellation.pendingIxTechnology; g.auction!.peekKnown = technologyCancellation.peek.peekKnown; g.response = technologyCancellation.peek.response; log(g, 'Ixian auction substitution did not occur.'); return; } const pending = g.pendingIxTechnology!; const ixians = getPlayer(g, response.owner); const replacement = ixians.hand.find((c) => c.id === pending.card); if (!canceled && replacement) { const a = g.auction!, taken = a.cards[a.index]; ixians.hand = ixians.hand.filter((c) => c.id !== replacement.id); ixians.hand.push(taken); a.cards[a.index] = replacement; if (g.ixAuctionKnown?.turn === g.turn) g.ixAuctionKnown.cards = [ ...g.ixAuctionKnown.cards.filter( (c) => c.id !== taken.id && c.id !== replacement.id, ), replacement, ]; log( g, `${ixians.name} exchanged a hand card for the upcoming auction card.`, ); } else log(g, 'Ixian auction substitution did not occur.'); g.pendingIxTechnology = null; offerAuctionPeek(g); } else if (response.kind === 'ixAllyCard') { const pending = g.pendingIxAlly!; const buyer = getPlayer(g, pending.player); if (!canceled && buyer.hand.some((c) => c.id === pending.card)) { const sale = g.currentAuctionSale; requireRule( sale, 'The paid auction must remain available for its replacement.', ); const card = discard(g, buyer, pending.card); g.pendingIxAlly = null; stageTreacheryDiscard( g, 'ixAllyCard', [ { card, discardedBy: buyer.id, publicFace: sale.origin === 'cache', }, ], { kind: 'ixAllyCard', ...pending, sale, auctionIndex: g.auction?.index ?? null, auctionEvent: g.richeseAuction?.event ?? null, }, ); return; } g.pendingIxAlly = null; continueAuctionSale(g, pending.free); } else if (response.kind === 'mobileStronghold') { if (canceled) movementCancellationQuote(g, response); const pending = g.pendingMobileMove!; g.pendingMobileMove = null; if (!canceled) relocateMobileStronghold(g, pending); else log(g, 'Mobile stronghold movement and spice collection were canceled.'); beginStormTurn(g); } else if (response.kind === 'fremenMovement') { const pending = pendingFremenMovement(g, response); const quote = canceled ? movementCancellationQuote(g, response) : null; const player = getPlayer(g, pending.order.player); g.pendingFremenMove = null; if (canceled) { requireRule( quote?.kind === 'fremenMovement', 'Missing canceled Fremen movement.', ); player.fremenMovementBlocked = { turn: quote.turn, move: quote.move }; log( g, `${player.name}'s two-territory movement advantage was canceled for this move. The forces remain in place and no movement was spent. Choose a one-territory move; available ornithopters still apply.`, ); } else { log( g, `${player.name}'s two-territory movement advantage was allowed. The declared movement continues.`, { faction: 'fremen', name: 'Fremen movement' }, ); offerChoamMovement(g, pending.order); } } else if (response.kind === 'ixMovement') { const quote = canceled ? movementCancellationQuote(g, response) : null; const move = g.pendingIxMove!; g.pendingIxMove = null; if (canceled) { const player = getPlayer(g, move.player); requireRule( quote?.kind === 'ixMovement', 'Missing canceled Ixian movement.', ); player.ixMovementBlocked = { turn: quote.turn, move: quote.move }; log( g, `${player.name}'s cyborg movement advantage was canceled. A one-territory move remains available.`, ); } else offerChoamMovement(g, move); } else if (response.kind === 'ixSubstitution') { const quote = canceled ? ixSubstitutionCancellationQuote(g, response)! : null; const pending = g.pendingIxSubstitution!; const player = getPlayer(g, pending.player); if (!canceled) { for (const [key, count] of Object.entries(pending.sources!)) kill(g, player, key, count, true, 0); for (const [key, count] of Object.entries(pending.recover!)) { player.tanks -= count; player.elites!.tanks -= count; player.battleLosses -= count; const loc = splitLocation(key); place(player, loc.territory, loc.sector, count, count); } log( g, `${player.name} substituted surviving suboids for cyborgs lost in this battle.`, ); } g.pendingIxSubstitution = null; if (quote) { log( g, 'Karama prevented Ixian substitution. Original cyborg casualties remain in the Tanks; no surviving suboids were exchanged.', ); if (quote.decision) g.decision = quote.decision; else finishBattle(g); } else finishWinner(g, player, pending.territory, pending.cards); } else if ( [ 'choamRevival', 'revivalLimit', 'revivalDiscount', 'earlyRevival', 'foreignGhola', ].includes(response.kind) ) { if (canceled) { const quote = revivalCancellationQuote(g, response)!; const player = getPlayer(g, g.pendingRevival!.player); g.revivalRules = quote.rules; g.pendingRevival = quote.pending; if (quote.block?.kind === 'foreignGhola') { player.gholaBlocked ??= {}; player.gholaBlocked[quote.block.leader] = g.turn; } if (response.kind === 'choamRevival') { log(g, 'CHOAM revival advantage was canceled for this phase.'); if (quote.outcome === 'abandoned') log( g, `${player.name} must choose a revival within the remaining allowance. No forces or spice changed.`, ); } else log(g, `${player.name}'s revival benefit was canceled.`); } else if (response.kind !== 'choamRevival') g.revivalRules ??= newRevivalRules(); if (g.pendingRevival) finishRevival(g); } else if (response.kind === 'revivalIncome') { if (!canceled) getPlayer(g, response.owner).spice += response.amount!; } else if (response.kind === 'faceDancerReplacement') { const owner = getPlayer(g, response.owner); const index = owner.faceDancers!.findIndex( (c) => c.leader === response.intent && !c.revealed, ); if (!canceled && index >= 0) { g.traitorReserve = shuffle([ ...(g.traitorReserve ?? []), owner.faceDancers![index].leader, ]); owner.faceDancers![index] = { leader: g.traitorReserve.shift()!, revealed: false, }; log(g, `${owner.name} replaced one unrevealed Face Dancer.`); } } else if (response.kind === 'worthlessKarama') { const pending = g.pendingKarama!; g.pendingKarama = null; const owner = getPlayer(g, pending.owner); if (!canceled) completeKarama(g, owner, pending.use); else if (pending.use.kind === 'cancel') g.response = pending.use.response; else if (pending.use.kind === 'auctionPayment') recoverAuctionPayment(g, owner); } else if (response.kind === 'choamSale') { const market = g.choamMarket!; const owner = getPlayer(g, market.owner); const sale = market.sale!; if (canceled) { const quote = choamSaleCancellationQuote(g, response)!; g.choamMarket = quote.market; g.decision = quote.decision; log( g, 'The declared CHOAM card sale was canceled; the card stays in hand.', ); return; } else if (quoteSale(owner.hand, sale.card, sale.witness)) { discard(g, owner, sale.card); owner.spice += sale.price; log(g, `${owner.name} received ${sale.price} spice from the card sale.`); } else log( g, 'The declared card sale is no longer possible; no spice was paid.', ); delete market.sale; resumeChoamMarket(g); } else if (response.kind === 'choamInflation') { if (!canceled) { g.inflation = { side: response.intent as Inflation['side'], placedTurn: g.turn, updatedTurn: g.turn, flipped: false, }; g.inflationUsed = true; log( g, `CHOAM placed Inflation: ${g.inflation.side} next turn’s charity.`, ); } else log(g, 'Inflation placement was canceled for this Mentat Pause.'); } else if (response.kind === 'choamCharity') { g.choamCharity = terminal?.kind === 'choamCharity' ? terminal.receipt : { turn: g.turn, canceled }; if (!canceled) { const choam = getPlayer(g, response.owner); const amount = 2 * g.players.length * charityMultiplier(g); choam.spice += amount; log(g, `${choam.name} collected ${amount} spice before charity claims.`); } else log( g, 'CHOAM income was canceled. Charity is paid by the Spice Bank this turn.', ); } else if (response.kind === 'bgCharity') { if (!canceled) payCharity(g, getPlayer(g, response.owner), response.amount ?? 2); } else if (response.kind === 'advisorFlip') { if (canceled) movementCancellationQuote(g, response); const bg = getPlayer(g, response.owner), to = response.location!; if (!canceled) { bg.advisors ??= {}; if (response.advisors) bg.advisors[to] = {}; else delete bg.advisors[to]; log( g, `${bg.name} changed all forces in ${territory(to).name} to ${response.advisors ? 'advisors' : 'fighters'}.`, ); } finishAdvisorReaction(g, response); } else if (response.kind === 'capture') { const capture = g.pendingCapture!; g.pendingCapture = null; if (canceled) finishBattle(g); else { const candidates = captureCandidates(g, capture.loser, capture.territory); const l = shuffle(candidates)[0]; if (!l) { finishBattle(g); return; } const owner = g.players.find((p) => p.leaders.includes(l))!; l.concealed = { captor: capture.player, controller: capture.loser, dead: l.dead, deaths: l.deaths, usedAt: l.usedAt, }; l.capturedBy = capture.player; g.decision = { kind: 'capturedLeader', player: capture.player, owner: owner.id, leader: l.id, controller: capture.loser, }; log( g, `${getPlayer(g, capture.player).name} captured a leader from ${getPlayer(g, capture.loser).name}.`, ); } } else if (response.kind === 'choamAudit') { const pending = g.pendingAuditor; requireRule( pending && pending.stage === 'response' && response.intent === pending.event && response.owner === pending.owner, 'This Auditor cancellation window is stale.', ); if (canceled) finishAuditor(g, 'cancel'); else if ( !currentAuditCount(g) || getPlayer(g, pending.opponent).spice < currentAuditCount(g) ) finishAuditor(g, 'inspect'); else { pending.stage = 'payment'; g.decision = { kind: 'choamAuditPayment', player: pending.opponent, event: pending.event, }; } } else if (response.kind === 'choamBattleIncome') { const pending = g.pendingChoamBattleIncome!; g.pendingChoamBattleIncome = null; if (!canceled) { const choam = getPlayer(g, pending.owner); choam.spice += pending.amount; log( g, `${choam.name} received ${pending.amount} spice from battle force payments.`, ); } else log(g, 'CHOAM battle force payments went to the bank.'); finishBattle(g); } else if (isCombatResponseKind(response.kind)) { commitCombatResponseQuote( g, currentCombatResponseQuote(g, { kind: 'response', response: { kind: response.kind, owner: response.owner }, canceled, }), ); } else if (response.kind === 'guildTiming') { if (canceled) { const quote = movementCancellationQuote(g, response); requireRule( quote?.kind === 'guildTiming', 'Missing canceled Guild timing.', ); g.guildTimingLocked = true; g.active = quote.active; } else { g.guildTimingGranted = true; chooseGuildTiming(g, response.take!); } } else if (response.kind === 'atreidesAuction') { if (g.richeseAuction) { g.richesePeekKnown = !canceled; settleRicheseLot(g); } else g.auction!.peekKnown = terminal?.kind === 'atreidesAuction' ? terminal.known : !canceled; } else if (response.kind === 'atreidesSpice') { g.spicePeekKnown = terminal?.kind === 'atreidesSpice' ? terminal.known : !canceled; } else if (response.kind === 'wormPlacement') { if (canceled) { g.wormPlacementCanceledTurn = g.turn; continueSpice(g); } else beginWorm(g, response.location!); } else if (response.kind === 'wormAllyProtection') { wormSurvival( g, response.location!, canceled ? undefined : response.recipient, ); } else if (response.kind === 'wormSurvival') { devour(g, response.location!, response.recipient, !canceled); afterWorm(g); } else if (response.kind === 'stormProtection') { if (response.resume === 'storm') { g.stormResolution!.protected = !canceled; continueStorm(g); } else { const p = getPlayer(g, response.owner); if (canceled) kill( g, p, response.location!, response.amount!, false, response.elite ?? 0, ); else stormCasualties( g, p, response.location!, response.amount!, response.elite ?? 0, 'shipment', ); } } else if (response.kind === 'stormPeek') { g.stormCardKnown = terminal?.kind === 'stormPeek' ? terminal.known : !canceled; } else if ( response.kind === 'emperorGift' || response.kind === 'emperorRevival' ) { if (!canceled) { const emperor = getPlayer(g, response.owner), recipient = getPlayer(g, response.recipient!); const amount = response.amount!; if (response.kind === 'emperorGift') { emperor.spice -= amount; recipient.spice += amount; } else { beginRevival(g, { player: recipient.id, payer: emperor.id, emperorExtra: true, kind: 'forces', amount, elite: response.elite ?? 0, cost: paidForceRevivalCost(recipient, amount, response.elite ?? 0), normalCost: paidForceRevivalCost( recipient, amount, response.elite ?? 0, ), free: 0, checks: [], }); } log( g, `${emperor.name} ${response.kind === 'emperorGift' ? 'shared spice with' : `offered ${amount} extra force revivals to`} ${recipient.name}.`, ); } } else if (response.kind === 'guildIncome') { if (!canceled) getPlayer(g, response.owner).spice += response.amount!; log( g, canceled ? `Karama sent the ${response.amount} spice of Guild shipment income to the bank. The shipment and its price remain unchanged.` : `${getPlayer(g, response.owner).name} collected ${response.amount} spice paid by other factions for shipment. Spice contributed by the Guild itself goes to the bank.`, { faction: 'guild', name: canceled ? 'Shipment income prevented' : 'Shipment income', }, ); } else if (response.kind === 'harkonnenTraitor') { const b = g.battle!; if (canceled) b.traitorCalls[response.owner] = false; if ( traitorVoters(g, b).every((voter) => b.traitorCalls[voter] !== undefined) ) resolveBattle(g); } else if (response.kind === 'richesePurchaseIncome') { const pending = g.pendingRichesePurchaseIncome; requireRule( pending && pending.owner === response.owner && pending.turn === g.turn && pending.phase === g.phase && response.amount === 3, 'This Richese purchase income is no longer current.', ); if (!canceled) getPlayer(g, response.owner).spice += 3; log( g, canceled ? 'Karama prevented the Emperor from collecting the 3-spice Richese purchase payment. The purchase remains completed and the payment goes to the bank.' : `${getPlayer(g, response.owner).name} collected 3 spice because the Emperor receives another faction’s Treachery Card purchase payment.`, { faction: 'emperor', name: canceled ? 'Purchase income prevented' : 'Card purchase income', }, ); const resume = richeseCancellation?.kind === 'richesePurchaseIncome' ? richeseCancellation.resume : pending.resume; g.pendingRichesePurchaseIncome = null; g.response = resume.response; g.decision = resume.decision; g.pendingKarama = resume.pendingKarama; } else if (response.kind === 'emperorIncome') { if (!canceled) { const owner = getPlayer(g, response.owner); const amount = g.currentAuctionSale?.amount ?? g.auction!.bid; owner.spice += amount; log( g, `${owner.name} received ${amount} spice because the Emperor collects another faction’s paid auction bid.`, { faction: owner.faction, name: 'Auction income' }, ); } auctionBonus(g); } else if (response.kind === 'harkonnenBonus') { if (!canceled) { const owner = getPlayer(g, response.owner); const room = owner.hand.length < handLimit(owner); const bonus = room ? draw(g) : undefined; if (bonus) { owner.hand.push(bonus); log( g, `${owner.name} drew one bonus treachery card because Harkonnen receives an extra card after winning an auction while below its eight-card limit. Its identity remains private.`, { faction: owner.faction, name: 'Bonus treachery card' }, ); } else log( g, room ? `${owner.name} received no Harkonnen bonus card because no treachery card was available to draw.` : `${owner.name} received no Harkonnen bonus because their hand reached its eight-card limit before the draw.`, ); } else log(g, 'Karama prevented the Harkonnen bonus treachery card.'); nextAuction(g); } else if (response.kind === 'advisor') { if (response.advisorResume === 'ambassador') { finishGuildAmbassadorAdvisor(g, response, canceled); return; } if (canceled) return; const owner = getPlayer(g, response.owner); requireRule(owner.reserves > 0, 'No forces remain in reserves.'); owner.reserves--; const target = splitLocation(response.location ?? 'polar_sink:0'); const advisors = arrivalAsAdvisor( g, owner, target.territory, undefined, target.territory !== 'polar_sink', ); place(owner, target.territory, target.sector, 1); techIncome(g, 'heighliners', owner); if (advisors) { owner.advisors ??= {}; owner.advisors[target.territory] = { lockedTurn: g.turn }; } log( g, `${owner.name} sent a free force to ${territory(target.territory).name}.`, ); openTerritoryEntry( g, owner, target.territory, target.sector, 1, 0, 'advisor', ); } else throw new RuleError('Unknown faction response.'); } function validatePhysicalShipment(g: Game, shipment: PendingShipment) { if (shipment.source === 'ambassador') { validateAmbassadorShipmentOrder(g, shipment); return; } // Concealed and allied No-Fields retain their separate custody/quote contracts. if (shipment.noField || shipment.alliedNoField) return; const p = getPlayer(g, shipment.player); requireRule( g.status === 'playing' && g.phase === 5 && g.active === p.id && !p.shipped && (shipment.turn === undefined || shipment.turn === g.turn), 'This shipment no longer belongs to the current unused shipment opportunity.', ); const n = integer(shipment.amount, 1, p.reserves, 'Shipment forces'); integer(shipment.sector, 0, 18, 'Shipment sector'); eliteChoice(n, p.reserves, p.elites?.reserves ?? 0, shipment.elite); requireRule( typeof shipment.elite === 'number' && shipment.advisors === arrivalAsAdvisor(g, p, shipment.territory), 'The declared force allocation or arrival stance is no longer current.', ); requireRule( shipment.territory !== MOBILE_STRONGHOLD || p.faction === 'ixians', 'Only Ixians may ship directly into the mobile stronghold.', ); allowedEntry( g, p, shipment.territory, shipment.sector, g.advanced && p.faction === 'fremen', shipment.advisors, ); if (p.faction === 'fremen') requireRule( territory('the_great_flat').sectors.some((from) => territory(shipment.territory).sectors.some( (to) => distance( location('the_great_flat', from), location(shipment.territory, to), ) <= 2, ), ), 'Fremen reinforcements must arrive within two territories of the Great Flat.', ); const cost = reserveShipmentCost( { faction: p.faction, halfRate: p.faction === 'guild' || byFaction(g, 'guild')?.id === p.ally || g.karamaShipping?.player === p.id, }, territory(shipment.territory).type, n, ); requireRule( Number.isSafeInteger(shipment.cost) && shipment.cost === cost, 'The declared shipment price is no longer current.', ); integer(p.spice, 0, Number.MAX_SAFE_INTEGER, 'Current shipment spice'); integer(shipment.allyPayment, 0, cost, 'Shipment ally payment'); const credit = aidFor(g, p); if (credit) integer(credit.amount, 0, Number.MAX_SAFE_INTEGER, 'Current ally pledge'); if (shipment.allyPayment > 0) requireRule( p.ally && getPlayer(g, p.ally).ally === p.id, 'The declared shipment contributor is no longer your mutual ally.', ); contribution(g, p, cost, shipment.allyPayment); } function validateGuildShipmentDecision( g: Game, decision: Extract, ) { const shipment = g.pendingShipment; const guild = byFaction(g, 'guild'); requireRule( shipment && g.advanced && guild?.id === decision.player && !guild.specialKaramaUsed && decision.shipper === shipment.player && getPlayer(g, shipment.player).faction !== 'fremen' && decision.territory === shipment.territory && decision.sector === shipment.sector && decision.amount === (shipment.alliedNoField ? 1 : shipment.amount), 'The Guild decision does not match the pending shipment.', ); validatePhysicalShipment(g, shipment); return shipment; } function offerShipment(g: Game, shipment: PendingShipment) { validatePhysicalShipment(g, shipment); const p = getPlayer(g, shipment.player), id = p.id; const { territory: to, sector: s } = shipment; const n = shipment.alliedNoField ? 1 : shipment.amount; const guild = byFaction(g, 'guild'); if ( g.advanced && g.phase === 5 && guild && !guild.specialKaramaUsed && p.faction !== 'fremen' ) { // Offer independently of the Guild's hidden hand. No payment or arrival // effects occur until this decision has resolved. g.pendingShipment = shipment; g.decision = { kind: 'guildShipment', player: guild.id, shipper: id, territory: to, sector: s, amount: n, }; log( g, `${p.name} declared a shipment of ${n} forces to ${territory(to).name}, sector ${s}.`, ); } else commitShipment(g, shipment); } function commitShipment(g: Game, shipment: PendingShipment) { if (shipment.source === 'ambassador') { commitAmbassadorShipment(g, shipment); return; } validatePhysicalShipment(g, shipment); finishShipmentPromises( g, getPlayer(g, shipment.player), shipment.noField || shipment.alliedNoField ? null : shipment, ); const { territory: to, sector: s, amount: n, elite, cost, allyPayment, advisors, } = shipment; const p = getPlayer(g, shipment.player); if (shipment.noField) { requireRule( p.faction === 'richese' && p.noField && p.noFieldEvent === shipment.noField.event, 'This No-Field shipment is no longer current.', ); p.noField = noFieldRule(() => deployRicheseNoField(p.noField!, { tokenId: shipment.noField!.tokenId, controller: p.id, location: { territory: to, sector: s }, }), ); p.noFieldEvent = crypto.randomUUID(); } if (shipment.alliedNoField) { const offer = shipment.alliedNoField; let quote: ReturnType; try { quote = alliedNoFieldQuote(g, offer); requireRule( quote.amount === n && quote.cost === cost && elite >= quote.eliteMin && elite <= quote.eliteMax, 'The offered reserve allocation changed before shipment.', ); } catch (error) { if (!(error instanceof RuleError)) throw error; g.pendingShipment = null; log( g, 'The allied No-Field declaration became unavailable before commitment. No shipment, payment or token reveal occurred; the recipient may ship normally.', ); return; } const owner = getPlayer(g, offer.owner); if (owner.noField!.deployed) revealPlayerNoField(g, owner, 'beforeAlly'); const result = noFieldRule(() => shipAlliedRicheseNoField( owner.noField!, { tokenId: offer.tokenId, controller: p.id, location: { territory: to, sector: s }, }, p.reserves, ), ); owner.noField = result.state; owner.noFieldEvent = crypto.randomUUID(); owner.spice -= quote.ownerPayment; p.spice -= quote.recipientPayment; } else payWithAlly(g, p, cost, allyPayment); if (!shipment.noField) { p.reserves -= n; if (p.elites) p.elites.reserves -= elite; if (n > 0) place(p, to, s, n, elite); } if (advisors && n > 0) (p.advisors ??= {})[to] ??= {}; p.shipped = true; if (p.faction !== 'fremen') techIncome(g, 'heighliners', p); const guild = byFaction(g, 'guild'); const guildPayment = guildShipmentIncome({ guild: guild?.id, shipper: p.id, ally: p.ally, cost, allyPayment, bankOnly: g.karamaShipping?.player === p.id, }); if (guild && guildPayment > 0) g.response = { kind: 'guildIncome', owner: guild.id, amount: guildPayment, passed: [], }; g.karamaShipping = null; log( g, shipment.alliedNoField ? `${getPlayer(g, shipment.alliedNoField.owner).name} shipped ${p.name} with No-Field ${quoteAlliedTokenValue(g, shipment.alliedNoField)}, immediately placing ${n} physical forces (${elite} elite) in ${territory(to).name}, sector ${s}. ${shipment.alliedNoField.payer === 'both' ? 'Each ally paid 1 spice' : `${getPlayer(g, shipment.alliedNoField.payer).name} paid ${cost} spice`}.` : shipment.noField ? `${p.name} shipped one concealed No-Field to ${territory(to).name}, sector ${s}, for ${cost} spice. It counts as one force; physical reserves remain unchanged until reveal.` : `${p.name} shipped ${n} forces to ${territory(to).name}, sector ${s}.`, shipment.noField || shipment.alliedNoField ? { faction: 'richese', name: 'No-Field shipment' } : undefined, ); const bg = byFaction(g, 'beneGesserit'); const followup = p.faction !== 'fremen' && bg && bg.id !== p.id && bg.reserves > 0 ? { shipment: p.id, destination: location(to, s) } : undefined; if (!intrusion(g, p, to, { followup }) && followup) g.decision = { kind: 'advisor', player: bg!.id, ...followup }; if (g.advanced && p.faction === 'fremen' && s === g.storm) g.response = { kind: 'stormProtection', owner: p.id, passed: [], resume: 'shipment', location: location(to, s), amount: n, elite, }; openTerritoryEntry( g, p, to, s, shipment.alliedNoField ? Math.max(1, n) : n, elite, 'shipment', ); } function quoteAlliedTokenValue(g: Game, offer: RicheseAllyOffer) { return getPlayer(g, offer.owner).noField!.tokens.find( (t) => t.id === offer.tokenId, )!.value; } function validateMobileMove(g: Game, p: Player, input: unknown, max: number) { requireRule( p.faction === 'ixians' && g.mobileStronghold?.location && at(p, MOBILE_STRONGHOLD) > 0, 'Ixian forces must occupy the mobile stronghold to move it.', ); requireRule( Array.isArray(input) && input.every((key) => typeof key === 'string'), 'Declare a route of board sectors.', ); const route = input as string[]; requireRule( route.every((key) => splitLocation(key).sector !== g.storm), 'The mobile stronghold cannot move into, out of or through the storm.', ); requireRule( route.length > 1 && route[0] === g.mobileStronghold.location && mobileRouteDistance(route) <= max, `Choose a connected route of at most ${max} territories, starting at the stronghold.`, ); requireRule( territory(splitLocation(route.at(-1)!).territory).type !== 'stronghold', 'The mobile stronghold must finish in a non-stronghold territory.', ); return route; } function relocateMobileStronghold( g: Game, move: { player: string; route: string[]; collect: boolean }, ) { const p = getPlayer(g, move.player); let collected = 0; if (move.collect) for (const key of move.route) { const amount = Math.min(g.spice[key] ?? 0, at(p, MOBILE_STRONGHOLD) * 2); if (amount) { g.spice[key] -= amount; p.spice += amount; collected += amount; } } g.mobileStronghold!.location = move.route.at(-1)!; const loc = splitLocation(g.mobileStronghold!.location); log( g, `${p.name} moved the Hidden Mobile Stronghold to ${territory(loc.territory).name}, sector ${loc.sector}, collecting ${collected} spice.`, ); } function choamWorthlessBlocked(g: Game, card: string) { return ( g.choamWorthlessBlocked?.turn === g.turn && g.choamWorthlessBlocked.phase === g.phase && g.choamWorthlessBlocked.cards.includes(card) ); } function gamontAvailable( g: Game, target: string, key: string, elite: number, noFieldEvent?: string, ) { const p = g.players.find((p) => p.id === target); if (!p || !key) return false; if (noFieldEvent) { const marker = p.noField?.deployed; return ( !!marker && p.noFieldEvent === noFieldEvent && location(marker.location.territory, marker.location.sector) === key && elite === 0 ); } if ( p.noField?.deployed && location( p.noField.deployed.location.territory, p.noField.deployed.location.sector, ) === key ) return false; const n = p.forces[key] ?? 0, e = p.elites?.forces[key] ?? 0; return elite === 1 ? e > 0 : elite === 0 && n > e; } function playChoamWorthless(g: Game, p: Player, action: Action) { const storming = g.decision?.kind === 'choamStorm' && g.decision.player === p.id; const reactive = g.decision?.kind === 'choamFreeRevival' && g.decision.player === p.id; const moving = g.decision?.kind === 'choamMovement' && g.decision.player === p.id; const mentat = g.decision?.kind === 'choamMentat' && g.decision.player === p.id; requireRule( g.status === 'playing' && p.faction === 'choam' && !g.response && !g.choamMarket && (!g.decision || reactive || mentat || moving || storming) && (!g.pendingRevival || reactive), 'CHOAM must finish the current decision before playing this Worthless effect.', ); const card = p.hand.find( (c) => c.id === action.card && c.kind === 'worthless', ); requireRule( card && !choamWorthlessBlocked(g, card.id), 'Choose an owned Worthless card whose effect is not blocked this phase.', ); let effect: 'kulon' | 'laLaLa' | 'gamont' | 'baliset' | 'jubba'; let target: string | undefined; let key: string | undefined; let elite: number | undefined; let noFieldEvent: string | undefined; if (card.name === 'Kulon') { requireRule( g.ornithopter?.mode !== 'range3' || g.ornithopter.player !== p.id, 'Kulon combined with fixed Ornithopter range awaits a ruling.', ); requireRule( g.phase === 5 && g.active === p.id && p.moved < movesAllowed(g, p), 'Use Kulon on your movement turn before an available move.', ); effect = 'kulon'; } else if (card.name === 'Jubba Cloak') { requireRule( g.phase === 0 && storming && g.stormResolution, 'Use Jubba Cloak when CHOAM responds to the moving storm.', ); key = stringField(action.territory); requireRule( choamStormOptions(g).some((t) => t.territory === key), 'Choose an unprotected territory where the moving storm threatens CHOAM forces.', ); requireRule( action.target === undefined || action.target === p.id, 'Jubba Cloak protects only CHOAM forces.', ); target = p.id; effect = 'jubba'; } else if (card.name === 'Baliset') { requireRule( g.phase === 5, 'Baliset is played during Shipment and Movement.', ); target = moving && g.decision?.kind === 'choamMovement' ? g.decision.mover : stringField(action.target); key = moving && g.decision?.kind === 'choamMovement' ? g.decision.territory : stringField(action.territory); requireRule( target !== p.id && g.players.some((other) => other.id === target), 'Choose another player.', ); requireRule( gameTerritories(g).some((t) => t.id === key) && at(p, key) > 0, 'Choose a territory CHOAM occupies.', ); requireRule( !g.choamBaliset?.some( (b) => b.turn === g.turn && b.player === target && b.territory === key, ), 'That player and territory are already restricted.', ); effect = 'baliset'; } else if (card.name === 'Trip to Gamont') { requireRule( g.phase === 8 && (action.amount === undefined || action.amount === 1), 'Trip to Gamont returns exactly one force during Mentat Pause.', ); target = stringField(action.target); const other = getPlayer(g, target); requireRule( other.id !== p.id, 'Choose a force belonging to another player.', ); key = stringField(action.from); const loc = splitLocation(key); const marker = other.noField?.deployed; const concealed = !!marker && location(marker.location.territory, marker.location.sector) === key; requireRule( validLocation(loc.territory, loc.sector) && ((other.forces[key] ?? 0) > 0 || concealed), 'Choose an occupied board sector.', ); if (concealed) { requireRule( other.faction === 'richese' && !!other.noFieldEvent && (action.elite === undefined || action.elite === 0), 'Choose the Richese No-Field sector and ordinary force type.', ); noFieldEvent = other.noFieldEvent; elite = 0; } else elite = eliteChoice( 1, other.forces[key], other.elites?.forces[key] ?? 0, action.elite, ); effect = 'gamont'; } else { requireRule( card.name === 'La La La' && g.phase === 4, 'This Worthless effect is not available in this phase.', ); effect = 'laLaLa'; target = reactive && g.decision?.kind === 'choamFreeRevival' ? g.decision.recipient : stringField(action.target); getPlayer(g, target); requireRule( !g.revivalRules?.freeBlocked?.includes(target), 'That player is already prevented from free revival.', ); } g.pendingChoamWorthless = { owner: p.id, card: card.id, effect, target, revival: reactive, movement: moving, storm: storming, location: key, elite, mentat, ...(noFieldEvent ? { noFieldEvent } : {}), }; if (reactive || mentat || moving || storming) g.decision = null; g.ready = []; g.response = { kind: 'choamWorthless', owner: p.id, ...(target ? { recipient: target } : {}), ...(key ? { location: effect === 'jubba' ? location( key, choamStormOptions(g).find((t) => t.territory === key)! .sectors[0], ) : effect === 'baliset' ? location( key, g.pendingChoamMove?.sector ?? territory(key).sectors[0], ) : key, ...(elite !== undefined ? { elite } : {}), } : {}), intent: card.name, passed: [], }; log(g, `${p.name} declared ${card.name} for its special effect.`); } /** Internal normalized command; never accept this object directly from a route. */ export type SpecialKaramaIntent = { owner: string; card: string; turn: number; phase: number; } & ( | { kind: 'choam'; cards: string[] } | { kind: 'richese'; acquire: string } | { kind: 'ixians'; route: string[]; collect: boolean } | { kind: 'tleilaxu'; target: string; revival: PendingRevival } | { kind: 'fremen'; territory: string } | { kind: 'atreides'; target: string; battle: Pick; } | { kind: 'guild'; target: string; shipment: PendingShipment } | { kind: 'emperorForces'; amount: number; elite: number } | { kind: 'emperorLeader'; leader: string } | { kind: 'harkonnen'; target: string; amount: number } ); /** Validates and copies a selection without spending resources, continuing play, or drawing RNG. */ export function prepareSpecialKaramaIntent( g: Game, playerId: string, action: Action, ): SpecialKaramaIntent { const p = getPlayer(g, playerId); // Delayed execution must respect the same enclosing windows as dispatch. // CHOAM's early cash-in bypasses the later gates, but never Truthtrance. requireRule(!g.truthtrance, 'Resolve the current Truthtrance first.'); if (p.faction !== 'choam') { requireRule( !g.phaseOpening, 'Play Amal or pass the phase opening before taking other actions.', ); requireRule( !g.choamMarket, 'Finish the end-of-phase exchange before another special power.', ); requireRule( !g.pendingRevival || (p.faction === 'tleilaxu' && g.decision?.kind === 'revivalStop'), 'Resolve the pending revival first.', ); } requireRule( g.status === 'playing' && g.advanced, 'Special Karama powers require the advanced game.', ); requireRule( !p.specialKaramaUsed, 'Your special Karama power has already been used this game.', ); const card = p.hand.find( (c) => c.id === action.card && c.effect === 'karama', ); requireRule(card, 'Choose a Karama card in your hand.'); const base = { owner: p.id, card: card.id, turn: g.turn, phase: g.phase }; if (p.faction === 'richese') { const blocked = karamaSpendingBlock(g, p, card); requireRule(!blocked, blocked ?? 'This Karama is committed elsewhere.'); requireRule( !( g.richeseAuction?.source === 'blackMarket' && !g.currentAuctionSale && g.richeseAuction.owner === p.id && g.richeseAuction.cardId === card.id ), 'The Karama reserved for Black Market cannot activate another effect.', ); requireRule( p.hand.length < handLimit(p), 'Special Richese Karama with a full hand awaits a ruling on acquisition and discard order.', ); requireRule( uncommittedSpice(g, p) >= 3, 'This purchase needs 3 uncommitted spice.', ); requireRule( !g.pendingRichesePurchaseIncome, 'Resolve the previous purchase income first.', ); const acquire = stringField(action.acquire); const chosen = g.richeseCache?.find((c) => c.id === acquire); requireRule( chosen && richeseCardDefinition(chosen), 'Choose a verified card in your Richese cache.', ); requireRule( g.richeseAuction?.source !== 'cache' || g.currentAuctionSale || g.richeseAuction.cardId !== acquire, 'The card already offered for auction must remain in that auction.', ); requireRule( g.richeseCache!.length > 1, 'Buying the final cache card awaits the empty-cache Bidding rule implementation.', ); return { ...base, kind: 'richese', acquire }; } else if (p.faction === 'choam') { requireRule( Array.isArray(action.cards) && action.cards.length > 0 && action.cards.every((id) => typeof id === 'string'), 'Select cards to discard for spice.', ); const selected = action.cards as string[]; requireRule( new Set(selected).size === selected.length && !selected.includes(card.id), 'Choose distinct cards; the activating Karama is spent separately.', ); const available = cashInCards(g, p); requireRule( [card.id, ...selected].every((id) => available.some((c) => c.id === id)), 'Choose your own uncommitted cards. Sealed and prescience cards must remain available.', ); const removed = new Set([card.id, ...selected]); const credit = g.auction?.allyPayment ?? 0; requireRule( !g.auction || g.auction.bidder !== p.id || g.auction.bid <= p.spice + selected.length * 3 + credit || p.hand.some((c) => c.effect === 'karama' && !removed.has(c.id)), 'Retain enough spice or Karama to honor your current winning bid.', ); return { ...base, kind: 'choam', cards: [...selected] }; } else if (p.faction === 'ixians') { requireRule( g.phase === 5 && g.active === p.id && !g.response && !g.decision, 'Move the stronghold with special Karama during your own Shipment and Movement turn.', ); const route = validateMobileMove(g, p, action.route, 2); return { ...base, kind: 'ixians', route: [...route], collect: action.collect !== false, }; } else if (p.faction === 'tleilaxu') { requireRule( g.phase === 4 && !g.response && g.decision?.kind === 'revivalStop' && g.decision.player === p.id && g.pendingRevival, 'Wait for a normal revival declaration before preventing it.', ); const target = g.pendingRevival.player; requireRule( action.target === undefined || action.target === target, 'This decision applies to the faction currently reviving.', ); return { ...base, kind: 'tleilaxu', target, revival: structuredClone(g.pendingRevival), }; } else if (p.faction === 'fremen') { requireRule( g.phase === 1 && !g.summonedWorm, 'Summon Shai-Hulud during Spice Blow and Nexus.', ); const t = stringField(action.territory); requireRule( TERRITORIES.some((x) => x.id === t && x.type === 'sand'), 'Summon Shai-Hulud in a sand territory.', ); return { ...base, kind: 'fremen', territory: t }; } else if (p.faction === 'atreides') { const b = g.battle; requireRule( g.phase === 6 && !g.response && g.decision?.kind === 'fullPlanOffer' && g.decision.player === p.id && b && !b.revealed && !b.preparation && !Object.keys(b.plans).length, 'Choose special prescience after battle preparation and before plans are sealed.', ); const target = stringField(action.target); requireRule( [b.attacker, b.defender].includes(target), 'Choose a player in this battle.', ); requireRule( !b.noFieldPlayers?.includes(target), 'Whole-plan special prescience against a No-Field awaits its disclosure ruling. Ordinary permitted elements remain available.', ); return { ...base, kind: 'atreides', target, battle: { territory: b.territory, attacker: b.attacker, defender: b.defender, }, }; } else if (p.faction === 'guild') { requireRule( g.phase === 5 && !g.response && g.decision?.kind === 'guildShipment' && g.decision.player === p.id && g.pendingShipment, 'Wait for an off-planet shipment declaration.', ); validateGuildShipmentDecision(g, g.decision); const shipper = getPlayer(g, g.pendingShipment.player); return { ...base, kind: 'guild', target: shipper.id, shipment: { ...g.pendingShipment }, }; } else if (p.faction === 'emperor') { requireRule( action.target === undefined || action.target === p.id, 'This revival power applies to the Emperor’s own forces or leaders.', ); requireRule( g.phase === 4, 'The Emperor special Karama is used during Revival.', ); if (action.leader) { const l = p.leaders.find((l) => l.id === action.leader); requireRule( l?.dead && !l.capturedBy && !l.gholaBy, 'Choose one of your dead leaders.', ); requireRule( action.amount === undefined && action.elite === undefined, 'Choose forces or one leader, not both.', ); return { ...base, kind: 'emperorLeader', leader: l.id }; } const n = integer(action.amount, 1, Math.min(3, p.tanks), 'Forces'); const elite = eliteChoice(n, p.tanks, p.elites?.tanks ?? 0, action.elite); requireRule( elite <= eliteRevivalRemaining(p), 'Only one elite force may be revived per turn.', ); return { ...base, kind: 'emperorForces', amount: n, elite }; } else if (p.faction === 'harkonnen') { requireRule( g.phase === 3 && !g.pendingExchange, 'The Harkonnen special Karama is used during Bidding.', ); const target = getPlayer(g, stringField(action.target)); requireRule(target.id !== p.id, 'Choose another player’s hand.'); requireRule( g.pendingRicheseGift?.intent.owner !== target.id, 'Resolve the reserved Richese gift before randomly exchanging that hand.', ); const count = integer( action.amount, 1, Math.min(4, target.hand.length), 'Cards to take', ); requireRule( action.cards === undefined, 'Cards are drawn unseen, not selected by identity.', ); return { ...base, kind: 'harkonnen', target: target.id, amount: count }; } else throw new RuleError( 'This faction’s special Karama power is still being implemented.', ); } // Convert only known normalized fields for revalidation. The original client Action // is never retained, and omitted defaults cannot choose another card or elite mix. function specialKaramaAction(intent: SpecialKaramaIntent): Action { const action = { type: 'card', mode: 'special', card: intent.card }; switch (intent.kind) { case 'richese': return { ...action, acquire: intent.acquire }; case 'choam': return { ...action, cards: [...intent.cards] }; case 'ixians': return { ...action, route: [...intent.route], collect: intent.collect }; case 'tleilaxu': case 'atreides': case 'guild': return { ...action, target: intent.target }; case 'fremen': return { ...action, territory: intent.territory }; case 'emperorLeader': return { ...action, leader: intent.leader }; case 'emperorForces': return { ...action, amount: intent.amount, elite: intent.elite }; case 'harkonnen': return { ...action, target: intent.target, amount: intent.amount }; } } /** Revalidates exact custody and the original window before any mutation. * A future paused-intent caller must restore its original decision/response first; * this executor does not open, pass, or recreate a reaction window. * Like other effect executors, it mutates a disposable working Game. The caller * must publish that clone only after success; applyAction provides this boundary. */ export function executeSpecialKaramaIntent( g: Game, intent: SpecialKaramaIntent, ): void { requireRule( g.turn === intent.turn && g.phase === intent.phase, 'This special Karama declaration belongs to an earlier turn or phase.', ); const prepared = prepareSpecialKaramaIntent( g, intent.owner, specialKaramaAction(intent), ); requireRule( prepared.kind === intent.kind, 'This special Karama declaration belongs to a different faction.', ); // Pending declarations contain scalar fields and (for revival) an ordered checks // array. Compare field values independently of JSON object property order. const sameDeclaration = (before: object, now: object) => { const previous = before as Record; const current = now as Record; return [...new Set([...Object.keys(before), ...Object.keys(now)])].every( (key) => JSON.stringify(previous[key]) === JSON.stringify(current[key]), ); }; if (intent.kind === 'tleilaxu' && prepared.kind === 'tleilaxu') requireRule( intent.target === prepared.target && sameDeclaration(intent.revival, prepared.revival), 'The original revival declaration is no longer pending.', ); if (intent.kind === 'guild' && prepared.kind === 'guild') requireRule( intent.target === prepared.target && sameDeclaration(intent.shipment, prepared.shipment), 'The original shipment declaration is no longer pending.', ); if (intent.kind === 'atreides' && prepared.kind === 'atreides') requireRule( sameDeclaration(intent.battle, prepared.battle), 'The original battle is no longer awaiting special prescience.', ); const p = getPlayer(g, intent.owner); const card = { id: intent.card }; if (intent.kind === 'richese') { const selected = g.richeseCache!.findIndex((c) => c.id === intent.acquire); discard(g, p, card.id); p.spice -= 3; p.hand.push(...g.richeseCache!.splice(selected, 1)); p.specialKaramaUsed = true; log( g, `${p.name} spent Karama and 3 spice to buy one secretly chosen card from the Richese cache. This once-per-game special power cannot be canceled.`, { faction: 'richese', name: 'Special Karama purchase' }, ); const emperor = byFaction(g, 'emperor'); if (emperor) { g.pendingRichesePurchaseIncome = { owner: emperor.id, turn: g.turn, phase: g.phase, resume: { response: g.response, decision: g.decision, pendingKarama: g.pendingKarama ?? null, }, }; g.decision = null; g.pendingKarama = null; g.response = { kind: 'richesePurchaseIncome', owner: emperor.id, amount: 3, passed: [], }; } } else if (intent.kind === 'choam') { const selected = intent.cards; discard(g, p, card.id); for (const id of selected) discard(g, p, id); p.spice += selected.length * 3; p.specialKaramaUsed = true; log( g, `${p.name} used special Karama to discard ${selected.length} cards and gain ${selected.length * 3} spice.`, ); } else if (intent.kind === 'ixians') { const route = intent.route; discard(g, p, card.id); p.specialKaramaUsed = true; relocateMobileStronghold(g, { player: p.id, route, collect: intent.collect, }); } else if (intent.kind === 'tleilaxu') { const target = intent.target; discard(g, p, card.id); p.specialKaramaUsed = true; g.revivalPrevention = { player: target, turn: g.turn }; g.pendingRevival = null; g.decision = null; if (g.revivalRequests) delete g.revivalRequests[target]; log( g, `${p.name} used special Karama to prevent ${getPlayer(g, target).name}’s normal force and leader revivals for this turn.`, ); } else if (intent.kind === 'fremen') { const t = intent.territory; discard(g, p, card.id); p.specialKaramaUsed = true; g.summonedWorm = { territory: t, resume: { response: g.response, decision: g.decision, pendingKarama: g.pendingKarama, spiceWindow: g.spiceWindow, spiceResolution: g.spiceResolution, spiceSequence: g.spiceSequence, nexus: g.nexus, wormRides: g.wormRides, ready: g.ready, }, }; g.response = null; g.decision = null; g.pendingKarama = null; g.spiceWindow = null; g.spiceResolution = null; g.spiceSequence = null; g.wormRides = []; g.ready = []; g.nexus = true; log( g, `${p.name} used special Karama to summon Shai-Hulud in ${territory(t).name}.`, ); beginWorm(g, t); } else if (intent.kind === 'atreides') { const b = g.battle!, target = intent.target; discard(g, p, card.id); p.specialKaramaUsed = true; b.fullPlan = { owner: p.id, target }; g.decision = null; log( g, `${p.name} used special Karama to inspect ${getPlayer(g, target).name}’s entire battle plan.`, ); } else if (intent.kind === 'guild') { const shipper = getPlayer(g, intent.target); discard(g, p, card.id); p.specialKaramaUsed = true; if (g.pendingShipment?.source === 'ambassador') { currentGuildAmbassador(g, g.pendingShipment.ambassadorEvent); g.pendingShipment = null; g.decision = null; log( g, `${p.name} used special Karama to stop ${shipper.name}’s Guild Ambassador shipment. No forces arrive; ordinary shipment and movement allowances are unchanged.`, ); finishAmbassador(g); return; } shipper.shipped = true; // Provisional canceled-shipment settlement pending a primary-source // clarification: retain the attempted shipment's spice and rate card. const benefit = g.karamaShipping; if (benefit?.player === shipper.id && benefit.card) { const index = g.discard.findIndex((c) => c.id === benefit.card); if (index >= 0) getPlayer(g, benefit.owner).hand.push(...g.discard.splice(index, 1)); } g.karamaShipping = null; g.pendingShipment = null; g.decision = null; log( g, `${p.name} used special Karama to stop ${shipper.name}’s shipment. No forces arrive or spice is paid; movement remains available.`, ); } else if ( intent.kind === 'emperorLeader' || intent.kind === 'emperorForces' ) { if (intent.kind === 'emperorLeader') { const l = p.leaders.find((l) => l.id === intent.leader)!; l.dead = false; delete l.concealed; log(g, `${p.name} used special Karama to revive ${l.name} for free.`); } else { const n = intent.amount, elite = intent.elite; p.tanks -= n; p.reserves += n; if (p.elites) { p.elites.tanks -= elite; p.elites.reserves += elite; p.elites.revived += elite; } log(g, `${p.name} used special Karama to revive ${n} forces for free.`); } techIncome(g, 'axlotl', p); discard(g, p, card.id); p.specialKaramaUsed = true; } else if (intent.kind === 'harkonnen') { const target = getPlayer(g, intent.target), count = intent.amount; discard(g, p, card.id); p.specialKaramaUsed = true; const taken = shuffle(target.hand).slice(0, count); target.hand = target.hand.filter((c) => !taken.some((t) => t.id === c.id)); p.hand.push(...taken); g.pendingExchange = { response: g.response, decision: g.decision }; g.response = null; g.decision = { kind: 'handExchange', player: p.id, target: target.id, count, }; log( g, `${p.name} used special Karama to take ${count} unseen cards from ${target.name}; an equal return is pending.`, ); } } function specialKarama(g: Game, p: Player, action: Action) { executeSpecialKaramaIntent(g, prepareSpecialKaramaIntent(g, p.id, action)); } /** Private, read-only choices; validation performs no draws or continuations. */ function richeseSpecialKaramaView(g: Game, p: Player) { if ( g.status !== 'playing' || !g.advanced || p.faction !== 'richese' || p.specialKaramaUsed ) return null; const karamas = p.hand.filter( (c) => c.effect === 'karama' && !karamaSpendingBlock(g, p, c) && !( g.richeseAuction?.source === 'blackMarket' && !g.currentAuctionSale && g.richeseAuction.owner === p.id && g.richeseAuction.cardId === c.id ), ); const cache = (g.richeseCache ?? []).filter((c) => richeseCardDefinition(c)); const cards: Card[] = []; let blocked: string | null = !karamas.length ? 'A spendable Karama card is required.' : !cache.length ? 'There are no verified cards in your Richese cache.' : null; if (karamas.length) for (const candidate of cache) { try { prepareSpecialKaramaIntent(g, p.id, { type: 'card', mode: 'special', card: karamas[0].id, acquire: candidate.id, }); cards.push(candidate); } catch (error) { if (!(error instanceof RuleError)) throw error; blocked ??= error.message; } } return { cards, karamas, blocked: cards.length ? null : blocked, payee: byFaction(g, 'emperor')?.id ?? null, }; } type CompletedMovement = Pick< MovementOrder, 'player' | 'origin' | 'total' | 'to' | 'sector' | 'elite' > & { noField: boolean }; type MovementOrder = { source?: 'ambassador'; ambassadorEvent?: string; player: string; group: [string, number][]; eliteGroup: Record; elite: number; origin: string; total: number; to: string; sector: number; advisors: boolean; wantsFighters: boolean; lockedTurn?: number; noField?: { tokenId: string; event: string; from: string }; ornithopterEvent?: string; ornithopterRange?: boolean; }; export type Action = { type: string; [key: string]: unknown }; function normalizeCardNames(g: Game) { normalizeLegacyCardNames([ ...g.deck, ...g.discard, ...(g.ornithopter ? [g.ornithopter.card] : []), ...g.players.flatMap((p) => p.hand), ...(g.auction?.cards ?? []), ...(g.ixSetupCards ?? []), ...(g.ixAuction?.cards ?? []), ...(g.ixAuctionKnown?.cards ?? []), ]); } export function applyAction(state: Game, id: string, action: Action): Game { karamaConversionIntegrity(state); treacheryDiscardIntegrity(state); shipmentPromiseIntegrity(state); saphoMovementIntegrity(state); ambassadorRelocationIntegrity(state); ecazCollectionIntegrity(state); // Seat control is independent of gameplay locks and must preserve their exact continuation. if (action?.type === 'setAutopilot') { requireRule( state.status === 'setup' || state.status === 'playing', 'AI control is available after the game starts and before it ends.', ); const player = getPlayer(state, id); requireRule(!player.bot, 'This is a permanent AI seat.'); requireRule( Object.keys(action).every( (key) => key === 'type' || key === 'difficulty', ), 'You may change AI control only for your own seat.', ); requireRule( action.difficulty === null || (typeof action.difficulty === 'string' && ['Easy', 'Medium', 'Hard', 'Brutal'].includes(action.difficulty)), 'Choose an AI difficulty or take back control.', ); const g = structuredClone(state); const owner = getPlayer(g, id); if ((owner.autopilot ?? null) === action.difficulty) return g; if (action.difficulty === null) { delete owner.autopilot; log(g, `${owner.name} took back control from AI.`); } else { owner.autopilot = action.difficulty as NonNullable; log( g, `${owner.name} enabled ${owner.autopilot} AI control for their seat.`, ); } return g; } if (state.pendingTreacheryDiscard) { getPlayer(state, id); requireRule( action?.type === 'advanceBots', 'Finish the committed discard continuation before another game action.', ); return normalizeAutomaticGame(state); } const g = applyActionInner(state, id, action); karamaConversionIntegrity(g); ornithopterIntegrity(g); lateDefenseIntegrity(g); stoneBurnerIntegrity(g); strongholdIntegrity(g); auditorIntegrity(g); if (g.pendingNullentropy) { const invalid = nullentropyIntegrity(g); requireRule(!invalid, invalid ?? 'The search cannot continue.'); return g; } if ( g.pendingRicheseGift && state.pendingRicheseGift?.event === g.pendingRicheseGift.event ) { const pending = g.pendingRicheseGift; requireRule( getPlayer(g, pending.intent.owner).hand.filter( (c) => c.id === pending.intent.cardId, ).length === 1, 'The card reserved for a pending Richese gift must remain in its owner’s hand.', ); } // An accepted exchange obliges its beneficiary to complete the discard. // Interrupting voluntary card plays must leave a legal completion available. const pending = g.pendingAmbassador; if ( pending && pending.event === state.pendingAmbassador?.event && pending.beneficiary === id ) { const beneficiary = getPlayer(g, id), entrant = getPlayer(g, pending.entrant); if (pending.stage === 'cards' && pending.effect === 'ixians') requireRule( !ambassadorEffectBlock(g, 'ixians', beneficiary, entrant), 'Complete the Ixian Ambassador exchange before spending your last available card.', ); if (pending.stage === 'copy') requireRule( pending.copyChoices.some( (effect) => !ambassadorEffectBlock(g, effect, beneficiary, entrant), ), 'Leave an available effect for the committed Bene Gesserit Ambassador.', ); } const lot = g.richeseAuction; if (lot && !g.currentAuctionSale) { requireRule( lot.source !== 'blackMarket' || !g.truthtrance?.queue.some( (entry) => entry.player === lot.owner && entry.card === lot.cardId, ), 'The card reserved for this Black Market auction cannot also be queued for Truthtrance.', ); requireRule( richeseLotCard(g), 'The card reserved for this auction must remain available.', ); for (const bidder of g.players) { requireRule( richeseOwnCommitment(lot, g.richeseFunding ?? {}, bidder.id) <= bidder.spice, 'Spice committed to an auction cannot be spent again.', ); requireRule( richeseAllyCommitment(lot, g.richeseFunding ?? {}, bidder.id) <= (g.aid[bidder.id]?.amount ?? 0), 'Ally spice committed to an auction cannot be withdrawn.', ); const hasWinningCommitment = lot.outcome ? lot.outcome.kind === 'sold' && lot.outcome.winner === bidder.id : lot.method === 'silent' ? (lot.sealed[bidder.id] ?? 0) > 0 : lot.bidder === bidder.id; if (hasWinningCommitment) requireRule( bidder.hand.length < handLimit(bidder), 'Leave room for the card in your committed auction.', ); } } finishActionContinuations(g); reconcileBattlePromises(g, { actor: id, action }); reconcileShipmentPromises(g, { actor: id, action }); settleAutomaticContinuations(g); return g; } function finishActionContinuations(g: Game) { if (g.pendingNullentropy) return; finishTreacheryDiscard(g); if (!g.truthtrance && !g.decision && !g.response) advanceSetup(g); if ( !g.truthtrance && g.pendingTerrorEntry?.stage === 'discard' && g.decision?.kind === 'moritaniTerror' ) { const moritani = getPlayer(g, g.decision.player); if (moritani.hand.length <= handLimit(moritani)) { g.decision = null; finishTerrorEntry(g); } } if ( g.status === 'setup' && !g.truthtrance && !g.decision && !g.response && otherSetupComplete(g) ) { const moritani = byFaction(g, 'moritani'); if (moritani?.reserves === 20) { g.moritaniTerror ??= createTerrorState(random); g.decision = { kind: 'moritaniSetup', player: moritani.id }; } } if ( g.status === 'setup' && !g.truthtrance && !g.response && !g.decision && setupComplete(g) ) finishSetup(g); settleAdvisors(g); } function settleAutomaticContinuations(g: Game) { if (g.pendingNullentropy) return; for (let iteration = 0; iteration < 128; iteration++) { if (g.truthtrance || g.phaseOpening || g.status === 'finished') return; const response = g.response; const before = JSON.stringify(g); if (response) { if ( !( response.kind === 'harkonnenBonus' && getPlayer(g, response.owner).hand.length >= handLimit(getPlayer(g, response.owner)) ) && g.players.some( (p) => !response.passed.includes(p.id) && responseCancelCards(g, p, response).length > 0, ) ) return; finishResponse(g, false); } else if (!finishAutomaticDecision(g)) return; finishActionContinuations(g); // Opposing/automatic consequences can release a now-impossible promise; // they are not the original actor voluntarily spending a promised resource. reconcileBattlePromises(g); reconcileShipmentPromises(g); requireRule( JSON.stringify(g) !== before, 'Automatic response continuation made no progress.', ); } throw new RuleError( 'Automatic response continuation exceeded its bounded limit.', ); } /** Internal authoritative continuation. Callers must persist with their usual CAS fence. */ export function normalizeAutomaticGame(state: Game): Game { karamaConversionIntegrity(state); treacheryDiscardIntegrity(state); shipmentPromiseIntegrity(state); saphoMovementIntegrity(state); ambassadorRelocationIntegrity(state); ecazCollectionIntegrity(state); const g = structuredClone(state); ornithopterIntegrity(g); lateDefenseIntegrity(g); stoneBurnerIntegrity(g); strongholdIntegrity(g); auditorIntegrity(g); if (g.pendingNullentropy) return g; normalizeCardNames(g); normalizeBattle(g); finishActionContinuations(g); reconcileBattlePromises(g); reconcileShipmentPromises(g); settleAutomaticContinuations(g); return g; } function applyActionInner( state: Game, id: string, action: Action, execution: 'live' | 'shipmentPreparation' = 'live', ): Game { const g = structuredClone(state); ornithopterIntegrity(g); lateDefenseIntegrity(g); stoneBurnerIntegrity(g); strongholdIntegrity(g); auditorIntegrity(g); if (g.pendingNullentropy) { if (action?.type === 'advanceBots') return g; requireRule( action?.type === 'decision', 'Finish the paid Nullentropy Box search before another game action.', ); finishNullentropy( g, id, stringField(action.event), stringField(action.card), ); return g; } if (!g.playerPositions) { g.playerPositions = normalizedPlayerPositions(g); if (g.status === 'lobby') g.storm = STORM_START_SECTOR; if (g.status !== 'lobby') log( g, 'Assigned printed player circles in original seating order. Current turn order and storm choices are unchanged; the next completed storm uses these circles.', ); } normalizeCardNames(g); // Older persisted rooms predate decision windows. g.decision ??= null; g.pendingCapture ??= null; g.response ??= null; g.stormPending ??= null; g.stormCard ??= null; g.stormCardKnown ??= false; g.stormResolution ??= null; g.karamaShipping ??= null; g.wormRides ??= []; g.wormPlacementCanceledTurn ??= 0; g.spiceResolution ??= null; g.spiceSequence ??= null; g.spiceWindow ??= null; g.spicePeekKnown ??= false; g.freeRevival ??= []; g.emperorExtra ??= {}; g.aid ??= {}; g.movementRemaining ??= null; g.guildTimingGranted ??= false; g.guildTimingLocked ??= false; normalizeBattle(g); const p = getPlayer(g, id); requireRule( action && typeof action.type === 'string', 'Action type is required.', ); requireRule(g.status !== 'finished', 'This game has ended.'); const t = action.type; if (t === 'advanceBots') return g; requireRule( !( t === 'card' && g.richeseAuction?.source === 'blackMarket' && !g.currentAuctionSale && g.richeseAuction.owner === p.id && action.card === g.richeseAuction.cardId ), 'The card reserved for this Black Market auction cannot be played or queued for another effect.', ); try { if ( resolveTruthAction(g, p, action, { completeDefiniteAnswer: finishTruthtranceAnswer, log, shipmentAnswers: shipmentTruthAnswers, bindShipment: bindShipmentTruth, battleAnswers: battleTruthAnswers, bindBattle: bindBattleTruth, }) ) return g; } catch (error) { if (error instanceof TruthError || error instanceof PlanClaimError) throw new RuleError(error.message); throw error; } if (t === 'card' && action.card === 'richese-juice-of-sapho') { playSapho(g, p, action); return g; } if (t === 'portableSnooper') { requireRule( Object.keys(action).every((key) => ['type', 'card', 'event'].includes(key), ), 'Choose only the held Portable Snooper and current battle event.', ); const { b, card } = validatePortableSnooper( g, p, stringField(action.card), stringField(action.event), ); (b.lateDefense ??= {})[id] = card.id; log( g, `${p.name} played Portable Snooper after revealing their battle plan. It protects against ordinary poison without changing the original plan. Battle and traitor decisions continue.`, { faction: p.faction, name: 'Portable Snooper' }, ); return g; } if (t === 'battlePreparationReady') { const b = g.battle, opportunity = b?.preLeader; requireRule( g.phase === 6 && b && opportunity && !opportunity.closed && !b.revealed && [b.attacker, b.defender].includes(id), 'No shared pre-leader opportunity is waiting for you.', ); requireRule( action.event === opportunity.event && !opportunity.ready.includes(id), 'This preparation declaration is stale or already recorded.', ); requireRule( !g.truthtrance && !g.response && !g.decision && !g.phaseOpening, 'Finish the current interaction before leaving preparation.', ); opportunity.ready.push(id); opportunity.closed = [b.attacker, b.defender].every((player) => opportunity.ready.includes(player), ); log( g, `${p.name} is ready to continue battle preparation${opportunity.closed ? '; both combatants have finished their shared pre-leader opportunity' : ''}.`, ); return g; } if ( t === 'card' && p.hand.some((c) => c.id === action.card && c.effect === 'residualPoison') ) { requireRule( Object.keys(action).every((key) => ['type', 'card', 'target', 'event'].includes(key), ), 'Residual Poison chooses the victim on the server; do not submit a leader or random selection.', ); playResidualPoison( g, p, stringField(action.card), stringField(action.target), stringField(action.event), ); return g; } if ( t === 'card' && p.hand.some((c) => c.id === action.card && c.effect === 'nullentropyBox') ) { requireRule( Object.keys(action).every((key) => key === 'type' || key === 'card'), 'Begin the private Box search before selecting any discard card.', ); beginNullentropy(g, p, stringField(action.card)); return g; } if ( t === 'card' && p.hand.some((c) => c.id === action.card && c.effect === 'distrans') ) { requireRule(!action.mode, 'Use Distrans as its printed card effect.'); const { recipient, result } = validateDistrans( g, p, stringField(action.card), stringField(action.target), stringField(action.give), ); p.hand = result.ownerHand; recipient.hand = result.recipientHand; g.discard.push(result.discarded); log( g, `${p.name} played Distrans to give ${recipient.name} one private Treachery Card, then discarded Distrans. No spice, purchase bonus or recipient confirmation is required.`, { faction: p.faction, name: 'Distrans' }, ); return g; } if (t === 'richeseGift') { const { intent } = validateRicheseGift(g, p, stringField(action.card)); g.pendingRicheseGift = { event: crypto.randomUUID(), intent, turn: g.turn, phase: g.phase, resume: { response: g.response, decision: g.decision, pendingKarama: g.pendingKarama, phaseOpening: g.phaseOpening, }, }; g.response = { kind: 'richeseGift', owner: id, recipient: intent.recipient, passed: [], }; g.decision = null; g.pendingKarama = null; g.phaseOpening = null; log( g, `${p.name} offered their ally one Richese Treachery Card from hand. Its identity stays private; resolve the alliance power before transferring it.`, ); return g; } if (t === 'card' && action.mode === 'special' && p.faction === 'choam') { specialKarama(g, p, action); return g; } if (g.phaseOpening) { requireRule( g.status === 'playing', 'Phase opening requires an active game.', ); requireRule( !g.phaseOpening.passed.includes(id), 'You have already passed the phase opening.', ); if (t === 'ready') { g.phaseOpening.passed.push(id); if (g.phaseOpening.passed.length === g.players.length) { const initialize = g.phaseOpening.initialize; g.phaseOpening = null; if (initialize) beginPhase(g); } } else { const card = p.hand.find( (c) => c.id === action.card && c.effect === 'amal', ); requireRule( t === 'card' && card && !action.mode, 'Play Amal or pass the phase opening before taking other actions.', ); discard(g, p, card.id); for (const player of g.players) player.spice = Math.floor(player.spice / 2); g.phaseOpening.passed = []; log( g, `${p.name} played Amal. Each faction returned half its available spice to the bank, rounded up.`, ); } return g; } if (t === 'card' && action.mode === 'choam') { playChoamWorthless(g, p, action); return g; } if (t === 'card' && action.mode === 'special') { requireRule( !g.choamMarket, 'Finish the end-of-phase exchange before another special power.', ); requireRule( !g.pendingRevival || (p.faction === 'tleilaxu' && g.decision?.kind === 'revivalStop'), 'Resolve the pending revival first.', ); specialKarama(g, p, action); return g; } if (g.response) { const response = g.response; if (response.kind === 'fremenMovement') pendingFremenMovement(g, response); if (t === 'passResponse') { requireRule( !response.passed.includes(id), 'You have already passed this response window.', ); response.passed.push(id); } else { requireRule( t === 'card' && action.mode === 'cancel', 'Resolve the Karama response window first.', ); requireRule( id !== response.owner, 'Karama cancels another faction’s power.', ); const card = karamaCard(g, p, action.card); requireRule(card, 'Choose a Karama in your hand.'); spendKarama(g, p, card, { kind: 'cancel', response }); } return g; } if ( t === 'offerRicheseNoField' && g.decision?.kind === 'richeseAllyOpportunity' && g.decision.player === id ) g.decision = null; if (g.decision) { const decision = g.decision; requireRule( t === 'decision' && decision.player === id, 'Waiting for the player with the pending decision.', ); g.decision = null; if (decision.kind === 'ecazSpice') { g.decision = decision; decideSharedSpice(g, id, action); return g; } if ( decision.kind === 'richeseBlackMarket' || decision.kind === 'richeseDeclaration' || decision.kind === 'richeseCache' || decision.kind === 'richeseUnbid' ) { decideRichese(g, p, decision, action); return g; } if (decision.kind === 'ecazAmbassador') { decideAmbassador(g, p, action); return g; } if (decision.kind === 'ecazPlacement') { requireRule( g.status === 'playing' && g.phase === 4 && p.faction === 'ecaz' && g.ecazAmbassadors && g.ecazPlacementTurn !== g.turn, 'Place Ambassadors during your end-of-Revival opportunity.', ); if (action.decline === true) { log(g, `${p.name} finished Ambassador placement.`); finishEcazPlacement(g); } else { const token = stringField(action.token), to = stringField(action.territory); const quote = ambassadorPlacementQuote(g, p, token, to); g.pendingEcazPlacement = { token, territory: to, turn: g.turn, cost: quote.cost, }; g.response = { kind: 'ecazPlacement', owner: id, passed: [], intent: `Place the ${faction(g.ecazAmbassadors.tokens.find((t) => t.id === token)!.effect).name} Ambassador in ${territory(to).name} for ${quote.cost} spice.`, }; log( g, `${p.name} declared an Ambassador placement. No spice has been paid yet.`, ); } return g; } if (decision.kind === 'moritaniRetention') { const pending = g.moritaniRetention; requireRule( pending?.stage === 'choose' && pending.player === id && pending.turn === g.turn && g.phase === 6, 'No alliance battle-card choice is pending for you.', ); requireRule( action.keep === null || (typeof action.keep === 'string' && pending.eligible.includes(action.keep) && p.hand.some((c) => c.id === action.keep)), 'Keep one eligible played card, or explicitly keep none.', ); if (action.keep === null) finishMoritaniRetention(g, null); else { pending.keep = action.keep as string; pending.stage = 'response'; g.response = { kind: 'moritaniRetention', owner: pending.owner, passed: [], }; } return g; } if (decision.kind === 'moritaniTerror') { decideTerror(g, p, action); return g; } if (decision.kind === 'moritaniSetup') { requireRule( g.status === 'setup' && p.faction === 'moritani' && p.reserves === 20 && otherSetupComplete(g), 'Moritani places its starting forces after the other factions finish setup.', ); const to = stringField(action.territory); const sector = integer(action.sector, 0, 18, 'Sector'); requireRule( TERRITORIES.some((t) => t.id === to) && to !== MOBILE_STRONGHOLD && validLocation(to, sector), 'Choose a printed territory and sector.', ); requireRule( g.players.every((player) => at(player, to) === 0), 'Moritani must start in an unoccupied territory, including no advisors.', ); place(p, to, sector, 6); p.reserves -= 6; log( g, `${p.name} placed six starting forces in ${territory(to).name}, sector ${sector}.`, ); return g; } if (decision.kind === 'richeseAllyOpportunity') { requireRule( action.decline === true, 'Offer a No-Field shipment or let your ally ship normally.', ); log( g, `${p.name} passed the allied No-Field offer opportunity. Their ally may ship normally.`, ); return g; } if (decision.kind === 'richeseAllyShipment') { const offer = g.richeseAllyOffer; requireRule( offer && offer.recipient === id && offer.owner === decision.owner && action.event === offer.event, 'This allied No-Field offer is no longer current.', ); if (action.decline === true) { g.richeseAllyOffer = null; g.richeseAllyDeclined = { turn: g.turn, recipient: id }; log( g, `${p.name} declined the allied No-Field offer. Shipment and token custody remain unchanged.`, ); } else { requireRule(action.accept === true, 'Accept this offer or decline it.'); const quote = alliedNoFieldQuote(g, offer); const elite = integer( action.elite ?? quote.eliteMin, quote.eliteMin, quote.eliteMax, 'Elite forces', ); g.pendingShipment = { turn: g.turn, player: id, territory: offer.territory, sector: offer.sector, amount: quote.amount, elite, cost: quote.cost, allyPayment: 0, advisors: quote.advisors, alliedNoField: offer, }; // Verify the entire arrival continuation before the recipient commits. // Unsupported simultaneous reactions leave this offer declineable. const trial = structuredClone(g); commitShipment(trial, trial.pendingShipment!); g.richeseAllyOffer = null; g.response = { kind: 'richeseNoField', owner: offer.owner, passed: [] }; log( g, `${p.name} accepted an allied No-Field shipment. Resolve the Richese power before payment, token reveal or arrival.`, ); } return g; } if (decision.kind === 'moritaniPlacement') { requireRule( g.status === 'playing' && g.phase === 8 && p.faction === 'moritani' && g.moritaniTerror && g.moritaniTerror.placementTurn !== g.turn, 'Place or relocate one Terror token during your Mentat opportunity.', ); if (action.decline === true) { g.moritaniTerror.placementTurn = g.turn; log(g, `${p.name} passed the Terror placement opportunity.`); finishMoritaniPlacement(g); } else { const token = stringField(action.token); const to = stringField(action.territory); try { placeTerror(g.moritaniTerror, token, to, g.turn); } catch (error) { throw new RuleError( error instanceof Error ? error.message : 'Invalid Terror placement.', ); } g.pendingMoritaniPlacement = { token, territory: to, turn: g.turn }; g.response = { kind: 'moritaniPlacement', owner: id, passed: [] }; log(g, `${p.name} declared a hidden Terror placement or relocation.`); } return g; } if (decision.kind === 'choamStorm') { requireRule( action.decline === true && g.stormResolution, 'Continue the storm or declare Jubba Cloak.', ); beginStormProtection(g); return g; } if (decision.kind === 'choamMovement') { requireRule( action.decline === true, 'Allow the movement or declare Baliset.', ); resumeChoamMovement(g); return g; } if ( decision.kind === 'choamAudit' || decision.kind === 'choamAuditPayment' ) { const pending = g.pendingAuditor; requireRule( pending && action.event === pending.event && action.event === decision.event, 'This Auditor decision is stale or already completed.', ); requireRule( Object.keys(action).every((key) => ['type', 'event', 'audit', 'pay', 'count'].includes(key), ), 'Choose only whether to audit or pay the current cancellation cost.', ); if (decision.kind === 'choamAudit') { requireRule( typeof action.audit === 'boolean' && action.pay === undefined && action.count === undefined, 'Choose whether to use the Auditor.', ); if (!action.audit) finishAuditor(g, 'decline'); else { pending.stage = 'response'; g.response = { kind: 'choamAudit', owner: id, intent: pending.event, passed: [], }; log( g, `${p.name} declared an Auditor inspection. Opposing Karama cancellation resolves before any inspection payment.`, ); } } else { requireRule( typeof action.pay === 'boolean' && action.audit === undefined, 'Pay the full cancellation cost or allow the audit.', ); if (action.pay) requireRule( action.count === currentAuditCount(g), 'The eligible-card count changed. Review the current full cancellation cost.', ); else requireRule( action.count === undefined, 'Allowing an audit does not submit a payment.', ); finishAuditor(g, action.pay ? 'pay' : 'inspect'); } return g; } if (decision.kind === 'strongholdCopy') { const b = g.battle; requireRule( b?.event === decision.event && action.event === decision.event && b.territory === MOBILE_STRONGHOLD && !Object.keys(b.plans).length && !b.strongholdCopy && g.strongholdCards?.owners.hidden_mobile_stronghold === id, 'Choose a copied Stronghold advantage for this exact battle before plans.', ); const choices = strongholdCopyChoices(g, id); requireRule( typeof action.stronghold === 'string' && decision.choices.includes(action.stronghold as StrongholdId) && choices.includes(action.stronghold as StrongholdId), 'Choose another stronghold you currently control.', ); b.strongholdCopy = action.stronghold as StrongholdId; log( g, `${p.name} declared ${STRONGHOLD_CARDS.find((card) => card.id === b.strongholdCopy)!.name} as their copied mobile Stronghold advantage.`, { faction: p.faction, name: 'Stronghold advantage' }, ); beginBattlePowers(g); return g; } if (decision.kind === 'choamMentat') { requireRule( action.done === true && g.choamMentatPending && g.phase === 8, 'Finish CHOAM’s Mentat opportunity before checking victory.', ); g.choamMentatPending = false; victory(g); if (g.status === 'playing') completePhase(g); return g; } if (decision.kind === 'choamFreeRevival') { requireRule( action.decline === true, 'Allow the free revival or play La La La.', ); offerRevivalStop(g); return g; } if (decision.kind === 'choamBattleFunding') { requireRule( p.faction === 'choam' && g.advanced && g.phase === 6 && g.battle, 'CHOAM battle funding is unavailable.', ); pledgeAid(g, p, action.amount); battlePreparation(g, 'voice'); combatResponses(g); return g; } if ( decision.kind === 'choamMarket' || decision.kind === 'choamTradeReply' || decision.kind === 'choamTradeConfirm' ) { const market = g.choamMarket!; const owner = getPlayer(g, market.owner); if (decision.kind === 'choamMarket') { if (action.done === true) { finishChoamMarket(g); } else if (action.mode === 'sell') { const sale = quoteSale( owner.hand, action.card, action.witness, market.blocked, ); requireRule( sale, 'Choose an available Worthless card or surplus exact duplicate.', ); market.sale = sale; const card = owner.hand.find((c) => c.id === sale.card)!; log( g, `${owner.name} revealed ${sale.witness ? 'two copies of ' : ''}${card.name} and offered one for ${sale.price} spice.`, ); g.response = { kind: 'choamSale', owner: owner.id, passed: [] }; } else { requireRule( action.mode === 'trade', 'Sell a card, offer a trade or finish the phase.', ); requireRule( g.choamTradeTurn !== g.turn, 'CHOAM may exchange one card with its ally only once per turn.', ); const ally = g.players.find( (p) => p.id === owner.ally && p.ally === owner.id, ); requireRule(ally, 'You need an ally for a two-way trade.'); requireRule( owner.hand.some((c) => c.id === action.card), 'Offer a card from your own hand.', ); market.trade = { ally: ally.id, offered: stringField(action.card) }; market.tradeAttempted = true; g.decision = { kind: 'choamTradeReply', player: ally.id }; } } else { const trade = market.trade!; const ally = getPlayer(g, trade.ally); if (action.decline === true) { delete market.trade; resumeChoamMarket(g); } else if (decision.kind === 'choamTradeReply') { requireRule( ally.hand.some((c) => c.id === action.card), 'Choose one of your cards to return, or decline.', ); trade.returned = stringField(action.card); g.decision = { kind: 'choamTradeConfirm', player: owner.id }; } else { requireRule( action.accept === true, 'Confirm the two-way exchange or decline.', ); const offered = owner.hand.find((c) => c.id === trade.offered); const returned = ally.hand.find((c) => c.id === trade.returned); if ( offered && returned && owner.ally === ally.id && ally.ally === owner.id && g.choamTradeTurn !== g.turn ) { owner.hand = owner.hand.filter((c) => c.id !== offered.id); ally.hand = ally.hand.filter((c) => c.id !== returned.id); owner.hand.push(returned); ally.hand.push(offered); g.choamTradeTurn = g.turn; log( g, `${owner.name} and ${ally.name} exchanged one treachery card each.`, ); } else log( g, 'The proposed card exchange is no longer possible; both hands remain unchanged.', ); delete market.trade; resumeChoamMarket(g); } } } else if (decision.kind === 'ixSetup') { requireRule( g.status === 'setup' && p.faction === 'ixians' && g.ixSetupCards, 'Ixian starting-card selection is not available.', ); const card = g.ixSetupCards.find((c) => c.id === action.card); requireRule(card, 'Choose one of the starting cards.'); p.hand.push(card); const remainder = shuffle(g.ixSetupCards.filter((c) => c.id !== card.id)); for (const other of g.players.filter((other) => other.id !== p.id)) { const dealt = remainder.shift(); if (dealt) other.hand.push(dealt); } const harkonnen = byFaction(g, 'harkonnen'); if (harkonnen) { const bonus = draw(g); if (bonus) harkonnen.hand.push(bonus); } g.ixSetupCards = null; log( g, `${p.name} selected a starting card. The other starting cards were shuffled and dealt privately.`, ); } else if (decision.kind === 'ixAuction') { requireRule( g.ixAuction && p.faction === 'ixians', 'The auction pool is not available.', ); const card = g.ixAuction.cards.find((c) => c.id === action.card); requireRule(card, 'Choose a card from the auction pool.'); requireRule( action.position === 'top' || action.position === 'bottom', 'Return the card to the top or bottom of the deck.', ); if (action.position === 'top') g.deck.unshift(card); else g.deck.push(card); const rest = g.ixAuction.cards.filter((c) => c.id !== card.id); g.ixAuctionKnown = { turn: g.turn, cards: [...rest] }; g.ixAuction = null; setAuction(g, shuffle(rest)); log( g, `${p.name} returned one card face down to the ${action.position} of the deck and shuffled the auction pool.`, ); } else if (decision.kind === 'ixTechnology') { requireRule( g.advanced && p.faction === 'ixians' && g.auction && g.ixTechnologyTurn !== g.turn, 'Ixian auction substitution is not available.', ); if (action.decline === true) offerAuctionPeek(g); else { const card = p.hand.find((c) => c.id === action.card); requireRule( card, 'Choose one of your hand cards to offer for exchange.', ); g.ixTechnologyTurn = g.turn; g.pendingIxTechnology = { card: card.id }; g.response = { kind: 'ixTechnology', owner: p.id, passed: [] }; } } else if (decision.kind === 'ixAllyCard') { const pending = g.pendingIxAlly; requireRule( pending && pending.player === p.id, 'This purchased-card choice is not available.', ); requireRule( typeof action.accept === 'boolean', 'Keep or replace the purchased card.', ); if (action.accept) { const ixians = byFaction(g, 'ixians'); requireRule( ixians && p.ally === ixians.id && p.hand.some((c) => c.id === pending.card), 'The purchased card and Ixian alliance must remain available.', ); g.response = { kind: 'ixAllyCard', owner: ixians.id, recipient: p.id, passed: [], }; } else { g.pendingIxAlly = null; continueAuctionSale(g, pending.free); } } else if (decision.kind === 'mobileStronghold') { requireRule( p.faction === 'ixians' && g.mobileStronghold, 'Only Ixians place or move their stronghold.', ); if (decision.placement) { const dest = stringField(action.location); const loc = splitLocation(dest); requireRule( TERRITORIES.some( (t) => t.id === loc.territory && t.sectors.includes(loc.sector) && t.type !== 'stronghold', ) && location(loc.territory, loc.sector) === dest, 'Point the stronghold at a sector in a non-stronghold territory.', ); g.mobileStronghold.location = dest; log( g, `${p.name} placed the Hidden Mobile Stronghold at ${territory(loc.territory).name}, sector ${loc.sector}.`, ); finishStorm(g); } else if (action.decline === true) beginStormTurn(g); else { const route = validateMobileMove(g, p, action.route, 3); g.pendingMobileMove = { player: p.id, route, collect: action.collect !== false, }; g.response = { kind: 'mobileStronghold', owner: p.id, location: route.at(-1), passed: [], }; } } else if (decision.kind === 'ixSubstitution') { const pending = g.pendingIxSubstitution!; if (action.decline === true) { g.pendingIxSubstitution = null; finishWinner(g, p, pending.territory, pending.cards); } else { const parse = (input: unknown, available: Record) => { requireRule( input && typeof input === 'object' && !Array.isArray(input), 'Choose sector counts for the substitution.', ); return Object.fromEntries( Object.entries(input as Record) .map(([key, value]) => { requireRule( Object.hasOwn(available, key), 'Choose a sector in this battle.', ); return [key, integer(value, 0, available[key], 'Forces')]; }) .filter(([, count]) => Number(count) > 0), ) as Record; }; const sources = Object.fromEntries( Object.entries(p.forces) .filter( ([key]) => splitLocation(key).territory === pending.territory, ) .map(([key, count]) => [key, count - (p.elites?.forces[key] ?? 0)]), ); pending.sources = parse(action.sources, sources); pending.recover = parse(action.recover, pending.losses); const sum = (counts: Record) => Object.values(counts).reduce((a, b) => a + b, 0); requireRule( sum(pending.sources) > 0 && sum(pending.sources) === sum(pending.recover), 'Exchange one surviving suboid for each cyborg lost in this battle.', ); g.response = { kind: 'ixSubstitution', owner: id, passed: [] }; } } else if (decision.kind === 'revivalStop') { requireRule( action.decline === true, 'Allow the revival, or spend special Karama to prevent it.', ); finishRevival(g); } else if (decision.kind === 'faceDance') { requireRule( typeof action.reveal === 'boolean', 'Reveal a Face Dancer or decline.', ); if (action.reveal) { const dancer = p.faceDancers?.find( (c) => !c.revealed && c.leader === decision.identity, ); requireRule( dancer, 'You do not have an unrevealed Face Dancer for that leader.', ); const winner = getPlayer(g, decision.winner); const maximum = at(winner, decision.territory); const sources = action.sources; requireRule( sources && typeof sources === 'object' && !Array.isArray(sources), 'Choose reserves or board forces to replace the winner.', ); const selected = Object.entries(sources).map(([key, value]) => { requireRule( key === 'reserves' || key in p.forces, 'Choose your own reserves or occupied sectors.', ); return { key, count: integer( value, 0, key === 'reserves' ? p.reserves : p.forces[key], 'Forces', ), }; }); const total = selected.reduce((sum, source) => sum + source.count, 0); requireRule( total <= maximum, 'You cannot replace more forces than the winner has remaining.', ); const sector = integer(action.sector, 0, 18, 'Sector'); requireRule( validLocation(decision.territory, sector), 'Choose a sector in the battle territory.', ); // This is replacement, not shipment or movement: no transport cost or worm/storm transit. for (const source of selected) { if (source.key === 'reserves') p.reserves -= source.count; else { p.forces[source.key] -= source.count; if (!p.forces[source.key]) delete p.forces[source.key]; } } for (const key of Object.keys(winner.forces).filter( (key) => splitLocation(key).territory === decision.territory, )) { winner.reserves += winner.forces[key]; if (winner.elites) { winner.elites.reserves += winner.elites.forces[key] ?? 0; delete winner.elites.forces[key]; } delete winner.forces[key]; } const leader = g.players .flatMap((player) => player.leaders) .find((l) => l.id === decision.leader); if (leader && !leader.dead) { leader.dead = true; leader.deaths++; delete leader.usedAt; delete leader.capturedBy; delete leader.concealed; } if (total) place(p, decision.territory, sector, total); dancer.revealed = true; log( g, `${p.name} revealed a Face Dancer and replaced ${total} forces in ${territory(decision.territory).name}.`, ); if (p.faceDancers!.every((c) => c.revealed)) { g.traitorReserve = shuffle([ ...(g.traitorReserve ?? []), ...p.faceDancers!.map((c) => c.leader), ]); p.faceDancers = g.traitorReserve .splice(0, 3) .map((leader) => ({ leader, revealed: false })); log(g, `${p.name} drew three new Face Dancers.`); } } g.pendingFaceDance = null; const harkonnen = byFaction(g, 'harkonnen'); if (harkonnen) returnCaptives(g, harkonnen); settleAdvisors(g); finishBattle(g); } else if (decision.kind === 'stoneBurner') { requireRule( Object.keys(action).every((key) => ['type', 'event', 'mode'].includes(key), ), 'Stone Burner needs only this battle event and a mode.', ); requireRule( g.battle?.revealed && action.event === decision.event && action.event === g.battle.event, 'Choose Stone Burner’s mode for this exact revealed battle.', ); requireRule( action.mode === 'kill' || action.mode === 'ignore', 'Choose to kill both leaders or ignore surviving leader strength.', ); requireRule( isStoneBurner(cardOf(p, g.battle.plans[id]?.weapon)) && !g.battle.stoneBurner?.[id], 'Stone Burner’s mode is already chosen or its weapon is missing.', ); (g.battle.stoneBurner ??= {})[id] = action.mode; log( g, `${p.name} chose Stone Burner: ${action.mode === 'kill' ? 'both participating leaders will be killed' : 'surviving leaders add no strength; other weapon attacks still apply'}. Undialed physical force tokens decide the ordinary battle; traitors and explosions retain precedence.`, ); nextRevealedDecision(g); } else if (decision.kind === 'poisonTooth') { requireRule( g.battle?.revealed && typeof action.activate === 'boolean', 'Choose whether to activate Poison Tooth.', ); g.battle.poisonTooth ??= {}; g.battle.poisonTooth[id] = action.activate; log( g, `${p.name} ${action.activate ? 'activated' : 'declined to activate'} Poison Tooth.`, ); nextRevealedDecision(g); } else if (decision.kind === 'techToken') { requireRule( decision.choices.includes(action.token as TechId) && g.techTokens?.[action.token as TechId]?.owner === decision.loser, 'Choose one tech token controlled by the defeated faction.', ); transferTech(g, action.token as TechId, id); g.pendingTech = null; finishBattle(g); } else if (decision.kind === 'fullPlanOffer') { requireRule( action.decline === true, 'Decline the inspection, or spend the Atreides special Karama.', ); } else if (decision.kind === 'fullPlanRead') { requireRule( action.continue === true, 'Confirm that you have inspected the battle plan.', ); } else if (decision.kind === 'guildShipment') { requireRule( action.allow === true, 'Allow the shipment, or play the Guild special Karama.', ); const shipment = validateGuildShipmentDecision(g, decision); g.pendingShipment = null; commitShipment(g, shipment); } else if (decision.kind === 'nullentropy') { throw new RuleError( 'No matching paid search was saved; restore the saved search before continuing.', ); } else if (decision.kind === 'handExchange') { requireRule( Array.isArray(action.returnCards) && action.returnCards.length === decision.count && new Set(action.returnCards).size === decision.count && action.returnCards.every( (id) => typeof id === 'string' && p.hand.some((c) => c.id === id), ), 'Return exactly the required number of distinct cards from your hand.', ); const target = getPlayer(g, decision.target); const returnIds = action.returnCards as string[]; const returned = p.hand.filter((c) => returnIds.includes(c.id)); p.hand = p.hand.filter((c) => !returnIds.includes(c.id)); target.hand.push(...returned); requireRule( p.hand.length <= handLimit(p) && target.hand.length <= handLimit(target), 'The completed exchange must respect hand limits.', ); const pending = g.pendingExchange!; g.pendingExchange = null; g.response = pending.response; g.decision = pending.decision; if (!g.response && g.decision?.kind === 'auctionPayment') recoverAuctionPayment(g, getPlayer(g, g.decision.player)); log(g, `${p.name} returned ${decision.count} cards to ${target.name}.`); } else if (decision.kind === 'captureOffer') { requireRule( typeof action.accept === 'boolean', 'Choose whether to capture a leader.', ); if (action.accept) g.response = { kind: 'capture', owner: id, passed: [] }; else { g.pendingCapture = null; finishBattle(g); } } else if (decision.kind === 'capturedLeader') { requireRule( action.mode === 'keep' || action.mode === 'execute', 'Choose keep or execute for the captured leader.', ); const owner = getPlayer(g, decision.owner), l = owner.leaders.find((l) => l.id === decision.leader)!; if (action.mode === 'execute') { l.dead = true; l.deaths = Math.max(2, l.deaths + 1); delete l.usedAt; delete l.capturedBy; p.spice += 2; log(g, `${p.name} executed a captive for 2 spice.`); } else { returnCaptives(g, p); log( g, `${p.name} ${l.capturedBy === id ? 'retained' : 'released'} the captive.`, ); } finishBattle(g); } else if (decision.kind === 'guildTiming') { requireRule( typeof action.take === 'boolean', 'Choose whether to ship and move now or wait.', ); const inOrder = decision.next === id; if (action.take === inOrder) g.active = action.take ? id : decision.next; else if (g.guildTimingGranted) chooseGuildTiming(g, action.take); else g.response = { kind: 'guildTiming', owner: id, passed: [], take: action.take, }; } else if (decision.kind === 'stormLosses') { const elite = integer( action.elite ?? decision.minElite, decision.minElite, decision.maxElite, 'Elite casualties', ); kill(g, p, decision.key, decision.amount, false, elite); if (decision.resume === 'storm') continueStorm(g); } else if (decision.kind === 'battleLosses') { const choice = decision.options[ integer( action.choice, 0, decision.options.length - 1, 'Casualty choice', ) ]; settleWinnerCasualties(g, p, decision.territory, decision.cards, choice); } else if (decision.kind === 'auctionPayment') { normalKaramaAuction(g, p, true, action.karama === true); requireRule( typeof action.karama === 'boolean', 'Choose spice payment or Karama.', ); if (action.karama) { const card = karamaCard(g, p, action.card); requireRule(card, 'You do not hold a Karama card.'); spendKarama(g, p, card, { kind: 'auctionPayment' }); } else settleAuction(g, false); } else if (decision.kind === 'intrusion') { requireRule( typeof action.accept === 'boolean', 'Choose whether to become advisors.', ); const details = { advisorFollowup: decision.followup, advisorResume: decision.ambassadorEvent ? ('ambassador' as const) : decision.wormRide ? ('wormRide' as const) : undefined, advisorAmbassadorEvent: decision.ambassadorEvent, }; if (action.accept) g.response = { kind: 'advisorFlip', owner: id, location: decision.territory, advisors: true, passed: [], ...details, }; else finishAdvisorReaction(g, details); } else if (decision.kind === 'advisorBattle') { requireRule( typeof action.accept === 'boolean', 'Choose whether to prepare advisors for battle.', ); if (action.accept) { const to = stringField(action.territory); requireRule( decision.territories.includes(to) && advisorBattleOptions(g).includes(to), 'These advisors cannot prepare for battle.', ); g.response = { kind: 'advisorFlip', owner: id, location: to, advisors: false, passed: [], advisorResume: 'declaration', advisorRemaining: decision.territories.filter((t) => t !== to), }; } else movementTurn(g); } else if (decision.kind === 'advisor') { requireRule( typeof action.accept === 'boolean', 'Choose whether to ship an advisor.', ); if (decision.ambassadorEvent !== undefined) { const { order } = guildAmbassadorArrival( g, decision.ambassadorEvent, 'advisor', ); requireRule( decision.shipment === order.player && p.faction === 'beneGesserit' && p.id !== order.player, 'This accompaniment does not belong to its Guild Ambassador shipment.', ); if (action.accept) { const quote = guildAmbassadorRule(() => quoteGuildAmbassadorAdvisor(g, p.id, order, action), ); validateGuildAdvisorEntry(g, quote); g.response = { kind: 'advisor', owner: id, passed: [], location: location(quote.territory, quote.sector), advisorResume: 'ambassador', advisorAmbassadorEvent: decision.ambassadorEvent, }; } else { log( g, `${p.name} declined the free accompaniment to the Guild Ambassador shipment.`, ); finishAmbassador(g); } } else if (action.accept) { requireRule(p.reserves > 0, 'No forces remain in your reserves.'); const target = action.accompany === true && g.advanced ? decision.destination : 'polar_sink:0'; requireRule(target, 'This shipment has no accompanying destination.'); const dest = splitLocation(target); if ( g.advanced && action.accompany === true && action.sector !== undefined ) dest.sector = integer(action.sector, 0, 18, 'Sector'); const advisors = arrivalAsAdvisor( g, p, dest.territory, undefined, action.accompany === true, ); allowedEntry(g, p, dest.territory, dest.sector, false, advisors); g.response = { kind: 'advisor', owner: id, passed: [], location: location(dest.territory, dest.sector), }; } else log(g, `${p.name} declined the free shipment.`); } else if (decision.kind === 'wormPlacement') { requireRule( typeof action.accept === 'boolean', 'Choose whether to place the additional worm.', ); if (action.accept) { const t = stringField(action.territory); requireRule( TERRITORIES.some((x) => x.id === t && x.type === 'sand'), 'Additional worms must be placed in sand territory.', ); g.response = { kind: 'wormPlacement', owner: id, location: t, passed: [], }; } else continueSpice(g); } else if (decision.kind === 'wormProtection') { requireRule( typeof action.accept === 'boolean', 'Choose whether to protect your ally.', ); if (action.accept) { g.response = { kind: 'wormAllyProtection', owner: id, location: decision.territory, recipient: decision.ally, passed: [], }; } else wormSurvival(g, decision.territory); } else if (decision.kind === 'wormRide') { requireRule( typeof action.accept === 'boolean', 'Choose whether to ride Shai-Hulud.', ); if (action.accept) { const to = stringField(action.territory); const sector = integer(action.sector, 0, 18, 'Sector'); requireRule( to !== decision.territory, 'Choose another destination territory.', ); allowedEntry(g, p, to, sector); requireRule( action.forces && typeof action.forces === 'object' && !Array.isArray(action.forces), 'Choose forces from the worm territory.', ); let total = 0, totalElite = 0; for (const [key, value] of Object.entries(action.forces)) { const source = splitLocation(key); requireRule( source.territory === decision.territory && validLocation(source.territory, source.sector), 'Ride only from the territory where the worm appeared.', ); const n = integer(value, 0, p.forces[key] ?? 0, 'Forces'); if (!n) continue; requireRule( source.sector !== g.storm, 'Forces cannot leave a sector in storm.', ); const elite = eliteChoice( n, p.forces[key], p.elites?.forces[key] ?? 0, (action.eliteForces as Record | undefined)?.[key], ); removeGroup(p, [[key, n]], { [key]: elite }); totalElite += elite; total += n; } requireRule(total > 0, 'Choose at least one force to ride.'); place(p, to, sector, total, totalElite); log( g, `${p.name} rode Shai-Hulud with ${total} forces to ${territory(to).name}.`, ); const intruded = intrusion(g, p, to, { wormRide: true }); if ( openTerritoryEntry( g, p, to, sector, total, totalElite, 'wormRide', 'wormRide', ) || intruded ) return g; } else log(g, `${p.name} declined the sandworm ride.`); nextWormRide(g); } else { requireRule( Array.isArray(action.discard) && action.discard.every( (card) => typeof card === 'string' && decision.cards.includes(card), ), 'You may discard only cards played in this battle.', ); const selected = new Set(action.discard); const context = selected.size ? cleanupBattleContext(g, decision.territory, p.id) : null; const discarded = [...selected].map((id) => ({ card: discard(g, p, id), discardedBy: p.id, publicFace: true, })); log( g, `${p.name} discarded ${selected.size} of their played battle cards.`, ); if (discarded.length) stageTreacheryDiscard(g, 'battle:winner', discarded, { kind: 'battleCleanup', event: context!.event, combatants: [...g.lastBattle], territory: decision.territory, player: p.id, source: 'winner', kept: decision.cards.filter((id) => !selected.has(id)), played: [...decision.cards], }); else finishBattle(g); } return g; } if (g.status === 'lobby') { if (t === 'seatPosition') { requireRule( action.target === undefined || action.target === id, 'You may choose only your own player circle.', ); const position = integer(action.position, 1, 6, 'Player circle'); const positions = normalizedPlayerPositions(g); requireRule( !g.players.some( (other) => other.id !== id && positions[other.id] === position, ), 'That player circle is occupied.', ); requireRule( positions[id] !== position, 'You already occupy that player circle.', ); positions[id] = position; g.playerPositions = positions; g.players.forEach((player) => (player.ready = !!player.bot)); log(g, `${p.name} chose player circle ${position}.`); return g; } if (t === 'techTokens') { requireRule(id === g.host, 'Only the host can change optional rules.'); requireRule( typeof action.enabled === 'boolean', 'Choose whether to use tech tokens.', ); g.techTokens = action.enabled ? createTechTokens() : null; g.players.forEach((p) => (p.ready = !!p.bot)); return g; } if (t === 'strongholdCards') { requireRule(id === g.host, 'Only the host can change optional rules.'); requireRule( typeof action.enabled === 'boolean' && (!action.enabled || g.advanced), 'Stronghold Cards require Advanced rules.', ); g.strongholdCards = action.enabled ? createStrongholdCards() : null; g.players.forEach((player) => (player.ready = !!player.bot)); return g; } if (t === 'addBot' || t === 'removeBot') { requireRule(id === g.host, 'Only the host can manage AI seats.'); if (t === 'addBot') { const difficulty = stringField(action.difficulty); requireRule( ['Easy', 'Medium', 'Hard', 'Brutal'].includes(difficulty), 'Choose an AI difficulty.', ); const f = stringField(action.faction) as FactionId; requireRule( FACTIONS.some((x) => x.id === f), 'Unknown faction.', ); const bot = newPlayer(crypto.randomUUID(), `${faction(f).name} AI`, f); bot.bot = difficulty as NonNullable; joinGame(g, bot); } else { const target = getPlayer(g, stringField(action.target)); requireRule(target.bot, 'Only an AI seat can be removed here.'); g.players = g.players.filter((p) => p.id !== target.id); if (g.playerPositions) delete g.playerPositions[target.id]; g.players.forEach((p) => (p.ready = false)); } g.players.filter((p) => p.bot).forEach((p) => (p.ready = true)); return g; } if (t === 'ready') { p.ready = !p.ready; return g; } if (t === 'start') { requireRule(id === g.host, 'Only the host can start the game.'); start(g); return g; } if (t === 'faction') { const f = action.faction as FactionId; requireRule( FACTIONS.some((x) => x.id === f), 'Unknown faction.', ); requireRule( !g.players.some((x) => x.id !== id && x.faction === f), 'That faction is taken.', ); requireRule( faction(f).expansion === 'base' || g.expansions.includes(faction(f).expansion), 'That expansion is disabled.', ); p.faction = f; p.elites = f === 'ixians' ? { reserves: 7, tanks: 0, forces: {}, revived: 0 } : undefined; p.leaders = leaders(f); g.players.forEach((x) => (x.ready = false)); return g; } throw new RuleError('That action is not available in the lobby.'); } if (g.status === 'setup') { if (g.setupStage) { const expected = g.setupStage === 'prediction' ? ['predict'] : g.setupStage === 'traitors' ? ['traitor'] : ['fremenSetup', 'advisorSetup']; requireRule( expected.includes(t) && setupPending(g).includes(id), `Wait for the current setup step: ${g.setupStage}.`, ); } if (t === 'advisorSetup') { requireRule( g.advanced && p.faction === 'beneGesserit' && !p.advisorSetup, 'Advisor setup is not available.', ); requireRule( !byFaction(g, 'fremen') || byFaction(g, 'fremen')!.reserves === 10, 'Wait for Fremen placement.', ); const to = stringField(action.territory); const sector = integer(action.sector, 0, 18, 'Sector'); requireRule( to !== MOBILE_STRONGHOLD && validLocation(to, sector), 'Choose a printed board sector; the mobile stronghold is not yet placed.', ); requireRule( p.reserves === 20, 'Advisor setup requires the unplaced starting force.', ); p.reserves--; place(p, to, sector, 1); p.advisors = { [to]: {} }; p.advisorSetup = true; settleAdvisors(g); } else if (t === 'traitor') { requireRule( p.traitorChoices.includes(String(action.leader)), 'Choose one of your dealt traitors.', ); if (g.setupStage === 'traitors') (g.traitorReserve ??= []).push( ...p.traitorChoices.filter( (leader) => leader !== String(action.leader), ), ); p.traitors = [String(action.leader)]; p.traitorChoices = []; } else if (t === 'fremenSetup') { requireRule( p.faction === 'fremen' && p.reserves === 20, 'Fremen setup is already complete.', ); requireRule( action.placements && typeof action.placements === 'object' && !Array.isArray(action.placements), 'Choose starting force placements.', ); const placements = Object.entries(action.placements).map( ([key, value]) => { const loc = key.includes(':') ? splitLocation(key) : { territory: key, sector: FREMEN_START.includes(key) ? territory(key).sectors[0] : -1, }; requireRule( FREMEN_START.includes(loc.territory) && validLocation(loc.territory, loc.sector), 'Starting forces belong in sectors of Sietch Tabr, False Wall South or False Wall West.', ); return { ...loc, amount: integer(value, 0, 10, 'Starting forces') }; }, ); requireRule( placements.reduce((sum, p) => sum + p.amount, 0) === 10, 'Place exactly ten starting forces.', ); let totalElite = 0; for (const loc of placements) { const key = location(loc.territory, loc.sector); const elite = integer( (action.elitePlacements as Record | undefined)?.[ key ] ?? 0, 0, Math.min(loc.amount, p.elites?.reserves ?? 0), 'Elite forces', ); totalElite += elite; if (loc.amount) place(p, loc.territory, loc.sector, loc.amount, elite); } requireRule( totalElite <= (p.elites?.reserves ?? 0), 'Too many elite forces in starting placements.', ); if (p.elites) p.elites.reserves -= totalElite; p.reserves = 10; } else if (t === 'predict') { requireRule( p.faction === 'beneGesserit' && !p.prediction, 'Prediction is already locked.', ); requireRule( g.players.some((x) => x.faction === action.faction && x.id !== id), 'Predict another faction in this game.', ); p.prediction = { faction: action.faction as FactionId, turn: integer(action.turn, 1, 10, 'Turn'), }; } else throw new RuleError('Complete the starting choices.'); return g; } if (t === 'replaceFaceDancer') { requireRule( g.phase === 8 && p.faction === 'tleilaxu', 'Replace a Face Dancer during Mentat Pause.', ); requireRule( p.faceDancerReplacedTurn !== g.turn, 'Only one Face Dancer replacement is permitted per turn.', ); const dancer = p.faceDancers?.find( (c) => !c.revealed && c.leader === action.leader, ); requireRule(dancer, 'Choose one of your unrevealed Face Dancers.'); p.faceDancerReplacedTurn = g.turn; g.response = { kind: 'faceDancerReplacement', owner: id, passed: [], intent: dancer.leader, }; return g; } if (t === 'choamInflation') { requireRule( g.phase === 8 && p.faction === 'choam', 'CHOAM places Inflation during Mentat Pause.', ); requireRule( !g.inflationUsed && !g.inflation, 'The Inflation token is unavailable or already used.', ); requireRule( g.inflationAttemptTurn !== g.turn, 'Inflation placement was already attempted this Mentat Pause.', ); requireRule( action.side === 'double' || action.side === 'cancel', 'Choose Double or Cancel for next turn’s charity.', ); g.inflationAttemptTurn = g.turn; g.response = { kind: 'choamInflation', owner: id, intent: action.side, passed: [], }; g.ready = []; return g; } if (t === 'bribe') { requireRule( g.inflation?.side !== 'double', 'Bribes are prohibited while Inflation shows Double.', ); requireRule( g.phase !== 8, 'Bribes cannot be made during the Mentat pause.', ); const target = getPlayer(g, stringField(action.target)); requireRule( target.id !== id && target.id !== p.ally, 'Bribes are between different, non-allied factions.', ); const n = integer(action.amount, 1, uncommittedSpice(g, p), 'Spice'); p.spice -= n; target.bribes += n; log( g, `${p.name} promised ${n} spice to ${target.name}; collect it at the Mentat pause.`, ); return g; } if (t === 'stormDial') { requireRule( g.phase === 0 && g.stormPending === null && g.stormDialers.includes(id), 'You are not dialing the storm.', ); requireRule( g.stormDials[id] === undefined, 'Your storm dial is already locked.', ); g.stormDials[id] = integer( action.amount, g.turn === 1 ? 0 : 1, g.turn === 1 ? 20 : 3, 'Storm dial', ); if (g.stormDialers.every((x) => g.stormDials[x] !== undefined)) { g.stormPending = Object.values(g.stormDials).reduce((a, b) => a + b, 0); g.ready = []; log( g, `Storm distance revealed: ${g.stormPending} sectors. Resolve storm cards before moving.`, ); } return g; } if (t === 'alliance') { requireRule( g.phase === 1 && g.nexus && !g.spiceWindow && !g.spiceResolution, 'Alliances change only during a Nexus.', ); if (!action.target) { if (p.ally) getPlayer(g, p.ally).ally = null; p.ally = null; delete g.allianceOffers[id]; log(g, `${p.name} is unallied.`); } else { const other = getPlayer(g, stringField(action.target)); requireRule(other.id !== id, 'Choose another player.'); requireRule( !p.ally && !other.ally, 'Break existing alliances before forming a new one.', ); g.allianceOffers[id] = other.id; if (g.allianceOffers[other.id] === id) { p.ally = other.id; other.ally = id; p.allySinceTurn = other.allySinceTurn = g.turn; delete g.allianceOffers[id]; delete g.allianceOffers[other.id]; log(g, `${p.name} and ${other.name} formed an alliance.`); } } g.ready = []; return g; } if (t === 'charity') { const advancedBG = g.advanced && p.faction === 'beneGesserit'; const amount = charityAmount(g, p); requireRule( g.phase === 2 && amount > 0, 'Charity is available here when you have fewer than two spice.', ); requireRule( !byFaction(g, 'choam') || g.choamCharity?.turn === g.turn, 'Resolve CHOAM income before claiming charity.', ); requireRule( p.charityTurn !== g.turn, 'Charity may be claimed only once per turn.', ); p.charityTurn = g.turn; if (advancedBG && p.spice >= 2) g.response = { kind: 'bgCharity', owner: id, passed: [], amount }; else payCharity(g, p, amount); log(g, `${p.name} claimed CHOAM charity.`); return g; } if (t === 'pledgeAid') { requireRule( ([3, 5].includes(g.phase) || (g.phase === 6 && g.advanced && p.faction === 'choam')) && p.ally, 'You may fund an ally during bidding/shipment, or as advanced CHOAM during Battle.', ); pledgeAid(g, p, action.amount); return g; } if (t === 'richeseBid') { const lot = g.richeseAuction; requireRule( g.phase === 3 && lot && !lot.outcome, 'There is no open Richese auction.', ); requireRule(p.hand.length < handLimit(p), 'Your hand is full.'); requireRule( lot.source !== 'blackMarket' || p.id !== lot.owner || action.amount === null || action.amount === 0, 'A positive Black Market self-bid is awaiting a ruling on payment and sale counting.', ); const amount = action.amount === null ? null : integer(action.amount, 0, Number.MAX_SAFE_INTEGER, 'Bid'); const ownAvailable = uncommittedSpice(g, p) + richeseOwnCommitment(lot, g.richeseFunding ?? {}, p.id); const allyAvailable = aidFor(g, p)?.amount ?? 0; const allyPayment = amount === null ? 0 : integer( action.allyPayment ?? Math.max(0, amount - ownAvailable), 0, Math.min(amount, allyAvailable), 'Ally payment', ); if (amount !== null) validateRicheseFunding(amount, allyPayment, ownAvailable, allyAvailable); g.richeseAuction = submitRicheseBid( lot, { event: stringField(action.event), actor: p.id, amount }, ownAvailable + allyAvailable, ); if (amount !== null) (g.richeseFunding ??= {})[p.id] = { amount, allyPayment, donor: allyPayment ? p.ally : null, }; g.active = g.richeseAuction.active; log( g, lot.method === 'silent' ? `${p.name} submitted a sealed bid; its amount remains concealed until every eligible bidder submits.` : `${p.name} ${amount === null ? 'passed' : `bid ${amount} spice`} in the ${lot.method === 'onceAround' ? 'Once Around' : 'Black Market'} auction.`, ); if (lot.method === 'silent' && g.richeseAuction.outcome) log( g, `Silent bids revealed together: ${g.richeseAuction.tieOrder.map((id) => `${getPlayer(g, id).name} ${g.richeseAuction!.sealed[id]}`).join('; ')}. Ties are resolved in storm order.`, ); settleRicheseLot(g); return g; } if (t === 'bid' || t === 'passBid') { requireRule( g.phase === 3 && g.auction?.active === id, 'Wait for your bid.', ); const a = g.auction; if (t === 'bid') { a.bid = integer( action.amount, a.bid + 1, karamaCard(g, p) ? Number.MAX_SAFE_INTEGER : p.spice + (aidFor(g, p)?.amount ?? 0), 'Bid', ); a.allyPayment = a.bid <= p.spice + (aidFor(g, p)?.amount ?? 0) ? contribution(g, p, a.bid, action.allyPayment) : 0; a.bidder = id; a.passed = []; } else if (!a.passed.includes(id)) a.passed.push(id); auctionNext(g); return g; } if (t === 'emperorGift' || t === 'emperorRevival') { requireRule( p.faction === 'emperor' && p.ally, 'Only the Emperor may use this power for an ally.', ); const recipient = getPlayer(g, p.ally); if (t === 'emperorRevival') { requireRule( !revivalPrevented(g, recipient.id), 'Tleilaxu prevented this faction’s normal revivals for this turn.', ); requireRule( g.phase === 4, 'Extra revivals are available during Revival.', ); } const maximum = t === 'emperorGift' ? uncommittedSpice(g, p) : Math.min( 3 - (g.emperorExtra[recipient.id] ?? 0), recipient.tanks, Math.floor(uncommittedSpice(g, p) / 2), ); const amount = integer( action.amount, 1, maximum, t === 'emperorGift' ? 'Spice' : 'Forces', ); const elite = t === 'emperorRevival' ? eliteChoice( amount, recipient.tanks, recipient.elites?.tanks ?? 0, action.elite, ) : 0; requireRule( elite <= eliteRevivalRemaining(recipient), 'Only one elite force may be revived per turn.', ); if (t === 'emperorRevival') requireRule( p.spice >= paidForceRevivalCost(recipient, amount, elite), 'Not enough spice for these extra revivals.', ); g.response = { kind: t, elite, owner: id, recipient: recipient.id, amount, passed: [], }; return g; } if (t === 'grantRevival') { requireRule( g.phase === 4 && p.faction === 'fremen' && p.ally, 'Only Fremen may grant their ally free revival during revival.', ); requireRule( !g.freeRevival.includes(p.ally), 'Free revival has already been granted.', ); g.freeRevival.push(p.ally); log( g, `${p.name} granted their ally up to three free force revivals this phase.`, ); return g; } if (t === 'tleilaxuRevivalLimit' || t === 'tleilaxuAllyDiscount') { requireRule( g.phase === 4 && p.faction === 'tleilaxu', 'Only Tleilaxu may grant revival benefits during Revival.', ); g.revivalRules ??= newRevivalRules(); if (t === 'tleilaxuRevivalLimit') { const target = getPlayer(g, stringField(action.target)); requireRule( target.id !== id && !g.revivalRules.limitBlocked, 'The expanded revival limit is unavailable.', ); requireRule( !g.revivalRules.expanded.includes(target.id), 'That faction already has permission to revive five forces.', ); g.revivalRules.expanded.push(target.id); log( g, `${p.name} allowed ${target.name} to revive up to five forces this phase.`, ); } else { requireRule( p.ally && !g.revivalRules.discountBlocked && g.revivalRules.allyDiscount !== p.ally, 'The allied revival discount is unavailable or already granted.', ); g.revivalRules.allyDiscount = p.ally; log(g, `${p.name} offered their ally half-price revival this phase.`); } return g; } if ( t === 'requestLeaderRevival' || t === 'quoteLeaderRevival' || t === 'declineLeaderRevival' || t === 'cancelLeaderRequest' || t === 'acceptLeaderRevival' ) { const tleilaxu = byFaction(g, 'tleilaxu'); requireRule( g.phase === 4 && tleilaxu, 'Tleilaxu leader negotiations require the Revival phase.', ); g.revivalRequests ??= {}; if (t === 'cancelLeaderRequest') { delete g.revivalRequests[id]; return g; } if (t === 'quoteLeaderRevival' || t === 'declineLeaderRevival') { requireRule( p.id === tleilaxu.id, 'Only Tleilaxu may quote a revival price.', ); const recipient = getPlayer(g, stringField(action.target)).id; const request = g.revivalRequests[recipient]; requireRule(request, 'Wait for that faction to request a leader.'); if (t === 'declineLeaderRevival') request.declined = true; else { request.price = integer(action.amount, 0, 1000000, 'Price'); delete request.declined; } return g; } requireRule( p.id !== tleilaxu.id && !p.leaderRevived, 'Only one negotiated leader revival is permitted per faction per phase.', ); const requested = t === 'requestLeaderRevival' ? stringField(action.leader) : g.revivalRequests[id]?.leader; requireRule(requested, 'Request a leader first.'); requireRule( !revivalPrevented(g, p.id), 'Tleilaxu prevented this faction’s normal revivals for this turn.', ); const buyback = p.leaders.some( (l) => l.id === requested && l.gholaBy === tleilaxu.id, ); requireRule( buyback || p.leaders.some(nativeAvailable), 'When all native leaders are unavailable, use ordinary revival rules.', ); if (requested === 'kwisatz') requireRule( g.advanced && p.faction === 'atreides' && p.kwisatz?.dead, 'Kwisatz Haderach is not in the tanks.', ); else requireRule( p.leaders.some((l) => l.id === requested && l.dead && !l.capturedBy), 'Choose one of your dead native leaders.', ); requireRule( !g.revivalRules?.earlyBlocked.includes(`${p.id}:${requested}`), 'Early revival of that leader was canceled this phase.', ); if (t === 'requestLeaderRevival') g.revivalRequests[id] = { leader: requested, price: null }; else { const price = g.revivalRequests[id]?.price; requireRule( !g.revivalRequests[id]?.declined, 'That request was declined.', ); requireRule( typeof price === 'number', 'Wait for Tleilaxu to quote a price.', ); requireRule(p.spice >= price, 'Not enough spice for the agreed revival.'); beginRevival(g, { player: id, kind: requested === 'kwisatz' ? 'kwisatz' : 'leader', leader: requested, normalCost: price, cost: price, free: 0, checks: ['earlyRevival'], }); } return g; } if (t === 'reviveForeignGhola') { requireRule( g.advanced && g.phase === 4 && p.faction === 'tleilaxu', 'Foreign gholas require advanced Tleilaxu during Revival.', ); const leader = g.players .flatMap((owner) => owner.leaders) .find((l) => l.id === action.leader); requireRule( leader && leader.faction !== p.faction && leader.dead && !leader.capturedBy, 'Choose a dead leader of another faction.', ); requireRule( !isAuditorLeader(leader), 'The Auditor cannot be acquired as a Tleilaxu foreign ghola.', ); requireRule( controlledLeaders(g, p).filter((l) => !l.dead).length < 5, 'Foreign gholas may fill your active leader pool only up to five.', ); requireRule( p.gholaBlocked?.[leader.id] !== g.turn, 'Reviving that foreign ghola was canceled this turn.', ); const normalCost = leader.strength; const cost = revivalDiscount(g, p) ? Math.ceil(normalCost / 2) : normalCost; requireRule(p.spice >= cost, 'Not enough spice for this ghola.'); beginRevival(g, { player: id, kind: 'foreignGhola', leader: leader.id, normalCost, cost, free: 0, checks: cost < normalCost ? ['foreignGhola', 'revivalDiscount'] : ['foreignGhola'], }); return g; } if (t === 'revive') { requireRule(g.phase === 4, 'Revival is not the current phase.'); const n = integer(action.amount, 1, forceRevivalRemaining(g, p), 'Forces'); const elite = eliteChoice(n, p.tanks, p.elites?.tanks ?? 0, action.elite); requireRule( elite <= eliteRevivalRemaining(p), 'Only one elite force may be revived per turn.', ); const availableFree = forceRevivalQuote(g, p, n).free; const freeElite = p.faction === 'ixians' ? integer( action.freeElite ?? Math.min(availableFree, elite), Math.max(0, availableFree - (n - elite)), Math.min(availableFree, elite), 'Free cyborg revivals', ) : 0; const quote = forceRevivalQuote(g, p, n, elite, freeElite); requireRule(quote.cost <= p.spice, 'Not enough spice for revival.'); const checks: PendingRevival['checks'] = []; const choamBenefit = p.faction === 'choam' && !g.revivalRules?.choamBlocked; if (choamBenefit && (n > quote.free || p.revived + n > 3)) checks.push('choamRevival'); if (!choamBenefit && p.revived + n > 3) checks.push('revivalLimit'); const factionCost = choamBenefit ? n - quote.free : quote.normalCost; if (quote.cost < factionCost) checks.push('revivalDiscount'); beginRevival(g, { player: id, kind: 'forces', amount: n, elite, ...quote, checks, }); return g; } if (t === 'reviveKwisatz' || t === 'reviveLeader') { requireRule( g.phase === 4 && (!p.leaderRevived || p.faction === 'tleilaxu'), 'Only one leader revival is permitted this phase.', ); requireRule( !revivalPrevented(g, p.id), 'Tleilaxu prevented this faction’s normal revivals for this turn.', ); const options = leaderRevivalOptions(g, p); p.revivalCycle = options.cycle; const l = t === 'reviveLeader' ? p.leaders.find((l) => l.id === action.leader) : undefined; if (t === 'reviveKwisatz') requireRule( g.advanced && p.faction === 'atreides' && p.kwisatz?.dead, 'Kwisatz Haderach is not in the tanks.', ); else requireRule( l?.dead && !l.capturedBy && !l.gholaBy, 'Choose a dead leader.', ); const option = options.leaders.find((option) => option.id === l?.id); const normal = t === 'reviveKwisatz' ? !!options.kwisatz : !!option && !option.early; requireRule( normal || p.faction === 'tleilaxu', 'All leaders must die before a new revival cycle can begin.', ); const checks: PendingRevival['checks'] = []; if (!normal) { requireRule( !g.revivalRules?.earlyBlocked.includes(`${id}:${l!.id}`), 'Early revival of that leader was canceled this phase.', ); checks.push('earlyRevival'); } requireRule( t === 'reviveKwisatz' ? !!options.kwisatz : !!option, 'That leader is unavailable for revival this phase.', ); const normalCost = l?.strength ?? 2; const cost = revivalDiscount(g, p) ? Math.ceil(normalCost / 2) : normalCost; requireRule(p.spice >= cost, 'Not enough spice.'); if (cost < normalCost) checks.push('revivalDiscount'); beginRevival(g, { player: id, kind: t === 'reviveKwisatz' ? 'kwisatz' : 'leader', leader: l?.id, normalCost, cost, free: 0, checks, }); return g; } if (t === 'revealNoField') { requireRule( p.faction === 'richese', 'Only Richese controls the concealed token.', ); const blocked = noFieldRevealBlock(g, p); requireRule(!blocked, blocked ?? 'Voluntary reveal is unavailable.'); requireRule( action.event === p.noFieldEvent && action.token === p.noField?.deployed?.tokenId, 'This No-Field reveal selection is stale.', ); revealPlayerNoField(g, p, 'voluntary'); return g; } if (['ship', 'move', 'endMovement', 'guildShip'].includes(t)) { const richese = byFaction(g, 'richese'); requireRule( !richese || noFieldAllyOfferBlock(g, richese) || (g.richeseAllyOpportunity?.turn === g.turn && g.richeseAllyOpportunity.recipient === id), 'Wait for Richese to offer or pass its allied shipment opportunity.', ); } if (t === 'offerRicheseNoField') { const blocked = noFieldAllyOfferBlock(g, p); requireRule( !blocked, blocked ?? 'Allied No-Field shipment is unavailable.', ); requireRule( action.event === p.noFieldEvent, 'This No-Field token selection is stale.', ); const offer: RicheseAllyOffer = { event: crypto.randomUUID(), owner: id, recipient: p.ally!, tokenId: stringField(action.token), tokenEvent: p.noFieldEvent!, territory: stringField(action.territory), sector: integer(action.sector, 0, 18, 'Sector'), payer: stringField(action.payer), }; alliedNoFieldQuote(g, offer, offer.recipient === id); g.richeseAllyOffer = offer; g.richeseAllyOpportunity = { turn: g.turn, recipient: offer.recipient }; g.decision = { kind: 'richeseAllyShipment', player: offer.recipient, owner: id, }; g.ready = []; log( g, `${p.name} offered their ally a No-Field shipment. Its proposed token and destination remain private until shipment is declared.`, ); return g; } if (t === 'ship') { requireRule( g.phase === 5 && g.active === id && !p.shipped, 'You may ship once, before your movement.', ); checkShipmentPromises(g, p, { territory: String(action.territory), amount: Number(action.amount), }); const to = String(action.territory), s = integer(action.sector, 0, 18, 'Sector'); const advisors = arrivalAsAdvisor(g, p, to); requireRule( to !== MOBILE_STRONGHOLD || p.faction === 'ixians', 'Only Ixians may ship directly into the mobile stronghold.', ); allowedEntry(g, p, to, s, g.advanced && p.faction === 'fremen', advisors); let noField: PendingShipment['noField']; if (action.noField !== undefined) { requireRule( p.faction === 'richese', 'Only Richese may declare its own concealed No-Field shipment.', ); const blocked = noFieldShipBlock(g, p); requireRule(!blocked, blocked ?? 'No-Field shipment is unavailable.'); requireRule( action.event === p.noFieldEvent, 'This No-Field selection is stale.', ); const tokenId = stringField(action.noField); noFieldRule(() => deployRicheseNoField(p.noField!, { tokenId, controller: id, location: { territory: to, sector: s }, }), ); requireRule( action.amount === undefined || action.amount === 1, 'The shipment price is for one No-Field, not a chosen number of physical forces.', ); requireRule( action.elite === undefined || action.elite === 0, 'A concealed No-Field is not an elite force.', ); noField = { tokenId, event: p.noFieldEvent! }; } const n = noField ? 1 : integer(action.amount, 1, p.reserves, 'Forces'); const elite = noField ? 0 : eliteChoice(n, p.reserves, p.elites?.reserves ?? 0, action.elite); const cost = reserveShipmentCost( { faction: p.faction, halfRate: p.faction === 'guild' || byFaction(g, 'guild')?.id === p.ally || g.karamaShipping?.player === id, }, territory(to).type, n, ); if (p.faction === 'fremen') { requireRule( territory('the_great_flat').sectors.some((fs) => territory(to).sectors.some( (ts) => distance(location('the_great_flat', fs), location(to, ts)) <= 2, ), ), 'Fremen reinforcements must arrive within two territories of the Great Flat.', ); } requireRule( cost <= p.spice + (aidFor(g, p)?.amount ?? 0), 'Not enough spice to ship those forces.', ); const allyPayment = contribution(g, p, cost, action.allyPayment); const shipment: PendingShipment = { turn: g.turn, player: id, territory: to, sector: s, amount: n, elite, cost, allyPayment, advisors, ...(noField ? { noField } : {}), }; if (shipment.noField) { g.pendingShipment = shipment; g.response = { kind: 'richeseNoField', owner: p.id, passed: [] }; log( g, `${p.name} declared a concealed No-Field shipment to ${territory(to).name}, sector ${s}.`, ); } else offerShipment(g, shipment); return g; } if (t === 'guildShip') { checkShipmentPromises( g, p, action.from === 'reserves' ? { territory: String(action.territory), amount: Number(action.amount) } : null, ); requireRule( action.noField === undefined, 'Guild transport of a concealed No-Field awaits its specific return and custody rules.', ); requireRule( g.phase === 5 && g.active === id && !p.shipped && (p.faction === 'guild' || byFaction(g, 'guild')?.id === p.ally), 'Guild shipment is not available.', ); const fromReserves = action.from === 'reserves'; requireRule( !fromReserves || p.faction === 'fremen', 'Only allied Fremen may cross-ship from their southern reserves.', ); const { group, eliteGroup, elite, origin, total: n, } = fromReserves ? { origin: 'reserves', group: [] as [string, number][], eliteGroup: {} as Record, elite: eliteChoice( Number(action.amount), p.reserves, p.elites?.reserves ?? 0, action.elite, ), total: integer(action.amount, 1, p.reserves, 'Forces'), } : forceGroup(p, action); requireRule( group.every(([key]) => splitLocation(key).sector !== g.storm), 'The source is in storm.', ); const to = stringField(action.territory ?? 'reserves'); const sector = integer(action.sector ?? 0, 0, 18); requireRule( to !== 'reserves' || p.faction === 'guild', 'Only the Guild may ship forces back to reserves.', ); const advisors = to !== 'reserves' && arrivalAsAdvisor(g, p, to); const sourceLock = p.advisors?.[origin]?.lockedTurn; requireRule( to === 'reserves' || advisors || sourceLock !== g.turn || !g.players.some((other) => other.id !== id && at(other, to)), 'New advisors cannot become fighters this turn.', ); requireRule( to !== MOBILE_STRONGHOLD || p.faction === 'ixians', 'Only Ixians may ship directly into the mobile stronghold.', ); if (to !== 'reserves') allowedEntry(g, p, to, sector, false, advisors); const cost = guildShipmentCost( to === 'reserves' ? 'reserves' : territory(to).type, n, ); requireRule( p.spice + (aidFor(g, p)?.amount ?? 0) >= cost, 'Not enough spice.', ); const allyPayment = contribution(g, p, cost, action.allyPayment); payWithAlly(g, p, cost, allyPayment); const guild = byFaction(g, 'guild'); const guildPayment = guildShipmentIncome({ guild: guild?.id, shipper: id, ally: p.ally, cost, allyPayment, bankOnly: g.karamaShipping?.player === id, }); if (guild && guildPayment > 0) g.response = { kind: 'guildIncome', owner: guild.id, amount: guildPayment, passed: [], }; if (fromReserves) { p.reserves -= n; if (p.elites) p.elites.reserves -= elite; } else removeGroup(p, group, eliteGroup); if (to === 'reserves') { p.reserves += n; if (p.elites) p.elites.reserves += elite; } else { place(p, to, sector, n, elite); if (advisors) (p.advisors ??= {})[to] = { lockedTurn: Math.max(sourceLock ?? 0, p.advisors?.[to]?.lockedTurn ?? 0) || undefined, }; else if (p.advisors) delete p.advisors[to]; } finishShipmentPromises( g, p, fromReserves ? { territory: to, amount: n } : null, ); p.shipped = true; g.karamaShipping = null; log(g, `${p.name} used Guild transport for ${n} forces.`); if (to !== 'reserves') { intrusion(g, p, to); if (origin !== to) openTerritoryEntry(g, p, to, sector, n, elite, 'guildTransport'); } return g; } if (t === 'move') { if (!p.shipped) checkShipmentPromises(g, p, null); requireRule( g.phase === 5 && g.active === id && p.moved < movesAllowed(g, p), 'Your movement is not available.', ); const { group, eliteGroup, elite, origin, total: n, noField, sourceKeys, } = forceGroup(p, action); const to = stringField(action.territory), s = integer(action.sector, 0, 18, 'Sector'); const advisors = arrivalAsAdvisor(g, p, to, origin); const lockedTurn = p.advisors?.[origin]?.lockedTurn; requireRule( advisors || lockedTurn !== g.turn || !g.players.some((other) => other.id !== id && at(other, to)), 'New advisors cannot become fighters this turn.', ); const wantsFighters = advisors && action.fighters === true; requireRule( !wantsFighters || (lockedTurn !== g.turn && !at(p, to) && (!(p.ally && at(getPlayer(g, p.ally), to)) || sharesEcazOccupation(g, p, to))), 'These advisors cannot flip to fighters on arrival.', ); requireRule( !balisetPrevents(g, id, origin, to), 'Baliset prevents movement into this CHOAM territory; shipment remains possible.', ); allowedEntry(g, p, to, s, false, advisors && !wantsFighters); requireRule( sourceKeys.every((key) => key !== location(to, s)), 'Choose a different destination.', ); let flight = g.ornithopter?.player === id ? g.ornithopter : null; if (action.movementCard !== undefined) { const mode = action.ornithopter; requireRule( mode === 'range3' || mode === 'twoGroups', 'Choose an Ornithopter movement mode.', ); const blocked = ornithopterBlock(g, p, mode); requireRule(!blocked, blocked ?? 'Ornithopter cannot be played.'); const card = p.hand.find((c) => c.id === action.movementCard); requireRule( card && richeseCardDefinition(card)?.card.effect === 'ornithopter', 'Choose the canonical Ornithopter in your hand.', ); const reserved = transferCardBlock(g, p, card); requireRule(!reserved, reserved ?? 'The card is committed.'); requireRule( action.ornithopterEvent === undefined, 'A new card movement cannot name an old event.', ); flight = { event: crypto.randomUUID(), player: id, card, turn: g.turn, mode, startingMove: p.moved, completed: 0, }; g.ornithopter = flight; p.hand = p.hand.filter((c) => c.id !== card.id); log( g, `${p.name} played Ornithopter: ${mode === 'range3' ? 'one group with a maximum three-territory route' : 'two different groups using normal movement'}. The card remains on the table until its use finishes.`, ); } else if (flight) requireRule( action.ornithopterEvent === flight.event, 'Use the current Ornithopter movement event.', ); else requireRule( action.ornithopterEvent === undefined && action.ornithopter === undefined, 'No matching Ornithopter movement is active.', ); requireRule( !flight || !isAdvisor(p, origin), 'Advanced advisor use of the Ornithopter card awaits its ruling.', ); const speed = flight?.mode === 'range3' ? 3 : movementRange(g, p, elite); requireRule( sourceKeys.every( (key) => gameDistance(g, key, location(to, s), (k) => pathBlocked(g, p, k, isAdvisor(p, origin)), ) <= speed, ), `That destination is blocked or more than ${speed} territories away.`, ); const move: MovementOrder = { player: id, group, eliteGroup, elite, origin, total: n, to, sector: s, advisors, wantsFighters, lockedTurn, ...(noField ? { noField } : {}), ...(flight ? { ornithopterEvent: flight.event, ornithopterRange: flight.mode === 'range3', } : {}), }; validateFlightSelection(g, p, move); if ( !move.ornithopterRange && p.faction === 'ixians' && elite > 0 && speed === 2 && group.some( ([key]) => gameDistance(g, key, location(to, s), (k) => pathBlocked(g, p, k, isAdvisor(p, origin)), ) > 1, ) ) { g.pendingIxMove = move; g.response = { kind: 'ixMovement', owner: id, location: location(to, s), amount: n, elite, passed: [], }; } else if ( !move.ornithopterRange && p.faction === 'fremen' && speed === 2 && group.some( ([key]) => gameDistance(g, key, location(to, s), (k) => pathBlocked(g, p, k, isAdvisor(p, origin)), ) > 1, ) ) { g.pendingFremenMove = { turn: g.turn, move: p.moved, order: move }; g.response = { kind: 'fremenMovement', owner: id, location: location(to, s), amount: n, passed: [], }; } else offerChoamMovement(g, move); return g; } if (t === 'endMovement') { finishShipmentPromises(g, p, null); requireRule( g.phase === 5 && g.active === id, 'Wait for your movement turn.', ); if (g.ornithopter?.player === id) finishOrnithopter(g, 'end'); else finishMovementTurn(g, id); return g; } if (t === 'chooseBattle') { requireRule( g.phase === 6 && g.active === id && !g.battle, 'Wait for your battle choice.', ); const choice = battles(g).find( (b) => b.attacker === id && b.territory === action.territory && b.defender === action.target, ); requireRule(choice, 'Choose one of your unresolved battles.'); g.auditorInsight = null; const noFieldPlayers = [choice.attacker, choice.defender].filter( (playerId) => getPlayer(g, playerId).noField?.deployed?.location.territory === choice.territory, ); for (const playerId of noFieldPlayers) requireRule( Object.entries(getPlayer(g, playerId).forces).every( ([key, n]) => !n || splitLocation(key).territory !== choice.territory, ), 'Mixed ordinary-force and No-Field battle dialing awaits a ruling. Reveal voluntarily before Battle while that option is available.', ); const battleEvent = crypto.randomUUID(); g.battle = { ...choice, event: battleEvent, ...(byFaction(g, 'richese') ? { preLeader: { event: battleEvent, ready: [], closed: false } } : {}), ...(noFieldPlayers.length ? { noFieldPlayers } : {}), prepared: true, plans: {}, revealed: false, traitorCalls: {}, }; beginStrongholdBattle(g); log( g, `${p.name} attacks ${getPlayer(g, choice.defender).name} in ${territory(choice.territory).name}.`, ); return g; } if (t === 'declineBattlePower') { const b = g.battle; requireRule( b?.preparation?.owner === id && b.preparation.kind !== 'prescienceAnswer', 'You do not own this battle power decision.', ); const kind = b.preparation.kind; if (kind === 'voice') battlePreparation(g, 'prescience'); else finishBattlePreparation(g); log( g, `${p.name} declined ${kind === 'voice' ? 'the Voice' : 'battle prescience'}.`, ); return g; } if (t === 'prescience') { const b = g.battle; requireRule( b?.preparation?.kind === 'prescience' && b.preparation.owner === id, 'Prescience is not available. The Voice decision must finish first.', ); requireRule( ['leader', 'weapon', 'defense', 'dial'].includes(String(action.field)), 'Choose a battle-plan element.', ); const beneficiary = b.preparation.beneficiary; const target = beneficiary === b.attacker ? b.defender : b.attacker; requireRule( action.field !== 'dial' || !b.noFieldPlayers?.includes(target), 'Atreides may not inspect the number dialed in a No-Field battle.', ); b.prescience = { player: beneficiary, field: action.field as PlanField }; b.preparation = { kind: 'prescienceAnswer', owner: beneficiary === b.attacker ? b.defender : b.attacker, beneficiary, }; g.response = { kind: 'prescience', owner: id, passed: [] }; return g; } if (t === 'prescienceAnswer') { const b = g.battle; requireRule( b?.preparation?.kind === 'prescienceAnswer' && b.preparation.owner === id && b.prescience, 'You are not revealing a prescience element.', ); requireRule( b.prescience.field !== 'leader' || !b.preLeader || b.preLeader.closed, 'Both combatants must finish their shared preparation before leaders are committed.', ); const field = b.prescience.field; requireRule( field !== 'dial' || !b.noFieldPlayers?.includes(id), 'Atreides may not inspect the number dialed in a No-Field battle.', ); const value = field === 'dial' ? g.advanced || p.faction === 'ixians' ? typeof action.value === 'number' ? action.value : NaN : integer(action.value, 0, at(p, b.territory), 'Forces dialed') : action.value === null || action.value === '' ? null : stringField(action.value); requireRule( feasiblePrescience(g, p, field, value), 'Choose an element that permits a legal battle plan and obeys the Voice.', ); b.prescience.value = value; finishBattlePreparation(g); return g; } if (t === 'voice') { const b = g.battle; requireRule( b?.preparation?.kind === 'voice' && b.preparation.owner === id, 'Voice must be chosen by Bene Gesserit before prescience and battle plans.', ); requireRule( VOICE_KINDS.includes(String(action.kind)), 'Choose a card type.', ); requireRule(typeof action.must === 'boolean', 'Choose require or forbid.'); if (action.kind === 'stoneBurner' && action.must) { const target = getPlayer( g, b.attacker === b.preparation.beneficiary ? b.defender : b.attacker, ); const blocked = stoneCompulsionBlock(g, target); requireRule( !blocked, blocked ?? 'Stone Burner cannot be compelled in this battle.', ); } b.voice = { target: b.attacker === b.preparation.beneficiary ? b.defender : b.attacker, kind: action.kind as NonNullable['kind'], must: action.must, }; battlePreparation(g, 'prescience'); g.response = { kind: 'voice', owner: id, passed: [] }; return g; } if (t === 'battlePlan') { const b = g.battle; requireRule( !b?.preLeader || b.preLeader.closed, 'Both combatants must finish their shared preparation before leaders are committed.', ); requireRule( b && !b.preparation && !b.revealed && [b.attacker, b.defender].includes(id) && !b.plans[id], 'Your battle plan is not available.', ); requireRule( !b.fullPlan || b.plans[b.fullPlan.target] || id === b.fullPlan.target, 'Wait for the plan requested by special prescience.', ); b.plans[id] = validatePlan(g, p, action); if (b.fullPlan?.target === id) { recordFullPlanInspection(g, b.fullPlan.owner); return g; } if (b.plans[b.attacker] && b.plans[b.defender]) { for (const playerId of b.noFieldPlayers ?? []) { const owner = getPlayer(g, playerId); if (owner.noField?.deployed?.location.territory === b.territory) revealPlayerNoField(g, owner, 'battle'); } b.revealed = true; for (const plan of Object.values(b.plans)) { const l = g.players .flatMap((p) => p.leaders) .find((l) => l.id === plan.leader); if (l?.capturedBy) delete l.concealed; } log(g, 'Both battle plans are revealed.'); nextRevealedDecision(g); } return g; } if (t === 'traitorCall') { const b = g.battle; requireRule( b?.revealed && traitorVoters(g, b).includes(id) && b.traitorCalls[id] === undefined, 'Traitor decision is not available.', ); requireRule(typeof action.call === 'boolean', 'Choose reveal or decline.'); if (action.call) { const other = b.attacker === traitorBeneficiary(g, b, id) ? b.defender : b.attacker; requireRule( !b.plans[other].kwisatz, 'Kwisatz Haderach prevents this leader from turning traitor.', ); requireRule( matchingTraitor( p.traitors, b.plans[other].leader, cardOf(getPlayer(g, other), b.plans[other].leader), ), 'You do not hold that leader as a traitor.', ); } b.traitorCalls[id] = action.call; if ( action.call && p.faction === 'harkonnen' && ![b.attacker, b.defender].includes(id) ) { g.response = { kind: 'harkonnenTraitor', owner: id, passed: [] }; return g; } if ( traitorVoters(g, b).every((voter) => b.traitorCalls[voter] !== undefined) ) resolveBattle(g); return g; } if (t === 'card') { const c = p.hand.find((c) => c.id === action.card); requireRule( c && (c.kind === 'special' || canUseAsKarama(g.advanced, p.faction, c)), 'Select a special card in your hand.', ); if ( canUseAsKarama(g.advanced, p.faction, c) && action.mode === 'shipment' ) { const recipient = getPlayer(g, stringField(action.target ?? id)); requireRule( g.phase === 5 && g.active === recipient.id && !recipient.shipped, 'Choose the active player before their shipment.', ); requireRule( !g.karamaShipping, 'A Karama shipment benefit is already available.', ); spendKarama(g, p, c, { kind: 'shipment', recipient: recipient.id }); return g; } if ( canUseAsKarama(g.advanced, p.faction, c) && action.mode === 'purchase' ) { requireRule( g.phase === 3 && g.auction && p.hand.length < handLimit(p), 'You may take the current auction card only when eligible to bid.', ); spendKarama(g, p, c, { kind: 'purchase' }); return g; } if (c.effect === 'thumper') { requireRule( g.phase === 1 && !g.spiceSequence && !g.spiceResolution && !g.spiceWindow && !g.nexus, 'Use Thumper at the beginning of Spice Blow, before the first draw.', ); discard(g, p, c.id); log( g, `${p.name} played Thumper in place of the next spice-card reveal.`, ); blowSpice(g, true); return g; } const availability = ordinaryCardAvailability( { status: g.status, phase: g.phase, active: g.active, me: id, truthtrance: g.truthtrance, phaseOpening: g.phaseOpening, response: g.response, decision: g.decision, shieldWallDestroyed: g.shieldWallDestroyed, spiceWindow: g.spiceWindow, players: [ { id: p.id, faction: p.faction, forces: p.forces, advisors: p.advisors, hand: p.hand, movesAllowed: movesAllowed(g, p), }, ], }, c.id, { amount: action.amount }, ); if (availability && !availability.available) throw new RuleError(availability.reason); if (c.effect === 'hajr') { requireRule( g.ornithopter?.player !== id, 'Hajr combined with Ornithopter awaits its timing ruling.', ); g.hajr.push(id); } else if (c.effect === 'ghola') { applyGholaEffect(g, p, action); } else if (c.effect === 'harvester') { const blow = g.spiceWindow!; if (blow.sector !== g.storm) { const key = location(blow.territory, blow.sector); g.spice[key] = (g.spice[key] ?? 0) + blow.amount; } blow.amount *= 2; blow.harvested = true; blow.harvesters = (blow.harvesters ?? 0) + 1; g.ready = []; } else if (c.effect === 'weather') { // The shared availability check validated this exact numeric selection. g.stormPending = action.amount as number; g.ready = []; } else if (c.effect === 'atomics') { g.shieldWallDestroyed = true; for (const other of g.players) killTerritory(g, other, 'shield_wall'); g.ready = []; } else throw new RuleError( 'This card’s timing and effect are still being implemented.', ); const used = discard(g, p, c.id); log( g, `${p.name} played ${c.name}.`, c.effect === 'ghola' ? { faction: p.faction, name: 'Ghola revival' } : undefined, ); // Shipment feasibility executes deterministic preparations on private clones. // Its internal mode never comes from the submitted action or player state. if (execution === 'live') stageOrdinaryCardDiscard(g, p, used); return g; } if (t === 'ready') { requireRule( [1, 2, 4, 7, 8].includes(g.phase) || (g.phase === 0 && g.stormPending !== null), 'Complete the current phase action first.', ); requireRule(!g.ready.includes(id), 'You are already ready.'); g.ready.push(id); if (g.ready.length === g.players.length) { if (g.phase === 0) moveStorm(g, g.stormPending!); else if (g.phase === 1 && g.spiceWindow) finishSpiceWindow(g); else if (g.phase === 1 && (!g.nexus || g.summonedBeforeBlow)) blowSpice(g); else if (g.phase === 1) nextWormRide(g); else nextPhase(g); } return g; } throw new RuleError('That action is not available.'); } export function viewGame(state: Game, id: string) { karamaConversionIntegrity(state); treacheryDiscardIntegrity(state); shipmentPromiseIntegrity(state); saphoMovementIntegrity(state); ambassadorRelocationIntegrity(state); ecazCollectionIntegrity(state); const g = structuredClone(state); normalizeCardNames(g); settleAdvisors(g); normalizeBattle(g); const me = getPlayer(g, id); let victoryProgress: StrongholdProgress[] = []; if (g.status === 'playing' || g.status === 'finished') { try { victoryProgress = strongholdProgress(state).progress; } catch (error) { // A malformed restored board has no reliable preview. Authoritative // victory still rejects it through its strict quote. if (!(error instanceof VictoryProgressError)) throw error; } } const leaderRevivals = leaderRevivalOptions(g, me); const b = g.battle; const completion = !g.pendingTreacheryDiscard && b && !b.revealed && !b.plans[id] && [b.attacker, b.defender].includes(id) && (b.truthPromises?.some( (promise) => promise.player === id && !promise.released, ) || (b.prescience && b.prescience.player !== id && 'value' in b.prescience)) ? findReachableBattlePlan(g, me) : null; return { guildAmbassadorAdvisorChoices: guildAdvisorChoices(g, id), truthShipmentAnswers: g.truthtrance?.stage === 'answer' && g.truthtrance.question?.target === id && g.truthtrance.question.kind === 'shipment' ? shipmentTruthAnswers(g, me, g.truthtrance.question) : null, shipmentPromises: g.shipmentPromises ?? [], shipmentCompletion: !g.pendingTreacheryDiscard && liveShipmentPromises(g.shipmentPromises ?? [], id, g.turn).some( (p) => p.answer, ) ? findShipmentCompletion(g, me) : null, truthBattleAnswers: g.truthtrance?.stage === 'answer' && g.truthtrance.question?.target === id && g.truthtrance.question.kind === 'battlePlan' ? battleTruthAnswers(g, me, g.truthtrance.question.claim) : null, truthtrance: g.truthtrance ?? null, truthHistory: g.truthHistory ?? [], truthAnswer: g.truthtrance?.stage === 'answer' && g.truthtrance.question?.target === id && g.truthtrance.question.kind === 'fact' ? truthFactAnswer(me, g.truthtrance.question.fact) : null, schema: g.schema, botsPending: g.botsPending ?? false, automaticContinuationPending: !!g.pendingTreacheryDiscard, botNextActionAt: g.botNextActionAt ?? null, code: g.code, version: g.version, host: g.host, status: g.status, setupStage: g.status === 'setup' ? (g.setupStage ?? null) : null, setupPending: setupPending(g), advanced: g.advanced, dukeVidal: g.dukeVidal ? { ...g.dukeVidal, leader: projectLeader(g, g.dukeVidal.leader, id) } : null, moritaniTerror: g.moritaniTerror ? projectTerror(g.moritaniTerror, me.faction === 'moritani') : null, moritaniPendingPlacement: me.faction === 'moritani' ? (g.pendingMoritaniPlacement ?? null) : null, terrorEntry: g.pendingTerrorEntry ? (() => { const entry = g.pendingTerrorEntry!; const token = g.moritaniTerror?.tokens.find( (t) => t.id === entry.token, ); return { entrant: entry.entrant, territory: entry.territory, sector: entry.sector, cause: entry.cause, stage: entry.stage, ...(me.faction === 'moritani' && token ? { kind: token.kind as TerrorKind, canOfferAlliance: !terrorAllianceBlocked( g, entry, token.kind, ), allianceBlockedReason: terrorAllianceBlocked(g, entry, token.kind) ?? undefined, canReveal: !terrorRevealBlocked(g, entry, token.kind), revealBlocked: terrorRevealBlocked(g, entry, token.kind) ?? undefined, ...(token.kind === 'sneakAttack' ? { sneakAttack: sneakAttackOptions(g, entry) } : {}), } : {}), }; })() : null, ixTechnology: me.faction === 'ixians' ? { setup: g.ixSetupCards ?? null, pool: g.ixAuction?.cards.length ? [...g.ixAuction.cards].sort((a, b) => a.id.localeCompare(b.id)) : null, known: g.ixAuctionKnown?.turn === g.turn ? [...g.ixAuctionKnown.cards].sort((a, b) => a.id.localeCompare(b.id), ) : [], used: g.ixTechnologyTurn === g.turn, } : null, ixPurchased: g.pendingIxAlly?.player === id ? (me.hand.find((c) => c.id === g.pendingIxAlly!.card) ?? null) : null, ambassadors: g.ecazAmbassadors ? { tokens: g.ecazAmbassadors.tokens.map((token) => ({ ...token })), nextCost: (g.ecazAmbassadors.placement?.turn === g.turn ? g.ecazAmbassadors.placement.count : 0) + 1, blocked: g.ecazAmbassadors.placement?.turn === g.turn && g.ecazAmbassadors.placement.blocked, completedTurn: g.ecazPlacementTurn ?? null, } : null, ambassadorEntry: g.pendingAmbassador ? (() => { const entry = g.pendingAmbassador!; const owner = getPlayer(g, entry.owner); const entrant = getPlayer(g, entry.entrant); const token = g.ecazAmbassadors!.tokens.find( (t) => t.id === entry.token, )!; return { event: entry.event, owner: entry.owner, entrant: entry.entrant, territory: entry.territory, stage: entry.stage, effect: entry.effect ?? token.effect, beneficiary: entry.beneficiary ?? null, shipment: entry.stage === 'ship' && id === entry.beneficiary ? (() => { const choices = guildAmbassadorRule(() => guildAmbassadorShipments(g, id), ); return { ...choices, destinations: choices.destinations.map((destination) => ({ ...destination, blocked: ambassadorRelocationArrivalBlock( g, id, 'reserves', destination.territory, ), })), }; })() : null, movement: entry.stage === 'move' && id === entry.beneficiary ? ambassadorRelocationMovement(g, id) : null, dukeAcquisition: entry.stage === 'offer' && token.effect === 'ecaz' && id === owner.id ? { blocked: ambassadorEffectBlock(g, 'ecaz', owner, entrant) } : null, beneficiaries: entry.stage === 'offer' && id === owner.id ? g.players .filter( (p) => p.id === owner.id || (p.id === owner.ally && p.ally === owner.id), ) .map((p) => ({ player: p.id, blocked: ambassadorEffectBlock(g, token.effect, p, entrant) ?? (token.effect === 'beneGesserit' && !entry.copyChoices.some( (effect) => !ambassadorEffectBlock(g, effect, p, entrant), ) ? 'No copied effect is currently available.' : null), })) : [], copies: entry.stage === 'copy' && id === entry.beneficiary ? entry.copyChoices.map((effect) => ({ effect, blocked: ambassadorEffectBlock(g, effect, me, entrant), })) : [], cards: entry.stage === 'cards' && id === entry.beneficiary ? me.hand.map((card) => ({ card: card.id, blocked: ambassadorDiscardBlock(g, me, card), })) : [], }; })() : null, ambassadorInsights: (g.ambassadorInsights ?? []).filter( (entry) => entry.viewer === id, ), auditor: g.pendingAuditor ? { event: g.pendingAuditor.event, owner: g.pendingAuditor.owner, opponent: g.pendingAuditor.opponent, survived: g.pendingAuditor.survived, count: currentAuditCount(g), } : null, auditorInsight: g.auditorInsight?.viewer === id ? structuredClone(g.auditorInsight) : null, mobileStronghold: g.mobileStronghold ?? null, mobileRoute: g.pendingMobileMove ? { route: g.pendingMobileMove.route, collect: g.pendingMobileMove.collect, } : null, techTokens: g.techTokens ?? null, strongholdCards: g.strongholdCards ?? null, expansions: g.expansions, turn: g.turn, phase: g.phase, phaseOpening: g.phaseOpening ? { passed: g.phaseOpening.passed } : null, storm: g.status === 'lobby' ? STORM_START_SECTOR : g.storm, playerPositions: normalizedPlayerPositions(g), order: g.order, active: g.active, movementRemaining: g.movementRemaining ?? null, guildTimingLocked: g.guildTimingLocked ?? false, ready: g.ready, spice: g.spice, log: g.log, nexus: g.nexus, summonedWorm: g.summonedWorm ? { territory: g.summonedWorm.territory } : null, sandtrout: g.sandtrout ?? false, spiceWindow: g.spiceWindow ?? null, spicePile: g.spiceSequence?.pile ?? 0, beforeSpiceDraw: g.phase === 1 && !g.spiceSequence && !g.spiceResolution && !g.spiceWindow && !g.nexus, spiceDiscardTop: g.spiceDiscard.map((pile) => pile.at(-1) ?? null), allianceOffers: g.allianceOffers, ecazSpice: g.ecazCollection?.stage === 'allocation' && g.ecazCollection.allocation ? { event: g.ecazCollection.event, allocation: [ g.ecazCollection.allocation.lots[0].ecaz, g.ecazCollection.allocation.lots[0].ally, ].includes(id) ? g.ecazCollection.allocation : null, player: g.ecazCollection.allocation.player, territory: g.ecazCollection.allocation.lots[ g.ecazCollection.allocation.index ].territory, } : null, winner: g.winner, victoryProgress, shieldWallDestroyed: g.shieldWallDestroyed, stormDialers: g.stormDialers, stormSubmitted: Object.keys(g.stormDials), karamaShipping: g.karamaShipping ? { player: g.karamaShipping.player, owner: g.karamaShipping.owner } : null, stormPending: g.stormPending ?? null, stormForecast: me.faction === 'fremen' && g.stormCardKnown ? g.stormCard : null, stormRevealed: g.phase === 0 && g.stormDialers.length > 0 && g.stormDialers.every((id) => g.stormDials[id] !== undefined) ? g.stormDials : null, me: id, choamWorthless: me.faction === 'choam' ? { pending: g.pendingChoamWorthless?.owner === id ? g.pendingChoamWorthless.card : null, baliset: gameTerritories(g) .filter((t) => at(me, t.id) > 0) .map((t) => t.id), gamont: g.players .filter((p) => p.id !== id) .flatMap((p) => Object.entries(presenceByLocation(p)).flatMap( ([key, count]) => { const elites = p.elites?.forces[key] ?? 0; const marker = p.noField?.deployed; const noField = !!marker && location( marker.location.territory, marker.location.sector, ) === key; return [ ...(count > elites ? [{ target: p.id, key, elite: 0, noField }] : []), ...(elites > 0 ? [{ target: p.id, key, elite: 1, noField: false }] : []), ]; }, ), ), targets: g.players .filter((p) => !g.revivalRules?.freeBlocked?.includes(p.id)) .map((p) => p.id), cards: me.hand.filter( (c) => c.kind === 'worthless' && [ 'Baliset', 'Kulon', 'La La La', 'Trip to Gamont', 'Jubba Cloak', ].includes(c.name) && !choamWorthlessBlocked(g, c.id), ), } : null, balisetRestrictions: g.phase === 5 ? (g.choamBaliset ?? []).filter((b) => b.turn === g.turn) : [], mentatVictoryPending: g.phase === 8 && g.status === 'playing' && (!!byFaction(g, 'choam') || g.decision?.kind === 'moritaniPlacement' || g.response?.kind === 'moritaniPlacement'), choamMovementBonus: g.choamMovement?.turn === g.turn ? g.choamMovement.bonus : 0, choamCashIn: g.status === 'playing' && me.faction === 'choam' && g.advanced && !me.specialKaramaUsed ? { cards: cashInCards(g, me), karamas: cashInCards(g, me).filter((c) => c.effect === 'karama'), } : null, choamMarket: g.choamMarket ? { owner: g.choamMarket.owner, ...(g.choamMarket.owner === id ? { sales: saleOptions(me.hand, g.choamMarket.blocked), canTrade: !!me.ally && g.choamTradeTurn !== g.turn, tradeAttempted: !!g.choamMarket.tradeAttempted, } : {}), ...(g.choamMarket.trade && [g.choamMarket.owner, g.choamMarket.trade.ally].includes(id) ? { offered: getPlayer(g, g.choamMarket.owner).hand.find( (c) => c.id === g.choamMarket!.trade!.offered, ), returned: getPlayer(g, g.choamMarket.trade.ally).hand.find( (c) => c.id === g.choamMarket!.trade!.returned, ), } : {}), } : null, inflation: g.inflation ?? null, inflationUsed: g.inflationUsed ?? false, inflationAttempted: g.inflationAttemptTurn === g.turn, charity: { multiplier: charityMultiplier(g), amount: charityAmount(g, me), payer: charityPayer(g)?.id ?? null, incomePending: !!byFaction(g, 'choam') && g.choamCharity?.turn !== g.turn, incomeCanceled: g.choamCharity?.turn === g.turn && g.choamCharity.canceled, }, moritaniRetention: g.moritaniRetention ?? null, moritaniRetentionCard: g.moritaniRetention?.keep ? (cardOf( getPlayer(g, g.moritaniRetention.player), g.moritaniRetention.keep, ) ?? null) : null, decision: g.decision?.kind === 'capturedLeader' && ![g.decision.player, g.decision.controller ?? g.decision.owner].includes( id, ) ? { ...g.decision, leader: '', owner: '' } : (g.decision ?? null), response: g.response ? { ...g.response, passed: g.response.passed.includes(id) ? [id] : [], ...(g.response.kind === 'revivalIncome' && ![g.response.owner, g.response.recipient].includes(id) ? { amount: undefined } : {}), ...(g.response.kind === 'faceDancerReplacement' && g.response.owner !== id ? { intent: undefined } : {}), ...(g.response.kind === 'emperorGift' && ![g.response.owner, g.response.recipient].includes(id) ? { amount: undefined } : {}), } : null, responseControls: g.response ? { cancelCards: responseCancelCards(g, me, g.response), hasPassed: g.response.passed.includes(id), } : undefined, revivalPrevention: g.revivalPrevention?.turn === g.turn ? g.revivalPrevention : null, emperorExtra: g.emperorExtra ?? {}, freeRevival: g.freeRevival ?? [], revivalRules: { ...(g.revivalRules ?? newRevivalRules()), earlyBlocked: (g.revivalRules?.earlyBlocked ?? []).filter( (key) => me.faction === 'tleilaxu' || key.startsWith(`${id}:`), ), }, revivalRequests: Object.fromEntries( Object.entries(g.revivalRequests ?? {}).filter( ([player]) => player === id || me.faction === 'tleilaxu', ), ), revival: { pending: !!g.pendingRevival, leaders: leaderRevivals.leaders, kwisatz: leaderRevivals.kwisatz, prevented: revivalPrevented(g, me.id), eliteRemaining: eliteRevivalRemaining(me), limit: forceRevivalLimit(g, me), forcesRemaining: forceRevivalRemaining(g, me), discount: revivalDiscount(g, me), choamBlocked: !!g.revivalRules?.choamBlocked, freeRemaining: freeRevivalRemaining(g, me), freeBlocked: g.revivalRules?.freeBlocked?.includes(id) ?? false, }, ghola: gholaOptions(g, me), aid: { pledged: g.aid?.[id]?.amount ?? 0, available: g.phase === 6 ? (battleAidFor(g, me)?.amount ?? 0) : me.ally && g.aid?.[me.ally]?.recipient === id ? g.aid[me.ally].amount : 0, }, players: g.players.map((p) => ({ ...(p.noField ? { noField: projectRicheseNoField(p.noField, false) } : {}), id: p.id, name: p.name, bot: p.bot, autopilot: p.autopilot, faction: p.faction, handLimit: handLimit(p), ready: p.ready, reserves: p.reserves, tanks: p.tanks, forces: p.forces, elites: p.elites, advisors: p.advisors, advisorSetup: p.advisorSetup, specialKaramaUsed: p.specialKaramaUsed ?? false, fremenMovementBlocked: p.fremenMovementBlocked?.turn === g.turn && p.fremenMovementBlocked.move === p.moved, ixMovementBlocked: p.ixMovementBlocked?.turn === g.turn && p.ixMovementBlocked.move === p.moved, revealedTraitors: p.revealedTraitors ?? [], revealedFaceDancers: (p.faceDancers ?? []) .filter((c) => c.revealed) .map((c) => c.leader), leaders: [ ...(g.dukeVidal?.controller === p.id && !g.dukeVidal.leader.capturedBy && !g.dukeVidal.leader.gholaBy && !(g.advanced && byFaction(g, 'harkonnen')) ? [{ ...g.dukeVidal.leader, controller: p.id }] : []), ...p.leaders.map((l) => projectLeader(g, l, id)), ...g.players .filter((owner) => owner.id !== p.id) .flatMap((owner) => owner.leaders .filter( (l) => l.gholaBy === p.id || (p.id === id && l.capturedBy === p.id), ) .map((l) => projectLeader(g, l, id)), ), ], ally: p.ally, allySinceTurn: p.allySinceTurn, ...(p.id === id ? { spice: p.spice, charityClaimed: p.charityTurn === g.turn, ...(g.advanced && p.faction === 'atreides' ? { kwisatz: { active: p.battleLosses >= 7, dead: p.kwisatz?.dead ?? false, usedAt: p.kwisatz?.usedAt, losses: p.battleLosses, }, } : {}), hand: p.hand, traitors: p.traitors, faceDancers: p.faceDancers, faceDancerReplaced: p.faceDancerReplacedTurn === g.turn, traitorChoices: p.traitorChoices, prediction: p.prediction, bribes: p.bribes, shipped: p.shipped, moved: p.moved, movesAllowed: movesAllowed(g, p), revived: p.revived, leaderRevived: p.leaderRevived, gholaBlocked: Object.entries(p.gholaBlocked ?? {}) .filter(([, turn]) => turn === g.turn) .map(([leader]) => leader), } : {}), ...(g.phase === 3 || p.id === id ? { handCount: p.hand.length } : {}), })), richeseNoField: (() => { const owner = byFaction(g, 'richese'); if (!owner?.noField || !owner.noFieldEvent) return null; const mine = owner.id === me.id; const shipBlock = mine ? noFieldShipBlock(g, owner) : 'Only Richese controls this token inventory.'; const revealBlock = mine ? noFieldRevealBlock(g, owner) : 'Only Richese controls this concealed token.'; return { owner: owner.id, event: owner.noFieldEvent, public: projectRicheseNoField(owner.noField, false), private: mine ? projectRicheseNoField(owner.noField, true).private : null, canShip: !shipBlock, shipBlock, canReveal: !revealBlock, revealBlock, canOfferAlly: mine && !noFieldAllyOfferBlock(g, owner), allyOfferBlock: mine ? noFieldAllyOfferBlock(g, owner) : 'Only Richese may offer these tokens.', allyDeclined: g.richeseAllyDeclined?.turn === g.turn && g.richeseAllyDeclined.recipient === owner.ally, allyOffer: (() => { const offer = g.richeseAllyOffer; if (!offer || ![offer.owner, offer.recipient].includes(id)) return null; const { event, owner, recipient, territory, sector, payer } = offer; try { const { cost, ownerPayment, recipientPayment, amount, value, eliteMin, eliteMax, } = alliedNoFieldQuote(g, offer, offer.recipient === id); return { event, owner, recipient, territory, sector, payer, cost, ownerPayment, recipientPayment, amount, value, eliteMin, eliteMax, blocked: null as string | null, }; } catch (error) { if (!(error instanceof RuleError)) throw error; return { event, owner, recipient, territory, sector, payer, cost: 0, ownerPayment: 0, recipientPayment: 0, amount: 0, value: 0 as const, eliteMin: 0, eliteMax: 0, blocked: error.message, }; } })(), }; })(), richeseGift: richeseGiftView(g, me), distrans: distransView(g, me), nullentropy: nullentropyView(g, me), ornithopter: ornithopterView(g, me), residualPoison: residualPoisonView(g, me), portableSnooper: portableSnooperView(g, me), saphoOptions: saphoOptions(g, me), saphoMovementLast: g.saphoMovementLast ? { event: g.saphoMovementLast.event, turn: g.saphoMovementLast.turn, player: g.saphoMovementLast.player, } : null, playedOrnithopter: g.ornithopter ? { player: g.ornithopter.player, card: g.ornithopter.card, mode: g.ornithopter.mode, completed: g.ornithopter.completed, } : null, richeseSpecialKarama: richeseSpecialKaramaView(g, me), richeseBidding: g.richeseBidding?.turn === g.turn ? { owner: g.richeseBidding.owner, event: g.richeseBidding.event, stage: g.richeseBidding.stage, position: g.richeseBidding.position, normalCount: g.richeseBidding.normalCount, offerBlocked: richeseOfferBlock(g), cache: g.richeseBidding.owner === id ? (g.richeseCache ?? []) : null, } : null, richeseAuction: g.richeseAuction ? { ...projectRicheseAuction(g.richeseAuction, id), card: g.richeseAuction.source === 'cache' || g.richeseAuction.owner === id || (me.faction === 'atreides' && g.richesePeekKnown) ? (g.richeseOfferedCard ?? richeseLotCard(g) ?? null) : null, claim: g.richeseClaim ?? null, ownAllyPayment: g.richeseFunding?.[id]?.allyPayment ?? 0, ownAvailable: uncommittedSpice(g, me) + richeseOwnCommitment(g.richeseAuction, g.richeseFunding ?? {}, id), allyAvailable: aidFor(g, me)?.amount ?? 0, peekKnown: !!g.richesePeekKnown, } : null, auction: g.auction ? { bid: g.auction.bid, bidder: g.auction.bidder, active: g.auction.active, ...(g.auction.bidder === id ? { allyPayment: g.auction.allyPayment ?? 0 } : {}), remaining: g.auction.cards.length - g.auction.index, card: me.faction === 'atreides' && g.auction.peekKnown ? g.auction.cards[g.auction.index] : null, } : null, battle: b ? { strongholdCopy: b.strongholdCopy ?? null, strongholdEffects: Object.fromEntries( [b.attacker, b.defender].map((player) => [ player, strongholdEffect(g, player), ]), ) as Record, tieWinner: battleTieWinner(g), noFieldPlayers: b.noFieldPlayers ?? [], ownForces: [b.attacker, b.defender].includes(id) ? combatForces( g, me, b.territory, getPlayer(g, b.attacker === id ? b.defender : b.attacker), ) : null, truthPromises: b.truthPromises ?? [], compliantPlan: completion && !completion.actions.length && !completion.waitingForIncome ? completion.plan : null, compliantPreparation: completion && (completion.actions.length || completion.waitingForIncome) ? completion : null, poisonTooth: b.revealed ? (b.poisonTooth ?? {}) : {}, stoneBurner: b.revealed ? (b.stoneBurner ?? {}) : {}, stoneBurnerContext: [b.attacker, b.defender].includes(id) ? { blocked: stoneTimingBlock(g), opponentPools: stonePublicPools( g, getPlayer(g, b.attacker === id ? b.defender : b.attacker), me, ), } : null, lateDefense: b.revealed ? (b.lateDefense ?? {}) : {}, fullPlan: b.fullPlan ?? null, fullPlanInsight: b.fullPlan?.owner === id && b.plans[b.fullPlan.target] && !b.revealed ? { target: b.fullPlan.target, plan: b.plans[b.fullPlan.target], cards: getPlayer(g, b.fullPlan.target).hand.filter((c) => { const plan = b.plans[b.fullPlan!.target]; return [plan.leader, plan.weapon, plan.defense].includes( c.id, ); }), } : null, event: b.event ?? null, preLeader: b.preLeader ?? null, territory: b.territory, attacker: b.attacker, defender: b.defender, revealed: b.revealed, kwisatzBlocked: b.kwisatzBlocked ?? false, eliteBlocked: b.eliteBlocked ?? [], fremenSupportBlocked: b.fremenSupportBlocked ?? false, choamAidBlocked: b.choamAidBlocked ?? false, cards: b.revealed ? [b.attacker, b.defender].flatMap((playerId) => { const plan = b.plans[playerId]; return getPlayer(g, playerId).hand.filter((c) => [ plan.leader, plan.weapon, plan.defense, b.lateDefense?.[playerId], ].includes(c.id), ); }) : [], submitted: Object.keys(b.plans), plans: b.revealed ? b.plans : b.plans[id] ? { [id]: b.plans[id] } : {}, traitorSubmitted: Object.keys(b.traitorCalls), traitorVoters: traitorVoters(g, b), preparation: b.preparation ?? null, prescience: b.prescience ? { player: b.prescience.player, field: b.prescience.field, answered: 'value' in b.prescience, } : undefined, voice: b.voice, insight: b.prescience && 'value' in b.prescience && (b.prescience.player === id || [b.attacker, b.defender].includes(id)) ? { field: b.prescience.field, value: b.prescience.value, label: g.players .flatMap((p) => [...p.leaders, ...p.hand]) .concat(g.dukeVidal ? [g.dukeVidal.leader] : []) .find((item) => item.id === b.prescience!.value)?.name ?? String(b.prescience.value ?? 'None'), } : null, } : null, spicePeek: g.phase === 5 && me.faction === 'atreides' && g.spicePeekKnown ? (g.spiceDeck[0] ?? null) : null, allLeaders: [ ...g.players.flatMap((p) => p.leaders), ...(g.dukeVidal ? [g.dukeVidal.leader] : []), ].map((l) => ({ id: l.id, name: l.name, faction: l.faction, strength: l.strength, })), }; } export type GameView = ReturnType;