import { classicNexusModulesProfile, mixedE1E2NexusProfile, mixedE3NexusProfile, pairedE3NexusProfile, pairedNexusModulesProfile, singleE1E2NexusProfile, standaloneE3NexusProfile } from './nexus-module-profile'; import { nativeFactionTechProfile } from './faction-module-profile'; import { classicDiscoveryLeaderSkillsProfile, classicDiscoveryNexusProfile, homeworldDiscoveryProfile, discoveryModeSupported, discoveryModuleProfile, mixedE1E2DiscoveryNexusProfile, mixedE3DiscoveryNexusProfile, pairedDiscoveryNexusProfile, pairedE3DiscoveryNexusProfile, singleE1E2DiscoveryNexusProfile, standaloneE3DiscoveryNexusProfile } from './discovery-module-profile'; import { advancedMoritaniLeaderSkillsProfile, advancedNativeLeaderSkillsProfile, homeworldLeaderSkillsProfile, classicNexusLeaderSkillsProfile, classicTechLeaderSkillsProfile, guildBetrayalNexusLeaderSkillsProfile, mixedE1E2NexusLeaderSkillsProfile, mixedE3NexusLeaderSkillsProfile, nativeDiscoveryLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, nativeTechLeaderSkillsProfile, ordinaryLeaderSkillModeSupported, pairedE3NexusLeaderSkillsProfile, pairedNexusLeaderSkillsProfile, singleE1E2NexusLeaderSkillsProfile, standaloneE3NexusLeaderSkillsProfile, strongholdLeaderSkillsProfile } from './leader-skill-profile'; import { bribeTimingBlock, maximumBribe, type BribeOptions } from './bribe-options'; import { quoteSpicePlacement, stormExposesTerritory, stormSectorAfter, wormConsumesForces } from './disaster-rules'; import { isStormCardDistance, type StormCardComponent } from './storm-cards'; import { createLeaderSkills, validateLeaderSkills, dealLeaderSkills, chooseLeaderSkill, returnDeadLeaderSkills, offerRevivedLeaderSkill, drawRevivedLeaderSkills, declineRevivedLeaderSkill, LeaderSkillError, type LeaderSkillsState, type LeaderSkillsView } from './leader-skills'; import { leaderSkillCard } from './leader-skill-cards'; import { bureaucratAlliedFundingModeSupported, bureaucratPaymentModeSupported, bureaucratPaymentSignature, bureaucratUsed, quoteBureaucratPayment, type BureaucratPaymentSource, type BureaucratPaymentUse, type BureaucratPaymentView } from './bureaucrat-payment'; import { MENTAT_EMPTY_HAND, MentatQuestionError, mentatQuestionModeSupported, mentatWeaponNames, mentatHand, quoteMentatReveal, mentatSignature, type MentatQuestionReceipt, type MentatObservation, type MentatView } from './mentat-question'; import { canUsePlanetologistBattleSpecial, type BattleLeaderSkill, } from './leader-skill-combat'; import { quoteSukPhysicalRescue, quoteSukRescue, SukPhysicalRescueError, sukReceiptSignature, sukRescueOptions, type SukForceGroup, type SukPhysicalRescueQuote, type SukPhysicalRescueReceipt, type SukReserveDestinations, type SukRescueOption, type SukRescueReceipt } from './suk-graduate'; import { quoteTleilaxuAmbassadorForces, TleilaxuAmbassadorForceError } from './tleilaxu-ambassador-forces'; import { tleilaxuAmbassadorLeaderChoices, tleilaxuAmbassadorLeaderSelection } from './tleilaxu-ambassador-leaders'; import { leaderSkillStrongholdCount, sandmasterVictorySpice, sandmasterVictoryPiles, type SandmasterVictorySpiceOffer } from './leader-skill-battle-board'; import { beginRihani, chooseRihaniDraw, finishRihani, validateRihani, type RihaniReceipt, type RihaniSkill } from './rihani-decipherer'; import { planetologistMovementModeSupported, planetologistLeader, planetologistRange, selectedOriginElites, groundMovementRange, type PlanetologistMovement } from './planetologist-movement'; import { quoteSmugglerNoField, smugglerNoFieldModeSupported, type SmugglerNoFieldCompanion } from './smuggler-no-field'; import { quoteSmugglerShipment, type SmugglerShipment } from './smuggler-shipment'; import { createSmugglerBattle, settleSmugglerBattle, smugglerBattleModeSupported, smugglerBattlePlanBlock, smugglerBattlePile, smugglerBattleSignature, smugglerBattleAllocationRequired, chooseSmugglerBattleAllocation, type SmugglerBattleReceipt, type SmugglerBattleCollectionOffer } from './smuggler-battle'; import { quoteSandmasterMovement, validateSandmasterMovement, sandmasterCollectionPiles, sandmasterLeader, sandmasterRouteDistance, validateSandmasterStronghold, type SandmasterMovement, type SandmasterOrder } from './sandmaster-movement'; import { sandmasterWormCollection } from './sandmaster-worm'; import { spiceBankerModeSupported, validateSpiceBankerSpend } from './spice-banker'; import { BankerIncomeError, createBankerIncomeState, validateBankerIncomeState, quoteBankerIncome, commitBankerIncome, quoteBankerIncomeCollection, commitBankerIncomeCollection, projectBankerIncome, type BankerIncomeState, type BankerIncomeAuthority, type BankerIncomeContext } from './spice-banker-income'; import { quoteDiplomatDefense, diplomatDefenseModeSupported, diplomatDefenseLabel, type DiplomatDefenseQuote } from './diplomat-defense'; import { diplomatRetreatChoices, type DiplomatRetreatSelection, type DiplomatRetreatDestination } from './diplomat-retreat'; import { ECAZ_START_FORCES, quoteEcazStartingForces } from './ecaz-setup'; import { battleCardSlotEligible, battleCategoryInspectionValue, fixedBattleInspectionMatches, validBattleSlotPair } from './battle-card-slots'; import { HARASS_WITHDRAW_CARD, HarassWithdrawError, isHarassWithdraw, quoteHarassWithdraw, defaultHarassWithdrawAllocation, harassWithdrawNeedsAllocation, type HarassWithdrawSelection, type HarassWithdrawContext, type HarassWithdrawPreview } from './harass-withdraw'; import { discoveryChoices, discoveryStashSignature, type DiscoveryStash } from './discovery-actions'; import { createStormSource, validateStormSource, discoveryStormOffer, createDiscoveryStorm, chooseDiscoveryStorm, validateDiscoveryStorm, finishDiscoveryStorm, discoveryStormDistance, type StormMovementSource, type DiscoveryStorm } from './discovery-storm'; import { completeDiscoveryFlight, discoveryFlightOffer, quoteDiscoveryFlight, validateDiscoveryFlight, type DiscoveryFlightReceipt } from './discovery-flight'; import { advanceDiscoveryEntry, beginDiscoveryEntryArrival, createDiscoveryEntryRound, finishDiscoveryEntryArrival, quoteDiscoveryEntry, validateDiscoveryEntryArrivalChild, validateDiscoveryEntryRound, type DiscoveryEntryArrivalChild, type DiscoveryEntryRound, type DiscoveryEntrySelection } from './discovery-entry'; import { createDiscoveryState, validateDiscoveryState, placeDiscovery, rememberDiscoveryFace, revealDiscoveryToken, isDiscoverySpiceCardId, isDiscoveryTokenId, DISCOVERY_SPICE_CARDS, DISCOVERY_CARD_PLACEMENTS, DISCOVERY_TOKEN_BY_ID, JACURUTU_SIETCH, type DiscoveryState } from './discoveries'; import { quoteJacurutuBattleIncome, jacurutuDialSelection, type JacurutuDialOffer } from './discovery-battle'; import { greatMakerSignature, greatMakerMajority, validateGreatMaker, type GreatMaker } from './great-maker'; import { quoteNexusChoamTrade, validateNexusChoamTrade, nexusChoamTradeSignature, type NexusChoamTrade } from './nexus-choam-trade'; import { quoteNexusChoamInspection, sampleNexusChoamInspection, nexusChoamInspectionSignature, type NexusChoamInspectionReceipt } from './nexus-choam-inspection'; import { quoteNexusMoritaniRetention, nexusMoritaniRetentionSignature, type NexusMoritaniRetentionReceipt } from './nexus-moritani-retention'; import { quoteNexusChoamBetrayal, validateNexusChoamBetrayal, nexusChoamBetrayalSignature, type NexusChoamBetrayal } from './nexus-choam-betrayal'; import { quoteEcazInquiry, ecazInquiryAnswer, ecazInquiryHistory, ecazInquirySignature, validateEcazInquiry, type EcazInquiryReceipt } from './nexus-ecaz-inquiry'; import { nexusCleanPlayBlocked } from './nexus-play-boundary'; import { EMPEROR_NEXUS_REVIVALS, emperorNexusModeSupported, quoteEmperorNexusRevival, createEmperorNexusRevival, settleEmperorNexusRevival, validateEmperorNexusRevival, type NexusEmperorRevival } from './nexus-emperor-secret-ally'; import { emperorNexusPurchaseEvent, quoteEmperorNexusPurchase, signEmperorNexusPurchase, validateEmperorNexusPurchase, emperorNexusSellerPurchaseModeSupported, quoteEmperorNexusSellerPurchase, createEmperorNexusSellerPurchase, emperorNexusAmbassadorPurchaseModeSupported, quoteEmperorNexusAmbassadorPurchase, createEmperorNexusAmbassadorPurchase, quoteEmperorNexusSpecialPurchase, createEmperorNexusSpecialPurchase, type EmperorNexusPurchase } from './nexus-emperor-purchase'; import { createEmperorPaymentReceipt, validateEmperorPaymentReceipt, emperorPaymentUseBlock, emperorPaymentSourceQuote, requiredEmperorPaymentResponders, emperorPaymentReceiptSignature, type EmperorPaymentReceipt } from './nexus-emperor-payment'; import { FREMEN_NEXUS_FREE_FORCES, fremenNexusRevivalOffer, createFremenNexusRevival, upgradeFremenNexusRevival, validateFremenNexusRevival, type FremenNexusRevival } from './nexus-fremen-revival'; import { captureFremenCunningOccurrence, quoteFremenCunning, quoteFremenCunningRide, validateFremenCunningSelection, type FremenCunningOccurrence, type FremenCunningRideAuthorization, type FremenCunningSelection } from './nexus-fremen-cunning'; import { createNexusFremenProtection, currentNexusFremenProtectionOwner, answerNexusFremenProtection, validateNexusFremenProtection, type NexusFremenProtectionFrame, type NexusFremenProtectionInput } from './nexus-fremen-protection'; import { truthKnowledgeOf } from './truthtrance-knowledge'; import { createMoritaniAssassinateOpportunity, moritaniAssassinateTrigger, moritaniAssassinateChoices, quoteMoritaniAssassinate, moritaniAssassinateSignature, validateMoritaniAssassinate, type MoritaniAssassinateState, type MoritaniAssassinateReceipt } from './moritani-assassinate'; import { createNexusGuildSecretAlly, validateNexusGuildSecretAlly, quoteNexusGuildSecretShipment, type NexusGuildSecretAllyReceipt } from './nexus-guild-secret-ally'; import { shipmentAvailable } from './shipment-opportunity'; import { createGuildBetrayalInvoice, createGuildBetrayalReceipt, guildBetrayalEligible, guildBetrayalResponders, validateGuildBetrayalInvoice, validateGuildBetrayalHistory, nexusGuildBetrayalSourceReceipt, type GuildBetrayalInvoice, type GuildBetrayalCursor, type GuildBetrayalReceipt, type GuildBetrayalAuthority } from './nexus-guild-betrayal'; import { createIxianReplacementSource, validateIxianReplacementSource, closeIxianReplacementSource, validateIxianReplacementHistory, initialIxianReplacementCursor, ixianReplacementEvent, type IxianReplacementSource, type IxianReplacementReceipt, type IxianReplacementCursor } from './nexus-ixian-replacement'; import { createIxianBetrayalSource, validateIxianBetrayalFrame, initialIxianBetrayalCursor, closeIxianBetrayalSource, validateIxianBetrayalHistory, ixianBetrayalResponders, ixianBetrayalEligible, type IxianBetrayalSource, type IxianBetrayalCursor, type IxianBetrayalReceipt } from './nexus-ixian-betrayal'; import { createHarkonnenBetrayalSource, validateHarkonnenBetrayalFrame, initialHarkonnenBetrayalCursor, closeHarkonnenBetrayalSource, validateHarkonnenBetrayalHistory, harkonnenBetrayalResponders, harkonnenBetrayalEligible, createHarkonnenBetrayalReplacement, validateHarkonnenBetrayalReplacements, drawHarkonnenBetrayalReplacement, type HarkonnenBetrayalSource, type HarkonnenBetrayalCursor, type HarkonnenBetrayalReceipt, type HarkonnenBetrayalReplacement, type HarkonnenBetrayalAuthority } from './nexus-harkonnen-betrayal'; import { createNexusGuildCunning, validateNexusGuildCunning, nexusGuildCunningMoves, type NexusGuildCunningReceipt } from './nexus-guild-cunning'; import { createNexusRichese, validateNexusRichese, quoteNexusRicheseShipment, type NexusRicheseReceipt } from './nexus-richese'; import { createNexusBorrowedVoice, currentNexusBorrowedVoiceOwner, passNexusBorrowedVoice, declareNexusBorrowedVoice, finishNexusBorrowedVoice, validateNexusBorrowedVoice, type NexusBorrowedVoiceFrame } from './nexus-borrowed-voice'; import { createNexusInspection, allowNexusInspection, answerNexusInspection, reopenNexusInspection, cancelNexusInspection, reopenNexusNative, answerNexusNative, validateNexusInspection, committedPlanElements, type NexusInspection, type BattleInspectionContext } from './battle-inspections'; import { beginNexusTraitorExchange, finishNexusTraitorExchange, validateNexusTraitorExchange, validateNexusTraitorHistory, validateNexusTraitorSnapshot, type NexusTraitorExchange, type NexusTraitorSnapshot } from './nexus-traitor-exchange'; import { replaceNexusFaceDancers, validateNexusFaceDancerHistory, type NexusFaceDancerReceipt } from './nexus-face-dancers'; import { createNexusSuboids, validateNexusSuboids, nexusSuboidsActive, type NexusSuboidReceipt } from './nexus-suboids'; import { quoteNexusAdvisors, createNexusAdvisors, validateNexusAdvisors, type NexusAdvisorReceipt } from './nexus-advisors'; import { createNexusSardaukar, validateNexusSardaukar, type NexusSardaukarReceipt } from './nexus-sardaukar'; import { createNexusMoritani, validateNexusMoritani, quoteNexusMoritaniPlacement, quoteNexusMoritaniGrummanPlacement, type NexusMoritaniReceipt } from './nexus-moritani'; import { moritaniBetrayalOffer } from './nexus-moritani-betrayal'; import { ecazBetrayalOffer, quoteEcazBetrayal, validateEcazBetrayalSnapshot, type EcazBetrayalSnapshot } from './nexus-ecaz-betrayal'; import { fremenBetrayalOffer, fremenBetrayalSignature, validateFremenBetrayal, type FremenNexusBetrayal, type StoredFremenNexusBetrayal } from './nexus-fremen-betrayal'; import { atomicsShipmentBlocked, quoteMoritaniAtomics, type AtomicsAftermath, type AtomicsQuote } from './moritani-atomics'; import { nexusMoritaniRecordSignature, terrorLocationAllowed, terrorEntryLocationAllowed } from './terror-location'; import { CHOAM_NEXUS_EFFECTS, createNexusChoam, validateNexusChoam, type NexusChoamEffect, type NexusChoamReceipt } from './nexus-choam'; import { createTraitorDeclaration, validateTraitorDeclarations, type TraitorDeclaration, type TraitorDeclarationContext } from './traitor-declarations'; import { createNexusCards, validateNexusCards, drawNexusCard, replaceNexusCard, discardNexusCard, projectNexusCards, nexusCardMode, type NexusState } from './nexus-cards'; import { createNexusCardPhase, markNexusCardOccurred, validateNexusCardPhase, closeNexusCardPhase, nexusCardChoices, finishNexusCardChoice, type NexusCardPhase, type NexusCardChoice } from './nexus-card-phase'; import { createHomeworldOccupationHistory, observeHomeworldOccupation, validateHomeworldOccupationHistory, tupileOccupationStatus, type HomeworldOccupationHistory } from './homeworld-occupation-history'; import { tupileIntelligenceTargets, quoteTupileIntelligenceRequest, type TupileIntelligenceCategory } from './tupile-intelligence'; import { quoteTupileIntelligenceAnswer } from './tupile-intelligence-answer'; import { createTupileIntelligenceState, appendTupileIntelligenceObservation, validateTupileIntelligenceState, type TupileIntelligenceState } from './tupile-intelligence-state'; import { homeworldForceGroups, quoteHomeworldCustody } from './homeworld-custody'; import { quoteHomeworldShipment, type HomeworldShipmentIntent } from './homeworld-shipment'; import { quoteGuildHomeworldShipment } from './guild-homeworld-shipment'; import { quoteJunctionTransport } from './junction-transport'; import { homeworldMovementForesightBlock, homeworldSpiritualAdvisorLimit, homeworldMobileStrongholdMovementBlock, homeworldNoFieldMovementBlock } from './homeworld-mobility'; import { currentJunctionOffer, junctionOfferEvent, junctionOfferIntegrity, junctionSponsor, type JunctionOffer } from './junction-offer'; import { homeworldAllianceBlock } from './homeworld-alliance'; import { quoteNexusAlliance } from './nexus-alliance'; import { quoteHomeworldSubstitution } from './homeworld-substitution'; import { combatArmy, combatLocations, combatLocationName, homeworldBattleLocation, quoteCombatBoard, quoteCombatBoardContinuation } from './combat-location'; import { battleChooserEvent, quoteBattleChoosers, reorderBattleChoosers, validateBattleChooserOrder, BattleChooserOrderError, type BattleChooserOrder } from './battle-chooser-order'; import { battleTieOwner, changeSaphoAggressor, saphoAggressorEvent, validateSaphoAggressor, SaphoAggressorError, type SaphoAggressorState } from './sapho-aggressor'; import { quoteHomeworldCombatLoss } from './homeworld-combat-loss'; import { quoteHomeworldBattleRules } from './homeworld-battle-rules'; import type { HomeworldForces } from './homeworld-custody'; import { nativeReserveSources } from './homeworld-options'; import { HOMEWORLD_CARDS } from './homeworld-cards'; import { lowGrummanRevealBlock, type HomeworldCollectionSource } from './homeworld-collection'; import { quoteOccupiedHomeworldVoice, quoteOccupiedHomeworldFaceDancer, quoteOccupiedHomeworldTerror, occupiedHomeworldSardaukarStatus } from './homeworld-occupied-defenses'; import { beginHomeworldOccupiedIncome, quoteHomeworldOccupiedIncomeChoice, homeworldOccupiedIncomeOffer, validateHomeworldOccupiedIncomeState, type HomeworldOccupiedIncomeState } from './homeworld-occupied-income'; import { createOccupiedPercentageState, quoteOccupiedPercentageSource, allocateOccupiedPercentageReceipt, validateOccupiedPercentageState, type OccupiedPercentageState, type OccupiedPercentageSource } from './homeworld-occupied-percentage'; import { quoteOccupiedBiddingAuthority, requireOccupiedBiddingController, requireOccupiedBiddingBonusRecipient } from './homeworld-occupied-bidding'; import { quoteStableHomeworldOccupation } from './homeworld-stable-occupation'; import { createOccupiedTupileState, syncOccupiedTupileLease, quoteOccupiedTupileAuthority, quoteOccupiedTupileCleanup, completeOccupiedTupileCleanup, validateOccupiedTupileState, type OccupiedTupileState } from './homeworld-occupied-tupile'; import { ecazHomeworldVictoryProgress, type EcazHomeworldVictoryProgress } from './ecaz-homeworld-victory'; import { quoteGrummanCollection, quoteGrummanCollectionAction } from './grumman-collection'; import { grummanCollectionSignature, validateGrummanCollection, type GrummanCollection } from './grumman-collection-return'; import { ambassadorPhaseAllowed } from './ambassador-phase'; import { makeHomeworldVictoryReturn, validateHomeworldVictoryReturn, homeworldVictoryOfferSignature, homeworldVictoryObligationSignature, type HomeworldVictoryReturn, type HomeworldVictoryObligation } from './homeworld-victory-return'; import { quoteHomeworldVictoryReinforcement, quoteHomeworldVictoryReinforcementDestination } from './homeworld-victory-reinforcement'; import { homeworldArrivalSignature, appendHomeworldArrivalAmbassador, completeHomeworldArrivalAmbassador, validateHomeworldArrival } from './homeworld-arrival'; import { quoteHomeworldRevivalDeployment, type HomeworldRevivalDeploymentSource, type HomeworldRevivalDeploymentGroup, } from './homeworld-revival-deployment'; import { makeHomeworldRevivalReturn, makeHomeworldRevivalProgress, type HomeworldRevivalProgress, validateHomeworldRevivalReturn, homeworldRevivalResumeSignature, appendHomeworldRevivalAmbassador, homeworldRevivalArrivalSignature, type HomeworldRevivalReturn, } from './homeworld-revival-return'; import { homeworldRevivalDestinations, quoteHomeworldRevivalDestination } from './homeworld-revival-destinations'; import { terrorEntrySignature, terrorSelectionSignature, validateTerrorEntrySignature } from './terror-entry-receipt'; import { arrivalOverlapSignature, overlapOwnsWormTerror } from './arrival-overlap'; import { homeworldLowBonus, homeworldRevivalKaramaBlock, homeworldSardaukarFreeSupport, homeworldSardaukarGholaBlock, snapshotHomeworldRevival, tleilaxuHomeworldFreeIncomeBlocked, HomeworldBenefitError, type HomeworldRevivalOpening, } from './homeworld-benefits'; import { quoteEcazAlliance, ecazAllianceBlock, EcazAllianceError, } from './ecaz-alliance'; import { createHomeworldCustody, HomeworldCustodyError, type HomeworldCustody, } from './homeworld-custody'; import { quoteGiediCollectionReceipt, validateGiediCollection, type GiediCollection, } from './giedi-collection'; import { homeworldContext, homeworldGameIntegrity, homeworldTable, } from './homeworld-game'; import { quoteEmperorHomeworldMove, EmperorHomeworldMoveError, type EmperorHomeworld, } from './homeworld-emperor-move'; import { quoteNativeReserveWithdrawal, quoteNativeReserveDeposit, NativeReserveError, type NativeReserveSelections, } from './homeworld-native-reserves'; import { validAmbassadorResume } from './ambassador-resume'; import { ecazOccupancyRelation } from './ecaz-occupy'; import { quoteEcazOccupyBattle, quoteEcazOccupyDial, type EcazOccupyBattleProfile } from './ecaz-occupy-battle'; 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 { fremenSpecialVictory, type FremenVictoryProgress } from './fremen-victory'; 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, auctionEmperorIncomeAmount, AuctionContinuationError, type AuctionContinuationOperation, type AuctionContinuationQuote, } from './auction-continuation-quote'; import { quoteNormalAuctionBid, quoteNormalAuctionNext, } from './normal-auction'; 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 { quoteSpiceCollection, quoteBattlePhaseAdvance, BoardResolutionError, type AdvisorRelease, type CollectionQuote, } from './board-resolution-quote'; import { quoteDiscoveryCollection, quoteSharedOrgiz, DiscoveryCollectionError, type DiscoveryCollectionQuote, } from './discovery-collection'; import {tleilaxuSecretAllyOptions,quoteTleilaxuSecretAlly} from './nexus-tleilaxu-revival'; 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, type BattleResolutionQuote, } 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 { validateSavedTruthtrance } from './truthtrance'; import { reorderOrderedOpportunity, protectOrderedOpportunityLast, validateOrderedOpportunity, type OrderedOpportunity, } from './ordered-opportunity'; import { matchesShipment, parseShipmentClaim, validShipmentClaim, shipmentClaimDestinations, liveShipmentPromises, shipmentPromiseModeSupported, type ShipmentClaim, type ShipmentPromise, } from './shipment-promises'; import { portableSnooperPlanBlock } from './portable-snooper'; import { createAuditorLeader, isAuditorLeader, auditCount, sampleAuditCards, } from './choam-auditor'; import { stoneBurnerPlanBlock, type StoneBurnerContext } 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 { SEMUTA_DRUG_ID, committedSemutaCandidates, resolveSemutaDrug, type FreshDiscardBatch, type SemutaContext } from './semuta-drug'; import { prepareRicheseGift, transferRicheseGift, type RicheseGiftIntent, } from './richese-gift'; import { richeseCards, richeseCardDefinition } from './richese-cards'; import { createRicheseNoField, deployRicheseNoField, deployRicheseNoFieldPair, validateRicheseNoFieldPair, 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, type RicheseSettlementQuote, } from './richese-settlement'; import { validateAmbassadors, createAmbassadors, placeAmbassador, triggerAmbassador, destroyAmbassador, replenishAmbassadors, copiedAmbassadorEffects, canTriggerAmbassador, type AmbassadorEffect, type AmbassadorToken, type AmbassadorState, } from './ecaz-ambassadors'; import { createDukeVidal, acquireDuke, consumeDuke, expireDuke, DUKE_VIDAL_ID, type DukeState, } from './duke-vidal'; import { createNexusEcazDukeReceipt, quoteNexusEcazDuke, validateNexusEcazDuke, type NexusEcazDukeReceipt } from './nexus-ecaz-duke'; import { retentionReservesCard, type MoritaniRetention, } from './moritani-retention'; import { ordinaryCardAvailability } from './card-availability'; import { ecazDukeRevivalBlock, quoteEcazDukeRevival } from './ecaz-duke-revival'; import { reserveShipmentCost, guildShipmentCost, guildShipmentIncome, shipmentPaymentBounds, alliedNoFieldShipmentPricing, } from './shipment-price'; import { TERROR_DEFINITIONS, createTerrorState, placeTerror, TERROR_STRONGHOLDS, projectTerror, revealTerror, returnTerror, retireExtortion, type TerrorKind, type TerrorState, } from './moritani-terror'; import { reserveExtortion, collectExtortion, answerExtortion, validateExtortionState, type ExtortionState } from './moritani-extortion'; import { STORM_START_SECTOR, PLAYER_CIRCLE_SECTORS } from './player-positions'; import { cashInCards } from './choam-karama'; import { kullBlocksKarama, activeKullRestrictions, validateKullPhaseRestrictions, validateKullAttemptStamp, kullNativeCostCards, kullNexusCostCards, kullCounterCards, type KullPhaseRestriction } from './choam-kull'; import { quoteRicheseBetrayalInvoice, requiredRicheseBetrayalResponders, richeseBetrayalEligibility, richeseBetrayalEvent, createRicheseBetrayalReceipt, validateRicheseBetrayalCursor, type RicheseBetrayalCursor, type RicheseBetrayalReceipt } from './nexus-richese-betrayal'; import { saleOptions, quoteSale, type ChoamMarket } from './choam-market'; import { highKaitainDiscardsAvailable, quoteKaitainDiscards, homeworldWorthlessSaleBlock } from './homeworld-card-economy'; import { biddingEndError, biddingEndQuiet, biddingEndPubliclyEmpty, type BiddingEnd } from './bidding-end'; import { quoteHomeworldPaymentIncome, quoteHomeworldBankPayment } from './homeworld-payment-income'; import { quoteEcazPoisonIncome, EcazPoisonIncomeError, type EcazPoisonDiscard } from './ecaz-poison-income'; import { resolveBattleWeapons } from './effective-weapons'; import { choamGholaEvent, choamGholaSkillOfferEvent, choamGholaSkillSignature, choamMarketGholaError, choamSaleGholaTiming, type ChoamMarketGhola } from './choam-market-ghola'; import { charityAmount, charityQuote, charityPayer, charityMultiplier, type Inflation, } from './charity'; import { PlanClaimError, respectsBattlePromises, type PlanClaim, type BattlePromise, } from './battle-promises'; import { resolveTruthAction, validateTruthQuestionReceipt, queuedTruthCardMatches, truthFactAnswer, truthShipmentClaim, TruthError, type TruthWindow, type TruthQueueEntry, type TruthRecord, type TruthAnswer, } from './truthtrance'; import type { MixedShipmentExpression } from './mixed-shipment-question'; import { controlsLeader, nativeAvailable } from './leader-control'; import { newRevivalRules, eliteRevivalRemaining, paidForceRevivalCost, revivalPrevented, normalForceRevivalLimit, freeRevivalRate, revivalDiscount, forceRevivalQuote, forceRevivalRemaining, freeRevivalRemaining, normalRevivalAllowance, leaderRevivalOptions, type RevivalRules, type PendingRevival, } from './revival'; import { traitorDeck, matchingTraitor, CHEAP_HERO_TRAITOR } from './traitors'; import { chooseEcazLoyalty, withoutEcazLoyalty, validateEcazLoyalty, type EcazLoyalty } from './ecaz-loyalty'; import { isStoneBurner, isMirrorWeapon, isPortableSnooper, VOICE_KINDS, defaultVoiceMatch, playedVoiceMatch, } from './battle-cards'; import { mirrorWeaponModeBlock } from './mirror-weapon-mode'; import { quoteEcazSpecialKarama, quoteEcazSpecialKaramaDeclaration, ecazSpecialKaramaPlanAllowed, EcazSpecialKaramaError, type EcazSpecialKaramaDeclaration } from './ecaz-special-karama'; import { quoteMoritaniSpecialKarama, validateMoritaniSpecialKaramaChoice, MoritaniSpecialKaramaError, type MoritaniSpecialKaramaSource } from './moritani-special-karama'; import { TECH_TOKENS, createTechTokens, ownedTech, type TechId, type TechState, } from './tech-tokens'; import { canUseAsKaramaRole } from './shrine'; import { isAdvisor, fighterCount, settleAdvisors, arrivalAsAdvisor, } from './advisors'; import { quoteAlliedSeparation, isAlliedShipmentVisit, alliedShipmentPhysicalPool, type BasicAlliedShipment } from './allied-separation'; import { casualtyOptions, maxCombatDial, maxCombatSupport, validCombatForces, type Casualties, type CombatForces } from './combat'; import { FACTIONS, faction, type FactionId } from './catalog'; import { quoteLobbyBotConfiguration } from './lobby-bot-configuration'; import { battleLeaderStrength, treacheryDeck, normalizeLegacyCardNames, leaders, spiceDeck, type Card, type Leader, type SpiceCard, } from './cards'; import { ecazTreacheryCards, ecazTreacheryDefinition } from './ecaz-cards'; import { isReinforcements, quoteReinforcements, ReinforcementsError } from './reinforcements'; import { RECRUITS_CARD_ID, type RecruitsPreview, } from './recruits'; import { TERRITORIES, MOBILE_STRONGHOLD, MOBILE_LOCATION, gameTerritories, gameDistance, mobileRouteDistance, FREMEN_START, territory, location, splitLocation, distance, validGameLocation, } 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 }; fremenNexusMovementBlockedTurn?: number; fremenNexusWormBlockedTurn?: number; /** Permanent printed one-card reduction after Atomics; bound to its Aftermath receipt. */ atomicsHandLimitPenalty?: boolean; /** Derived occupied Tupile slot, backed by the original fresh lease ledger. */ tupileHandSlot?: true; 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 = { /** Separate own-spice commitment for the selected Spice Banker; absent is zero. */ bankerSpice?: number; 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' | 'bankerSpice' >; export type DiplomatDefenseReceipt = DiplomatDefenseQuote & { event: string; player: string; battle: string; turn: number; territory: string; /** Only the revealed plans, played cards and public skill stance are bound. */ frame: string; stage: 'offered' | 'copied' | 'declined'; card: string | null; signature: string; }; export type HarassAllocationReceipt = { player: string; event: string; battle: string; turn: number; frame: string; stage: 'offered' | 'selected'; selection: HarassWithdrawSelection | null; signature: string; }; export type Battle = { ecazOccupy?: { event: string; ecaz: string; ally: string; lead: string | null; canceled: boolean }; /** New battles bind automatic collection to the original public plan reveal. */ smugglerCollectionVersion?: 1; smugglerCollection?: SmugglerBattleReceipt | null; sandmasterPile?: string; /** The defeated player identifies its revealed physical dial before settlement. */ jacurutuDial?: JacurutuDialOffer & { selectedDial?: Casualties; retreat?: DiplomatRetreatSelection }; /** New battles allow physical withdrawal choice after reveal, never in the sealed plan. */ harassAllocationVersion?: 1; harassAllocationEvent?: string; harassAllocation?: HarassAllocationReceipt; /** Scheduling owner; physical combat slots and aggressor remain independent. */ chooser?: string; chooserEvent?: string; saphoAggressor?: SaphoAggressorState; saphoAggressorEvents?: string[]; /** Only newly created supported battles enter the pre-plan Mentat question. */ mentatQuestionVersion?: 1; mentatQuestionEvent?: string; mentatQuestion?: MentatQuestionReceipt; /** New battles opt into the revealed Diplomat step; legacy battles are not reopened. */ diplomatDefenseVersion?: 1 | 2; diplomatDefenseEvent?: string; diplomatDefense?: DiplomatDefenseReceipt; /** New battles may pause after the outcome is known, before loser losses. */ diplomatRetreatVersion?: 1; diplomatRetreat?: { player: string; event: string; battle: string; turn: number; frame: string; destinations: DiplomatRetreatDestination[]; }; /** Public face-up/behind-shield choice precedes faction battle powers. */ leaderSkillHidden?: Record; nexusInspection?: NexusInspection; /** Independent presence marker: deleting a spent-card record is not a legacy save. */ nexusInspectionUsed?: string; nexusSardaukarUsed?: string; traitorDeclarationVersion?: 1; traitorDeclarations?: Record; homeworldDefensePassed?: string[]; event?: string; strongholdCopy?: StrongholdId; preLeader?: { event: string; ready: string[]; closed: boolean }; /** Public marker participation persists after a zero-token reveal. */ noFieldPlayers?: string[]; /** Provisional mixed-pool participation, separate from the hidden value. */ mixedNoFieldPlayers?: string[]; ecazSpecialKarama?: EcazSpecialKaramaDeclaration; 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; /** Original native allowance, retained separately from the Nexus source. */ nexusHarkonnenAllowance?: { provider: string; nativeWindow: 'direct' | 'harkonnenTraitor'; required: string[]; passed: string[]; }; nexusBorrowedVoice?: NexusBorrowedVoiceFrame; preparation?: { kind: 'voice' | 'nexusVoice' | 'prescience' | 'prescienceAnswer' | 'nexusPrescienceAnswer'; owner: string; beneficiary: string; blocked?: string; }; prescience?: { player: string; field: PlanField; value?: string | number | null; }; voice?: { target: string; kind: Card['kind'] | 'stoneBurner' | 'mirrorWeapon'; must: boolean }; }; export type Auction = { peekKnown?: boolean; occupiedEvent?: string; cards: Card[]; index: number; bid: number; bidder: string | null; active: string; passed: string[]; opener: number; allyPayment?: number; }; export type Decision = | { kind: 'ecazBattleLead'; player: string; event: string; choices: string[] } | { kind: 'harassWithdraw'; player: string; event: string } | ({ kind: 'smugglerCollection' } & SmugglerBattleCollectionOffer) | ({ kind: 'sandmasterVictorySpice' } & SandmasterVictorySpiceOffer) | { kind: 'diplomatDefense'; player: string; event: string; cards: string[]; source: string } | { kind: 'diplomatRetreat'; player: string; event: string; destinations: DiplomatRetreatDestination[] } | { kind: 'leaderSkillVisibility'; player: string; event: string; resumePowers?: boolean } | { kind: 'leaderSkillRevival'; player: string; event: string } | { kind: 'homeworldRevivalDeployment'; player: string; event: string } | { kind: 'grummanCollection'; player: string; event: string } | { kind: 'homeworldOccupiedIncome'; player: string; event: string; world: string } | { kind: 'homeworldOccupiedPercentage'; player: string; event: string } | { kind: 'homeworldOccupiedBonus'; player: string; event: string } | { kind: 'homeworldTupileCleanup'; player: string; event: string } | { kind: 'caladanReinforcement'; player: string; event: string } | { kind: 'choamAudit'; player: string; event: string } | { kind: 'nexusChoamInspection'; 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: 'richeseCacheTerms'; 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: 'moritaniExtortion'; player: string; event: 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: 'nexusIxianReplacement'; player: string; event: string } | { kind: 'mobileStronghold'; player: string; placement: boolean } | { kind: 'ixSubstitution'; player: string; territory: string; losses: Record; } | { kind: 'revivalStop'; player: string; recipient: string; revival: 'forces' | 'leader'; source?: 'nexusEmperor'; } | { kind: 'faceDance'; blocked?: string; player: string; winner: string; winners?: string[]; leader: string | null; identity: string | null; territory: string; } | { kind: 'techToken'; player: string; loser: string; choices: TechId[] } | { kind: 'poisonTooth'; player: string; event?: string; physicalId?: string; copiedFrom?: string | null } | { kind: 'stoneBurner'; player: string; event: string; physicalId?: string; copiedFrom?: string | null } | { kind: 'sukRescue'; player: string; event: string; territory: string; mode: 'normal' | 'skilled'; options: SukRescueOption[]; reserveHomes?: { id: string; name: string; secondary: boolean }[] } | { kind: 'rihani'; player: string; event: string; stage: 'offer' | 'return' } | { kind: 'fullPlanOffer'; player: string } | {kind:'bureaucratPayment';player:string;event:string} | { kind: 'moritaniAssassinate'; player: string; event: string } | { kind: 'mentatQuestion'; player: string; event: string; owner: string; target: string; stage: 'name' | 'reveal'; weapon: string | null } | { kind: 'fullPlanRead'; player: string; target: string } | { kind: 'homeworldShipmentGuild'; player: string; shipper: string; destination: string; amount: number; event: string } | { kind: 'guildShipment'; /** Private original No-Field declaration; never projected to any seat. */ noFieldSkillProof?: string; 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[]; forceOwner?: string; cards: string[]; } | { kind: 'homeworldDefense'; player: string; event: string } | { kind: 'homeworldExplosion'; player: string; territory: string; event: string; options: HomeworldForces[]; pool: HomeworldForces } | { 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: 'nexusEmperorPayment'; player: string; event: string } | { kind: 'nexusEmperorSellerPurchase'; player: string; event: string } | { kind: 'nexusEmperorAmbassadorPurchase'; player: string; event: string } | { kind: 'advisor'; player: string; shipment: string; destination?: string; ambassadorEvent?: string; } | { kind: 'intrusion'; player: string; territory: string; wormRide?: boolean; discoveryEntry?: DiscoveryEntryArrivalChild; 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: 'nexusFremenProtection'; player: string; event: string } | { kind: 'discoveryDiscard'; player: string; event: string } | { kind: 'discoveryEntry'; player: string; event: string } | { kind: 'ecologicalStorm'; player: string; event: string } | { kind: 'jacurutuDial'; player: string; event: string } | { kind: 'greatMakerVote'; player: string; event: string } | { kind: 'greatMakerRide'; player: string; event: string } | { kind: 'wormRide'; player: string; territory: string } | { kind: 'nexusFremenCunningOffer'; player: string; event: string } | { kind: 'nexusFremenCunningRide'; player: string; event: string } | { kind: 'moritaniRetention'; owner: string; player: string; territory: string; cards: string[]; source?: 'nexus'; event?: string; } | { kind: 'moritaniSpecialKarama'; player: string; event: string } | { kind: 'battleCards'; player: string; territory: string; cards: string[] }; type SpiceBankerIncomePayment = { event: string; turn: number; phase: number; kind: BankerIncomeAuthority['kind']; legs: {payer: string; amount: number; recipient: 'bank' | 'player'}[]; signature: string; }; export type ResponseWindow = { spiceBankerIncomePayment?: SpiceBankerIncomePayment; bureaucratPayment?: BureaucratPaymentSource; bureaucratPaymentEvent?: string; noFieldSkillProof?: string; /** Original optional collection binds a pending ground movement, including saved responses. */ sandmasterProof?: string; /** Original eligible contributor amounts; private routing evidence, not a new payment. */ guildContributions?: number[]; guildPaymentProof?: string; occupiedPayment?: { event: string; contributions: { payer: string; amount: number }[] }; /** Bank-paid portion of a frozen Homeworld charity claim. */ charityHomeworld?: number; source?: 'ambassador'; intent?: string; advisors?: boolean; /** Saved exact ordinary Guild-rate response; not the independent Karama rate. */ guildRateEvent?: string; advisorResume?: 'wormRide' | 'declaration' | 'ambassador' | 'discoveryEntry'; discoveryEntry?: DiscoveryEntryArrivalChild; 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' | 'ecazOccupy' | 'moritaniAlliance' | 'moritaniDuke' | 'moritaniRetention' | 'voice' | 'prescience' | 'nexusPrescience' | 'advisor' | 'emperorIncome' | 'richesePurchaseIncome' | 'richeseGift' | 'richeseNoField' | 'harkonnenBonus' | 'harkonnenTraitor' | 'guildIncome' | 'guildRate' | 'emperorGift' | 'emperorRevival' | 'stormPeek' | 'stormProtection' | 'wormPlacement' | 'wormSurvival' | 'wormAllyProtection' | 'atreidesAuction' | 'atreidesSpice' | 'guildTiming' | 'kwisatz' | 'capture' | 'eliteStrength' | 'fremenSupport' | 'advisorFlip' | 'nexusAdvisorFlip' | 'nexusSardaukar' | 'nexusFremenCunning' | 'nexusEcazBetrayal' | 'nexusGuildCunning' | '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 PendingHomeworldShipment = HomeworldShipmentIntent & { guildSecretEvent?: string; guildNexusEvent?: string; /** Absent in older world-to-world declarations. */ route?: 'arrakis'; event: string; turn: number; amount: number; elite: number; cost: number; allyPayment: number; pools: ReturnType['sources'] | ReturnType['boardSources']; }; type PendingShipment = { /** Only new declarations use the bonus; legacy quoted shipments retain their original price. */ smuggler?: SmugglerShipment; smugglerCompanion?: SmugglerNoFieldCompanion; noFieldSkillProof?: string; guildSecretEvent?: string; guildRateEvent?: string; guildNexusEvent?: string; nexusEvent?: string; richesePair?: { event: string; revealedTokenId: string }; homeworldSources?: NativeReserveSelections; 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 PendingGuildTransport = { event: string; signature: string; turn: number; player: string; fromReserves: boolean; group: [string, number][]; eliteGroup: Record; origin: string; to: string; sector: number; amount: number; elite: number; cost: number; allyPayment: number; advisors: boolean; }; type GuildTransportOrder = Omit; type JunctionTransportDeclaration = { player: string; turn: number; event: string; offer: string; destination: string; sources: HomeworldShipmentIntent['sources']; allyPayment: number; }; type GuildBetrayalContinuation = | {source: 'reserve'; shipment: PendingShipment} | {source: 'guildTransport'; frame: GuildTransportOrder; fromReserves: boolean} | {source: 'homeworld'; shipment: PendingHomeworldShipment} | {source: 'junction'; transport: JunctionTransportDeclaration}; type RicheseAllyOffer = { event: string; owner: string; recipient: string; tokenId: string; tokenEvent: string; territory: string; sector: number; payer: string; }; export type Game = { ecazOccupyPreview?: boolean; /** Fresh native Guild/Nexus payment-replacement profile, never inferred from a save. */ guildBetrayalPreview?: boolean; guildBetrayal?: GuildBetrayalCursor; nexusGuildBetrayalHistory?: GuildBetrayalReceipt[]; guildBetrayalSourceReceipts?: {event: string; turn: number; shipper: string; sourceSignature: string; receipt: string}[]; pendingGuildBetrayal?: { invoice: GuildBetrayalInvoice; continuation: GuildBetrayalContinuation; required: string[]; passed: string[]; signature: string; } | null; /** Explicit bounded Nexus/Richese auction profile; never inferred for old saves. */ richeseBetrayalPreview?: boolean; richeseBetrayal?: RicheseBetrayalCursor; pendingRicheseBetrayal?: { receipt: RicheseBetrayalReceipt; lot: RicheseAuction; quote: Extract; signature: string; } | null; /** Original normal-auction obligation; neutral acknowledgements precede funding. */ nexusEmperorPayment?: { sequence: number; current: string | null; completed: EmperorPaymentReceipt[] }; pendingNexusEmperorPayment?: { receipt: EmperorPaymentReceipt } | null; /** Uniform winning-buyer confirmation bound to the original unpaid seller lot. */ pendingNexusEmperorSellerPurchase?: { event: string; turn: number; winner: string; round: string; lot: RicheseAuction; quote: Extract; bank: boolean; signature: string; } | null; /** Explicit development profile; never enabled by a public start or player action. */ kullPreview?: boolean; nexusKullPreview?: boolean; /** Fresh bounded classic Nexus purchased-card prototype; never inferred for old saves. */ nexusIxianReplacementPreview?: true; pendingNexusIxianReplacement?: IxianReplacementSource | null; nexusIxianReplacementHistory?: IxianReplacementReceipt[]; nexusIxianReplacementCursor?: IxianReplacementCursor; /** Fresh native Ixian attempt prevention; never inferred from held cards. */ nexusIxianBetrayalPreview?: true; pendingNexusIxianBetrayal?: { source: IxianBetrayalSource; continuation: ResponseWindow; required: string[]; passed: string[]; } | null; nexusIxianBetrayalHistory?: IxianBetrayalReceipt[]; nexusIxianBetrayalCursor?: IxianBetrayalCursor; /** Fresh bounded native Harkonnen traitor-cancellation profile. */ nexusHarkonnenBetrayalPreview?: true; pendingNexusHarkonnenBetrayal?: { source: HarkonnenBetrayalSource; required: string[]; passed: string[]; } | null; nexusHarkonnenBetrayalHistory?: HarkonnenBetrayalReceipt[]; nexusHarkonnenBetrayalCursor?: HarkonnenBetrayalCursor; pendingNexusHarkonnenReplacement?: HarkonnenBetrayalReplacement | null; nexusHarkonnenReplacementHistory?: HarkonnenBetrayalReplacement[]; kullSequence?: number; kullRestrictions?: KullPhaseRestriction[]; pendingKull?: { event: string; player: string; owner: string; card: string; turn: number; phase: number; form: 'printed' | 'substitution'; stage: 'offer' | 'counter'; selection?: { source: 'printed' | 'nexus'; card: string; nexusEvent?: string }; intent: { kind: 'ordinary'; use: KaramaUse } | { kind: 'special'; use: SpecialKaramaIntent }; resume: NonNullable['resume']; worthless: Game['pendingChoamWorthless']; signature: string; } | null; stormMovementSource?: StormMovementSource; ecologicalStorm?: DiscoveryStorm; discoveryEnabled?: boolean; discoveries?: DiscoveryState; discoveryStash?: DiscoveryStash; discoveryEntry?: DiscoveryEntryRound; greatMaker?: GreatMaker; nexusChoamTrades?: NexusChoamTrade[]; nexusChoamTradeLast?: { event: string; stage: NexusChoamTrade['stage'] }; nexusChoamBetrayals?: NexusChoamBetrayal[]; nexusChoamBetrayalLast?: { event: string; stage: NexusChoamBetrayal['stage'] }; nexusEcazInquiries?: EcazInquiryReceipt[]; nexusEcazInquiryLast?: { event: string }; nexusEcazDukeHistory?: NexusEcazDukeReceipt[]; nexusEcazDukeEvents?: string[]; nexusEmperorSecretHistory?: NexusEmperorRevival[]; nexusEmperorSecretEvents?: string[]; nexusEmperorPurchaseHistory?: EmperorNexusPurchase[]; nexusEmperorPurchaseEvents?: string[]; nexusFremenRevivalHistory?: FremenNexusRevival[]; nexusFremenRevivalEvents?: string[]; treacheryDiscardSequence?: number; resolvedTreacheryDiscardSequence?: number; /** Committed discard receipts are evidence, not additional card custody. */ pendingTreacheryDiscard?: { sequence: number; batch: FreshDiscardBatch; reaction?: | { stage: 'offer'; passed: string[] } | { stage: 'select'; player: string; candidates: string[] }; continuation: | { kind: 'ambassador'; entry: NonNullable } | { kind: 'discoveryStash'; event: string; owner: string; card: string } | { kind: 'nexusChoamTrade'; event: string; owner: string; card: string; spiceAfter: number } | { kind: 'nexusChoamBetrayal'; event: string; owner: string; target: string; card: string; handBefore: number } | { kind: 'kaitainDiscard'; owner: string; event: string; cost: number; spiceAfter: number } | { kind: 'tupileCleanupDiscard'; cleanup: NonNullable; cards: string[]; stateSignature: string; } | { kind: 'winnerMandatoryDiscard'; event: string; player: string; territory: string; optional: string[]; commitment: 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: 'distransDiscard'; owner: string; recipient: string; transferred: string; card: string; resume: NonNullable['resume']; stateSignature: string; } | { kind: 'saphoMovementDiscard'; player: string; card: string; event: string; mode: 'first' | 'last'; previousActive: string; resume: NonNullable['resume']; stateSignature: string; } | { kind: 'saphoAuctionDiscard'; player: string; card: string; event: string; mode: 'first' | 'last'; resume: NonNullable['resume']; stateSignature: string; } | { kind: 'saphoBattleOrderDiscard'; player: string; card: string; event: string; mode: 'first' | 'last'; resume: NonNullable['resume']; stateSignature: string; } | { kind: 'saphoAggressorDiscard'; player: string; card: string; battleEvent: string; saphoEvent: string; resume: NonNullable['resume']; stateSignature: string; } | { kind: 'residualPoisonDiscard'; player: string; target: string; victim: string; battleEvent: string; card: string; resume: NonNullable['resume']; stateSignature: string; } | { kind: 'karamaCharityDiscard' | 'karamaInflationDiscard' | 'karamaBgCharityDiscard' | 'karamaBattleDiscard' | 'karamaChoamWorthlessDiscard' | 'karamaEmperorShareDiscard' | 'karamaIncomeDiscard'; player: string; card: string; use: Extract; resume: NonNullable['resume']; stateSignature: string; } | { kind: 'karamaPurchaseDiscard' | 'karamaPaymentDiscard'; player: string; card: string; resume: NonNullable['resume']; stateSignature: string; } | { kind: 'choamSaleDiscard'; owner: string; card: string; witness: string | null; price: number; spiceAfter: number; stateSignature: string; } | { kind: 'choamKulonDiscard'; player: string; card: string; nexusEvent?: string; before: number; stateSignature: string; } | { kind: 'choamLaLaLaDiscard'; player: string; card: string; nexusEvent?: string; target: string; stoppedRevival?: true; stateSignature: string; } | { kind: 'choamBalisetDiscard'; player: string; card: string; nexusEvent?: string; target: string; territory: string; stoppedMovement?: true; stateSignature: string; } | { kind: 'choamGamontDiscard'; player: string; card: string; nexusEvent?: string; target: string; location: string; elite?: 1; noFieldEvent?: string; mentat?: true; stateSignature: string; } | { kind: 'choamJubbaDiscard'; player: string; card: string; nexusEvent?: string; territory: string; stateSignature: string; } | { kind: 'thumperDiscard'; player: string; card: string; resume: NonNullable['resume']; stateSignature: string; } | { kind: 'amalDiscard'; player: string; card: string; before: { player: string; spice: number }[]; resume: NonNullable['resume']; stateSignature: string; } | { kind: 'truthtranceDiscard'; consumed: TruthQueueEntry; 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: 'terrorAtomics'; entry: NonNullable; aftermath: AtomicsAftermath; handBefore: { player: string; count: number }[]; } | { kind: 'battleResolved'; event: string; result: 'normal' | 'traitor' | 'mutualTraitors' | 'explosion'; combatants: string[]; territory: string; winner: string | null; cards: string[]; casualties?: { owner?: string; /** Harass has already returned the complement of this physical pool. */ fullyCommitted?: true; 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; /** Frozen before the cache deduction; cancellation never recounts live hands. */ normalCountBeforeCache?: number; blackMarketSold: boolean; cacheCanceled: boolean; cacheAuthority?: string; cacheTerms?: { method: 'onceAround' | 'silent'; direction: 'clockwise' | 'counterclockwise' }; cacheTermsEvent?: string; cacheTermsProof?: string; opener?: number; } | null; richeseAuction?: RicheseAuction | null; richeseFunding?: RicheseFunding; richesePeekKnown?: boolean; richeseOfferedCard?: Card | null; richeseClaim?: string | null; currentAuctionSale?: { occupiedEvent?: string; occupiedContributions?: { payer: string; amount: number }[]; spiceBankerIncomePayment?: SpiceBankerIncomePayment; bureaucratPayment?: BureaucratPaymentSource; bureaucratPaymentEvent?: string; winner: string; amount: number; free: boolean; origin: 'normal' | 'cache' | 'blackMarket'; seller: string | null; /** Original seller remains the source owner when the invoice is sent to bank. */ recipient?: 'bank'; /** Actual Richese donor share of another buyer's lot; paid to Emperor or bank. */ richeseContribution?: number; } | null; pendingAmbassador?: { revivalEvent?: string; victoryEvent?: string; event: string; owner: string; entrant: string; token: string; territory: string; sector: number; turn: number; phase: number; stage: | 'offer' | 'allianceReply' | 'loan' | 'copy' | 'cards' | 'purchase' | 'income' | 'bonus' | 'move' | 'ship' | 'revival' | 'arrival'; nexusPurchase?: { event: string; signature: string }; 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 | 2; elite: 0; }; }; purchaseReceipt?: { buyer: string; card: string; amount: 3; emperor: string | null; stage: 'income' | 'bonus'; nexusEvent?: string; }; 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?: { /** Original public arrival, absent only in legacy saves. */ entrySignature?: string; candidates?: string[]; selectionSignature?: string; nexusPlacements?: Record; token: string; entrant: string; territory: string; sector: number; amount: number; elite: number; cause: | 'shipment' | 'movement' | 'guildTransport' | 'advisor' | 'wormRide' | 'homeworldRevival' | 'caladanReinforcement' | 'ambassador'; turn: number; phase: number; stage: | 'select' | 'offer' | 'robbery' | 'discard' | 'gift' | 'sneakAttack' | 'allianceResponse' | 'allianceReply'; allianceBlocked?: boolean; resume: 'none' | 'wormRide' | 'ambassador'; ambassadorEvent?: string; } | null; /** Current ordinary Basic shipment's physical departure obligation. */ basicAlliedShipment?: BasicAlliedShipment | null; moritaniSpecialKarama?: { source: MoritaniSpecialKaramaSource; cards: string[]; optional: string[]; stage: 'offer' | 'discard' | 'done'; } | null; /** Committed entries whose optional reactions must wait for the arrival's own * pending interaction (payment, advisor choice, storm protection). */ pendingArrivalReaction?: { entrant: string; territory: string; sector: number; amount: number; elite: number; cause: NonNullable['cause']; resume: 'none' | 'wormRide'; turn: number; phase: number; }[] | null; /** One committed entry with two independently optional expansion reactions. */ pendingArrivalOverlap?: { event: string; signature: string; turn: number; phase: number; entrant: string; territory: string; sector: number; amount: number; elite: number; cause: NonNullable['cause']; resume: 'none' | 'wormRide'; ecaz: string; moritani: string; first: 'ambassador' | 'terror'; stage: 'first' | 'second'; ambassadorToken: string; terrorTokens: string[]; ambassadorEvent?: string; } | null; moritaniTerror?: TerrorState; moritaniAtomics?: AtomicsAftermath | null; moritaniExtortion?: ExtortionState; grummanCollection?: GrummanCollection; pendingMoritaniPlacement?: { token: string; territory: string; turn: number; nexusEvent?: string; } | 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' | 'kull'; storm?: boolean; movement?: boolean; location?: string; elite?: number; mentat?: boolean; noFieldEvent?: string; target?: string; revival: boolean; nexusEvent?: string; } | 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; nexusChoamInsight?: { event: string; viewer: string; target: string; turn: number; card: Card; } | null; choamMarket?: ChoamMarket | null; biddingEnd?: BiddingEnd | null; ecazPoisonIncome?: { player: string; turn: number; phase: number; amount: number; count: number }[]; pendingWinnerDiscards?: { event: string; turn: number; territory: string; player: string; cards: string[]; optional: string[]; signature: string } | null; pendingSukRescue?: SukRescueReceipt | null; rihaniHistory?: RihaniReceipt[]; pendingChoamMarketGhola?: ChoamMarketGhola | null; choamTradeTurn?: number; inflation?: Inflation | null; inflationUsed?: boolean; inflationAttemptTurn?: number; choamCharity?: { turn: number; canceled: boolean }; homeworldRevival?: HomeworldRevivalOpening | null; homeworldRevivalReturn?: HomeworldRevivalReturn; homeworldRevivalProgress?: HomeworldRevivalProgress; homeworldVictoryReinforcement?: HomeworldVictoryReturn; giediCollection?: GiediCollection; 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[]; authority?: string } | null; ixAuctionKnown?: { turn: number; cards: Card[]; controller?: string } | 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; sandmasterPiles?: string[]; } | 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?: { receipt?: { event: string; turn: number; physical: string; signature: string }; homeworld?: { pool: HomeworldForces; cyborgsLost: number; eliteTanks: number; normalTanks: number; battleLosses: number }; player: string; /** Physical Ix owner remains player; the selected lead owns the cleanup. */ winner?: string; territory: string; losses: Record; cards: string[]; sources?: Record; recover?: Record; } | null; revivalPrevention?: { player: string; turn: number }; traitorReserve?: string[]; ecazLoyalty?: EcazLoyalty; pendingFaceDance?: { player: string; winner: string; winners?: string[]; leader: string | null; identity: string | null; territory: string; } | null; phaseOpening?: { passed: string[]; initialize: boolean } | null; sandtrout?: boolean; techTokens?: TechState | null; strongholdCards?: StrongholdState | null; /** Optional independent module; complete effect coverage is still release-gated. */ nexusCards?: { cards: NexusState | null; phase: NexusCardPhase | null } | null; nexusTraitorExchanges?: NexusTraitorExchange[]; /** Original public threatened-seat queue; a spent card protects only its owner. */ nexusFremenProtection?: NexusFremenProtectionFrame; /** An initially empty actual worm occurrence; the optional card is not disclosed by this offer. */ nexusFremenCunningOffer?: { occurrence: FremenCunningOccurrence; owner: string } | null; /** Accepted appearances wait for post-Nexus riding, then retain completion custody. */ nexusFremenCunningRides?: { occurrence: FremenCunningOccurrence; authorization: FremenCunningRideAuthorization; stage: 'pending' | 'queued' | 'select' | 'complete'; }[]; /** A spent Ecaz card binds one exact allied force group until Karama settles. */ nexusEcazBetrayalHistory?: { snapshot: EcazBetrayalSnapshot; stage: 'pending' | 'complete' }[]; /** Spent Fremen card receipts include the earlier movement-only saved form. */ nexusFremenBetrayalHistory?: StoredFremenNexusBetrayal[]; /** One actual Sardaukar battle advantage suppressed by a spent Emperor card. */ nexusEmperorBetrayalHistory?: { event: string; turn: number; territory: string; owner: string; target: string }[]; /** Original native Voice canceled by a spent Bene Gesserit Nexus card. */ nexusBgBetrayalHistory?: { event: string; turn: number; territory: string; owner: string; target: string; voice: NonNullable }[]; nexusFaceDancerHistory?: NexusFaceDancerReceipt[]; nexusSuboidHistory?: NexusSuboidReceipt[]; nexusSuboidLast?: { event: string; owner: string; turn: number }; nexusAdvisorHistory?: { receipt: NexusAdvisorReceipt; stage: 'pending' | 'completed' | 'canceled'; frame: string; signature: string }[]; nexusAdvisorLast?: { event: string; stage: 'pending' | 'completed' | 'canceled' }; nexusSardaukarHistory?: {receipt: NexusSardaukarReceipt; stage: 'pending' | 'active' | 'canceled'; parent: string; signature: string; casualties?: {forces: CombatForces; dial: number; support: number; options: Casualties[]; outcome: 'pending' | 'complete'}}[]; nexusSardaukarLast?: {event: string; stage: 'pending' | 'active' | 'canceled'}; nexusChoamHistory?: {receipt: NexusChoamReceipt; stage: 'pending' | 'canceled' | 'complete' | 'fizzled'; frame: string; parent: string; signature: string}[]; nexusMoritaniHistory?: {receipt:NexusMoritaniReceipt;stage:'pending'|'complete'|'canceled';before:string;frame:string;signature:string}[]; nexusGuildCunningHistory?: {receipt:NexusGuildCunningReceipt;stage:'pending'|'secondShipment'|'extraMove'|'completed'|'canceled';parent:string;movesAfter:number;shipment?:{kind:'reserve'|'guild'|'homeworld';frame:string;stage:'pending'|'complete'|'stopped'}|{kind:'declined';stage:'declined'};signature:string}[]; nexusGuildCunningLast?: {event:string;stage:'pending'|'secondShipment'|'extraMove'|'completed'|'canceled'}; nexusGuildSecretHistory?: {receipt:NexusGuildSecretAllyReceipt;kind:'reserve'|'cross'|'homeworld';frame:string;signature:string}[]; nexusGuildSecretLast?: {event:string}; nexusRicheseHistory?: {receipt:NexusRicheseReceipt;stage:'pending'|'shipped'|'stopped';frame:string;halfRate:boolean;signature:string}[]; nexusRicheseLast?: {event:string;stage:'pending'|'shipped'|'stopped'}; nexusRicheseCunningHistory?: { event: string; owner: string; turn: number; phase: 5; tokenEvent: string; frame: string; stage: 'pending' | 'shipped' | 'stopped'; signature: string }[]; nexusRicheseCunningLast?: { event: string; stage: 'pending' | 'shipped' | 'stopped' }; nexusMoritaniLocations?: Record; nexusMoritaniLast?: {event:string;stage:'pending'|'complete'|'canceled'}; nexusChoamLast?: {event: string; stage: 'pending' | 'canceled' | 'complete' | 'fizzled'}; nexusTraitorPending?: string | null; nexusTraitorParent?: { event: string; signature: string } | null; /** Null/absent disables the module; custody is installed at force placement. */ homeworlds?: { custody: HomeworldCustody | null; historyVersion?: 1 } | null; homeworldOccupationHistory?: HomeworldOccupationHistory; /** Fresh development occupation entry only; never inferred for an older Homeworld save. */ homeworldOccupationPreview?: true; /** Fresh printed Ecaz high-card victory, separate from ordinary stronghold points. */ homeworldEcazVictoryPreview?: true; /** Fresh Tupile capacity entry only; older occupation games are not converted. */ homeworldTupilePreview?: true; homeworldTupileState?: OccupiedTupileState; pendingHomeworldTupileCleanup?: { event: string; player: string; hand: string[]; opening: Game['phaseOpening']; signature: string; } | null; homeworldAuctionInspection?: { lot: string; audience: string[]; native: string | null; occupation: string; occupiedKnown: boolean }; pendingHomeworldOccupiedIncome?: HomeworldOccupiedIncomeState | null; homeworldOccupiedIncomeHistory?: HomeworldOccupiedIncomeState[]; homeworldOccupiedPercentageLedger?: OccupiedPercentageState; pendingHomeworldOccupiedPercentage?: { source: OccupiedPercentageSource; proof: string; suffix: OccupiedPercentageSuffix; suffixSignature: string; } | null; pendingHomeworldOccupiedBonus?: { event: string; proof: string; response: ResponseWindow; } | null; fremenCollectionCredits?: { turn: number; event: string; collected: { id: string; amount: number }[]; complete: boolean }; tupileIntelligence?: TupileIntelligenceState; pendingTech?: { player: string; loser: string; choices: TechId[] } | null; summonedBeforeBlow?: boolean; summonedNexusBeforeRides?: boolean; summonedWorm?: { territory: string; /** Server-created identity for this one physical special-Karama appearance. */ event: string; resume: Pick< Game, | 'response' | 'decision' | 'pendingKarama' | 'spiceWindow' | 'spiceResolution' | 'spiceSequence' | 'nexus' | 'wormRides' | 'ready' >; } | null; pendingShipment?: PendingShipment | null; pendingGuildTransport?: PendingGuildTransport | null; /** One canceled unaffordable declaration may be redeclared at full price this turn. */ guildRateBlocked?: { turn: number; player: string }; pendingHomeworldShipment?: PendingHomeworldShipment | null; junctionOffer?: JunctionOffer | null; pendingExchange?: { response: ResponseWindow | null; decision: Decision | null; } | null; /** Private knowledge copies, never an additional physical card zone. */ harkonnenExchangeInspection?: { owner: string; target: string; turn: number; phase: 3; kind: 'draw' | 'return'; cards: Card[]; signature: string; }; /** Purchase is already committed; only the Emperor's receipt remains pending. */ pendingRichesePurchaseIncome?: { event?: string; buyer?: string; 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' | 'skillTreachery' | 'leaderSkills' | 'traitors' | 'forces'; leaderSkills?: LeaderSkillsState; /** Fresh local classic profile; earned custody remains with its original faction. */ spiceBankerIncomePreview?: true; spiceBankerIncome?: BankerIncomeState; bureaucratPayments?: {used:BureaucratPaymentUse[];pending?:{ source:BureaucratPaymentSource;owner:string;leader:string; resume:{response:ResponseWindow|null;decision:Decision|null}; continuation:{kind:'response'}|{kind:'auction';quote:AuctionContinuationQuote}|{kind:'bribe'}|{kind:'guildBetrayal';event:string;recipient:string;amount:number}; frame:string;signature:string; }}; bureaucratPaymentEvent?: string; bureaucratUseEvents?: string[]; /** Private development opt-in while uniform hidden-eligibility UX is pending. */ moritaniAssassinatePreview?: true; /** Explicit development-only neutral Semuta reaction profile. */ semutaPreview?: true; moritaniAssassinate?: MoritaniAssassinateState; moritaniAssassinateCallEvents?: string[]; moritaniAssassinateResume?: { event: string; continuation: Extract['continuation'], {kind:'battleResolved'}>; signature: string; }; mentatHistory?: MentatObservation[]; mentatHistoryEvents?: string[]; advanced: boolean; /** Explicit normal-lobby opt-in; unfinished rules remain labeled as a preview. */ advancedPreview?: true; 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; /** Separate Battle-phase scheduling; never rewrites physical storm order. */ battleOrder?: BattleChooserOrder; battleOrderUseEvents?: string[]; 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 }; /** Public at the actual card reveal; never populated from a private forecast. */ component?: StormCardComponent; }[]; stormDials: Record; stormPending: number | null; stormCard: number | null; stormCardKnown: boolean; stormResolution: { discoveryStorm?: string; 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[]; homeworldBattleLoss?: { event: string; territory: string; player: string; kind: 'winner' | 'explosion'; pool: HomeworldForces; options: HomeworldForces[]; commitment?: { forces: CombatForces; dial: number; support: number }; } | null; lastBattleContext?: { event: string; turn: number; territory: string; combatants: string[]; winner: string | null; result: 'normal' | 'traitor' | 'mutualTraitors' | 'explosion' | 'legacy'; cardRoles?: Record>>; cardRolesSignature?: string; mandatoryDiscard?: { entries: { card: string; owner: string }[]; signature: string; }; winnerDiscards?: { cards: string[]; completed: boolean; signature: string }; sukRescue?: { signature: string; completed: boolean; physical?: SukPhysicalRescueReceipt }; ixSubstitution?: { signature: string; completed: boolean }; rihani?: { skill: RihaniSkill; cards: string[]; signature: string; completed: boolean; faceDanceStarted?: true }; sandmaster?: { leader: string; key: string; before: number; after: number }; smugglerCollection?: SmugglerBattleReceipt; caladanReinforcement?: HomeworldVictoryObligation; nexusSardaukarCasualties?: string; moritaniAssassinate?: {event:string;signature:string;continuation?:string}; nexusChoamInspection?: NexusChoamInspectionReceipt; nexusMoritaniRetention?: NexusMoritaniRetentionReceipt; } | 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[]; /** Presence enables the independently selected three-card Ecaz variant. */ ecazTreachery?: true; /** Public, turn-scoped receipt for the discarded Recruits card. */ recruits?: { turn: number; player: string; card: typeof RECRUITS_CARD_ID; } | null; emperorExtra: Record; aid: Record; }; export class RuleError extends Error {} function nexusRule(work: () => T): T { try { return work(); } catch (error) { throw new RuleError(error instanceof Error ? error.message : 'Invalid Nexus card operation.'); } } function nexusCardsIntegrity(g: Game) { const nexus = g.nexusCards; if (!nexus) return; requireRule(Object.keys(nexus).sort().join(',') === 'cards,phase', 'Invalid Nexus module fields.'); if (g.status === 'lobby') { requireRule(nexus.cards === null && nexus.phase === null, 'Nexus cards are dealt only after setup begins.'); return; } requireRule(nexus.cards, 'The Nexus inventory is missing.'); nexusRule(() => validateNexusCards(nexus.cards!, g.players)); requireRule(g.players.every(p => !p.ally || nexus.cards!.hands[p.id] === null), 'Allied players cannot retain Nexus cards.'); const phase = nexus.phase; if (!phase) { requireRule(g.turn === 1 && g.phase === 0, 'The Nexus phase record is missing.'); return; } nexusRule(() => validateNexusCardPhase(phase, g.players, nexus.cards!)); requireRule(phase.turn <= g.turn && phase.turn >= g.turn - 1 && (phase.stage === 'complete' || (phase.turn === g.turn && g.phase === 1)) && (g.phase !== 1 || phase.turn === g.turn), 'The Nexus record does not match its phase.'); if (phase.stage === 'drawing') requireRule(!g.decision && !g.response && !g.phaseOpening && !g.spiceSequence && !g.spiceWindow && !g.spiceResolution && !g.summonedWorm && !g.wormRides.length && !g.choamMarket && !g.truthtrance && !g.pendingKarama && !g.pendingTreacheryDiscard, 'Finish the preceding phase interaction before drawing Nexus cards.'); } function nexusChoamTradeIntegrity(g: Game) { const history = g.nexusChoamTrades; const continuation = g.pendingTreacheryDiscard?.continuation; const pending = continuation?.kind === 'nexusChoamTrade' ? continuation : null; if (history === undefined) { requireRule(!g.nexusChoamTradeLast && !pending, 'The CHOAM Nexus trade has lost its saved history.'); return; } requireRule(g.nexusCards?.cards && Array.isArray(history) && history.length > 0, 'The CHOAM Nexus trade requires its physical module and history.'); const events = new Set(), turns = new Set(); const physical = physicalTreacheryCards(g); for (const [index, record] of history.entries()) { nexusRule(() => validateNexusChoamTrade(g, record, physical)); requireRule(!events.has(record.event) && !turns.has(record.turn) && (record.stage === 'complete' || index === history.length - 1), 'The CHOAM Nexus trade has a duplicated or unfinished prior payment.'); events.add(record.event); turns.add(record.turn); } const last = history.at(-1)!; requireRule(JSON.stringify(g.nexusChoamTradeLast) === JSON.stringify({event: last.event, stage: last.stage}) && (last.stage === 'discard') === !!pending, 'The CHOAM Nexus trade has lost or reopened its committed discard.'); if (last.turn === g.turn) requireRule(g.nexusCards.cards.discard.includes('choam'), 'The CHOAM Nexus trade has lost its spent Nexus card.'); if (pending) requireRule(g.status === 'playing' && g.phase === 7 && last.turn === g.turn && pending.event === last.event && pending.owner === last.owner && pending.card === last.card && pending.spiceAfter === last.spiceBefore + 2 && getPlayer(g, last.owner).spice === pending.spiceAfter, 'The CHOAM Nexus trade no longer matches its committed payment.'); } function choamInspectionDecision( decision: Game['decision'] | undefined, ): Extract | null { return decision?.kind === 'nexusChoamInspection' ? decision : null; } function nexusChoamInspectionIntegrity(g: Game) { const context = g.lastBattleContext; const receipt = context?.nexusChoamInspection; const continuation = g.pendingTreacheryDiscard?.continuation; const active = choamInspectionDecision(g.decision); const exchange = choamInspectionDecision(g.pendingExchange?.decision); const nullentropy = choamInspectionDecision(g.pendingNullentropy?.resume.decision); const gift = choamInspectionDecision(g.pendingRicheseGift?.resume.decision); const purchase = choamInspectionDecision(g.pendingRichesePurchaseIncome?.resume.decision); const discard = choamInspectionDecision( continuation && 'resume' in continuation ? continuation.resume.decision : null, ); const decisionCount = Number(!!active) + Number(!!exchange) + Number(!!nullentropy) + Number(!!gift) + Number(!!purchase) + Number(!!discard); const inspectionDecision = active ?? exchange ?? nullentropy ?? gift ?? purchase ?? discard; if (!receipt) { requireRule(decisionCount === 0, 'The CHOAM Nexus inspection has lost its battle.'); return; } requireRule( !!g.nexusCards?.cards && !!context?.winner && context.result !== 'legacy' && receipt.event === context.event && context.combatants.length === 2 && context.combatants.includes(receipt.opponent) && receipt.opponent !== context.winner && Array.isArray(receipt.usedCards) && receipt.usedCards.every(id => typeof id === 'string' && id.length > 0) && new Set(receipt.usedCards).size === receipt.usedCards.length && ['pending', 'offer', 'complete'].includes(receipt.stage) && receipt.signature === nexusChoamInspectionSignature(receipt), 'The CHOAM Nexus inspection lost its original battle or used cards.', ); if (receipt.stage === 'offer') requireRule(g.phase === 6 && !g.battle && decisionCount === 1 && inspectionDecision?.event === receipt.event && inspectionDecision.player === context.winner, 'The CHOAM Nexus inspection offer has lost its winner.'); else requireRule(decisionCount === 0, 'The CHOAM Nexus inspection reopened after its battle.'); if (receipt.stage === 'pending') requireRule(g.phase === 6 && !g.battle, 'Finish the original battle cleanup before CHOAM inspection.'); if (receipt.inspected !== undefined) requireRule(receipt.stage === 'complete' && !receipt.usedCards.includes(receipt.inspected) && g.nexusChoamInsight?.event === receipt.event && g.nexusChoamInsight.viewer === context.winner && g.nexusChoamInsight.target === receipt.opponent && g.nexusChoamInsight.turn === context.turn && g.nexusChoamInsight.card.id === receipt.inspected, 'The private CHOAM Nexus inspection lost its sampled card.'); } function nexusMoritaniDecision( decision: Game['decision'] | undefined, ): Extract | null { return decision?.kind === 'moritaniRetention' && decision.source === 'nexus' ? decision : null; } function nexusMoritaniRetentionIntegrity(g: Game) { const context = g.lastBattleContext; const receipt = context?.nexusMoritaniRetention; const pending = g.moritaniRetention?.source === 'nexus' ? g.moritaniRetention : null; const continuation = g.pendingTreacheryDiscard?.continuation; const active = nexusMoritaniDecision(g.decision); const exchange = nexusMoritaniDecision(g.pendingExchange?.decision); const nullentropy = nexusMoritaniDecision(g.pendingNullentropy?.resume.decision); const gift = nexusMoritaniDecision(g.pendingRicheseGift?.resume.decision); const purchase = nexusMoritaniDecision(g.pendingRichesePurchaseIncome?.resume.decision); const worm = nexusMoritaniDecision(g.summonedWorm?.resume.decision); const discard = nexusMoritaniDecision( continuation && 'resume' in continuation ? continuation.resume.decision : null, ); const count = Number(!!active) + Number(!!exchange) + Number(!!nullentropy) + Number(!!gift) + Number(!!purchase) + Number(!!worm) + Number(!!discard); const decision = active ?? exchange ?? nullentropy ?? gift ?? purchase ?? worm ?? discard; if (!receipt) { requireRule(!pending && count === 0, 'The Moritani Nexus cleanup lost its battle.'); return; } requireRule( !!g.nexusCards?.cards && !!context?.winner && context.result !== 'legacy' && !byFaction(g, 'moritani') && context.event === receipt.event && context.combatants.includes(receipt.player) && context.winner !== receipt.player && Array.isArray(receipt.played) && Array.isArray(receipt.eligible) && new Set(receipt.played).size === receipt.played.length && new Set(receipt.eligible).size === receipt.eligible.length && receipt.played.every(id => typeof id === 'string' && !!id) && receipt.eligible.every(id => receipt.played.includes(id)) && ['pending', 'offer', 'complete'].includes(receipt.stage) && receipt.signature === nexusMoritaniRetentionSignature(receipt), 'The Moritani Nexus retention lost its physical losing battle.', ); if (receipt.stage === 'complete') { requireRule(!pending && count === 0 && (receipt.kept === null || receipt.eligible.includes(receipt.kept!)), 'The Moritani Nexus retention reopened after cleanup.'); return; } requireRule( g.phase === 6 && !g.battle && !!pending && pending.stage === 'choose' && pending.event === receipt.event && pending.owner === receipt.player && pending.player === receipt.player && pending.turn === context.turn && pending.territory === context.territory && JSON.stringify(pending.played) === JSON.stringify(receipt.played) && JSON.stringify(pending.eligible) === JSON.stringify(receipt.eligible) && pending.played.every(id => getPlayer(g, pending.player).hand.some(card => card.id === id)), 'The Moritani Nexus retention lost its reserved battle cards.', ); if (receipt.stage === 'offer') requireRule(count === 1 && decision?.player === receipt.player && decision.event === receipt.event && JSON.stringify(decision.cards) === JSON.stringify(receipt.eligible), 'The Moritani Nexus offer lost its original loser.'); else requireRule(count === 0, 'Finish earlier battle cleanup before Moritani Nexus retention.'); } function retireNexusChoamInsight(g: Game) { g.nexusChoamInsight = null; if (g.lastBattleContext?.nexusChoamInspection?.stage === 'complete') delete g.lastBattleContext.nexusChoamInspection; } function nexusChoamBetrayalIntegrity(g: Game) { const history = g.nexusChoamBetrayals; const continuation = g.pendingTreacheryDiscard?.continuation; const pending = continuation?.kind === 'nexusChoamBetrayal' ? continuation : null; if (history === undefined) { requireRule(!g.nexusChoamBetrayalLast && !pending, 'The CHOAM Nexus Betrayal has lost its spent card history.'); return; } requireRule(g.nexusCards?.cards && Array.isArray(history) && history.length > 0, 'CHOAM Nexus Betrayal requires its physical card and saved history.'); const events = new Set(); const physical = physicalTreacheryCards(g); for (const [index, record] of history.entries()) { nexusRule(() => validateNexusChoamBetrayal(g, record, physical)); requireRule(!events.has(record.event) && (record.stage === 'complete' || index === history.length - 1), 'The CHOAM Nexus Betrayal has a duplicated or unfinished prior discard.'); events.add(record.event); } const last = history.at(-1)!; requireRule(JSON.stringify(g.nexusChoamBetrayalLast) === JSON.stringify({ event: last.event, stage: last.stage }) && (last.stage === 'discard') === !!pending, 'The CHOAM Nexus Betrayal has lost or reopened its committed discard.'); if (pending) requireRule(g.nexusCards.cards.discard.includes('choam'), 'The pending CHOAM Nexus Betrayal has lost its spent card.'); if (pending) requireRule(g.status === 'playing' && last.turn === g.turn && pending.event === last.event && pending.owner === last.owner && pending.target === last.target && pending.card === last.card && pending.handBefore === last.handBefore && getPlayer(g, last.target).hand.length === last.handBefore - 1, 'The CHOAM Nexus Betrayal no longer matches its random discard.'); } function nexusEcazInquiryIntegrity(g: Game) { const history = g.nexusEcazInquiries; if (history === undefined) { requireRule(!g.nexusEcazInquiryLast, 'The Ecaz Nexus inquiry lost its history.'); return; } requireRule(Array.isArray(history) && history.length > 0 && !!g.nexusCards?.cards, 'The Ecaz Nexus inquiry lost its physical card history.'); for (const [index, receipt] of history.entries()) { nexusRule(() => validateEcazInquiry(g, receipt, index)); if (index > 0) { const previous = history[index - 1]; requireRule(previous.turn < receipt.turn || (previous.turn === receipt.turn && previous.phase <= receipt.phase), 'The Ecaz Nexus inquiries changed their original order.'); } } requireRule(g.nexusEcazInquiryLast?.event === history.at(-1)!.event, 'The last Ecaz Nexus inquiry lost its original answer.'); } function nexusEcazDukeIntegrity(g: Game) { const history = g.nexusEcazDukeHistory, events = g.nexusEcazDukeEvents; if (history === undefined) { requireRule(events === undefined, 'Ecaz Nexus Duke lost its saved acquisition history.'); return; } requireRule(Array.isArray(history) && history.length > 0 && !!g.nexusCards?.cards && Array.isArray(events) && events.length === history.length && new Set(events).size === events.length, 'Ecaz Nexus Duke lost its physical card or independent use markers.'); for (const [index, receipt] of history.entries()) { nexusRule(() => validateNexusEcazDuke(g, receipt, index)); requireRule(events[index] === receipt.event, 'Ecaz Nexus Duke acquired from a changed event.'); } if (history.at(-1)!.turn === g.turn) requireRule(g.nexusCards.cards.discard.includes('ecaz'), 'Ecaz Nexus Duke has reopened its spent physical card.'); } function currentNexusChoamTrade(g: Game, owner: string) { return nexusRule(() => quoteNexusChoamTrade(g, owner, g.phase === 7 && grummanCollectionAutomatic(g))); } function emperorBetrayalWindow(g: Game) { const response = g.response, battle = g.battle; return !!(g.nexusCards?.cards && g.status === 'playing' && g.phase === 6 && battle && !battle.revealed && battle.event && response?.kind === 'eliteStrength' && response.owner === byFaction(g, 'emperor')?.id && (response.owner === battle.attacker || response.owner === battle.defender) && !battle.eliteBlocked?.includes(response.owner)); } function nexusEmperorBetrayalOffer(g: Game, owner: string) { const player = g.players.find((p) => p.id === owner); if (g.truthtrance || pendingNexusTraitors(g) || !emperorBetrayalWindow(g) || !player || player.ally || owner === g.response!.owner || g.response!.passed.includes(owner) || g.nexusCards!.cards!.hands[owner] !== 'emperor') return null; return { event: g.battle!.event!, target: g.response!.owner }; } function nexusEmperorBetrayalIntegrity(g: Game) { const history = g.nexusEmperorBetrayalHistory; if (history === undefined) return; requireRule(g.nexusCards?.cards && Array.isArray(history) && history.length > 0, 'Emperor Betrayal has lost its physical card module.'); const events = new Set(); for (const record of history) { requireRule(record && typeof record.event === 'string' && record.event.length > 0 && Number.isInteger(record.turn) && record.turn > 0 && record.turn <= g.turn && typeof record.territory === 'string' && record.territory.length > 0 && record.owner !== record.target && g.players.some(p => p.id === record.owner) && g.players.some(p => p.id === record.target && p.faction === 'emperor') && !events.has(record.event), 'Emperor Betrayal has a malformed or repeated battle receipt.'); events.add(record.event); } const last = history.at(-1)!; if (last.turn === g.turn) requireRule(g.nexusCards.cards.discard.includes('emperor'), 'Emperor Betrayal has lost its spent physical card.'); if (g.battle?.event === last.event) requireRule(g.battle.territory === last.territory && g.battle.eliteBlocked?.includes(last.target), 'The current battle has lost its Emperor Betrayal suppression.'); } function playNexusEmperorBetrayal(g: Game, p: Player, action: Action) { const offer = nexusEmperorBetrayalOffer(g, p.id); requireRule(offer && action.event === offer.event && Object.keys(action).sort().join(',') === 'event,type', 'Choose your current Emperor Sardaukar battle response.'); const response = g.response!; const record = { event: offer.event, turn: g.turn, territory: g.battle!.territory, owner: p.id, target: response.owner }; g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!, p.id, g.players)); (g.nexusEmperorBetrayalHistory ??= []).push(record); finishResponse(g, true); log(g, `${p.name} spent Emperor Nexus Betrayal to suppress ${getPlayer(g, record.target).name}'s Sardaukar strength in this battle. Actual starred counters remain Sardaukar.`, { faction: p.faction, name: 'Emperor Nexus Betrayal' }); } function nexusFremenBetrayalIntegrity(g: Game) { const history = g.nexusFremenBetrayalHistory; const fremen = byFaction(g, 'fremen'); if (history === undefined) { requireRule(fremen?.fremenNexusMovementBlockedTurn === undefined && fremen?.fremenNexusWormBlockedTurn === undefined, 'Fremen Betrayal restrictions require a spent Nexus card receipt.'); return; } requireRule(g.nexusCards?.cards && fremen && Array.isArray(history) && history.length > 0, 'Fremen Betrayal has lost its physical card module.'); const turns = new Set(); let latestMovement: number | undefined; let latestWorm: number | undefined; let lastMode: FremenNexusBetrayal['mode'] = 'movement'; for (const record of history) { const mode = nexusRule(() => validateFremenBetrayal(g, record)); requireRule(!turns.has(record.turn), 'Fremen Betrayal cannot suppress two advantages in one turn.'); turns.add(record.turn); if (mode === 'worm') latestWorm = record.turn; else latestMovement = record.turn; lastMode = mode; } const last = history.at(-1)!; requireRule(fremen.fremenNexusMovementBlockedTurn === latestMovement && fremen.fremenNexusWormBlockedTurn === latestWorm, 'Fremen Betrayal has lost its turn-long restriction.'); if (last.turn === g.turn) { requireRule(lastMode !== 'worm' || (g.nexusCards.phase?.turn === g.turn && !g.wormRides.length && !g.nexusFremenCunningOffer && !g.nexusFremenCunningRides?.some(row => row.occurrence.turn === g.turn && row.stage !== 'complete')), 'Fremen Betrayal cannot leave a current worm ride or Cunning offer.'); if (lastMode === 'movement' || g.nexusCards.phase?.stage === 'spice') requireRule(g.nexusCards.cards.discard.includes('fremen'), 'Fremen Betrayal has lost its spent physical card.'); } } function playNexusFremenBetrayal(g: Game, p: Player, action: Action) { const offer = fremenBetrayalOffer(g, p.id); requireRule(offer && !offer.blocked && action.event === offer.event && Object.keys(action).sort().join(',') === 'event,type', 'Use Fremen Betrayal before its current printed advantage begins.'); const record: FremenNexusBetrayal = { event: offer.event, owner: p.id, target: offer.target, turn: g.turn, phase: offer.mode === 'worm' ? 1 : 5, mode: offer.mode, signature: '', }; record.signature = fremenBetrayalSignature(record); g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!, p.id, g.players)); const fremen = getPlayer(g, offer.target); if (offer.mode === 'worm') fremen.fremenNexusWormBlockedTurn = g.turn; else fremen.fremenNexusMovementBlockedTurn = g.turn; (g.nexusFremenBetrayalHistory ??= []).push(record); log(g, offer.mode === 'worm' ? `${p.name} spent Fremen Nexus Betrayal before the first blow to suppress Fremen worm riding for this turn. Worm appearances and protection still resolve; ordinary and remote rides do not.` : `${p.name} spent Fremen Nexus Betrayal to suppress the Fremen two-territory movement advantage this turn. Independent ornithopters and movement abilities remain available.`, { faction: p.faction, name: 'Fremen Nexus Betrayal' }); } function quoteVoiceCommand(g: Game, target: string, kind: unknown, must: unknown): NonNullable { const occupation = homeworldRule(() => quoteOccupiedHomeworldVoice(g, target)); requireRule(occupation.status === 'allowed', occupation.blocked ?? 'This target cannot receive the Voice.'); requireRule(typeof kind === 'string' && VOICE_KINDS.includes(kind), 'Choose a card type.'); if (kind === 'mirrorWeapon') { const blocked = mirrorWeaponModeBlock(g); requireRule(!blocked, blocked ?? 'Mirror Weapon is unavailable in this battle configuration.'); } requireRule(typeof must === 'boolean', 'Choose require or forbid.'); if ((kind === 'stoneBurner' || kind === 'mirrorWeapon') && must) { const blocked = stoneCompulsionBlock(g, getPlayer(g, target)); requireRule(!blocked, blocked ?? `${kind === 'mirrorWeapon' ? 'Mirror Weapon' : 'Stone Burner'} cannot be compelled in this battle.`); } return { target, kind: kind as NonNullable['kind'], must }; } function nexusBorrowedVoiceIntegrity(g: Game) { const b = g.battle, frame = b?.nexusBorrowedVoice; if (!b || !frame) return; requireRule(b.event && g.nexusCards?.cards && !byFaction(g, 'beneGesserit'), 'Borrowed Voice has lost its absent-faction Nexus module.'); nexusRule(() => validateNexusBorrowedVoice({ turn: g.turn, battle: { event: b.event!, attacker: b.attacker, defender: b.defender }, }, frame)); if (frame.stage === 'choose') { const owner = currentNexusBorrowedVoiceOwner(frame); requireRule(b.preparation?.kind === 'nexusVoice' && b.preparation.owner === owner && b.preparation.beneficiary === owner && !b.voice && !b.prescience && !b.nexusInspection && !Object.keys(b.plans).length, 'The borrowed Voice queue has lost its original pre-inspection preparation.'); } else if (frame.stage !== 'passed') { requireRule(frame.owner && !getPlayer(g, frame.owner).ally && g.nexusCards.cards.discard.includes('beneGesserit'), 'Borrowed Voice has lost its combatant or spent physical card.'); if (frame.stage === 'canceled') requireRule(!b.voice, 'Canceled borrowed Voice cannot regain its command.'); else requireRule(b.voice && frame.voice && b.voice.target === frame.voice.target && b.voice.kind === frame.voice.kind && b.voice.must === frame.voice.must, 'Borrowed Voice has lost or changed its declared command.'); if (frame.stage === 'declared') requireRule(g.response?.kind === 'voice' && g.response.owner === frame.owner, 'Borrowed Voice has lost its original cancellation response.'); } } function nexusBorrowedVoiceOffer(g: Game, id: string) { const b = g.battle, frame = b?.nexusBorrowedVoice; if (!b || !frame || currentNexusBorrowedVoiceOwner(frame) !== id || b.preparation?.kind !== 'nexusVoice') return null; const p = getPlayer(g, id), target = b.attacker === id ? b.defender : b.attacker; const canUse = !p.ally && g.nexusCards?.cards?.hands[id] === 'beneGesserit'; const occupation = homeworldRule(() => quoteOccupiedHomeworldVoice(g, target)); const blocked = g.truthtrance || pendingNexusTraitors(g) || g.response || g.decision || g.pendingKarama || g.pendingTreacheryDiscard || g.pendingNullentropy ? 'Finish the current response before continuing borrowed Voice.' : null; const choices: { kind: NonNullable['kind']; must: boolean }[] = []; if (canUse && occupation.status === 'allowed') for (const kind of VOICE_KINDS) { if (kind === 'mirrorWeapon' && mirrorWeaponModeBlock(g)) continue; choices.push({ kind: kind as NonNullable['kind'], must: false }); if (!['stoneBurner', 'mirrorWeapon'].includes(kind) || !stoneCompulsionBlock(g, getPlayer(g, target))) choices.push({ kind: kind as NonNullable['kind'], must: true }); } return { event: frame.event, owner: id, target, canUse, blocked: blocked ?? (canUse && occupation.status !== 'allowed' ? occupation.blocked ?? 'This target cannot receive the Voice.' : null), choices }; } function playNexusBorrowedVoice(g: Game, p: Player, action: Action) { const offer = nexusBorrowedVoiceOffer(g, p.id), b = g.battle; requireRule(offer && b && action.event === offer.event && !offer.blocked, 'This borrowed Voice opportunity is stale, blocked, or belongs to another combatant.'); requireRule(typeof action.use === 'boolean' && Object.keys(action).every(key => ['type', 'event', 'use', ...(action.use ? ['kind', 'must'] : [])].includes(key)), 'Choose borrowed Voice or continue without a command.'); if (!action.use) { b.nexusBorrowedVoice = nexusRule(() => passNexusBorrowedVoice(b.nexusBorrowedVoice!, p.id)); const next = currentNexusBorrowedVoiceOwner(b.nexusBorrowedVoice); if (next) b.preparation = { kind: 'nexusVoice', owner: next, beneficiary: next }; else battlePreparation(g, 'prescience'); return; } requireRule(offer.canUse, 'Use the held Bene Gesserit Nexus card while unallied.'); const voice = quoteVoiceCommand(g, offer.target, action.kind, action.must); b.nexusBorrowedVoice = nexusRule(() => declareNexusBorrowedVoice(b.nexusBorrowedVoice!, p.id, voice)); g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!, p.id, g.players)); b.voice = voice; battlePreparation(g, 'prescience'); g.response = { kind: 'voice', owner: p.id, passed: [] }; log(g, `${p.name} spent Bene Gesserit Nexus Secret Ally to use the Voice before Prescience. Provisional policy: Karama may cancel the advantage; the Nexus card remains spent.`, { faction: p.faction, name: 'Borrowed Voice' }); } function bgBetrayalWindow(g: Game) { const response = g.response, battle = g.battle; return !!(g.nexusCards?.cards && g.status === 'playing' && g.phase === 6 && battle && !battle.revealed && battle.event && battle.voice && response?.kind === 'voice' && response.owner === byFaction(g, 'beneGesserit')?.id && (battle.voice.target === battle.attacker || battle.voice.target === battle.defender)); } function nexusBgBetrayalOffer(g: Game, owner: string) { const player = g.players.find(p => p.id === owner); if (g.truthtrance || pendingNexusTraitors(g) || !bgBetrayalWindow(g) || !player || player.ally || owner === g.response!.owner || g.response!.passed.includes(owner) || g.nexusCards!.cards!.hands[owner] !== 'beneGesserit') return null; return { event: g.battle!.event!, target: g.response!.owner }; } function nexusBgBetrayalIntegrity(g: Game) { const history = g.nexusBgBetrayalHistory; if (history === undefined) return; requireRule(g.nexusCards?.cards && Array.isArray(history) && history.length > 0, 'Bene Gesserit Betrayal has lost its physical card module.'); const events = new Set(); for (const record of history) { requireRule(record && typeof record.event === 'string' && record.event.length > 0 && Number.isInteger(record.turn) && record.turn > 0 && record.turn <= g.turn && typeof record.territory === 'string' && record.territory.length > 0 && record.owner !== record.target && g.players.some(p => p.id === record.owner) && g.players.some(p => p.id === record.target && p.faction === 'beneGesserit') && record.voice && typeof record.voice.must === 'boolean' && VOICE_KINDS.includes(record.voice.kind) && g.players.some(p => p.id === record.voice.target) && !events.has(record.event), 'Bene Gesserit Betrayal has a malformed or repeated Voice receipt.'); events.add(record.event); } const last = history.at(-1)!; if (last.turn === g.turn) requireRule(g.nexusCards.cards.discard.includes('beneGesserit'), 'Bene Gesserit Betrayal has lost its spent physical card.'); if (g.battle?.event === last.event) requireRule(g.battle.territory === last.territory && !g.battle.voice, 'The current battle has regained its canceled Voice.'); } function playNexusBgBetrayal(g: Game, p: Player, action: Action) { const offer = nexusBgBetrayalOffer(g, p.id); requireRule(offer && action.event === offer.event && Object.keys(action).sort().join(',') === 'event,type', 'Choose your current Bene Gesserit Voice response.'); const record = { event: offer.event, turn: g.turn, territory: g.battle!.territory, owner: p.id, target: g.response!.owner, voice: structuredClone(g.battle!.voice!) }; g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!, p.id, g.players)); (g.nexusBgBetrayalHistory ??= []).push(record); finishResponse(g, true); log(g, `${p.name} spent Bene Gesserit Nexus Betrayal to prevent ${getPlayer(g, record.target).name}'s Voice in this battle. The battle and any later Prescience remain available.`, { faction: p.faction, name: 'Bene Gesserit Nexus Betrayal' }); } /** The public payment window must not depend on who privately holds Emperor Nexus. */ function emperorNexusUniformAuctionPayment(g: Game): boolean { return !!g.nexusCards?.cards && !byFaction(g,'emperor') && emperorNexusModeSupported(g) && !g.ecazTreachery && !g.sandtrout && !!g.auction && !g.currentAuctionSale; } function nexusEmperorSecretAllyOffer(g:Game,owner:string) { const p = g.players.find(p => p.id === owner); if (!p || g.nexusCards?.cards?.hands[owner] !== 'emperor' || byFaction(g,'emperor')) return null; const revival = quoteEmperorNexusRevival(g, owner); const purchasing = g.status === 'playing' && g.phase === 3 && g.decision?.kind === 'auctionPayment' && g.decision.player === owner && g.auction?.bidder === owner && !g.currentAuctionSale; const event = purchasing ? emperorNexusPurchaseEvent(g.turn,owner,g.nexusEmperorPurchaseHistory?.length ?? 0,g.auction!.index,g.auction!.bid) : revival.event; let purchase: {blocked:string|null;price:number}|null = null; if (purchasing) { let purchaseBlock:string|null = null; if (p.ally) purchaseBlock = 'Emperor Nexus Secret Ally requires an unallied buyer.'; else if (!emperorNexusModeSupported(g) || g.ecazTreachery || g.sandtrout) purchaseBlock = 'Emperor Nexus purchase requires a supported classic Nexus profile; unrelated modules remain unavailable.'; else if (g.response || g.truthtrance || g.phaseOpening || g.pendingKarama || g.pendingTreacheryDiscard || g.pendingNullentropy || g.pendingExchange || g.pendingRicheseGift || g.pendingRichesePurchaseIncome || g.pendingAmbassador || g.pendingCapture || g.battle || g.nexusTraitorPending || g.nexusCards?.phase?.stage === 'drawing') purchaseBlock = 'Finish the current interaction before this auction payment.'; else { try { normalKaramaAuction(g,p,true); quoteEmperorNexusPurchase(g.auction!.bid,p.spice,g.auction!.allyPayment ?? 0); } catch (error) { purchaseBlock = error instanceof Error ? error.message : 'The buyer cannot prove the full price.'; } } purchase = {blocked:purchaseBlock,price:g.auction!.bid}; } return {event,revival,purchase}; } function nexusEmperorSecretIntegrity(g:Game) { const history = g.nexusEmperorSecretHistory, events = g.nexusEmperorSecretEvents; if (history === undefined) requireRule(events === undefined,'Emperor Nexus has lost its saved revival history.'); else { requireRule(g.nexusCards?.cards && Array.isArray(history) && history.length > 0 && Array.isArray(events) && events.length === history.length && new Set(events).size === events.length, 'Emperor Nexus has lost its physical module or independent use markers.'); for (const [index,record] of history.entries()) { nexusRule(() => validateEmperorNexusRevival(g,record)); if (!('version' in record)) requireRule(emperorNexusModeSupported(g), 'A legacy Emperor return lost its original supported roster.'); else if (record.stage === 'requested') requireRule(index === history.length - 1 && g.pendingRevival?.nexusEmperorEvent === record.event && g.pendingRevival.player === record.owner, 'The extra-three request lost its original pending revival.'); requireRule(record.sequence === index && events[index] === record.event, 'Emperor Nexus has duplicated or reordered a completed revival.'); } const last = history.at(-1)!; if (last.turn === g.turn) requireRule(g.nexusCards.cards.discard.includes('emperor'), 'Emperor Nexus has reopened its already spent physical card.'); } const purchases = g.nexusEmperorPurchaseHistory, purchaseEvents = g.nexusEmperorPurchaseEvents; if (purchases === undefined) requireRule(purchaseEvents === undefined,'Emperor Nexus has lost its saved purchase history.'); else { requireRule(g.nexusCards?.cards && Array.isArray(purchases) && purchases.length > 0 && Array.isArray(purchaseEvents) && purchases.length === purchaseEvents.length && new Set(purchaseEvents).size === purchaseEvents.length, 'Emperor Nexus purchase has lost its physical module or independent use markers.'); for (const [index,record] of purchases.entries()) { nexusRule(() => validateEmperorNexusPurchase(g,record)); if (!('version' in record)) requireRule(emperorNexusModeSupported(g) && !g.ecazTreachery && !g.sandtrout, 'A normal-bank Emperor purchase lost its original supported profile.'); else if (record.version === 3) requireRule(emperorNexusAmbassadorPurchaseModeSupported(g), 'An Emperor Ambassador purchase lost its original supported native profile.'); else if (record.version === 4) requireRule(g.advanced && emperorNexusSellerPurchaseModeSupported(g), 'An Emperor special purchase lost its original supported native profile.'); requireRule(record.sequence === index && purchaseEvents[index] === record.event, 'Emperor Nexus purchase has duplicated or reordered a completed use.'); } const last = purchases.at(-1)!; if (last.turn === g.turn) requireRule(g.nexusCards.cards.discard.includes('emperor'), 'Emperor Nexus purchase has reopened its already spent physical card.'); } } function playNexusEmperorRevive(g:Game,p:Player,action:Action) { const offer = nexusEmperorSecretAllyOffer(g,p.id); requireRule(offer && !offer.revival.blocked && action.event === offer.event && Object.keys(action).sort().join(',') === 'elite,event,type' && offer.revival.eliteOptions.includes(action.elite as number), offer?.revival.blocked ?? 'Choose the current Emperor Nexus revival and an eligible exact three-force group.'); const record = nexusRule(() => createEmperorNexusRevival(g, p.id, action.elite as number)); g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!,p.id,g.players)); (g.nexusEmperorSecretHistory ??= []).push(record); (g.nexusEmperorSecretEvents ??= []).push(record.event); log(g, `${p.name} spent Emperor Nexus Secret Ally to request exactly three additional forces for free through the original revival continuation. Ordinary force/free allowances are unchanged; native Tleilaxu prevention applies under the provisional independent-extra-return policy.`, {faction:p.faction,name:'Emperor Nexus revival'}); beginRevival(g, { player: p.id, kind: 'forces', amount: EMPEROR_NEXUS_REVIVALS, elite: record.elite, free: EMPEROR_NEXUS_REVIVALS, normalCost: 0, cost: 0, checks: [], nexusEmperorEvent: record.event }); } function finishNexusEmperorRevival(g: Game, revival: PendingRevival, outcome: 'returned' | 'denied') { if (!revival.nexusEmperorEvent) return; const history = g.nexusEmperorSecretHistory; const index = history?.findIndex(record => record.event === revival.nexusEmperorEvent) ?? -1; const record = history?.[index]; requireRule(record && 'version' in record && record.version === 2 && record.stage === 'requested' && record.owner === revival.player, 'The extra-three revival lost its original consumed Nexus source.'); history![index] = nexusRule(() => settleEmperorNexusRevival(g, record, outcome)); if (outcome === 'denied') log(g, `${getPlayer(g, revival.player).name}'s Emperor Nexus extra return was prevented. The Nexus remains spent; no counters, payment, revival income or Tech activity was granted.`); } function playNexusEmperorPurchase(g:Game,p:Player,action:Action) { const offer = nexusEmperorSecretAllyOffer(g,p.id), purchase = offer?.purchase; requireRule(purchase && !purchase.blocked && action.event === offer.event && Object.keys(action).sort().join(',') === 'event,type', purchase?.blocked ?? 'Choose your current Emperor Nexus bank-auction payment.'); const auction = g.auction!; normalKaramaAuction(g,p,true); nexusRule(() => quoteEmperorNexusPurchase(auction.bid,p.spice,auction.allyPayment ?? 0)); const purchased = auction.cards[auction.index], auctionIndex = auction.index; requireRule(purchased && auction.bidder === p.id && g.decision?.kind === 'auctionPayment' && g.decision.player === p.id,'The original winning bank-auction lot is no longer available.'); const beforeSpice = p.spice; g.decision = null; g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!,p.id,g.players)); settleAuction(g,true,false,true); const record:EmperorNexusPurchase = {kind:'purchase',event:offer.event,owner:p.id,turn:g.turn, phase:3,sequence:g.nexusEmperorPurchaseHistory?.length ?? 0,faction:p.faction, advanced:g.advanced,price:auction.bid,auctionIndex,card:purchased.id,beforeSpice,afterSpice:p.spice,signature:''}; record.signature = signEmperorNexusPurchase(record); nexusRule(() => validateEmperorNexusPurchase(g,record)); (g.nexusEmperorPurchaseHistory ??= []).push(record); (g.nexusEmperorPurchaseEvents ??= []).push(record.event); log(g,`${p.name} spent Emperor Nexus Secret Ally after proving the ${record.price}-spice winning bid, bought the Treachery card and kept their own spice. The physical Nexus card was discarded.`, {faction:p.faction,name:'Emperor Nexus purchase'}); } function nexusEcazBetrayalIntegrity(g: Game) { const history = g.nexusEcazBetrayalHistory; if (history === undefined) return; requireRule(Array.isArray(history) && history.length > 0 && g.nexusCards?.cards, 'Ecaz Betrayal has lost its spent card history.'); const events = new Set(); let pending = 0; for (const record of history) { const snapshot = record?.snapshot; requireRule(snapshot && typeof snapshot.event === 'string' && !events.has(snapshot.event) && Number.isSafeInteger(snapshot.turn) && snapshot.turn > 0 && snapshot.turn <= g.turn && Number.isSafeInteger(snapshot.discardIndex) && snapshot.discardIndex >= 0 && g.players.some(player => player.id === snapshot.owner && player.faction !== 'ecaz') && g.players.some(player => player.id === snapshot.ally) && ['pending', 'complete'].includes(record.stage), 'Ecaz Betrayal has lost its unique owner, ally or turn.'); events.add(snapshot.event); if (record.stage === 'pending') { pending++; requireRule(pending === 1 && record === history.at(-1) && ((g.response?.kind === 'nexusEcazBetrayal' && g.response.owner === snapshot.owner && g.response.intent === snapshot.event && g.response.location === snapshot.territory) || (g.pendingKarama?.use.kind === 'cancel' && g.pendingKarama.use.response.kind === 'nexusEcazBetrayal' && g.pendingKarama.use.response.intent === snapshot.event)), 'Ecaz Betrayal has lost its pending Karama response.'); nexusRule(() => validateEcazBetrayalSnapshot(g, snapshot, true)); } else if (snapshot.turn === g.turn) { requireRule(g.nexusCards!.cards!.discard[snapshot.discardIndex] === 'ecaz', 'Ecaz Betrayal has reopened its spent card.'); } } } function nexusFremenCunningIntegrity(g: Game) { const offer = g.nexusFremenCunningOffer; const rides = g.nexusFremenCunningRides; if (offer === undefined && rides === undefined) return; requireRule(g.nexusCards?.cards && (!rides || Array.isArray(rides)), 'Fremen Cunning has lost its physical Nexus module or saved rides.'); const suspended = g.summonedWorm?.resume; if (offer) { const occurrence = offer.occurrence; const decision = g.decision?.kind === 'nexusFremenCunningOffer' ? g.decision : suspended?.decision; requireRule(g.status === 'playing' && g.phase === 1 && occurrence.turn === g.turn && occurrence.initiallyEmpty && occurrence.event && byFaction(g, 'fremen')?.id === offer.owner && decision?.kind === 'nexusFremenCunningOffer' && decision.player === offer.owner && decision.event === occurrence.event, 'The empty worm appearance has lost its original public offer.'); if (occurrence.origin === 'summoned') requireRule( occurrence.parent && g.summonedWorm?.event === occurrence.parent && g.summonedWorm?.territory === occurrence.territory, 'The empty summoned worm has lost its original special Karama parent.', ); } const events = new Set(); for (const ride of rides ?? []) { const { occurrence, authorization, stage } = ride; requireRule(occurrence.initiallyEmpty && occurrence.event && !events.has(occurrence.event) && authorization.event === occurrence.event && authorization.turn === occurrence.turn && occurrence.turn <= g.turn && authorization.owner && getPlayer(g, authorization.owner).faction === 'fremen' && ['pending', 'queued', 'select', 'complete'].includes(stage), 'Fremen Cunning has lost its unique accepted worm appearance.'); requireRule( ['natural', 'additional', 'summoned'].includes(occurrence.origin) && (occurrence.origin !== 'summoned' || (typeof occurrence.parent === 'string' && occurrence.parent.length > 0 && (stage !== 'pending' || (g.summonedWorm?.event === occurrence.parent && g.summonedWorm?.territory === occurrence.territory)))), 'Fremen Cunning has lost its summoned worm parent.', ); events.add(occurrence.event); if (stage === 'complete') continue; requireRule(g.status === 'playing' && g.phase === 1 && occurrence.turn === g.turn && g.nexusCards!.cards!.discard.includes('fremen'), 'A pending Fremen ride has lost its spent physical card.'); if (stage === 'select') { const decision = g.decision?.kind === 'nexusFremenCunningRide' ? g.decision : suspended?.decision; requireRule(decision?.kind === 'nexusFremenCunningRide' && decision.player === authorization.owner && decision.event === occurrence.event, 'Fremen Cunning has lost its current rider choice.'); } if (stage === 'pending') { const controls = [g, suspended, g.pendingKull?.resume]; requireRule(controls.some(control => { const response = control?.response; const karama = control?.pendingKarama; return (response?.kind === 'nexusFremenCunning' && response.owner === authorization.owner && response.intent === occurrence.event) || (karama?.use.kind === 'cancel' && karama.use.response.kind === 'nexusFremenCunning' && karama.use.response.owner === authorization.owner && karama.use.response.intent === occurrence.event); }), 'Fremen Cunning has lost its pending cancellation window.'); } } requireRule(!offer || !events.has(offer.occurrence.event), 'A worm occurrence cannot be both offered and already spent.'); } function migrateFremenNexusRevival(state: Game): Game { const history = state.nexusFremenRevivalHistory; if (!Array.isArray(history)) return state; let adopted: FremenNexusRevival[] | null = null; const owners: string[] = []; for (const [index, record] of history.entries()) { if (record?.version === 2) continue; const next = nexusRule(() => upgradeFremenNexusRevival(state, record)); adopted ??= [...history]; adopted[index] = next; if (state.phase === 4 && next.turn === state.turn) owners.push(next.owner); } if (!adopted) return state; const next = { ...state, nexusFremenRevivalHistory: adopted }; if (owners.length) { const prior = state.revivalRules?.nexusFremen; next.revivalRules = { ...(state.revivalRules ?? newRevivalRules()), nexusFremen: { turn: state.turn, owners: [...new Set([...(prior?.turn === state.turn ? prior.owners : []), ...owners])], } }; } return next; } function nexusFremenRevivalIntegrity(g: Game) { const history = g.nexusFremenRevivalHistory, events = g.nexusFremenRevivalEvents; if (history === undefined) { requireRule(events === undefined && !g.revivalRules?.nexusFremen, 'Fremen Nexus has lost its saved quota history.'); return; } requireRule(g.nexusCards?.cards && Array.isArray(history) && history.length > 0 && Array.isArray(events) && history.length === events.length && new Set(events).size === events.length, 'Fremen Nexus has lost its physical card or independent use markers.'); for (const [index, record] of history.entries()) { nexusRule(() => validateFremenNexusRevival(g, record)); requireRule(record.event === events[index] && (index === 0 || record.turn >= history[index - 1].turn), 'Fremen Nexus has duplicated or reordered a completed return.'); } const last = history.at(-1)!; if (last.turn === g.turn) requireRule(g.nexusCards.cards.discard.includes('fremen'), 'Fremen Nexus has reopened its already spent physical card.'); const permission = g.revivalRules?.nexusFremen; if (permission) requireRule(Number.isSafeInteger(permission.turn) && permission.turn > 0 && permission.turn <= g.turn && Array.isArray(permission.owners) && permission.owners.length > 0 && new Set(permission.owners).size === permission.owners.length && permission.owners.every(owner => history.some(record => record.turn === permission.turn && record.owner === owner)), 'The Fremen quota has lost its actual phase-bound source grant.'); if (g.phase === 4) for (const record of history.filter(entry => entry.turn === g.turn)) { const player = getPlayer(g, record.owner); requireRule(permission?.turn === g.turn && permission.owners.includes(record.owner) && player.revived >= record.revivedAtUse && (player.freeForcesRevived ?? (player.revived === 0 ? 0 : -1)) >= record.freeAtUse, 'The Fremen quota has lost its source permission or actual free-return ledger.'); } } function playNexusFremenRevival(g: Game, p: Player, action: Action) { const offer = fremenNexusRevivalOffer(g, p.id); requireRule(offer && !offer.blocked && action.event === offer.event && Object.keys(action).sort().join(',') === 'event,type', offer?.blocked ?? 'Choose your current Fremen ordinary free-quota grant.'); const record = nexusRule(() => createFremenNexusRevival(g, p.id)); g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!, p.id, g.players)); g.revivalRules ??= newRevivalRules(); const prior = g.revivalRules.nexusFremen; g.revivalRules.nexusFremen = { turn: g.turn, owners: [...(prior?.turn === g.turn ? prior.owners : []), p.id] }; (g.nexusFremenRevivalHistory ??= []).push(record); (g.nexusFremenRevivalEvents ??= []).push(record.event); log(g, `${p.name} spent Fremen Nexus Secret Ally to enable a fixed ordinary free quota of ${FREMEN_NEXUS_FREE_FORCES} for this Revival phase. Use the normal controls for partial and typed returns. Prior free usage counts; paid returns are not refunded. Provisional policy: native limits, prevention and higher rates remain; Recruits does not double this card quota.`, { faction: p.faction, name: 'Fremen Nexus free quota' }); } function playNexusChoamTrade(g: Game, p: Player, action: Action) { const offer = currentNexusChoamTrade(g, p.id); requireRule(Object.keys(action).every(key => ['type', 'event', 'card'].includes(key)) && offer && !offer.blocked && action.event === offer.event && offer.cards.some(card => card.id === action.card), offer?.blocked ?? 'Choose your current CHOAM Nexus trade and one held Worthless card.'); requireRule(Number.isSafeInteger(p.spice) && p.spice >= 0 && Number.isSafeInteger(p.spice + 2), 'The CHOAM Nexus trade needs a valid spice balance.'); const record: NexusChoamTrade = { event: offer.event, turn: g.turn, owner: p.id, card: action.card as string, spiceBefore: p.spice, stage: 'discard', signature: '' }; record.signature = nexusChoamTradeSignature(record); g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!, p.id, g.players)); const card = discard(g, p, record.card); p.spice += 2; (g.nexusChoamTrades ??= []).push(record); g.nexusChoamTradeLast = { event: record.event, stage: record.stage }; stageTreacheryDiscard(g, 'nexus:choamTrade', [{card, discardedBy: p.id, publicFace: true}], {kind: 'nexusChoamTrade', event: record.event, owner: p.id, card: card.id, spiceAfter: p.spice}); log(g, `${p.name} spent CHOAM Nexus Secret Ally and discarded ${card.name} during Spice Collection to receive 2 spice from the bank. Both cards are spent.`, {faction: p.faction, name: 'CHOAM Nexus trade'}); } function playNexusChoamBetrayal(g: Game, p: Player, action: Action) { const offer = quoteNexusChoamBetrayal(g, p.id, g.grummanCollection?.stage === 'waiting' && grummanCollectionAutomatic(g)); requireRule(Object.keys(action).sort().join(',') === 'event,type' && offer && !offer.blocked && action.event === offer.event, offer?.blocked ?? 'Choose the current CHOAM Nexus Betrayal.'); const target = getPlayer(g, offer.target.id); requireRule(target.hand.length > 0, 'CHOAM Betrayal has no legal card to discard.'); const card = target.hand[Math.floor(random() * target.hand.length)]; const record: NexusChoamBetrayal = { event: offer.event, turn: g.turn, phase: g.phase, owner: p.id, target: target.id, card: card.id, handBefore: target.hand.length, stage: 'discard', signature: '', }; record.signature = nexusChoamBetrayalSignature(record); g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!, p.id, g.players)); const discarded = discard(g, target, card.id); (g.nexusChoamBetrayals ??= []).push(record); g.nexusChoamBetrayalLast = { event: record.event, stage: record.stage }; stageTreacheryDiscard(g, 'nexus:choamBetrayal', [{ card: discarded, discardedBy: target.id, publicFace: false }], { kind: 'nexusChoamBetrayal', event: record.event, owner: p.id, target: target.id, card: card.id, handBefore: record.handBefore }); log(g, `${p.name} spent CHOAM Nexus Betrayal. ${target.name} discarded one randomly selected Treachery card without receiving spice.`, { faction: p.faction, name: 'CHOAM Nexus Betrayal' }); } function playNexusEcazInquiry(g: Game, p: Player, action: Action) { const offer = quoteEcazInquiry(g, p.id, g.grummanCollection?.stage === 'waiting' && grummanCollectionAutomatic(g)); requireRule(Object.keys(action).sort().join(',') === 'event,target,type' && offer && !offer.blocked && action.event === offer.event && offer.targets.some(target => target.id === action.target), offer?.blocked ?? 'Choose the current Ecaz Nexus inquiry and a seated player.'); const target = getPlayer(g, action.target as string); const receipt: EcazInquiryReceipt = { event: offer.event, owner: p.id, ownerFaction: p.faction, target: target.id, targetFaction: target.faction, turn: g.turn, phase: g.phase, scope: 'native', answer: ecazInquiryAnswer(p, target), signature: '', }; receipt.signature = ecazInquirySignature(receipt); g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!, p.id, g.players)); (g.nexusEcazInquiries ??= []).push(receipt); g.nexusEcazInquiryLast = { event: receipt.event }; log(g, `${p.name} spent Ecaz Nexus Secret Ally to ask whether ${target.name} holds any of their native leaders as Traitor Cards. The yes/no answer was delivered privately.`, { faction: p.faction, name: 'Ecaz Nexus inquiry' }); } function playNexusEcazDuke(g: Game, p: Player, action: Action) { const offer = nexusRule(() => quoteNexusEcazDuke(g, p.id)); requireRule(offer && !offer.blocked && action.event === offer.event && Object.keys(action).sort().join(',') === 'event,type', offer?.blocked ?? 'Choose the current Ecaz Nexus Duke acquisition.'); const index = g.nexusEcazDukeHistory?.length ?? 0; const receipt = nexusRule(() => createNexusEcazDukeReceipt(g, p.id, index)); g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!, p.id, g.players)); g.dukeVidal = nexusRule(() => acquireDuke(g.dukeVidal!, p.id, g.turn, 'ecazNexus')); nexusRule(() => validateNexusEcazDuke(g, receipt, index)); (g.nexusEcazDukeHistory ??= []).push(receipt); (g.nexusEcazDukeEvents ??= []).push(receipt.event); log(g, `${p.name} spent Ecaz Nexus Cunning to take Duke Prad Vidal for this turn. The shared leader remains physically unique and returns after use or at turn end.`, { faction: p.faction, name: 'Ecaz Nexus Cunning' }); } function playMoritaniBetrayal(g: Game, p: Player, action: Action) { const offer = moritaniBetrayalOffer(g, p.id, g.grummanCollection?.stage === 'waiting' && grummanCollectionAutomatic(g)); requireRule(offer && !offer.blocked && action.event === offer.event && Object.keys(action).sort().join(',') === 'event,token,type' && offer.tokens.some(token => token.id === action.token), offer?.blocked ?? 'Choose a currently placed Terror token.'); const target = offer.tokens.find(token => token.id === action.token)!; g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!, p.id, g.players)); g.moritaniTerror = nexusRule(() => returnTerror(g.moritaniTerror!, target.id, random)); if (g.nexusMoritaniLocations) delete g.nexusMoritaniLocations[target.id]; log(g, `${p.name} spent Moritani Nexus Betrayal to return a hidden Terror token from ${territory(target.territory).name} to Moritani's supply without revealing its face.`, { faction: p.faction, name: 'Moritani Nexus Betrayal' }); } function currentEcazBetrayalResponse(g: Game, response: ResponseWindow) { const record = g.nexusEcazBetrayalHistory?.find(row => row.stage === 'pending' && row.snapshot.event === response.intent); requireRule(response.kind === 'nexusEcazBetrayal' && record && response.owner === record.snapshot.owner && response.location === record.snapshot.territory, 'The Ecaz Betrayal interruption has lost its spent card or territory.'); nexusRule(() => validateEcazBetrayalSnapshot(g, record.snapshot, true)); return record; } function finishEcazBetrayalResponse(g: Game, response: ResponseWindow, canceled: boolean) { const record = currentEcazBetrayalResponse(g, response); const ally = getPlayer(g, record.snapshot.ally); if (!canceled) validateReserveReturnCounters(ally, record.snapshot.total, 'Ecaz Betrayal'); record.stage = 'complete'; if (!canceled) { for (const key of Object.keys(record.snapshot.sectors)) { delete ally.forces[key]; if (ally.elites) delete ally.elites.forces[key]; } ally.reserves += record.snapshot.total.normal + record.snapshot.total.elite; if (ally.elites) ally.elites.reserves += record.snapshot.total.elite; observeOccupation(g); } log(g, canceled ? `Karama prevented the Ecaz Nexus return from ${territory(record.snapshot.territory).name}; the Nexus card remains spent.` : `${ally.name} returned ${record.snapshot.total.normal + record.snapshot.total.elite} forces from ${territory(record.snapshot.territory).name} to reserves under Ecaz Nexus Betrayal.`, { faction: ally.faction, name: 'Ecaz Nexus Betrayal' }); } function playEcazBetrayal(g: Game, p: Player, action: Action) { const offer = ecazBetrayalOffer(g, p.id, g.grummanCollection?.stage === 'waiting' && grummanCollectionAutomatic(g)); requireRule(offer && !offer.blocked && Object.keys(action).sort().join(',') === 'event,territory,type' && typeof action.event === 'string' && typeof action.territory === 'string', offer?.blocked ?? 'Choose one currently shared Ecaz territory.'); const snapshot = nexusRule(() => quoteEcazBetrayal(g, p.id, stringField(action.event), stringField(action.territory))); g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!, p.id, g.players)); (g.nexusEcazBetrayalHistory ??= []).push({ snapshot, stage: 'pending' }); g.response = { kind: 'nexusEcazBetrayal', owner: p.id, intent: snapshot.event, location: snapshot.territory, passed: [] }; log(g, `${p.name} spent Ecaz Nexus Betrayal to require Ecaz’s ally to return all forces from ${territory(snapshot.territory).name}, unless Karama prevents the return.`, { faction: p.faction, name: 'Ecaz Nexus Betrayal' }); } function markNexusOccurred(g: Game) { if (!g.nexusCards) return; const phase = g.nexusCards.phase; requireRule(phase?.turn === g.turn && phase.stage === 'spice', 'A Nexus needs an open Spice Blow phase.'); g.nexusCards.phase = nexusRule(() => markNexusCardOccurred(phase)); } function discardAllianceNexusCards(g: Game, ...players: Player[]) { if (!g.nexusCards?.cards) return; for (const player of players) { if (!g.nexusCards.cards.hands[player.id]) continue; g.nexusCards.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!, player.id, g.players)); log(g, `${player.name} discarded their held Nexus Card to enter the alliance.`, { faction: player.faction, name: 'Nexus alliance discard' }); } } function closeNexusCards(g: Game): boolean { const nexus = g.nexusCards; if (g.phase !== 1 || !nexus) return false; requireRule(nexus.phase?.turn === g.turn && nexus.cards, 'The Nexus phase record is missing.'); if (nexus.phase.stage === 'spice') { nexus.phase = nexusRule(() => closeNexusCardPhase(nexus.phase!, g.players)); if (nexus.phase.stage === 'drawing') log(g, 'Spice Blow and Nexus is ending. Unallied factions may draw a secret Nexus Card, replace their held card, or keep their current position. Alliances are settled.'); } return nexus.phase.stage === 'drawing'; } function decideNexusCard(g: Game, p: Player, action: Action) { const nexus = g.nexusCards; requireRule(g.status === 'playing' && g.phase === 1 && nexus?.cards && nexus.phase?.stage === 'drawing' && action.turn === nexus.phase.turn && action.card === nexus.cards.hands[p.id] && Object.keys(action).sort().join(',') === 'card,choice,ownRedraws,turn,type' && [0, 1, 2].includes(action.ownRedraws as number), 'Choose your current Nexus card opportunity.'); const choice = action.choice as NexusCardChoice; requireRule(nexusCardChoices(nexus.phase, nexus.cards, p.id).includes(choice), 'That Nexus choice is unavailable.'); requireRule(choice !== 'keep' || action.ownRedraws === 0, 'Keeping your card does not draw a replacement.'); if (choice === 'draw') nexus.cards = nexusRule(() => drawNexusCard(nexus.cards!, p.id, g.players, random)); else if (choice === 'replace') nexus.cards = nexusRule(() => replaceNexusCard(nexus.cards!, p.id, g.players, random)); if (choice !== 'keep') { // The player preselects this optional policy knowing their own faction's // printed effect. No private redraw prompt leaks its identity through // whether the public phase waits or advances. At most two redraws are // possible: twelve cards and at most six held cards leave other cards in // the recycled deck after drawing the same own card for a second time. for (let remaining = action.ownRedraws as number; remaining > 0 && nexus.cards.hands[p.id] === p.faction; remaining--) nexus.cards = nexusRule(() => replaceNexusCard(nexus.cards!, p.id, g.players, random)); } nexus.phase = nexusRule(() => finishNexusCardChoice(nexus.phase!, g.players, nexus.cards!, p.id, choice)); log(g, `${p.name} ${choice === 'keep' ? 'finished their Nexus card choice' : choice === 'draw' ? 'drew a secret Nexus Card' : 'discarded a Nexus Card and drew a secret replacement'}.`); if (nexus.phase.stage === 'complete') completePhase(g); } function projectedNexusCards(g: Game, id: string) { const nexus = g.nexusCards; if (!nexus?.cards) return null; return { ...projectNexusCards(nexus.cards, id, g.players), turn: nexus.phase?.turn ?? null, choices: nexusCardChoices(nexus.phase, nexus.cards, id), waiting: nexus.phase?.stage === 'drawing' ? nexus.phase.eligible.filter(owner => !nexus.phase!.done.includes(owner)) : [] }; } function nexusTraitorUniverse(g: Game) { // This describes the printed setup inventory; it never regenerates the deck // from current hands, captured leaders or Face Dancer custody. return withoutEcazLoyalty(traitorDeck(g.players.map(p => ({ leaders: [ ...leaders(p.faction), ...(g.advanced && p.faction === 'choam' ? [createAuditorLeader()] : []), ] })), g.expansions.includes('ix')), g.ecazLoyalty); } function nexusTraitorSnapshot(g: Game): NexusTraitorSnapshot { return { reserve: [...(g.traitorReserve ?? [])], players: g.players.map(p => ({ id: p.id, faction: p.faction, traitors: [...p.traitors], ...(p.faceDancers ? { faceDancers: structuredClone(p.faceDancers) } : {}), })) }; } function pendingNexusTraitors(g: Game) { return g.nexusTraitorExchanges?.find(exchange => exchange.stage === 'return') ?? null; } function nexusTraitorParentSignature(g: Game, event: string) { const continuation = g.pendingTreacheryDiscard?.continuation; const context = continuation && 'resume' in continuation ? continuation.resume : g; const resume = { response: context.response ?? null, decision: context.decision ?? null, pendingKarama: 'pendingKarama' in context ? context.pendingKarama as Game['pendingKarama'] ?? null : null, phaseOpening: 'phaseOpening' in context ? context.phaseOpening as Game['phaseOpening'] ?? null : null }; const battle = g.battle ? Object.fromEntries(Object.entries(g.battle).filter(([key]) => key !== 'truthPromises')) : null; return JSON.stringify({ event, turn: g.turn, phase: g.phase, status: g.status, active: g.active, order: g.order, ready: g.ready, battle, nullentropy: g.pendingNullentropy ?? null, controls: nullentropyParentSignature(g, resume, event) }); } function traitorDeclarationContext(g: Game): TraitorDeclarationContext { const b = g.battle!; return { event: b.event!, attacker: b.attacker, defender: b.defender, plans: b.plans, ...(b.ecazOccupy ? { ecazOccupy: ecazBattleProfile(g)! } : {}), players: g.players.map(({id,faction,ally,traitors}) => ({id,faction,ally,traitors})), heroLeaderIds: physicalTreacheryCards(g).filter(c => c.kind === 'hero').map(c => c.id), }; } function traitorDeclarationIntegrity(g: Game) { const b = g.battle; if (!b) return; if (b.traitorDeclarationVersion === undefined && b.traitorDeclarations === undefined) return; nexusRule(() => validateTraitorDeclarations(traitorDeclarationContext(g), b.traitorCalls, b.traitorDeclarations, b.traitorDeclarationVersion)); } function ensureTraitorDeclarations(g: Game) { const b = g.battle; if (!b?.revealed || b.traitorDeclarationVersion) return; requireRule(b.event, 'This battle predates the durable traitor declaration record.'); b.traitorDeclarationVersion = 1; b.traitorDeclarations = {}; for (const [voter, called] of Object.entries(b.traitorCalls)) if (called) b.traitorDeclarations[voter] = nexusRule(() => createTraitorDeclaration(traitorDeclarationContext(g), voter)); } function nexusTraitorIntegrity(g: Game) { const history = g.nexusTraitorExchanges; if (!history) { requireRule(!g.nexusTraitorPending && !g.nexusTraitorParent, 'The pending Nexus exchange has lost its physical draw record.'); return; } requireRule(g.nexusCards?.cards && Array.isArray(history), 'Nexus exchanges require the original card module.'); const universe = nexusTraitorUniverse(g); nexusRule(() => validateNexusTraitorSnapshot(nexusTraitorSnapshot(g), universe)); const events = new Set(); for (const exchange of history) { requireRule(!events.has(exchange.event) && exchange.turn <= g.turn, 'Nexus exchange history is duplicated or belongs to a future turn.'); events.add(exchange.event); if (exchange.stage === 'complete') nexusRule(() => validateNexusTraitorHistory(universe, exchange)); else requireRule(exchange.stage === 'return', 'Invalid Nexus exchange progress.'); } const pending = history.filter(exchange => exchange.stage === 'return'); requireRule(pending.length <= 1 && (pending[0]?.event ?? null) === (g.nexusTraitorPending ?? null), 'The Nexus exchange pending marker contradicts its draw history.'); if (pending.length) { const exchange = pending[0]; requireRule(g.status === 'playing' && exchange === history.at(-1) && exchange.turn === g.turn && exchange.phase === g.phase && g.nexusCards.cards.discard.includes('harkonnen'), 'The pending Nexus return has lost its turn, phase or spent card.'); nexusRule(() => validateNexusTraitorExchange(nexusTraitorSnapshot(g), universe, exchange)); requireRule(g.nexusTraitorParent?.event === exchange.event && g.nexusTraitorParent.signature === nexusTraitorParentSignature(g, exchange.event), 'The Nexus exchange has lost or changed its preceding game decision.'); } else requireRule(!g.nexusTraitorParent, 'A completed Nexus return cannot retain a suspended decision.'); traitorDeclarationIntegrity(g); } function nexusTraitorOffer(g: Game, id: string) { if (g.nexusCards?.cards?.hands[id] !== 'harkonnen') return null; const player = getPlayer(g, id), mode = nexusCardMode('harkonnen', player.faction, g.players.map(p => p.faction)); let blocked: string | null = null; if (g.status !== 'playing') blocked = 'Nexus effects belong to the started game.'; else if (player.ally) blocked = 'Allied players cannot retain or play a Nexus card.'; else if (g.truthtrance) blocked = 'Finish the active Truthtrance first.'; else if (g.nexusCards.phase?.stage === 'drawing') blocked = 'Finish the closing Nexus draws first.'; else if (pendingNexusTraitors(g)) blocked = 'Finish the current Nexus return first.'; else if (mode === 'betrayal') blocked = 'Betrayal reactions await the private response timing decision.'; else if (mode === 'secretAlly' && g.phase !== 8) blocked = 'Use Secret Ally during Mentat Pause.'; const draw = mode === 'secretAlly' ? 2 : 1; return { event: JSON.stringify(['nexusTraitors', g.turn, g.phase, g.nexusTraitorExchanges?.length ?? 0, id]), mode, draw, blocked }; } function applyNexusTraitorSnapshot(g: Game, snapshot: NexusTraitorSnapshot) { g.traitorReserve = [...snapshot.reserve]; for (const entry of snapshot.players) { const player = getPlayer(g, entry.id); player.traitors = [...entry.traitors]; if (entry.faceDancers) player.faceDancers = structuredClone(entry.faceDancers); } } function playNexusTraitorDraw(g: Game, p: Player, action: Action) { const offer = nexusTraitorOffer(g, p.id); requireRule(offer && !offer.blocked && offer.mode !== 'betrayal' && action.event === offer.event && action.mode === offer.mode && Object.keys(action).sort().join(',') === 'event,mode,type', offer?.blocked ?? 'This Nexus Traitor draw is stale or belongs to another player.'); const result = nexusRule(() => beginNexusTraitorExchange(nexusTraitorSnapshot(g), nexusTraitorUniverse(g), { event: offer.event, owner: p.id, mode: offer.mode as 'cunning' | 'secretAlly', turn: g.turn, phase: g.phase, })); ensureTraitorDeclarations(g); g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!, p.id, g.players)); applyNexusTraitorSnapshot(g, result.state); (g.nexusTraitorExchanges ??= []).push(result.exchange); g.nexusTraitorPending = result.exchange.event; g.nexusTraitorParent = { event: result.exchange.event, signature: nexusTraitorParentSignature(g, result.exchange.event) }; log(g, `${p.name} played the Harkonnen Nexus Card: ${offer.mode === 'cunning' ? 'Cunning' : 'Secret Ally'}. They drew ${offer.draw} private ${p.faction === 'tleilaxu' ? 'Face Dancer' : 'Traitor'} ${offer.draw === 1 ? 'Card' : 'Cards'} and must choose ${offer.draw} to return before play continues.`, { faction: p.faction, name: 'Harkonnen Nexus' }); } function finishNexusTraitorReturn(g: Game, p: Player, action: Action) { const pending = pendingNexusTraitors(g); requireRule(pending?.owner === p.id && action.event === pending.event && Object.keys(action).sort().join(',') === 'cards,event,type' && Array.isArray(action.cards), 'This Nexus return is stale or belongs to another player.'); const result = nexusRule(() => finishNexusTraitorExchange(nexusTraitorSnapshot(g), nexusTraitorUniverse(g), pending, action.cards as string[], random)); applyNexusTraitorSnapshot(g, result.state); g.nexusTraitorExchanges![g.nexusTraitorExchanges!.length - 1] = result.exchange; g.nexusTraitorPending = null; g.nexusTraitorParent = null; log(g, `${p.name} secretly returned ${result.exchange.returned.length} ${p.faction === 'tleilaxu' ? 'Face Dancer' : 'Traitor'} ${result.exchange.returned.length === 1 ? 'Card' : 'Cards'}. The Traitor Deck was shuffled; their other cards and any prior traitor declaration remain unchanged.`); } function projectedNexusTraitors(g: Game, id: string) { if (!g.nexusCards?.cards) return null; const pending = pendingNexusTraitors(g); const owner = pending ? getPlayer(g, pending.owner) : null; return { offer: nexusTraitorOffer(g, id), pending: pending ? { event: pending.event, owner: pending.owner, mode: pending.mode, count: pending.drawn.length, choices: pending.owner !== id ? [] : (owner!.faction === 'tleilaxu' ? owner!.faceDancers!.filter(card => !card.revealed).map(card => ({id:card.leader, revealed:card.revealed, drawn:pending.drawn.includes(card.leader)})) : owner!.traitors.map(card => ({id:card, revealed:false, drawn:pending.drawn.includes(card)}))), } : null }; } function nexusFaceDancerIntegrity(g: Game) { if (g.nexusFaceDancerHistory === undefined) return; requireRule(g.nexusCards?.cards && Array.isArray(g.nexusFaceDancerHistory), 'Face Dancer Nexus history requires its original module.'); const universe = nexusTraitorUniverse(g), events = new Set(); nexusRule(() => validateNexusTraitorSnapshot(nexusTraitorSnapshot(g), universe)); for (const receipt of g.nexusFaceDancerHistory) { requireRule(!events.has(receipt.event) && receipt.turn <= g.turn && g.players.some(p => p.id === receipt.owner && p.faction === 'tleilaxu'), 'The Face Dancer replacement has lost its original owner or turn.'); events.add(receipt.event); nexusRule(() => validateNexusFaceDancerHistory(universe, receipt)); } } function projectedNexusTleilaxu(g: Game, id: string) { if (!g.nexusCards?.cards) return null; if (g.nexusCards.cards.hands[id] !== 'tleilaxu') return { cunning: null, secretAlly: null }; const p = getPlayer(g, id); if (p.faction !== 'tleilaxu') return { cunning: null, secretAlly: byFaction(g,'tleilaxu')?null:tleilaxuSecretAllyOptions(g,p) }; const count = p.faceDancers?.filter(card => card.revealed).length ?? 0; let blocked: string | null = null; if (g.status !== 'playing') blocked = 'Use Cunning during an active game.'; else if (p.ally) blocked = 'Allied players cannot use a Nexus card.'; else if (g.truthtrance) blocked = 'Finish Truthtrance before replacing Face Dancers.'; else if (g.pendingTreacheryDiscard) blocked = 'Finish the automatic card disposal first.'; else if (g.nexusCards.phase?.stage === 'drawing') blocked = 'Finish the closing Nexus draws first.'; else if (pendingNexusTraitors(g)) blocked = 'Finish the current private Traitor Deck return first.'; else if (!count) blocked = 'No revealed Face Dancers are available to replace.'; else if ((g.traitorReserve?.length ?? 0) < count) blocked = 'The Traitor Deck cannot supply the full replacement draw.'; return { cunning: { event: JSON.stringify(['nexusFaceDancers', g.turn, g.phase, g.nexusFaceDancerHistory?.length ?? 0, id]), count, blocked }, secretAlly: null }; } function playNexusFaceDancers(g: Game, p: Player, action: Action) { requireRule(Object.keys(action).every(key => ['type', 'event'].includes(key)), 'Cunning replaces every revealed Face Dancer; do not select a subset.'); const offer = projectedNexusTleilaxu(g, p.id)?.cunning; requireRule(offer && action.event === offer.event && !offer.blocked, offer?.blocked ?? 'Choose the current owned Face Dancer replacement.'); const result = nexusRule(() => replaceNexusFaceDancers(nexusTraitorSnapshot(g), nexusTraitorUniverse(g), { event: offer.event, owner: p.id, turn: g.turn, phase: g.phase }, random)); g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!, p.id, g.players)); applyNexusTraitorSnapshot(g, result.state); (g.nexusFaceDancerHistory ??= []).push(result.receipt); log(g, `${p.name} set aside ${offer.count} revealed Face Dancer ${offer.count === 1 ? 'Card' : 'Cards'}, secretly drew replacements, then shuffled the set-aside cards into the Traitor Deck. Unrevealed Face Dancers and the normal Mentat replacement allowance are unchanged.`, { faction: p.faction, name: 'Nexus Face Dancer replacement' }); } function playNexusTleilaxuRevival(g: Game, p: Player, action: Action) { requireRule(Object.keys(action).every(key=>['type','event','leader','amount','elite'].includes(key)), 'Use only the owned Nexus revival selection; its price and source are authoritative.'); const offer=projectedNexusTleilaxu(g,p.id)?.secretAlly; requireRule(offer&&!offer.blocked&&action.event===offer.event, offer?.blocked??'Choose the current Tleilaxu Secret Ally offer.'); const quote=nexusRule(()=>quoteTleilaxuSecretAlly(g,p,{ leader:stringField(action.leader),amount:integer(action.amount,0,5,'Forces'), elite:integer(action.elite,0,5,'Elite forces'), })); const group={amount:quote.amount,elite:quote.elite,free:0}; const grant=quote.amount>0?requireHomeworldRevivalGrant(g,p,'nexusTleilaxu',group):null; const ordinary=leaderRevivalOptions(g,p); const payment=g.spiceBankerIncomePreview&"e.cost>0 ?bankerIncomePayment(g,'force-revival',[{payer:p.id,amount:quote.cost,recipient:'bank'}]):undefined; g.nexusCards!.cards=nexusRule(()=>discardNexusCard(g.nexusCards!.cards!,p.id,g.players)); g.revivalRules??=newRevivalRules(); (g.revivalRules.nexusTleilaxu??=[]).push(p.id); if(quote.kind!=='duke'||ordinary.cycle>p.revivalCycle)p.revivalCycle=ordinary.cycle; // The card quote establishes a free own return; the original mutation keeps // Kwisatz/Duke custody, one-leader usage and skill revival lifecycle. finishRevival(g,{ pending:{player:p.id,kind:quote.kind==='kwisatz'?'kwisatz':'leader', ...(quote.kind==='kwisatz'?{}:{leader:quote.leader}),normalCost:0,cost:0,free:0,checks:[]}, outcome:'revive', }); if(quote.amount>0) { p.spice-=quote.cost; addNativeReserves(g,p,quote.amount,quote.elite); p.tanks-=quote.amount; if(p.freeForcesRevived!==undefined||p.revived===0)p.freeForcesRevived??=0; p.revived+=quote.amount; if(p.elites){p.elites.tanks-=quote.elite;p.elites.revived+=quote.elite} if(payment)awardBankerIncome(g,payment); stageHomeworldRevivalReturn(g,p,'nexusTleilaxu',group,grant); } log(g,`${p.name} spent Tleilaxu Nexus Secret Ally: one own leader returned free and ${quote.amount} physical forces returned for ${quote.cost} bank spice. The ordinary leader slot and shared five-force allowance apply.`, {faction:p.faction,name:'Tleilaxu Nexus revival'}); } function nexusSuboidIntegrity(g: Game) { if (g.nexusSuboidHistory === undefined) { requireRule(!g.nexusSuboidLast, 'The Suboid Nexus effect has lost its saved history.'); return; } requireRule(g.nexusCards?.cards, 'Suboid Nexus history requires its original module.'); nexusRule(() => validateNexusSuboids(g, g.nexusSuboidHistory!)); const last = g.nexusSuboidHistory.at(-1); requireRule(last && JSON.stringify(g.nexusSuboidLast) === JSON.stringify({ event: last.event, owner: last.owner, turn: last.turn }), 'The Suboid Nexus effect has lost its latest committed play.'); if (g.nexusSuboidHistory.some(receipt => receipt.turn === g.turn)) requireRule(g.nexusCards.cards.discard.includes('ixians'), 'The active Suboid effect has lost its spent Nexus card.'); } function projectedNexusSuboids(g: Game, id: string) { if (!g.nexusCards?.cards) return null; const ixians = byFaction(g, 'ixians'); const active = !!ixians && nexusSuboidsActive(g, g.nexusSuboidHistory, ixians.id); if (g.nexusCards.cards.hands[id] !== 'ixians' || ixians?.id !== id) return { offer: null, active }; const b = g.battle; let blocked: string | null = null; if (g.status !== 'playing' || g.phase !== 6 || !b || ![b.attacker, b.defender].includes(id)) blocked = 'Use Cunning in your battle before submitting your Battle Plan.'; else if (ixians.ally) blocked = 'Allied players cannot use a Nexus card.'; else if (b.revealed || b.plans[id]) blocked = 'Your Battle Plan is already submitted.'; else if (active) blocked = 'Your Suboids already have full strength this turn.'; else if (g.truthtrance || g.pendingTreacheryDiscard || pendingNexusTraitors(g)) blocked = 'Finish the current private question or card return first.'; else if (g.pendingNullentropy) blocked = 'Finish the paid Nullentropy search first.'; else if (g.nexusCards.phase?.stage === 'drawing') blocked = 'Finish the closing Nexus draws first.'; const event = b?.event ? JSON.stringify(['nexusSuboids', g.turn, b.event, id]) : ''; if (!blocked) { const trial = structuredClone(g); (trial.nexusSuboidHistory ??= []).push(nexusRule(() => createNexusSuboids(trial, id, b!.event!))); trial.nexusSuboidLast = { event, owner: id, turn: g.turn }; trial.nexusCards!.cards = nexusRule(() => discardNexusCard(trial.nexusCards!.cards!, id, trial.players)); if (!findReachableBattlePlan(trial, getPlayer(trial, id))) blocked = 'Full-strength Suboids would prevent you from honoring an existing Battle Plan commitment.'; } return { offer: { event, blocked }, active }; } function playNexusSuboids(g: Game, p: Player, action: Action) { const offer = projectedNexusSuboids(g, p.id)?.offer; requireRule(Object.keys(action).every(key => ['type', 'event'].includes(key)) && offer && action.event === offer.event && !offer.blocked, offer?.blocked ?? 'Choose your current Ixian Cunning opportunity.'); commitNexusSuboids(g, p); } function commitNexusSuboids(g: Game, p: Player) { const receipt = nexusRule(() => createNexusSuboids(g, p.id, g.battle!.event!)); g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!, p.id, g.players)); (g.nexusSuboidHistory ??= []).push(receipt); g.nexusSuboidLast = { event: receipt.event, owner: receipt.owner, turn: receipt.turn }; log(g, `${p.name} used Ixian Cunning. Every Suboid has full strength without spice support in all battles for the rest of this turn. Cyborg strength, support and physical force counts are unchanged.`, { faction: p.faction, name: 'Nexus Suboid strength' }); } function nexusAdvisorFrame(g: Game, owner: string) { const p = getPlayer(g, owner); return JSON.stringify([g.status, g.turn, g.phase, g.active, g.movementRemaining, p.shipped, p.moved, g.hajr, g.guildTimingGranted, g.guildTimingLocked]); } function nexusAdvisorRecordSignature(record: NonNullable[number]) { return JSON.stringify([record.receipt.signature, record.stage, record.frame]); } function pendingNexusAdvisors(g: Game) { return g.nexusAdvisorHistory?.find(record => record.stage === 'pending') ?? null; } function validateNexusAdvisorResponse(g: Game, response: ResponseWindow) { const record = pendingNexusAdvisors(g); requireRule(record && response.kind === 'nexusAdvisorFlip' && response.owner === record.receipt.owner && response.intent === record.receipt.event && Object.keys(response).every(key => ['kind', 'owner', 'intent', 'passed'].includes(key)) && Array.isArray(response.passed) && new Set(response.passed).size === response.passed.length && response.passed.every(id => g.players.some(p => p.id === id)), 'The Nexus advisor conversion has lost its original response.'); requireRule(g.status === 'playing' && g.phase === 5 && g.active === record.receipt.owner && record.receipt.turn === g.turn && record.frame === nexusAdvisorFrame(g, record.receipt.owner), 'The Nexus advisor conversion has lost its original Shipment and Movement action.'); nexusRule(() => validateNexusAdvisors(g, record.receipt, true)); } function nexusAdvisorIntegrity(g: Game) { const continuation = g.pendingTreacheryDiscard?.continuation; const contexts = [g, g.pendingExchange, g.pendingNullentropy?.resume, g.pendingRicheseGift?.resume, g.pendingRichesePurchaseIncome?.resume, g.summonedWorm?.resume,g.bureaucratPayments?.pending?.resume, g.pendingKull?.resume, continuation && 'resume' in continuation ? continuation.resume : null]; const responses = contexts.flatMap(context => { const pending = context && 'pendingKarama' in context ? context.pendingKarama as Game['pendingKarama'] : null; return [context?.response, pending?.use.kind === 'cancel' ? pending.use.response : null]; }).filter(response => response?.kind === 'nexusAdvisorFlip'); const history = g.nexusAdvisorHistory; if (history === undefined) { requireRule(!g.nexusAdvisorLast && !responses.length, 'The Nexus advisor conversion has lost its saved history.'); return; } requireRule(g.nexusCards?.cards && Array.isArray(history) && history.length > 0, 'Nexus advisor history requires its original module and played card.'); const turns = new Set(); for (const [index, record] of history.entries()) { requireRule(record && record.receipt && typeof record.receipt === 'object' && Object.keys(record).sort().join(',') === 'frame,receipt,signature,stage' && ['pending', 'completed', 'canceled'].includes(record.stage) && typeof record.frame === 'string' && record.signature === nexusAdvisorRecordSignature(record) && record.receipt.event === JSON.stringify(['nexusAdvisors', record.receipt.turn, record.receipt.owner, index]) && !turns.has(record.receipt.turn) && (record.stage !== 'pending' || index === history.length - 1), 'The Nexus advisor conversion history is malformed.'); nexusRule(() => validateNexusAdvisors(g, record.receipt, record.stage === 'pending')); turns.add(record.receipt.turn); } const last = history.at(-1)!; requireRule(JSON.stringify(g.nexusAdvisorLast) === JSON.stringify({event: last.receipt.event, stage: last.stage}), 'The Nexus advisor conversion has lost its latest outcome.'); requireRule((last.stage === 'pending') === (responses.length > 0), 'The Nexus advisor conversion has lost or reopened its cancellation response.'); if (last.receipt.turn === g.turn) requireRule(g.nexusCards.cards.discard.includes('beneGesserit'), 'The advisor conversion has lost its spent Nexus card.'); for (const response of responses) validateNexusAdvisorResponse(g, response!); } function projectedNexusAdvisors(g: Game, id: string) { if (!g.nexusCards?.cards) return null; const record = pendingNexusAdvisors(g); const pending = record ? {owner: record.receipt.owner, event: record.receipt.event, territories: record.receipt.selections.map(selection => selection.territory)} : null; const p = getPlayer(g, id); if (p.faction !== 'beneGesserit' || g.nexusCards.cards.hands[id] !== 'beneGesserit') return {offer: null, pending}; let blocked: string | null = null; if (!g.advanced) blocked = 'Advisors are available only in the Advanced game.'; else if (g.status !== 'playing' || g.phase !== 5 || g.active !== id) blocked = 'Use Cunning during your own Shipment and Movement action.'; else if (p.ally) blocked = 'Allied players cannot use a Nexus card.'; else if (g.truthtrance || g.response || g.decision || g.phaseOpening || g.pendingKarama || g.pendingTreacheryDiscard || g.pendingNullentropy || pendingNexusTraitors(g) || record) blocked = 'Finish the current interaction before converting advisors.'; else if (g.nexusCards.phase?.stage === 'drawing') blocked = 'Finish the closing Nexus draws first.'; const territories = nexusRule(() => quoteNexusAdvisors(g, id)).territories; if (!blocked && !territories.some(group => !group.blocked)) blocked = 'No advisor group is currently available to convert.'; return {offer: {event: JSON.stringify(['nexusAdvisors', g.turn, id, g.nexusAdvisorHistory?.length ?? 0]), blocked, territories}, pending}; } function playNexusAdvisors(g: Game, p: Player, action: Action) { const offer = projectedNexusAdvisors(g, p.id)?.offer; requireRule(Object.keys(action).every(key => ['type', 'event', 'territories'].includes(key)) && offer && action.event === offer.event && !offer.blocked, offer?.blocked ?? 'Choose your current Nexus advisor conversion.'); requireRule(Array.isArray(action.territories) && action.territories.every(t => typeof t === 'string'), 'Select whole advisor territories.'); const receipt = nexusRule(() => createNexusAdvisors(g, p.id, offer.event, action.territories as string[])); const record = {receipt, stage: 'pending' as const, frame: nexusAdvisorFrame(g, p.id), signature: ''}; record.signature = nexusAdvisorRecordSignature(record); g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!, p.id, g.players)); (g.nexusAdvisorHistory ??= []).push(record); g.nexusAdvisorLast = {event: receipt.event, stage: record.stage}; g.response = {kind: 'nexusAdvisorFlip', owner: p.id, intent: receipt.event, passed: []}; log(g, `${p.name} spent Bene Gesserit Nexus Cunning to convert all advisors in ${receipt.selections.map(s => territory(s.territory).name).join(', ')}. Karama may prevent this entire conversion. Provisional card exception: fresh and storm-bound advisors may change stance in place; ordinary movement and battle restrictions remain.`, {faction: p.faction, name: 'Nexus advisor conversion declared'}); } function finishNexusAdvisors(g: Game, canceled: boolean) { const record = pendingNexusAdvisors(g)!; const p = getPlayer(g, record.receipt.owner); if (!canceled) for (const selection of record.receipt.selections) delete p.advisors![selection.territory]; record.stage = canceled ? 'canceled' : 'completed'; record.signature = nexusAdvisorRecordSignature(record); g.nexusAdvisorLast = {event: record.receipt.event, stage: record.stage}; log(g, canceled ? 'Karama prevented the entire Nexus advisor conversion. Every selected group remains advisors; the Nexus card stays spent.' : `${p.name} converted every advisor in ${record.receipt.selections.map(s => territory(s.territory).name).join(', ')} to fighters. The forces remain in place and no shipment, movement or spice was spent.`, {faction: p.faction, name: canceled ? 'Nexus advisor conversion prevented' : 'Nexus advisors become fighters'}); } function currentNexusSardaukar(g: Game) { return g.battle?.event ? g.nexusSardaukarHistory?.find(record => record.receipt.battle === g.battle!.event) ?? null : null; } function nexusSardaukarEffective(g: Game, id: string, to: string) { const record = currentNexusSardaukar(g); return record && record.receipt.owner === id && record.receipt.territory === to && record.stage === 'active' ? record.receipt.count : 0; } function nexusSardaukarParent(g: Game) { const b = g.battle!; return JSON.stringify([b.attacker, b.defender, b.territory, b.preparation ?? null, b.powerChecks ?? null, b.preLeader ?? null, b.plans]); } function nexusSardaukarSignature(record: NonNullable[number]) { return JSON.stringify([record.receipt.signature, record.stage, record.parent, record.casualties ?? null]); } function validateNexusSardaukarResponse(g: Game, response: ResponseWindow) { const record = currentNexusSardaukar(g), b = g.battle; requireRule(record?.stage === 'pending' && b && !b.revealed && record.receipt.turn === g.turn && record.receipt.territory === b.territory && response.kind === 'nexusSardaukar' && response.owner === record.receipt.owner && response.intent === record.receipt.event && Object.keys(response).every(key => ['kind', 'owner', 'intent', 'passed'].includes(key)) && Array.isArray(response.passed) && new Set(response.passed).size === response.passed.length && response.passed.every(id => g.players.some(p => p.id === id)) && record.parent === nexusSardaukarParent(g), 'The Nexus Sardaukar response has lost its original battle and preparation.'); const p = getPlayer(g, record.receipt.owner), other = getPlayer(g, b.attacker === p.id ? b.defender : b.attacker); const forces = combatForces(g, p, b.territory, other); requireRule(forces.normal === record.receipt.normal && forces.elite === 0, 'The pending Nexus Sardaukar declaration has lost its original eligible forces.'); } function nexusSardaukarIntegrity(g: Game) { const continuation = g.pendingTreacheryDiscard?.continuation; const contexts = [g, g.pendingExchange, g.pendingNullentropy?.resume, g.pendingRicheseGift?.resume, g.pendingRichesePurchaseIncome?.resume, g.summonedWorm?.resume, g.pendingKull?.resume, continuation && 'resume' in continuation ? continuation.resume : null]; const responses = contexts.flatMap(context => { const pending = context && 'pendingKarama' in context ? context.pendingKarama as Game['pendingKarama'] : null; return [context?.response, pending?.use.kind === 'cancel' ? pending.use.response : null]; }).filter(response => response?.kind === 'nexusSardaukar'); const history = g.nexusSardaukarHistory; if (history === undefined) { requireRule(!g.nexusSardaukarLast && !g.battle?.nexusSardaukarUsed && !g.lastBattleContext?.nexusSardaukarCasualties && !responses.length, 'The Nexus Sardaukar use has lost its saved history.'); return; } requireRule(g.advanced && g.nexusCards?.cards && Array.isArray(history) && history.length > 0, 'Nexus Sardaukar history requires its original Advanced module.'); const battles = new Set(), turns = new Set(); for (const [index, record] of history.entries()) { requireRule(record && record.receipt && typeof record.receipt === 'object' && Object.keys(record).sort().join(',') === (record.casualties ? 'casualties,parent,receipt,signature,stage' : 'parent,receipt,signature,stage') && ['pending', 'active', 'canceled'].includes(record.stage) && typeof record.parent === 'string' && record.signature === nexusSardaukarSignature(record) && !battles.has(record.receipt.battle) && !turns.has(record.receipt.turn) && (record.stage !== 'pending' || index === history.length - 1), 'The Nexus Sardaukar battle history is malformed.'); nexusRule(() => validateNexusSardaukar(g, record.receipt)); const losses = record.casualties; if (losses) { requireRule(record.stage === 'active' && ['pending','complete'].includes(losses.outcome) && Object.keys(losses).sort().join(',') === 'dial,forces,options,outcome,support' && validCombatForces(losses.forces) && losses.forces.temporaryElite === 5 && Array.isArray(losses.options) && losses.options.length > 0 && JSON.stringify(losses.options) === JSON.stringify(casualtyOptions(losses.forces,losses.dial,losses.support)), 'The Nexus Sardaukar casualties have lost their committed force allocation.'); if (losses.outcome === 'pending') { const context = g.lastBattleContext; requireRule(!g.battle && context?.event === record.receipt.battle && context.result === 'normal' && context.winner === record.receipt.owner && context.nexusSardaukarCasualties === record.receipt.event && context.territory === record.receipt.territory && context.turn === record.receipt.turn, 'The pending Sardaukar casualties have lost their completed battle.'); const pool = combatArmy(g,record.receipt.owner,record.receipt.territory); requireRule(pool.normal === losses.forces.normal && pool.elite === losses.forces.elite, 'The pending Sardaukar casualties have lost their exact physical army.'); const decisions = homeworldSavedDecisions(g).filter(d => d.kind === 'battleLosses' && d.player === record.receipt.owner); const committed = continuation?.kind === 'battleResolved' && continuation.event === record.receipt.battle ? continuation.casualties : null; const rescue = g.pendingSukRescue?.event === record.receipt.battle ? g.pendingSukRescue : null; requireRule(decisions.length > 0 || committed || rescue?.losses, 'The Nexus Sardaukar battle has lost its casualty choice.'); if (rescue) requireRule(rescue.player === record.receipt.owner && rescue.turn === record.receipt.turn && rescue.territory === record.receipt.territory && JSON.stringify(rescue.commitment) === JSON.stringify({forces:losses.forces,dial:losses.dial,support:losses.support,options:losses.options}) && (!rescue.losses || losses.options.some(option => JSON.stringify(option) === JSON.stringify(rescue.losses))), 'The Suk rescue differs from the committed Nexus Sardaukar casualties.'); for (const decision of decisions) requireRule(decision.kind === 'battleLosses' && decision.territory === record.receipt.territory && JSON.stringify(decision.options) === JSON.stringify(losses.options), 'The casualty choices differ from the committed Nexus Sardaukar plan.'); if (committed) requireRule(JSON.stringify(committed) === JSON.stringify({forces:losses.forces,dial:losses.dial,support:losses.support,options:losses.options}), 'The suspended Sardaukar casualty allocation was changed.'); if (g.homeworldBattleLoss) requireRule(JSON.stringify(g.homeworldBattleLoss.commitment) === JSON.stringify({forces:losses.forces,dial:losses.dial,support:losses.support}), 'The Homeworld Sardaukar casualties have lost their effective combat roles.'); } } battles.add(record.receipt.battle); turns.add(record.receipt.turn); } const last = history.at(-1)!; requireRule(JSON.stringify(g.nexusSardaukarLast) === JSON.stringify({event: last.receipt.event, stage: last.stage}), 'The Nexus Sardaukar use has lost its latest saved outcome.'); if (last.receipt.turn === g.turn) requireRule(g.nexusCards.cards.discard.includes('emperor'), 'The Nexus Sardaukar use has lost its spent card.'); const record = currentNexusSardaukar(g), b = g.battle; requireRule((record?.stage === 'pending') === (responses.length > 0) && (last.stage !== 'pending' || record === last), 'The Nexus Sardaukar use has lost or reopened its response.'); if (b) requireRule(b.nexusSardaukarUsed === record?.receipt.event, 'The battle has lost its original Nexus Sardaukar marker.'); if (record) { requireRule(g.phase === 6 && record.receipt.turn === g.turn && b!.territory === record.receipt.territory && [b!.attacker, b!.defender].includes(record.receipt.owner), 'The temporary Sardaukar belong to another battle.'); if (record.stage !== 'canceled') { const p = getPlayer(g, record.receipt.owner), other = getPlayer(g, b!.attacker === p.id ? b!.defender : b!.attacker); const forces = combatForces(g, p, b!.territory, other); requireRule(forces.normal >= 5 && forces.elite === 0, 'The Nexus battle no longer has five eligible ordinary forces.'); } } for (const response of responses) validateNexusSardaukarResponse(g, response!); const lastLoss = history.find(record => record.receipt.battle === g.lastBattleContext?.event)?.casualties; const lossEvent = history.find(record => record.receipt.battle === g.lastBattleContext?.event)?.receipt.event; requireRule(g.lastBattleContext?.nexusSardaukarCasualties === (lastLoss ? lossEvent : undefined), 'The completed battle has lost its Nexus Sardaukar casualty receipt.'); } function nexusSardaukarEligibility(g: Game, p: Player): string | null { const b = g.battle; if (!g.advanced) return 'Sardaukar are an Advanced advantage.'; if (g.status !== 'playing' || g.phase !== 6 || !b?.event || ![b.attacker,b.defender].includes(p.id)) return 'Use Cunning in your own battle before submitting your Battle Plan.'; if (p.faction !== 'emperor' || p.ally || g.nexusCards?.cards?.hands[p.id] !== 'emperor') return 'Use your own Emperor Nexus card while unallied.'; if (b.revealed || b.plans[p.id] || b.nexusSardaukarUsed) return 'This battle has already committed your plan or used Emperor Cunning.'; const other = getPlayer(g, b.attacker === p.id ? b.defender : b.attacker); const forces = combatForces(g, p, b.territory, other); if (forces.elite) return 'Cunning requires a battle containing none of your actual Sardaukar.'; if (forces.normal < 5) return 'Using Emperor Cunning with fewer than five eligible forces awaits a ruling.'; return null; } function projectedNexusSardaukar(g: Game, id: string) { if (!g.nexusCards?.cards) return null; const record = currentNexusSardaukar(g); const active = record?.stage === 'active'; const pending = record?.stage === 'pending' ? {event: record.receipt.event, owner: record.receipt.owner} : null; const p = getPlayer(g,id); if (p.faction !== 'emperor' || g.nexusCards.cards.hands[id] !== 'emperor') return {offer:null,active,pending}; let blocked = nexusSardaukarEligibility(g,p); if (!blocked && (g.truthtrance || g.response || g.decision || g.phaseOpening || g.pendingKarama || g.pendingTreacheryDiscard || g.pendingNullentropy || pendingNexusTraitors(g) || g.nexusCards.phase?.stage === 'drawing')) blocked = 'Finish the current interaction before declaring Nexus Sardaukar.'; const event = g.battle?.event ? JSON.stringify(['nexusSardaukar',g.turn,g.battle.event,id]) : ''; if (!blocked) { const trial = structuredClone(g); commitNexusSardaukar(trial,getPlayer(trial,id),'active'); if (!findReachableBattlePlan(trial,getPlayer(trial,id))) blocked = 'Sardaukar strength would prevent you from honoring an existing Battle Plan commitment.'; } return {offer:{event,blocked},active,pending}; } function commitNexusSardaukar(g: Game, p: Player, stage: 'pending' | 'active') { const blocked = nexusSardaukarEligibility(g,p); requireRule(!blocked, blocked ?? 'The Nexus Sardaukar opportunity is unavailable.'); const b = g.battle!, other = getPlayer(g,b.attacker === p.id ? b.defender : b.attacker); const receipt = nexusRule(() => createNexusSardaukar(g,p.id,b.event!,b.territory,combatForces(g,p,b.territory,other).normal)); const record = {receipt,stage,parent:nexusSardaukarParent(g),signature:''}; record.signature = nexusSardaukarSignature(record); g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!,p.id,g.players)); (g.nexusSardaukarHistory ??= []).push(record); g.nexusSardaukarLast = {event:receipt.event,stage}; b.nexusSardaukarUsed = receipt.event; if (stage === 'pending') g.response = {kind:'nexusSardaukar',owner:p.id,intent:receipt.event,passed:[]}; } function playNexusSardaukar(g: Game,p: Player,action: Action) { const offer = projectedNexusSardaukar(g,p.id)?.offer; requireRule(Object.keys(action).every(key => ['type','event'].includes(key)) && offer && !offer.blocked && action.event === offer.event, offer?.blocked ?? 'Choose the current Emperor Cunning opportunity.'); commitNexusSardaukar(g,p,'pending'); log(g,`${p.name} spent Emperor Nexus Cunning to count five ordinary forces as Sardaukar for this battle. Karama may prevent this use. The physical counters remain ordinary forces.`, {faction:p.faction,name:'Nexus Sardaukar declared'}); } function finishNexusSardaukar(g: Game,canceled: boolean) { const record = currentNexusSardaukar(g)!; record.stage = canceled ? 'canceled' : 'active'; record.signature = nexusSardaukarSignature(record); g.nexusSardaukarLast = {event:record.receipt.event,stage:record.stage}; if (canceled) reconcileChangedBattleInspections(g, 'the canceled Sardaukar enhancement'); log(g,canceled ? 'Karama prevented Emperor Cunning. These forces remain ordinary in both strength and physical custody; the Nexus card stays spent.' : 'Five ordinary Emperor forces count as Sardaukar in this battle only. They use Sardaukar strength and support, including the Fremen exception, but every casualty remains an ordinary physical counter.', {faction:'emperor',name:canceled ? 'Nexus Sardaukar prevented' : 'Nexus Sardaukar active'}); } function battleInspectionContext(g: Game): BattleInspectionContext { const b = g.battle!; return { event: b.event!, attacker: b.attacker, defender: b.defender, players: g.players.map(({id,faction}) => ({id,faction})), native: b.prescience }; } function nexusInspectionIntegrity(g: Game) { const b = g.battle; if (!b) return; // Native Prescience is binding even when this battle used no Nexus card. // Check all sealed plans so removing a slot-only special cannot evade it. for (const target of [b.attacker, b.defender]) { const plan = b.plans[target]; if (plan) requireRule(committedPlanElements(b, target).every(element => plan[element.field] !== undefined && inspectedPlanMatches(getPlayer(g, target), element.field, element.value, plan[element.field])), 'The sealed plan contradicts its inspected commitment.'); } const record = b.nexusInspection; requireRule(!!record === !!b.nexusInspectionUsed, 'The Nexus inspection presence record is missing.'); if (!record) return; requireRule(g.phase === 6 && b.event && b.nexusInspectionUsed === b.event && g.nexusCards?.cards?.discard.includes('atreides'), 'The played Nexus inspection has lost its battle or physical card.'); nexusRule(() => validateNexusInspection(battleInspectionContext(g), record)); const continuation = g.pendingTreacheryDiscard?.continuation; const contexts = [g, g.pendingExchange, g.pendingNullentropy?.resume, g.pendingRicheseGift?.resume, g.pendingRichesePurchaseIncome?.resume, g.summonedWorm?.resume, g.pendingKull?.resume, continuation && 'resume' in continuation ? continuation.resume : null]; const responses = contexts.flatMap(context => { const pending = context && 'pendingKarama' in context ? context.pendingKarama as Game['pendingKarama'] : null; return [context?.response, pending?.use.kind === 'cancel' ? pending.use.response : null]; }).filter(response => response?.kind === 'nexusPrescience'); requireRule((record.stage === 'response') === (responses.length > 0), 'The extra inspection has lost or reopened its original cancellation response.'); for (const response of responses) validateNexusInspectionResponse(g, response!); if (record.stage === 'answer' || record.stage === 'response') requireRule(!b.revealed && (b.preparation?.kind === 'nexusPrescienceAnswer' || (b.preparation?.kind === 'prescienceAnswer' && b.prescience && !Object.hasOwn(b.prescience, 'value'))), 'The unanswered Nexus inspection is missing its owned answer.'); if (b.preparation?.kind === 'nexusPrescienceAnswer') requireRule((record.stage === 'answer' || record.stage === 'response') && b.preparation.owner === record.target && b.preparation.beneficiary === record.owner, 'The pending Nexus answer belongs to the wrong player.'); } function nexusAtreidesOffer(g: Game, id: string) { if (g.nexusCards?.cards?.hands[id] !== 'atreides') return null; const p = getPlayer(g, id), b = g.battle; const mode = nexusCardMode('atreides', p.faction, g.players.map(player => player.faction)); let blocked: string | null = null; if (g.status !== 'playing' || g.phase !== 6 || !b?.event || b.revealed) blocked = 'Play this Nexus Card before the current battle plans are revealed.'; else if (p.ally) blocked = 'Allied players cannot use a Nexus Card.'; else if (b.nexusInspection || b.nexusInspectionUsed) blocked = 'This battle already used the Atreides Nexus Card.'; else if (pendingNexusTraitors(g)) blocked = 'Finish the private Nexus card return first.'; else if (g.truthtrance || g.decision || g.phaseOpening || g.pendingKarama || g.pendingTreacheryDiscard || g.pendingNullentropy) blocked = 'Finish the current interaction before playing this Nexus Card.'; else if (mode === 'betrayal') { blocked = 'Betrayal reactions await the private response timing decision.'; } else if (![b.attacker, b.defender].includes(id) || b.plans[id]) blocked = 'Use this inspection in your own battle before sealing your plan.'; else if (g.response || b.preparation || (b.preLeader && !b.preLeader.closed)) blocked = 'Finish the preceding battle preparation first.'; else if (mode === 'cunning' && (!b.prescience || b.prescience.player !== id || !Object.hasOwn(b.prescience, 'value'))) blocked = 'Cunning needs your first completed native inspection.'; const target = b && (b.attacker === id ? b.defender : b.attacker); const fields = mode === 'betrayal' ? [] : (['leader', 'weapon', 'defense', 'dial'] as PlanField[]) .filter(field => !(mode === 'cunning' && (field === b?.prescience?.field || (field === 'dial' && target && battleNoFieldPlan(g, target))))); return { event: b?.event ?? '', mode, fields, blocked }; } function finishInspectionAnswers(g: Game) { const b = g.battle!; if (b.prescience && !Object.hasOwn(b.prescience, 'value')) { b.preparation = { kind: 'prescienceAnswer', owner: b.prescience.player === b.attacker ? b.defender : b.attacker, beneficiary: b.prescience.player }; } else if (b.nexusInspection?.stage === 'answer') { b.preparation = { kind: 'nexusPrescienceAnswer', owner: b.nexusInspection.target, beneficiary: b.nexusInspection.owner }; } else if (b.nexusInspection && b.nexusInspection.mode !== 'betrayal') delete b.preparation; else finishBattlePreparation(g); } function answerCurrentNexusInspection(g: Game, value: unknown) { const b = g.battle!, record = b.nexusInspection!; const target = getPlayer(g, record.target); requireRule(feasiblePrescience(g, target, record.field, value), 'Choose an element that permits a legal battle plan and respects every prior commitment.'); b.nexusInspection = nexusRule(() => answerNexusInspection(battleInspectionContext(g), record, value as string | number | null)); log(g, `${target.name} committed the requested ${record.field}. Its value is private to the two combatants.`); finishInspectionAnswers(g); } function validateNexusInspectionResponse(g: Game, response: ResponseWindow) { const b = g.battle, record = b?.nexusInspection; requireRule(response.kind === 'nexusPrescience' && record?.mode === 'cunning' && record.stage === 'response' && !record.answers.length && response.owner === record.owner && response.intent === b!.event, 'The extra Prescience response no longer matches its original attempt.'); return record; } function settleNexusInspectionResponse(g: Game, response: ResponseWindow, canceled: boolean) { const b = g.battle!, record = validateNexusInspectionResponse(g, response); if (canceled) { b!.nexusInspection = nexusRule(() => cancelNexusInspection(battleInspectionContext(g), record)); log(g, 'Karama canceled the additional Atreides inspection. The first disclosed element remains binding.'); finishInspectionAnswers(g); } else { b.nexusInspection = nexusRule(() => allowNexusInspection(battleInspectionContext(g), record)); if (b.plans[record.target]) answerCurrentNexusInspection(g, inspectedPlanValue(getPlayer(g, record.target), record.field, b.plans[record.target][record.field])); } } function playNexusAtreides(g: Game, p: Player, action: Action) { const offer = nexusAtreidesOffer(g, p.id); requireRule(offer && !offer.blocked && action.event === offer.event && action.mode === offer.mode, offer?.blocked ?? 'This Atreides Nexus opportunity is stale.'); requireRule(Object.keys(action).sort().join(',') === (offer.mode === 'betrayal' ? 'event,mode,type' : 'event,field,mode,type') && (offer.mode === 'betrayal' || offer.fields.includes(action.field as PlanField)), 'Choose a current Nexus inspection field.'); const b = g.battle!; const field = offer.mode === 'betrayal' ? b.prescience!.field : action.field as PlanField; const beneficiary = offer.mode === 'betrayal' ? b.prescience!.player : p.id; const target = beneficiary === b.attacker ? b.defender : b.attacker; const record = nexusRule(() => createNexusInspection(battleInspectionContext(g), { mode: offer.mode, owner: p.id, target, field })); g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!, p.id, g.players)); b.nexusInspection = record; b.nexusInspectionUsed = b.event; log(g, `${p.name} played the Atreides Nexus Card: ${offer.mode === 'cunning' ? 'Cunning' : offer.mode === 'secretAlly' ? 'Secret Ally' : 'Betrayal'}.`, { faction: p.faction, name: 'Atreides Nexus' }); if (offer.mode === 'betrayal') { g.response = null; delete b.prescience; finishBattlePreparation(g); } else { b.preparation = { kind: 'nexusPrescienceAnswer', owner: target, beneficiary: p.id }; if (offer.mode === 'cunning') g.response = { kind: 'nexusPrescience', owner: p.id, intent: b.event, passed: [] }; else if (b.plans[target]) answerCurrentNexusInspection(g, inspectedPlanValue(getPlayer(g, target), field, b.plans[target][field])); } } function projectedNexusInsights(g: Game, id: string) { const record = g.battle?.nexusInspection; if (!record || ![record.owner, record.target].includes(id)) return []; return record.answers.map((answer, index) => { const value = inspectedPlanValue(getPlayer(g, record.target), record.field, answer); return { field: record.field, value, label: g.players.flatMap(p => [...p.leaders, ...p.hand]).concat(g.dukeVidal ? [g.dukeVidal.leader] : []) .find(item => item.id === value)?.name ?? String(value ?? 'None'), active: record.stage === 'answered' && index === record.answers.length - 1 }; }); } function homeworldRule(quote: () => T): T { try { return quote(); } catch (error) { if ( error instanceof HomeworldCustodyError || error instanceof EmperorHomeworldMoveError || error instanceof NativeReserveError || error instanceof EcazPoisonIncomeError || error instanceof HomeworldBenefitError ) throw new RuleError(error.message); throw 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 }, component?: StormCardComponent, ) => { g.log.push({ seq: (g.log.at(-1)?.seq ?? 0) + 1, text, ...(automatic ? { automatic } : {}), ...(component ? { component } : {}), }); 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.', ); requireRule(validGameLocation(g, p.noField!.deployed!.location.territory, p.noField!.deployed!.location.sector), 'A No-Field can reveal only in a current board location.'); 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); observeOccupation(g); 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 ( 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 && validGameLocation(g, 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 pricing = alliedNoFieldShipmentPricing(g, recipient); const cost = territory(offer.territory).type === 'stronghold' ? pricing.stronghold : pricing.other; 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.'); const limit = handLimit(owner); requireRule( owner.hand.length <= limit, 'The final exchanged hand must not exceed its hand limit.', ); 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), true, ); } 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), true, ); 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; const fullHandExchange = owner.hand.length === handLimit(owner); if (pending?.player === owner.id) { const blocked = nullentropyIntegrity(g); return { card, ...(fullHandExchange ? { fullHandExchange: true as const } : {}), 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, ...(fullHandExchange ? { fullHandExchange: true as const } : {}) }; } 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 (isPortableSnooper(card) && homeworldSavedDecisions(g).some((d) => d.kind === 'homeworldDefense' && d.player === owner.id)) return 'Resolve the Homeworld late-defense choice before transferring its Portable Snooper.'; 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 nativeHandLimit = ( p: Pick & Partial>, ) => (p.faction === 'harkonnen' ? 8 : p.faction === 'choam' ? 5 : 4) - Number(p.atomicsHandLimitPenalty === true); export const handLimit = ( p: Pick & Partial>, ) => nativeHandLimit(p) + Number(p.tupileHandSlot === true); function atomicsRule(run: () => T): T { try { return run(); } catch (error) { if (error instanceof Error) throw new RuleError(error.message); throw error; } } /** Only the clean printed-territory entry is connected; other arrival parents remain gated. */ function atomicsModeSupported(g: Game): boolean { return !!byFaction(g, 'moritani') && !byFaction(g, 'ecaz') && !byFaction(g, 'guild') && g.players.every(p => CLASSIC_FACTIONS[p.faction] === true || p.faction === 'moritani') && g.expansions.every(expansion => expansion === 'ecaz') && !g.homeworlds && !g.nexusCards && !g.leaderSkills && !g.discoveryEnabled && !g.techTokens && !g.strongholdCards && !g.ecazTreachery && !g.pendingArrivalOverlap; } function currentAtomicsQuote( g: Game, entry: NonNullable, ): AtomicsQuote { requireRule(atomicsModeSupported(g) && !g.moritaniAtomics && !g.moritaniExtortion, 'Atomics with these combined modules or another unresolved Terror effect is not yet supported.'); const token = g.moritaniTerror?.tokens.find(row => row.id === entry.token); requireRule(token, 'The Atomics token has lost its original stronghold.'); return atomicsRule(() => quoteMoritaniAtomics({ token, territory: entry.territory, moritaniId: byFaction(g, 'moritani')!.id, turn: g.turn, aftermath: g.moritaniAtomics ?? null, players: g.players.map(player => ({ ...player, handSize: player.hand.length, baseHandLimit: handLimit(player), })), })); } function resolveAtomics( g: Game, entry: NonNullable, quote: AtomicsQuote, ) { g.moritaniAtomics = quote.aftermath; for (const casualty of quote.casualties) { const player = getPlayer(g, casualty.playerId); kill(g, player, casualty.location, casualty.normal + casualty.elite, false, casualty.elite); } for (const player of g.players) if (!at(player, quote.aftermath.territory) && player.advisors) delete player.advisors[quote.aftermath.territory]; observeOccupation(g); const handBefore: { player: string; count: number }[] = []; const entries: FreshDiscardBatch['entries'][number][] = []; for (const reduction of quote.handReductions) { const player = getPlayer(g, reduction.playerId); handBefore.push({ player: player.id, count: player.hand.length }); player.atomicsHandLimitPenalty = true; requireRule(handLimit(player) === reduction.limit, 'Atomics has lost its original hand-limit reduction.'); for (let i = 0; i < reduction.randomDiscards; i++) { const card = player.hand[Math.floor(random() * player.hand.length)]; entries.push({ card: discard(g, player, card.id), discardedBy: player.id, publicFace: false }); } } log(g, `Atomics destroyed every force in ${territory(quote.aftermath.territory).name}. Aftermath permanently blocks shipments there, including Fremen reinforcements. ${quote.aftermath.allyAtActivation ? 'Moritani and its ally each lost' : 'Moritani lost'} one Treachery hand slot; ${entries.length} excess ${entries.length === 1 ? 'card was' : 'cards were'} discarded at random.`); if (entries.length) { g.pendingTerrorEntry = null; g.decision = null; stageTreacheryDiscard(g, 'terror:atomics', entries, { kind: 'terrorAtomics', entry, aftermath: quote.aftermath, handBefore }); } else finishTerrorEntry(g); } 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 = !!x.bot)); 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, role?: Omit) { 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.'); const income = homeworldRule(() => quoteEcazPoisonIncome(g, [{ card: c, ...role }])); if (income) { const ecaz = getPlayer(g, income.player); requireRule(Number.isSafeInteger(ecaz.spice + income.amount), 'Ecaz income would exceed a valid spice balance.'); ecaz.spice += income.amount; // Discard category and resulting private spice must not enter the public // chronicle. The native owner receives a private income receipt instead. (g.ecazPoisonIncome ??= []).push({ ...income, turn: g.turn, phase: g.phase }); } g.discard.push(c); p.hand = p.hand.filter((c) => c.id !== id); return c; } function battleDiscardRoles(g: Game, battle: Battle) { const players = [getPlayer(g, battle.attacker), getPlayer(g, battle.defender)]; const selected = players.map((p) => ({ weapon: cardOf(p, battle.plans[p.id].weapon), defense: cardOf(p, battle.plans[p.id].defense) })); const weapons = resolveBattleWeapons({ attacker: selected[0], defender: selected[1] }); requireRule(!weapons.error, weapons.error ?? 'Invalid effective battle weapons.'); const roles: NonNullable['cardRoles']> = {}; for (const [index, p] of players.entries()) { roles[p.id] = {}; for (const slot of ['weapon', 'defense', 'leader'] as const) { const id = battle.plans[p.id][slot]; if (!id || !cardOf(p, id)) continue; roles[p.id][id] = { battleSlot: slot, ...(slot === 'weapon' ? { effectiveWeapon: index === 0 ? weapons.attacker : weapons.defender } : {}) }; } } return roles; } function cleanupDiscardRole(g: Game, player: Player, id: string) { battleCardRolesIntegrity(g); const card = cardOf(player, id)!; const context = g.lastBattleContext; const role = context?.cardRoles?.[player.id]?.[id]; if (g.homeworlds?.custody && byFaction(g, 'ecaz') && (card.kind === 'chemistry' || card.effect === 'mirrorWeapon')) requireRule(context?.turn === g.turn && context.combatants.includes(player.id) && role, 'This conditional weapon cleanup needs its original battle role receipt.'); return role; } function battleCardRolesSignature(context: NonNullable) { const { event, turn, territory, combatants, winner, result, cardRoles } = context; return JSON.stringify({ event, turn, territory, combatants, winner, result, cardRoles }); } function battleMandatoryDiscardSignature(context: NonNullable) { return JSON.stringify({ event: context.event, turn: context.turn, territory: context.territory, combatants: context.combatants, winner: context.winner, result: context.result, entries: context.mandatoryDiscard?.entries, }); } function battleCardRolesIntegrity(g: Game) { const context = g.lastBattleContext; if (!context?.cardRoles && !context?.cardRolesSignature) return; requireRule(context.cardRoles && context.cardRolesSignature === battleCardRolesSignature(context), 'The saved battle card roles no longer match their resolved battle receipt.'); for (const [player, cards] of Object.entries(context.cardRoles)) { requireRule(context.combatants.includes(player) && cards && !Array.isArray(cards), 'The resolved battle has an invalid card-role owner.'); const slots = Object.values(cards).map((role) => role?.battleSlot); requireRule(new Set(slots).size === slots.length && slots.every((slot) => ['weapon', 'defense', 'leader'].includes(slot ?? '')), 'The resolved battle card slots are missing or duplicated.'); for (const [card, role] of Object.entries(cards)) requireRule(role.battleSlot === 'weapon' ? role.effectiveWeapon?.physicalId === card : role.effectiveWeapon === undefined, 'The resolved battle weapon descriptor does not match its physical slot.'); } } /** 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, homeworlds: g.homeworlds, homeworldRevival: g.homeworldRevival, homeworldRevivalReturn: g.homeworldRevivalReturn, ...(g.homeworldRevivalProgress === undefined ? {} : { homeworldRevivalProgress: g.homeworldRevivalProgress }), homeworldVictoryReinforcement: g.homeworldVictoryReinforcement, ...(g.grummanCollection === undefined ? {} : { grummanCollection: g.grummanCollection }), 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 DistransDiscardContinuation = Extract< NonNullable['continuation'], { kind: 'distransDiscard' } >; function distransDiscardSignature(g: Game, c: DistransDiscardContinuation) { const owner = getPlayer(g, c.owner), recipient = getPlayer(g, c.recipient); return JSON.stringify({ owner: c.owner, recipient: c.recipient, transferred: c.transferred, card: c.card, parent: nullentropyParentSignature(g, c.resume, `distrans:${c.card}`), turn: g.turn, phase: g.phase, active: g.active, ownerHand: owner.hand.map(card => card.id), recipientHand: recipient.hand.map(card => card.id), }); } type SaphoMovementDiscardContinuation = Extract< NonNullable['continuation'], { kind: 'saphoMovementDiscard' } >; function saphoMovementDiscardSignature(g: Game, c: SaphoMovementDiscardContinuation) { return JSON.stringify({ player: c.player, card: c.card, event: c.event, mode: c.mode, previousActive: c.previousActive, parent: nullentropyParentSignature(g, c.resume, c.event), turn: g.turn, phase: g.phase, advanced: g.advanced, active: g.active, order: g.order, remaining: g.movementRemaining, protectedLast: g.saphoMovementLast, guildTimingGranted: g.guildTimingGranted, guildTimingLocked: g.guildTimingLocked, hajr: g.hajr, karamaShipping: g.karamaShipping, choamMovement: g.choamMovement, players: g.players.map(p => ({ id: p.id, faction: p.faction, moved: p.moved, shipped: p.shipped, })), }); } type SaphoAuctionDiscardContinuation = Extract< NonNullable['continuation'], { kind: 'saphoAuctionDiscard' } >; function saphoAuctionDiscardSignature(g: Game, c: SaphoAuctionDiscardContinuation) { return JSON.stringify({ player: c.player, card: c.card, event: c.event, mode: c.mode, parent: nullentropyParentSignature(g, c.resume, c.event), turn: g.turn, phase: g.phase, active: g.active, order: g.order, lot: g.richeseAuction, bidding: g.richeseBidding, funding: g.richeseFunding, players: g.players.map(p => ({ id: p.id, faction: p.faction, ally: p.ally, hand: p.hand.map(card => card.id), spice: p.spice, })), }); } type SaphoBattleOrderDiscardContinuation = Extract< NonNullable['continuation'], { kind: 'saphoBattleOrderDiscard' } >; function saphoBattleOrderDiscardSignature(g: Game, c: SaphoBattleOrderDiscardContinuation) { return JSON.stringify({ player: c.player, card: c.card, event: c.event, mode: c.mode, parent: nullentropyParentSignature(g, c.resume, c.event), turn: g.turn, phase: g.phase, active: g.active, order: g.order, battleOrder: g.battleOrder, useEvents: g.battleOrderUseEvents, afterBattle: g.lastBattleContext, battles: quoteCombatBoard(g).battles, players: g.players.map(p => ({ id: p.id, faction: p.faction, ally: p.ally, hand: p.hand.map(card => card.id), forces: p.forces, })), }); } type SaphoAggressorDiscardContinuation = Extract< NonNullable['continuation'], { kind: 'saphoAggressorDiscard' } >; function saphoAggressorDiscardSignature(g: Game, c: SaphoAggressorDiscardContinuation) { const battle = g.battle; return JSON.stringify({ player: c.player, card: c.card, battleEvent: c.battleEvent, saphoEvent: c.saphoEvent, parent: nullentropyParentSignature(g, c.resume, c.saphoEvent), turn: g.turn, phase: g.phase, advanced: g.advanced, active: g.active, order: g.order, battle: battle && { event: battle.event, attacker: battle.attacker, defender: battle.defender, chooser: battle.chooser, territory: battle.territory, preLeader: battle.preLeader, preparation: battle.preparation, saphoAggressor: battle.saphoAggressor, saphoAggressorEvents: battle.saphoAggressorEvents, plans: battle.plans, revealed: battle.revealed, voice: battle.voice, prescience: battle.prescience, fullPlan: battle.fullPlan, nexusInspection: battle.nexusInspection, truthPromises: battle.truthPromises, }, players: g.players.map(p => ({ id: p.id, faction: p.faction, ally: p.ally })), }); } type ResidualPoisonDiscardContinuation = Extract< NonNullable['continuation'], { kind: 'residualPoisonDiscard' } >; function residualPoisonDiscardSignature(g: Game, c: ResidualPoisonDiscardContinuation) { return JSON.stringify({ player: c.player, target: c.target, victim: c.victim, battleEvent: c.battleEvent, card: c.card, parent: nullentropyParentSignature(g, c.resume, c.battleEvent), turn: g.turn, phase: g.phase, active: g.active, order: g.order, battle: g.battle, duke: g.dukeVidal, players: g.players.map(p => ({ id: p.id, faction: p.faction, ally: p.ally, hand: p.hand.map(card => card.id), leaders: p.leaders, reserves: p.reserves, tanks: p.tanks, forces: p.forces, })), }); } function semutaBattleKaramaResponse(g: Game, response: ResponseWindow): boolean { return response.kind === 'capture' || response.kind === 'choamAudit' || response.kind === 'choamBattleIncome' || response.kind === 'ixSubstitution' || (!g.advanced && response.kind === 'moritaniRetention' && g.moritaniRetention?.source === undefined); } /** Keep the same ordinary reactive parents admitted by the five printed cost continuations. */ function semutaChoamKaramaResponse(g: Game, response: ResponseWindow): boolean { const pending = g.pendingChoamWorthless; if (response.kind !== 'choamWorthless' || !pending || pending.effect === 'kull' || pending.owner !== response.owner || g.battle || g.ornithopter || g.pendingArrivalOverlap || g.ecologicalStorm) return false; const movement = g.pendingChoamMove; return !movement || (!movement.source && !movement.ornithopterEvent && !movement.ornithopterRange && !movement.discoveryFlight); } /** Emperor gift/revival cancellation is a clean public cost with no competing parent. */ function semutaEmperorShareKaramaResponse(g: Game, response: ResponseWindow): boolean { if (response.kind !== 'emperorGift' && response.kind !== 'emperorRevival') return false; const emperor = getPlayer(g, response.owner), recipient = response.recipient ? g.players.find(p => p.id === response.recipient) : null; return !!emperor && !!recipient && emperor.faction === 'emperor' && emperor.id !== recipient.id && emperor.ally === recipient.id && recipient.ally === emperor.id && Number.isSafeInteger(response.amount) && response.amount! > 0 && (response.elite === undefined || response.elite === 0 || response.elite === 1) && !g.battle && !g.pendingShipment && !g.pendingGuildTransport; } /** Original paid income sources; cancellation never reverses the completed transaction. */ function semutaIncomeKaramaResponse(g: Game, response: ResponseWindow): boolean { if (g.battle || g.pendingShipment || g.pendingGuildTransport || response.source === 'ambassador') return false; const owner = g.players.find(p => p.id === response.owner); if (response.kind === 'guildIncome') return g.phase === 5 && owner?.faction === 'guild' && Number.isSafeInteger(response.amount) && response.amount! > 0; if (owner?.faction !== 'emperor') return false; if (response.kind === 'emperorIncome') return g.phase === 3 && !!g.currentAuctionSale && auctionEmperorIncomeAmount(g.currentAuctionSale) > 0; const purchase = g.pendingRichesePurchaseIncome; return response.kind === 'richesePurchaseIncome' && g.advanced && response.amount === 3 && !!purchase && purchase.owner === owner.id && purchase.turn === g.turn && purchase.phase === g.phase; } type KaramaResponseDiscardContinuation = Extract< NonNullable['continuation'], { kind: 'karamaCharityDiscard' | 'karamaInflationDiscard' | 'karamaBgCharityDiscard' | 'karamaBattleDiscard' | 'karamaChoamWorthlessDiscard' | 'karamaEmperorShareDiscard' | 'karamaIncomeDiscard' } >; function karamaResponseDiscardSignature(g: Game, c: KaramaResponseDiscardContinuation) { const battle = c.kind === 'karamaBattleDiscard'; const worthless = c.kind === 'karamaChoamWorthlessDiscard'; const emperorShare = c.kind === 'karamaEmperorShareDiscard'; const income = c.kind === 'karamaIncomeDiscard'; return JSON.stringify({ player: c.player, card: c.card, use: c.use, parent: nullentropyParentSignature(g, c.resume, c.kind === 'karamaCharityDiscard' ? `karama:charity:${c.card}` : `karama:${c.use.response.kind}:${c.card}`), turn: g.turn, phase: g.phase, advanced: g.advanced, charity: g.choamCharity, ...(c.kind === 'karamaInflationDiscard' ? { inflation: g.inflation, inflationUsed: g.inflationUsed, inflationAttemptTurn: g.inflationAttemptTurn, } : {}), ...(c.kind === 'karamaBgCharityDiscard' ? { claimed: getPlayer(g, c.use.response.owner).charityTurn === g.turn, } : {}), battleContext: battle ? g.lastBattleContext : undefined, retention: battle ? g.moritaniRetention : undefined, techTokens: battle ? g.techTokens : undefined, choamWorthless: worthless ? { declaration: g.pendingChoamWorthless, movement: g.pendingChoamMove, revival: g.pendingRevival, storm: g.stormResolution, mentat: g.choamMentatPending, nexus: g.nexusChoamHistory?.at(-1), } : undefined, emperorShare: emperorShare ? { response: g.response, emperorExtra: g.emperorExtra, } : undefined, income: income ? { auction: g.auction, sale: g.currentAuctionSale, richeseBidding: g.richeseBidding, richeseAuction: g.richeseAuction, active: g.active, ready: g.ready, movementRemaining: g.movementRemaining, homeworld: g.homeworlds ? { custody: g.homeworlds, history: g.homeworldOccupationHistory, percentages: g.homeworldOccupiedPercentageLedger, } : undefined, } : undefined, order: g.order, players: g.players.map(p => ({ id: p.id, faction: p.faction, ally: p.ally, hand: p.hand.map(card => card.id), spice: p.spice, shipped: income ? p.shipped : undefined, physical: battle || worthless || income ? { forces: p.forces, reserves: p.reserves, tanks: p.tanks, elites: p.elites, leaders: battle ? p.leaders : undefined } : undefined, })), }); } type KaramaAuctionDiscardContinuation = Extract< NonNullable['continuation'], { kind: 'karamaPurchaseDiscard' | 'karamaPaymentDiscard' } >; function karamaAuctionDiscardSignature(g: Game, c: KaramaAuctionDiscardContinuation) { return JSON.stringify({ player: c.player, card: c.card, parent: nullentropyParentSignature(g, c.resume, `karama:${c.kind === 'karamaPaymentDiscard' ? 'auctionPayment' : 'purchase'}:${c.card}`), turn: g.turn, phase: g.phase, active: g.active, auction: g.auction, richeseBidding: g.richeseBidding, richeseFunding: g.richeseFunding, richeseCache: g.richeseCache?.map(card => card.id), deck: g.deck.map(card => card.id), order: g.order, players: g.players.map(p => ({ id: p.id, faction: p.faction, ally: p.ally, hand: p.hand.map(card => card.id), spice: p.spice, })), }); } type ChoamSaleDiscardContinuation = Extract< NonNullable['continuation'], { kind: 'choamSaleDiscard' } >; function choamSaleDiscardSignature(g: Game, c: ChoamSaleDiscardContinuation) { const owner = getPlayer(g, c.owner); return JSON.stringify({ owner: c.owner, card: c.card, witness: c.witness, price: c.price, spiceAfter: c.spiceAfter, turn: g.turn, phase: g.phase, active: g.active, order: g.order, market: g.choamMarket, hand: owner.hand.map(card => card.id), spice: owner.spice, }); } type ChoamKulonDiscardContinuation = Extract< NonNullable['continuation'], { kind: 'choamKulonDiscard' } >; function choamKulonDiscardSignature(g: Game, c: ChoamKulonDiscardContinuation) { const owner = getPlayer(g, c.player); return JSON.stringify({ player: c.player, card: c.card, before: c.before, nexusEvent: c.nexusEvent, turn: g.turn, phase: g.phase, active: g.active, order: g.order, movement: g.choamMovement, remaining: g.movementRemaining, parent: nullentropyParentSignature(g, { response: null, decision: null, pendingKarama: null, phaseOpening: null }, `choam:kulon:${c.card}`), hand: owner.hand.map(card => card.id), moved: owner.moved, shipped: owner.shipped, spice: owner.spice, }); } type ChoamLaLaLaDiscardContinuation = Extract< NonNullable['continuation'], { kind: 'choamLaLaLaDiscard' } >; function choamLaLaLaDiscardSignature(g: Game, c: ChoamLaLaLaDiscardContinuation) { const owner = getPlayer(g, c.player), target = getPlayer(g, c.target); return JSON.stringify({ player: c.player, card: c.card, target: c.target, nexusEvent: c.nexusEvent, stoppedRevival: c.stoppedRevival, turn: g.turn, phase: g.phase, active: g.active, order: g.order, revivalRules: g.revivalRules, pendingRevival: g.pendingRevival, parent: nullentropyParentSignature(g, { response: null, decision: null, pendingKarama: null, phaseOpening: null }, `choam:laLaLa:${c.card}`), ownerHand: owner.hand.map(card => card.id), targetRevived: target.revived, targetSpice: target.spice, }); } type ChoamBalisetDiscardContinuation = Extract< NonNullable['continuation'], { kind: 'choamBalisetDiscard' } >; function choamBalisetDiscardSignature(g: Game, c: ChoamBalisetDiscardContinuation) { const owner = getPlayer(g, c.player); return JSON.stringify({ player: c.player, card: c.card, target: c.target, territory: c.territory, nexusEvent: c.nexusEvent, stoppedMovement: c.stoppedMovement, turn: g.turn, phase: g.phase, active: g.active, order: g.order, restrictions: g.choamBaliset, remaining: g.movementRemaining, parent: nullentropyParentSignature(g, { response: null, decision: null, pendingKarama: null, phaseOpening: null }, `choam:baliset:${c.card}`), ownerHand: owner.hand.map(card => card.id), ownerForces: owner.forces, ownerElites: owner.elites?.forces, ownerSpice: owner.spice, }); } type ChoamGamontDiscardContinuation = Extract< NonNullable['continuation'], { kind: 'choamGamontDiscard' } >; function choamGamontDiscardSignature(g: Game, c: ChoamGamontDiscardContinuation) { const owner = getPlayer(g, c.player), target = getPlayer(g, c.target); return JSON.stringify({ player: c.player, card: c.card, target: c.target, location: c.location, nexusEvent: c.nexusEvent, elite: c.elite, noFieldEvent: c.noFieldEvent, mentat: c.mentat, mentatPending: c.mentat ? g.choamMentatPending : undefined, turn: g.turn, phase: g.phase, active: g.active, order: g.order, ready: g.ready, storm: g.storm, parent: nullentropyParentSignature(g, { response: null, decision: null, pendingKarama: null, phaseOpening: null }, `choam:gamont:${c.card}`), ownerHand: owner.hand.map(card => card.id), targetForces: target.forces, targetReserves: target.reserves, targetElites: target.elites, targetNoField: target.noField, }); } type ChoamJubbaDiscardContinuation = Extract< NonNullable['continuation'], { kind: 'choamJubbaDiscard' } >; function choamJubbaDiscardSignature(g: Game, c: ChoamJubbaDiscardContinuation) { const owner = getPlayer(g, c.player); return JSON.stringify({ player: c.player, card: c.card, territory: c.territory, nexusEvent: c.nexusEvent, turn: g.turn, phase: g.phase, active: g.active, order: g.order, storm: g.storm, stormPending: g.stormPending, resolution: g.stormResolution, parent: nullentropyParentSignature(g, { response: null, decision: null, pendingKarama: null, phaseOpening: null }, `choam:jubba:${c.card}`), ownerHand: owner.hand.map(card => card.id), ownerForces: owner.forces, ownerElites: owner.elites, }); } type ChoamCunningDiscardContinuation = ChoamKulonDiscardContinuation | ChoamLaLaLaDiscardContinuation | ChoamBalisetDiscardContinuation | ChoamGamontDiscardContinuation | ChoamJubbaDiscardContinuation; function choamCunningDiscard( c: NonNullable['continuation'], ): c is ChoamCunningDiscardContinuation & { nexusEvent: string } { switch (c.kind) { case 'choamKulonDiscard': case 'choamLaLaLaDiscard': case 'choamBalisetDiscard': case 'choamGamontDiscard': case 'choamJubbaDiscard': return typeof c.nexusEvent === 'string' && c.nexusEvent.length > 0; default: return false; } } /** Cunning changes the physical fuel, not the original quoted effect or its parent. */ function choamDiscardCostMatches( g: Game, c: ChoamCunningDiscardContinuation, entry: FreshDiscardBatch['entries'][number], effect: Exclude, ) { if (!entry || entry.card.id !== c.card || entry.discardedBy !== c.player) return false; if (c.nexusEvent === undefined) return entry.publicFace && entry.card.kind === 'worthless' && entry.card.name === CHOAM_NEXUS_EFFECTS[effect]; if (!choamCunningDiscard(c) || entry.publicFace) return false; nexusChoamIntegrity(g); const record = g.nexusChoamHistory?.at(-1); if (!record || record.stage !== 'complete' || record.receipt.event !== c.nexusEvent || record.receipt.owner !== c.player || record.receipt.card !== c.card || record.receipt.effect !== effect || record.receipt.turn !== g.turn || record.receipt.phase !== g.phase) return false; let original: Game['pendingChoamWorthless']; try { original = JSON.parse(record.frame); } catch { return false; } if (!original || original.owner !== c.player || original.card !== c.card || original.effect !== effect || original.nexusEvent !== c.nexusEvent) return false; switch (c.kind) { case 'choamKulonDiscard': return !original.storm && !original.revival && !original.movement && !original.mentat; case 'choamLaLaLaDiscard': return original.target === c.target && original.revival === (c.stoppedRevival === true) && !original.storm && !original.movement && !original.mentat; case 'choamBalisetDiscard': return original.target === c.target && original.location === c.territory && original.movement === (c.stoppedMovement === true) && !original.storm && !original.revival && !original.mentat; case 'choamGamontDiscard': return original.target === c.target && original.location === c.location && original.elite === (c.elite ?? 0) && original.noFieldEvent === c.noFieldEvent && original.mentat === (c.mentat === true) && !original.storm && !original.revival && !original.movement; case 'choamJubbaDiscard': return original.location === c.territory && original.storm === true && !original.revival && !original.movement && !original.mentat; } } type ThumperDiscardContinuation = Extract< NonNullable['continuation'], { kind: 'thumperDiscard' } >; function thumperDiscardSignature(g: Game, c: ThumperDiscardContinuation) { return JSON.stringify({ player: c.player, card: c.card, parent: nullentropyParentSignature(g, c.resume, `thumper:${c.card}`), turn: g.turn, phase: g.phase, advanced: g.advanced, expansions: g.expansions, active: g.active, order: g.order, ready: g.ready, storm: g.storm, nexus: g.nexus, sandtrout: g.sandtrout, summonedBeforeBlow: g.summonedBeforeBlow, wormRides: g.wormRides, wormPlacementCanceledTurn: g.wormPlacementCanceledTurn, spiceDeck: g.spiceDeck, spiceDiscard: g.spiceDiscard, spice: g.spice, aid: g.aid, allianceOffers: g.allianceOffers, players: g.players.map(p => ({ id: p.id, faction: p.faction, ally: p.ally, forces: p.forces, reserves: p.reserves, tanks: p.tanks, advisors: p.advisors, noField: p.noField, elites: p.elites, })), }); } type AmalDiscardContinuation = Extract< NonNullable['continuation'], { kind: 'amalDiscard' } >; function amalDiscardSignature(g: Game, c: AmalDiscardContinuation) { return JSON.stringify({ player: c.player, card: c.card, before: c.before, parent: nullentropyParentSignature(g, c.resume, `amal:${c.card}`), turn: g.turn, phase: g.phase, advanced: g.advanced, expansions: g.expansions, active: g.active, order: g.order, ready: g.ready, storm: g.storm, deck: g.deck, richeseCache: g.richeseCache, auction: g.auction, spice: g.spice, spiceDeck: g.spiceDeck, players: g.players.map(p => ({ id: p.id, faction: p.faction, spice: p.spice, bribes: p.bribes, forces: p.forces, reserves: p.reserves, tanks: p.tanks, })), }); } 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' || record.question.kind === 'mixedShipment') 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); battleSpecialWeaponIntegrity(context); strongholdIntegrity(context); auditorIntegrity(context); } /** Original native Nexus roster/modules with no extra overlay-specific setup. */ function semutaNexusProfile(g: Game): boolean { return !!g.nexusCards && (g.leaderSkills ? pairedNexusLeaderSkillsProfile(g) || singleE1E2NexusLeaderSkillsProfile(g) || mixedE1E2NexusLeaderSkillsProfile(g) : pairedNexusModulesProfile(g) || singleE1E2NexusProfile(g) || mixedE1E2NexusProfile(g)); } /** Original Richese/CHOAM Homeworld occupation only; other optional envelopes stay separate. */ function semutaHomeworldRoster(g: Game): boolean { return !!g.homeworlds && g.expansions.length === 1 && g.expansions[0] === 'choam' && g.players.length >= 2 && g.players.length <= 6 && g.players.some(p => p.faction === 'richese') && g.players.every(p => p.faction === 'richese' || p.faction === 'choam' || faction(p.faction).expansion === 'base') && !g.nexusCards && !g.leaderSkills && !g.techTokens && !g.strongholdCards && !g.discoveryEnabled && !g.discoveries && !g.discoveryStash && !g.greatMaker && !g.ecazTreachery; } type TupileDiscardContinuation = Extract< NonNullable['continuation'], { kind: 'tupileCleanupDiscard' } >; function tupileDiscardSignature(g: Game, c: TupileDiscardContinuation) { return JSON.stringify({ cleanup: c.cleanup, cards: c.cards, turn: g.turn, phase: g.phase, active: g.active, order: g.order, ready: g.ready, movementRemaining: g.movementRemaining, response: g.response, decision: g.decision, opening: g.phaseOpening, homeworlds: g.homeworlds, history: g.homeworldOccupationHistory, tupile: g.homeworldTupileState, players: g.players.map(p => ({ id: p.id, faction: p.faction, ally: p.ally, hand: p.hand.map(card => card.id), spice: p.spice, reserves: p.reserves, tanks: p.tanks, forces: p.forces, elites: p.elites, shipped: p.shipped, moved: p.moved, slot: p.tupileHandSlot, })), }); } function treacheryDiscardIntegrity(g: Game) { requireRule(g.semutaPreview === undefined || g.semutaPreview === true && g.expansions.includes('choam') && !!byFaction(g, 'richese') && (!g.homeworlds || g.homeworldOccupationPreview === true && semutaHomeworldRoster(g)) && (!g.nexusCards || semutaNexusProfile(g)) && (!g.leaderSkills || ordinaryLeaderSkillModeSupported(g)) && !g.discoveries && !g.discoveryEnabled && (!g.techTokens || (g.nexusCards ? semutaNexusProfile(g) : g.leaderSkills ? nativeTechLeaderSkillsProfile(g) : nativeFactionTechProfile(g, true))) && (!g.strongholdCards || nativeStrongholdFactionRoster(g)), 'This Semuta preview no longer matches its original Richese profile.'); 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); } const reaction = pending.reaction; requireRule(reaction !== undefined || !semutaOfferSupported(g, continuation, batch.entries), 'The saved fresh discard lost its neutral Semuta reaction.'); if (reaction !== undefined) { requireRule(semutaOfferSupported(g, continuation, batch.entries), 'This saved Semuta opportunity has no supported fresh discard.'); const physical = physicalTreacheryCards(g).filter(card => card.id === SEMUTA_DRUG_ID); requireRule(physical.length === 1 && richeseCardDefinition(physical[0])?.card.effect === 'semutaDrug', 'The saved Semuta reaction lost unique physical card custody.'); if (reaction.stage === 'offer') { requireRule(Object.keys(reaction).sort().join(',') === 'passed,stage' && Array.isArray(reaction.passed) && new Set(reaction.passed).size === reaction.passed.length && reaction.passed.length < g.players.length && reaction.passed.every(id => g.players.some(p => p.id === id)), 'The neutral Semuta passes lost their original participants.'); } else if (reaction.stage === 'select') { const owner = g.players.find(p => p.id === reaction.player); requireRule(Object.keys(reaction).sort().join(',') === 'candidates,player,stage' && owner && Array.isArray(reaction.candidates) && reaction.candidates.length >= 1 && JSON.stringify(reaction.candidates) === JSON.stringify(semutaCandidates(g, owner).map(card => card.id)), 'The committed Semuta claim lost its private fresh candidates.'); } else throw new RuleError('Unknown saved Semuta reaction stage.'); } 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 === 'distransDiscard') { const c = continuation, entry = batch.entries[0]; const owner = g.players.find(p => p.id === c.owner); const recipient = g.players.find(p => p.id === c.recipient); requireRule( owner && recipient && owner !== recipient && batch.cause === 'distrans' && batch.entries.length === 1 && entry.publicFace && entry.discardedBy === c.owner && entry.card.id === c.card && richeseCardDefinition(entry.card)?.card.effect === 'distrans' && recipient.hand.filter(card => card.id === c.transferred).length === 1 && !owner.hand.some(card => card.id === c.transferred || card.id === c.card) && outside.filter(card => card.id === c.transferred).length === 1 && !g.discard.some(card => card.id === c.transferred) && !c.resume.response && !c.resume.decision && !c.resume.pendingKarama && !c.resume.phaseOpening && c.stateSignature === distransDiscardSignature(g, c), 'The completed private Distrans transfer no longer matches its fresh discard.', ); } else if (continuation?.kind === 'saphoMovementDiscard') { const c = continuation, entry = batch.entries[0]; requireRule( g.phase === 5 && g.active === c.previousActive && c.event === `movement:${g.turn}` && g.players.some(p => p.id === c.player) && Array.isArray(g.movementRemaining) && g.movementRemaining.length > 1 && new Set(g.movementRemaining).size === g.movementRemaining.length && g.movementRemaining.every(id => g.order.includes(id)) && g.movementRemaining.includes(c.previousActive) && (c.mode === 'first' ? g.movementRemaining[0] === c.player && c.previousActive !== c.player && !g.saphoMovementLast : c.mode === 'last' && g.movementRemaining.at(-1) === c.player && g.saphoMovementLast?.event === c.event && g.saphoMovementLast.turn === g.turn && g.saphoMovementLast.player === c.player) && batch.cause === 'sapho:movement' && batch.entries.length === 1 && entry.publicFace && entry.discardedBy === c.player && entry.card.id === c.card && richeseCardDefinition(entry.card)?.card.effect === 'juiceOfSapho' && !c.resume.response && !c.resume.decision && !c.resume.pendingKarama && !c.resume.phaseOpening && c.stateSignature === saphoMovementDiscardSignature(g, c), 'The used Juice of Sapho no longer matches its committed movement order.', ); } else if (continuation?.kind === 'saphoAuctionDiscard') { const c = continuation, entry = batch.entries[0], lot = g.richeseAuction; const remaining = lot?.order.filter(id => !lot.acted.includes(id)); requireRule( g.phase === 3 && lot?.method === 'onceAround' && lot.event === c.event && !lot.outcome && lot.eligible.includes(c.player) && !lot.acted.includes(c.player) && (c.mode === 'first' ? remaining?.[0] === c.player && lot.active === c.player : c.mode === 'last' && remaining?.at(-1) === c.player) && g.active === lot.active && batch.cause === 'sapho:auction' && batch.entries.length === 1 && entry.publicFace && entry.discardedBy === c.player && entry.card.id === c.card && richeseCardDefinition(entry.card)?.card.effect === 'juiceOfSapho' && !c.resume.response && !c.resume.decision && !c.resume.pendingKarama && !c.resume.phaseOpening && c.stateSignature === saphoAuctionDiscardSignature(g, c), 'The used Juice of Sapho no longer matches its committed Once Around order.', ); } else if (continuation?.kind === 'saphoBattleOrderDiscard') { const c = continuation, entry = batch.entries[0], order = g.battleOrder; const use = order?.uses.at(-1); requireRule( g.phase === 6 && !g.battle && order && use && use.event === c.event && use.player === c.player && use.mode === c.mode && g.battleOrderUseEvents?.at(-1) === c.event && g.active === battleOrderQuote(g).current && batch.cause === 'sapho:battleOrder' && batch.entries.length === 1 && entry.publicFace && entry.discardedBy === c.player && entry.card.id === c.card && richeseCardDefinition(entry.card)?.card.effect === 'juiceOfSapho' && !c.resume.response && !c.resume.decision && !c.resume.pendingKarama && !c.resume.phaseOpening && c.stateSignature === saphoBattleOrderDiscardSignature(g, c), 'The used Juice of Sapho no longer matches its committed battle chooser order.', ); } else if (continuation?.kind === 'saphoAggressorDiscard') { const c = continuation, entry = batch.entries[0], battle = g.battle; requireRule( g.phase === 6 && battle && battle.event === c.battleEvent && battle.preLeader?.event === c.battleEvent && !battle.preLeader.closed && !battle.preLeader.ready.includes(c.player) && [battle.attacker, battle.defender].includes(c.player) && !battle.revealed && Object.keys(battle.plans).length === 0 && battle.saphoAggressor?.uses.at(-1)?.event === c.saphoEvent && battle.saphoAggressor.uses.at(-1)?.player === c.player && battle.saphoAggressorEvents?.includes(c.saphoEvent) && batch.cause === 'sapho:aggressor' && batch.entries.length === 1 && entry.publicFace && entry.discardedBy === c.player && entry.card.id === c.card && richeseCardDefinition(entry.card)?.card.effect === 'juiceOfSapho' && !c.resume.response && !c.resume.decision && !c.resume.pendingKarama && !c.resume.phaseOpening && c.stateSignature === saphoAggressorDiscardSignature(g, c), 'The used Juice of Sapho no longer matches its pre-plan aggressor choice.', ); saphoAggressorIntegrity(g); } else if (continuation?.kind === 'residualPoisonDiscard') { const c = continuation, entry = batch.entries[0], battle = g.battle; const victim = g.players.some(p => p.id === c.target) ? controlledLeaders(g, getPlayer(g, c.target)).find(l => l.id === c.victim) : null; requireRule( g.phase === 6 && battle && battle.event === c.battleEvent && !battle.revealed && !Object.keys(battle.plans).length && [battle.attacker, battle.defender].includes(c.player) && c.target === (battle.attacker === c.player ? battle.defender : battle.attacker) && battle.preLeader?.event === c.battleEvent && !battle.preLeader.closed && battle.preLeader.ready.length === 0 && victim?.dead && !victim.capturedBy && batch.cause === 'residualPoison' && batch.entries.length === 1 && entry.publicFace && entry.discardedBy === c.player && entry.card.id === c.card && richeseCardDefinition(entry.card)?.card.effect === 'residualPoison' && !c.resume.response && !c.resume.decision && !c.resume.pendingKarama && !c.resume.phaseOpening && c.stateSignature === residualPoisonDiscardSignature(g, c), 'The used Residual Poison no longer matches its committed leader death.', ); } else if (continuation?.kind === 'karamaCharityDiscard' || continuation?.kind === 'karamaInflationDiscard' || continuation?.kind === 'karamaBgCharityDiscard' || continuation?.kind === 'karamaBattleDiscard' || continuation?.kind === 'karamaChoamWorthlessDiscard' || continuation?.kind === 'karamaEmperorShareDiscard' || continuation?.kind === 'karamaIncomeDiscard') { const c = continuation, entry = batch.entries[0]; requireRule( !g.response && ((c.kind === 'karamaCharityDiscard' && c.use.response.kind === 'choamCharity' && g.phase === 2 && g.choamCharity?.turn !== g.turn) || (c.kind === 'karamaInflationDiscard' && c.use.response.kind === 'choamInflation' && g.phase === 8 && g.inflationAttemptTurn === g.turn && !g.inflation && !g.inflationUsed) || (c.kind === 'karamaBgCharityDiscard' && c.use.response.kind === 'bgCharity' && g.phase === 2 && g.players.some(p => p.id === c.use.response.owner && p.faction === 'beneGesserit' && p.charityTurn === g.turn)) || (c.kind === 'karamaBattleDiscard' && g.phase === 6 && !g.battle && semutaBattleKaramaResponse(g, c.use.response)) || (c.kind === 'karamaChoamWorthlessDiscard' && semutaChoamKaramaResponse(g, c.use.response)) || (c.kind === 'karamaEmperorShareDiscard' && semutaEmperorShareKaramaResponse(g, c.use.response)) || (c.kind === 'karamaIncomeDiscard' && semutaIncomeKaramaResponse(g, c.use.response))) && c.use.response.owner !== c.player && c.resume.response?.kind === c.use.response.kind && JSON.stringify(c.resume.response) === JSON.stringify(c.use.response) && !c.resume.decision && !c.resume.pendingKarama && !c.resume.phaseOpening && batch.cause === `karama:${c.use.response.kind}` && batch.entries.length === 1 && entry.publicFace && entry.discardedBy === c.player && entry.card.id === c.card && entry.card.effect === 'karama' && g.players.some(p => p.id === c.player) && c.stateSignature === karamaResponseDiscardSignature(g, c), 'The spent Karama no longer matches its suspended native cancellation.', ); if (c.kind === 'karamaBattleDiscard') { if (c.use.response.kind === 'moritaniRetention') validateMoritaniCancellation(g, c.use.response.owner); else if (c.use.response.kind === 'ixSubstitution') ixSubstitutionCancellationQuote(g, c.use.response); else validateAftermathCancellation(g, c.use.response); } if (c.kind === 'karamaIncomeDiscard') { if (c.use.response.kind === 'emperorIncome') validateAuctionContinuationCancellation(g, c.use.response); else if (c.use.response.kind === 'richesePurchaseIncome') currentRicheseCancellationQuote(g, c.use.response); } if (c.kind === 'karamaChoamWorthlessDiscard') { try { quoteChoamWorthlessCancellation(g, c.use.response); } catch (error) { if (error instanceof ChoamWorthlessCancellationError) throw new RuleError(error.message); throw error; } } } else if (continuation?.kind === 'karamaPurchaseDiscard' || continuation?.kind === 'karamaPaymentDiscard') { const c = continuation, entry = batch.entries[0]; requireRule( g.phase === 3 && !g.response && !g.decision && !g.pendingKarama && !g.phaseOpening && !c.resume.response && !c.resume.decision && !c.resume.pendingKarama && !c.resume.phaseOpening && batch.cause === (c.kind === 'karamaPaymentDiscard' ? 'karama:auctionPayment' : 'karama:purchase') && batch.entries.length === 1 && entry.publicFace && entry.discardedBy === c.player && entry.card.id === c.card && entry.card.effect === 'karama' && g.players.some(p => p.id === c.player) && c.stateSignature === karamaAuctionDiscardSignature(g, c), 'The spent Karama no longer matches its reserved auction settlement.', ); normalKaramaAuction(g, getPlayer(g, c.player), c.kind === 'karamaPaymentDiscard'); } else if (continuation?.kind === 'choamSaleDiscard') { const c = continuation, entry = batch.entries[0]; const owner = g.players.find(p => p.id === c.owner); requireRule( g.choamMarket?.owner === c.owner && !g.choamMarket.sale && !g.choamMarket.trade && !g.response && !g.decision && !g.pendingKarama && !g.phaseOpening && owner?.faction === 'choam' && batch.cause === 'choamSale' && batch.entries.length === 1 && entry.publicFace && entry.discardedBy === c.owner && entry.card.id === c.card && (c.witness === null ? c.price === 2 && entry.card.kind === 'worthless' : c.price === 3 && owner.hand.some(card => card.id === c.witness && card.name === entry.card.name)) && owner.spice === c.spiceAfter && c.stateSignature === choamSaleDiscardSignature(g, c), 'The sold CHOAM card no longer matches its saved income and market.', ); } else if (continuation?.kind === 'choamKulonDiscard') { const c = continuation, entry = batch.entries[0]; const owner = g.players.find(p => p.id === c.player); requireRule( g.phase === 5 && g.active === c.player && owner?.faction === 'choam' && owner.moved < movesAllowed(g, owner) && !g.pendingChoamWorthless && !g.response && !g.decision && !g.phaseOpening && !g.ornithopter && !g.pendingChoamMove && batch.cause === 'choam:kulon' && batch.entries.length === 1 && choamDiscardCostMatches(g, c, entry, 'kulon') && Number.isSafeInteger(c.before) && c.before >= 0 && c.before === (g.choamMovement?.turn === g.turn ? g.choamMovement.bonus : 0) && c.stateSignature === choamKulonDiscardSignature(g, c), 'The used Kulon no longer matches its pending movement bonus.', ); } else if (continuation?.kind === 'choamLaLaLaDiscard') { const c = continuation, entry = batch.entries[0]; const owner = g.players.find(p => p.id === c.player); requireRule( g.phase === 4 && owner?.faction === 'choam' && g.players.some(p => p.id === c.target) && (c.stoppedRevival === undefined || c.stoppedRevival === true) && !g.revivalRules?.freeBlocked?.includes(c.target) && !g.pendingChoamWorthless && !g.pendingRevival && !g.response && !g.decision && !g.phaseOpening && batch.cause === 'choam:laLaLa' && batch.entries.length === 1 && choamDiscardCostMatches(g, c, entry, 'laLaLa') && c.stateSignature === choamLaLaLaDiscardSignature(g, c), 'The used La La La no longer matches its pending revival block.', ); } else if (continuation?.kind === 'choamBalisetDiscard') { const c = continuation, entry = batch.entries[0]; const owner = g.players.find(p => p.id === c.player); requireRule( g.phase === 5 && owner?.faction === 'choam' && c.target !== c.player && g.players.some(p => p.id === c.target) && (c.stoppedMovement === undefined || c.stoppedMovement === true) && at(owner, c.territory) > 0 && !g.pendingChoamWorthless && !g.pendingChoamMove && !g.response && !g.decision && !g.phaseOpening && !g.ornithopter && batch.cause === 'choam:baliset' && batch.entries.length === 1 && choamDiscardCostMatches(g, c, entry, 'baliset') && c.stateSignature === choamBalisetDiscardSignature(g, c), 'The used Baliset no longer matches its pending territory restriction.', ); } else if (continuation?.kind === 'choamGamontDiscard') { const c = continuation, entry = batch.entries[0]; const owner = g.players.find(p => p.id === c.player); requireRule( g.phase === 8 && owner?.faction === 'choam' && c.target !== c.player && (c.elite === undefined || c.elite === 1) && (c.noFieldEvent === undefined || (typeof c.noFieldEvent === 'string' && c.noFieldEvent.length > 0)) && (c.mentat === undefined || (c.mentat === true && g.choamMentatPending === true)) && gamontAvailable(g, c.target, c.location, c.elite ?? 0, c.noFieldEvent) && !g.pendingChoamWorthless && !g.response && !g.decision && !g.phaseOpening && !g.choamMarket && batch.cause === 'choam:gamont' && batch.entries.length === 1 && choamDiscardCostMatches(g, c, entry, 'gamont') && c.stateSignature === choamGamontDiscardSignature(g, c), 'The used Trip to Gamont no longer matches its pending typed force return.', ); } else if (continuation?.kind === 'choamJubbaDiscard') { const c = continuation, entry = batch.entries[0]; const owner = g.players.find(p => p.id === c.player); requireRule( g.phase === 0 && owner?.faction === 'choam' && !!g.stormResolution && choamStormOptions(g).some(option => option.territory === c.territory) && !g.pendingChoamWorthless && !g.response && !g.decision && !g.phaseOpening && batch.cause === 'choam:jubba' && batch.entries.length === 1 && choamDiscardCostMatches(g, c, entry, 'jubba') && c.stateSignature === choamJubbaDiscardSignature(g, c), 'The used Jubba Cloak no longer matches its pending storm protection.', ); } else if (continuation?.kind === 'thumperDiscard') { const c = continuation, entry = batch.entries[0]; requireRule( g.players.some(p => p.id === c.player) && g.phase === 1 && !g.spiceSequence && !g.spiceResolution && !g.spiceWindow && !g.nexus && batch.cause === 'thumper' && batch.entries.length === 1 && entry.publicFace && entry.discardedBy === c.player && entry.card.id === c.card && entry.card.name === 'Thumper' && entry.card.kind === 'special' && entry.card.effect === 'thumper' && !c.resume.response && !c.resume.decision && !c.resume.pendingKarama && !c.resume.phaseOpening && c.stateSignature === thumperDiscardSignature(g, c), 'The used Thumper no longer matches its undrawn Spice Blow.', ); } else if (continuation?.kind === 'amalDiscard') { const c = continuation, entry = batch.entries[0]; requireRule( g.expansions.includes('ix') && g.players.some(p => p.id === c.player) && batch.cause === 'amal' && batch.entries.length === 1 && entry.publicFace && entry.discardedBy === c.player && entry.card.id === c.card && entry.card.name === 'Amal' && entry.card.kind === 'special' && entry.card.effect === 'amal' && c.resume && !c.resume.response && !c.resume.decision && !c.resume.pendingKarama && c.resume.phaseOpening && Array.isArray(c.resume.phaseOpening.passed) && c.resume.phaseOpening.passed.length === 0 && typeof c.resume.phaseOpening.initialize === 'boolean' && Array.isArray(c.before) && c.before.length === g.players.length && c.before.every((row, index) => row.player === g.players[index].id && Number.isSafeInteger(row.spice) && row.spice >= 0 && g.players[index].spice === Math.floor(row.spice / 2)) && c.stateSignature === amalDiscardSignature(g, c), 'The used Amal no longer matches its halved spice and saved phase opening.', ); suspendedControlsIntegrity(g, c.resume); } else if (continuation?.kind === 'truthtranceDiscard') { const c = continuation, entry = batch.entries[0], record = c.record; requireRule( c.consumed && typeof c.consumed.player === 'string' && (c.consumed.source === undefined || c.consumed.source === 'shrine') && batch.cause === 'truthtrance' && batch.entries.length === 1 && entry.publicFace && entry.card.effect === (c.consumed.source === 'shrine' ? 'karama' : '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' && queuedTruthCardMatches( g.players.find((p) => p.id === e.player)?.hand ?? [], e, ) && 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' || q.kind === 'mixedShipment'; 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' || q.kind === 'mixedShipment') && promise.value.turn === g.turn && !promise.value.fulfilled && (q.kind === 'mixedShipment' ? JSON.stringify(promise.value.mixed) === JSON.stringify(q.mixed) : promise.value.mixed === undefined && 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) && validGameLocation(g, 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 && terrorEntryLocationAllowed(g,entry) && validGameLocation(g, entry.territory,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' || overlapOwnsWormTerror(g, entry)) && 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 === 'terrorAtomics') { const { entry, aftermath, handBefore } = continuation; atomicsIntegrity(g); const owner = byFaction(g, 'moritani'); const token = g.moritaniTerror?.tokens.find(row => row.id === entry?.token); requireRule(!g.pendingTerrorEntry && !g.pendingExchange && !g.summonedWorm && entry && entry.turn === g.turn && entry.phase === g.phase && entry.stage === 'offer' && entry.resume === (entry.cause === 'wormRide' ? 'wormRide' : 'none') && (entry.cause !== 'wormRide' || (g.phase === 1 && g.players.some(player => player.id === entry.entrant && player.faction === 'fremen'))) && ['shipment', 'movement', 'advisor', 'wormRide'].includes(entry.cause) && entry.entrant !== owner?.id && entry.territory === aftermath.territory && validGameLocation(g, entry.territory, entry.sector) && token?.kind === 'atomics' && token.status === 'removed' && token.location === null && batch.cause === 'terror:atomics' && batch.entries.length > 0 && batch.entries.every(row => !row.publicFace && (row.discardedBy === owner?.id || row.discardedBy === aftermath.allyAtActivation)) && Array.isArray(handBefore) && handBefore.length === (aftermath.allyAtActivation ? 2 : 1) && handBefore.every((row, index) => { const id = index === 0 ? owner?.id : aftermath.allyAtActivation; const player = g.players.find(seat => seat.id === id); const discarded = batch.entries.reduce((count, card) => count + Number(card.discardedBy === row.player), 0); return player && row.player === id && Number.isSafeInteger(row.count) && row.count >= 0 && row.count === player.hand.length + discarded && discarded === Math.max(0, row.count - handLimit(player)); }), 'The saved Atomics random discards lost their revealed entry or hand custody.'); } 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)) && combatLocations(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.', ); if (c.kind === 'battleResolved' && context.mandatoryDiscard) { const receipt = context.mandatoryDiscard; requireRule( Array.isArray(receipt.entries) && receipt.entries.length === batch.entries.length && receipt.entries.every(entry => typeof entry.card === 'string' && typeof entry.owner === 'string') && receipt.signature === battleMandatoryDiscardSignature(context) && JSON.stringify(receipt.entries) === JSON.stringify(batch.entries.map(entry => ({ card: entry.card.id, owner: entry.discardedBy, }))), 'The fresh mandatory battle cards no longer match their former owners.', ); } // 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 && (r.source === 'nexus' ? r.owner === c.player && r.event === context.event && !byFaction(g, 'moritani') && context.nexusMoritaniRetention?.stage === 'complete' && context.nexusMoritaniRetention.kept === (c.kept[0] ?? null) && !!g.nexusCards?.cards && (!c.kept.length || g.nexusCards.cards.discard.includes('moritani')) : 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.pendingSukRescue || !!g.homeworlds || 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, fullyCommitted } = c.casualties; const winner = g.players.find((p) => p.id === c.winner); const forceOwner = g.players.find((p) => p.id === (c.casualties!.owner ?? c.winner)); const pool = forceOwner ? combatArmy(g, forceOwner.id, c.territory) : { normal: 0, elite: 0 }; const total = pool.normal + pool.elite; const elites = pool.elite; requireRule( winner && forceOwner && (forceOwner.id === winner.id || (forceOwner.ally === winner.id && winner.ally === forceOwner.id && (winner.faction === 'ecaz' || forceOwner.faction === 'ecaz'))) && (fullyCommitted === undefined || fullyCommitted === true) && forces && validCombatForces(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.eliteFreeSupport === undefined || typeof forces.eliteFreeSupport === '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(fullyCommitted ? casualtyOptions(forces, dial, support).filter(option => option.normal === forces.normal && option.elite === forces.elite) : casualtyOptions(forces, dial, support)), 'The saved battle casualties do not match the committed dial, support and effective forces.', ); } } } else if (continuation?.kind === 'winnerMandatoryDiscard') { const c = continuation; requireRule(g.phase === 6 && g.lastBattleContext?.event === c.event && g.lastBattleContext.winner === c.player && g.lastBattleContext.territory === c.territory && batch.cause === 'battle:winnerMandatory' && !g.pendingWinnerDiscards && c.commitment?.signature === winnerDiscardSignature(c.commitment) && c.commitment.event === c.event && c.commitment.player === c.player && c.commitment.turn === g.turn && c.commitment.territory === c.territory && JSON.stringify(c.commitment.optional) === JSON.stringify(c.optional) && JSON.stringify(c.commitment.cards) === JSON.stringify(batch.entries.map((entry) => entry.card.id)) && batch.entries.every((entry) => entry.discardedBy === c.player && entry.publicFace) && Array.isArray(c.optional) && new Set(c.optional).size === c.optional.length && c.optional.every((id) => getPlayer(g, c.player).hand.some((card) => card.id === id)), 'The mandatory winning card discard no longer matches its resolved battle.'); } else if (continuation?.kind === 'discoveryStash') { const c = continuation; discoveryIntegrity(g); requireRule(batch.cause === 'discovery:stash' && batch.entries.length === 1 && batch.entries[0].discardedBy === c.owner && batch.entries[0].publicFace && batch.entries[0].card.id === c.card, 'The Treachery Card Stash has lost its chosen discard.'); } else if (continuation?.kind === 'nexusChoamTrade') { const c = continuation; nexusChoamTradeIntegrity(g); requireRule(batch.cause === 'nexus:choamTrade' && batch.entries.length === 1 && batch.entries[0].discardedBy === c.owner && batch.entries[0].publicFace && batch.entries[0].card.id === c.card && batch.entries[0].card.kind === 'worthless', 'The CHOAM Nexus trade has changed its discarded Worthless card.'); } else if (continuation?.kind === 'nexusChoamBetrayal') { const c = continuation; nexusChoamBetrayalIntegrity(g); requireRule(batch.cause === 'nexus:choamBetrayal' && batch.entries.length === 1 && batch.entries[0].discardedBy === c.target && !batch.entries[0].publicFace && batch.entries[0].card.id === c.card && g.nexusChoamBetrayals?.at(-1)?.event === c.event, 'The CHOAM Nexus Betrayal has changed its random physical discard.'); } else if (continuation?.kind === 'tupileCleanupDiscard') { const c = continuation, source = c.cleanup; const row = g.homeworldTupileState?.cleanups.find(cleanup => cleanup.event === source.event); const player = getPlayer(g, source.player); requireRule(semutaHomeworldRoster(g) && g.homeworldTupilePreview && !g.pendingHomeworldTupileCleanup && !g.decision && !g.response && !g.phaseOpening && row?.status === 'pending' && row.player === source.player && source.signature === JSON.stringify([source.event, source.player, source.hand, source.opening, g.turn, g.phase]) && batch.cause === 'homeworld:tupileCleanup' && c.cards.length > 0 && batch.entries.length === c.cards.length && batch.entries.every((entry, index) => !entry.publicFace && entry.discardedBy === source.player && entry.card.id === c.cards[index]) && JSON.stringify(player.hand.map(card => card.id)) === JSON.stringify(source.hand.filter(id => !c.cards.includes(id))) && c.stateSignature === tupileDiscardSignature(g, c), 'The private Tupile discard no longer matches its original cleanup.'); const quote = homeworldRule(() => quoteOccupiedTupileCleanup(g, g.homeworldTupileState!, source.event)); requireRule(!quote.blocked && quote.excess === 0, 'The original normal-limit cleanup must be paid before its Semuta reaction.'); } else if (continuation?.kind === 'kaitainDiscard') { const c = continuation; requireRule(g.phase === 3 && g.biddingEnd?.event === c.event && getPlayer(g, c.owner).faction === 'emperor' && batch.cause === 'kaitain' && batch.entries.length > 0 && c.cost === 2 * batch.entries.length && Number.isSafeInteger(c.spiceAfter) && c.spiceAfter >= 0 && getPlayer(g, c.owner).spice === c.spiceAfter && batch.entries.every((entry) => entry.discardedBy === c.owner && !entry.publicFace), 'The committed Kaitain discard no longer matches its payment and closing opportunity.'); } else throw new RuleError('Unknown saved discard continuation.'); if (pending.reaction && g.moritaniRetention && (continuation.kind === 'winnerMandatoryDiscard' || (continuation.kind === 'battleCleanup' && continuation.source === 'winner'))) { const retention = g.moritaniRetention; const owner = getPlayer(g, retention.owner), loser = getPlayer(g, retention.player); requireRule(!g.advanced && retention.source === undefined && retention.stage === 'choose' && owner.faction === 'moritani' && owner.ally === loser.id && loser.ally === owner.id, 'The pending native allied retention no longer matches the preceding winner discard.'); currentBattleAftermathQuote(g); } } /** Bounded public single-card reaction after completed effects. Battle cleanup * preserves the original winner queue and bounded native retention/rewards without replaying them. * Final flight arrival may resume native Intrusion, but other entry responses remain gated. */ function semutaOfferSupported( g: Game, continuation: NonNullable['continuation'], entries: FreshDiscardBatch['entries'], ) { if (continuation.kind !== 'ordinaryCardDiscard' && continuation.kind !== 'nullentropyDiscard' && continuation.kind !== 'distransDiscard' && continuation.kind !== 'terrorDiscard' && continuation.kind !== 'nexusChoamTrade' && continuation.kind !== 'nexusChoamBetrayal' && continuation.kind !== 'saphoMovementDiscard' && continuation.kind !== 'saphoAggressorDiscard' && continuation.kind !== 'saphoAuctionDiscard' && continuation.kind !== 'saphoBattleOrderDiscard' && continuation.kind !== 'residualPoisonDiscard' && continuation.kind !== 'karamaCharityDiscard' && continuation.kind !== 'karamaInflationDiscard' && continuation.kind !== 'karamaBgCharityDiscard' && continuation.kind !== 'karamaBattleDiscard' && continuation.kind !== 'karamaChoamWorthlessDiscard' && continuation.kind !== 'karamaEmperorShareDiscard' && continuation.kind !== 'karamaIncomeDiscard' && continuation.kind !== 'kaitainDiscard' && continuation.kind !== 'tupileCleanupDiscard' && continuation.kind !== 'karamaPurchaseDiscard' && continuation.kind !== 'karamaPaymentDiscard' && continuation.kind !== 'thumperDiscard' && continuation.kind !== 'amalDiscard' && continuation.kind !== 'ixAllyCard' && continuation.kind !== 'choamSaleDiscard' && continuation.kind !== 'choamKulonDiscard' && continuation.kind !== 'choamLaLaLaDiscard' && continuation.kind !== 'choamBalisetDiscard' && continuation.kind !== 'choamGamontDiscard' && continuation.kind !== 'choamJubbaDiscard' && !(continuation.kind === 'battleCleanup' && (continuation.source === 'winner' || (!g.advanced && continuation.source === 'moritani' && continuation.retention?.source === undefined))) && continuation.kind !== 'winnerMandatoryDiscard' && continuation.kind !== 'battleResolved' && !(continuation.kind === 'ornithopterDiscard' && (continuation.source === 'end' || (continuation.source === 'move' && !g.players.some(p => p.faction === 'ecaz' || p.faction === 'moritani') && !g.pendingArrivalOverlap))) && !(continuation.kind === 'truthtranceDiscard' && continuation.consumed.source === undefined && (continuation.promise === null || continuation.promise.kind === 'shipment' || continuation.promise.kind === 'battle'))) return false; if (g.homeworlds && continuation.kind !== 'kaitainDiscard' && continuation.kind !== 'tupileCleanupDiscard' && continuation.kind !== 'karamaIncomeDiscard' && continuation.kind !== 'ordinaryCardDiscard' && continuation.kind !== 'nullentropyDiscard' && continuation.kind !== 'distransDiscard' && continuation.kind !== 'truthtranceDiscard' && continuation.kind !== 'choamLaLaLaDiscard' && continuation.kind !== 'choamGamontDiscard' && continuation.kind !== 'choamKulonDiscard' && continuation.kind !== 'choamBalisetDiscard' && continuation.kind !== 'choamJubbaDiscard' && continuation.kind !== 'karamaChoamWorthlessDiscard' && continuation.kind !== 'choamSaleDiscard' && continuation.kind !== 'ornithopterDiscard' && continuation.kind !== 'saphoAuctionDiscard' && continuation.kind !== 'saphoMovementDiscard' && continuation.kind !== 'battleResolved' && continuation.kind !== 'winnerMandatoryDiscard' && continuation.kind !== 'battleCleanup' && continuation.kind !== 'residualPoisonDiscard' && continuation.kind !== 'saphoAggressorDiscard' && continuation.kind !== 'saphoBattleOrderDiscard' && continuation.kind !== 'karamaCharityDiscard' && continuation.kind !== 'karamaInflationDiscard' && continuation.kind !== 'karamaPurchaseDiscard' && continuation.kind !== 'karamaPaymentDiscard' && continuation.kind !== 'karamaBgCharityDiscard' && continuation.kind !== 'karamaEmperorShareDiscard' && continuation.kind !== 'karamaBattleDiscard') return false; if (continuation.kind === 'battleResolved' && !g.lastBattleContext?.mandatoryDiscard) return false; if ((continuation.kind === 'battleCleanup' || continuation.kind === 'winnerMandatoryDiscard' || continuation.kind === 'battleResolved') && ((g.pendingWinnerDiscards && continuation.kind !== 'battleResolved') || (g.moritaniRetention && (g.advanced || g.moritaniRetention.source !== undefined || g.moritaniRetention.stage !== 'choose' || continuation.kind === 'battleResolved')) || g.pendingIxSubstitution)) return false; if (continuation.kind === 'terrorDiscard' && (continuation.entry.resume !== 'none' || (continuation.entry.cause !== 'shipment' && continuation.entry.cause !== 'movement') || g.pendingArrivalOverlap || g.pendingChoamMove || g.pendingIxMove || g.pendingFremenMove || g.ornithopter || g.summonedWorm || g.wormRides.length > 0)) return false; if ((continuation.kind === 'distransDiscard' || continuation.kind === 'truthtranceDiscard' || continuation.kind === 'saphoMovementDiscard' || (continuation.kind === 'ornithopterDiscard' && continuation.source === 'move')) && (g.pendingChoamMove || g.pendingIxMove || g.pendingFremenMove || g.ornithopter || g.summonedWorm || g.wormRides.length > 0)) return false; if (continuation.kind === 'saphoAggressorDiscard' && (!g.battle || g.pendingAuditor || g.pendingCapture || g.pendingTech || g.pendingFaceDance || g.pendingChoamBattleIncome || g.pendingWinnerDiscards || g.moritaniRetention || g.pendingIxSubstitution)) return false; if (continuation.kind === 'residualPoisonDiscard' && (!g.battle?.preLeader || g.battle.preLeader.closed || g.battle.preLeader.ready.length > 0 || g.battle.preparation || g.battle.prescience || g.battle.nexusInspection || g.battle.truthPromises?.length || g.pendingAuditor || g.pendingCapture || g.pendingTech || g.pendingFaceDance || g.pendingChoamBattleIncome || g.pendingWinnerDiscards || g.moritaniRetention || g.pendingIxSubstitution)) return false; if (continuation.kind === 'thumperDiscard' && (g.summonedBeforeBlow || g.summonedWorm || g.wormRides.length > 0 || g.pendingFremenMove || g.pendingIxMove || g.pendingChoamMove)) return false; return g.semutaPreview === true && g.status === 'playing' && g.players.some(p => p.faction === 'richese') && entries.length > 0 && (continuation.kind === 'kaitainDiscard' || continuation.kind === 'tupileCleanupDiscard' ? entries.every(entry => !entry.publicFace) : continuation.kind === 'ixAllyCard' || continuation.kind === 'terrorDiscard' || continuation.kind === 'nexusChoamBetrayal' || choamCunningDiscard(continuation) ? entries.length === 1 && !entries[0].publicFace : continuation.kind === 'battleCleanup' || continuation.kind === 'winnerMandatoryDiscard' || continuation.kind === 'battleResolved' ? entries.every(entry => entry.publicFace) : entries.length === 1 && entries[0].publicFace) && (continuation.kind === 'karamaCharityDiscard' || continuation.kind === 'karamaInflationDiscard' || continuation.kind === 'karamaBgCharityDiscard' || continuation.kind === 'karamaBattleDiscard' || continuation.kind === 'karamaChoamWorthlessDiscard' || continuation.kind === 'karamaEmperorShareDiscard' || continuation.kind === 'karamaIncomeDiscard' ? (continuation.kind === 'karamaBattleDiscard' ? semutaBattleKaramaResponse(g, continuation.use.response) : continuation.kind === 'karamaChoamWorthlessDiscard' ? semutaChoamKaramaResponse(g, continuation.use.response) : continuation.kind === 'karamaEmperorShareDiscard' ? semutaEmperorShareKaramaResponse(g, continuation.use.response) : continuation.kind === 'karamaIncomeDiscard' ? semutaIncomeKaramaResponse(g, continuation.use.response) : ['choamCharity', 'choamInflation', 'bgCharity'].includes(continuation.resume.response?.kind ?? '')) && !continuation.resume.decision && !continuation.resume.pendingKarama && !continuation.resume.phaseOpening && !g.pendingKarama && (!g.response || JSON.stringify(g.response) === JSON.stringify(continuation.resume.response)) : true) && (continuation.kind === 'battleCleanup' || continuation.kind === 'winnerMandatoryDiscard' || continuation.kind === 'battleResolved' || continuation.kind === 'kaitainDiscard' || continuation.kind === 'tupileCleanupDiscard' || continuation.kind === 'ixAllyCard' || continuation.kind === 'terrorDiscard' || continuation.kind === 'nexusChoamTrade' || continuation.kind === 'nexusChoamBetrayal' || continuation.kind === 'choamSaleDiscard' || continuation.kind === 'choamKulonDiscard' || continuation.kind === 'choamLaLaLaDiscard' || continuation.kind === 'choamBalisetDiscard' || continuation.kind === 'choamGamontDiscard' || continuation.kind === 'choamJubbaDiscard' || (continuation.kind === 'karamaCharityDiscard' || continuation.kind === 'karamaInflationDiscard' || continuation.kind === 'karamaBgCharityDiscard' || continuation.kind === 'karamaBattleDiscard' || continuation.kind === 'karamaChoamWorthlessDiscard' || continuation.kind === 'karamaEmperorShareDiscard' || continuation.kind === 'karamaIncomeDiscard' || (!continuation.resume.response && !continuation.resume.decision && !continuation.resume.pendingKarama && (continuation.kind === 'amalDiscard' ? continuation.resume.phaseOpening?.passed.length === 0 : !continuation.resume.phaseOpening)))) && !g.karamaShipping && (continuation.kind === 'ixAllyCard' ? !!g.currentAuctionSale && continuation.sale.origin === g.currentAuctionSale.origin && (continuation.sale.origin === 'normal' ? !!g.auction && !g.richeseAuction : continuation.sale.origin === 'blackMarket' && g.richeseAuction?.source === 'blackMarket' && !g.auction) : continuation.kind === 'karamaPurchaseDiscard' || continuation.kind === 'karamaPaymentDiscard' ? !!g.auction && !g.currentAuctionSale && !g.richeseAuction : continuation.kind === 'karamaIncomeDiscard' && continuation.use.response.kind === 'emperorIncome' ? !!g.currentAuctionSale && (g.currentAuctionSale.origin === 'normal' ? !!g.auction && !g.richeseAuction : !!g.richeseAuction && !g.auction) : !g.auction) && (continuation.kind === 'saphoAuctionDiscard' || continuation.kind === 'ixAllyCard' || (continuation.kind === 'karamaIncomeDiscard' && continuation.use.response.kind === 'emperorIncome') || !g.richeseAuction) && (continuation.kind === 'saphoAggressorDiscard' || continuation.kind === 'residualPoisonDiscard' || continuation.kind === 'truthtranceDiscard' || continuation.kind === 'nullentropyDiscard' || continuation.kind === 'distransDiscard' || (continuation.kind === 'ordinaryCardDiscard' && continuation.effect === 'ghola') || !g.battle) && (continuation.kind !== 'choamKulonDiscard' || (!g.ornithopter && !g.pendingChoamMove && !g.summonedWorm && g.wormRides.length === 0)) && (continuation.kind !== 'choamLaLaLaDiscard' || (!g.pendingRevival && !g.pendingChoamMove && !g.ornithopter)) && (continuation.kind !== 'choamBalisetDiscard' || (!g.pendingChoamMove && !g.ornithopter && !g.pendingArrivalOverlap)) && (continuation.kind !== 'choamGamontDiscard' || (!g.pendingChoamMove && !g.pendingArrivalOverlap && !g.pendingRevival && !g.ornithopter)) && (continuation.kind !== 'choamJubbaDiscard' || (!!g.stormResolution && !g.pendingChoamMove && !g.pendingArrivalOverlap && !g.pendingRevival && !g.ornithopter && !g.ecologicalStorm)) && !g.pendingExchange && !g.pendingIxAlly && !g.pendingNullentropy && !g.pendingAmbassador && !g.pendingTerrorEntry && !g.pendingShipment && !g.pendingHomeworldShipment && !g.pendingRicheseGift && ((continuation.kind === 'karamaIncomeDiscard' && continuation.use.response.kind === 'richesePurchaseIncome') || !g.pendingRichesePurchaseIncome) && (continuation.kind === 'choamSaleDiscard' ? !!g.choamMarket && !g.choamMarket.sale && !g.choamMarket.trade && !g.biddingEnd && !g.currentAuctionSale : continuation.kind === 'kaitainDiscard' ? g.biddingEnd?.event === continuation.event && biddingEndError(g) === null : !g.choamMarket) && !g.pendingChoamMarketGhola; } function semutaContext(g: Game, owner: Player): SemutaContext { const pending = g.pendingTreacheryDiscard!; return { batch: pending.batch, event: pending.batch.event, turn: g.turn, phase: g.phase, owner: owner.id, ownerHand: owner.hand, discard: g.discard, semutaId: SEMUTA_DRUG_ID, handLimit: handLimit(owner), incomingReservedSlots: 0, reservedTargetIds: [], }; } function semutaCandidates(g: Game, owner: Player) { const context = semutaContext(g, owner); const semuta = owner.hand.find(card => card.id === SEMUTA_DRUG_ID && richeseCardDefinition(card)?.card.effect === 'semutaDrug'); requireRule(semuta && !transferCardBlock(g, owner, semuta), 'This Semuta Drug is reserved for an unfinished transaction.'); try { return committedSemutaCandidates(context, { event: context.event, player: owner.id, semutaId: SEMUTA_DRUG_ID }); } catch { throw new RuleError('This fresh Semuta claim is not available.'); } } function projectedSemutaReaction(g: Game, me: Player) { const pending = g.pendingTreacheryDiscard; const reaction = pending?.reaction; if (!pending || !reaction) return null; if (reaction.stage === 'select') return { event: pending.batch.event, stage: 'select' as const, passed: true, canCommit: false, blocked: null, candidates: reaction.player === me.id ? semutaCandidates(g, me) : [] as Card[], }; const passed = reaction.passed.includes(me.id); const semuta = me.hand.find(card => card.id === SEMUTA_DRUG_ID && richeseCardDefinition(card)?.card.effect === 'semutaDrug'); const held = !!semuta; const blocked = !held || passed ? null : transferCardBlock(g, me, semuta!) ?? (me.hand.length > handLimit(me) ? 'Resolve your excess hand cards before using Semuta Drug.' : pending.batch.entries.every(entry => entry.discardedBy === me.id) ? 'No other player discarded a card in this fresh event.' : null); return { event: pending.batch.event, stage: 'offer' as const, passed, canCommit: held && !passed && !blocked, blocked, candidates: [] as Card[], }; } 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), }, ...(semutaOfferSupported(g, continuation, entries) ? { reaction: { stage: 'offer' as const, passed: [] as string[] } } : {}), continuation: structuredClone(continuation), }; treacheryDiscardIntegrity(g); } /** Retire a completed fresh discard before its typed continuation resumes. */ function finishTreacheryDiscard(g: Game, claim?: { player: string; card: string }, finalPass?: string) { treacheryDiscardIntegrity(g); const pending = g.pendingTreacheryDiscard; if (!pending) return; const next = pending.continuation; requireRule(!pending.reaction || (claim ? pending.reaction.stage === 'select' && pending.reaction.player === claim.player && pending.reaction.candidates.includes(claim.card) : pending.reaction.stage === 'offer' && finalPass !== undefined && pending.reaction.passed.length === g.players.length - 1 && g.players.some(p => p.id === finalPass) && !pending.reaction.passed.includes(finalPass)), 'Finish the fresh discard reaction before its continuation.'); const owner = claim ? getPlayer(g, claim.player) : null; const result = claim && owner ? resolveSemutaDrug(semutaContext(g, owner), claim.card) : null; // Retire before the Semuta transfer or any suffix can draw the fresh card. g.resolvedTreacheryDiscardSequence = pending.sequence; g.pendingTreacheryDiscard = null; if (result && owner) { owner.hand = result.ownerHand; g.discard = result.discard; log(g, `${owner.name} played Semuta Drug and recovered one freshly discarded Treachery Card. The acquired card stays private.`, { faction: owner.faction, name: 'Semuta Drug' }); } if (next.kind === 'winnerMandatoryDiscard') { finishWinner(g, getPlayer(g, next.player), next.territory, next.optional); return; } if (next.kind === 'discoveryStash') { g.discoveryStash!.stage = 'complete'; g.discoveryStash!.signature = discoveryStashSignature(g.discoveryStash!); return; } if (next.kind === 'nexusChoamTrade') { const record = g.nexusChoamTrades!.at(-1)!; record.stage = 'complete'; record.signature = nexusChoamTradeSignature(record); g.nexusChoamTradeLast = { event: record.event, stage: record.stage }; return; } if (next.kind === 'nexusChoamBetrayal') { const record = g.nexusChoamBetrayals!.at(-1)!; record.stage = 'complete'; record.signature = nexusChoamBetrayalSignature(record); g.nexusChoamBetrayalLast = { event: record.event, stage: record.stage }; return; } if (next.kind === 'kaitainDiscard') return; if (next.kind === 'tupileCleanupDiscard') { finishTupileCleanup(g, next.cleanup, next.cards.length); return; } 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 === 'choamSaleDiscard') { resumeChoamMarket(g); } else if (next.kind === 'choamKulonDiscard') { completeChoamKulon(g, getPlayer(g, next.player)); } else if (next.kind === 'choamLaLaLaDiscard') { completeChoamLaLaLa(g, next.target); if (next.stoppedRevival) log(g, 'The free revival request was stopped without moving forces or paying spice. Choose a paid revival if desired.'); } else if (next.kind === 'choamBalisetDiscard') { completeChoamBaliset(g, next.target, next.territory); if (next.stoppedMovement) log(g, 'The declared move was prevented without moving forces or spending a movement.'); } else if (next.kind === 'choamGamontDiscard') { completeChoamGamont(g, getPlayer(g, next.player), next.target, next.location, next.elite ?? 0, next.noFieldEvent); if (next.mentat) g.decision = { kind: 'choamMentat', player: next.player }; } else if (next.kind === 'choamJubbaDiscard') { completeChoamJubba(g, getPlayer(g, next.player), next.territory); offerChoamStorm(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; if (g.pendingChoamMarketGhola?.discardSequence === pending.sequence) g.pendingChoamMarketGhola.stage = g.response ? 'income' : 'complete'; } 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 === 'distransDiscard') { g.response = next.resume.response; g.decision = next.resume.decision; g.pendingKarama = next.resume.pendingKarama; g.phaseOpening = next.resume.phaseOpening; } else if (next.kind === 'saphoMovementDiscard') { g.response = next.resume.response; g.decision = next.resume.decision; g.pendingKarama = next.resume.pendingKarama; g.phaseOpening = next.resume.phaseOpening; if (g.active !== g.movementRemaining?.[0]) movementTurn(g); } else if (next.kind === 'saphoAuctionDiscard') { g.response = next.resume.response; g.decision = next.resume.decision; g.pendingKarama = next.resume.pendingKarama; g.phaseOpening = next.resume.phaseOpening; settleRicheseLot(g); } else if (next.kind === 'saphoBattleOrderDiscard') { g.response = next.resume.response; g.decision = next.resume.decision; g.pendingKarama = next.resume.pendingKarama; g.phaseOpening = next.resume.phaseOpening; } else if (next.kind === 'saphoAggressorDiscard') { g.response = next.resume.response; g.decision = next.resume.decision; g.pendingKarama = next.resume.pendingKarama; g.phaseOpening = next.resume.phaseOpening; } else if (next.kind === 'residualPoisonDiscard') { g.response = next.resume.response; g.decision = next.resume.decision; g.pendingKarama = next.resume.pendingKarama; g.phaseOpening = next.resume.phaseOpening; reconcileChangedBattleInspections(g, 'the leader death'); const battle = g.battle!; if (battle.preLeader && !battle.preLeader.closed) battle.preLeader.ready = battle.preLeader.ready.filter(id => id !== next.player); } else if (next.kind === 'karamaCharityDiscard' || next.kind === 'karamaInflationDiscard' || next.kind === 'karamaBgCharityDiscard' || next.kind === 'karamaBattleDiscard' || next.kind === 'karamaChoamWorthlessDiscard' || next.kind === 'karamaEmperorShareDiscard' || next.kind === 'karamaIncomeDiscard' || next.kind === 'karamaPurchaseDiscard' || next.kind === 'karamaPaymentDiscard') { g.response = next.resume.response; g.decision = next.resume.decision; g.pendingKarama = next.resume.pendingKarama; g.phaseOpening = next.resume.phaseOpening; const use: KaramaUse = 'use' in next ? next.use : { kind: next.kind === 'karamaPurchaseDiscard' ? 'purchase' : 'auctionPayment' }; completeKarama(g, getPlayer(g, next.player), use); } else if (next.kind === 'amalDiscard') { g.response = next.resume.response; g.decision = next.resume.decision; g.pendingKarama = next.resume.pendingKarama; g.phaseOpening = next.resume.phaseOpening; } else if (next.kind === 'thumperDiscard') { g.response = next.resume.response; g.decision = next.resume.decision; g.pendingKarama = next.resume.pendingKarama; g.phaseOpening = next.resume.phaseOpening; blowSpice(g, true); } 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 if (next.kind === 'terrorAtomics') { g.pendingTerrorEntry = next.entry; finishTerrorEntry(g); } else if (next.kind === 'battleCleanup' && next.source === 'winner' && g.moritaniSpecialKarama?.stage === 'discard' && next.event === g.moritaniSpecialKarama.source.event) { resumeMoritaniSpecialKarama(g); } else { finishBattle(g); } } function applySemutaReaction(g: Game, id: string, action: Action) { const pending = g.pendingTreacheryDiscard; const reaction = pending?.reaction; requireRule(pending && reaction && action.event === pending.batch.event, 'This fresh discard reaction is no longer available.'); const player = getPlayer(g, id); if (reaction.stage === 'offer') { requireRule(!reaction.passed.includes(id), 'You already passed this discard.'); if (action.type === 'semutaPass') { requireRule(Object.keys(action).sort().join(',') === 'event,type', 'Pass only the current fresh discard.'); if (reaction.passed.length + 1 === g.players.length) finishTreacheryDiscard(g, undefined, id); else reaction.passed.push(id); return; } requireRule(action.type === 'semutaCommit' && Object.keys(action).sort().join(',') === 'event,type', 'Choose whether to commit Semuta to this discard.'); const candidates = semutaCandidates(g, player).map(card => card.id); pending.reaction = { stage: 'select', player: id, candidates }; if (candidates.length === 1) finishTreacheryDiscard(g, { player: id, card: candidates[0] }); else log(g, `${player.name} committed Semuta Drug to this fresh discard and must choose one card privately.`, { faction: player.faction, name: 'Semuta Drug' }); return; } requireRule(action.type === 'semutaSelect' && Object.keys(action).sort().join(',') === 'card,event,type' && id === reaction.player && typeof action.card === 'string' && reaction.candidates.includes(action.card), 'Choose one committed Semuta card from this fresh discard.'); finishTreacheryDiscard(g, { player: id, card: action.card }); } 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 withdrawNativeReserves( g: Game, p: Player, amount: number, elite: number, selections?: NativeReserveSelections, ) { if (!g.homeworlds?.custody) { requireRule( selections === undefined, 'Homeworld source selection requires the Homeworld module.', ); p.reserves -= amount; if (p.elites) p.elites.reserves -= elite; return []; } const result = homeworldRule(() => quoteNativeReserveWithdrawal( homeworldContext(g), g.homeworlds!.custody!, p.id, { normal: amount - elite, elite }, selections, ), ); const changed = result.players.find((seat) => seat.id === p.id)!; p.reserves = changed.reserves; if (p.elites) p.elites.reserves = changed.eliteReserves; g.homeworlds.custody = result.state; // All Homeworld changes are committed here; the destination is on Arrakis. observeOccupation(g); return result.receipts; } /** Adds counters to a faction's reserves, depositing into the native Homeworld * custody when Homeworlds are enabled. Used by revival and by the authorized * Nexus Secret Ally reserve return (user ruling 7 October 2026). */ function addNativeReserves(g: Game, p: Player, amount: number, elite: number) { if (!g.homeworlds?.custody) { p.reserves += amount; if (p.elites) p.elites.reserves += elite; return; } const result = homeworldRule(() => quoteNativeReserveDeposit( homeworldContext(g), g.homeworlds!.custody!, p.id, { normal: amount - elite, elite }, ), ); const changed = result.players.find((seat) => seat.id === p.id)!; p.reserves = changed.reserves; if (p.elites) p.elites.reserves = changed.eliteReserves; g.homeworlds.custody = result.state; observeOccupation(g); } 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 homeworldSavedDecisions(g: Game): Decision[] { const continuation = g.pendingTreacheryDiscard?.continuation; return [g, g.pendingExchange, g.pendingNullentropy?.resume, g.pendingRicheseGift?.resume, g.pendingRichesePurchaseIncome?.resume, g.summonedWorm?.resume, continuation && 'resume' in continuation ? continuation.resume : null, ].flatMap((context) => context?.decision ? [context.decision] : []); } function spiritualAdvisorMaximum(g: Game, player: string, destination = 'polar_sink') { return homeworldRule(() => homeworldSpiritualAdvisorLimit(g, player, destination)); } function homeworldMobilityIntegrity(g: Game) { if (!g.homeworlds?.custody) return; 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) => { if (!context) return []; const pending = 'pendingKarama' in context ? context.pendingKarama as Game['pendingKarama'] : null; return [context.response, pending?.use?.kind === 'cancel' ? pending.use.response : null]; }); for (const response of responses) { if (response?.kind === 'advisor') { const destination = splitLocation(response.location ?? 'polar_sink:0'); integer(response.amount === undefined ? 1 : response.amount, 1, spiritualAdvisorMaximum(g, response.owner, destination.territory), 'Saved Spiritual Advisor forces'); } if (response?.kind === 'atreidesSpice') { const blocked = homeworldRule(() => homeworldMovementForesightBlock(g, response.owner)); requireRule(!blocked, blocked ?? 'Caladan prevents the saved foresight response.'); } if (response?.kind === 'mobileStronghold') { const blocked = homeworldRule(() => homeworldMobileStrongholdMovementBlock(g, response.owner)); requireRule(!blocked, blocked ?? 'Ix prevents the saved mobile stronghold response.'); } } } function homeworldDefenseIntegrity(g: Game) { const b = g.battle; const decisions = homeworldSavedDecisions(g).filter((d) => d.kind === 'homeworldDefense'); if (!decisions.length && b?.homeworldDefensePassed === undefined) return; requireRule(g.status === 'playing' && g.phase === 6 && b?.revealed && b.territory.startsWith('homeworld:') && b.event, 'The Homeworld late-defense frame needs its current revealed battle.'); const voters = traitorVoters(g, b); const passed = b.homeworldDefensePassed ?? []; requireRule(Array.isArray(passed) && new Set(passed).size === passed.length && passed.every((id) => [b.attacker, b.defender].includes(id) && !voters.includes(id)), 'Homeworld late-defense passes must belong to distinct invading combatants.'); for (const d of decisions) { requireRule(d.event === b.event && [b.attacker, b.defender].includes(d.player) && !voters.includes(d.player) && !passed.includes(d.player) && voters.every((id) => b.traitorCalls[id] !== undefined), 'The saved Homeworld late-defense choice does not match its battle owner and timing.'); const owner = getPlayer(g, d.player); const card = owner.hand.find(isPortableSnooper); requireRule(card && !b.lateDefense?.[owner.id] && !portableSnooperPlanBlock(b.plans[owner.id], owner.hand, card, b.voice?.target === owner.id ? b.voice : undefined), 'The saved late-defense opportunity needs its held legal Portable Snooper.'); } } function faceDanceReturnBlock(g: Game, winner: string): string | null { const protectedWinner = homeworldRule(() => quoteOccupiedHomeworldFaceDancer(g, winner)); if (protectedWinner.blocked) return protectedWinner.blocked; const coside = g.pendingFaceDance?.winner === winner ? g.pendingFaceDance.winners : undefined; if (coside) for (const target of coside) { if (target === winner) continue; const quote = homeworldRule(() => quoteOccupiedHomeworldFaceDancer(g, target)); if (quote.blocked) return quote.blocked; } return g.advanced && g.homeworlds && getPlayer(g, winner).faction === 'emperor' ? 'Face Dance returning the Emperor’s army awaits the Kaitain/Salusa reserve-placement ruling.' : null; } function homeworldSubstitutionIntegrity(g: Game) { const pending = g.pendingIxSubstitution; if (!pending?.territory.startsWith('homeworld:')) { requireRule(!pending?.homeworld, 'A native substitution receipt requires a Homeworld battle.'); return; } const receipt = pending.homeworld; const p = getPlayer(g, pending.player); const context = g.lastBattleContext; requireRule(receipt && p.faction === 'ixians' && g.phase === 6 && !g.battle && context?.turn === g.turn && context.territory === pending.territory && context.winner === p.id && context.result === 'normal', 'The Homeworld substitution needs its completed Ixian battle receipt.'); const pool = combatArmy(g, p.id, pending.territory); requireRule(receipt.pool && pool.normal === receipt.pool.normal && pool.elite === receipt.pool.elite && p.elites!.tanks === receipt.eliteTanks && p.tanks - p.elites!.tanks === receipt.normalTanks && p.battleLosses === receipt.battleLosses && Number.isSafeInteger(receipt.cyborgsLost) && receipt.cyborgsLost > 0 && Object.keys(pending.losses).length === 1 && pending.losses[pending.territory] === receipt.cyborgsLost, 'The Homeworld substitution no longer matches its exact survivors and Tanks.'); const maps = [pending.sources, pending.recover]; requireRule(maps.every((map) => map === undefined) || maps.every((map) => map && Object.keys(map).length === 1 && Object.hasOwn(map, pending.territory)), 'A Homeworld substitution must use its exact battle location.'); if (pending.sources && pending.recover) { const amount = pending.sources[pending.territory]; requireRule(amount === pending.recover[pending.territory], 'The substitution must exchange matching physical counts.'); quoteHomeworldSubstitution(homeworldLossContext(g), g.homeworlds!.custody!, {location: pending.territory, player: p.id, amount, cyborgsLost: receipt.cyborgsLost}); } else quoteHomeworldLoss(g, p.id, pending.territory, {normal: 0, elite: 0}); } function currentHomeworldBattleRules(g: Game, to: string) { const home = homeworldBattleLocation(g, to); return home ? { native: home.native, card: home.card, side: home.side, nativeForces: { ...home.forces[home.native] } } : null; } function quoteHomeworldLoss(g: Game, player: string, to: string, losses: HomeworldForces) { requireRule(g.homeworlds?.custody, 'Homeworld casualties require saved custody.'); return quoteHomeworldCombatLoss(homeworldLossContext(g), g.homeworlds.custody, { location: to, player, losses }); } function homeworldLossContext(g: Game) { return { advanced: g.advanced, players: g.players.map((p) => { const total = Object.values(p.forces).reduce((a, b) => a + b, 0); const elite = Object.values(p.elites?.forces ?? {}).reduce((a, b) => a + b, 0); return { id: p.id, faction: p.faction, reserves: p.reserves, eliteReserves: p.elites?.reserves ?? 0, tanks: p.tanks, eliteTanks: p.elites?.tanks ?? 0, battleLosses: p.battleLosses, boardForces: { normal: total - elite, elite } }; }) }; } function commitHomeworldLoss(g: Game, player: string, to: string, losses: HomeworldForces) { const quote = quoteHomeworldLoss(g, player, to, losses); commitHomeworldResources(g, quote); } function commitHomeworldResources(g: Game, quote: Pick, 'custody' | 'players'>) { g.homeworlds!.custody = quote.custody; for (const update of quote.players) { const p = getPlayer(g, update.id); p.reserves = update.reserves; p.tanks = update.tanks; p.battleLosses = update.battleLosses; if (p.elites) { p.elites.reserves = update.eliteReserves; p.elites.tanks = update.eliteTanks; } } } /** The location's exact typed pool remains fixed while casualty/card choices * wait. No population, support, death or payment is replayed on restoration. */ function homeworldBattleLossIntegrity(g: Game) { const pending = g.homeworldBattleLoss; const decisions = homeworldSavedDecisions(g).filter((d) => d.kind === 'homeworldExplosion' || (d.kind === 'battleLosses' && d.territory.startsWith('homeworld:'))); if (!pending) { requireRule(!decisions.length, 'The saved Homeworld casualty receipt is missing.'); return; } const context = g.lastBattleContext; requireRule(g.phase === 6 && !g.battle && context && context.turn === g.turn && context.event === pending.event && context.territory === pending.territory && pending.territory.startsWith('homeworld:') && ((pending.kind === 'winner' && context.result === 'normal' && context.winner === pending.player) || (pending.kind === 'explosion' && context.result === 'explosion' && context.winner === null)), 'The Homeworld casualty receipt does not match its resolved battle.'); const pool = combatArmy(g, pending.player, pending.territory); requireRule(pending.pool && Object.keys(pending.pool).length === 2 && pool.normal === pending.pool.normal && pool.elite === pending.pool.elite, 'The Homeworld casualty receipt does not match its exact physical pool.'); let options: HomeworldForces[]; if (pending.kind === 'explosion') { const rules = currentHomeworldBattleRules(g, pending.territory)!; requireRule(rules.native === pending.player && !pending.commitment, 'Native explosion casualties belong to this Homeworld’s native faction.'); options = quoteHomeworldBattleRules(pending.territory, g.players, rules).explosion.options; } else { const commitment = pending.commitment; requireRule(commitment && commitment.forces.normal === pool.normal && commitment.forces.elite === pool.elite, 'The Homeworld winner needs its committed physical forces.'); options = casualtyOptions(commitment.forces, commitment.dial, commitment.support).map(({normal, elite}) => ({normal, elite})); } requireRule(options.length > 0 && JSON.stringify(options) === JSON.stringify(pending.options), 'The Homeworld casualty choices do not match the committed outcome.'); for (const choice of options) quoteHomeworldLoss(g, pending.player, pending.territory, choice); for (const decision of decisions) { if (decision.kind !== 'homeworldExplosion' && decision.kind !== 'battleLosses') continue; requireRule(decision.player === pending.player && decision.territory === pending.territory && (decision.kind === 'homeworldExplosion' ? pending.kind === 'explosion' && decision.event === pending.event && JSON.stringify(decision.pool) === JSON.stringify(pending.pool) : pending.kind === 'winner') && JSON.stringify(decision.options.map(({normal, elite}) => ({normal, elite}))) === JSON.stringify(options), 'The owned Homeworld casualty decision does not match its receipt.'); } } function settleHomeworldExplosion(g: Game, choice: HomeworldForces, automatic: boolean) { homeworldBattleLossIntegrity(g); homeworldSubstitutionIntegrity(g); homeworldDefenseIntegrity(g); homeworldShipmentIntegrity(g); const pending = g.homeworldBattleLoss!; requireRule(pending.kind === 'explosion' && pending.options.some((p) => p.normal === choice.normal && p.elite === choice.elite), 'Choose a native explosion casualty allocation.'); commitHomeworldLoss(g, pending.player, pending.territory, choice); observeOccupation(g); const player = getPlayer(g, pending.player); log(g, `${player.name} lost ${choice.normal} normal and ${choice.elite} special forces on ${combatLocationName(g, pending.territory)}. The remaining native forces survived the Lasgun–shield explosion.${automatic ? ' The only physical allocation was applied automatically.' : ''}`, automatic ? { faction: player.faction, name: 'Native explosion casualties' } : undefined); g.homeworldBattleLoss = null; finishBattle(g); } function killTerritory( g: Game, p: Player, t: string, n = Infinity, battle = false, noFieldCause?: NoFieldRevealCause, ) { if (t.startsWith('homeworld:')) { requireRule(battle && n === Infinity, 'Homeworld force removal requires an exact combat casualty allocation.'); commitHomeworldLoss(g, p.id, t, combatArmy(g, p.id, t)); return; } 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 army = combatArmy(g, p.id, t); const physical = army.normal + army.elite; const elite = army.elite; const occupation = g.homeworldOccupationPreview && p.faction === 'emperor' ? homeworldRule(() => occupiedHomeworldSardaukarStatus(g)) : undefined; requireRule(!occupation?.blocked, occupation?.blocked ?? 'Occupied Salusa combat is unresolved.'); return { normal: physical + prospective - elite, ...(nexusSardaukarEffective(g,p.id,t) ? {temporaryElite:nexusSardaukarEffective(g,p.id,t)} : {}), ...(!occupation?.suppressed && homeworldSardaukarFreeSupport(g, p.id) ? { eliteFreeSupport: true } : {}), normalFixedHalf: p.faction === 'ixians' && !nexusSuboidsActive(g, g.nexusSuboidHistory, p.id), ...(nexusSuboidsActive(g, g.nexusSuboidHistory, p.id) ? { normalFreeSupport: true } : {}), elite, eliteStrength: (!g.advanced && p.faction !== 'ixians') || (g.battle?.eliteBlocked?.includes(p.id) && !(g.advanced && p.faction === 'ixians')) || occupation?.suppressed || (p.faction === 'emperor' && opponent.faction === 'fremen') ? 1 : 2, freeSupport: !g.advanced || (p.faction === 'fremen' && !g.battle?.fremenSupportBlocked), }; } function occupyArmiesConnected(g: Game, ecaz: Player, ally: Player, t: string): boolean { const marker = ally.noField?.deployed; for (const x in ecaz.forces) { if (ecaz.forces[x] <= 0 || splitLocation(x).territory !== t) continue; for (const y in ally.forces) if (ally.forces[y] > 0 && splitLocation(y).territory === t && gameDistance(g, x, y, key => splitLocation(key).sector === g.storm) === 0) return true; if (marker?.location.territory === t && gameDistance(g, x, location(t, marker.location.sector), key => splitLocation(key).sector === g.storm) === 0) return true; } return false; } /** Inspection follows the selected plan actor; reveal still belongs to the physical marker owner. */ function battleNoFieldPlan(g: Game, id: string): boolean { const b = g.battle, occupy = b?.ecazOccupy; const owner = occupy?.lead === id ? occupy.canceled ? id : occupy.ally : id; return !!b?.noFieldPlayers?.includes(owner); } function battleConcealedForces(g: Game, id: string): boolean { const b = g.battle, occupy = b?.ecazOccupy; const owner = occupy?.lead === id ? occupy.canceled ? id : occupy.ally : id; return !!b && getPlayer(g, owner).noField?.deployed?.location.territory === b.territory; } function ecazBattleProfile(g: Game): EcazOccupyBattleProfile | null { const b = g.battle, occupy = b?.ecazOccupy; if (!b || !occupy?.lead) return null; const lead = occupy.lead; const ecaz = getPlayer(g, occupy.ecaz), ally = getPlayer(g, occupy.ally); requireRule(occupy.event === b.event && ecaz.faction === 'ecaz' && ecaz.ally === ally.id && ally.ally === ecaz.id && [b.attacker, b.defender].includes(occupy.lead) && !isAdvisor(ecaz, b.territory) && !isAdvisor(ally, b.territory) && (occupyArmiesConnected(g, ecaz, ally, b.territory) || (b.revealed && b.noFieldPlayers?.includes(ally.id) && fighterCount(ecaz, b.territory) > 0 && fighterCount(ally, b.territory) === 0)), 'Occupy needs the original battle and physically connected allied fighters.'); const opponent = getPlayer(g, b.attacker === occupy.lead ? b.defender : b.attacker); return nexusRule(() => quoteEcazOccupyBattle({ advanced: g.advanced, battleOrderActor: b.chooser ?? b.attacker, ecaz: { id: ecaz.id, faction: 'ecaz', ally: ecaz.ally, forces: combatForces(g, ecaz, b.territory, opponent) }, ally: { id: ally.id, faction: ally.faction, ally: ally.ally, ...(b.noFieldPlayers?.includes(ally.id) ? { noFieldPresence: true as const } : {}), forces: combatForces(g, occupy.canceled && lead === ecaz.id ? { ...ally, noField: undefined } : ally, b.territory, opponent) }, lead, canceled: occupy.canceled, })); } function planCombatForces(g: Game, p: Player, opponent: Player): CombatForces { const profile = ecazBattleProfile(g); return profile?.lead === p.id ? profile.forces : combatForces(g, p, g.battle!.territory, profile?.lead === opponent.id ? getPlayer(g, profile.forceOwner) : opponent); } function takeBattleLosses(g: Game, p: Player, t: string, losses: Casualties) { if (t.startsWith('homeworld:')) { commitHomeworldLoss(g, p.id, t, { normal: losses.normal, elite: losses.elite }); return losses.elite ? { [t]: losses.elite } : {}; } 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 baseStartProfileBlock(g: Game): string | null { if (g.homeworlds) return 'Homeworld gameplay is still being implemented.'; if (g.nexusCards) return 'Nexus card effects are still being implemented.'; if (g.discoveryEnabled || g.discoveries || g.discoveryStash || g.greatMaker) return 'Discoveries are still being implemented.'; if (g.leaderSkills) return 'Leader Skills are still being implemented.'; if (g.ecazTreachery) return 'Ecaz Treachery Cards are still being implemented.'; if (g.moritaniAssassinatePreview || g.moritaniAssassinate) return 'Separate development profiles cannot be enabled by a normal start.'; if (!g.players.every(p => FACTIONS.some(entry => entry.id === p.faction && entry.expansion === 'base'))) return 'Expansion factions are still being implemented.'; if (g.expansions.length !== 0) return 'Expansion rules are still being implemented.'; return null; } function advancedPreviewIntegrity(g: Game) { if (g.advancedPreview === undefined) return; requireRule(g.advancedPreview === true && g.advanced === true && g.status !== 'lobby' && !baseStartProfileBlock(g), 'The saved Advanced preview no longer matches its supported rules profile.'); } function requireFreshSetup(g: Game, allowIxElites = false) { requireRule(g.status === 'lobby' && !g.setupStage && g.turn === 1 && g.phase === 0 && g.advancedPreview === undefined, 'Start only from 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), 'Start requires two through six distinct ready players and an existing host.'); requireRule(!g.deck.length && !g.discard.length && !g.spiceDeck.length && !g.spiceDiscard.some(pile => pile.length) && !g.traitorReserve?.length && g.ecazLoyalty === undefined && !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 || (allowIxElites && p.faction === 'ixians' && p.elites.reserves === 7 && p.elites.tanks === 0 && p.elites.revived === 0 && Object.keys(p.elites.forces).length === 0))), 'Starting setup cannot redeal or replace existing game pieces.'); requireRule(g.players.every(p => { const native = leaders(p.faction); return p.leaders.length === native.length && native.every(expected => { const actual = p.leaders.find(leader => leader.id === expected.id); return actual && Object.keys(actual).length === Object.keys(expected).length && Object.entries(expected).every(([key, value]) => actual[key as keyof Leader] === value); }) && p.ally === null && p.allySinceTurn === undefined && p.bribes === 0 && p.revived === 0 && (p.freeForcesRevived ?? 0) === 0 && p.leaderRevived === false && p.revivalCycle === 0 && p.battleLosses === 0 && !p.specialKaramaUsed && p.kwisatz === undefined && p.charityTurn === undefined && p.noField === undefined && p.noFieldEvent === undefined && p.noFieldBlockedTurn === undefined && p.advisors === undefined && p.gholaBlocked === undefined && p.ixMovementBlocked === undefined && p.fremenMovementBlocked === undefined && p.fremenNexusMovementBlockedTurn === undefined && p.fremenNexusWormBlockedTurn === undefined && p.atomicsHandLimitPenalty === undefined && p.tupileHandSlot === undefined && p.faceDancers === undefined && p.faceDancerReplacedTurn === undefined && p.revealedTraitors === undefined; }), 'Starting setup requires unused native leaders and no prior alliance, revival or battle history.'); requireRule(Object.keys(g.playerPositions ?? {}).every(id => g.players.some(p => p.id === id)), 'Player circles must belong to seated players.'); normalizedPlayerPositions(g); } function requireFreshBaseRuntime(g: Game) { requireRule((g.botsPending === undefined || g.botsPending === false) && g.botNextActionAt === undefined, 'Start with no pending bot work or prior action deadline.'); const fresh = createGame(g.code, getPlayer(g, g.host), g.advanced); // These are the live lobby settings and storage metadata. Module admission and // unowned optional components are checked separately; gameplay fields stay fresh. const configuration = new Set(['code', 'version', 'host', 'players', 'playerPositions', 'advanced', 'expansions', 'log', 'botsPending', 'botNextActionAt', 'techTokens', 'strongholdCards', 'homeworlds', 'nexusCards', 'discoveryEnabled', 'leaderSkills', 'ecazTreachery']); const keys = [...new Set([...Object.keys(g), ...Object.keys(fresh)])] as (keyof Game)[]; requireRule(keys.every(key => configuration.has(key) || JSON.stringify(g[key]) === JSON.stringify(fresh[key])), 'Starting a base table cannot retain existing game resources, histories or continuations.'); } function start(g: Game, advancedPreview = false) { requireRule(g.players.length >= 2, 'At least two players are needed.'); requireRule( g.players.every((p) => p.ready), 'Every player must be ready.', ); requireRule(typeof g.advanced === 'boolean' && (!g.advanced || advancedPreview), 'Advanced rules are unfinished. Explicitly choose Start Advanced preview to continue.'); requireRule(!advancedPreview || g.advanced, 'Choose Advanced preview rules before starting that preview.'); const blocked = baseStartProfileBlock(g); requireRule(!blocked, blocked ?? 'This rules profile is unavailable.'); requireFreshSetup(g); requireRule(!g.techTokens || JSON.stringify(g.techTokens) === JSON.stringify(createTechTokens()), 'Start with unused Tech Tokens before assigning their initial owners.'); requireRule(!g.strongholdCards || JSON.stringify(g.strongholdCards) === JSON.stringify(createStrongholdCards()), 'Start with unused Stronghold Cards before assigning ownership.'); requireFreshBaseRuntime(g); initializeSetup(g); if (advancedPreview) g.advancedPreview = true; } function skillRule(operation: () => T): T { try { return operation(); } catch (error) { if (error instanceof LeaderSkillError) throw new RuleError(error.message); throw error; } } function skillEligibleLeaders(g: Game, owner: string): string[] { return getPlayer(g, owner) .leaders.filter( (leader) => !leader.dead && !leader.capturedBy && !leader.gholaBy && !isAuditorLeader(leader) && !g.leaderSkills?.assignments.some( (assignment) => assignment.leader === leader.id, ), ) .map((leader) => leader.id); } /** Public standalone E3 roster; the existing held-card owner remains the plan actor. */ function e3StrongholdFactionProfile(g: Game): boolean { if (!g.advanced || g.expansions.length !== 1 || g.expansions[0] !== 'ecaz' || g.players.length < 2 || g.players.length > 6 || g.homeworlds || g.nexusCards || g.leaderSkills || g.discoveryEnabled || g.discoveries || g.discoveryStash || g.greatMaker || g.ecazTreachery || (g.techTokens && !nativeFactionTechProfile(g))) return false; let native: 'ecaz' | 'moritani' | null = null; for (const player of g.players) { if (player.faction === 'ecaz' || player.faction === 'moritani') { if (native !== null) return false; native = player.faction; } else if (!CLASSIC_FACTIONS[player.faction]) return false; } return native !== null && (native !== 'moritani' || !byFaction(g, 'harkonnen')); } function moritaniAssassinateModeSupported(g: Game) { if (mixedE3NexusProfile(g) || mixedE3NexusLeaderSkillsProfile(g) || standaloneE3NexusProfile(g) || pairedE3NexusProfile(g) || pairedE3NexusLeaderSkillsProfile(g)) return g.advanced && g.players.some(player => player.faction === 'moritani'); if (g.discoveryEnabled || g.discoveries) return g.advanced && !!g.discoveries && (discoveryModeSupported(g) || nativeDiscoveryLeaderSkillsProfile(g)) && g.players.some(player => player.faction === 'moritani'); return g.advanced && g.expansions.length === 1 && g.expansions[0] === 'ecaz' && g.players.some(p => p.faction === 'moritani') && g.players.every(p => p.faction === 'moritani' || (p.faction !== 'harkonnen' && CLASSIC_FACTIONS[p.faction] === true)) && (!g.nexusCards || (!!g.leaderSkills && standaloneE3NexusLeaderSkillsProfile(g))) && (!g.leaderSkills || advancedMoritaniLeaderSkillsProfile(g)) && (!g.strongholdCards || e3StrongholdFactionProfile(g) || (!!g.leaderSkills && strongholdLeaderSkillsProfile(g))) && (!g.homeworlds || (!!g.leaderSkills && homeworldLeaderSkillsProfile(g))) && !g.discoveryEnabled && !g.discoveries && (!g.techTokens || nativeFactionTechProfile(g) || (!!g.leaderSkills && nativeTechLeaderSkillsProfile(g))); } function moritaniAssassinateContext(g: Game, receipt: MoritaniAssassinateReceipt) { const p = getPlayer(g,receipt.owner), opponent = getPlayer(g,receipt.opponent); return {state:g.moritaniAssassinate!,receipt,held:p.traitors, normallyRevealed:p.revealedTraitors ?? [],leaders:opponent.leaders}; } function moritaniAssassinateIntegrity(g: Game) { const state = g.moritaniAssassinate, resume = g.moritaniAssassinateResume; requireRule(g.moritaniAssassinatePreview === undefined || g.moritaniAssassinatePreview === true, 'Invalid assassination preview setting.'); if (!state) { requireRule(!g.moritaniAssassinatePreview && !resume && !g.moritaniAssassinateCallEvents && !g.lastBattleContext?.moritaniAssassinate && g.decision?.kind !== 'moritaniAssassinate', 'The assassination preview lost its original state.'); return; } requireRule(g.moritaniAssassinatePreview === true && moritaniAssassinateModeSupported(g) && state.owner === byFaction(g,'moritani')?.id, 'This assassination preview no longer matches its original faction profile.'); nexusRule(() => validateMoritaniAssassinate(state,{turn:g.turn,held:getPlayer(g,state.owner).traitors, removed:Array.isArray(state.opportunities) ? state.opportunities.filter(r => r?.stage === 'replaced').map(r => r.card!) : []})); const calls = g.moritaniAssassinateCallEvents; requireRule(Array.isArray(calls) && calls.every(event => typeof event === 'string' && event.length > 0) && new Set(calls).size === calls.length && state.normalTraitorCall === (calls.length > 0), 'The assassination normal-traitor-call guard lost its original event record.'); for (const receipt of state.opportunities) { const opponent = getPlayer(g,receipt.opponent); requireRule(receipt.turn <= g.turn && opponent.faction === receipt.faction && receipt.owner === state.owner && (TERRITORIES.some(t => t.id === receipt.territory) || validGameLocation(g, receipt.territory, 0) || (receipt.territory.startsWith('homeworld:') && !!homeworldBattleLocation(g, receipt.territory))), 'The assassination history no longer matches its original battle participants.'); } const pending = state.opportunities.filter(r => r.stage === 'skills' || r.stage === 'choice'); requireRule(pending.length <= 1, 'Finish the current assassination opportunity first.'); const obligation = g.lastBattleContext?.moritaniAssassinate; if (obligation) { const receipt = state.opportunities.find(r => r.event === obligation.event); requireRule(receipt && receipt.signature === obligation.signature && receipt.event === g.lastBattleContext!.event, 'The completed battle lost or changed its assassination obligation.'); } if (pending.length) { const r = pending[0]; requireRule(g.status === 'playing' && g.phase === 6 && g.turn === r.turn && !g.battle && g.lastBattleContext?.event === r.event && g.lastBattleContext.winner === r.opponent && g.lastBattleContext.result === 'normal' && g.lastBattleContext.territory === r.territory && obligation?.event === r.event, 'The assassination choice lost its resolved battle.'); if (r.stage === 'skills') { const discard = g.pendingTreacheryDiscard?.continuation; const decision = g.decision; requireRule(g.leaderSkills && !resume && ((discard?.kind === 'battleResolved' && discard.event === r.event) || (decision?.player === r.opponent && (decision.kind === 'battleLosses' || decision.kind === 'sukRescue' || decision.kind === 'rihani'))), 'Assassination must retain the winner’s original skill and casualty continuation.'); } else { requireRule(resume && resume.event === r.event && resume.continuation.event === r.event && resume.continuation.winner === r.opponent && resume.continuation.territory === r.territory && resume.continuation.result === 'normal' && JSON.stringify(resume.continuation.combatants) === JSON.stringify(g.lastBattleContext.combatants) && resume.signature === JSON.stringify(resume.continuation) && resume.signature === obligation.continuation, 'The assassination choice lost or changed its saved cleanup.'); const decisions = homeworldSavedDecisions(g).filter(d => d.kind === 'moritaniAssassinate'); requireRule(decisions.length === 1 && decisions[0].player === r.owner && (decisions[0] as Extract).event === r.event, 'The assassination choice lost its owning decision.'); } } else requireRule(!resume && g.decision?.kind !== 'moritaniAssassinate', 'A settled assassination cannot retain its decision.'); if (g.status === 'playing' || g.status === 'finished') { const retired = state.opportunities.filter(r => r.stage === 'replaced').map(r => r.card!); const physical = [...(g.traitorReserve ?? []), ...g.players.flatMap(p => [...p.traitors, ...(p.faceDancers ?? []).map(dancer => dancer.leader)]), ...retired].sort(); requireRule(JSON.stringify(physical) === JSON.stringify([...nexusTraitorUniverse(g)].sort()), 'Assassination must preserve every physical Traitor Card in hand, deck or face-up set-aside custody.'); for (const r of state.opportunities.filter(r => r.stage === 'revealed')) requireRule(getPlayer(g,r.owner).traitors.includes(r.card!), 'The revealed assassination card must remain held until Mentat replacement.'); const replacements = new Set(); for (const [index,r] of state.opportunities.entries()) if (r.stage === 'replaced') { const laterUses = state.opportunities.slice(index+1).filter(next => next.card === r.replacement && (next.stage === 'revealed' || next.stage === 'replaced')); const ownedReplacement = laterUses.length ? laterUses.every(next => next.turn > r.turn) : getPlayer(g,r.owner).traitors.includes(r.replacement!); requireRule(!replacements.has(r.replacement!) && ownedReplacement, 'Each assassination replacement must stay with Moritani or be consumed by its later assassination.'); replacements.add(r.replacement!); } } } function syncMoritaniAssassinateReceipt(g: Game, receipt: MoritaniAssassinateReceipt) { receipt.signature = moritaniAssassinateSignature(receipt); if (g.lastBattleContext?.event === receipt.event) g.lastBattleContext.moritaniAssassinate = {...g.lastBattleContext.moritaniAssassinate,event:receipt.event,signature:receipt.signature}; } function recordMoritaniAssassinateOpportunity(g: Game, b: Battle, winner: Player | undefined, loser: Player | null) { const state = g.moritaniAssassinate; if (!state || state.normalTraitorCall || !winner || loser?.id !== state.owner || state.opportunities.some(r => r.faction === winner.faction && ['revealed','replaced'].includes(r.stage))) return; const opposingLeader = winner.leaders.find(l => l.id === b.plans[winner.id].leader); const trigger = {advanced:g.advanced,owner:state.owner,ownerFaction:loser.faction,opponent:winner.id, faction:winner.faction,event:g.lastBattleContext!.event,turn:g.turn,territory:b.territory, opposingLeader:opposingLeader?.id ?? null,opposingLeaderSurvived:!!opposingLeader && !opposingLeader.dead, normalBattle:g.lastBattleContext!.result === 'normal',lostBattle:true, traitorCalled:Object.values(b.traitorCalls).some(Boolean)}; if (!moritaniAssassinateTrigger(trigger)) return; const receipt = createMoritaniAssassinateOpportunity(trigger); if (g.leaderSkills) receipt.stage = 'skills'; state.opportunities.push(receipt); syncMoritaniAssassinateReceipt(g,receipt); } function projectedMoritaniAssassinate(g: Game, viewer: string) { const state = g.moritaniAssassinate; if (!state) return null; const pending = state.opportunities.find(r => r.stage === 'choice'); const own = viewer === state.owner; const choices = pending && own ? nexusRule(() => moritaniAssassinateChoices(moritaniAssassinateContext(g,pending))) : null; return {owner:state.owner,blocked:own && state.normalTraitorCall ? 'Moritani forfeited Assassinate Leaders for the rest of this game after revealing its Traitor Card normally.' : null, pending:pending ? {event:pending.event,opponent:pending.opponent,territory:pending.territory, cards:choices?.cards ?? [],blocked:choices?.blocked ?? null} : null, history:state.opportunities.filter(r => r.stage === 'revealed' || r.stage === 'replaced').map(r => ({ event:r.event,turn:r.turn,opponent:r.opponent,faction:r.faction,territory:r.territory, card:r.card!,bounty:r.bounty,stage:r.stage as 'revealed'|'replaced', }))}; } function actMoritaniAssassinate(g: Game, decision: Extract, action: Action) { const state = g.moritaniAssassinate!, r = state.opportunities.find(r => r.stage === 'choice')!; requireRule(r && action.event === r.event && decision.event === r.event && decision.player === r.owner, 'This assassination opportunity has already changed.'); const resume = g.moritaniAssassinateResume!; requireRule(resume?.event === r.event, 'The original assassination cleanup is missing.'); if (action.decline === true) { requireRule(Object.keys(action).every(k => ['type','event','decline'].includes(k)), 'Continue without selecting an assassination card.'); r.stage = 'declined'; log(g, `${getPlayer(g,r.owner).name} finished the Assassinate Leaders opportunity without revealing a card.`, {faction:'moritani',name:'Assassinate Leaders'}); } else { requireRule(Object.keys(action).every(k => ['type','event','card'].includes(k)), 'Choose one assassination Traitor Card or continue.'); const quote = nexusRule(() => quoteMoritaniAssassinate(moritaniAssassinateContext(g,r),stringField(action.card))); const target = getPlayer(g,r.opponent).leaders.find(l => l.id === quote.leader)!; if (quote.kill) {target.dead = true;target.deaths++;delete target.usedAt;} const owner = getPlayer(g,r.owner); requireRule(Number.isSafeInteger(owner.spice + quote.bounty), 'Assassination income exceeds the supported spice balance.'); owner.spice += quote.bounty; owner.revealedTraitors = [...new Set([...(owner.revealedTraitors ?? []),quote.leader])]; Object.assign(r,quote.receipt); log(g, `${owner.name} revealed ${target.name} as a traitor of ${faction(r.faction).name}. ${quote.kill ? `${target.name} was sent to the Tanks and ${owner.name} collected ${quote.bounty} spice from the bank.` : 'That leader was already in the Tanks; no new death or spice was awarded.'} This uses Assassinate Leaders against that faction for the game. The revealed card will be set aside and replaced during Mentat Pause.`, {faction:'moritani',name:'Assassinate Leaders'}); } syncMoritaniAssassinateReceipt(g,r); delete g.moritaniAssassinateResume; continueResolvedBattle(g,resume.continuation); } function replaceMoritaniAssassinationTraitors(g: Game) { const state = g.moritaniAssassinate; if (!state) return; for (const r of state.opportunities.filter(r => r.stage === 'revealed')) { const owner = getPlayer(g,r.owner); requireRule(r.turn === g.turn && owner.traitors.includes(r.card!) && (g.traitorReserve?.length ?? 0) > 0, 'Mentat replacement needs the original revealed card and one remaining physical Traitor Card.'); owner.traitors = owner.traitors.filter(id => id !== r.card); // The set-aside card can still sit in the reserve; the replacement must be // a distinct physical Traitor Card, never the revealed one again. const index = g.traitorReserve!.findIndex(id => id !== r.card); requireRule(index >= 0, 'The Traitor Deck cannot supply a distinct replacement for the revealed Assassinate traitor.'); r.replacement = g.traitorReserve!.splice(index, 1)[0]; owner.traitors.push(r.replacement); r.stage = 'replaced'; syncMoritaniAssassinateReceipt(g,r); log(g, `${owner.name} set the revealed ${faction(r.faction).name} Traitor Card aside face up and drew one private replacement during Mentat Pause. The set-aside card records this faction's used assassination.`, {faction:'moritani',name:'Assassination replacement'}); } } function leaderSkillsIntegrity(g: Game) { spiceBankerIncomeIntegrity(g); moritaniAssassinateIntegrity(g); bureaucratPaymentIntegrity(g); mentatQuestionIntegrity(g); diplomatDefenseIntegrity(g); diplomatRetreatIntegrity(g); harassAllocationIntegrity(g); const noFieldShipment = leaderSkillNoFieldIntegrity(g); sandmasterIntegrity(g); sukRescueIntegrity(g); ixSubstitutionIntegrity(g); rihaniIntegrity(g); smugglerBattleIntegrity(g); for (const [owner, plan] of Object.entries(g.battle?.plans ?? {})) { if (plan.bankerSpice === undefined) continue; const p = getPlayer(g, owner); if (plan.bankerSpice) requireRule(spiceBankerModeSupported(g), 'Spice Banker with expansion modules is still being integrated.'); const leader = controlledLeaders(g, p).find(l => l.id === plan.leader && !l.dead); requireRule([g.battle!.attacker, g.battle!.defender].includes(owner), 'Spice Banker belongs to a current combatant.'); nexusRule(() => validateSpiceBankerSpend(battleLeaderSkills(g, p), leader?.id, plan.bankerSpice, p.spice, battleSupportCost(g, p, plan.support) - (plan.allyPayment ?? 0))); } if (!noFieldShipment && (g.leaderSkills || g.pendingShipment?.smuggler)) { const decisions = homeworldSavedDecisions(g).filter(d => d.kind === 'guildShipment'); requireRule(decisions.length === (g.pendingShipment ? 1 : 0), 'The saved Leader Skills shipment lost its unique Guild interception decision.'); for (const decision of decisions) validateGuildShipmentDecision(g, decision); } if (!g.leaderSkills) return; requireRule(g.leaderSkills.assignments.every((assignment) => g.players.find((p) => p.id === assignment.owner)?.leaders.some((leader) => leader.id === assignment.leader && !leader.dead)), 'A saved Leader Skill cannot remain assigned to a dead or missing leader.'); skillRule(() => validateLeaderSkills(g.leaderSkills!, g.players)); let foreignProfileAllowed: boolean | undefined; for (const owner of g.players) for (const leader of owner.leaders) { if (leader.gholaBy === undefined) continue; const controller = g.players.find((p) => p.id === leader.gholaBy); requireRule( (foreignProfileAllowed ??= advancedNativeLeaderSkillsProfile(g)) && controller?.faction === 'tleilaxu' && controller.id !== owner.id && leader.faction === owner.faction && (leader.controller === undefined || leader.controller === owner.id) && !isAuditorLeader(leader) && leader.id !== DUKE_VIDAL_ID, 'Leader Skills foreign gholas require original native Advanced Tleilaxu custody.', ); const concealed = leader.concealed; if (leader.capturedBy !== undefined || concealed) { const captor = g.players.find((p) => p.id === (leader.capturedBy ?? concealed?.captor)); // Native capture overrides, but does not erase, the Tleilaxu loan. // Revealing its actual plan removes secrecy; execution keeps the snapshot. requireRule( captor?.faction === 'harkonnen' && (leader.capturedBy === captor.id ? !leader.dead : leader.capturedBy === undefined && leader.dead) && ((concealed?.captor === captor.id && concealed.controller === controller.id && concealed.dead === false) || (leader.capturedBy === captor.id && g.battle?.revealed === true && g.battle.plans[captor.id]?.leader === leader.id)), 'A foreign ghola capture must retain its native Harkonnen captor and Tleilaxu return custody.', ); } let copies = 0; for (const seat of g.players) for (const disc of seat.leaders) if (disc.id === leader.id) copies++; requireRule(copies === 1, 'A foreign ghola must remain one physical disc in its original leader roster.'); } if (g.battle?.leaderSkillHidden) requireRule( Object.entries(g.battle.leaderSkillHidden).every( ([id, hidden]) => [g.battle!.attacker, g.battle!.defender].includes(id) && typeof hidden === 'boolean', ), 'Invalid saved skilled-leader visibility.', ); if (g.decision?.kind === 'leaderSkillVisibility') requireRule( g.battle?.event === g.decision.event && g.leaderSkills.assignments.some((a) => a.owner === g.decision!.player), 'The saved skilled-leader choice lost its battle or assigned leader.', ); if (g.decision?.kind === 'leaderSkillRevival') requireRule( g.leaderSkills.offers[g.decision.player]?.event === g.decision.event, 'The saved revival choice lost its physical skill offer.', ); } function leaderSkillController( g: Game, assignment: { owner: string; leader: string }, ): string { return ( getPlayer(g, assignment.owner).leaders.find( (l) => l.id === assignment.leader, )!.capturedBy ?? assignment.owner ); } function battleLeaderSkills(g: Game, player: Player): BattleLeaderSkill[] { return ( g.leaderSkills?.assignments .filter((assignment) => leaderSkillController(g, assignment) === player.id) .map((assignment) => ({ skill: assignment.skill, leader: assignment.leader, faceUp: assignment.owner === player.id && !g.battle?.leaderSkillHidden?.[player.id], captured: assignment.owner !== player.id, })) ?? [] ); } function canUsePlanetologistWeapon( g: Game, player: Player, leader: string | null, card: Card | undefined, ) { return canUsePlanetologistBattleSpecial({ assignments: battleLeaderSkills(g, player), selectedLeader: leader, card, }); } function nativeLeaderSkill(g: Game, owner: string) { return g.leaderSkills?.assignments.find( (a) => a.owner === owner && leaderSkillController(g, a) === owner, ); } function bankerIncomeRule(operation: () => T): T { try { return operation(); } catch (error) { if (error instanceof BankerIncomeError) throw new RuleError(error.message); throw error; } } function bankerIncomeContext(g: Game): BankerIncomeContext { return {turn: g.turn, phase: g.phase, players: g.players.map(p => ({id: p.id, leaders: p.leaders.map(l => ({ id: l.id, dead: l.dead, ...(l.capturedBy ? {capturedBy: l.capturedBy} : {}), ...(l.gholaBy ? {gholaBy: l.gholaBy} : {}), }))})), assignments: g.leaderSkills?.assignments ?? []}; } function bankerIncomeProfile(g: Game): boolean { const classicNexus = classicNexusLeaderSkillsProfile(g); const homeworlds = !!g.homeworlds && !g.expansions.length && !g.nexusCards && !g.techTokens && !g.strongholdCards && homeworldLeaderSkillsProfile(g); return g.players.length >= 2 && g.players.length <= 6 && new Set(g.players.map(p => p.faction)).size === g.players.length && ((!g.expansions.length && g.players.every(p => FACTIONS.some(f => f.id === p.faction && f.expansion === 'base'))) || nativeExpansionLeaderSkillsProfile(g)) && (!g.nexusCards || classicNexus) && (!g.homeworlds || homeworlds) && (!g.techTokens || classicTechLeaderSkillsProfile(g) || nativeTechLeaderSkillsProfile(g) || strongholdLeaderSkillsProfile(g) || classicNexus) && (!g.strongholdCards || strongholdLeaderSkillsProfile(g) || classicNexus) && !g.discoveryEnabled && !g.discoveries && !g.discoveryStash && !g.greatMaker && !g.ecazTreachery && !g.semutaPreview && ((!g.moritaniAssassinatePreview && !g.moritaniAssassinate) || advancedMoritaniLeaderSkillsProfile(g)) && !g.advancedPreview && !g.kullPreview && !g.nexusKullPreview && !g.guildBetrayalPreview && !g.richeseBetrayalPreview && !g.nexusIxianReplacementPreview && !g.nexusIxianBetrayalPreview && !g.nexusHarkonnenBetrayalPreview; } function bankerIncomePaymentSignature(payment: SpiceBankerIncomePayment): string { return JSON.stringify([payment.event, payment.turn, payment.phase, payment.kind, payment.legs]); } function spiceBankerIncomeIntegrity(g: Game): void { if (!g.spiceBankerIncomePreview) { requireRule(g.spiceBankerIncomePreview === undefined && g.spiceBankerIncome === undefined, 'Deferred Spice Banker income requires its fresh local profile.'); return; } requireRule(g.spiceBankerIncomePreview === true && bankerIncomeProfile(g) && !!g.leaderSkills && !!g.spiceBankerIncome, 'The saved Spice Banker income profile lost its supported physical setup.'); bankerIncomeRule(() => validateBankerIncomeState(g.spiceBankerIncome!, bankerIncomeContext(g))); const continuation = g.pendingTreacheryDiscard?.continuation; const responses = [g, g.pendingExchange, g.pendingNullentropy?.resume, g.pendingRicheseGift?.resume, g.pendingRichesePurchaseIncome?.resume, g.summonedWorm?.resume, g.bureaucratPayments?.pending?.resume, continuation && 'resume' in continuation ? continuation.resume : null] .flatMap(context => context?.response ? [context.response] : []); if (g.pendingKarama?.use.kind === 'cancel') responses.push(g.pendingKarama.use.response); for (const carrier of [g.currentAuctionSale, ...responses]) { const payment = carrier?.spiceBankerIncomePayment; if (!payment) { requireRule(!carrier || ('winner' in carrier ? carrier.free || (carrier.recipient !== 'bank' && auctionEmperorIncomeAmount(carrier) === 0) : carrier.kind !== 'guildIncome' && !(carrier.kind === 'revivalIncome' && carrier.recipient !== carrier.owner && (carrier.amount ?? 0) > 1)), 'The original paid transaction lost its native bank invoice.'); continue; } requireRule(payment.signature === bankerIncomePaymentSignature(payment) && payment.turn === g.turn && payment.phase === g.phase && typeof payment.event === 'string' && payment.event.length > 0 && Array.isArray(payment.legs) && payment.legs.every(leg => g.players.some(p => p.id === leg.payer) && Number.isSafeInteger(leg.amount) && leg.amount > 0 && ['bank', 'player'].includes(leg.recipient)) && new Set(payment.legs.map(leg => leg.payer)).size === payment.legs.length, 'The original paid bank transaction changed its event or contribution legs.'); if (carrier && 'winner' in carrier) { const amount = carrier.recipient === 'bank' ? carrier.amount : auctionEmperorIncomeAmount(carrier); const contributionOnly = carrier.origin !== 'normal' && carrier.seller !== carrier.winner && carrier.recipient !== 'bank'; requireRule(payment.kind === 'auction' && !carrier.free && payment.legs.reduce((sum, leg) => sum + leg.amount, 0) === amount && (contributionOnly ? g.advanced && !!carrier.richeseContribution && payment.legs.length === 1 && payment.legs[0].payer === carrier.seller && payment.legs[0].amount === carrier.richeseContribution : payment.legs.every(leg => leg.payer === carrier.winner || leg.payer === getPlayer(g, carrier.winner).ally)), 'The deferred bank invoice no longer matches its original auction payer legs.'); } else if (carrier?.kind === 'guildIncome') requireRule(payment.kind === 'shipment' && payment.legs.filter(leg => leg.recipient === 'player').reduce((sum, leg) => sum + leg.amount, 0) === carrier.amount, 'The deferred bank invoice no longer matches its original Guild income.'); else if (carrier) { const paid = payment.legs[0]; requireRule(carrier.kind === 'revivalIncome' && payment.phase === 4 && (payment.kind === 'force-revival' || payment.kind === 'leader-revival' || payment.kind === 'kh-revival' || payment.kind === 'emperor-extra-revival') && getPlayer(g, carrier.owner).faction === 'tleilaxu' && carrier.recipient !== carrier.owner && payment.legs.length === 1 && paid.payer === carrier.recipient && paid.recipient === 'player' && (carrier.amount === paid.amount || carrier.amount === paid.amount + 1), 'The deferred bank invoice no longer matches its original paid revival.'); } } } function bankerIncomePayment(g: Game, kind: BankerIncomeAuthority['kind'], legs: SpiceBankerIncomePayment['legs']): SpiceBankerIncomePayment | undefined { if (!g.spiceBankerIncomePreview) return undefined; const payment = {event: crypto.randomUUID(), turn: g.turn, phase: g.phase, kind, legs: legs.filter(leg => leg.amount > 0), signature: ''}; payment.signature = bankerIncomePaymentSignature(payment); return payment; } type BureaucratSettlement = { amount: 0 } | { amount: 2; payer: string }; function firstBureaucratSettlement(source: BureaucratPaymentSource, owner: string, amount: 0 | 2): BureaucratSettlement { if (amount === 0) return { amount: 0 }; let payer: string | undefined; if (source.contributions) { for (const leg of source.contributions) { if (leg.amount < 5 || leg.payer === owner || leg.payer === source.payee) continue; if (leg.payer === source.payer) { payer = leg.payer; break; } if (payer === undefined) payer = leg.payer; } } else if (source.amount >= 5 && source.payer !== owner && source.payer !== source.payee) { payer = source.payer; } requireRule(payer !== undefined, 'The original payment has no eligible Bureaucrat contribution.'); return { amount: 2, payer: payer! }; } function awardBankerIncome(g: Game, payment: SpiceBankerIncomePayment | undefined, canceled = false, resolvedBattle = false, finalBankLegs?: BankerIncomeAuthority['bankLegs']): void { if (!payment) return; const bankLegs = finalBankLegs ?? payment.legs.filter(leg => leg.recipient === 'bank' || canceled) .map(({payer, amount}) => ({payer, amount})); if (!bankLegs.length) return; const assignment = g.leaderSkills?.assignments.find(a => a.skill === 'spice-banker'); const leader = assignment && getPlayer(g, assignment.owner).leaders.find(l => l.id === assignment.leader); const trainer = assignment && leader && !leader.dead && !leader.capturedBy && !leader.gholaBy ? { assignment: {...assignment}, faceUp: !g.battle?.leaderSkillHidden?.[assignment.owner], captured: false, selected: resolvedBattle && g.battle?.plans[assignment.owner]?.leader === assignment.leader, resolved: payment.kind !== 'battle-support' || resolvedBattle, survived: true, } : null; const authority: BankerIncomeAuthority = {kind: payment.kind, event: payment.event, turn: payment.turn, phase: payment.phase, bankLegs, trainer}; const context = bankerIncomeContext(g); const quote = bankerIncomeRule(() => quoteBankerIncome(g.spiceBankerIncome!, context, authority)); g.spiceBankerIncome = bankerIncomeRule(() => commitBankerIncome(g.spiceBankerIncome!, context, authority, quote.receipt)); if (quote.receipt?.grant) { const owner = getPlayer(g, quote.receipt.grant.owner); log(g, `${owner.name} gained 1 Spice Banker spice in front of the shield, unavailable until Mentat Pause.`, {faction: owner.faction, name: 'Spice Banker'}); } } /** Original invoice legs stay signed and private. Only its actually retained * Bank portion, including the same payer's selected diversion, earns income. */ function awardFinalFactionBankerIncome(g: Game, payment: SpiceBankerIncomePayment | undefined, bankRetained: number, settlement?: BureaucratSettlement): void { if (!payment) return; const playerLegs: { payer: string; amount: number }[] = []; const bankLegs: BankerIncomeAuthority['bankLegs'] = []; for (const leg of payment.legs) { if (leg.recipient === 'bank') bankLegs.push({ payer: leg.payer, amount: leg.amount }); else playerLegs.push({ payer: leg.payer, amount: leg.amount }); } const diversion = settlement?.amount === 2 ? { payer: settlement.payer, amount: 2 as const } : undefined; bankLegs.push(...homeworldRule(() => quoteHomeworldBankPayment(playerLegs, bankRetained, diversion))); awardBankerIncome(g, payment, false, false, bankLegs); } function collectBankerIncome(g: Game): void { if (!g.spiceBankerIncomePreview) return; const context = bankerIncomeContext(g), authority = {event: crypto.randomUUID(), turn: g.turn, phase: 8 as const}; const quote = bankerIncomeRule(() => quoteBankerIncomeCollection(g.spiceBankerIncome!, context, authority)); g.spiceBankerIncome = bankerIncomeRule(() => commitBankerIncomeCollection(g.spiceBankerIncome!, context, authority, quote.collection)); for (const credit of quote.credits) { const owner = getPlayer(g, credit.owner); owner.spice += credit.amount; log(g, `${owner.name} collected ${credit.amount} deferred Spice Banker spice during Mentat Pause.`, {faction: owner.faction, name: 'Spice Banker'}); } } function settleBankerShipmentIncome(g: Game, p: Player, cost: number, allyPayment: number, recipients?: { own: boolean; ally: boolean }): void { if (!g.spiceBankerIncomePreview) return; const guild = byFaction(g, 'guild'); const bankOnly = !guild || g.karamaShipping?.player === p.id; const payment = bankerIncomePayment(g, 'shipment', [ {payer: p.id, amount: cost - allyPayment, recipient: recipients ? recipients.own ? 'player' : 'bank' : bankOnly || p.id === guild?.id ? 'bank' : 'player'}, ...(allyPayment ? [{payer: p.ally!, amount: allyPayment, recipient: recipients ? recipients.ally ? 'player' as const : 'bank' as const : bankOnly || p.ally === guild?.id ? 'bank' as const : 'player' as const}] : []), ]); if (g.response?.kind === 'guildIncome') g.response.spiceBankerIncomePayment = payment; else awardBankerIncome(g, payment); } function nativeBureaucrat(g: Game) { return g.leaderSkills?.assignments.find(a => a.skill === 'bureaucrat' && getPlayer(g,a.owner).leaders.some(l => l.id === a.leader && !l.dead && !l.capturedBy && !l.gholaBy) && !g.battle?.leaderSkillHidden?.[a.owner]); } function bureaucratPaymentQuote(g: Game,source: Pick) { const owner = nativeBureaucrat(g)?.owner ?? null; return quoteBureaucratPayment({...source,owner,turn:g.turn,phase:g.phase,used:g.bureaucratPayments?.used ?? []}); } function checkBureaucratContributors(g: Game,payee:string|undefined,shares:{payer:string;amount:number}[],privateFunding=false) { if (!g.leaderSkills || !payee) return; const paid = shares.filter(share => share.amount > 0); const owner = nativeBureaucrat(g)?.owner; if (!owner || owner === payee || bureaucratUsed(g.bureaucratPayments?.used ?? [],g.turn,g.phase) || !paid.some(share => share.amount >= 5 && share.payer !== owner && share.payer !== payee)) return; requireRule(bureaucratPaymentModeSupported(g),'Bureaucrat with other optional modules is still being integrated.'); requireRule(paid.length === 1 && !privateFunding || bureaucratAlliedFundingModeSupported(g), 'Bureaucrat on allied funding or multiple payer contributions in this module composition is awaiting integration.'); } function stampBureaucratPayment(g: Game,kind:BureaucratPaymentSource['kind'],payer:string,payee:string,amount:number, contributions?:BureaucratPaymentSource['contributions']): {bureaucratPayment?:BureaucratPaymentSource;bureaucratPaymentEvent?:string} { if (!g.leaderSkills || !bureaucratPaymentModeSupported(g) || amount < 5 || payer === payee) return {}; const source:BureaucratPaymentSource = {event:crypto.randomUUID(),turn:g.turn,phase:g.phase,kind,payer,payee,amount, ...(contributions ? {contributions,privateFunding:true as const} : {}),signature:''}; source.signature = bureaucratPaymentSignature(source); return {bureaucratPayment:source,bureaucratPaymentEvent:source.event}; } function stampBureaucratContributors(g:Game,kind:'auction'|'shipment',payee:string, shares:{payer:string;amount:number}[],privateFunding:boolean) { const paid = shares.filter(share => share.amount > 0); if (!privateFunding && paid.length === 1) return stampBureaucratPayment(g,kind,paid[0].payer,payee,paid[0].amount); if (!bureaucratAlliedFundingModeSupported(g)) return {}; const owner = nativeBureaucrat(g)?.owner; const qualifying = paid.find(share => share.amount >= 5 && share.payer !== owner && share.payer !== payee); return qualifying ? stampBureaucratPayment(g,kind,qualifying.payer,payee, paid.reduce((total,share) => total + share.amount,0),paid) : {}; } function bureaucratPaymentFrame(g: Game): string { return JSON.stringify({turn:g.turn,phase:g.phase,active:g.active,order:g.order, players:g.players.map(p => ({id:p.id,spice:p.spice,bribes:p.bribes,hand:p.hand,leaders:p.leaders,forces:p.forces, elites:p.elites,reserves:p.reserves,tanks:p.tanks,ally:p.ally,shipped:p.shipped,moved:p.moved})), aid:g.aid,auction:g.auction,sale:g.currentAuctionSale,richeseAuction:g.richeseAuction,richeseBidding:g.richeseBidding, richeseFunding:g.richeseFunding,richeseCache:g.richeseCache,pendingIxAlly:g.pendingIxAlly, battle:g.battle,leaderSkills:g.leaderSkills,phaseOpening:g.phaseOpening}); } function validateBureaucratSource(g: Game,source:BureaucratPaymentSource) { requireRule(source && typeof source === 'object' && typeof source.event === 'string' && source.event.length > 0 && source.turn === g.turn && source.phase === g.phase && ['auction','shipment','bribe','revival'].includes(source.kind) && (source.kind !== 'revival' || g.phase === 4) && g.players.some(p => p.id === source.payer) && g.players.some(p => p.id === source.payee) && source.payer !== source.payee && Number.isSafeInteger(source.amount) && source.amount >= 5 && source.signature === bureaucratPaymentSignature(source), 'The saved Bureaucrat payment changed its actual payer, payee, amount or event.'); if (source.contributions) { requireRule(bureaucratAlliedFundingModeSupported(g) && source.privateFunding === true && (source.kind === 'auction' || source.kind === 'shipment') && source.contributions.length >= 1 && source.contributions.length <= 2 && source.contributions.every(share => g.players.some(player => player.id === share.payer)) && source.contributions.some(share => share.payer === source.payer && share.amount >= 5), 'The saved Bureaucrat payment lost its original allied contributors.'); bureaucratPaymentQuote(g,source); } else requireRule(!source.privateFunding,'The private Bureaucrat payment lost its actual payer contributions.'); } function bureaucratPaymentIntegrity(g: Game) { const state = g.bureaucratPayments,events = g.bureaucratUseEvents ?? []; requireRule(!state || Array.isArray(state.used),'The saved Bureaucrat usage ledger is invalid.'); requireRule(Array.isArray(events) && events.length === (state?.used.length ?? 0) && new Set(events).size === events.length && new Set((state?.used ?? []).map(use => use?.event)).size === (state?.used.length ?? 0), 'The physical Bureaucrat card lost its independent usage events.'); const phases = new Set(); for (const use of state?.used ?? []) { const phase = `${use.turn}:${use.phase}`; requireRule(use && typeof use.event === 'string' && events.includes(use.event) && !phases.has(phase) && Number.isSafeInteger(use.turn) && use.turn >= 1 && use.turn <= g.turn && Number.isSafeInteger(use.phase) && use.phase >= 0 && use.phase <= 8 && (use.turn < g.turn || use.phase <= g.phase) && g.players.some(p => p.id === use.owner) && use.signature === bureaucratPaymentSignature(use), 'The physical Bureaucrat skill cannot gain a second use in the same phase.'); phases.add(phase); } const pending = state?.pending; 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,pending?.resume]; const responses = contexts.flatMap(context => context?.response ? [context.response] : []); if (g.pendingKarama?.use.kind === 'cancel') responses.push(g.pendingKarama.use.response); for (const carrier of [g.currentAuctionSale,...responses].filter((value): value is NonNullable => !!value)) { const source = carrier.bureaucratPayment; if (!source) {requireRule(!carrier.bureaucratPaymentEvent,'The saved payment lost its original Bureaucrat source.');continue;} requireRule(g.leaderSkills && bureaucratPaymentModeSupported(g) && carrier.bureaucratPaymentEvent === source.event, 'The saved Bureaucrat payment lost its module or independent event.'); validateBureaucratSource(g,source); if ('winner' in carrier) requireRule(source.kind === 'auction' && !carrier.free && source.amount === carrier.amount && (source.payer === carrier.winner || getPlayer(g,carrier.winner).ally === source.payer) && source.payee === (carrier.origin !== 'normal' && carrier.seller !== carrier.winner ? carrier.seller : byFaction(g,'emperor')?.id), 'The Bureaucrat source no longer matches its paid auction sale.'); else requireRule(source.payee === carrier.owner && (carrier.kind === 'guildIncome' && source.kind === 'shipment' && source.amount === carrier.amount || carrier.kind === 'emperorIncome' && source.kind === 'auction' && g.currentAuctionSale?.bureaucratPaymentEvent === source.event || carrier.kind === 'revivalIncome' && source.kind === 'revival' && byFaction(g,'tleilaxu')?.id === source.payee && source.amount <= (carrier.amount ?? -1) && (carrier.amount ?? 0) <= source.amount + 1 && source.payer === carrier.recipient), 'The Bureaucrat source no longer matches its original native income.'); } const decisions = homeworldSavedDecisions(g).filter(d => d.kind === 'bureaucratPayment'); if (!pending) {requireRule(!g.bureaucratPaymentEvent && !decisions.length,'The saved Bureaucrat decision lost its original payment.');return;} validateBureaucratSource(g,pending.source); const assignment = nativeBureaucrat(g); requireRule(g.status === 'playing' && g.leaderSkills && bureaucratPaymentModeSupported(g) && !g.response && g.bureaucratPaymentEvent === pending.source.event && pending.signature === bureaucratPaymentSignature(pending) && pending.frame === bureaucratPaymentFrame(g) && assignment?.owner === pending.owner && assignment.leader === pending.leader && bureaucratPaymentQuote(g,pending.source) && decisions.length === 1 && decisions[0] === g.decision && decisions[0].player === pending.owner && decisions[0].event === pending.source.event, 'The pending Bureaucrat payment changed its trainer, resources, callback or unique decision.'); if (pending.continuation.kind === 'response') requireRule(pending.resume.response?.bureaucratPaymentEvent === pending.source.event, 'The Bureaucrat income lost its uncanceled response continuation.'); else if (pending.continuation.kind === 'auction') requireRule(g.currentAuctionSale?.bureaucratPaymentEvent === pending.source.event && JSON.stringify(pending.continuation.quote) === JSON.stringify(currentAuctionContinuationQuote(g,{kind:'sale',free:false})), 'The Bureaucrat sale lost its original seller-credit continuation.'); else if (pending.continuation.kind === 'guildBetrayal') { const betrayal = pending.continuation; const receipt = (g.nexusGuildBetrayalHistory ?? []).find(entry => entry.invoice.event === betrayal.event); requireRule(pending.source.kind === 'shipment' && g.guildBetrayal?.status === 'completed' && !!receipt && receipt.recipient === betrayal.recipient && receipt.invoice.price === betrayal.amount && pending.source.payee === betrayal.recipient && pending.source.amount === betrayal.amount, 'The Bureaucrat Betrayal receipt lost its original holder payment.'); } else requireRule(pending.continuation.kind === 'bribe' && pending.source.kind === 'bribe', 'The Bureaucrat payment has an invalid continuation.'); } function offerBureaucratPayment(g: Game,source:BureaucratPaymentSource|undefined, continuation:NonNullable['pending']>['continuation']): boolean { if (!source || !bureaucratPaymentQuote(g,source)) return false; requireRule(!g.bureaucratPayments?.pending,'Finish the current Bureaucrat payment first.'); validateBureaucratSource(g,source); const assignment = nativeBureaucrat(g)!; const pending = {source,owner:assignment.owner,leader:assignment.leader, resume:{response:g.response,decision:g.decision},continuation,frame:bureaucratPaymentFrame(g),signature:''}; pending.signature = bureaucratPaymentSignature(pending); (g.bureaucratPayments ??= {used:[]}).pending = pending; g.bureaucratPaymentEvent = source.event; g.response = null; g.decision = {kind:'bureaucratPayment',player:assignment.owner,event:source.event}; return true; } function projectedBureaucrat(g: Game,viewer: string): BureaucratPaymentView { const pending = g.bureaucratPayments?.pending; const privateRevival = pending?.source.kind === 'revival' && ![pending.owner,pending.source.payer,pending.source.payee,pending.resume.response?.recipient].includes(viewer); return {pending:pending && !privateRevival ? {event:pending.source.event,owner:pending.owner, payer:pending.source.privateFunding ? null : pending.source.payer,payee:pending.source.payee, amount:pending.source.amount,kind:pending.source.kind,redirect:2, ...(g.homeworlds && (pending.source.kind === 'auction' || pending.source.kind === 'shipment') ? { homeworldAllocation: true as const } : {})} : null, usedThisPhase:bureaucratUsed(g.bureaucratPayments?.used ?? [],g.turn,g.phase)}; } function actBureaucratPayment(g: Game,action:Action) { const pending = g.bureaucratPayments!.pending!; requireRule(action.event === pending.source.event && typeof action.redirect === 'boolean' && Object.keys(action).every(key => ['type','event','redirect'].includes(key)), 'Choose whether to redirect this exact Bureaucrat payment.'); const redirected = action.redirect ? 2 : 0; const settlement = firstBureaucratSettlement(pending.source, pending.owner, redirected); if (redirected) { const use:BureaucratPaymentUse = {event:pending.source.event,owner:pending.owner,turn:g.turn,phase:g.phase,signature:''}; use.signature = bureaucratPaymentSignature(use); g.bureaucratPayments!.used.push(use);(g.bureaucratUseEvents ??= []).push(use.event); log(g,`${getPlayer(g,pending.owner).name} used Bureaucrat to redirect 2 spice of ${pending.source.kind === 'revival' ? 'a qualifying revival payment' : `the ${pending.source.amount}-spice payment`} to the Bank.`); } delete g.bureaucratPayments!.pending;delete g.bureaucratPaymentEvent; g.response = pending.resume.response;g.decision = pending.resume.decision; if (pending.continuation.kind === 'response') finishResponse(g,false,settlement); else if (pending.continuation.kind === 'auction') commitAuctionContinuation(g,pending.continuation.quote,redirected); else if (pending.continuation.kind === 'guildBetrayal') { // The native adapter already credited the full fee to the holder; the ruled // diversion takes exactly two from that receipt. if (redirected) getPlayer(g,pending.continuation.recipient).spice -= redirected; } else getPlayer(g,pending.source.payee).bribes += pending.source.amount-redirected; } function finishLeaderSkillCustody(g: Game, before?: Game) { if (!g.leaderSkills) return; const result = skillRule(() => returnDeadLeaderSkills(g.leaderSkills!, g.players, random), ); g.leaderSkills = result.state; for (const assignment of result.returned) { if (g.battle?.leaderSkillHidden) delete g.battle.leaderSkillHidden[assignment.owner]; log( g, `${leaderSkillCard(assignment.skill).name} returned to the shuffled skill deck because its assigned leader died.`, { faction: getPlayer(g, assignment.owner).faction, name: 'Leader Skill lost', }, ); } if (before?.leaderSkills) for (const player of g.players) for (const leader of player.leaders) if ( !leader.dead && !leader.capturedBy && !leader.gholaBy && !isAuditorLeader(leader) && before.players .find((p) => p.id === player.id) ?.leaders.find((l) => l.id === leader.id)?.dead ) { g.leaderSkills = skillRule(() => offerRevivedLeaderSkill( g.leaderSkills!, player.id, leader.id, crypto.randomUUID(), ), ); } } function projectedLeaderSkills( g: Game, owner: string, ): LeaderSkillsView | null { if (!g.leaderSkills) return null; const offer = g.leaderSkills.offers[owner] ?? null; const eligible = skillEligibleLeaders(g, owner); const assignment = nativeLeaderSkill(g, owner); return { assignments: g.leaderSkills.assignments.map((a) => { const controller = leaderSkillController(g, a); return { ...a, controller, captured: controller !== a.owner, faceUp: controller === a.owner && !g.battle?.leaderSkillHidden?.[a.owner], }; }), offer, eligibleLeaders: getPlayer(g, owner) .leaders.filter((l) => eligible.includes(l.id)) .map((l) => ({ id: l.id, name: l.name })), battleChoice: assignment && g.decision?.kind === 'leaderSkillVisibility' && g.decision.player === owner ? { event: g.decision.event, leader: assignment.leader, skill: assignment.skill, } : null, }; } /** Later Voice/card use can remove the last alternative to the skilled disc. */ function finishMandatorySkillVisibility(g: Game) { const b = g.battle; if (!g.leaderSkills || !b || b.revealed) return; for (const owner of [b.attacker, b.defender]) { if (b.plans[owner] || b.leaderSkillHidden?.[owner] !== false) continue; const assignment = nativeLeaderSkill(g, owner); if (!assignment) continue; const p = getPlayer(g, owner); const available = controlledLeaders(g, p).filter( (l) => !l.dead && (!l.usedAt || l.usedAt === b.territory), ); const heroAvailable = p.hand.some((c) => c.kind === 'hero') && !(b.voice?.target === owner && b.voice.kind === 'hero' && !b.voice.must); if ( available.length === 1 && available[0].id === assignment.leader && !heroAvailable ) { b.leaderSkillHidden![owner] = true; log( g, `${p.name} moved the skilled leader behind the shield because the remaining legal Battle Plan must use that leader.`, { faction: p.faction, name: 'Leader Skill' }, ); } } } function initializeSetup(g: Game, strongholds = !!g.strongholdCards) { const ecaz = byFaction(g, 'ecaz'); if (ecaz && g.advanced) g.ecazLoyalty = { player: ecaz.id, card: null }; if (g.discoveryEnabled) g.discoveries = createDiscoveryState(random); if (g.nexusCards) g.nexusCards = { cards: createNexusCards(g.players, random), phase: null }; const choam = byFaction(g, 'choam'); if (choam && g.advanced && !choam.leaders.some(isAuditorLeader)) choam.leaders.push(createAuditorLeader()); if (strongholds) { 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.ecazTreachery ? ecazTreacheryCards() : []), ]); g.spiceDeck = shuffle([...spiceDeck(g.expansions.includes('ix')), ...(g.discoveryEnabled ? [...DISCOVERY_SPICE_CARDS, {worm:true as const, greatMaker:true as const}] : [])]); 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.' : g.leaderSkills ? 'Player positions are fixed. Starting Treachery Cards and Leader Skills precede traitor selection in this variant.' : '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 || g.homeworlds) && ['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]; } } if (g.homeworlds) g.homeworlds.custody = createHomeworldCustody(homeworldContext(g)); } 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'} ${g.leaderSkills ? 'before Leader Skill assignment' : 'after force placement'}.`, { faction: p.faction, name: 'Starting cards' }, ); } } if (ixians) log( g, g.leaderSkills ? 'Ixians must choose their starting card before the remaining cards and Leader Skills are dealt.' : '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 === 'skillTreachery') return g.ixSetupCards ? [byFaction(g, 'ixians')!.id] : []; if (g.setupStage === 'leaderSkills') return Object.keys(g.leaderSkills!.offers); 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 ecaz = byFaction(g, 'ecaz'); if (ecaz?.reserves === 20) return [ecaz.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; if (g.leaderSkills) { g.setupStage = 'skillTreachery'; dealStartingTreachery(g); if (g.decision) return; } } if (g.setupStage === 'skillTreachery') { if (g.ixSetupCards || g.decision) return; g.leaderSkills = skillRule(() => dealLeaderSkills(g.leaderSkills!, g.players.map((p) => p.id), crypto.randomUUID())); g.setupStage = 'leaderSkills'; log(g, 'Each faction received two private Leader Skill cards. Keep one and assign an eligible leader before traitors are dealt.'); } if (g.setupStage === 'leaderSkills' && setupPending(g).length) return; if (g.setupStage === 'prediction' || g.setupStage === 'leaderSkills') { const inventory = traitorDeck(g.players, g.expansions.includes('ix')); if (g.ecazLoyalty && g.ecazLoyalty.card === null) { g.ecazLoyalty.card = chooseEcazLoyalty(inventory, random()); const loyal = leaders('ecaz').find(leader => leader.id === g.ecazLoyalty!.card)!; log(g, `Ecaz Loyalty set ${loyal.name}’s Traitor Card face up outside the deck before initial dealing. This card stays out of the game; its leader is unchanged.`, { faction: 'ecaz', name: 'Loyalty' }); } const traitors = shuffle(withoutEcazLoyalty(inventory, g.ecazLoyalty)); 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, g.leaderSkills ? 'Leader Skills are assigned publicly. Traitor Cards were dealt privately; choose before starting force placement.' : '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, g.leaderSkills ? 'Traitor choices are complete. Starting spice and fixed forces are placed; complete faction force placement to begin play.' : '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; if (!g.leaderSkills) dealStartingTreachery(g); } } /** Offline-only test seam. No player action or room API dispatches this function. */ export function initializeBaseGameForAudit(state: Game): Game { return initializeSetupGameForAudit(state, false); } /** Offline-only seam through the real setup pipeline. Public Homeworld starts remain gated. */ export function initializeHomeworldGameForAudit(state: Game): Game { requireRule( !!state.homeworlds, 'Enable the Homeworld module in the audit lobby first.', ); return initializeSetupGameForAudit(state, true); } /** Fresh occupation prototype; ambiguity remains source-gated rather than assigned a controller. */ export function initializeHomeworldOccupationGameForAudit(state: Game, leaderSkills = false): Game { requireRule(state.homeworlds?.custody === null && !state.homeworldOccupationPreview && !state.homeworldOccupationHistory && !state.nexusCards && !state.leaderSkills && !state.techTokens && !state.strongholdCards && !state.discoveryEnabled && !state.discoveries && !state.discoveryStash && !state.greatMaker && !state.ecazTreachery && typeof state.advanced === 'boolean' && state.expansions.length <= 3 && state.expansions.every((id, index) => (id === 'ix' || id === 'choam' || id === 'ecaz') && state.expansions.indexOf(id) === index) && state.players.every(p => FACTIONS.some(f => f.id === p.faction && (f.expansion === 'base' || state.expansions.includes(f.expansion)))), 'Occupation requires a fresh Homeworld lobby with original selected faction decks and no other modules.'); requireFreshBaseRuntime(state); requireFreshFactionInventory(state); requireRule(!leaderSkills || !state.expansions.length && homeworldLeaderSkillsProfile(state), 'Occupation with Leader Skills requires original classic Homeworlds without other modules.'); const g = leaderSkills ? initializeLeaderSkillsGameForAudit(state) : initializeSetupGameForAudit(state, true, false, false, false, false, false, true); g.homeworldOccupationPreview = true; g.pendingHomeworldOccupiedIncome = null; g.homeworldOccupiedIncomeHistory = []; g.homeworldOccupiedPercentageLedger = createOccupiedPercentageState(); g.pendingHomeworldOccupiedPercentage = null; g.pendingHomeworldOccupiedBonus = null; if (byFaction(g, 'choam')) { g.homeworldTupilePreview = true; g.homeworldTupileState = createOccupiedTupileState(); g.pendingHomeworldTupileCleanup = null; } if (byFaction(g, 'ecaz')) g.homeworldEcazVictoryPreview = true; return g; } /** Original classic and supported native Nexus setup; public starts remain gated. */ export function initializeNexusGameForAudit(state: Game): Game { requireRule(!!state.nexusCards && state.nexusCards.cards === null && state.nexusCards.phase === null, 'Enable Nexus cards in a fresh audit lobby first.'); const standalone = standaloneE3NexusProfile(state); const pairedE3 = pairedE3NexusProfile(state); const mixedE3 = mixedE3NexusProfile(state); const native = mixedE3 || standalone || pairedE3 || singleE1E2NexusProfile(state) || mixedE1E2NexusProfile(state); const discovery = classicDiscoveryNexusProfile(state) || standaloneE3DiscoveryNexusProfile(state) || mixedE3DiscoveryNexusProfile(state) || singleE1E2DiscoveryNexusProfile(state) || mixedE1E2DiscoveryNexusProfile(state) || pairedE3DiscoveryNexusProfile(state); if (state.discoveryEnabled) { requireRule(discovery, 'Discovery Nexus requires a fresh supported original classic or native roster and module envelope.'); requireFreshBaseRuntime(state); requireFreshFactionInventory(state); } const g = initializeSetupGameForAudit(state, !!state.homeworlds, true, false, discovery, false, false, native); if ((mixedE3 || standalone || pairedE3) && byFaction(g, 'ecaz')) g.ecazOccupyPreview = true; if ((mixedE3 || standalone || pairedE3) && g.advanced && byFaction(g, 'moritani')) initializeMoritaniAssassinateState(g); return g; } /** Fresh audit admission only; ordinary games and public starts never opt in. */ export function initializeHarkonnenNexusBetrayalGameForAudit(state: Game): Game { requireRule(harkonnenNexusBetrayalModeSupported(state) && state.nexusCards?.cards === null && state.nexusCards.phase === null && state.nexusHarkonnenBetrayalPreview === undefined && state.pendingNexusHarkonnenBetrayal === undefined && state.nexusHarkonnenBetrayalHistory === undefined && state.nexusHarkonnenBetrayalCursor === undefined && state.pendingNexusHarkonnenReplacement === undefined && state.nexusHarkonnenReplacementHistory === undefined, 'Harkonnen Nexus Betrayal requires a fresh classic Harkonnen lobby with only base Treachery and Nexus.'); requireFreshBaseRuntime(state); const g = initializeNexusGameForAudit(state); g.nexusHarkonnenBetrayalPreview = true; g.pendingNexusHarkonnenBetrayal = null; g.nexusHarkonnenBetrayalHistory = []; g.nexusHarkonnenBetrayalCursor = initialHarkonnenBetrayalCursor(); g.pendingNexusHarkonnenReplacement = null; g.nexusHarkonnenReplacementHistory = []; return g; } function harkonnenNexusBetrayalModeSupported(g: Game): boolean { return typeof g.advanced === 'boolean' && !!g.nexusCards && g.expansions.length === 0 && g.players.length >= 2 && g.players.length <= 6 && !!byFaction(g,'harkonnen') && new Set(g.players.map(p => p.faction)).size === g.players.length && g.players.every(p => CLASSIC_FACTIONS[p.faction]) && !g.homeworlds && !g.leaderSkills && !g.discoveryEnabled && !g.discoveries && !g.discoveryStash && !g.greatMaker && !g.techTokens && !g.strongholdCards && !g.ecazTreachery && !g.semutaPreview && !g.kullPreview && !g.nexusKullPreview && !g.richeseBetrayalPreview && !g.guildBetrayalPreview && !g.nexusIxianReplacementPreview && !g.nexusIxianBetrayalPreview && !g.moritaniAssassinatePreview; } function harkonnenNexusBetrayalContext(g: Game) { const b = g.battle!; const plans = Object.fromEntries([b.attacker,b.defender].map(id => [id,{ leader:b.plans[id].leader,...(b.plans[id].kwisatz === undefined ? {} : {kwisatz:b.plans[id].kwisatz}), }])); const battle = {event:b.event!,attacker:b.attacker,defender:b.defender,plans,heroLeaderIds:[]}; return {status:g.status,phase:g.phase,turn:g.turn,advanced:g.advanced, sequence:g.nexusHarkonnenBetrayalCursor!.sequence,players:g.players, cards:g.nexusCards!.cards!,physicalCards:physicalTreacheryCards(g), traitors:nexusTraitorSnapshot(g),universe:nexusTraitorUniverse(g),battle}; } function harkonnenNexusBetrayalAuthority(g: Game): HarkonnenBetrayalAuthority { const b = g.battle, allowance = b?.nexusHarkonnenAllowance; requireRule(b?.revealed && b.event && allowance && g.phase === 6 && b.traitorCalls[allowance.provider] === true && traitorVoters(g,b).includes(allowance.provider) && getPlayer(g,allowance.provider).faction === 'harkonnen', 'The Harkonnen reaction lost its original legal native call.'); const declaration = nexusRule(() => createTraitorDeclaration(traitorDeclarationContext(g),allowance.provider)); requireRule(JSON.stringify(declaration) === JSON.stringify(b.traitorDeclarations?.[allowance.provider]) && allowance.nativeWindow === ([b.attacker,b.defender].includes(allowance.provider) ? 'direct' : 'harkonnenTraitor'), 'The Harkonnen call lost its original canonical declaration or native window.'); const required = allowance.nativeWindow === 'direct' ? [] : g.players.filter(p => responseCancelCards(g,p,{kind:'harkonnenTraitor',owner:allowance.provider,passed:[]}).length > 0).map(p => p.id); requireRule(JSON.stringify(required) === JSON.stringify(allowance.required) && new Set(allowance.passed).size === allowance.passed.length && allowance.passed.every(id => g.players.some(p => p.id === id)) && required.every(id => allowance.passed.includes(id)) && (allowance.nativeWindow !== 'direct' || allowance.passed.length === 0), 'The original native Harkonnen counters were not legally completed.'); return {provider:allowance.provider,declaration,nativeWindow:allowance.nativeWindow, nativeRequired:required,nativePassed:allowance.passed, nativeContext:JSON.stringify({event:b.event,territory:b.territory,attacker:b.attacker, defender:b.defender,plans:b.plans,allowance})}; } function harkonnenNexusBetrayalParent(g: Game): string { return JSON.stringify({status:g.status,turn:g.turn,phase:g.phase,advanced:g.advanced, active:g.active,order:g.order,ready:g.ready,battle:g.battle, players:g.players.map(p => ({id:p.id,faction:p.faction,ally:p.ally,spice:p.spice, hand:p.hand,traitors:p.traitors,revealedTraitors:p.revealedTraitors,leaders:p.leaders, forces:p.forces,reserves:p.reserves,tanks:p.tanks,elites:p.elites,advisors:p.advisors})), deck:g.deck,discard:g.discard,traitorReserve:g.traitorReserve,nexus:g.nexusCards, response:g.response,decision:g.decision,truthtrance:g.truthtrance, phaseOpening:g.phaseOpening,pendingKarama:g.pendingKarama, pendingNullentropy:g.pendingNullentropy,pendingTreacheryDiscard:g.pendingTreacheryDiscard, replacement:g.pendingNexusHarkonnenReplacement}); } function harkonnenNexusBetrayalIntegrity(g: Game, mentatOpening = false): void { const frame = g.pendingNexusHarkonnenBetrayal; if (!g.nexusHarkonnenBetrayalPreview) { requireRule(g.nexusHarkonnenBetrayalPreview === undefined && !frame && g.nexusHarkonnenBetrayalHistory === undefined && g.nexusHarkonnenBetrayalCursor === undefined && !g.pendingNexusHarkonnenReplacement && g.nexusHarkonnenReplacementHistory === undefined, 'Harkonnen Nexus Betrayal lost its explicit fresh profile.'); return; } requireRule(harkonnenNexusBetrayalModeSupported(g) && g.nexusHarkonnenBetrayalCursor && Array.isArray(g.nexusHarkonnenBetrayalHistory) && Array.isArray(g.nexusHarkonnenReplacementHistory), 'The Harkonnen profile lost its bounded history.'); const universe = nexusTraitorUniverse(g); nexusCardsIntegrity(g); nexusRule(() => validateHarkonnenBetrayalHistory(g.nexusHarkonnenBetrayalHistory!,g.nexusHarkonnenBetrayalCursor!,universe)); nexusRule(() => validateHarkonnenBetrayalReplacements(g.nexusHarkonnenBetrayalHistory!, g.nexusHarkonnenBetrayalCursor!,[...g.nexusHarkonnenReplacementHistory!, ...(g.pendingNexusHarkonnenReplacement ? [g.pendingNexusHarkonnenReplacement] : [])],universe)); requireRule(g.nexusHarkonnenBetrayalHistory!.every(r => r.source.turn <= g.turn) && g.nexusHarkonnenReplacementHistory!.every(r => r.status === 'drawn' && r.turn <= g.turn) && (!g.pendingNexusHarkonnenReplacement || (g.status === 'playing' && g.pendingNexusHarkonnenReplacement.status === 'due' && g.pendingNexusHarkonnenReplacement.turn === g.turn && (g.phase === 6 || g.phase === 7 || (mentatOpening && g.phase === 8)))), 'The private replacement lost its current native Mentat obligation.'); if (g.status !== 'setup' && g.status !== 'lobby') nexusRule(() => validateNexusTraitorSnapshot(nexusTraitorSnapshot(g),universe)); if (!frame) { const battle = g.battle, allowance = battle?.nexusHarkonnenAllowance; if (allowance) { const closed = g.nexusHarkonnenBetrayalHistory!.find(receipt => receipt.source.declaration.event === battle!.event && receipt.source.provider === allowance.provider); requireRule(closed && closed.source.nativeWindow === allowance.nativeWindow && JSON.stringify(closed.source.nativeRequired) === JSON.stringify(allowance.required) && JSON.stringify(closed.source.nativePassed) === JSON.stringify(allowance.passed) && JSON.stringify(closed.source.declaration) === JSON.stringify(battle!.traitorDeclarations?.[allowance.provider]) && battle!.traitorCalls[allowance.provider] === (closed.outcome === 'pass'), 'The original native Harkonnen allowance lost its pending or closed Nexus acknowledgement.'); } return; } requireRule(Object.keys(frame).sort().join(',') === 'passed,required,source' && Array.isArray(frame.required) && Array.isArray(frame.passed) && frame.required.some(id => !frame.passed.includes(id)) && !g.response && !g.decision && !g.phaseOpening && !g.truthtrance && !g.pendingKarama && !g.pendingNullentropy && !g.pendingTreacheryDiscard, 'The Harkonnen acknowledgement lost original native priority.'); nexusRule(() => validateHarkonnenBetrayalFrame(harkonnenNexusBetrayalContext(g),frame.source, harkonnenNexusBetrayalAuthority(g),harkonnenNexusBetrayalParent(g),frame.required,frame.passed)); } function offerHarkonnenNexusBetrayal(g: Game, provider: string, response?: ResponseWindow): boolean { if (!g.nexusHarkonnenBetrayalPreview || getPlayer(g,provider).faction !== 'harkonnen') return false; const b = g.battle!; const required = nexusRule(() => harkonnenBetrayalResponders(g.nexusCards!.cards!,g.players)); if (!required.length) return false; requireRule(!b.nexusHarkonnenAllowance && (!response || response.kind === 'harkonnenTraitor'), 'The original Harkonnen declaration was already acknowledged.'); b.nexusHarkonnenAllowance = {provider,nativeWindow:response ? 'harkonnenTraitor' : 'direct', required:response ? g.players.filter(p => responseCancelCards(g,p,response).length > 0).map(p => p.id) : [], passed:response ? [...response.passed] : []}; const declaration = harkonnenNexusBetrayalAuthority(g).declaration; const owner = getPlayer(g,provider); owner.revealedTraitors = [...new Set([...(owner.revealedTraitors ?? []),declaration.identity])]; const name = g.players.flatMap(p => p.leaders).find(l => l.id === declaration.leader)?.name ?? declaration.leader; log(g,`${owner.name} revealed ${name} as a traitor.`); const source = nexusRule(() => createHarkonnenBetrayalSource(harkonnenNexusBetrayalContext(g), harkonnenNexusBetrayalAuthority(g),harkonnenNexusBetrayalParent(g))); g.pendingNexusHarkonnenBetrayal = {source,required,passed:[]}; return true; } function harkonnenNexusBetrayalView(g: Game,id: string) { const frame = g.pendingNexusHarkonnenBetrayal; if (!frame) return null; const hasPassed = frame.passed.includes(id), canPass = frame.required.includes(id) && !hasPassed; const d = frame.source.declaration; return {event:frame.source.event,provider:frame.source.provider,beneficiary:d.beneficiary, target:d.target,leader:d.leader,identity:d.identity,canPass,hasPassed, canUse:canPass && nexusRule(() => harkonnenBetrayalEligible(g.nexusCards!.cards!,g.players,id)),blocked:null as string | null}; } function decideHarkonnenNexusBetrayal(g: Game,id: string,action: Action): void { harkonnenNexusBetrayalIntegrity(g); const frame = g.pendingNexusHarkonnenBetrayal!; requireRule(['nexusHarkonnenBetrayalPass','nexusHarkonnenBetrayalUse'].includes(action?.type) && action.event === frame.source.event && Object.keys(action).length === 2 && Object.keys(action).every(key => key === 'type' || key === 'event') && frame.required.includes(id) && !frame.passed.includes(id), 'A current required seat must acknowledge only the original Harkonnen event.'); const use = action.type === 'nexusHarkonnenBetrayalUse'; if (use) requireRule(!g.pendingNexusHarkonnenReplacement && nexusRule(() => harkonnenBetrayalEligible(g.nexusCards!.cards!,g.players,id)), 'You do not own an eligible Harkonnen Nexus card or the prior replacement is still due.'); if (!use) { frame.passed.push(id); if (frame.required.some(seat => !frame.passed.includes(seat))) return; } const closed = nexusRule(() => closeHarkonnenBetrayalSource(frame.source,use ? 'use' : 'pass', use ? id : null,g.nexusHarkonnenBetrayalCursor!)); if (use) { const owner = getPlayer(g,frame.source.provider), identity = frame.source.declaration.identity; requireRule(owner.traitors.filter(card => card === identity).length === 1 && g.traitorReserve, 'The original called physical traitor is no longer held exactly once.'); const replacement = nexusRule(() => createHarkonnenBetrayalReplacement(closed.receipt)); g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!,id,g.players)); owner.traitors = owner.traitors.filter(card => card !== identity); owner.revealedTraitors = (owner.revealedTraitors ?? []).filter(card => card !== identity); g.traitorReserve = shuffle([...g.traitorReserve,identity]); g.battle!.traitorCalls[owner.id] = false; g.pendingNexusHarkonnenReplacement = replacement; log(g,`${getPlayer(g,id).name} spent Harkonnen Nexus Betrayal. The declared Traitor Card returned to the shuffled Traitor Deck; Harkonnen will draw one private replacement during this turn’s Mentat Pause.`); } g.nexusHarkonnenBetrayalHistory!.push(closed.receipt); g.nexusHarkonnenBetrayalCursor = closed.cursor; g.pendingNexusHarkonnenBetrayal = null; if (traitorVoters(g,g.battle!).every(voter => g.battle!.traitorCalls[voter] !== undefined)) advanceBattleOutcome(g); } function drawHarkonnenNexusBetrayalReplacement(g: Game): void { const due = g.pendingNexusHarkonnenReplacement; if (!due) return; harkonnenNexusBetrayalIntegrity(g,true); requireRule(g.status === 'playing' && g.phase === 8 && due.turn === g.turn && g.traitorReserve && g.traitorReserve.length > 0, 'The private Harkonnen replacement requires its actual current Mentat opening and Traitor Deck.'); const identity = g.traitorReserve[0]; const drawn = nexusRule(() => drawHarkonnenBetrayalReplacement(due,g.nexusHarkonnenBetrayalHistory!, g.nexusHarkonnenBetrayalCursor!,identity,nexusTraitorUniverse(g))); g.pendingNexusHarkonnenReplacement = null; g.nexusHarkonnenReplacementHistory!.push(drawn); g.traitorReserve.shift(); getPlayer(g,due.provider).traitors.push(identity); log(g,`${getPlayer(g,due.provider).name} drew the one private Traitor Card replacement owed by Harkonnen Nexus Betrayal.`); } /** Offline-only, fresh native Ixian/Ix47 + Nexus profile. */ export function initializeIxianNexusBetrayalGameForAudit(state: Game): Game { requireRule(ixianNexusBetrayalModeSupported(state) && state.nexusIxianBetrayalPreview === undefined && state.pendingNexusIxianBetrayal === undefined && state.nexusIxianBetrayalHistory === undefined && state.nexusIxianBetrayalCursor === undefined && state.nexusCards?.cards === null && state.nexusCards.phase === null, 'Ixian Nexus Betrayal needs a fresh native Ixian lobby with only Ix Treachery and Nexus Cards.'); requireFreshBaseRuntime(state); const g = initializeSetupGameForAudit(state, false, true, false, false, false, false, true); g.nexusIxianBetrayalPreview = true; g.pendingNexusIxianBetrayal = null; g.nexusIxianBetrayalHistory = []; g.nexusIxianBetrayalCursor = initialIxianBetrayalCursor(); // This profile does not opt into the optional Sandtrout Spice card. g.spiceDeck = shuffle(spiceDeck()); return g; } function ixianNexusBetrayalModeSupported(g: Game): boolean { return typeof g.advanced === 'boolean' && !!g.nexusCards && g.expansions.length === 1 && g.expansions[0] === 'ix' && g.players.length >= 2 && g.players.length <= 6 && new Set(g.players.map(p => p.faction)).size === g.players.length && !!byFaction(g, 'ixians') && g.players.every(p => CLASSIC_FACTIONS[p.faction] || p.faction === 'ixians' || p.faction === 'tleilaxu') && !g.homeworlds && !g.leaderSkills && !g.discoveryEnabled && !g.discoveries && !g.discoveryStash && !g.greatMaker && !g.techTokens && !g.strongholdCards && !g.ecazTreachery && !g.semutaPreview && !g.kullPreview && !g.nexusKullPreview && !g.richeseBetrayalPreview && !g.guildBetrayalPreview && !g.nexusIxianReplacementPreview && !g.moritaniAssassinatePreview; } function ixianNexusBetrayalContext(g: Game) { return {status:g.status,phase:g.phase,turn:g.turn,advanced:g.advanced, sequence:g.nexusIxianBetrayalCursor!.sequence,players:g.players, cards:g.nexusCards!.cards!,physicalCards:physicalTreacheryCards(g)}; } function ixianNexusBetrayalAuthority(g: Game, response: ResponseWindow) { requireRule(response.kind === 'ixAuction' || response.kind === 'ixTechnology', 'The saved Nexus continuation is not an original native Ixian window.'); requireRule(Object.keys(response).sort().join(',') === 'kind,owner,passed', 'The native Ixian continuation changed its original response shape.'); if (response.kind === 'ixAuction') currentIxAuctionDrawQuote(g,response,false); else { auctionTechnologyQuote(() => quoteIxTechnologyCancellation(g,response)); requireRule(getPlayer(g,response.owner).hand.filter(c => c.id === g.pendingIxTechnology!.card).length === 1, 'The original Ixian Technology declaration lost its selected hand card.'); } return {kind:response.kind === 'ixAuction' ? 'bidding' as const : 'technology' as const, provider:response.owner,nativeWindow:response.kind, nativeContext:JSON.stringify({ixAuction:g.ixAuction,pendingIxTechnology:g.pendingIxTechnology, ixTechnologyTurn:g.ixTechnologyTurn,auction:g.auction}), nativeRequired:g.players.filter(p => responseCancelCards(g,p,response).length > 0).map(p => p.id), nativePassed:response.passed}; } function ixianNexusBetrayalParent(g: Game, response: ResponseWindow): string { return JSON.stringify({continuation:response,status:g.status,phase:g.phase,turn:g.turn,advanced:g.advanced, active:g.active,order:g.order,auction:g.auction,sale:g.currentAuctionSale,ixAuction:g.ixAuction, ixKnown:g.ixAuctionKnown,technology:g.pendingIxTechnology,technologyTurn:g.ixTechnologyTurn, players:g.players.map(p => ({id:p.id,faction:p.faction,ally:p.ally,spice:p.spice,hand:p.hand, forces:p.forces,reserves:p.reserves,tanks:p.tanks,elites:p.elites,advisors:p.advisors})), deck:g.deck,discard:g.discard,aid:g.aid,nexus:g.nexusCards,decision:g.decision,response:g.response, phaseOpening:g.phaseOpening,truthtrance:g.truthtrance,pendingKarama:g.pendingKarama, pendingNullentropy:g.pendingNullentropy,pendingTreacheryDiscard:g.pendingTreacheryDiscard}); } function ixianNexusBetrayalIntegrity(g: Game): void { const frame = g.pendingNexusIxianBetrayal; if (!g.nexusIxianBetrayalPreview) { requireRule(g.nexusIxianBetrayalPreview === undefined && !frame && g.nexusIxianBetrayalHistory === undefined && g.nexusIxianBetrayalCursor === undefined, 'Ixian Nexus Betrayal lost its explicit fresh profile.'); return; } requireRule(ixianNexusBetrayalModeSupported(g) && g.nexusIxianBetrayalCursor && Array.isArray(g.nexusIxianBetrayalHistory), 'The native Ixian Nexus profile lost its history or cursor.'); nexusCardsIntegrity(g); nexusRule(() => validateIxianBetrayalHistory(g.nexusIxianBetrayalHistory!, g.nexusIxianBetrayalCursor!,physicalTreacheryCards(g))); if (!frame) { if (g.ixAuction?.cards.length === 0 || g.pendingIxTechnology) { 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 provider = byFaction(g, 'ixians')!.id; const ownsWindow = (kind: 'ixAuction' | 'ixTechnology') => contexts.some(context => { const pending = context && 'pendingKarama' in context ? context.pendingKarama as Game['pendingKarama'] : null; const original = pending?.use.kind === 'cancel' ? pending.use.response : null; return (context?.response?.kind === kind && context.response.owner === provider) || (original?.kind === kind && original.owner === provider); }); requireRule((!g.ixAuction || g.ixAuction.cards.length > 0 || ownsWindow('ixAuction')) && (!g.pendingIxTechnology || ownsWindow('ixTechnology')), 'The native Ixian attempt lost its original counter or Nexus acknowledgement.'); } return; } requireRule(Object.keys(frame).sort().join(',') === 'continuation,passed,required,source' && Array.isArray(frame.required) && Array.isArray(frame.passed) && frame.required.length > 0 && frame.required.some(id => !frame.passed.includes(id)) && !g.response && !g.decision && !g.phaseOpening && !g.truthtrance && !g.pendingKarama && !g.pendingNullentropy && !g.pendingTreacheryDiscard, 'The Ixian Nexus acknowledgement lost its original native priority.'); nexusRule(() => validateIxianBetrayalFrame(ixianNexusBetrayalContext(g),frame.source, ixianNexusBetrayalAuthority(g,frame.continuation),ixianNexusBetrayalParent(g,frame.continuation), frame.required,frame.passed)); } function offerIxianNexusBetrayal(g: Game,response: ResponseWindow): boolean { if (!g.nexusIxianBetrayalPreview) return false; if (response.kind === 'ixTechnology') { auctionTechnologyQuote(() => quoteIxTechnologyCancellation(g, response)); // Native interruptions may legally move the declared card before counters // finish. That spent attempt fizzles without exposing a Nexus offer. if (!getPlayer(g, response.owner).hand.some(c => c.id === g.pendingIxTechnology!.card)) return false; } const required = nexusRule(() => ixianBetrayalResponders(g.nexusCards!.cards!,g.players)); if (!required.length) return false; const source = nexusRule(() => createIxianBetrayalSource(ixianNexusBetrayalContext(g), ixianNexusBetrayalAuthority(g,response),ixianNexusBetrayalParent(g,response))); g.pendingNexusIxianBetrayal = {source,continuation:structuredClone(response),required,passed:[]}; return true; } function ixianNexusBetrayalView(g: Game,id: string) { const frame = g.pendingNexusIxianBetrayal; if (!frame) return null; const hasPassed = frame.passed.includes(id), canPass = frame.required.includes(id) && !hasPassed; const eligible = canPass && nexusRule(() => ixianBetrayalEligible(g.nexusCards!.cards!,g.players,id)); return {event:frame.source.event,kind:frame.source.kind,provider:frame.source.provider, canPass,hasPassed,canUse:eligible,blocked:null as string | null}; } function decideIxianNexusBetrayal(g: Game,id: string,action: Action): void { ixianNexusBetrayalIntegrity(g); const frame = g.pendingNexusIxianBetrayal!; requireRule(['nexusIxianBetrayalPass','nexusIxianBetrayalUse'].includes(action?.type) && action.event === frame.source.event && Object.keys(action).length === 2 && Object.keys(action).every(key => key === 'type' || key === 'event') && frame.required.includes(id) && !frame.passed.includes(id), 'A current required seat must acknowledge only the original Ixian event.'); const use = action.type === 'nexusIxianBetrayalUse'; if (use) requireRule(nexusRule(() => ixianBetrayalEligible(g.nexusCards!.cards!,g.players,id)), 'You do not own an eligible unallied Ixian Nexus card.'); if (!use) { frame.passed.push(id); if (frame.required.some(seat => !frame.passed.includes(seat))) return; } // Validation above precedes the one physical Nexus cost and native RNG. const closed = nexusRule(() => closeIxianBetrayalSource(frame.source,use ? 'use' : 'pass', use ? id : null,g.nexusIxianBetrayalCursor!)); if (use) g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!,id,g.players)); g.nexusIxianBetrayalHistory!.push(closed.receipt); g.nexusIxianBetrayalCursor = closed.cursor; g.pendingNexusIxianBetrayal = null; if (use) log(g,`${getPlayer(g,id).name} spent Ixian Nexus Betrayal to prevent the original native ${frame.source.kind} attempt.`); finishIxianNativeAttempt(g,frame.continuation,use); } /** Fresh classic or Basic native Tleilaxu profile; no public start or retrofit. */ export function initializeIxianNexusReplacementGameForAudit(state: Game): Game { requireRule(!state.nexusIxianReplacementPreview && !state.pendingNexusIxianReplacement && state.nexusIxianReplacementHistory === undefined && state.nexusIxianReplacementCursor === undefined && ixianNexusReplacementModeSupported(state), 'Ixian Nexus replacement needs a fresh classic/base or Basic Tleilaxu/Ix lobby with Nexus alone.'); const g = initializeNexusGameForAudit(state); // Ix supplies Treachery here, not the optional Sandtrout Spice card. if (g.expansions.length) g.spiceDeck = shuffle(spiceDeck()); g.nexusIxianReplacementPreview = true; g.pendingNexusIxianReplacement = null; g.nexusIxianReplacementHistory = []; g.nexusIxianReplacementCursor = initialIxianReplacementCursor(); return g; } function ixianNexusReplacementModeSupported(g: Game): boolean { return !!g.nexusCards && g.players.length >= 2 && g.players.length <= 6 && new Set(g.players.map(p => p.faction)).size === g.players.length && ((!g.expansions.length && g.players.every(p => CLASSIC_FACTIONS[p.faction] === true)) || (!g.advanced && g.expansions.length === 1 && g.expansions[0] === 'ix' && g.players.some(p => p.faction === 'tleilaxu') && g.players.every(p => p.faction === 'tleilaxu' || CLASSIC_FACTIONS[p.faction] === true))) && !g.homeworlds && !g.leaderSkills && !g.discoveryEnabled && !g.discoveries && !g.techTokens && !g.strongholdCards && !g.ecazTreachery && !g.mobileStronghold && !g.sandtrout && !g.kullPreview && !g.nexusKullPreview && !g.semutaPreview && !g.richeseBetrayalPreview && !g.guildBetrayalPreview && !g.moritaniAssassinatePreview; } function ixianNexusReplacementParent(g: Game): string { return JSON.stringify({status:g.status,turn:g.turn,phase:g.phase,advanced:g.advanced, order:g.order,active:g.active,auction:g.auction,sale:g.currentAuctionSale, players:g.players.map(p => ({id:p.id,faction:p.faction,ally:p.ally,spice:p.spice,hand:p.hand})), deck:g.deck,discard:g.discard,aid:g.aid,nexus:g.nexusCards, decision:g.decision,response:g.response,phaseOpening:g.phaseOpening}); } function ixianNexusReplacementContext(g: Game) { requireRule(g.auction, 'The Nexus purchased-card choice lost its normal auction.'); return {status:g.status,phase:g.phase,turn:g.turn,sequence:g.nexusIxianReplacementCursor!.sequence, auction:g.auction,sale:g.currentAuctionSale ?? null, players:g.players.map(p => ({id:p.id,hand:p.hand,handLimit:handLimit(p)})), physicalCards:physicalTreacheryCards(g)}; } function ixianNexusReplacementIntegrity(g: Game): void { const pending = g.pendingNexusIxianReplacement; if (!g.nexusIxianReplacementPreview) { requireRule(g.nexusIxianReplacementPreview === undefined && !pending && g.nexusIxianReplacementHistory === undefined && g.nexusIxianReplacementCursor === undefined && g.decision?.kind !== 'nexusIxianReplacement', 'The Ixian Nexus replacement lost its explicit fresh profile.'); return; } requireRule(ixianNexusReplacementModeSupported(g) && Array.isArray(g.nexusIxianReplacementHistory) && g.nexusIxianReplacementCursor, 'The Ixian Nexus replacement profile or history is invalid.'); nexusRule(() => validateIxianReplacementHistory(g.nexusIxianReplacementHistory!, g.nexusIxianReplacementCursor!,physicalTreacheryCards(g))); if (!pending) { requireRule(g.decision?.kind !== 'nexusIxianReplacement', 'The purchased-card choice lost its source.'); return; } const buyer = getPlayer(g,pending.buyer); requireRule(!buyer.ally && buyer.faction !== 'harkonnen' && !!g.nexusCards?.cards?.hands[buyer.id] && g.decision?.kind === 'nexusIxianReplacement' && g.decision.player === buyer.id && g.decision.event === pending.event && Object.keys(g.decision).every(key => ['kind','player','event'].includes(key)) && !g.response && !g.phaseOpening && !g.truthtrance && !g.pendingKarama && !g.pendingNullentropy && !g.pendingTreacheryDiscard && !g.richeseAuction, 'The purchased-card choice lost its buyer or native decision priority.'); nexusRule(() => validateIxianReplacementSource(ixianNexusReplacementContext(g),pending, ixianNexusReplacementParent(g))); } function offerIxianNexusReplacement(g: Game, printedKarama: boolean): boolean { if (!g.nexusIxianReplacementPreview) return false; const sale = g.currentAuctionSale; const buyer = sale && getPlayer(g,sale.winner); if (!sale || !buyer || sale.origin !== 'normal' || (sale.free && !printedKarama) || buyer.faction === 'harkonnen' || buyer.ally || !g.nexusCards?.cards?.hands[buyer.id]) return false; requireRule(!g.response && !g.phaseOpening && !g.truthtrance && !g.pendingKarama, 'Finish the original auction priority before offering the purchased-card choice.'); const event = ixianReplacementEvent(g.turn,buyer.id,g.nexusIxianReplacementCursor!.sequence,g.auction!.index); g.decision = {kind:'nexusIxianReplacement',player:buyer.id,event}; g.active = buyer.id; g.pendingNexusIxianReplacement = nexusRule(() => createIxianReplacementSource( ixianNexusReplacementContext(g),event,ixianNexusReplacementParent(g))); return true; } function ixianNexusReplacementView(g: Game,id: string) { const source = g.pendingNexusIxianReplacement; if (!source) return null; const own = source.buyer === id; return {event:source.event,buyer:source.buyer,canPass:own, canUse:own && g.nexusCards?.cards?.hands[id] === 'ixians', blocked:null as string | null,purchased:own ? structuredClone(source.card) : null}; } function decideIxianNexusReplacement(g: Game,id: string,action: Action): void { ixianNexusReplacementIntegrity(g); const source = g.pendingNexusIxianReplacement!; requireRule(source.buyer === id && action.event === source.event && ['nexusIxianReplacementPass','nexusIxianReplacementUse'].includes(action.type) && Object.keys(action).length === 2 && Object.keys(action).every(key => ['type','event'].includes(key)), 'Choose only the current owned purchased-card event with Use or Pass.'); let replacement: Card | null = null; const use = action.type === 'nexusIxianReplacementUse'; if (use) { requireRule(g.nexusCards?.cards?.hands[id] === 'ixians', 'You do not hold the eligible Ixian Nexus card.'); // All original-source/custody checks precede either the singleton cost or RNG. g.nexusCards.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!,id,g.players)); const buyer = getPlayer(g,id); discard(g,buyer,source.card.id); replacement = draw(g) ?? null; requireRule(replacement, 'The purchased-card discard must supply a replacement draw.'); buyer.hand.push(replacement); } const closed = nexusRule(() => closeIxianReplacementSource(source,use ? 'use' : 'pass', replacement,g.nexusIxianReplacementCursor!)); g.nexusIxianReplacementHistory!.push(closed.receipt); g.nexusIxianReplacementCursor = closed.cursor; g.pendingNexusIxianReplacement = null; g.decision = null; if (use) log(g,`${getPlayer(g,id).name} spent Ixian Nexus Secret Ally to discard the purchased Treachery Card and draw a private replacement.`, {faction:getPlayer(g,id).faction,name:'Nexus purchased-card replacement'}); continueAuctionSale(g,source.free); } /** Opt-in fresh classic setup through the real physical Nexus/Homeworld setup. */ export function initializeGuildBetrayalGameForAudit(state: Game): Game { requireRule(!state.guildBetrayalPreview && !state.guildBetrayal && !state.pendingGuildBetrayal && !state.nexusGuildBetrayalHistory && !state.guildBetrayalSourceReceipts && guildBetrayalModeSupported(state), 'Guild Betrayal needs a fresh classic native-Guild lobby with only Nexus and optional Homeworlds.'); const g = initializeNexusGameForAudit(state); g.guildBetrayalPreview = true; g.guildBetrayal = {sequence: 0, event: null, turn: null, sourceSignature: null, status: 'completed'}; g.nexusGuildBetrayalHistory = []; g.guildBetrayalSourceReceipts = []; g.pendingGuildBetrayal = null; return g; } /** Development-only composition of the bounded Betrayal preview with classic Leader Skills. */ export function initializeGuildBetrayalLeaderSkillsGameForAudit(state: Game): Game { requireRule(!state.leaderSkills && !state.guildBetrayalPreview && !state.guildBetrayal && !state.pendingGuildBetrayal && !state.nexusGuildBetrayalHistory && !state.guildBetrayalSourceReceipts && (!state.nexusCards || state.nexusCards.cards === null && state.nexusCards.phase === null) && !state.homeworlds && !state.discoveryEnabled && !state.discoveries && !state.techTokens && !state.strongholdCards && !state.ecazTreachery && !state.kullPreview && !state.nexusKullPreview && !state.richeseBetrayalPreview && !state.mobileStronghold && state.expansions.length === 0 && state.players.length >= 2 && state.players.length <= 6 && !!byFaction(state, 'guild') && state.players.every(p => CLASSIC_FACTIONS[p.faction] === true), 'Guild Betrayal with Leader Skills needs a fresh classic native-Guild lobby with only Nexus.'); const g = structuredClone(state); g.nexusCards = {cards: null, phase: null}; g.guildBetrayalPreview = true; g.guildBetrayal = {sequence: 0, event: null, turn: null, sourceSignature: null, status: 'completed'}; g.nexusGuildBetrayalHistory = []; g.guildBetrayalSourceReceipts = []; g.pendingGuildBetrayal = null; return initializeLeaderSkillsGameForAudit(g); } function guildBetrayalModeSupported(g: Game): boolean { return !!g.nexusCards && g.expansions.length === 0 && g.players.length >= 2 && g.players.length <= 6 && !!byFaction(g, 'guild') && g.players.every(p => CLASSIC_FACTIONS[p.faction] === true) && (!g.leaderSkills || guildBetrayalNexusLeaderSkillsProfile(g)) && !g.discoveryEnabled && !g.discoveries && !g.techTokens && !g.strongholdCards && !g.ecazTreachery && !g.kullPreview && !g.nexusKullPreview && !g.richeseBetrayalPreview && !g.mobileStronghold; } function guildBetrayalParentSignature(g: Game, continuation: GuildBetrayalContinuation): string { return JSON.stringify({ continuation, status: g.status, turn: g.turn, phase: g.phase, active: g.active, order: g.order, storm: g.storm, movementRemaining: g.movementRemaining, players: g.players.map(p => ({id: p.id, faction: p.faction, ally: p.ally, spice: p.spice, forces: p.forces, reserves: p.reserves, tanks: p.tanks, elites: p.elites, advisors: p.advisors, shipped: p.shipped, moved: p.moved, hand: p.hand, specialKaramaUsed: p.specialKaramaUsed})), aid: g.aid, homeworlds: g.homeworlds, nexus: g.nexusCards, promises: g.shipmentPromises, karama: g.karamaShipping, rate: g.guildRateBlocked, offer: g.junctionOffer, decision: g.decision, response: g.response, phaseOpening: g.phaseOpening, truthtrance: g.truthtrance, pendingShipment: g.pendingShipment, pendingHomeworldShipment: g.pendingHomeworldShipment, pendingGuildTransport: g.pendingGuildTransport, }); } function guildBetrayalCleanParent(g: Game): void { requireRule(guildBetrayalModeSupported(g) && g.status === 'playing' && g.phase === 5 && !g.decision && !g.response && !g.phaseOpening && !g.truthtrance && !g.battle && !g.pendingNullentropy && !g.pendingTreacheryDiscard && !g.pendingExchange && !g.pendingRicheseGift && !g.pendingKarama && !g.pendingKull && !pendingNexusTraitors(g) && !g.pendingShipment && !g.pendingHomeworldShipment && !g.pendingGuildTransport && !g.pendingAmbassador && !g.pendingTerrorEntry && !g.pendingRicheseBetrayal && !g.bureaucratPayments?.pending, 'Guild Betrayal requires the original clean native shipment before funded payment.'); } function guildBetrayalAuthority(g: Game, continuation: GuildBetrayalContinuation): GuildBetrayalAuthority { requireRule(continuation && typeof continuation === 'object' && !Array.isArray(continuation), 'The saved Guild shipment producer is malformed.'); const producerKeys: Record = { reserve: 'shipment,source', guildTransport: 'frame,fromReserves,source', homeworld: 'shipment,source', junction: 'source,transport', }; requireRule(Object.hasOwn(producerKeys, continuation.source) && Object.keys(continuation).sort().join(',') === producerKeys[continuation.source], 'The saved Guild shipment producer contains foreign action fields.'); let shipper: string, price: number, allyPayment: number, originalReceiver: string | null; const guild = byFaction(g, 'guild')!; switch (continuation.source) { case 'reserve': { const shipment = continuation.shipment; requireRule(shipment && shipment.turn === g.turn && Object.keys(shipment).every(key => ['turn', 'player', 'territory', 'sector', 'amount', 'elite', 'cost', 'allyPayment', 'advisors', 'homeworldSources', 'guildRateEvent', 'smuggler', 'smugglerCompanion', 'guildSecretEvent', 'guildNexusEvent', 'nexusEvent', 'richesePair', 'noField', 'alliedNoField', 'noFieldSkillProof'].includes(key)), 'The saved reserve shipment contains foreign source fields.'); requireRule(!shipment.source && !shipment.noField && !shipment.alliedNoField && !shipment.smuggler && !shipment.smugglerCompanion && !shipment.richesePair && !shipment.guildSecretEvent && !shipment.guildNexusEvent && !shipment.nexusEvent, 'This Guild Betrayal profile supports native physical reserve shipments only.'); validatePhysicalShipment(g, shipment); validateShipmentArrival(g, shipment); checkShipmentIncomeFunding(g, getPlayer(g, shipment.player), shipment.cost, shipment.allyPayment); checkShipmentPromises(g, getPlayer(g, shipment.player), shipment); shipper = shipment.player; price = shipment.cost; allyPayment = shipment.allyPayment; const p = getPlayer(g, shipper); originalReceiver = guildShipmentIncome({guild: guild.id, shipper, ally: p.ally, cost: price, allyPayment, bankOnly: g.karamaShipping?.player === shipper}) > 0 ? guild.id : null; break; } case 'guildTransport': { requireRule(typeof continuation.fromReserves === 'boolean', 'The Guild transport source flag is malformed.'); const frame = continuation.frame; requireRule(frame && Object.keys(frame).sort().join(',') === 'advisors,allyPayment,amount,cost,elite,eliteGroup,group,origin,player,sector,to,turn', 'The saved Guild transport frame is malformed.'); const p = getPlayer(g, frame.player); validateGuildTransportForCommit(g, p, frame, continuation.fromReserves); shipper = p.id; price = frame.cost; allyPayment = frame.allyPayment; originalReceiver = guildShipmentIncome({guild: guild.id, shipper, ally: p.ally, cost: price, allyPayment, bankOnly: g.karamaShipping?.player === shipper}) > 0 ? guild.id : null; break; } case 'homeworld': { const shipment = continuation.shipment; requireRule(shipment && Object.keys(shipment).every(key => ['player', 'destination', 'sources', 'route', 'event', 'turn', 'amount', 'elite', 'cost', 'allyPayment', 'pools', 'guildSecretEvent', 'guildNexusEvent'].includes(key)), 'The saved Homeworld shipment contains foreign source fields.'); requireRule(!shipment.guildSecretEvent && !shipment.guildNexusEvent, 'This Guild Betrayal profile supports native Homeworld shipment routes only.'); const quote = validateHomeworldShipment(g, shipment); shipper = shipment.player; price = quote.cost; allyPayment = shipment.allyPayment; originalReceiver = getPlayer(g, shipper).ally !== guild.id && allyPayment > 0 ? guild.id : null; const income = getPlayer(g, shipper).ally !== guild.id ? allyPayment : 0; if (income > 0) validateGuildPaymentContributions(g, income, [income]); break; } case 'junction': { const declaration = continuation.transport; requireRule(declaration && Object.keys(declaration).sort().join(',') === 'allyPayment,destination,event,offer,player,sources,turn', 'The saved Junction declaration is malformed.'); const quote = quoteJunctionDeclaration(g, getPlayer(g, declaration.player), declaration); shipper = declaration.player; price = quote.quote.cost; allyPayment = declaration.allyPayment; originalReceiver = quote.option.owner; break; } default: throw new RuleError('The saved Guild Betrayal producer is unsupported.'); } const p = getPlayer(g, shipper); contribution(g, p, price, allyPayment); return {source: continuation.source, sourceSignature: guildBetrayalParentSignature(g, continuation), shipper, price, allyPayment, ownSpice: p.spice, allyEscrow: aidFor(g, p)?.amount ?? 0, donor: allyPayment ? p.ally! : null, originalReceiver}; } function guildBetrayalFrameSignature(frame: NonNullable): string { return JSON.stringify([frame.invoice.signature, frame.invoice.sourceSignature, frame.required, frame.passed]); } function guildBetrayalIntegrity(g: Game): void { const cursor = g.guildBetrayal, frame = g.pendingGuildBetrayal; if (!g.guildBetrayalPreview) { requireRule(!cursor && !frame && !g.nexusGuildBetrayalHistory && !g.guildBetrayalSourceReceipts, 'Guild Betrayal lost its explicit native profile.'); return; } requireRule(guildBetrayalModeSupported(g) && cursor && Array.isArray(g.nexusGuildBetrayalHistory) && Array.isArray(g.guildBetrayalSourceReceipts), 'The Guild Betrayal profile lost its cursor or independent native receipts.'); nexusCardsIntegrity(g); nexusRule(() => validateGuildBetrayalHistory({turn: g.turn, players: g.players}, g.nexusGuildBetrayalHistory!, cursor!, frame?.invoice ?? null)); requireRule(g.guildBetrayalSourceReceipts!.length === g.nexusGuildBetrayalHistory!.length, 'A completed Guild invoice lost its native shipment receipt.'); for (let i = 0; i < g.nexusGuildBetrayalHistory!.length; i++) { const receipt = g.nexusGuildBetrayalHistory![i], source = g.guildBetrayalSourceReceipts![i]; requireRule(source && source.event === receipt.invoice.event && source.turn === receipt.invoice.turn && source.shipper === receipt.invoice.shipper && source.sourceSignature === receipt.invoice.sourceSignature && source.receipt === receipt.sourceReceipt, 'A completed Guild invoice differs from its independently committed shipment.'); if (source.turn === g.turn && g.phase === 5) requireRule(getPlayer(g, source.shipper).shipped, 'A completed Guild invoice reopened its already used shipment.'); } if (!frame) return; requireRule(Object.keys(frame).sort().join(',') === 'continuation,invoice,passed,required,signature' && frame.continuation && Array.isArray(frame.required) && Array.isArray(frame.passed), 'The saved Guild Betrayal continuation is malformed.'); guildBetrayalCleanParent(g); const authority = guildBetrayalAuthority(g, frame.continuation); nexusRule(() => validateGuildBetrayalInvoice({turn: g.turn, players: g.players}, frame.invoice, authority, cursor!.sequence)); requireRule(frame.signature === guildBetrayalFrameSignature(frame) && JSON.stringify(frame.required) === JSON.stringify(guildBetrayalResponders(g.nexusCards!.cards!, g.players)) && new Set(frame.passed).size === frame.passed.length && frame.passed.every(id => frame.required.includes(id)) && frame.passed.length < frame.required.length, 'The saved Guild invoice lost its original custody, funding, acknowledgements or parent.'); } function beginGuildBetrayal(g: Game, continuation: GuildBetrayalContinuation): boolean { if (!g.guildBetrayalPreview) return false; // Release only the already accepted native declaration. No fee or custody // changes have happened, and no suspended decision can authorize a new gate. if (continuation.source === 'homeworld') g.pendingHomeworldShipment = null; guildBetrayalCleanParent(g); const authority = guildBetrayalAuthority(g, continuation); if (authority.price === 0) return false; const required = guildBetrayalResponders(g.nexusCards!.cards!, g.players); if (!required.length) return false; const sequence = g.guildBetrayal!.sequence + 1; const invoice = nexusRule(() => createGuildBetrayalInvoice({turn: g.turn, players: g.players}, authority, sequence)); g.pendingGuildBetrayal = {invoice, continuation: structuredClone(continuation), required, passed: [], signature: ''}; g.pendingGuildBetrayal.signature = guildBetrayalFrameSignature(g.pendingGuildBetrayal); g.guildBetrayal = {sequence, event: invoice.event, turn: g.turn, sourceSignature: invoice.sourceSignature, status: 'pending'}; return true; } function guildBetrayalUseBlock(g: Game, id: string): string | null { const frame = g.pendingGuildBetrayal; if (!frame || !frame.required.includes(id) || frame.passed.includes(id)) return 'This seat has no pending shipment acknowledgement.'; if (!nexusRule(() => guildBetrayalEligible(g.nexusCards!.cards!, g.players, id))) return 'Your held Nexus card cannot take this shipment payment.'; const promises = liveShipmentPromises(g.shipmentPromises ?? [], id, g.turn); if (promises.length && id !== frame.invoice.shipper) { const projected: Game = {...g, nexusCards: {...g.nexusCards!, cards: nexusRule(() => discardNexusCard(g.nexusCards!.cards!, id, g.players))}}; if (!findShipmentCompletion(projected, getPlayer(projected, id), promises)) return 'Spending this Nexus card would prevent your committed shipment answer.'; } return null; } function decideGuildBetrayal(g: Game, p: Player, action: Action): void { const frame = g.pendingGuildBetrayal!; requireRule(['guildBetrayalPass', 'guildBetrayalUse'].includes(action?.type) && action.event === frame.invoice.event && Object.keys(action).every(key => key === 'type' || key === 'event') && frame.required.includes(p.id) && !frame.passed.includes(p.id), 'Choose your exact current Guild Betrayal acknowledgement.'); const use = action.type === 'guildBetrayalUse'; if (use) { const blocked = guildBetrayalUseBlock(g, p.id); requireRule(!blocked, blocked ?? 'This Nexus card cannot be used.'); } else { frame.passed.push(p.id); frame.signature = guildBetrayalFrameSignature(frame); if (frame.passed.length < frame.required.length) return; } const discardIndex = use ? g.nexusCards!.cards!.discard.length : null; if (use) g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!, p.id, g.players)); g.pendingGuildBetrayal = null; const settlement = {recipient: use ? p.id : null}; const continuation = frame.continuation; switch (continuation.source) { case 'reserve': commitShipment(g, continuation.shipment, settlement); break; case 'guildTransport': commitGuildTransport(g, getPlayer(g, continuation.frame.player), continuation.frame, continuation.fromReserves, undefined, settlement); break; case 'homeworld': commitHomeworldShipment(g, continuation.shipment, settlement); break; case 'junction': commitJunctionTransport(g, getPlayer(g, continuation.transport.player), continuation.transport, settlement); break; } requireRule(getPlayer(g, frame.invoice.shipper).shipped, 'The funded native shipment did not complete.'); const sourceReceipt = nexusGuildBetrayalSourceReceipt(frame.invoice); const receipt = nexusRule(() => createGuildBetrayalReceipt({turn: g.turn, players: g.players}, frame.invoice, {recipient: settlement.recipient, holderFaction: use ? p.faction : null, holderUnallied: use ? !p.ally : null, nexusDiscardIndex: discardIndex, sourceReceipt})); g.nexusGuildBetrayalHistory!.push(receipt); g.guildBetrayalSourceReceipts!.push({event: frame.invoice.event, turn: g.turn, shipper: frame.invoice.shipper, sourceSignature: frame.invoice.sourceSignature, receipt: sourceReceipt}); g.guildBetrayal = {sequence: frame.invoice.sequence, event: frame.invoice.event, turn: g.turn, sourceSignature: frame.invoice.sourceSignature, status: 'completed'}; if (use) log(g, `${p.name} spent Guild Nexus Betrayal to receive the full ${frame.invoice.price}-spice shipment payment. The original funded shipment completed once.`, {faction: 'guild', name: 'Betrayal'}); if (use && settlement.recipient) { const legs = [{payer: frame.invoice.shipper, amount: frame.invoice.ownPayment}, ...(frame.invoice.donor ? [{payer: frame.invoice.donor, amount: frame.invoice.allyPayment}] : [])]; const source = stampBureaucratContributors(g, 'shipment', settlement.recipient, legs, frame.invoice.allyPayment > 0).bureaucratPayment; if (offerBureaucratPayment(g, source, {kind: 'guildBetrayal', event: frame.invoice.event, recipient: settlement.recipient, amount: frame.invoice.price})) return; } } /** Offline paired-faction Nexus setup; public expansion/module starts remain gated. */ export function initializePairedNexusGameForAudit(state: Game): Game { const expansion = state.expansions[0]; requireRule(state.expansions.length === 1 && ((expansion === 'choam' && state.players.some(player => player.faction === 'choam') && state.players.some(player => player.faction === 'richese')) || (expansion === 'ecaz' && state.players.some(player => player.faction === 'ecaz') && state.players.some(player => player.faction === 'moritani')) || (expansion === 'ix' && state.players.some(player => player.faction === 'ixians') && state.players.some(player => player.faction === 'tleilaxu'))), 'The paired Nexus sample requires both selected expansion factions and their deck.'); const pairedE3 = expansion === 'ecaz' && pairedE3NexusProfile(state); requireRule(expansion !== 'ecaz' || pairedE3, 'Paired E3 Nexus retains its original Advanced Harkonnen capture exclusion.'); const discovery = state.discoveryEnabled === true && (pairedDiscoveryNexusProfile(state) || pairedE3DiscoveryNexusProfile(state)); requireRule(!!state.nexusCards && state.nexusCards.cards === null && state.nexusCards.phase === null && (!state.homeworlds || expansion === 'ix' || expansion === 'choam' || pairedE3) && !state.leaderSkills && !state.ecazTreachery && (!state.discoveryEnabled || discovery) && ((!state.techTokens && !state.strongholdCards) || discovery || pairedNexusModulesProfile(state) || pairedE3), 'Enable Nexus Cards with supported original paired Homeworlds, Discovery, Tech or Advanced Stronghold components.'); if (state.discoveryEnabled) { requireFreshBaseRuntime(state); requireFreshFactionInventory(state); } const g = initializeSetupGameForAudit(state, !!state.homeworlds, true, false, discovery, false, false, true); if (pairedE3) g.ecazOccupyPreview = true; if (pairedE3 && g.advanced) initializeMoritaniAssassinateState(g); return g; } /** Genuine opt-in auction source interruption; ordinary/public starts stay gated. */ export function initializeRicheseBetrayalGameForAudit(state: Game): Game { requireRule(!state.richeseBetrayalPreview && !state.richeseBetrayal && !state.pendingRicheseBetrayal && richeseBetrayalModeSupported(state) && state.nexusCards?.cards === null && state.nexusCards.phase === null, 'Richese Betrayal needs a fresh CHOAM/Richese lobby, the CHOAM deck and only Nexus Cards.'); const g = initializeSetupGameForAudit(state, false, true, false, false, false, false, true); g.richeseBetrayalPreview = true; g.richeseBetrayal = {sequence:0,current:null,completed:[]}; g.pendingRicheseBetrayal = null; return g; } /** Offline-only all-expansion Nexus composition, optionally with Homeworlds; not a public start gate. */ export function initializeCombinedNexusGameForAudit(state: Game, homeworlds = false): Game { requireRule(state.expansions.length === 3 && ['ix', 'choam', 'ecaz'].every(expansion => state.expansions.includes(expansion)) && [5, 6].includes(state.players.length) && state.players.every(player => ['ixians', 'tleilaxu', 'choam', 'richese', 'ecaz', 'moritani'].includes(player.faction)) && ['ixians', 'tleilaxu', 'choam', 'ecaz', 'moritani'] .every(faction => state.players.some(player => player.faction === faction)), 'The combined Nexus sample requires five or six expansion factions and their three decks.'); requireRule(!!state.nexusCards && state.nexusCards.cards === null && state.nexusCards.phase === null && !!state.homeworlds === homeworlds && mixedE3NexusProfile(state), 'Enable original Nexus, optional Homeworlds/Discovery/Tech/Advanced Strongholds in a fresh combined expansion lobby.'); const g = initializeSetupGameForAudit(state, homeworlds, true, false, mixedE3DiscoveryNexusProfile(state), false, false, true); if (byFaction(g, 'ecaz')) g.ecazOccupyPreview = true; if (g.advanced && byFaction(g, 'moritani')) initializeMoritaniAssassinateState(g); return g; } /** Offline prototype entry through real Ix setup, including both expansion factions. * No player action or room API bypasses the normal release gates. */ export function initializeIxGameForAudit(state: Game): Game { requireRule(state.expansions.length === 1 && state.expansions[0] === 'ix', 'The Ix prototype requires exactly the Ixians & Tleilaxu expansion.'); return initializeSetupGameForAudit(state, false, false, true); } /** Genuine faction-only development setup. Optional variants and public starts remain gated. */ export function initializeFactionExpansionsGameForAudit(state: Game): Game { requireRule(typeof state.advanced === 'boolean', 'Choose Basic or Advanced rules for the faction prototype.'); requireRule(Array.isArray(state.expansions) && state.expansions.length > 0 && state.expansions.every(id => ['ix', 'choam', 'ecaz'].includes(id)) && new Set(state.expansions).size === state.expansions.length, 'The faction prototype needs a nonempty selection of distinct known expansions.'); requireRule(!state.homeworlds && !state.nexusCards && !state.leaderSkills && !state.discoveryEnabled && !state.discoveries && !state.discoveryStash && !state.greatMaker && (!state.techTokens || nativeFactionTechProfile(state)) && !state.strongholdCards, 'The faction prototype excludes other optional modules; Tech Tokens require a supported native three-through-six-seat lobby.'); requireFreshFactionInventory(state); const initialized = initializeSetupGameForAudit(state, false, false, false, false, false, false, true); if (initialized.advanced && nativeFactionTechProfile(initialized) && byFaction(initialized, 'moritani')) initializeMoritaniAssassinateState(initialized); return initialized; } function ecazOccupyCompositionSupported(g: Game): boolean { if (g.ecazOccupyPreview === true && (mixedE3NexusProfile(g) || standaloneE3NexusProfile(g) || pairedE3NexusProfile(g)) && g.players.some(player => player.faction === 'ecaz')) return true; if (g.leaderSkills && (mixedE3NexusLeaderSkillsProfile(g) || standaloneE3NexusLeaderSkillsProfile(g) || pairedE3NexusLeaderSkillsProfile(g)) && g.players.some(p => p.faction === 'ecaz')) return true; if (g.ecazOccupyPreview === true && g.discoveries && discoveryModeSupported(g) && g.players.some(p => p.faction === 'ecaz')) return true; if (g.discoveries && nativeDiscoveryLeaderSkillsProfile(g) && g.players.some(p => p.faction === 'ecaz')) return true; return typeof g.advanced === 'boolean' && Array.isArray(g.expansions) && g.expansions.includes('ecaz') && g.expansions.every(id => ['ecaz', 'ix', 'choam'].includes(id)) && new Set(g.expansions).size === g.expansions.length && (g.advanced || (g.expansions.length === 1 && g.expansions[0] === 'ecaz' && !g.ecazTreachery && g.players.every(p => p.faction === 'ecaz' || p.faction === 'moritani' || CLASSIC_FACTIONS[p.faction]))) && g.players.length >= 2 && g.players.length <= 6 && g.players.some(p => p.faction === 'ecaz') && g.players.every(p => faction(p.faction).expansion === 'base' || (['ecaz', 'moritani', 'ixians', 'tleilaxu', 'choam', 'richese'].includes(p.faction) && g.expansions.includes(faction(p.faction).expansion))) && !g.homeworlds && !g.nexusCards && !g.leaderSkills && (!g.strongholdCards || e3StrongholdFactionProfile(g)) && (!g.techTokens || nativeFactionTechProfile(g)) && !g.discoveryEnabled && !g.discoveries && !g.discoveryStash && !g.greatMaker && (g.ecazTreachery === undefined || g.ecazTreachery === true); } /** Fresh native Occupy profile; Basic odd counts use the main printed rule provisionally. */ export function initializeEcazOccupyGameForAudit(state: Game): Game { requireRule(ecazOccupyCompositionSupported(state), 'Occupy requires supported native factions and their distinct selected decks; Basic uses Ecaz/classic/optional Moritani without optional modules.'); requireFreshBaseRuntime(state); requireFreshFactionInventory(state); const g = initializeSetupGameForAudit(state, false, false, false, false, false, false, true, state.ecazTreachery === true); g.ecazOccupyPreview = true; return g; } /** Original E1/E2 Stronghold roster; shared by fresh setup and Semuta validation. */ function nativeStrongholdFactionRoster(g: Game): boolean { return g.advanced === true && g.players.length >= 2 && g.players.length <= 6 && Array.isArray(g.expansions) && g.expansions.length >= 1 && g.expansions.length <= 2 && g.expansions.every(id => id === 'ix' || id === 'choam') && (g.expansions.length < 2 || g.expansions[0] !== g.expansions[1]) && g.players.some(p => faction(p.faction).expansion !== 'base') && g.players.every(p => faction(p.faction).expansion === 'base' || g.expansions.includes(faction(p.faction).expansion)); } /** Fresh native Stronghold composition; other overlays and public starts stay gated. */ export function initializeStrongholdFactionsGameForAudit(state: Game): Game { requireRule(e3StrongholdFactionProfile(state) || nativeStrongholdFactionRoster(state), 'Stronghold factions require supported Advanced native factions, classic opponents and their selected distinct decks.'); requireRule(!state.homeworlds && !state.nexusCards && !state.leaderSkills && !state.discoveryEnabled && !state.discoveries && !state.discoveryStash && !state.greatMaker && (!state.techTokens || nativeFactionTechProfile(state)) && !state.ecazTreachery && (!state.strongholdCards || JSON.stringify(state.strongholdCards) === JSON.stringify(createStrongholdCards())), 'Stronghold factions admit unused Stronghold Cards and supported native Tech Tokens, without other optional modules.'); requireFreshFactionInventory(state); requireFreshBaseRuntime(state); const initialized = initializeSetupGameForAudit(state, false, false, false, false, false, false, true, false, true); if (e3StrongholdFactionProfile(initialized) && byFaction(initialized, 'moritani')) initializeMoritaniAssassinateState(initialized); return initialized; } function requireFreshFactionInventory(state: Game) { requireRule(state.richeseCache === undefined && state.richeseRemoved === undefined && state.ecazAmbassadors === undefined && state.moritaniTerror === undefined && state.dukeVidal === undefined && state.mobileStronghold === undefined && state.ixSetupCards === undefined && state.players.every(p => { const original = leaders(p.faction); return p.noField === undefined && p.noFieldEvent === undefined && p.faceDancers === undefined && p.revived === 0 && p.freeForcesRevived === 0 && !p.leaderRevived && p.revivalCycle === 0 && p.battleLosses === 0 && p.ally === null && p.bribes === 0 && p.leaders.length === original.length && original.every(expected => { const actual = p.leaders.find(l => l.id === expected.id); return actual && Object.keys(actual).length === Object.keys(expected).length && Object.entries(expected).every(([key, value]) => actual[key as keyof Leader] === value); }); }), 'The faction initializer cannot overwrite existing inventories, leader custody or revival history.'); } function initializeMoritaniAssassinateState(g: Game): void { g.moritaniAssassinatePreview = true; g.moritaniAssassinate = {version:1,owner:byFaction(g,'moritani')!.id,normalTraitorCall:false,opportunities:[]}; g.moritaniAssassinateCallEvents = []; } /** Genuine, explicitly opted-in preview; no player action enables this profile. */ export function initializeMoritaniAssassinateGameForAudit(state: Game): Game { requireRule(!state.moritaniAssassinatePreview && !state.moritaniAssassinate && !state.moritaniAssassinateResume && !state.moritaniAssassinateCallEvents, 'The assassination preview starts only from a fresh lobby.'); requireRule(moritaniAssassinateModeSupported(state), 'The assassination preview needs Advanced Moritani with ordinary base opponents except Harkonnen, the Ecaz expansion and no optional modules.'); const g = initializeFactionExpansionsGameForAudit(state); initializeMoritaniAssassinateState(g); return g; } /** Development-only Semuta with original native modules/skills, separate bounded * Homeworld occupation, or Basic Moritani/E3. Public starts stay gated. */ export function initializeSemutaGameForAudit(state: Game, skills = false): Game { const nativeTerror = !state.advanced && state.expansions.length === 2 && state.expansions.includes('choam') && state.expansions.includes('ecaz') && state.players.some(p => p.faction === 'moritani') && state.players.every(p => p.faction === 'richese' || p.faction === 'moritani' || faction(p.faction).expansion === 'base'); requireRule(!state.semutaPreview && state.expansions.includes('choam') && (state.expansions.every(id => id === 'choam' || id === 'ix') || nativeTerror) && state.players.some(p => p.faction === 'richese'), 'Semuta needs a fresh Richese faction game: CHOAM with optional Ix and supported original modules/skills; separate exact-CHOAM Homeworld occupation has no other modules, while Basic Moritani uses CHOAM/Ecaz and classics without modules. Other overlays remain excluded.'); if (state.nexusCards) requireRule(!nativeTerror && !state.homeworlds && !state.discoveryEnabled && !state.discoveries, 'Semuta Nexus retains original E1/E2 rosters without Homeworlds, Discovery or E3.'); if (state.homeworlds) requireRule(!skills && semutaHomeworldRoster(state), 'Semuta Homeworlds require original Richese/CHOAM and classic factions, exact CHOAM, and no other modules.'); const g = state.homeworlds ? initializeHomeworldOccupationGameForAudit(state) : skills ? initializeLeaderSkillsGameForAudit(state) : state.nexusCards ? state.expansions.length === 1 && !!byFaction(state, 'choam') ? initializePairedNexusGameForAudit(state) : initializeNexusGameForAudit(state) : state.strongholdCards ? initializeStrongholdFactionsGameForAudit(state) : initializeFactionExpansionsGameForAudit(state); g.semutaPreview = true; return g; } /** Opt-in printed Kull gameplay with the physical Ix deck, not a public release gate. */ export function initializeKullGameForAudit(state: Game): Game { requireRule(!state.kullPreview && !state.nexusKullPreview && !state.pendingKull && !state.kullRestrictions && state.expansions.length === 2 && state.expansions.includes('choam') && state.expansions.includes('ix') && state.players.some(p => p.faction === 'choam') && state.players.every(p => p.faction === 'choam' || faction(p.faction).expansion === 'base'), 'Kull preview needs a fresh CHOAM and classic-faction lobby with CHOAM and Ix decks.'); const g = initializeFactionExpansionsGameForAudit(state); g.kullPreview = true; return g; } /** Fresh native CHOAM Kull with the physical Ix deck and only Nexus Cards. */ export function initializeNexusKullGameForAudit(state: Game): Game { requireRule(!state.kullPreview && !state.nexusKullPreview && !state.pendingKull && state.kullRestrictions === undefined && state.kullSequence === undefined && !state.semutaPreview && !state.richeseBetrayalPreview && !state.richeseBetrayal && !state.pendingRicheseBetrayal && !state.homeworlds && !state.leaderSkills && !state.discoveryEnabled && !state.discoveries && !state.discoveryStash && !state.greatMaker && !state.techTokens && !state.strongholdCards && !state.ecazTreachery && state.nexusCards?.cards === null && state.nexusCards.phase === null && typeof state.advanced === 'boolean' && state.expansions.length === 2 && state.expansions.includes('choam') && state.expansions.includes('ix') && state.players.some(p => p.faction === 'choam') && state.players.every(p => p.faction === 'choam' || faction(p.faction).expansion === 'base'), 'Nexus Kull requires a fresh native CHOAM/classic lobby, CHOAM and Ix decks, and only Nexus Cards.'); requireFreshBaseRuntime(state); const g = initializeSetupGameForAudit(state, false, true, false, false, false, false, true); g.kullPreview = true; g.nexusKullPreview = true; // Ix supplies Treachery cards here, not the optional Sandtrout Spice card. g.spiceDeck = shuffle(spiceDeck()); return g; } /** Prototype-only Discovery setup. Public starts stay gated while remaining effects are connected. */ export function initializeDiscoveryGameForAudit(state: Game): Game { requireRule(discoveryModuleProfile(state), 'Discovery requires a fresh supported classic, E1/E2 or standalone E3 lobby without unrelated overlays.'); requireFreshBaseRuntime(state); requireFreshFactionInventory(state); const homeworlds = homeworldDiscoveryProfile(state); if (homeworlds) requireRule(state.homeworlds?.custody === null, 'Start Homeworld Discovery with original undealt native custody.'); const g = initializeSetupGameForAudit(state, homeworlds, false, false, true, false, false, state.expansions.length > 0); if (byFaction(g, 'ecaz')) g.ecazOccupyPreview = true; if (g.advanced && byFaction(g, 'moritani')) initializeMoritaniAssassinateState(g); return g; } /** Gated development setup; never dispatched by a player action or room route. */ export function initializeLeaderSkillsGameForAudit(state: Game): Game { requireRule(!state.leaderSkills, 'Leader Skills cannot redeal existing skill cards.'); const homeworlds = homeworldLeaderSkillsProfile(state); if (state.homeworlds) { requireRule(homeworlds && state.homeworlds.custody === null, 'Homeworld Leader Skills require a fresh supported classic or native E1/E2/standalone E3 lobby with original modules and undealt custody.'); requireFreshBaseRuntime(state); requireFreshFactionInventory(state); } if (state.discoveryEnabled) { requireRule(classicDiscoveryLeaderSkillsProfile(state) || nativeDiscoveryLeaderSkillsProfile(state) || classicNexusLeaderSkillsProfile(state), 'Discovery Leader Skills require a fresh supported classic or native lobby with optional original Nexus, Tech or Advanced Strongholds and no unrelated overlays.'); requireFreshBaseRuntime(state); requireFreshFactionInventory(state); } const g = structuredClone(state); g.leaderSkills = createLeaderSkills(random); const classicNexus = classicNexusLeaderSkillsProfile(g); const pairedE3 = pairedE3NexusLeaderSkillsProfile(g); const mixedE3 = mixedE3NexusLeaderSkillsProfile(g); const initialized = initializeSetupGameForAudit(g, homeworlds, classicNexus || pairedNexusLeaderSkillsProfile(g) || singleE1E2NexusLeaderSkillsProfile(g) || mixedE1E2NexusLeaderSkillsProfile(g) || mixedE3 || standaloneE3NexusLeaderSkillsProfile(g) || pairedE3 || guildBetrayalNexusLeaderSkillsProfile(g), false, classicDiscoveryLeaderSkillsProfile(g) || nativeDiscoveryLeaderSkillsProfile(g) || (g.discoveryEnabled === true && classicNexus), true, g.expansions.length === 1 && g.expansions[0] === 'choam', nativeExpansionLeaderSkillsProfile(g)); if (advancedMoritaniLeaderSkillsProfile(initialized) || ((mixedE3 || pairedE3) && initialized.advanced && byFaction(initialized, 'moritani'))) initializeMoritaniAssassinateState(initialized); if ((mixedE3 || homeworlds || nativeDiscoveryLeaderSkillsProfile(initialized) || pairedE3) && byFaction(initialized, 'ecaz')) initialized.ecazOccupyPreview = true; return initialized; } /** Fresh all-fourteen-card classic or supported native skills; no public income toggle. */ export function initializeSpiceBankerIncomeGameForAudit(state: Game): Game { requireRule(bankerIncomeProfile(state) && !state.leaderSkills && !state.spiceBankerIncomePreview && state.spiceBankerIncome === undefined, 'Spice Banker income requires a fresh classic or supported native skill lobby without unsupported optional modules.'); requireFreshSetup(state, state.expansions.length > 0); if (state.homeworlds) { const initialized = initializeHomeworldOccupationGameForAudit(state, true); initialized.spiceBankerIncomePreview = true; initialized.spiceBankerIncome = bankerIncomeRule(() => createBankerIncomeState(bankerIncomeContext(initialized))); return initialized; } const g = structuredClone(state); g.leaderSkills = createLeaderSkills(random); g.spiceBankerIncomePreview = true; g.spiceBankerIncome = bankerIncomeRule(() => createBankerIncomeState(bankerIncomeContext(g))); const initialized = initializeSetupGameForAudit(g, false, classicNexusLeaderSkillsProfile(g), false, false, true, g.expansions.length === 1 && g.expansions[0] === 'choam', g.expansions.length > 0); if (advancedMoritaniLeaderSkillsProfile(initialized)) initializeMoritaniAssassinateState(initialized); return initialized; } /** Gated development setup for the independent three-card Ecaz variant. */ export function initializeEcazTreacheryGameForAudit(state: Game): Game { requireRule(!state.ecazTreachery, 'Ecaz Treachery Cards cannot redeal an existing inventory.'); const g = structuredClone(state); g.ecazTreachery = true; return initializeSetupGameForAudit(g, false, false, false, false, false, false, true, true); } function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = false, ix = false, discovery = false, leaderSkills = false, choam = false, factions = false, ecazTreachery = false, strongholdFactions = false): Game { nexusCardsIntegrity(state); homeworldRule(() => homeworldGameIntegrity(state)); homeworldBattleLossIntegrity(state); homeworldSubstitutionIntegrity(state); homeworldDefenseIntegrity(state); homeworldShipmentIntegrity(state); const g = structuredClone(state); requireFreshSetup(g, homeworlds || nexus || ix || factions); const homeworldSkills = leaderSkills && homeworldLeaderSkillsProfile(g); const skillStronghold = leaderSkills && strongholdLeaderSkillsProfile(g); const skillTech = leaderSkills && (classicTechLeaderSkillsProfile(g) || nativeTechLeaderSkillsProfile(g) || skillStronghold || homeworldSkills || classicDiscoveryLeaderSkillsProfile(g)); const nativeTech = factions && !leaderSkills && nativeFactionTechProfile(g); const discoveryComposition = discovery && (discoveryModuleProfile(g) || classicDiscoveryNexusProfile(g) || pairedDiscoveryNexusProfile(g) || mixedE3DiscoveryNexusProfile(g) || mixedE1E2DiscoveryNexusProfile(g) || pairedE3DiscoveryNexusProfile(g) || (leaderSkills && (classicDiscoveryLeaderSkillsProfile(g) || nativeDiscoveryLeaderSkillsProfile(g) || classicNexusLeaderSkillsProfile(g)))); const nexusModules = nexus && (classicNexusModulesProfile(g) || pairedNexusModulesProfile(g) || mixedE3NexusProfile(g) || singleE1E2NexusProfile(g) || mixedE1E2NexusProfile(g) || pairedE3NexusProfile(g) || standaloneE3NexusProfile(g) || (leaderSkills && (classicNexusLeaderSkillsProfile(g) || pairedNexusLeaderSkillsProfile(g) || mixedE3NexusLeaderSkillsProfile(g) || singleE1E2NexusLeaderSkillsProfile(g) || mixedE1E2NexusLeaderSkillsProfile(g) || pairedE3NexusLeaderSkillsProfile(g) || standaloneE3NexusLeaderSkillsProfile(g)))); requireRule(!g.techTokens || ((skillTech || nativeTech || nexusModules || discoveryComposition) && JSON.stringify(g.techTokens) === JSON.stringify(createTechTokens())), 'Tech Tokens require a fresh supported three-through-six-seat lobby with unused tokens.'); requireRule(!g.strongholdCards || strongholdFactions || ((skillStronghold || homeworldSkills || nexusModules || discoveryComposition) && JSON.stringify(g.strongholdCards) === JSON.stringify(createStrongholdCards())), 'Stronghold Cards require a fresh supported Advanced lobby with unused cards.'); requireRule( (homeworlds || nexus || ix || choam || factions || g.expansions.length === 0) && (choam || factions || !g.expansions.includes('choam')) && (factions || !g.expansions.includes('ecaz')) && (leaderSkills || !g.leaderSkills) && (discovery || !g.discoveryEnabled) && (nexus || !g.nexusCards) && (ecazTreachery || !g.ecazTreachery) && (!g.techTokens || skillTech || nativeTech || nexusModules || discoveryComposition) && (strongholdFactions || skillStronghold || homeworldSkills || nexusModules || discoveryComposition || !g.strongholdCards) && (homeworlds || !g.homeworlds) && g.players.every((p) => FACTIONS.some( (f) => f.id === p.faction && (homeworlds || nexus || f.expansion === 'base' || (ix && f.expansion === 'ix') || (choam && f.expansion === 'choam') || (factions && g.expansions.includes(f.expansion))), ), ), strongholdFactions ? 'The Stronghold faction audit needs a fresh supported Advanced native lobby without other optional modules.' : factions && nexus ? 'The native expansion Nexus audit needs a fresh supported original roster and module envelope.' : factions ? 'The faction prototype supports base factions and the selected expansion factions without optional modules.' : choam ? 'The Leader Skills prototype supports base, CHOAM and Richese factions without other expansions or optional modules.' : ix ? 'The Ix prototype supports base, Ixian and Tleilaxu factions without optional modules.' : homeworlds ? 'The Homeworld setup audit requires a fresh supported roster with original selected optional modules.' : discoveryComposition ? 'Discovery supports fresh classic, selected E1/E2 or standalone E3 factions with optional original Tech or Advanced Strongholds and no other overlays.' : 'The audit initializer supports base factions without expansions or optional modules.', ); initializeSetup(g, strongholdFactions || !!g.strongholdCards); if (nativeTech && byFaction(g, 'ecaz')) g.ecazOccupyPreview = true; return normalizeAutomaticGame(g); } function ecazStartingForcesComplete(p: Player): boolean { if (p.reserves !== 20 - ECAZ_START_FORCES) return false; try { quoteEcazStartingForces(p.forces); return true; } catch { return false; } } 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 !== 'ecaz' || ecazStartingForcesComplete(p)) && (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 extortionEvent(state: ExtortionState): string { return JSON.stringify(['moritaniExtortion', state.turn, state.token, state.cursor]); } function extortionContext(g: Game) { return { turn: g.turn, phase: g.phase, players: g.players.map(player => ({ id: player.id, spice: player.spice })) }; } function extortionRule(apply: () => T): T { try { return apply(); } catch (error) { if (error instanceof Error) throw new RuleError(error.message); throw error; } } function moritaniExtortionIntegrity(g: Game) { const pending = g.moritaniExtortion; const revealed = g.moritaniTerror?.tokens.filter(token => token.status === 'extortion') ?? []; requireRule(revealed.length === (pending ? 1 : 0) && (!pending || (revealed[0].id === pending.token && revealed[0].kind === 'extortion' && byFaction(g, 'moritani')?.id === pending.owner && g.status === 'playing')), 'Extortion has lost its revealed token or Moritani owner.'); if (!pending) return; extortionRule(() => validateExtortionState(pending, extortionContext(g))); const stormPayers = pending.stage === 'payment' ? g.order.filter(id => id !== pending.owner) : null; const savedDecisions = pending.stage === 'payment' ? homeworldSavedDecisions(g).filter(decision => decision.kind === 'moritaniExtortion') : []; requireRule(pending.stage === 'reserved' ? g.phase <= 8 : pending.stage === 'payment' && g.phase === 8 && g.moritaniTerror?.placementTurn === g.turn && savedDecisions.length === 1 && savedDecisions[0].player === pending.queue[pending.cursor] && savedDecisions[0].event === extortionEvent(pending) && pending.queue.length === stormPayers?.length && pending.queue.every((seat, index) => seat === stormPayers?.[index]), 'Extortion payment must resume in the original Mentat storm order.'); } function settleMoritaniExtortion(g: Game, state: ExtortionState, recover: boolean) { requireRule(g.moritaniTerror && state.stage === 'settled', 'Extortion must settle with its revealed Terror token.'); g.moritaniTerror = recover ? returnTerror(g.moritaniTerror, state.token, random) : retireExtortion(g.moritaniTerror, state.token); delete g.moritaniExtortion; log(g, recover ? 'No one paid Extortion. Its token returned to the hidden Terror supply.' : 'Extortion was paid. Its revealed Terror token was removed from the game.'); finishMoritaniPlacement(g); } function finishMoritaniPlacement(g: Game) { const obligation = g.moritaniExtortion; if (obligation?.stage === 'reserved') { requireRule(g.phase === 8 && g.moritaniTerror?.placementTurn === g.turn, 'Finish the Terror placement before collecting Extortion.'); const result = extortionRule(() => collectExtortion(obligation, { ...extortionContext(g), stormOrder: g.order, })); getPlayer(g, obligation.owner).spice += 5; g.moritaniExtortion = result.state; log(g, 'Moritani collected five reserved spice from the bank for Extortion.'); if (result.state.stage === 'payment') { const payer = result.state.queue[result.state.cursor]; g.decision = { kind: 'moritaniExtortion', player: payer, event: extortionEvent(result.state) }; } else settleMoritaniExtortion(g, result.state, true); return; } requireRule(!obligation, 'Finish the pending Extortion payments first.'); 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( withoutEcazLoyalty(traitorDeck(g.players, g.expansions.includes('ix')), g.ecazLoyalty).filter( (card) => !held.has(card), ), ); tleilaxu.faceDancers = g.traitorReserve .splice(0, 3) .map((leader) => ({ leader, revealed: false })); } g.status = 'playing'; if (g.homeworlds?.custody) { g.homeworldOccupationHistory = homeworldRule(() => createHomeworldOccupationHistory(homeworldContext(g), g.homeworlds!.custody!, g.turn, crypto.randomUUID())); g.homeworlds.historyVersion = 1; const choam = byFaction(g, 'choam'); if (choam) g.tupileIntelligence = createTupileIntelligenceState(choam.id); } 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) { return stormExposesTerritory(splitLocation(key).territory, g.shieldWallDestroyed); } function moveStorm(g: Game, n: number) { g.stormResolution = { from: g.storm, distance: n, traversed: 0, pending: [], ...(g.ecologicalStorm?.stage === 'traversal' ? {discoveryStorm:g.ecologicalStorm.signature} : {}) }; offerChoamStorm(g); } function startStormMovement(g: Game, n: number) { const offer = nexusRule(() => discoveryStormOffer(g)); if (offer && !offer.blocked) { g.ecologicalStorm = nexusRule(() => createDiscoveryStorm(g,crypto.randomUUID())); g.decision = {kind:'ecologicalStorm',player:g.ecologicalStorm.owner,event:g.ecologicalStorm.event}; } else moveStorm(g,n); } 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 = stormSectorAfter(resolution.from, resolution.traversed); 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 ( stormExposesTerritory(t.id, g.shieldWallDestroyed) ) revealPlayerNoField(g, p, 'storm'); } for (const key of Object.keys(p.forces)) { const loc = splitLocation(key), t = territory(loc.territory); const exposed = stormExposesTerritory(t.id, g.shieldWallDestroyed); 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]; } if (g.ecologicalStorm?.stage === 'traversal') g.ecologicalStorm = nexusRule(() => finishDiscoveryStorm(g.ecologicalStorm!)); delete g.stormMovementSource; g.storm = stormSectorAfter(resolution.from, resolution.distance); 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.greatMaker ? {greatMaker: 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 (wormConsumesForces(p, protectedAlly, protectFremen)) 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 && fremen.fremenNexusWormBlockedTurn !== g.turn && at(fremen, t)) g.wormRides.push(t); log(g, `Shai-Hulud appeared in ${territory(t).name}.`); } function beginWorm(g: Game, t: string, origin: 'natural' | 'additional' | 'summoned' = 'natural') { const fremen = byFaction(g, 'fremen'); if (!fremen && g.nexusCards?.cards && g.players.some(p => !p.ally && g.nexusCards!.cards!.hands[p.id] && at(p, t) > 0)) { const frame = nexusRule(() => createNexusFremenProtection({ event: crypto.randomUUID(), turn: g.turn, territory: t, origin, ...(origin === 'summoned' ? { parent: g.summonedWorm?.event } : { pile: g.spiceSequence?.pile, index: g.spiceSequence ? g.spiceDiscard[g.spiceSequence.pile].length - 1 : undefined }), order: g.order, players: g.players.map(p => ({ id: p.id, faction: p.faction, ally: p.ally, forces: p.forces, noField: p.noField, hasNexus: !!g.nexusCards!.cards!.hands[p.id] })), })); if (frame) { requireRule(g.nexusFremenProtection?.stage !== 'choose', 'Finish the original worm protection before another appearance.'); g.nexusFremenProtection = frame; g.decision = { kind: 'nexusFremenProtection', player: currentNexusFremenProtectionOwner(frame)!, event: frame.event }; return; } } // An empty appearance offers the same decision with or without the private // Fremen card; the offer itself must not disclose its custody. if ((origin !== 'summoned' || (g.summonedWorm?.event && !g.nexusFremenCunningOffer && !g.nexusFremenCunningRides?.some(row => row.stage === 'pending' || row.stage === 'select'))) && !g.greatMaker && g.nexusCards?.cards && !g.expansions.length && !g.homeworlds && !g.leaderSkills && !g.discoveryEnabled && !g.discoveries && !g.discoveryStash && g.players.every(seat => ['atreides', 'harkonnen', 'emperor', 'guild', 'beneGesserit', 'fremen'].includes(seat.faction)) && fremen && !fremen.ally && g.phase === 1 && fremen.fremenNexusWormBlockedTurn !== g.turn && Object.entries(fremen.forces).some(([key, amount]) => { if (amount <= 0) return false; const source = splitLocation(key); return source.sector !== g.storm && TERRITORIES.some(row => row.id === source.territory && row.type === 'sand'); })) { const occurrence = captureFremenCunningOccurrence(g, { event: crypto.randomUUID(), territory: t, origin, ...(origin === 'summoned' ? { parent: g.summonedWorm!.event } : { pile: g.spiceSequence?.pile }), }); if (occurrence.initiallyEmpty) { requireRule(!g.nexusFremenCunningOffer, 'Finish the previous worm appearance before another Cunning offer.'); g.nexusFremenCunningOffer = { occurrence, owner: fremen.id }; g.decision = { kind: 'nexusFremenCunningOffer', player: fremen.id, event: occurrence.event }; return; } } 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 nexusFremenProtectionInput(g: Game, frame: NexusFremenProtectionFrame): NexusFremenProtectionInput { return { event: frame.event, turn: g.turn, territory: frame.territory, origin: frame.origin, ...(frame.origin === 'summoned' ? { parent: g.summonedWorm?.event } : { pile: g.spiceSequence?.pile, index: g.spiceSequence ? g.spiceDiscard[g.spiceSequence.pile].length - 1 : undefined }), order: g.order, players: g.players.map(p => ({ id: p.id, faction: p.faction, ally: p.ally, forces: p.forces, noField: p.noField, // An anytime Harkonnen exchange may spend an irrelevant card after this // public membership was captured; it cannot erase the original answer. hasNexus: frame.order.includes(p.id) })), }; } function nexusFremenProtectionIntegrity(g: Game) { const frame = g.nexusFremenProtection; const controls = [g, g.pendingExchange, g.pendingNullentropy?.resume, g.pendingRicheseGift?.resume, g.pendingRichesePurchaseIncome?.resume, g.summonedWorm?.resume]; const decisions = controls.flatMap(control => control?.decision?.kind === 'nexusFremenProtection' ? [control.decision] : []); if (!frame || frame.stage === 'complete') { requireRule(!decisions.length, 'Worm protection has lost its original active occurrence.'); return; } requireRule(g.status === 'playing' && g.phase === 1 && g.nexusCards?.cards && !byFaction(g, 'fremen'), 'Worm protection requires its original absent-Fremen Nexus module.'); nexusRule(() => validateNexusFremenProtection(nexusFremenProtectionInput(g, frame), frame)); requireRule(decisions.length === 1 && decisions[0].event === frame.event && decisions[0].player === currentNexusFremenProtectionOwner(frame), 'The original worm protection has lost its current threatened seat.'); if (frame.origin === 'summoned') requireRule(g.summonedWorm?.territory === frame.territory, 'Worm protection has lost its summoned territory.'); else { const card = frame.pile === undefined || frame.index === undefined ? undefined : g.spiceDiscard[frame.pile][frame.index]; requireRule(card && 'worm' in card && !card.suppressed, 'Worm protection has lost its actual unconsumed appearance.'); } if (frame.protected) requireRule(g.nexusCards.cards.discard.includes('fremen') && !getPlayer(g, frame.protected).ally, 'The pending worm protection has lost its spent card or unallied owner.'); } function nexusFremenProtectionOffer(g: Game, id: string) { const frame = g.nexusFremenProtection; if (!frame || currentNexusFremenProtectionOwner(frame) !== id || g.decision?.kind !== 'nexusFremenProtection' || g.decision.player !== id || g.decision.event !== frame.event) return null; const p = getPlayer(g, id); const blocked = g.truthtrance || pendingNexusTraitors(g) || g.response || g.pendingKarama || g.pendingTreacheryDiscard || g.pendingNullentropy ? 'Finish the current private response before worm protection.' : null; return { event: frame.event, territory: frame.territory, owner: id, canUse: !frame.protected && !p.ally && g.nexusCards?.cards?.hands[id] === 'fremen' && at(p, frame.territory) > 0, blocked }; } function playNexusFremenProtection(g: Game, p: Player, action: Action) { const frame = g.nexusFremenProtection, offer = nexusFremenProtectionOffer(g, p.id); requireRule(frame && offer && !offer.blocked && action.event === offer.event && typeof action.use === 'boolean' && Object.keys(action).every(key => ['type', 'event', 'use'].includes(key)), 'Choose only your current worm protection event with Use or Pass.'); requireRule(!action.use || offer.canUse, 'Only the actual unallied Fremen Nexus holder may protect its threatened forces.'); const next = nexusRule(() => answerNexusFremenProtection(frame, p.id, action.use as boolean)); if (action.use) { g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!, p.id, g.players)); log(g, `${p.name} spent Fremen Nexus Secret Ally to protect only their forces from this Shai-Hulud. Provisional policy: this independent protection has no additional Karama response.`, { faction: p.faction, name: 'Fremen Nexus protection' }); } g.nexusFremenProtection = next; const owner = currentNexusFremenProtectionOwner(next); if (owner) g.decision = { kind: 'nexusFremenProtection', player: owner, event: next.event }; else { g.decision = null; devour(g, next.territory, next.protected ?? undefined); afterWorm(g); } } function currentFremenCunningResponse(g: Game, response: ResponseWindow) { const ride = g.nexusFremenCunningRides?.find(row => row.stage === 'pending' && row.occurrence.event === response.intent); requireRule(response.kind === 'nexusFremenCunning' && ride && response.owner === ride.authorization.owner && response.location === ride.occurrence.territory && ride.occurrence.turn === g.turn && g.phase === 1 && g.nexusCards?.cards?.discard.includes('fremen'), 'The Fremen Nexus interruption has lost its spent card or worm appearance.'); return ride; } function finishFremenCunningResponse(g: Game, response: ResponseWindow, canceled: boolean) { const ride = currentFremenCunningResponse(g, response); ride.stage = canceled ? 'complete' : 'queued'; log(g, canceled ? 'Karama prevented the Fremen Nexus remote ride; the original worm still resolves.' : 'Fremen Nexus Cunning will ride from one desert territory after this Nexus.', { faction: 'fremen', name: 'Fremen Nexus Cunning' }); wormSurvival(g, ride.occurrence.territory); } 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) { if (g.greatMaker?.stage === 'worm') { g.wormRides = [...g.greatMaker.ridesBefore]; openGreatMakerVote(g); return; } 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]; markNexusOccurred(g); 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.decision?.kind === 'nexusFremenCunningRide') { const event = g.decision.event; const ride = g.nexusFremenCunningRides?.find(row => row.stage === 'select' && row.occurrence.event === event); requireRule(ride, 'The summoned worm has lost its interrupted Fremen Cunning ride.'); ride.stage = 'queued'; g.decision = null; } else if ( g.pendingAmbassador?.resume === 'wormRide' || g.pendingTerrorEntry?.resume === 'wormRide' || g.pendingArrivalOverlap?.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 discoveryIntegrity(g: Game) { ecologicalStormIntegrity(g); jacurutuDialIntegrity(g); discoveryEntryIntegrity(g); if (!g.discoveryEnabled) { requireRule(!g.discoveries && !g.discoveryStash, 'Discovery custody requires its enabled module.'); return; } if (g.status === 'lobby') { requireRule(!g.discoveries && !g.discoveryStash, 'Discovery components are dealt during setup.'); return; } requireRule(g.discoveries, 'The Discovery module has lost its physical tokens.'); nexusRule(() => validateDiscoveryState(g.discoveries!)); for (const token of g.discoveries.tokens) requireRule( (token.revealedTurn === null || token.revealedTurn <= g.turn) && (token.acquiredTurn === null || token.acquiredTurn <= g.turn) && (token.owner === null || g.players.some(player => player.id === token.owner)), 'A Discovery token has lost its receiving player or original turn.'); const record = g.discoveryStash, continuation = g.pendingTreacheryDiscard?.continuation; const pending = continuation?.kind === 'discoveryStash' ? continuation : null; const controls = [g, g.pendingExchange, g.pendingNullentropy?.resume, g.pendingRicheseGift?.resume, g.pendingRichesePurchaseIncome?.resume, continuation && 'resume' in continuation ? continuation.resume : null]; if (!record) { requireRule(!pending && !controls.some(control => control?.decision?.kind === 'discoveryDiscard'), 'The Discovery discard has lost its original draw.'); return; } const p = g.players.find(player => player.id === record.owner); requireRule(p && Object.keys(record).sort().join(',') === 'card,discarded,event,hand,owner,signature,stage,token,turn' && typeof record.event === 'string' && record.event && Number.isSafeInteger(record.turn) && record.turn >= 1 && record.turn <= g.turn && ['choose','discard','complete'].includes(record.stage) && Array.isArray(record.hand) && record.hand.every(id => typeof id === 'string') && new Set(record.hand).size === record.hand.length && record.hand.includes(record.card) && g.discoveries.tokens.some(token => token.id === record.token && token.face === 'treachery-card-stash' && token.status === 'removed' && token.revealedTurn === record.turn) && record.signature === discoveryStashSignature(record) && (record.stage === 'choose' ? record.discarded === null : record.discarded === null || record.hand.includes(record.discarded)) && (record.stage === 'discard') === !!pending, 'The Treachery Card Stash has lost its original draw or selected discard.'); const overflow = record.hand.length === handLimit(p) + 1; requireRule(record.hand.length >= 1 && record.hand.length <= handLimit(p) + 1 && (record.stage !== 'complete' ? overflow : overflow === (record.discarded !== null)), 'The Treachery Card Stash must complete its original overflow discard.'); if (record.stage === 'complete') { requireRule(!controls.some(control => control?.decision?.kind === 'discoveryDiscard'), 'The completed stash cannot retain a discard choice.'); return; } requireRule(g.status === 'playing' && g.turn === record.turn && g.phase === 7 && JSON.stringify(p.hand.map(card => card.id).sort()) === JSON.stringify(record.hand.filter(id => id !== record.discarded).sort()), 'Finish the original Treachery Card Stash hand before another card change.'); if (record.stage === 'choose') requireRule(controls.some(control => control?.decision?.kind === 'discoveryDiscard' && control.decision.player === p.id && control.decision.event === record.event), 'The stash has lost its owned discard choice.'); if (pending) requireRule(pending.event === record.event && pending.owner === p.id && pending.card === record.discarded, 'The stash has changed its committed discard.'); } function ecologicalStormIntegrity(g: Game) { const frame = g.ecologicalStorm, continuation = g.pendingTreacheryDiscard?.continuation; requireRule(!(g.status === 'playing' && g.phase === 0 && g.advanced && g.turn > 1 && g.stormDialers.length === 0 && g.stormPending !== null) || !!g.stormMovementSource, 'The revealed Advanced storm has lost its original movement source.'); if (g.stormMovementSource) { nexusRule(() => validateStormSource(g.stormMovementSource!)); const source = g.stormMovementSource; requireRule(source.kind !== 'card' || isStormCardDistance(source.distance), 'The revealed Storm Card must retain a canonical distance.'); requireRule(g.status === 'playing' && g.phase === 0 && source.turn === g.turn && g.stormPending === (frame && frame.stage !== 'complete' ? discoveryStormDistance(frame) : source.distance), 'The storm must retain its current turn and original movement source.'); } const controls = [g,g.pendingExchange,g.pendingNullentropy?.resume,g.pendingRicheseGift?.resume, g.pendingRichesePurchaseIncome?.resume,continuation && 'resume' in continuation ? continuation.resume : null]; const choices = controls.flatMap(c => c?.decision?.kind === 'ecologicalStorm' ? [c.decision] : []); if (!frame) { requireRule(!choices.length && !g.stormResolution?.discoveryStorm,'The station storm has lost its original choice.'); return; } nexusRule(() => validateDiscoveryStorm(g,frame)); if (frame.stage === 'choose') requireRule(choices.length === 1 && choices[0].player === frame.owner && choices[0].event === frame.event && !g.stormResolution && g.ready.length === g.players.length, 'The station has lost its owned choice before storm movement.'); else { requireRule(!choices.length,'A completed station choice cannot remain open.'); if (frame.stage === 'traversal') requireRule(g.stormResolution && g.stormResolution.discoveryStorm === frame.signature && g.stormResolution.from === frame.from && g.stormResolution.distance === discoveryStormDistance(frame), 'The storm traversal has lost its exact station adjustment.'); else requireRule(!g.stormResolution?.discoveryStorm,'The completed station choice cannot retain a traversal.'); } } function discoveryEntryIntegrity(g: Game) { const frame = g.discoveryEntry; const continuation = g.pendingTreacheryDiscard?.continuation; const controls = [g, g.pendingExchange, g.pendingNullentropy?.resume, g.pendingRicheseGift?.resume, g.pendingRichesePurchaseIncome?.resume, continuation && 'resume' in continuation ? continuation.resume : null]; const choices = controls.flatMap(control => control?.decision?.kind === 'discoveryEntry' ? [control.decision] : []); const children = controls.flatMap(control => { const pending = control && 'pendingKarama' in control ? control.pendingKarama as Game['pendingKarama'] : null; return [control?.response, pending?.use.kind === 'cancel' ? pending.use.response : null] .filter(response => response?.advisorResume === 'discoveryEntry' || response?.discoveryEntry) .map(response => ({receipt:response!.discoveryEntry, actor:response!.owner, territory:response!.location, valid:response!.kind === 'advisorFlip' && response!.advisorResume === 'discoveryEntry' && response!.advisors === true && response!.advisorAmbassadorEvent === undefined && response!.advisorFollowup === undefined && response!.advisorRemaining === undefined})); }); for (const control of controls) if (control?.decision?.kind === 'intrusion' && control.decision.discoveryEntry) children.push({receipt:control.decision.discoveryEntry, actor:control.decision.player, territory:control.decision.territory, valid:control.decision.wormRide === undefined && control.decision.ambassadorEvent === undefined && control.decision.followup === undefined}); if (!frame) { requireRule(!choices.length && !children.length, 'The Discovery entry has lost its original turn-opening round.'); return; } requireRule(g.discoveryEnabled && g.discoveries && g.status === 'playing' && g.phase === 0 && frame.stage !== 'complete', 'Discovery entry must finish before the Storm Phase begins.'); nexusRule(() => validateDiscoveryEntryRound({...g, discoveries:g.discoveries!},frame)); if (frame.stage === 'choose') { const owner = frame.order[frame.cursor % frame.order.length]; requireRule(!children.length && choices.length === 1 && choices[0].player === owner && choices[0].event === `${frame.event}:${frame.cursor}`, 'The Discovery entry has lost its owned choice.'); } else { requireRule(!choices.length && children.length === 1, 'The Discovery entry has lost its pending arrival interaction.'); const arrival = frame.arrival!, owner = getPlayer(g, arrival.owner), key = `${arrival.destination}:0`; requireRule(owner.forces[key] === arrival.destinationBefore + arrival.amount && (owner.elites?.forces[key] ?? 0) === arrival.destinationEliteBefore + arrival.elite, 'The Discovery arrival has lost its transferred ordinary or elite force custody.'); for (const child of children) { requireRule(child.valid && child.actor === byFaction(g,'beneGesserit')?.id && child.territory === frame.arrival!.destination && child.receipt, 'The Discovery arrival must retain its original Bene Gesserit interaction.'); nexusRule(() => validateDiscoveryEntryArrivalChild(frame, child.receipt!)); } } } function continueDiscoveryEntry(g: Game) { requireRule(g.discoveryEntry && g.discoveries, 'No Discovery entry round is pending.'); while (g.discoveryEntry!.stage === 'choose') { const frame: DiscoveryEntryRound = g.discoveryEntry!, owner = frame.order[frame.cursor % frame.order.length]; const offer = nexusRule(() => quoteDiscoveryEntry({...g,discoveries:g.discoveries!},frame,owner)); if (!offer.blocked) { g.decision = {kind:'discoveryEntry',player:owner,event:`${frame.event}:${frame.cursor}`}; return; } g.discoveryEntry = nexusRule(() => advanceDiscoveryEntry({...g,discoveries:g.discoveries!},frame,owner)); } requireRule(g.discoveryEntry!.stage === 'complete', 'Finish the Discovery arrival before the next entry.'); g.discoveries = {...g.discoveries,newlyRevealed:[]}; delete g.discoveryEntry; beginTurnBoardMovement(g); } function completeDiscoveryEntryArrival(g: Game, child: DiscoveryEntryArrivalChild) { requireRule(g.discoveryEntry && g.discoveries, 'The Discovery arrival has lost its round.'); nexusRule(() => validateDiscoveryEntryArrivalChild(g.discoveryEntry!,child)); g.discoveryEntry = nexusRule(() => finishDiscoveryEntryArrival({...g,discoveries:g.discoveries!},g.discoveryEntry!)); continueDiscoveryEntry(g); } function decideDiscoveryEntry(g: Game, p: Player, action: Action) { const frame = g.discoveryEntry; requireRule(frame && g.discoveries && typeof action.accept === 'boolean' && action.event === `${frame.event}:${frame.cursor}` && Object.keys(action).every(key => ['type','event','accept','groups'].includes(key)), 'Choose the current Discovery entry and explicit physical force groups.'); if (!action.accept) { requireRule(action.groups === undefined, 'Declining Discovery entry must not select forces.'); g.discoveryEntry = nexusRule(() => advanceDiscoveryEntry({...g,discoveries:g.discoveries!},frame,p.id)); log(g, `${p.name} declined this optional free Discovery entry.`); continueDiscoveryEntry(g); return; } g.discoveryEntry = nexusRule(() => beginDiscoveryEntryArrival({...g,discoveries:g.discoveries!},frame,p.id, {groups:action.groups as DiscoveryEntrySelection['groups']})); const arrival = g.discoveryEntry.arrival!; const advisors = arrivalAsAdvisor(g,p,arrival.destination,arrival.parent); removeGroup(p,arrival.groups.map(group => [group.source,group.normal+group.elite]), Object.fromEntries(arrival.groups.map(group => [group.source,group.elite]))); place(p,arrival.destination,0,arrival.amount,arrival.elite); if (advisors) (p.advisors ??= {})[arrival.destination] = {}; else if (p.advisors) delete p.advisors[arrival.destination]; log(g, `${p.name} moved ${arrival.amount} ${arrival.amount === 1 ? 'force' : 'forces'}${arrival.elite ? `, including ${arrival.elite} elite ${arrival.elite === 1 ? 'force' : 'forces'},` : ''} from ${territory(arrival.parent).name} into ${territory(arrival.destination).name} before the storm. This one-time Discovery entry spends no spice or normal movement.`); const child: DiscoveryEntryArrivalChild = {owner:p.id,territory:arrival.destination,sector:0, amount:arrival.amount,elite:arrival.elite,discoveryEntry:arrival.signature}; if (!intrusion(g,p,arrival.destination,{discoveryEntry:child})) completeDiscoveryEntryArrival(g,child); } function resolveDiscoveryBlow(g: Game, card: Extract) { requireRule(g.discoveryEnabled && g.discoveries && isDiscoverySpiceCardId(card.discovery), 'This Discovery card requires its physical module.'); const printed = DISCOVERY_SPICE_CARDS.find(value => value.discovery === card.discovery)!; requireRule(card.territory === printed.territory && card.sector === printed.sector && card.amount === printed.amount, 'The Discovery spice blow has changed its printed territory, sector or amount.'); disasterPreflight(() => validateWormDevouring(g,card.territory,undefined,false)); for (const p of g.players) killTerritory(g,p,card.territory); for (const key of Object.keys(g.spice)) if (splitLocation(key).territory === card.territory) delete g.spice[key]; log(g, `A Discovery spice blow destroyed all previous spice and forces in ${territory(card.territory).name}, including Fremen forces. The new spice is placed afterward.`); const placement = DISCOVERY_CARD_PLACEMENTS[card.discovery]; if (g.discoveries.tokens.some(token => token.type === placement.type && token.status === 'supply')) { g.discoveries = nexusRule(() => placeDiscovery(g.discoveries!,card.discovery as Parameters[1],random)); log(g, `A face-down ${placement.type === 'hiereg' ? 'Hiereg' : 'Smuggler'} Discovery was placed in ${territory(placement.territory).name}, sector ${placement.sector}.`); } else log(g, `The ${placement.type} Discovery supply is empty; no additional physical token can be placed.`); } function playDiscovery(g: Game, p: Player, action: Action) { const offer = discoveryChoices(g,p.id,g.phase === 7 && grummanCollectionAutomatic(g)); requireRule(offer && !offer.blocked && typeof action.reveal === 'boolean' && isDiscoveryTokenId(action.token) && Object.keys(action).every(key => ['type','token','reveal'].includes(key)) && (action.reveal ? offer.canReveal : offer.canInspect).includes(action.token), offer?.blocked ?? 'Choose an eligible hidden Discovery in a territory containing your non-advisor forces.'); if (!action.reveal) { g.discoveries = nexusRule(() => rememberDiscoveryFace(g.discoveries!,action.token as Parameters[1],p.faction)); return; } const token = g.discoveries!.tokens.find(token => token.id === action.token)!; if (token.face === 'spice-stash') requireRule(Number.isSafeInteger(p.spice) && p.spice >= 0 && Number.isSafeInteger(p.spice+7), 'The stash needs a valid spice balance.'); if (token.face === 'treachery-card-stash') requireRule(g.deck.length + g.discard.length > 0, 'No Treachery card is available to draw.'); const revealed = nexusRule(() => revealDiscoveryToken(g.discoveries!,token.id,g.turn,p.id)); g.discoveries = revealed.state; g.ready = []; log(g, `${p.name} revealed ${DISCOVERY_TOKEN_BY_ID[token.face].name} in ${territory(token.territory!).name}.`); if (revealed.outcome.kind === 'spice-stash') { p.spice += revealed.outcome.amount; log(g, `${p.name} gained 7 spice from the bank. The Spice Stash was removed from the game.`); } else if (revealed.outcome.kind === 'treachery-card-stash') { const card = draw(g)!; p.hand.push(card); const overflow = p.hand.length > handLimit(p); g.discoveryStash = {event:crypto.randomUUID(), token:token.id, owner:p.id, turn:g.turn, card:card.id, hand:p.hand.map(card => card.id), stage:overflow ? 'choose' : 'complete', discarded:null, signature:''}; g.discoveryStash.signature = discoveryStashSignature(g.discoveryStash); if (overflow) g.decision = {kind:'discoveryDiscard',player:p.id,event:g.discoveryStash.event}; log(g, `${p.name} drew one private Treachery card from the stash${overflow ? ' and must now discard any one card from the full hand' : ''}. The stash was removed from the game.`); } else if (revealed.outcome.kind === 'ornithopter') { log(g, `${p.name} took the Ornithopter token; it can be spent on a later turn for one movement action.`); } else log(g, `${DISCOVERY_TOKEN_BY_ID[token.face].name} is now a separate, initially unoccupied location within ${territory(token.territory!).name}.`); } function decideDiscoveryDiscard(g: Game, p: Player, action: Action) { const record = g.discoveryStash; requireRule(record?.stage === 'choose' && record.owner === p.id && action.event === record.event && typeof action.card === 'string' && record.hand.includes(action.card) && Object.keys(action).every(key => ['type','event','card'].includes(key)), 'Choose any card from your original stash hand to discard.'); const card = discard(g,p,action.card); record.discarded = card.id; record.stage = 'discard'; record.signature = discoveryStashSignature(record); stageTreacheryDiscard(g,'discovery:stash',[{card,discardedBy:p.id,publicFace:true}], {kind:'discoveryStash',event:record.event,owner:p.id,card:card.id}); log(g, `${p.name} discarded ${card.name} after opening the Treachery Card Stash.`); } function greatMakerRideOptions(g: Game, owner: string) { const p = getPlayer(g,owner), frame = g.greatMaker; if (frame?.stage !== 'ride' || p.faction !== 'fremen') return null; const destinations = gameTerritories(g).flatMap(t => t.sectors.flatMap(sector => { try { allowedEntry(g,p,t.id,sector); return [{territory:t.id,name:t.name,sector}]; } catch (error) { if (error instanceof RuleError) return []; throw error; } })); return {max:p.reserves,eliteMax:p.elites?.reserves ?? 0,destinations}; } function stampGreatMaker(g: Game) { g.greatMaker!.signature = greatMakerSignature(g.greatMaker!); } function greatMakerIntegrity(g: Game) { const frame = g.greatMaker, continuation = g.pendingTreacheryDiscard?.continuation; const controls = [g, g.pendingExchange, g.pendingNullentropy?.resume, g.pendingRicheseGift?.resume, g.pendingRichesePurchaseIncome?.resume, continuation && 'resume' in continuation ? continuation.resume : null]; const owned = controls.filter(control => control?.decision?.kind === 'greatMakerVote' || control?.decision?.kind === 'greatMakerRide'); if (!frame) { requireRule(owned.length === 0, 'The Great Maker has lost its original encounter.'); return; } requireRule(g.discoveryEnabled && g.discoveries, 'The Great Maker requires its physical Discovery module.'); nexusRule(() => validateGreatMaker(frame, g.turn, g.players)); if (frame.stage === 'complete') { requireRule(owned.length === 0, 'The completed Great Maker cannot retain another vote or reserve ride.'); return; } if (frame.arrival) requireRule(validGameLocation(g,frame.arrival.territory,frame.arrival.sector), 'The Great Maker has lost its declared reserve destination.'); const card = g.spiceDiscard[frame.pile][frame.index]; requireRule(g.phase === 1 && frame.turn === g.turn && g.spiceSequence?.pile === frame.pile && card && 'worm' in card && card.greatMaker && !card.suppressed && frame.order.join('|') === g.order.join('|'), 'The Great Maker has lost its current spice encounter.'); if (frame.stage === 'vote' || frame.stage === 'ride') { const kind = frame.stage === 'vote' ? 'greatMakerVote' : 'greatMakerRide'; const owner = frame.stage === 'vote' ? frame.order[frame.votes.length] : byFaction(g, 'fremen')?.id; requireRule(owned.length === 1 && owned[0]?.decision?.kind === kind && owned[0].decision.player === owner && owned[0].decision.event === frame.event, 'The Great Maker has lost its owned vote or reserve ride.'); } else { requireRule(owned.length === 0, 'Resolve the current Great Maker interaction before voting or riding.'); const responses = controls.flatMap(control => { const pending = control && 'pendingKarama' in control ? control.pendingKarama as Game['pendingKarama'] : null; return [control?.response, pending?.use.kind === 'cancel' ? pending.use.response : null]; }); if (frame.stage === 'worm') requireRule( controls.some(control => control?.decision?.kind === 'wormProtection' && control.decision.territory === frame.territory && control.decision.player === byFaction(g,'fremen')?.id && control.decision.ally === byFaction(g,'fremen')?.ally) || controls.some(control => control?.decision?.kind === 'nexusFremenProtection' && g.nexusFremenProtection?.stage === 'choose' && g.nexusFremenProtection.territory === frame.territory && g.nexusFremenProtection.pile === frame.pile && g.nexusFremenProtection.index === frame.index) || responses.some(response => (response?.kind === 'wormSurvival' || response?.kind === 'wormAllyProtection') && response.location === frame.territory && response.owner === byFaction(g,'fremen')?.id && (!response.recipient || response.recipient === byFaction(g,'fremen')?.ally)), 'The Great Maker has lost its pending worm survival or protection interaction.'); if (frame.stage === 'arrival') requireRule( controls.some(control => control?.decision?.kind === 'intrusion' && control.decision.wormRide && control.decision.player === byFaction(g,'beneGesserit')?.id && control.decision.territory === frame.arrival!.territory) || responses.some(response => response?.kind === 'advisorFlip' && response.advisorResume === 'wormRide' && response.owner === byFaction(g,'beneGesserit')?.id && response.location === frame.arrival!.territory) || (g.pendingAmbassador?.resume === 'wormRide' && g.pendingAmbassador.territory === frame.arrival!.territory && g.pendingAmbassador.sector === frame.arrival!.sector) || (g.pendingTerrorEntry?.resume === 'wormRide' && g.pendingTerrorEntry.territory === frame.arrival!.territory && g.pendingTerrorEntry.sector === frame.arrival!.sector), 'The Great Maker has lost its reserve arrival interaction.'); } } function openGreatMakerVote(g: Game) { const frame = g.greatMaker!; frame.stage = 'vote'; stampGreatMaker(g); g.decision = {kind:'greatMakerVote', player:frame.order[frame.votes.length], event:frame.event}; } function completeGreatMaker(g: Game) { g.greatMaker!.stage = 'complete'; stampGreatMaker(g); continueSpice(g); } function decideGreatMakerVote(g: Game, p: Player, action: Action) { const frame = g.greatMaker!; requireRule(frame?.stage === 'vote' && frame.event === action.event && frame.order[frame.votes.length] === p.id && typeof action.yes === 'boolean' && Object.keys(action).every(key => ['type','event','yes'].includes(key)), 'Cast the current Great Maker vote in storm order.'); frame.votes.push({player:p.id, yes:action.yes}); log(g, `${p.name} voted ${action.yes ? 'yes' : 'no'} to a Great Maker Nexus.`); if (frame.votes.length < frame.order.length) { openGreatMakerVote(g); return; } if (greatMakerMajority(frame)) { g.nexus = true; markNexusOccurred(g); log(g, 'A majority voted yes: the Great Maker produces a Nexus.'); } else log(g, 'The Great Maker vote has no yes majority, so this encounter adds no Nexus.'); const fremen = byFaction(g, 'fremen'); if (fremen && fremen.reserves > 0) { frame.stage = 'ride'; stampGreatMaker(g); g.decision = {kind:'greatMakerRide', player:fremen.id, event:frame.event}; } else completeGreatMaker(g); } function decideGreatMakerRide(g: Game, p: Player, action: Action) { const frame = g.greatMaker!; requireRule(frame?.stage === 'ride' && frame.event === action.event && p.faction === 'fremen' && typeof action.accept === 'boolean', 'Choose the current Great Maker reserve ride.'); if (!action.accept) { requireRule(Object.keys(action).every(key => ['type','event','accept'].includes(key)), 'Declining a ride selects no forces or destination.'); log(g, `${p.name} left the optional Great Maker riders in reserves.`); completeGreatMaker(g); return; } requireRule(Object.keys(action).every(key => ['type','event','accept','territory','sector','amount','elite'].includes(key)), 'Choose only a destination and your Great Maker reserve forces.'); const to = stringField(action.territory), sector = integer(action.sector,0,18,'Sector'); const amount = integer(action.amount,1,p.reserves,'Riding forces'); const elite = eliteChoice(amount,p.reserves,p.elites?.reserves ?? 0,action.elite); allowedEntry(g,p,to,sector); withdrawNativeReserves(g,p,amount,elite); place(p,to,sector,amount,elite); frame.arrival = {territory:to,sector,amount,elite}; frame.stage = 'arrival'; stampGreatMaker(g); log(g, `${p.name} rode the Great Maker with ${amount} reserve forces to ${territory(to).name}. This spends no shipment, movement or spice.`); const intruded = intrusion(g,p,to,{wormRide:true}); if (openTerritoryEntry(g,p,to,sector,amount,elite,'wormRide','wormRide') || intruded) return; completeGreatMaker(g); } 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 if (card.greatMaker) { g.spiceDiscard[sequence.pile].push(card); g.greatMaker = {arrival:null,event:crypto.randomUUID(),turn:g.turn,pile:sequence.pile, index:g.spiceDiscard[sequence.pile].length-1, territory:previous && 'territory' in previous ? previous.territory : null, stage:'worm',order:[...g.order],votes:[],ridesBefore:[...g.wormRides],signature:''}; stampGreatMaker(g); if (g.greatMaker.territory) beginWorm(g,g.greatMaker.territory); else openGreatMakerVote(g); return; } else { g.nexus = true; markNexusOccurred(g); 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) { if (card.discovery) resolveDiscoveryBlow(g, card); g.spiceDiscard[sequence.pile].push(card); const amount = card.amount * (doubleNext ? 2 : 1); g.spiceWindow = { ...card, amount, harvested: false }; const placement = quoteSpicePlacement(g.spice, g.storm, { ...card, amount }); if (!placement.blocked) { g.spice[placement.key] = placement.total; 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.greatMaker?.stage === 'arrival') { completeGreatMaker(g); return; } if (g.summonedNexusBeforeRides) { g.summonedNexusBeforeRides = false; g.nexus = true; g.ready = []; return; } const fremen = byFaction(g, 'fremen'); if (fremen?.fremenNexusWormBlockedTurn === g.turn) g.wormRides.length = 0; 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; } } for (const ride of g.nexusFremenCunningRides ?? []) { if (ride.stage !== 'queued' || ride.occurrence.turn !== g.turn) continue; if (fremen?.fremenNexusWormBlockedTurn === g.turn) { ride.stage = 'complete'; continue; } const source = nexusRule(() => quoteFremenCunningRide( g, ride.authorization.owner, ride.occurrence, ride.authorization, )); if (!source) { ride.stage = 'complete'; log(g, 'Fremen Nexus Cunning has no remaining legal desert force source.'); continue; } ride.stage = 'select'; g.decision = { kind: 'nexusFremenCunningRide', player: ride.authorization.owner, event: ride.occurrence.event }; 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' | 'richeseCacheTerms' | 'richeseUnbid', ) { const round = g.richeseBidding!; round.event = crypto.randomUUID(); const authority = (kind === 'richeseCache' || kind === 'richeseCacheTerms') && g.homeworldOccupationPreview ? quoteOccupiedBiddingAuthority(g, 'richeseCache') : null; const player = kind === 'richeseCache' ? authority?.controller ?? round.owner : round.owner; if (authority) round.cacheAuthority = JSON.stringify(quoteStableHomeworldOccupation(g, 'richese')); if (authority?.blocked) { g.decision = null; g.active = null; return; } g.decision = { kind, player }; g.active = player; } 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); } /** User ruling 7 October 2026: a cache lot runs only while physical cache cards * remain and the compulsory auction was not canceled; otherwise one ordinary * lot is restored, so the cache's one-lot reduction is not applied. */ function richeseCacheRuns(g: Game) { return (g.richeseCache?.length ?? 0) > 0 && !g.richeseBidding!.cacheCanceled; } function richeseDeclaration(g: Game) { richeseDeclarationCache(g); const round = g.richeseBidding!; round.stage = 'declaration'; round.normalCountBeforeCache = Math.max( 0, auctionEligible(g).length - Number(round.blackMarketSold), ); round.normalCount = Math.max( 0, round.normalCountBeforeCache - Number(richeseCacheRuns(g)), ); richeseDecision(g, 'richeseDeclaration'); } function validateRicheseCacheCancellation(g: Game, response: ResponseWindow) { const round = g.richeseBidding; requireRule(g.status === 'playing' && g.phase === 3 && round && round.turn === g.turn && round.stage === 'declaration' && round.owner === response.owner && getPlayer(g, round.owner).faction === 'richese' && (round.position === 'first' || round.position === 'last') && Number.isSafeInteger(round.normalCount) && round.normalCount! >= 0 && round.normalCount! <= g.players.length && !round.cacheCanceled && !g.auction && !g.richeseAuction && !g.ixAuction && (round.normalCountBeforeCache === undefined || Number.isSafeInteger(round.normalCountBeforeCache) && round.normalCountBeforeCache >= round.normalCount! && round.normalCountBeforeCache <= g.players.length && round.normalCountBeforeCache - round.normalCount! <= 1), 'Cache cancellation needs the original undealt Richese declaration.'); richeseDeclarationCache(g); return round; } function prepareRicheseNormal(g: Game) { g.richeseBidding!.stage = 'normal'; setAuction(g); } function offerRicheseCache(g: Game) { g.richeseBidding!.stage = 'cacheOffer'; const occupied = g.homeworldOccupationPreview && quoteOccupiedBiddingAuthority(g, 'richeseCache').occupied; richeseDecision(g, occupied ? 'richeseCacheTerms' : 'richeseCache'); } function finishNormalBidding(g: Game) { const round = g.richeseBidding; if (round?.turn === g.turn && round.stage === 'normal') { if (round.position === 'last' && richeseCacheRuns(g)) { 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; if (g.homeworldOccupationPreview) g.homeworldAuctionInspection = undefined; 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 richeseBetrayalModeSupported(g: Game): boolean { return g.expansions.length === 1 && g.expansions[0] === 'choam' && g.players.length >= 2 && g.players.length <= 6 && g.players.some(p => p.faction === 'richese') && g.players.some(p => p.faction === 'choam') && g.players.every(p => RICHESE_CUNNING_ROSTER[p.faction] === true) && !!g.nexusCards && !g.homeworlds && !g.leaderSkills && !g.discoveryEnabled && !g.ecazTreachery && !g.sandtrout && !g.techTokens && !g.strongholdCards && !g.semutaPreview && !g.kullPreview; } function richeseBetrayalResponders(g: Game): string[] { return requiredRicheseBetrayalResponders(g.players.map(p => ({ id:p.id,faction:p.faction,ally:p.ally,nexusHeld:!!g.nexusCards?.cards?.hands[p.id], }))); } function richeseBetrayalSourceSignature(g: Game, frame: NonNullable): string { return JSON.stringify({ event:frame.receipt.event,lot:frame.lot,quote:frame.quote,turn:g.turn,phase:g.phase,status:g.status, round:g.richeseBidding,auction:g.richeseAuction,order:g.order,positions:normalizedPlayerPositions(g), funding:g.richeseFunding,aid:g.aid,cache:g.richeseCache,offered:g.richeseOfferedCard, players:g.players.map(p => ({id:p.id,faction:p.faction,ally:p.ally,hand:p.hand,spice:p.spice})), active:g.active,decision:g.decision,response:g.response,phaseOpening:g.phaseOpening, peek:g.richesePeekKnown,claim:g.richeseClaim, }); } function richeseBetrayalInvoice(g: Game, quote: Extract) { const lot = g.richeseAuction!; return nexusRule(() => quoteRicheseBetrayalInvoice({ players:g.players,lot,quote,donor:quote.allyPayment ? getPlayer(g,quote.winner).ally : null, originalRecipient:quote.winner === lot.owner ? byFaction(g,'emperor')?.id ?? null : lot.owner, sourceCards:lot.source === 'cache' ? g.richeseCache! : getPlayer(g,lot.owner).hand, physicalCards:physicalTreacheryCards(g), })); } function richeseBetrayalSourcePreflight(g: Game, quote: Extract): void { requireRule(richeseBetrayalModeSupported(g) && g.status === 'playing' && g.phase === 3 && g.richeseBidding?.stage === 'lot' && g.richeseBidding.turn === g.turn && !g.auction && !g.currentAuctionSale && !g.response && !g.decision && !g.phaseOpening && !g.truthtrance && !g.pendingKarama && !g.pendingNullentropy && !g.pendingTreacheryDiscard && !g.pendingKull && !g.pendingChoamWorthless && !g.choamMarket && !g.pendingIxAlly && !g.pendingRicheseGift && !g.pendingRichesePurchaseIncome && !g.biddingEnd && !g.bureaucratPayments?.pending && !pendingNexusTraitors(g), 'Richese Betrayal requires a clean ordinary Richese lot before payment, without another effect overlay.'); const lot = g.richeseAuction!, donor = quote.allyPayment ? getPlayer(g,quote.winner).ally : null; const players = g.players.map(p => ({ ...p, spice:p.spice - (p.id === quote.winner ? quote.amount - quote.allyPayment : 0), hand:[...(lot.source === 'blackMarket' && p.id === lot.owner ? p.hand.filter(c => c.id !== quote.card.id) : p.hand),...(p.id === quote.winner ? [quote.card] : [])], })); const projected:Game = { ...g,players,richeseCache:lot.source === 'cache' ? g.richeseCache!.filter(c => c.id !== quote.card.id) : g.richeseCache, aid:donor ? {...g.aid,[donor]:{...g.aid[donor],amount:g.aid[donor].amount - quote.allyPayment}} : g.aid, currentAuctionSale:{winner:quote.winner,amount:quote.amount,free:false,origin:lot.source,seller:lot.owner}, }; // Both original and redirected settlement must retire this same lot. This // includes native exhausted-cache and Harkonnen continuation fences. currentAuctionContinuationQuote(projected,{kind:'sale',free:false}); currentAuctionContinuationQuote(projected,{kind:'bonus'}); currentAuctionContinuationQuote(projected,{kind:'next'}); for (const p of players) { const promises = liveShipmentPromises(g.shipmentPromises ?? [],p.id,g.turn); requireRule(!promises.length || !!findShipmentCompletion(projected,p,promises), 'This settlement would make a committed shipment answer impossible.'); } } function richeseBetrayalUseBlock(g: Game, owner: string): string | null { const frame = g.pendingRicheseBetrayal; if (!frame || !frame.receipt.required.includes(owner) || frame.receipt.passed.includes(owner)) return 'This seat has no current Richese Betrayal acknowledgement.'; const blocked = nexusRule(() => richeseBetrayalEligibility({players:g.players,cards:g.nexusCards!.cards!},owner)); if (blocked) return blocked; const p = getPlayer(g,owner), promises = liveShipmentPromises(g.shipmentPromises ?? [],owner,g.turn); if (promises.length) { const projected:Game = {...g,nexusCards:{...g.nexusCards!, cards:nexusRule(() => discardNexusCard(g.nexusCards!.cards!,owner,g.players))}}; if (!findShipmentCompletion(projected,p,promises)) return 'Spending this Nexus card would make your committed shipment answer impossible.'; } return null; } function richeseBetrayalIntegrity(g: Game): void { const cursor = g.richeseBetrayal, frame = g.pendingRicheseBetrayal; if (!g.richeseBetrayalPreview) { requireRule(!cursor && !frame,'Richese Betrayal lost its explicit preview profile.'); requireRule(!g.currentAuctionSale?.recipient,'The bank override lost its Richese Betrayal receipt.'); return; } requireRule(richeseBetrayalModeSupported(g) && cursor,'The Richese Betrayal profile lost its supported inventory or cursor.'); nexusCardsIntegrity(g); nexusRule(() => validateRicheseBetrayalCursor(g,cursor,frame?.receipt ?? null)); if (frame) { requireRule(Object.keys(frame).sort().join(',') === 'lot,quote,receipt,signature' && frame.quote?.kind === 'sold' && frame.lot && typeof frame.signature === 'string', 'The saved Richese Betrayal source frame is malformed.'); richeseBetrayalSourcePreflight(g,frame.quote); requireRule(frame.signature === richeseBetrayalSourceSignature(g,frame) && JSON.stringify(frame.lot) === JSON.stringify(g.richeseAuction) && JSON.stringify(frame.quote) === JSON.stringify(richeseSettlementQuote(g)) && JSON.stringify(frame.receipt.invoice) === JSON.stringify(richeseBetrayalInvoice(g,frame.quote)) && frame.receipt.round === g.richeseBidding!.event && frame.receipt.lot === g.richeseAuction!.event && frame.receipt.turn === g.turn && JSON.stringify(frame.receipt.required) === JSON.stringify(richeseBetrayalResponders(g)), 'The saved Richese Betrayal opportunity lost its original quote, custody, responders or source continuation.'); } const sale = g.currentAuctionSale; const completed = cursor.completed.at(-1); if (completed && completed.lot === g.richeseAuction?.event) { const waiting = g.pendingNexusEmperorSellerPurchase; requireRule(g.status === 'playing' && g.phase === 3 && completed.turn === g.turn && completed.round === g.richeseBidding?.event && !(completed.stage === 'used' && completed.invoice.kind === 'purchase'), 'The completed Richese reaction lost its original source.'); if (waiting) { requireRule(!sale && waiting.round === completed.round && waiting.lot.event === completed.lot && waiting.winner === completed.invoice.buyer && waiting.lot.owner === completed.invoice.target && waiting.lot.source === completed.invoice.source && waiting.quote.amount === completed.invoice.price && waiting.bank === (completed.stage === 'used'), 'The original Richese reaction lost its suspended winning-buyer confirmation.'); } else { const retained = sale?.free && g.nexusEmperorPurchaseHistory?.some(receipt => 'version' in receipt && receipt.version === 2 && receipt.lot === completed.lot && receipt.round === completed.round && receipt.owner === completed.invoice.buyer && receipt.seller === completed.invoice.target && receipt.origin === completed.invoice.source && receipt.price === completed.invoice.price); requireRule(sale && sale.winner === completed.invoice.buyer && sale.seller === completed.invoice.target && sale.origin === completed.invoice.source && sale.amount === completed.invoice.price && (!sale.free || retained) && sale.recipient === (completed.stage === 'used' ? 'bank' : undefined), 'The completed Richese reaction lost or reopened its original sale continuation.'); const responses = savedNoFieldResponses(g).filter(response => response.kind === 'emperorIncome' || response.kind === 'harkonnenBonus'); requireRule(responses.length === 1,'The completed Richese sale lost its native income or bonus continuation.'); currentAuctionContinuationQuote(g,{kind:'cancel',response:responses[0]}); } } if (sale?.recipient) { const receipt = cursor.completed.at(-1); requireRule(sale.recipient === 'bank' && receipt?.stage === 'used' && receipt.invoice.kind === 'sale' && receipt.lot === g.richeseAuction?.event && receipt.round === g.richeseBidding?.event && receipt.invoice.buyer === sale.winner && receipt.invoice.target === sale.seller && receipt.invoice.price === sale.amount && receipt.invoice.source === sale.origin, 'The diverted auction invoice lost its committed source receipt.'); } } function beginRicheseBetrayal(g: Game, quote: Extract): boolean { if (!g.richeseBetrayalPreview) return false; richeseBetrayalSourcePreflight(g,quote); const required = richeseBetrayalResponders(g); if (!required.length) return false; const cursor = g.richeseBetrayal!, lot = g.richeseAuction!, round = g.richeseBidding!; const sequence = cursor.sequence + 1; const receipt = nexusRule(() => createRicheseBetrayalReceipt(g,{ event:richeseBetrayalEvent(g.turn,sequence,round.event,lot.event),turn:g.turn,sequence, round:round.event,lot:lot.event,invoice:richeseBetrayalInvoice(g,quote), required,passed:[],stage:'pending',actor:null,nexusDiscardIndex:null, })); const frame:NonNullable = {receipt,lot:structuredClone(lot),quote:structuredClone(quote),signature:''}; frame.signature = richeseBetrayalSourceSignature(g,frame); cursor.sequence = sequence; cursor.current = receipt.event; g.pendingRicheseBetrayal = frame; return true; } function decideRicheseBetrayal(g: Game, p: Player, action: Action): void { const frame = g.pendingRicheseBetrayal!, receipt = frame.receipt; requireRule(['richeseBetrayalPass','richeseBetrayalUse'].includes(action.type) && action.event === receipt.event && Object.keys(action).every(key => ['type','event'].includes(key)) && receipt.required.includes(p.id) && !receipt.passed.includes(p.id), 'Choose your exact current Richese Betrayal acknowledgement.'); const use = action.type === 'richeseBetrayalUse'; if (use) { const blocked = richeseBetrayalUseBlock(g,p.id); requireRule(!blocked,blocked ?? 'This Nexus card cannot be used.'); } const passed = use ? receipt.passed : [...receipt.passed,p.id]; const stage = use ? 'used' : passed.length === receipt.required.length ? 'passed' : 'pending'; const next = nexusRule(() => createRicheseBetrayalReceipt(g,{ ...receipt,passed,stage,actor:use ? p.id : null, nexusDiscardIndex:use ? g.nexusCards!.cards!.discard.length : null, })); if (stage === 'pending') {frame.receipt = next;return;} if (use) g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!,p.id,g.players)); g.richeseBetrayal!.completed.push(next); g.richeseBetrayal!.current = null; g.pendingRicheseBetrayal = null; if (use && receipt.invoice.kind === 'purchase') { const card = frame.quote.card; g.richeseCache = g.richeseCache!.filter(c => c.id !== card.id); g.discard.push(card); g.richeseFunding = {}; log(g,`${p.name} spent Richese Nexus Betrayal to discard ${card.name} before Richese could buy it. No purchase payment or acquisition occurred.`); finishRicheseLot(g); } else { if (use) log(g,`${p.name} spent Richese Nexus Betrayal: the ${receipt.invoice.price}-spice Richese sale payment goes to the Spice Bank instead.`); settleRicheseSoldLot(g,frame.quote,use); } } function settleRicheseLot(g: Game) { const lot = g.richeseAuction!; const quote = richeseSettlementQuote(g); 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; } if (beginRicheseBetrayal(g,quote)) return; settleRicheseSoldLot(g,quote,false); } const EMPEROR_SELLER_POLICY = 'Provisional seller policy: retain your own full original price after proving it. The seller stays unpaid; no Spice Bank reimbursement, payer invoice or payment-derived income is created. Original card acquisition and native bonus/lot continuation still resolve.'; function emperorSellerSource(frame: NonNullable) { return { round: frame.round, lot: frame.lot, quote: frame.quote, bank: frame.bank }; } function emperorSellerSourceSignature(g: Game, frame: NonNullable) { const winner = getPlayer(g, frame.winner); return JSON.stringify([frame.event, frame.turn, frame.winner, frame.round, frame.lot, frame.quote, frame.bank, g.richeseBidding?.event, g.richeseBidding?.stage, g.richeseAuction, g.richeseFunding?.[frame.winner], aidFor(g, winner), winner.spice, winner.ally, getPlayer(g, frame.lot.owner).ally, g.nexusEmperorPurchaseHistory?.length ?? 0]); } function nexusEmperorSellerIntegrity(g: Game): void { const frame = g.pendingNexusEmperorSellerPurchase; const continuation = g.pendingTreacheryDiscard?.continuation; const decision = g.decision?.kind === 'nexusEmperorSellerPurchase' ? g.decision : continuation?.kind === 'truthtranceDiscard' ? continuation.resume.decision : null; if (!frame) { requireRule(decision?.kind !== 'nexusEmperorSellerPurchase', 'The original seller purchase lost its current winning-buyer confirmation.'); return; } requireRule(Object.keys(frame).sort().join(',') === 'bank,event,lot,quote,round,signature,turn,winner' && frame.turn === g.turn && typeof frame.event === 'string' && frame.event.length > 0 && typeof frame.bank === 'boolean' && typeof frame.signature === 'string' && emperorNexusSellerPurchaseModeSupported(g) && !g.pendingRicheseBetrayal && decision?.kind === 'nexusEmperorSellerPurchase' && decision.player === frame.winner && decision.event === frame.event && g.active === frame.winner && frame.signature === emperorSellerSourceSignature(g, frame), 'The original seller purchase lost its immutable lot, price, funding or winning buyer.'); const quote = nexusRule(() => quoteEmperorNexusSellerPurchase(g, emperorSellerSource(frame), frame.winner)); requireRule(quote.event === frame.event && JSON.stringify(frame.quote) === JSON.stringify(richeseSettlementQuote(g)), 'The original seller purchase lost its exact pre-acquisition source.'); } function nexusEmperorSellerOffer(g: Game, id: string) { const frame = g.pendingNexusEmperorSellerPurchase; if (!frame || g.decision?.kind !== 'nexusEmperorSellerPurchase' || g.decision.player !== id || frame.winner !== id) return null; const quote = nexusRule(() => quoteEmperorNexusSellerPurchase(g, emperorSellerSource(frame), id)); const blocked = g.truthtrance || pendingNexusTraitors(g) || g.pendingTreacheryDiscard ? 'Finish the current question or private return before confirming this purchase.' : quote.blocked; return { ...quote, blocked, canUse: !blocked, policy: EMPEROR_SELLER_POLICY }; } function beginNexusEmperorSellerPurchase(g: Game, quote: Extract, bank: boolean): boolean { if (quote.amount <= 0 || !emperorNexusSellerPurchaseModeSupported(g)) return false; requireRule(!g.pendingNexusEmperorSellerPurchase && !g.pendingRicheseBetrayal && !g.response && !g.decision && !g.truthtrance && !g.phaseOpening && !g.pendingKarama && !g.pendingTreacheryDiscard && !pendingNexusTraitors(g), 'Finish the original reaction before confirming the seller purchase.'); const input = { round: g.richeseBidding!.event, lot: g.richeseAuction!, quote, bank }; const offer = nexusRule(() => quoteEmperorNexusSellerPurchase(g, input, quote.winner)); const frame: NonNullable = { event: offer.event, turn: g.turn, winner: quote.winner, round: input.round, lot: structuredClone(input.lot), quote, bank, signature: '', }; frame.signature = emperorSellerSourceSignature(g, frame); g.pendingNexusEmperorSellerPurchase = frame; g.active = frame.winner; g.decision = { kind: 'nexusEmperorSellerPurchase', player: frame.winner, event: frame.event }; return true; } function decideNexusEmperorSellerPurchase(g: Game, p: Player, action: Action): void { const frame = g.pendingNexusEmperorSellerPurchase!; requireRule(action.type === 'nexusEmperorSellerPurchase' && action.event === frame.event && typeof action.use === 'boolean' && Object.keys(action).sort().join(',') === 'event,type,use' && p.id === frame.winner && g.decision?.kind === 'nexusEmperorSellerPurchase' && g.decision.player === p.id && !g.truthtrance && !pendingNexusTraitors(g), 'Confirm only your current original seller purchase with Pay or Use.'); nexusEmperorSellerIntegrity(g); const receipt = action.use ? nexusRule(() => createEmperorNexusSellerPurchase(g, emperorSellerSource(frame))) : null; if (receipt) { g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!, p.id, g.players)); (g.nexusEmperorPurchaseHistory ??= []).push(receipt); (g.nexusEmperorPurchaseEvents ??= []).push(receipt.event); log(g, `${p.name} spent Emperor Nexus after proving the original ${receipt.price}-spice seller price. They retain their spice; the seller stays unpaid and no bank subsidy or payment invoice is created. Provisional seller interpretation.`); } g.pendingNexusEmperorSellerPurchase = null; g.decision = null; settleRicheseSoldLot(g, frame.quote, frame.bank, !!receipt, true); } function settleRicheseSoldLot(g: Game, quote: Extract, bank: boolean, free = false, confirmed = false) { if (!confirmed && beginNexusEmperorSellerPurchase(g, quote, bank)) return; const lot = g.richeseAuction!, owner = getPlayer(g,lot.owner); const winner = getPlayer(g, quote.winner), card = quote.card; const richeseContribution = !free && g.advanced && winner.id !== owner.id && winner.ally === owner.id ? quote.allyPayment : 0; const bankAmount = free ? 0 : bank || winner.id === owner.id ? quote.amount : richeseContribution; const emperor = byFaction(g, 'emperor'); const incomePayment = g.spiceBankerIncomePreview && bankAmount > 0 ? bankerIncomePayment(g, 'auction', bank || winner.id === owner.id ? [{payer:winner.id,amount:quote.ownPayment,recipient:bank || !emperor ? 'bank' : 'player'}, ...(quote.allyPayment ? [{payer:winner.ally!,amount:quote.allyPayment,recipient:bank || !emperor ? 'bank' as const : 'player' as const}] : [])] : [{payer:owner.id,amount:richeseContribution,recipient:emperor ? 'player' : 'bank'}]) : undefined; if (!free) 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 = { ...(g.homeworldOccupationPreview ? { occupiedEvent: lot.event, occupiedContributions: free ? [] : [{ payer: winner.id, amount: quote.ownPayment }, ...(quote.allyPayment ? [{ payer: winner.ally!, amount: quote.allyPayment }] : [])] } : {}), ...(richeseContribution ? {richeseContribution} : {}), ...(incomePayment ? {spiceBankerIncomePayment:incomePayment} : {}), winner: winner.id, amount: quote.amount, free, origin: lot.source, seller: owner.id, ...(bank ? {recipient:'bank' as const} : {}), ...(!free ? stampAuctionBureaucrat(g,winner,quote.amount,quote.allyPayment,owner.id) : {}), }; if (bank || !emperor) awardBankerIncome(g, incomePayment); log( g, `${winner.name} bought ${lot.source === 'cache' ? card.name : 'the concealed Black Market card'} for ${quote.amount} spice.${free ? ' Emperor Nexus retained the proven price; no seller or bank payment occurred.' : ' 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 }; g.decision = { kind: 'ixAllyCard', player: winner.id }; } else continueAuctionSale(g, free); } 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 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 && (decision.kind === 'richeseCache' && g.homeworldOccupationPreview ? homeworldRule(() => requireOccupiedBiddingController(g, 'richeseCache', p.id)).provider === round.owner : round.owner === p.id) && action.event === round.event, 'This Richese bidding decision has expired.', ); if ((decision.kind === 'richeseCache' || decision.kind === 'richeseCacheTerms') && round.cacheAuthority) requireRule(round.cacheAuthority === JSON.stringify(quoteStableHomeworldOccupation(g, 'richese')), 'The original cache choice authority changed.'); 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 ${richeseCacheRuns(g) ? 'after the cache reduction' : 'because no cache lot can run'}${round.blackMarketSold ? ' and completed Black Market sale' : ''}.`, ); g.response = { kind: 'richeseAuction', owner: p.id, passed: [] }; } else if (decision.kind === 'richeseCacheTerms') { requireRule(round.stage === 'cacheOffer' && ['onceAround', 'silent'].includes(action.method as string) && ['clockwise', 'counterclockwise'].includes(action.direction as string) && Object.keys(action).sort().join(',') === 'direction,event,method,type', 'The original seller must choose the cache auction terms.'); round.cacheTerms = { method: action.method as 'onceAround' | 'silent', direction: action.direction as 'clockwise' | 'counterclockwise' }; round.cacheTermsEvent = round.event; round.cacheTermsProof = JSON.stringify([round.cacheTermsEvent, round.turn, round.owner, round.cacheTerms, round.cacheAuthority]); richeseDecision(g, 'richeseCache'); } else if (decision.kind === 'richeseCache') { requireRule( round.stage === 'cacheOffer', 'The cache offer is not available now.', ); if (p.id !== round.owner) { requireRule(round.cacheTerms && Object.keys(action).sort().join(',') === 'card,event,type', 'The occupier chooses only the original physical cache card, not the seller terms.'); requireRule(round.cacheTermsProof === JSON.stringify([round.cacheTermsEvent, round.turn, round.owner, round.cacheTerms, round.cacheAuthority]), 'The original seller terms changed before the physical cache choice.'); beginRicheseLot(g, 'cache', { ...action, ...round.cacheTerms }); } else 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.homeworldOccupationPreview ? { authority: JSON.stringify(quoteStableHomeworldOccupation(g, 'ix')) } : {}) }; g.ixAuctionKnown = null; g.response = { kind: 'ixAuction', owner: ixians.id, passed: [] }; if (g.homeworldOccupationPreview && quoteOccupiedBiddingAuthority(g, 'ixAuction').occupied) { const original = g.response; g.response = null; finishIxianNativeAttempt(g, original, false); } 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 = { ...(g.homeworldOccupationPreview ? { occupiedEvent: crypto.randomUUID() } : {}), 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; if (g.homeworldOccupationPreview) g.homeworldAuctionInspection = undefined; 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; } } /** Card identity alone is not a lot: Technology can return a previously sold card. */ function originalAuctionInspectionLot(g: Game) { return g.richeseAuction?.event ?? JSON.stringify([g.auction!.occupiedEvent ?? g.turn, g.auction!.index, g.auction!.cards[g.auction!.index].id]); } function openOccupiedAuctionInspection(g: Game) { if (!g.homeworldOccupationPreview) return; const authority = quoteOccupiedBiddingAuthority(g, 'atreidesInspection'); const lot = originalAuctionInspectionLot(g); g.homeworldAuctionInspection = { lot, audience: authority.blocked ? [] : [...authority.inspectionAudience], native: authority.provider, occupation: JSON.stringify(quoteStableHomeworldOccupation(g, 'caladan')), occupiedKnown: false }; } function offerAuctionPeek(g: Game) { const quote = auctionTechnologyQuote(() => quoteNormalAuctionPeek(g)); g.auction!.peekKnown = quote.peekKnown; g.response = quote.response; openOccupiedAuctionInspection(g); } 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!; const owners = [b.attacker, b.defender].filter(id => strongholdEffect(g, id) === 'habbanya_ridge_sietch'); return saphoAggressorRule(() => battleTieOwner(b.attacker, b.defender, battleAggressor(g), owners)); } function saphoAggressorRule(calculate: () => T): T { try { return calculate(); } catch (error) { if (error instanceof SaphoAggressorError) throw new RuleError(error.message); throw error; } } function battleAggressor(g: Game) { const b = g.battle!; return saphoAggressorRule(() => validateSaphoAggressor(b.saphoAggressor, b.saphoAggressorEvents, b, g.turn)); } function saphoAggressorIntegrity(g: Game) { const b = g.battle; if (!b) return; battleAggressor(g); if (!b.saphoAggressor) return; requireRule(g.status === 'playing' && g.phase === 6 && b.preLeader?.event === b.event && g.players.some(p => p.id === b.attacker) && g.players.some(p => p.id === b.defender), 'The saved Sapho aggressor needs its original battle preparation.'); const cards = physicalTreacheryCards(g).filter(card => card.id === 'richese-juice-of-sapho'); requireRule(cards.length === 1 && richeseCardDefinition(cards[0])?.card.effect === 'juiceOfSapho', 'The played Sapho aggressor card has lost its unique physical custody.'); } 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 mentatRule(quote: () => T): T { try { return quote(); } catch (error) { if (error instanceof MentatQuestionError) throw new RuleError(error.message); throw error; } } function createMentatQuestion(g: Game) { const b = g.battle!; if (b.mentatQuestionVersion !== 1) return; const owner = [b.attacker,b.defender].find(id => nativeLeaderSkill(g,id)?.skill === 'mentat'); if (!owner) return; const trained = nativeLeaderSkill(g,owner)!, target = b.attacker === owner ? b.defender : b.attacker; const event = crypto.randomUUID(); const receipt: MentatQuestionReceipt = {event,battle:b.event!,turn:g.turn,territory:b.territory, owner,target,leader:trained.leader,stage:'name',weapon:null,card:null, hand:mentatRule(() => mentatHand(getPlayer(g,target).hand,g.expansions)),signature:''}; if (!receipt.hand.length) {receipt.stage = 'unavailable';log(g,MENTAT_EMPTY_HAND,{faction:getPlayer(g,owner).faction,name:'Mentat'});} receipt.signature = mentatSignature(receipt); b.mentatQuestion = receipt; b.mentatQuestionEvent = event; } function mentatQuestionIntegrity(g: Game) { const history = g.mentatHistory, events = g.mentatHistoryEvents; requireRule(history === undefined && events === undefined || Array.isArray(history) && Array.isArray(events) && history.length === events.length && new Set(events).size === events.length && new Set(history.map(observation => observation?.event)).size === history.length, 'The saved Mentat observations lost their independent event history.'); for (const observation of history ?? []) { requireRule(observation && typeof observation === 'object' && typeof observation.event === 'string' && observation.event.length > 0 && events!.filter(event => event === observation.event).length === 1 && typeof observation.battle === 'string' && observation.battle.length > 0 && Number.isSafeInteger(observation.turn) && observation.turn >= 1 && observation.turn <= g.turn && typeof observation.territory === 'string' && observation.territory.length > 0 && observation.owner !== observation.target && g.players.some(p => p.id === observation.target) && g.players.some(p => p.id === observation.owner && p.leaders.some(l => l.id === observation.leader)) && mentatWeaponNames(g.expansions).includes(observation.weapon) && observation.signature === mentatSignature(observation), 'The saved Mentat observation changed its question, audience or event.'); mentatRule(() => mentatHand([observation.card],g.expansions)); } const b = g.battle, receipt = b?.mentatQuestion; const decisions = homeworldSavedDecisions(g).filter(d => d.kind === 'mentatQuestion'); if (!receipt) { requireRule(receipt === undefined && !b?.mentatQuestionEvent && !decisions.length, 'The saved Mentat question lost its original receipt.'); requireRule(b?.mentatQuestionVersion === undefined || b.mentatQuestionVersion === 1 && !!g.leaderSkills && mentatQuestionModeSupported(g), 'The saved Mentat question has an invalid battle version.'); return; } requireRule(b && b.mentatQuestionVersion === 1 && g.leaderSkills && mentatQuestionModeSupported(g) && g.status === 'playing' && g.phase === 6 && receipt.battle === b.event && receipt.turn === g.turn && receipt.territory === b.territory && typeof receipt.event === 'string' && receipt.event.length > 0 && receipt.event === b.mentatQuestionEvent && [b.attacker,b.defender].includes(receipt.owner) && [b.attacker,b.defender].includes(receipt.target) && receipt.owner !== receipt.target && receipt.signature === mentatSignature(receipt), 'The saved Mentat question changed its battle, audience or original event.'); mentatRule(() => mentatHand(receipt.hand,g.expansions)); requireRule(['name','reveal','answered','declined','unavailable'].includes(receipt.stage), 'The saved Mentat question has an invalid stage.'); requireRule(receipt.stage === 'unavailable' ? receipt.hand.length === 0 : receipt.hand.length > 0, 'The saved Mentat opportunity changed its empty-hand boundary.'); if (receipt.stage === 'name' || receipt.stage === 'declined' || receipt.stage === 'unavailable') requireRule(receipt.weapon === null && receipt.card === null, 'The unasked Mentat question cannot contain an answer.'); else { requireRule(typeof receipt.weapon === 'string', 'The saved Mentat weapon question is missing.'); const quote = mentatRule(() => quoteMentatReveal(receipt.hand,receipt.weapon!,g.expansions)); requireRule(receipt.stage === 'reveal' ? receipt.card === null : receipt.card && quote.cards.some(card => JSON.stringify(card) === JSON.stringify(receipt.card)), 'The saved Mentat disclosure does not answer its exact original question.'); } const pending = receipt.stage === 'name' || receipt.stage === 'reveal'; requireRule(decisions.length === (pending ? 1 : 0), 'The saved Mentat question lost or reopened its unique decision.'); if (pending) { const assignment = nativeLeaderSkill(g,receipt.owner); requireRule(!b.revealed && !Object.keys(b.plans).length && !Object.keys(b.leaderSkillHidden ?? {}).length && !b.preparation && !g.response && assignment?.skill === 'mentat' && assignment.leader === receipt.leader && getPlayer(g,receipt.owner).leaders.some(l => l.id === receipt.leader && !l.dead && !l.capturedBy && !l.gholaBy) && JSON.stringify(getPlayer(g,receipt.target).hand) === JSON.stringify(receipt.hand), 'The pending Mentat question changed its trainer, timing or reserved private hand.'); for (const card of receipt.hand) requireRule(physicalTreacheryCards(g).filter(c => c.id === card.id).length === 1, 'The pending Mentat response must retain unique physical card custody.'); const decision = decisions[0]; requireRule(decision === g.decision && decision.event === receipt.event && decision.owner === receipt.owner && decision.target === receipt.target && decision.player === (receipt.stage === 'name' ? receipt.owner : receipt.target) && decision.stage === receipt.stage && decision.weapon === receipt.weapon, 'The Mentat decision no longer matches its atomic question and answering player.'); } const observations = (history ?? []).filter(observation => observation.event === receipt.event); requireRule(observations.length === (receipt.stage === 'answered' ? 1 : 0), 'The completed Mentat question lost or duplicated its private observation.'); if (observations.length) { const {stage: _stage,hand: _hand,signature: _signature,...answer} = receipt; const {signature: _observationSignature,...observation} = observations[0]; requireRule(JSON.stringify(answer) === JSON.stringify(observation), 'The Mentat historical answer changed after disclosure.'); } } function projectedMentat(g: Game, viewer: string): MentatView { const receipt = g.battle?.mentatQuestion; const pending = receipt && (receipt.stage === 'name' || receipt.stage === 'reveal') ? receipt : null; return {pending: pending ? {event:pending.event,owner:pending.owner,target:pending.target, player:pending.stage === 'name' ? pending.owner : pending.target,stage:pending.stage as 'name'|'reveal',weapon:pending.weapon, weapons:pending.stage === 'name' && viewer === pending.owner && pending.hand.length ? mentatWeaponNames(g.expansions) : [], cards:pending.stage === 'reveal' && viewer === pending.target ? mentatRule(() => quoteMentatReveal(pending.hand,pending.weapon!,g.expansions)).cards : [], blocked:pending.stage === 'name' && !pending.hand.length ? MENTAT_EMPTY_HAND : null} : null, history:(g.mentatHistory ?? []).filter(observation => [observation.owner,observation.target].includes(viewer)) .map(({signature: _signature,...observation}) => structuredClone(observation))}; } function actMentatQuestion(g: Game, decision: Extract, action: Action) { const receipt = g.battle!.mentatQuestion!; requireRule(action.event === receipt.event, 'Choose the current Mentat question event.'); if (decision.stage === 'name') { if (action.decline === true) { requireRule(Object.keys(action).every(key => ['type','event','decline'].includes(key)), 'Decline only this Mentat question.'); receipt.stage = 'declined'; log(g,`${getPlayer(g,receipt.owner).name} declined the optional Mentat question.`); } else { requireRule(Object.keys(action).every(key => ['type','event','weapon'].includes(key)), 'Name only one specific Mentat weapon.'); const weapon = stringField(action.weapon); mentatRule(() => quoteMentatReveal(receipt.hand,weapon,g.expansions)); receipt.weapon = weapon; log(g,`${getPlayer(g,receipt.owner).name} asked ${getPlayer(g,receipt.target).name} about ${weapon} with Mentat. The card shown is private and need not be played.`); // Every question uses the same target-owned response, so public timing // cannot reveal whether the named weapon was actually held. receipt.stage = 'reveal'; } } else { requireRule(Object.keys(action).every(key => ['type','event','card'].includes(key)), 'Show exactly one held Treachery Card for Mentat.'); const quote = mentatRule(() => quoteMentatReveal(receipt.hand,receipt.weapon!,g.expansions)); const card = quote.cards.find(card => card.id === action.card); requireRule(card, 'Choose a held card that answers this Mentat question.'); receipt.card = {...card}; receipt.stage = 'answered'; } if (receipt.stage === 'answered') { const {stage: _stage,hand: _hand,signature: _signature,...answer} = receipt; const observation = {...answer,weapon:receipt.weapon!,card:receipt.card!,signature:''}; observation.signature = mentatSignature(observation); (g.mentatHistory ??= []).push(observation); (g.mentatHistoryEvents ??= []).push(observation.event); log(g,`${getPlayer(g,receipt.target).name} privately showed one Treachery Card to ${getPlayer(g,receipt.owner).name}.`); } receipt.signature = mentatSignature(receipt); beginBattlePowers(g); } function beginBattlePowers(g: Game) { const b = g.battle!; if (b.mentatQuestion?.stage === 'name' || b.mentatQuestion?.stage === 'reveal') { const receipt = b.mentatQuestion; g.decision = {kind:'mentatQuestion',player:receipt.stage === 'name' ? receipt.owner : receipt.target, event:receipt.event,owner:receipt.owner,target:receipt.target,stage:receipt.stage as 'name'|'reveal',weapon:receipt.weapon}; return; } if (g.leaderSkills) { b.leaderSkillHidden ??= {}; const pending = [b.attacker, b.defender].find((id) => nativeLeaderSkill(g,id) && b.leaderSkillHidden![id] === undefined, ); if (pending) { const p = getPlayer(g, pending); const trained = nativeLeaderSkill(g,pending)!; const available = controlledLeaders(g, p).filter((l) => !l.dead && (!l.usedAt || l.usedAt === b.territory)); if (available.length === 1 && available[0].id === trained.leader && !p.hand.some((c) => c.kind === 'hero')) { b.leaderSkillHidden[pending] = true; log(g, `${p.name} moved the skilled leader behind the shield because it is their only available battle leader.`, {faction:p.faction,name:'Leader Skill'}); beginBattlePowers(g); return; } g.decision = {kind:'leaderSkillVisibility',player:pending,event:b.event!}; return; } } 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', true); 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.pendingHomeworldShipment?.player === p.ally ? g.pendingHomeworldShipment.allyPayment : 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 projectedBribes(g: Game, p: Player): BribeOptions { const available = Math.max(0, uncommittedSpice(g, p)); const blocked = bribeTimingBlock(g); const battlePromises = g.battle?.truthPromises?.some(promise => promise.player === p.id && !promise.released && !g.battle?.revealed && !g.battle?.plans[p.id]); const shipmentPromises = liveShipmentPromises(g.shipmentPromises ?? [], p.id, g.turn).length > 0; // Reuse the authoritative promise checks on an isolated resource-only copy. // No payment, receipt or live continuation is created by this projection. const maximum = !blocked && (battlePromises || shipmentPromises) ? maximumBribe(available, amount => { const probe = structuredClone(g); getPlayer(probe, p.id).spice -= amount; try { if (battlePromises) reconcileBattlePromises(probe, { actor: p.id, action: { type: 'bribe' } }); if (shipmentPromises) reconcileShipmentPromises(probe, { actor: p.id, action: { type: 'bribe' } }); return true; } catch (error) { if (error instanceof RuleError) return false; throw error; } }) : available; return { available, incoming: p.bribes, blocked, targets: g.players.filter(target => target.id !== p.id && target.id !== p.ally).map(target => { let limit = maximum; let limitation: string | null = maximum < available ? 'Some spice must remain available to fulfill your Truthtrance answer.' : null; if (!blocked && limit >= 5) { try { checkBureaucratContributors(g, target.id, [{ payer: p.id, amount: limit }]); } catch (error) { if (!(error instanceof RuleError)) throw error; limit = 4; limitation = error.message; } } return { id: target.id, maximum: limit, limitation }; }), }; } 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)) - (g.battle?.plans[p.id]?.bankerSpice ?? 0) - (g.pendingHomeworldShipment?.player === p.id ? g.pendingHomeworldShipment.cost - g.pendingHomeworldShipment.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 auctionBureaucratParties(g:Game,p:Player,amount:number,allyPayment:number,seller?:string) { return {payee:seller && seller !== p.id ? seller : byFaction(g,'emperor')?.id, shares:[{payer:p.id,amount:amount-allyPayment},...(allyPayment ? [{payer:p.ally!,amount:allyPayment}] : [])].filter(share => share.amount > 0)}; } function stampAuctionBureaucrat(g:Game,p:Player,amount:number,allyPayment:number,seller?:string) { const {payee,shares} = auctionBureaucratParties(g,p,amount,allyPayment,seller); return payee ? stampBureaucratContributors(g,'auction',payee,shares,allyPayment > 0) : {}; } const EMPEROR_PAYMENT_POLICY = 'Provisional full-price funding: Emperor covers this original allied purchase, reusing prepaid aid and adding only the missing spice. No extra gift or bank subsidy is created; original acquisition and native income continue.'; function nexusEmperorPaymentIntegrity(g: Game): void { const cursor = g.nexusEmperorPayment, frame = g.pendingNexusEmperorPayment; if (!cursor) { requireRule(!frame && g.decision?.kind !== 'nexusEmperorPayment', 'Emperor compulsory funding lost its original obligation.'); return; } requireRule(g.nexusCards?.cards && Object.keys(cursor).sort().join(',') === 'completed,current,sequence' && Number.isSafeInteger(cursor.sequence) && cursor.sequence >= 1 && Array.isArray(cursor.completed) && cursor.sequence === cursor.completed.length + Number(!!frame), 'Emperor compulsory funding lost its original occurrence history.'); const events = new Set(); for (const receipt of cursor.completed) { nexusRule(() => validateEmperorPaymentReceipt(g, receipt)); requireRule(receipt.stage !== 'pending' && !events.has(receipt.event), 'Emperor compulsory funding repeated or reopened a completed obligation.'); events.add(receipt.event); } if (!frame) { requireRule(cursor.current === null && g.decision?.kind !== 'nexusEmperorPayment', 'Emperor compulsory funding lost its pending source.'); return; } requireRule(Object.keys(frame).join(',') === 'receipt' && frame.receipt.stage === 'pending' && cursor.current === frame.receipt.event && !events.has(frame.receipt.event), 'Emperor compulsory funding lost its pending receipt.'); nexusRule(() => validateEmperorPaymentReceipt(g, frame.receipt)); const owner = frame.receipt.required[frame.receipt.passed.length]; const continuation = g.pendingTreacheryDiscard?.continuation; const decision = g.decision?.kind === 'nexusEmperorPayment' ? g.decision : continuation?.kind === 'truthtranceDiscard' ? continuation.resume.decision : null; requireRule(decision?.kind === 'nexusEmperorPayment' && decision.event === frame.receipt.event && decision.player === owner && g.active === owner, 'Emperor compulsory funding lost its original acknowledgement owner.'); } function nexusEmperorPaymentOffer(g: Game, id: string) { const receipt = g.pendingNexusEmperorPayment?.receipt; if (!receipt || g.decision?.kind !== 'nexusEmperorPayment' || g.decision.player !== id || g.nexusCards?.cards?.hands[id] !== 'emperor') return null; const blocked = nexusRule(() => emperorPaymentUseBlock(g, receipt, id)); return { event: receipt.event, canUse: !blocked, blocked, policy: EMPEROR_PAYMENT_POLICY, price: receipt.price, emperor: receipt.emperor, winner: receipt.winner }; } function beginNexusEmperorPayment(g: Game): boolean { if (!g.nexusCards?.cards) return false; const source = nexusRule(() => emperorPaymentSourceQuote(g)); if (!source) return false; const last = g.nexusEmperorPayment?.completed.at(-1); if (last?.turn === g.turn && last.auctionIndex === source.auctionIndex && last.card === source.card && last.winner === source.winner && last.price === source.price) return false; const required = nexusRule(() => requiredEmperorPaymentResponders(g)); if (!required.length) return false; const receipt = nexusRule(() => createEmperorPaymentReceipt(g, crypto.randomUUID(), required)); const cursor = g.nexusEmperorPayment ??= { sequence: 0, current: null, completed: [] }; cursor.sequence++; cursor.current = receipt.event; g.pendingNexusEmperorPayment = { receipt }; g.active = required[0]; g.decision = { kind: 'nexusEmperorPayment', player: required[0], event: receipt.event }; return true; } function decideNexusEmperorPayment(g: Game, p: Player, action: Action): void { const receipt = g.pendingNexusEmperorPayment!.receipt; requireRule(action.type === 'nexusEmperorPayment' && typeof action.use === 'boolean' && Object.keys(action).sort().join(',') === 'event,type,use' && action.event === receipt.event && receipt.required[receipt.passed.length] === p.id, 'Acknowledge only your current original allied purchase.'); requireRule(!g.truthtrance && !pendingNexusTraitors(g), 'Finish Truthtrance and private Traitor returns before the auction reaction.'); if (action.use) { const blocked = nexusRule(() => emperorPaymentUseBlock(g, receipt, p.id)); requireRule(!blocked, blocked ?? 'This card cannot fund the original purchase.'); } const passed = action.use ? receipt.passed : [...receipt.passed, p.id]; const stage = action.use ? 'used' : passed.length === receipt.required.length ? 'passed' : 'pending'; const next: EmperorPaymentReceipt = { ...receipt, passed, stage, owner: action.use ? p.id : null, signature: '' }; next.signature = emperorPaymentReceiptSignature(next); nexusRule(() => validateEmperorPaymentReceipt(g, next)); if (stage === 'pending') { g.pendingNexusEmperorPayment!.receipt = next; const owner = next.required[next.passed.length]; g.active = owner; g.decision = { kind: 'nexusEmperorPayment', player: owner, event: next.event }; return; } const source = nexusRule(() => emperorPaymentSourceQuote(g)); requireRule(source, 'The original auction funding obligation is no longer present.'); if (action.use) { g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!, p.id, g.players)); getPlayer(g, source.emperor).spice -= source.missing; const credit = g.aid[source.emperor]; g.aid[source.emperor] = { recipient: source.winner, amount: (credit?.recipient === source.winner ? credit.amount : 0) + source.missing }; g.auction!.allyPayment = source.price; log(g, `${p.name} spent Emperor Nexus Betrayal to compel the original ${source.price}-spice allied purchase. ${source.prepaid} prepaid spice is reused and Emperor supplies ${source.missing}; no extra gift or bank subsidy is created. Provisional full-price interpretation.`); } g.nexusEmperorPayment!.completed.push(next); g.nexusEmperorPayment!.current = null; g.pendingNexusEmperorPayment = null; g.active = source.winner; g.decision = null; if (action.use) settleAuction(g, false); else recoverAuctionPayment(g, getPlayer(g, source.winner)); } 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 next = quoteNormalAuctionNext({ order: g.order, eligible, active: a.active, bid: a.bid, bidder: a.bidder, passed: a.passed, }); if (next.kind !== 'bid') { if (next.kind === 'unbid') { g.deck = [...a.cards.slice(a.index), ...g.deck]; g.auction = null; finishNormalBidding(g); return; } if (beginNexusEmperorPayment(g)) return; g.decision = { kind: 'auctionPayment', player: next.player }; g.active = next.player; recoverAuctionPayment(g, getPlayer(g, next.player)); return; } a.active = next.player; g.active = a.active; } function settleAuction(g: Game, free = false, automatic = false, emperorNexus = false, printedKarama = 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]); const emperor = byFaction(g, 'emperor'); const paysEmperor = !!emperor && emperor.id !== winner.id; const incomePayment = !free && g.spiceBankerIncomePreview ? bankerIncomePayment(g, 'auction', [ {payer: winner.id, amount: a.bid - (a.allyPayment ?? 0), recipient: paysEmperor ? 'player' : 'bank'}, ...(a.allyPayment ? [{payer: winner.ally!, amount: a.allyPayment, recipient: paysEmperor ? 'player' as const : 'bank' as const}] : []), ]) : undefined; g.currentAuctionSale = { ...(g.homeworldOccupationPreview ? { occupiedEvent: crypto.randomUUID(), occupiedContributions: [{ payer: winner.id, amount: free ? 0 : a.bid - (a.allyPayment ?? 0) }, ...(!free && a.allyPayment ? [{ payer: winner.ally!, amount: a.allyPayment }] : [])] } : {}), ...(incomePayment ? {spiceBankerIncomePayment: incomePayment} : {}), winner: winner.id, amount: a.bid, free, origin: 'normal', seller: null, ...(!free ? stampAuctionBureaucrat(g,winner,a.bid,a.allyPayment ?? 0) : {}), }; if (!paysEmperor) awardBankerIncome(g, incomePayment); log( g, `${faction(winner.faction).name} won a treachery card for ${emperorNexus ? `the Emperor Nexus card after proving ${a.bid} spice` : 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 if (!offerIxianNexusReplacement(g,printedKarama)) 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, ...(g.homeworldOccupationPreview ? { occupiedBonusAvailable: (() => { const authority = quoteOccupiedBiddingAuthority(g, 'harkonnenBonus'); return !!authority.blocked || authority.occupied && authority.bonusRecipients.length > 0; })() } : {}), 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,bureaucratDiversion?:number) { if (bureaucratDiversion === undefined && quote.steps.some(step => step.kind === 'sellerCredit') && offerBureaucratPayment(g,g.currentAuctionSale?.bureaucratPayment,{kind:'auction',quote})) return; for (const step of quote.steps) { if (step.kind === 'clearIxAlly') g.pendingIxAlly = null; else { const seller = getPlayer(g, step.player); seller.spice = step.balance-(bureaucratDiversion ?? 0); log( g, `${seller.name} collected ${step.amount-(bureaucratDiversion ?? 0)} spice from the ${quote.sale.origin === 'cache' ? 'Richese cache' : 'Black Market'} sale.`, { faction: 'richese', name: 'Auction income' }, ); if (g.homeworldOccupationPreview && g.currentAuctionSale?.occupiedEvent) { const sale = g.currentAuctionSale!, received = step.amount - (bureaucratDiversion ?? 0); const source: OccupiedPercentageSource = { kind: 'richese-income', event: crypto.randomUUID(), native: seller.id, amount: received, payment: { id: sale.occupiedEvent!, binding: occupiedProducerProof(g, 'richese-income'), paid: sale.amount, contributions: sale.occupiedContributions!, recipient: seller.id, received } }; if (admitOccupiedPercentage(g, source, received, { kind: 'auction', quote: { ...quote, steps: [] } })) return; } } } const next = quote.next; if (next.kind === 'response') { g.response = {...next.response,...(next.response.kind === 'emperorIncome' && g.currentAuctionSale?.bureaucratPayment ? {bureaucratPayment:g.currentAuctionSale.bureaucratPayment,bureaucratPaymentEvent:g.currentAuctionSale.bureaucratPaymentEvent} : {})}; offerOccupiedBonus(g, g.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) offerAuctionPeek(g); } } } 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.kullPreview && kullBlocksKarama(g.kullRestrictions, g.turn, g.phase, p.id)) return 'Kull Wahad prevents your Karama activations for this phase.'; if (g.pendingKull?.stage === 'counter' && (g.pendingKull.card === card.id || g.pendingChoamWorthless?.card === card.id || g.pendingKull.worthless?.card === card.id)) return 'This physical card is reserved for the interrupted Kull transaction.'; 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) || !canUseAsKaramaRole(g, p, 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.'; if (g.battle && committedPlanElements(g.battle, p.id).some(element => element.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) => canUseAsKaramaRole(g, p, 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 []; if (g.pendingKull?.stage === 'counter') return kullCounterCards(p.hand, physicalTreacheryCards(g), g.pendingKull.card, card => { if (!canUseAsKaramaRole(g, p, card) || karamaSpendingBlock(g, p, card)) return false; try { return assertKaramaPromiseFeasibility(g, p, { kind: 'cancel', response }, card); } catch (error) { if (error instanceof RuleError) return false; throw error; } }).map(card => card.id); // 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, g.pendingKull?.resume, ].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, ) { if (g.pendingKull && controlsContainKarama(g, g.pendingKull.resume, pending)) return kullSource(g, g.pendingKull); 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, ...(currentGuildCunning(g,use.recipient) ? {guildCunning:currentGuildCunning(g,use.recipient)} : {}), 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 + Number(g.currentAuctionSale?.origin === 'normal'), ) ?? []), ]; } function ecazTreacheryIntegrity(g: Game) { if (!g.ecazTreachery) { requireRule(!g.recruits, 'Recruits requires the independent Ecaz Treachery Cards variant.'); return; } requireRule(g.ecazTreachery === true, 'Invalid Ecaz Treachery Cards module marker.'); if (g.status === 'lobby') { throw new RuleError( 'Ecaz Treachery Cards can begin only through their gated audit initializer.', ); } const physical = physicalTreacheryCards(g); for (const expected of ecazTreacheryCards()) { const matching = physical.filter((card) => card.id === expected.id); requireRule( matching.length === 1 && !!ecazTreacheryDefinition(matching[0]) && Object.keys(matching[0]).sort().join(',') === 'effect,id,kind,name', 'The Ecaz Treachery Cards inventory is missing, duplicated or malformed.', ); } requireRule( physical.filter((card) => card.id.startsWith('ecaz-')).length === 3, 'The Ecaz Treachery Cards inventory contains an unknown physical card.', ); for (const [player, plan] of Object.entries(g.battle?.plans ?? {})) { if ([plan.weapon, plan.defense].includes('ecaz-reinforcements')) { const owner = getPlayer(g, player); requireRule(plan.leader !== 'ecaz-reinforcements' && g.battle?.lateDefense?.[player] !== 'ecaz-reinforcements' && plan.weapon !== plan.defense && owner.hand.some(isReinforcements) && [plan.weapon, plan.defense].every(id => id === null || !!cardOf(owner, id)) && validBattleSlotPair(cardOf(owner, plan.weapon), cardOf(owner, plan.defense)), 'The sealed Reinforcements plan lost its physical card or legal slot.'); currentReinforcementsCost(g, owner); } requireRule(plan.leader !== HARASS_WITHDRAW_CARD && g.battle?.lateDefense?.[player] !== HARASS_WITHDRAW_CARD && !(plan.weapon === HARASS_WITHDRAW_CARD && plan.defense === HARASS_WITHDRAW_CARD), 'Harass & Withdraw occupies exactly one ordinary Battle Plan card slot.'); if ([plan.weapon, plan.defense].includes(HARASS_WITHDRAW_CARD)) { const owner = getPlayer(g, player); requireRule(owner.hand.some(isHarassWithdraw), 'The sealed Harass & Withdraw card must remain in its owner’s hand.'); requireRule([plan.weapon, plan.defense].every(id => id === null || !!cardOf(owner, id)) && validBattleSlotPair(cardOf(owner, plan.weapon), cardOf(owner, plan.defense)), 'The sealed Harass & Withdraw plan has an invalid weapon and defense pair.'); currentHarassWithdrawQuote(g, owner, plan); } } if (g.recruits) { requireRule( Object.keys(g.recruits).sort().join(',') === 'card,player,turn' && g.recruits.card === RECRUITS_CARD_ID && g.recruits.turn === g.turn && g.players.some((player) => player.id === g.recruits!.player), 'The Recruits effect has a malformed or expired receipt.', ); requireRule( g.players.every( (player) => Number.isSafeInteger(player.revived) && player.revived >= 0 && Number.isSafeInteger(player.freeForcesRevived) && player.freeForcesRevived! >= 0 && player.freeForcesRevived! <= player.revived, ), 'The active Recruits effect has inconsistent normal revival ledgers.', ); } } function recruitsModeSupported(g: Game) { return ( !g.homeworlds && !g.nexusCards && !g.leaderSkills && !g.techTokens && !g.strongholdCards && !g.discoveryEnabled ); } function combinedCardBattleBlock(g: Game): string | null { const battle = g.battle; if (!battle || battle.ecazOccupy) return null; const ecaz = byFaction(g, 'ecaz'); const ally = ecaz && g.players.find(player => player.id === ecaz.ally && player.ally === ecaz.id); if (ecaz && ally && (battle.attacker === ecaz.id || battle.defender === ecaz.id || battle.attacker === ally.id || battle.defender === ally.id) && fighterCount(ecaz, battle.territory) > 0 && fighterCount(ally, battle.territory) > 0) return 'Combined card armies require the supported Advanced Occupy battle profile.'; return null; } function reinforcementsModeSupported(g: Game) { const pairedEcaz = g.expansions.length === 1 && g.expansions[0] === 'ecaz'; return !!g.ecazTreachery && recruitsModeSupported(g) && !g.sandtrout && ((g.ecazOccupyPreview === true && ecazOccupyCompositionSupported(g)) || ((g.expansions.length === 0 || pairedEcaz) && g.players.every(player => { const expansion = faction(player.faction).expansion; return expansion === 'base' || (pairedEcaz && expansion === 'ecaz'); }))); } function currentReinforcementsCost(g: Game, p: Player) { requireRule(reinforcementsModeSupported(g), 'Reinforcements requires the independent Ecaz card variant in a supported native faction composition.'); const combinedBlock = combinedCardBattleBlock(g); requireRule(!combinedBlock, combinedBlock ?? 'This combined card battle is unavailable.'); try { return quoteReinforcements(p.reserves - (p.elites?.reserves ?? 0), p.elites?.reserves ?? 0); } catch (error) { if (error instanceof ReinforcementsError) throw new RuleError(error.message); throw error; } } function reinforcementsPreview(g: Game, p: Player) { const battle = g.battle; if (!battle || ![battle.attacker, battle.defender].includes(p.id) || !p.hand.some(isReinforcements)) return null; const normal = Math.min(3, Math.max(0, p.reserves - (p.elites?.reserves ?? 0))); const elite = Math.min(3 - normal, Math.max(0, p.elites?.reserves ?? 0)); const combinedBlock = combinedCardBattleBlock(g); let blocked: string | null = null; if (!reinforcementsModeSupported(g)) blocked = 'Reinforcements requires the independent Ecaz card variant in a supported native faction composition.'; else if (g.status !== 'playing' || g.phase !== 6 || battle.revealed || battle.plans[p.id]) blocked = 'Choose Reinforcements before sealing your Battle Plan.'; else if (combinedBlock) blocked = combinedBlock; else if (normal + elite < 3) blocked = 'Reinforcements needs three physical forces in your reserves.'; return { blocked, normal, elite }; } function recruitsInteractionPending(g: Game) { return !!( g.response || g.decision || g.truthtrance || g.phaseOpening || g.pendingRevival || g.pendingKarama || g.pendingTreacheryDiscard || g.pendingNullentropy || g.pendingExchange || g.pendingRicheseGift || g.pendingRichesePurchaseIncome || g.pendingAmbassador || g.pendingCapture || g.battle ); } function recruitsPlayBlock(g: Game, owner: Player, card: Card): string | null { if (!g.ecazTreachery || !ecazTreacheryDefinition(card) || card.id !== RECRUITS_CARD_ID) return 'Choose the physical Recruits card from the enabled Ecaz Treachery Cards variant.'; if (!recruitsModeSupported(g)) return 'This Recruits prototype currently requires a faction-only game without other optional modules.'; if (g.status !== 'playing' || g.phase !== 4) return 'Play Recruits during Revival.'; if (g.recruits?.turn === g.turn) return 'Recruits is already active this turn; a recovered copy cannot be played again.'; const reserved = transferCardBlock(g, owner, card); if (reserved) return reserved; if (recruitsInteractionPending(g)) return 'Finish the current transaction or priority window before playing Recruits.'; for (const player of g.players) { if ( player.freeForcesRevived !== undefined && (!Number.isSafeInteger(player.freeForcesRevived) || player.freeForcesRevived < 0 || player.freeForcesRevived > player.revived) ) return 'Normal force revival history is inconsistent; Recruits cannot safely reprice it.'; if (player.revived > 0 && player.freeForcesRevived === undefined) return 'Earlier normal force revival history is incomplete; Recruits cannot safely reprice it.'; } return null; } function recruitsPreview(g: Game, viewer: Player): RecruitsPreview | null { if (!g.ecazTreachery) return null; ecazTreacheryIntegrity(g); const held = viewer.hand.find((card) => card.id === RECRUITS_CARD_ID); const play = held ? { type: 'card' as const, card: RECRUITS_CARD_ID, blocked: recruitsPlayBlock(g, viewer, held) } : null; const active = g.recruits?.turn === g.turn; return { active, ...(active ? { playedTurn: g.recruits!.turn, playedBy: g.recruits!.player } : {}), play, rates: g.order.map((player) => { const target = getPlayer(g, player); const allowance = normalRevivalAllowance(g, target); return { player, freeRate: allowance.freeRate, limit: allowance.limit }; }), grantRevivalBlocked: active ? 'Grant Fremen ally revival before Recruits; changing the free rate after activation awaits a rules ruling.' : null, }; } /** 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; } } /** The original native suffix, shared by native Karama and Nexus closure. * It consumes the accepted attempt directly; no declaration is replayed. */ function finishIxianNativeAttempt(g: Game, response: ResponseWindow, canceled: boolean): void { if (response.kind === 'ixAuction') { const authority = g.homeworldOccupationPreview ? quoteOccupiedBiddingAuthority(g, 'ixAuction') : null; requireRule(!authority?.blocked, authority?.blocked ?? 'The original Ixian pool is blocked.'); const request = currentIxAuctionDrawQuote(g, response, canceled && !authority?.occupied)!; const pending = g.ixAuction!; if (pending.authority) requireRule(pending.authority === JSON.stringify(quoteStableHomeworldOccupation(g, 'ix')), 'The original undrawn Ixian source authority changed.'); const cards: Card[] = []; for (let i = 0; i < request.drawCount; i++) { const card = draw(g); if (card) cards.push(card); } if ((!canceled || authority?.occupied) && cards.length > 1) { pending.cards = cards; if (authority) pending.authority = JSON.stringify(quoteStableHomeworldOccupation(g, 'ix')); g.decision = { kind: 'ixAuction', player: authority?.controller ?? response.owner }; } else { g.ixAuction = null; setAuction(g, cards); } return; } requireRule(response.kind === 'ixTechnology', 'Only an original Ixian native attempt may resume.'); const cancellation = auctionTechnologyQuote(() => quoteIxTechnologyCancellation(g, response))!; if (canceled) { g.pendingIxTechnology = cancellation.pendingIxTechnology; g.auction!.peekKnown = cancellation.peek.peekKnown; g.response = cancellation.peek.response; openOccupiedAuctionInspection(g); 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 (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.`); } g.pendingIxTechnology = null; offerAuctionPeek(g); } function ixSubstitutionCancellationQuote( g: Game, response: ResponseWindow, spending?: { player: string; card: string }, ) { if (response.kind !== 'ixSubstitution') return null; try { homeworldSubstitutionIntegrity(g); homeworldDefenseIntegrity(g); homeworldShipmentIntegrity(g); const quote = quoteIxSubstitutionCancellation( { ...g, combatLocations: combatLocations(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 (use.response.kind === 'nexusPrescience') validateNexusInspectionResponse(g, use.response); if (use.response.kind === 'nexusAdvisorFlip') validateNexusAdvisorResponse(g, use.response); if (use.response.kind === 'nexusSardaukar') validateNexusSardaukarResponse(g, use.response); if (use.response.kind === 'nexusFremenCunning') currentFremenCunningResponse(g, use.response); if (use.response.kind === 'nexusEcazBetrayal') currentEcazBetrayalResponse(g, use.response); if (isCombatResponseKind(use.response.kind)) currentCombatResponseQuote(g, { kind: 'response', response: { kind: use.response.kind, owner: use.response.owner }, canceled: true, }); if (use.response.kind === 'richeseAuction') validateRicheseCacheCancellation(g, use.response); 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, ); if (use.response.kind === 'nexusGuildCunning') validateGuildCunningResponse(g,use.response); 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 && shipmentAvailable(g,recipient) && !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 'nexusPrescience': if (!b?.nexusInspection) return false; b.nexusInspection = cancelNexusInspection(battleInspectionContext(projected), b.nexusInspection); delete b.preparation; break; case 'nexusSardaukar': if (!b || !currentNexusSardaukar(projected)) return false; finishNexusSardaukar(projected,true); 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; } type KullFrame = NonNullable; // Independent printed descriptors, assembled once rather than trusting a saved // hand to define its own identity or rebuilding the physical deck per quote. const kullCanonicalCards: Readonly> = Object.fromEntries(treacheryDeck(['choam', 'ix']).map(card => [card.id, card])); function canonicalKullCard(card: Card): boolean { const expected = Object.hasOwn(kullCanonicalCards, card.id) ? kullCanonicalCards[card.id] : undefined; return !!expected && card.name === expected.name && card.kind === expected.kind && card.effect === expected.effect; } function kullCostRetainsAuctionPayment(g: Game, p: Player, card: Card, physical: readonly Card[]): boolean { return !g.auction || g.auction.bidder !== p.id || auctionSpicePaymentFunded(g, p) || p.hand.some(held => held.id !== card.id && canonicalKullCard(held) && physical.reduce((count, candidate) => count + Number(candidate.id === held.id), 0) === 1 && !karamaSpendingBlock(g, p, held)); } function kullCostPromisesAllow(g: Game, p: Player, card: Card, fuelSource: 'printed' | 'nexus'): boolean { const source = g.pendingKull ? kullSource(g, g.pendingKull) : g; const battlePromises = source.battle && !source.battle.revealed && !source.battle.plans[p.id] ? (source.battle.truthPromises ?? []).filter(promise => promise.player === p.id && !promise.released) : []; const shipmentPromises = liveShipmentPromises(source.shipmentPromises ?? [], p.id, source.turn); if (!battlePromises.length && !shipmentPromises.length) return true; for (const success of [true, false]) { const projected = structuredClone(source); const owner = getPlayer(projected, p.id); // Acceptance spends Cunning in either outcome. Do not let feasibility // reuse its physical Nexus as a future preparation or shipment resource. if (fuelSource === 'nexus' && projected.nexusCards?.cards?.hands[p.id] === 'choam') projected.nexusCards.cards = nexusRule(() => discardNexusCard(projected.nexusCards!.cards!, p.id, projected.players)); if (success) { owner.hand = owner.hand.filter(held => held.id !== card.id); if (g.pendingKull) projected.kullRestrictions = [ ...activeKullRestrictions(projected.kullRestrictions, projected.turn, projected.phase), { player: g.pendingKull.owner, turn: projected.turn, phase: projected.phase }, ]; } projected.response = null; projected.decision = null; projected.pendingKarama = null; if ((battlePromises.length && !findReachableBattlePlan(projected, owner, { promises: battlePromises })) || (shipmentPromises.length && !findShipmentCompletion(projected, owner, shipmentPromises))) return false; } return true; } function kullCostCards(g: Game, p: Player, source: 'printed' | 'nexus' = 'printed'): Card[] { const original = g.pendingKull ? kullSource(g, g.pendingKull) : g; if (source === 'nexus' && (!g.nexusKullPreview || p.faction !== 'choam' || p.ally || (g.pendingKull?.selection?.source !== 'nexus' && g.nexusCards?.cards?.hands[p.id] !== 'choam'))) return []; const reserved = [ g.pendingKull?.card, g.pendingKull?.worthless?.card, ].filter((id): id is string => typeof id === 'string'); const physical = physicalTreacheryCards(g); const candidates = source === 'nexus' ? kullNexusCostCards(p.hand, physical, reserved) : kullNativeCostCards(cashInCards(g, p), physical).filter(card => !reserved.includes(card.id)); return candidates.filter(card => canonicalKullCard(card) && kullCostRetainsAuctionPayment(original, p, card, physical) && !choamWorthlessBlocked(original, card.id) && !giftReserved(original, p.id, card.id) && !transferCardBlock(original, p, card) && !retentionReservesCard(original, p.id, card.id) && original.battle?.lateDefense?.[p.id] !== card.id && ![original.battle?.plans[p.id]?.weapon, original.battle?.plans[p.id]?.defense, original.battle?.plans[p.id]?.leader].includes(card.id) && !(original.battle && committedPlanElements(original.battle, p.id).some(element => element.value === card.id)) && kullCostPromisesAllow(g, p, card, source)); } function kullPlays(g: Game, p: Player) { return (['printed', 'nexus'] as const).flatMap(source => kullCostCards(g, p, source).map(card => ({ source, effect: 'kull' as const, card, event: g.pendingKull!.event, blocked: null as string | null }))); } function kullSource(g: Game, frame: KullFrame): Game { return { ...g, ...frame.resume, pendingKull: null, pendingChoamWorthless: frame.worthless }; } function kullSignature(g: Game, frame: KullFrame): string { const source = kullSource(g, frame); return JSON.stringify({ event: frame.event, player: frame.player, owner: frame.owner, card: frame.card, turn: frame.turn, phase: frame.phase, form: frame.form, intent: frame.intent, selection: frame.selection, controls: nullentropyParentSignature(source, frame.resume, frame.event), status: source.status, advanced: source.advanced, active: source.active, order: source.order, ready: source.ready, hajr: source.hajr, choamMarket: source.choamMarket, auction: source.auction, battle: source.battle, movementRemaining: source.movementRemaining, karamaShipping: source.karamaShipping, spiceWindow: source.spiceWindow, spiceResolution: source.spiceResolution, spiceSequence: source.spiceSequence, stormResolution: source.stormResolution, worthless: frame.worthless, }); } /** Only the verified pre-source printed profile may bind its old held cost. */ function migratePrintedKullCounter(state: Game): Game { const frame = state.pendingKull; if (!frame || frame.stage !== 'counter' || frame.selection !== undefined || state.nexusKullPreview || state.nexusCards) return state; const declaration = state.pendingChoamWorthless; requireRule(state.kullPreview === true && declaration?.effect === 'kull' && !declaration.nexusEvent && declaration.owner === frame.player && frame.signature === kullSignature(state, frame), 'The legacy printed Kull counter has lost its original declaration or signature.'); const migrated = structuredClone(state); migrated.pendingKull!.selection = { source: 'printed', card: declaration.card }; migrated.pendingKull!.signature = kullSignature(migrated, migrated.pendingKull!); // All original stamp, intent, custody, response and quote checks still apply. kullIntegrity(migrated); return migrated; } function kullIntegrity(g: Game): void { try { requireRule(g.nexusKullPreview === undefined || g.nexusKullPreview === true && g.kullPreview === true && !!g.nexusCards?.cards && g.expansions.length === 2 && g.expansions.includes('choam') && g.expansions.includes('ix') && g.players.some(p => p.faction === 'choam') && g.players.every(p => p.faction === 'choam' || faction(p.faction).expansion === 'base') && !g.homeworlds && !g.leaderSkills && !g.discoveryEnabled && !g.discoveries && !g.discoveryStash && !g.greatMaker && !g.sandtrout && !g.techTokens && !g.strongholdCards && !g.ecazTreachery && !g.semutaPreview && !g.richeseBetrayalPreview && !g.richeseBetrayal && !g.pendingRicheseBetrayal, 'The Nexus Kull profile has lost its supported native inventory and modules.'); if (g.kullRestrictions !== undefined) validateKullPhaseRestrictions(g, g.kullRestrictions); const frame = g.pendingKull; if (!frame) { const conversion = g.pendingKarama?.use; requireRule(g.pendingChoamWorthless?.effect !== 'kull' && !(g.response?.kind === 'choamWorthless' && g.response.intent === 'Kull Wahad') && !(conversion?.kind === 'cancel' && conversion.response.kind === 'choamWorthless' && conversion.response.intent === 'Kull Wahad'), 'The saved Kull declaration has lost its interrupted Karama frame.'); return; } requireRule(g.kullPreview === true, 'This table has not opted into the Kull preview.'); validateKullAttemptStamp(g, frame, physicalTreacheryCards(g)); requireRule(Number.isSafeInteger(g.kullSequence) && g.kullSequence! > 0 && frame.event === JSON.stringify(['kull', g.turn, g.phase, g.kullSequence, frame.player, frame.owner]), 'The Kull attempt has lost its server-created opportunity identity.'); requireRule(frame.signature === kullSignature(g, frame), 'The saved Kull attempt no longer matches its original controls and opportunity.'); const source = kullSource(g, frame), owner = getPlayer(source, frame.owner); if (frame.worthless) { const parentOwner = getPlayer(source, frame.worthless.owner); requireRule(kullNexusCostCards(parentOwner.hand, physicalTreacheryCards(g)) .some(card => card.id === frame.worthless!.card && canonicalKullCard(card)), 'The interrupted CHOAM declaration must retain its exact unique physical cost.'); } const card = owner.hand.find(card => card.id === frame.card); requireRule(card, 'The original attempted Karama must remain in its owner’s hand.'); requireRule(frame.form === (card.kind === 'worthless' ? 'substitution' : 'printed'), 'The Kull attempt no longer matches its original physical card form.'); requireRule(!karamaSpendingBlock(source, owner, card), 'The original Karama activation is no longer legal.'); if (frame.intent?.kind === 'ordinary') { validateKaramaUse(source, owner, frame.intent.use, card); requireRule(assertKaramaPromiseFeasibility(source, owner, frame.intent.use, card), 'The interrupted Karama has no proven Truthtrance continuation.'); } else { requireRule(frame.intent?.kind === 'special', 'The Kull attempt has no validated intent.'); const prepared = prepareSpecialKaramaIntent(source, frame.owner, specialKaramaAction(frame.intent.use)); requireRule(JSON.stringify(prepared) === JSON.stringify(frame.intent.use), 'The saved special Karama selection has changed.'); } if (frame.stage === 'offer') { requireRule(frame.selection === undefined, 'An undeclared Kull offer cannot reserve a selected source.'); requireRule(JSON.stringify(g.response) === JSON.stringify(frame.resume.response) && JSON.stringify(g.decision) === JSON.stringify(frame.resume.decision) && JSON.stringify(g.pendingKarama ?? null) === JSON.stringify(frame.resume.pendingKarama ?? null) && JSON.stringify(g.pendingChoamWorthless ?? null) === JSON.stringify(frame.worthless ?? null), 'The Kull offer no longer owns its original controls.'); } else { const declaration = g.pendingChoamWorthless; const response = g.pendingKarama?.use.kind === 'cancel' ? g.pendingKarama.use.response : g.response; requireRule(frame.selection && ['printed','nexus'].includes(frame.selection.source) && declaration?.effect === 'kull' && declaration.owner === frame.player && declaration.card === frame.selection.card && declaration.nexusEvent === frame.selection.nexusEvent && (frame.selection.source === 'nexus' ? !!declaration.nexusEvent : !declaration.nexusEvent) && declaration.target === frame.owner && response?.kind === 'choamWorthless' && response.owner === frame.player && response.intent === 'Kull Wahad' && response.recipient === frame.owner && kullCostCards(g, getPlayer(g, frame.player), frame.selection.source).some(card => card.id === declaration.card), 'The Kull counter no longer matches its held cost and original attempted player.'); currentChoamWorthlessCancellationQuote(g, response); } } catch (error) { if (error instanceof RuleError) throw error; throw new RuleError(error instanceof Error ? error.message : 'Invalid saved Kull transaction.'); } } function beginKullAttempt(g: Game, p: Player, card: Card, intent: KullFrame['intent'], promiseProven = true): boolean { if (!g.kullPreview || g.pendingKull) return false; const choam = byFaction(g, 'choam'); if (!choam || choam.id === p.id) return false; requireRule(promiseProven, 'Kull preview cannot interrupt this unproven Truthtrance cancellation continuation.'); // This composition is user-deferred. The guard is private and independent // of CHOAM's hidden hand; never accept a transaction needing a made-up remedy. requireRule(!g.auction || g.auction.bidder !== p.id || g.auction.bid <= p.spice + (g.auction.allyPayment ?? 0), 'Kull preview defers Karama activation while your winning overbid cannot be paid in spice.'); const resume: KullFrame['resume'] = { response: g.response, decision: g.decision, pendingKarama: g.pendingKarama ?? null, phaseOpening: g.phaseOpening, }; if (intent.kind === 'ordinary' && intent.use.kind === 'auctionPayment') resume.decision = { kind: 'auctionPayment', player: p.id }; const frame: KullFrame = { event: JSON.stringify(['kull', g.turn, g.phase, (g.kullSequence ?? 0) + 1, choam.id, p.id]), player: choam.id, owner: p.id, card: card.id, turn: g.turn, phase: g.phase, form: card.kind === 'worthless' ? 'substitution' : 'printed', stage: 'offer', intent: structuredClone(intent), resume: structuredClone(resume), worthless: structuredClone(g.pendingChoamWorthless ?? null), signature: '', }; // Payment dispatch removed this control before entering its native branch. g.decision = resume.decision; frame.signature = kullSignature(g, frame); g.kullSequence = (g.kullSequence ?? 0) + 1; g.pendingKull = frame; log(g, `${p.name} attempted a Karama activation. CHOAM may react with Kull Wahad.`); return true; } function resumeKullAttempt(g: Game, prevented: boolean): void { const frame = g.pendingKull!; Object.assign(g, frame.resume); g.pendingChoamWorthless = frame.worthless; // Retire ownership before executing. Neither resumed execution nor a saved // retry can reopen this exact opportunity or consume its physical card twice. g.pendingKull = null; if (prevented) return; const p = getPlayer(g, frame.owner); if (frame.intent.kind === 'special') executeSpecialKaramaIntent(g, frame.intent.use); else { if (frame.intent.use.kind === 'auctionPayment') g.decision = null; spendKarama(g, p, p.hand.find(card => card.id === frame.card)!, frame.intent.use, true); } } function decideKull(g: Game, p: Player, action: Action): void { const frame = g.pendingKull!; requireRule(action.type === 'kullDecision' && action.event === frame.event && p.id === frame.player && Object.keys(action).every(key => ['type','event','decline','source','card'].includes(key)), 'Choose the current Kull opportunity as CHOAM.'); if (action.decline === true) { requireRule(action.card === undefined && action.source === undefined, 'Decline or choose one Kull source and card, not both.'); resumeKullAttempt(g, false); return; } requireRule(action.decline === undefined && (action.source === 'printed' || action.source === 'nexus') && typeof action.card === 'string' && kullCostCards(g, p, action.source).some(card => card.id === action.card), 'Choose your exact eligible held Kull source and card.'); const source = action.source; const card = p.hand.find(card => card.id === action.card)!; const nexusEvent = source === 'nexus' ? JSON.stringify(['nexusChoam', g.turn, g.phase, p.id, card.id, 'kull']) : undefined; frame.selection = { source, card: card.id, ...(nexusEvent ? { nexusEvent } : {}) }; frame.signature = kullSignature(g, frame); frame.stage = 'counter'; g.decision = null; g.pendingKarama = null; g.phaseOpening = null; g.pendingChoamWorthless = { owner: p.id, card: action.card, effect: 'kull', target: frame.owner, revival: false, ...(nexusEvent ? { nexusEvent } : {}), }; g.response = { kind: 'choamWorthless', owner: p.id, recipient: frame.owner, intent: 'Kull Wahad', passed: [] }; if (source === 'nexus') recordNexusChoam(g, p, card, 'kull'); log(g, `${p.name} declared Kull Wahad. Only a different Karama can prevent it.`); } function spendKarama(g: Game, p: Player, card: Card, use: KaramaUse, kullOffered = false) { const blocked = karamaSpendingBlock(g, p, card); requireRule(!blocked, blocked ?? 'This card cannot be spent as Karama.'); validateKaramaUse(g, p, use, card); const promiseProven = assertKaramaPromiseFeasibility(g, p, use, card); requireRule(!g.pendingKull || promiseProven, 'Kull preview cannot accept this unproven Truthtrance counter continuation.'); if (!kullOffered && beginKullAttempt(g, p, card, { kind: 'ordinary', use }, promiseProven)) return; const used = discard(g, p, card.id); if (g.semutaPreview && card.effect === 'karama' && use.kind === 'cancel' && (use.response.kind === 'choamCharity' || use.response.kind === 'choamInflation' || use.response.kind === 'bgCharity' || semutaBattleKaramaResponse(g, use.response) || semutaChoamKaramaResponse(g, use.response) || semutaEmperorShareKaramaResponse(g, use.response) || semutaIncomeKaramaResponse(g, use.response))) { const continuation: KaramaResponseDiscardContinuation = { kind: use.response.kind === 'choamCharity' ? 'karamaCharityDiscard' : use.response.kind === 'choamInflation' ? 'karamaInflationDiscard' : use.response.kind === 'bgCharity' ? 'karamaBgCharityDiscard' : use.response.kind === 'choamWorthless' ? 'karamaChoamWorthlessDiscard' : use.response.kind === 'emperorGift' || use.response.kind === 'emperorRevival' ? 'karamaEmperorShareDiscard' : use.response.kind === 'guildIncome' || use.response.kind === 'emperorIncome' || use.response.kind === 'richesePurchaseIncome' ? 'karamaIncomeDiscard' : 'karamaBattleDiscard', player: p.id, card: used.id, use, resume: { response: g.response, decision: g.decision, pendingKarama: g.pendingKarama, phaseOpening: g.phaseOpening }, stateSignature: '', }; const entries = [{ card: used, discardedBy: p.id, publicFace: true }]; if (semutaOfferSupported(g, continuation, entries)) { g.response = null; continuation.stateSignature = karamaResponseDiscardSignature(g, continuation); stageTreacheryDiscard(g, `karama:${use.response.kind}`, entries, continuation); return; } } if (g.semutaPreview && card.effect === 'karama' && (use.kind === 'purchase' || use.kind === 'auctionPayment')) { const continuation: KaramaAuctionDiscardContinuation = { kind: use.kind === 'purchase' ? 'karamaPurchaseDiscard' : 'karamaPaymentDiscard', player: p.id, card: used.id, resume: { response: g.response, decision: g.decision, pendingKarama: g.pendingKarama, phaseOpening: g.phaseOpening }, stateSignature: '', }; const entries = [{ card: used, discardedBy: p.id, publicFace: true }]; if (semutaOfferSupported(g, continuation, entries)) { continuation.stateSignature = karamaAuctionDiscardSignature(g, continuation); stageTreacheryDiscard(g, `karama:${use.kind}`, entries, continuation); return; } } 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, card); } function completeKarama(g: Game, p: Player, use: KaramaUse, card?: Card) { validateKaramaUse(g, p, use); const played = card?.effect === 'truthtrance' ? 'Truthtrance as Karama' : 'Karama'; if (use.kind === 'cancel') { g.response = use.response; finishResponse(g, true); log(g, `${p.name} used ${played} 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 ${played} 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, false, false, card?.effect === 'karama'); } } 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 harkonnenInspectionSignature( inspection: Omit, 'signature'>, ) { return JSON.stringify(['harkonnenExchangeInspection', inspection.owner, inspection.target, inspection.turn, inspection.phase, inspection.kind, inspection.cards.map(card => [card.id, card.name, card.kind, card.effect ?? null])]); } function recordHarkonnenInspection(g: Game, owner: string, target: string, kind: 'draw' | 'return', cards: Card[]) { const inspection = { owner, target, turn: g.turn, phase: 3 as const, kind, cards: structuredClone(cards) }; g.harkonnenExchangeInspection = { ...inspection, signature: harkonnenInspectionSignature(inspection) }; } function projectedHarkonnenInspection(g: Game, owner: string) { const inspection = g.harkonnenExchangeInspection; if (inspection?.owner !== owner) return null; return { owner: inspection.owner, target: inspection.target, turn: inspection.turn, phase: inspection.phase, kind: inspection.kind, cards: inspection.cards }; } function harkonnenExchangeIntegrity(g: Game) { const inspection = g.harkonnenExchangeInspection; const physical = inspection || g.pendingExchange ? physicalTreacheryCards(g) : []; if (inspection !== undefined) { requireRule( inspection && typeof inspection === 'object' && Object.keys(inspection).sort().join(',') === 'cards,kind,owner,phase,signature,target,turn' && g.advanced && g.players.some(p => p.id === inspection.owner && p.faction === 'harkonnen' && p.specialKaramaUsed === true) && g.players.some(p => p.id === inspection.target && p.id !== inspection.owner) && Number.isSafeInteger(inspection.turn) && inspection.turn >= 1 && inspection.turn <= g.turn && inspection.phase === 3 && ['draw', 'return'].includes(inspection.kind) && Array.isArray(inspection.cards) && inspection.cards.length >= 1 && inspection.cards.length <= 4 && inspection.cards.every(card => card && typeof card === 'object' && Object.keys(card).every(key => ['id', 'name', 'kind', 'effect'].includes(key)) && typeof card.id === 'string' && typeof card.name === 'string' && physical.filter(current => current.id === card.id && current.name === card.name && current.kind === card.kind && current.effect === card.effect).length === 1) && new Set(inspection.cards.map(card => card.id)).size === inspection.cards.length && inspection.signature === harkonnenInspectionSignature(inspection), 'The private Harkonnen exchange inspection is malformed.', ); } const exchanges = homeworldSavedDecisions(g).filter(decision => decision.kind === 'handExchange'); const pending = g.pendingExchange; if (!pending) { requireRule(!exchanges.length, 'The Harkonnen exchange has lost its suspended continuation.'); return; } requireRule( g.status === 'playing' && g.advanced && g.phase === 3 && Object.keys(pending).sort().join(',') === 'decision,response' && exchanges.length === 1 && pending.decision?.kind !== 'handExchange' && (pending.decision === null || (pending.decision && typeof pending.decision.kind === 'string' && g.players.some(p => p.id === pending.decision!.player))) && (pending.response === null || (pending.response && typeof pending.response.kind === 'string' && g.players.some(p => p.id === pending.response!.owner) && Array.isArray(pending.response.passed) && new Set(pending.response.passed).size === pending.response.passed.length && pending.response.passed.every(id => g.players.some(p => p.id === id)))), 'The saved Harkonnen exchange has an invalid Bidding continuation.', ); const decision = exchanges[0]; const owner = g.players.find(p => p.id === decision.player); const target = g.players.find(p => p.id === decision.target); requireRule( Object.keys(decision).sort().join(',') === 'count,kind,player,target' && owner?.faction === 'harkonnen' && owner.specialKaramaUsed === true && target && target.id !== owner.id && Number.isSafeInteger(decision.count) && decision.count >= 1 && decision.count <= 4 && owner.hand.length >= decision.count && owner.hand.length - decision.count <= handLimit(owner) && target.hand.length + decision.count <= handLimit(target), 'The saved Harkonnen exchange has an invalid owner, target or return count.', ); requireRule( [...owner.hand, ...target.hand].every(card => typeof card.id === 'string' && physical.filter(current => current.id === card.id).length === 1), 'The Harkonnen exchange requires distinct physical cards in their original custody.', ); if (inspection) requireRule( inspection.owner === owner.id && inspection.target === target.id && inspection.turn === g.turn && inspection.cards.length === decision.count, 'The pending Harkonnen return differs from its private inspection.', ); } function finishHarkonnenExchange( g: Game, decision: Extract, returnCards: unknown, automatic = false, ) { const owner = getPlayer(g, decision.player), target = getPlayer(g, decision.target); requireRule( Array.isArray(returnCards) && returnCards.length === decision.count && new Set(returnCards).size === decision.count && returnCards.every(id => typeof id === 'string' && owner.hand.some(card => card.id === id)), 'Return exactly the required number of distinct cards from your hand.', ); const returned = owner.hand.filter(card => returnCards.includes(card.id)); if (automatic && !g.harkonnenExchangeInspection) { // An old save records no original draw. Its forced return is still known exactly. recordHarkonnenInspection(g, owner.id, target.id, 'return', returned); } owner.hand = owner.hand.filter(card => !returnCards.includes(card.id)); target.hand.push(...returned); requireRule(owner.hand.length <= handLimit(owner) && 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, `${owner.name} returned ${decision.count} cards to ${target.name}${automatic ? ' automatically; every held card was required' : ''}.`, { faction: 'harkonnen', name: 'Harkonnen exchange' }); } function finishAutomaticDecision(g: Game): boolean { if (g.biddingEnd && biddingEndQuiet(g)) return finishBiddingEnd(g); if (advanceHomeworldReveal(g)) return true; const decision = g.decision; if (decision?.kind === 'handExchange' && !g.response && !g.truthtrance && !g.phaseOpening && !g.pendingNullentropy && !g.pendingRicheseGift && !g.pendingTreacheryDiscard && getPlayer(g, decision.player).hand.length === decision.count) { harkonnenExchangeIntegrity(g); finishHarkonnenExchange(g, decision, getPlayer(g, decision.player).hand.map(card => card.id), true); return true; } if (homeworldShipmentAutomatic(g)) { const shipment = g.pendingHomeworldShipment!; g.decision = null; commitHomeworldShipment(g, shipment); return true; } 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)) && !emperorNexusUniformAuctionPayment(g) ) { 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, temporaryAlliedShipment = false, ) { requireRule( t !== MOBILE_STRONGHOLD || g.mobileStronghold?.location, 'The mobile stronghold has not been placed.', ); requireRule( validGameLocation(g, 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, temporaryAlliedShipment), ); 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(g: Game, p: Player, action: Action, maxOrigins = 1) { 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; const origins = new Set(); 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; origins.add(origin); 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( validGameLocation(g, src.territory, src.sector), 'Invalid source sector.', ); const n = integer(value, 0, p.forces[key] ?? 0, 'Forces'); if (!n) continue; origins.add(src.territory); requireRule(origins.size <= maxOrigins, maxOrigins === 1 ? 'A force group must come from one territory.' : 'Planetologist gathers forces from exactly two territories.'); 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!, origins: [...origins], 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 cunning = currentGuildCunning(g,p.id); if (cunning) return p.moved + (cunning.stage === 'extraMove' ? nexusRule(() => nexusGuildCunningMoves(cunning.receipt,g.hajr.includes(p.id),cunning.movesAfter)) : 0); 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 emperorHomeworldMoveBlock(g: Game, p: Player): string | null { if (!g.homeworlds?.custody || !g.advanced || p.faction !== 'emperor') return 'Moving between Kaitain and Salusa requires the Advanced Emperor Homeworlds.'; if ( g.status !== 'playing' || g.phase !== 5 || g.active !== p.id || p.moved >= movesAllowed(g, p) ) return 'Use an available movement during your own Shipment and Movement turn.'; if ( g.truthtrance || g.response || g.decision || g.phaseOpening || g.pendingNullentropy || g.pendingTreacheryDiscard || g.pendingShipment || g.battle ) return 'Finish the current interaction before moving between Homeworlds.'; if (g.ornithopter) return 'Homeworld transfers during an active Ornithopter card await their movement-group integration.'; const richese = byFaction(g, 'richese'); if ( richese && !noFieldAllyOfferBlock(g, richese) && !( g.richeseAllyOpportunity?.turn === g.turn && g.richeseAllyOpportunity.recipient === p.id ) ) return 'Wait for Richese to offer or pass its allied shipment opportunity.'; if (!p.shipped) { try { checkShipmentPromises(g, p, null); } catch (error) { if (error instanceof RuleError) return error.message; throw error; } } return null; } function emperorHomeworldMoveEvent(g: Game, p: Player) { return JSON.stringify([ g.turn, g.phase, g.active, p.moved, p.shipped, homeworldContext(g), g.homeworlds?.custody, ]); } function ornithopterBlock( g: Game, p: Player, ): 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 (currentGuildCunning(g,p.id)) return 'Guild Cunning combined with the physical Ornithopter card awaits its timing ruling.'; if (p.moved || g.hajr.includes(p.id)) return 'Ornithopter combined with prior moves or Hajr awaits its timing ruling.'; // Provisional composition: Kulon augments normal movement, not the // Ornithopter card's explicit maximum of three territories. return null; } function discoveryFlightIntegrity(g: Game) { const pendingFlights = [g.pendingChoamMove, g.pendingIxMove, g.pendingFremenMove?.order] .filter((move): move is MovementOrder => !!move && move.discoveryFlight !== undefined); requireRule(pendingFlights.length <= 1, 'One Discovery Ornithopter cannot be reserved for multiple pending movements.'); for (const move of pendingFlights) nexusRule(() => validateDiscoveryFlight(g, move.discoveryFlight!, move)); } function ornithopterIntegrity(g: Game) { discoveryFlightIntegrity(g); 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) ?? 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 sandmasterOrder(g: Game, p: Player, move: MovementOrder): SandmasterOrder { return { group: move.group, eliteGroup: move.eliteGroup, elite: move.elite, origin: move.origin, to: move.to, sector: move.sector, range: movementOrderRange(g, p, move) }; } function validateSandmasterOrder(g: Game, p: Player, move: MovementOrder) { if (move.sandmaster === undefined) return; requireRule(!move.source && !move.noField && !move.planetologist && !move.ornithopterEvent && !move.discoveryFlight, 'Sandmaster with special movement routes is still being integrated.'); nexusRule(() => validateSandmasterMovement(g, p.id, sandmasterOrder(g, p, move), move.sandmaster!)); } function sandmasterIntegrity(g: Game) { 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 => { const karama = context && 'pendingKarama' in context ? context.pendingKarama as Game['pendingKarama'] : null; return [context?.response, karama?.use.kind === 'cancel' ? karama.use.response : null]; }).filter(r => r?.kind === 'fremenMovement'); const move = g.pendingFremenMove?.order; if (move?.sandmaster !== undefined || responses.some(r => r?.sandmasterProof !== undefined)) { requireRule(move && responses.length > 0 && responses.every(r => r!.owner === move.player && r!.sandmasterProof === JSON.stringify(move.sandmaster)), 'The saved Sandmaster route lost its original Fremen response.'); validateMovementOrder(g, getPlayer(g, move.player), move); } for (const other of [g.pendingIxMove, g.pendingChoamMove]) if (other?.sandmaster !== undefined) validateSandmasterOrder(g, getPlayer(g, other.player), other); } function movementOrderRange(g: Game, p: Player, move: MovementOrder, origin = move.origin) { return move.discoveryFlight || move.ornithopterRange ? 3 : planetologistRange( movementRange(g, p, selectedOriginElites(move.group, move.eliteGroup, origin)), move.planetologist?.mode); } function validatePlanetologistMove(g: Game, p: Player, move: Pick) { const skill = move.planetologist; if (!skill) { requireRule(move.origins === undefined, 'Two movement origins require Planetologist.'); return; } requireRule(g.phase === 5 && skill && typeof skill === 'object' && (skill.mode === 'range' || skill.mode === 'gather') && skill.leader === planetologistLeader(g, p.id), 'This Planetologist movement no longer has its living, uncaptured skilled leader.'); requireRule(planetologistMovementModeSupported(g), 'Planetologist movement combined with expansion factions is still being implemented.'); requireRule(!move.noField && !move.ornithopterEvent && !move.discoveryFlight, 'Planetologist combined with special movement cards or No-Field is still being implemented.'); requireRule(skill.mode === 'gather' ? Array.isArray(move.origins) && move.origins.length === 2 && new Set(move.origins).size === 2 && move.origins[0] === move.origin : move.origins === undefined, 'Choose the extra range or exactly two origins, not both.'); } function movementOrigins(move: Pick): string[] { return move.origins ?? [move.origin]; } function movementStance(g: Game, p: Player, origins: string[], to: string, fighters: boolean) { requireRule(!g.advanced || p.faction !== 'beneGesserit' || at(p, to) > 0 || !g.players.some(other => other.id !== p.id && at(other, to)) || origins.every(origin => isAdvisor(p, origin) === isAdvisor(p, origins[0])), 'Gathering advisors and fighters into an enemy-only territory still needs the combined flip response. Choose sources with the same stance or a destination with a determined stance.'); const advisors = arrivalAsAdvisor(g, p, to, origins[0]); const lockedTurn = Math.max(0, ...origins.map(origin => p.advisors?.[origin]?.lockedTurn ?? 0)) || undefined; requireRule(advisors || lockedTurn !== g.turn || !g.players.some(other => other.id !== p.id && at(other, to)), 'New advisors cannot become fighters this turn.'); const wantsFighters = advisors && fighters; 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.'); return {advisors, lockedTurn, wantsFighters}; } function validateFlightSelection(g: Game, p: Player, move: MovementOrder) { if (move.discoveryFlight) nexusRule(() => validateDiscoveryFlight(g, move.discoveryFlight!, move)); 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 blocked = p.faction === 'ixians' ? p.ixMovementBlocked : p.fremenMovementBlocked; return groundMovementRange({ faction: p.faction, cityOrnithopters: !!(fighterCount(p, 'arrakeen') || fighterCount(p, 'carthag')), selectedElites: elite, nativeBlocked: (blocked?.turn === g.turn && blocked.move === p.moved) || (p.faction === 'fremen' && p.fremenNexusMovementBlockedTurn === g.turn), choamBonus: 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) { if (move.source === 'ambassador') validateAmbassadorRelocationArrival(g, move); else validateMovementArrival(g, move); const choam = byFaction(g, 'choam'); if ( g.phase === 5 && choam && move.player !== choam.id && movementOrigins(move).some(origin => 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.noField) { const blocked = homeworldRule(() => homeworldNoFieldMovementBlock(g, p.id)); requireRule(!blocked, blocked ?? 'The No-Field cannot move.'); } 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.ambassadorSandmaster ? { sandmasterCollect: true, sandmasterPile: move.ambassadorSandmaster.key } : {}), ...(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); validatePlanetologistMove(g, p, move); validateSandmasterOrder(g, p, move); const selected = forceGroup(g, p, { type: 'move', forces: Object.fromEntries(move.group), eliteForces: move.eliteGroup, ...(move.noField ? { noField: move.noField.tokenId, event: move.noField.event } : {}), }, move.planetologist?.mode === 'gather' ? 2 : 1); requireRule( selected.origin === move.origin && selected.total === move.total && selected.elite === move.elite && selected.group.length === move.group.length && JSON.stringify(selected.origins) === JSON.stringify(movementOrigins(move)), 'The declared force group no longer matches its movement.', ); if (move.planetologist) { const stance = movementStance(g, p, selected.origins, move.to, move.wantsFighters); requireRule(stance.advisors === move.advisors && stance.wantsFighters === move.wantsFighters && stance.lockedTurn === move.lockedTurn, 'The declared Planetologist arrival stance is no longer current.'); } requireRule( movementOrigins(move).every(origin => !balisetPrevents(g, p.id, 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, splitLocation(key).territory)), ) <= movementOrderRange(g, p, move, splitLocation(key).territory), ), '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, retryUnsupportedArrival = false) { 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 (retryUnsupportedArrival) { try { if (move.source === 'ambassador') { const blocked = ambassadorRelocationArrivalBlock(g, move.player, move.origin, move.to, move.total); if (blocked) throw new MovementArrivalError(blocked); } else quoteMovementArrival(g, move); } catch (error) { if (!(error instanceof MovementArrivalError)) throw error; log(g, `${p.name}'s uncommitted movement returned for another choice. ${error.message} No additional forces, spice, cards or movement allowance were spent.`); if (move.source === 'ambassador') offerAmbassadorRelocation(g); return; } } if (move.source === 'ambassador') commitAmbassadorRelocation(g, move); else completeMove(g, move); } function quoteMovementArrival( g: Game, move: MovementOrder, cancelingChoam = false, ) { return quoteCompletedMovementArrival({ advanced: g.advanced, overlapSupported: arrivalOverlapModeSupported(g), players: g.players, order: move, ambassadors: g.ecazAmbassadors?.tokens ?? [], terror: homeworldTerrorEntryBlock(g, move.player, move.total, 'trigger') ? [] : (g.moritaniTerror?.tokens ?? []), flight: g.ornithopter, controls: { response: !cancelingChoam && !!g.response, decision: !!g.decision, pendingTerror: !!g.pendingTerrorEntry, pendingAmbassador: !!g.pendingAmbassador, paidBox: !!g.pendingNullentropy, }, }); } function validateMovementArrival(g: Game, move: MovementOrder, cancelingChoam = false) { try { return quoteMovementArrival(g, move, cancelingChoam); } catch (error) { if (error instanceof MovementArrivalError) throw new RuleError(error.message); throw error; } } function quoteShipmentArrival( g: Game, shipment: PendingShipment, ignoreGuildDecision = false, ) { // Guild Ambassador arrivals have a staged continuation which deliberately // owns pendingAmbassador throughout shipment, Terror and accompaniment. // Its source-specific validator handles those gates in their settled order. if (shipment.source === 'ambassador') return; const p = getPlayer(g, shipment.player); const amount = shipment.alliedNoField ? Math.max(1, shipment.amount) : shipment.amount + (shipment.smugglerCompanion ? 1 : 0); const guild = byFaction(g, 'guild'); const guildPayment = guildShipmentIncome({ guild: guild?.id, shipper: p.id, ally: p.ally, cost: shipment.cost, allyPayment: shipment.allyPayment, bankOnly: g.karamaShipping?.player === p.id, }); const bg = byFaction(g, 'beneGesserit'); const advisorFollowup = !!( p.faction !== 'fremen' && bg && bg.id !== p.id && spiritualAdvisorMaximum(g, bg.id) > 0 ); const currentDecision = !!( g.decision && !( ignoreGuildDecision && g.decision.kind === 'guildShipment' && g.decision.player === guild?.id && g.decision.shipper === shipment.player && g.decision.territory === shipment.territory && g.decision.sector === shipment.sector ) ); return quoteCompletedMovementArrival({ advanced: g.advanced, overlapSupported: arrivalOverlapModeSupported(g), players: g.players, order: { player: p.id, origin: 'reserves', to: shipment.territory, advisors: shipment.advisors, wantsFighters: false, }, ambassadors: g.ecazAmbassadors?.tokens ?? [], terror: homeworldTerrorEntryBlock(g, p.id, amount, 'trigger') ? [] : (g.moritaniTerror?.tokens ?? []), controls: { response: !!g.response || guildPayment > 0 || (g.advanced && p.faction === 'fremen' && shipment.sector === g.storm), decision: currentDecision || advisorFollowup, pendingTerror: !!g.pendingTerrorEntry, pendingAmbassador: !!g.pendingAmbassador, paidBox: !!g.pendingNullentropy, }, flight: null, }); } function validateShipmentArrival(g: Game, shipment: PendingShipment) { try { return quoteShipmentArrival(g, shipment); } catch (error) { if (error instanceof MovementArrivalError) throw new RuleError(error.message); throw error; } } function recoverableUnsupportedShipment(shipment: PendingShipment) { return ( shipment.source === undefined && shipment.ambassadorEvent === undefined && shipment.noField === undefined && shipment.noFieldSkillProof === undefined && shipment.alliedNoField === undefined && shipment.smuggler === undefined && shipment.smugglerCompanion === undefined && shipment.nexusEvent === undefined && shipment.guildNexusEvent === undefined && shipment.guildSecretEvent === undefined ); } function completeMove(g: Game, move: MovementOrder) { if (move.noField) { const blocked = homeworldRule(() => homeworldNoFieldMovementBlock(g, move.player)); requireRule(!blocked, blocked ?? 'The No-Field cannot move.'); } const { group, eliteGroup, elite, origin, total: n, to, sector: s, advisors, wantsFighters, lockedTurn, player: id, } = move; const p = getPlayer(g, id); validatePlanetologistMove(g, p, move); validateFlightSelection(g, p, move); validateMovementArrival(g, move); validateSandmasterOrder(g, p, 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(); } for (const pile of move.sandmaster?.piles ?? []) { g.spice[pile.key]--; if (!g.spice[pile.key]) delete g.spice[pile.key]; p.spice++; log(g, `${p.name}'s Sandmaster collected 1 spice while moving into or through ${territory(splitLocation(pile.key).territory).name}. This territory grants at most one collection during this movement.`, { faction: p.faction, name: 'Sandmaster collection' }); } removeGroup(p, group, eliteGroup); if (n > (move.noField ? 1 : 0)) place(p, to, s, n - (move.noField ? 1 : 0), elite); if (advisors) { const arrivalLock = Math.max(lockedTurn ?? 0, p.advisors?.[to]?.lockedTurn ?? 0); (p.advisors ??= {})[to] = arrivalLock ? { lockedTurn: arrivalLock } : {}; } 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++; if (move.discoveryFlight) { g.discoveries = nexusRule(() => completeDiscoveryFlight(g, move.discoveryFlight!, move)); log(g, `${p.name} used the carried Discovery Ornithopter for this movement and removed its token.`); } const cunning = currentGuildCunning(g,id); if (cunning) { requireRule(cunning.stage === 'extraMove', 'Guild Cunning permits no movement before the second shipment.'); cunning.movesAfter++; saveGuildCunning(g,cunning); } const completed: CompletedMovement = { player: id, origin, ...(move.origins ? {origins: move.origins} : {}), 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 ${movementOrigins(move).map(source => territory(source).name).join(' and ')} to ${territory(to).name}.`, { faction: p.faction, name: 'Movement' }, ); intrusion(g, p, to); if (movementOrigins(move).some(source => source !== to)) openTerritoryEntry(g, p, to, s, n, elite, 'movement'); } /** Native board classification is shared by the consequence and its own-seat preview. */ function alliedSeparationTerritories(g: Game, p: Player) { const ally = p.ally ? getPlayer(g, p.ally) : null; return quoteAlliedSeparation({ advanced: g.advanced, turn: g.turn, player: p.id, ally: ally?.id ?? null, playerAllySinceTurn: p.allySinceTurn, allySinceTurn: ally?.allySinceTurn, remaining: g.movementRemaining ?? g.order.slice(g.order.indexOf(p.id)), territories: gameTerritories(g).map(terr => ({ territory: terr.id, polar: terr.type === 'polar', ownPresent: at(p, terr.id) > 0, allyPresent: !!ally && at(ally, terr.id) > 0, ownAdvisors: isAdvisor(p, terr.id), allyAdvisors: !!ally && isAdvisor(ally, terr.id), ecazCoexist: !!ally && sharesEcazOccupation(g, p, terr.id), })), }); } function requireAlliedSeparationCustody(g: Game, p: Player) { const deployed = p.noField?.deployed; requireRule(!deployed || !alliedSeparationTerritories(g, p).includes(deployed.location.territory), 'This removal of a concealed No-Field awaits its specific reveal and casualty rules.'); } function advancedAllySeparationView(g: Game, p: Player): {territories: string[]} | null { if (!g.advanced || g.status !== 'playing' || g.phase !== 5 || g.active !== p.id || g.truthtrance || g.response || g.decision || g.phaseOpening || g.pendingKarama || g.pendingTreacheryDiscard || g.pendingNullentropy || g.pendingExchange || g.pendingRicheseGift || g.pendingRichesePurchaseIncome || g.pendingShipment || g.pendingHomeworldShipment || g.pendingGuildTransport || g.pendingGuildBetrayal || g.pendingRicheseBetrayal || g.pendingKull || g.pendingAmbassador || g.pendingTerrorEntry || g.pendingArrivalOverlap || g.pendingCapture || g.pendingIxAlly || g.pendingIxMove || g.pendingFremenMove || g.pendingChoamMove || g.bureaucratPayments?.pending || pendingNexusTraitors(g) || g.nexusCards?.phase?.stage === 'drawing' || currentGuildCunning(g, p.id)?.stage === 'pending' || currentGuildCunning(g, p.id)?.stage === 'secondShipment') return null; const territories = alliedSeparationTerritories(g, p).map(id => territory(id).name); return territories.length ? {territories} : null; } function basicAlliedShipmentView(g: Game, p: Player) { const visit = g.basicAlliedShipment; if (g.advanced || g.phase !== 5 || !visit || visit.player !== p.id || visit.turn !== g.turn || p.ally !== visit.ally) return null; const ally = getPlayer(g, visit.ally); if (ally.ally !== p.id || !at(ally, visit.territory) || sharesEcazOccupation(g, p, visit.territory)) return null; const pool = alliedShipmentPhysicalPool(p, visit.territory); const normal = Math.max(0, pool.normal - visit.baseline.normal); const elite = Math.max(0, pool.elite - visit.baseline.elite); return normal + elite ? { territory: territory(visit.territory).name, normal, elite } : null; } /** Provisional no-exit policy: only the excess physical visitors go to Tanks. */ function finishBasicAlliedShipment(g: Game, p: Player) { const visit = g.basicAlliedShipment; if (!visit || visit.player !== p.id) return; const quote = basicAlliedShipmentView(g, p); g.basicAlliedShipment = null; if (!quote) return; let normal = quote.normal; let elite = quote.elite; for (const key in p.forces) { if (splitLocation(key).territory !== visit.territory) continue; const special = p.elites?.forces[key] ?? 0; const lostElite = Math.min(elite, special); const lostNormal = Math.min(normal, p.forces[key] - special); if (lostElite + lostNormal) kill(g, p, key, lostElite + lostNormal, false, lostElite); elite -= lostElite; normal -= lostNormal; if (!normal && !elite) break; } log(g, `${p.name}'s ${quote.normal + quote.elite} new allied-shipment visitors left in ${quote.territory} went to Tanks. This is the provisional Basic no-departure policy, not a publisher ruling.`); } /** A played movement card is already retired before queue advancement. */ function finishMovementTurn(g: Game, id: string) { const p = getPlayer(g, id); requireAlliedSeparationCustody(g, p); const cunning = currentGuildCunning(g,id); if (cunning) { requireRule(cunning.stage !== 'pending' && cunning.shipment?.stage !== 'pending', 'Finish the Guild Cunning response or shipment first.'); cunning.stage = 'completed'; saveGuildCunning(g,cunning); } finishBasicAlliedShipment(g, p); 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' }, ); for (const terr of alliedSeparationTerritories(g, p)) killTerritory(g, p, terr); 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', 'tleilaxu', ]; 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, effect?: AmbassadorEffect) { if (g.pendingWinnerDiscards?.player === p.id && [...g.pendingWinnerDiscards.cards, ...g.pendingWinnerDiscards.optional].includes(card.id)) return 'This played card is reserved until the winner’s casualties and card cleanup.'; 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.'; if (g.battle && committedPlanElements(g.battle, p.id).some(element => element.value === card.id)) return 'This card is committed to prescience.'; if (effect === 'choam') return homeworldRule(() => homeworldWorthlessSaleBlock(g, p.id, card)); return null; } function ambassadorEffectBlock( g: Game, effect: AmbassadorEffect, beneficiary: Player, entrant: Player, ): string | null { if (g.pendingArrivalOverlap && (effect === 'fremen' || effect === 'guild')) return 'Finish the overlapping Terror before another Ambassador-created arrival; decline this effect.'; 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; } function arrivalOverlapModeSupported(g: Game): boolean { if ((mixedE3NexusLeaderSkillsProfile(g) || mixedE3NexusProfile(g)) && byFaction(g, 'ecaz') && byFaction(g, 'moritani')) return true; return pairedE3NexusLeaderSkillsProfile(g) || pairedE3NexusProfile(g) || (!!byFaction(g, 'ecaz') && !!byFaction(g, 'moritani') && g.players.every(player => CLASSIC_FACTIONS[player.faction] === true || player.faction === 'ecaz' || player.faction === 'moritani') && g.expansions.every(expansion => expansion === 'ecaz') && !g.homeworlds && !g.nexusCards && !g.leaderSkills && !g.discoveryEnabled && !g.techTokens && !g.strongholdCards && !g.ecazTreachery); } function arrivalOverlapIntegrity(g: Game) { const record = g.pendingArrivalOverlap; if (!record) return; const ecazIndex = g.order.indexOf(record.ecaz); const moritaniIndex = g.order.indexOf(record.moritani); const continuation = g.pendingTreacheryDiscard?.continuation; const ambassador = g.pendingAmbassador ?? (continuation?.kind === 'ambassador' ? continuation.entry : null); const terror = g.pendingTerrorEntry ?? (continuation?.kind === 'terrorDiscard' ? continuation.entry : null); const active = record.stage === 'first' ? record.first : record.first === 'ambassador' ? 'terror' : 'ambassador'; requireRule(g.status === 'playing' && arrivalOverlapModeSupported(g) && record.turn === g.turn && record.phase === g.phase && typeof record.event === 'string' && record.event.length > 0 && typeof record.signature === 'string' && record.signature === arrivalOverlapSignature(record) && g.players.some(p => p.id === record.ecaz && p.faction === 'ecaz') && g.players.some(p => p.id === record.moritani && p.faction === 'moritani') && g.players.some(p => p.id === record.entrant && p.id !== record.ecaz && p.id !== record.moritani) && ecazIndex >= 0 && moritaniIndex >= 0 && ecazIndex !== moritaniIndex && g.order.lastIndexOf(record.ecaz) === ecazIndex && g.order.lastIndexOf(record.moritani) === moritaniIndex && record.first === (ecazIndex < moritaniIndex ? 'ambassador' : 'terror') && (record.stage === 'first' || record.stage === 'second') && Number.isSafeInteger(record.sector) && TERROR_STRONGHOLDS.includes(record.territory) && territory(record.territory).sectors.includes(record.sector) && Number.isSafeInteger(record.amount) && record.amount >= 0 && Number.isSafeInteger(record.elite) && record.elite >= 0 && record.elite <= record.amount && (record.resume === 'none' || record.resume === 'wormRide') && typeof record.ambassadorToken === 'string' && record.ambassadorToken.length > 0 && Array.isArray(record.terrorTokens) && record.terrorTokens.length > 0 && record.terrorTokens.every(id => typeof id === 'string' && id.length > 0) && new Set(record.terrorTokens).size === record.terrorTokens.length && (active === 'ambassador' ? !!ambassador && !terror && ambassador.event === record.ambassadorEvent && ambassador.owner === record.ecaz && ambassador.entrant === record.entrant && ambassador.token === record.ambassadorToken && ambassador.territory === record.territory && ambassador.turn === record.turn && ambassador.phase === record.phase : !!terror && !ambassador && terror.entrant === record.entrant && terror.territory === record.territory && terror.turn === record.turn && terror.phase === record.phase && terror.cause === record.cause && record.terrorTokens.includes(terror.token)), 'The saved Ambassador/Terror overlap lost its original ordered arrival.'); } function openAmbassadorEntry( g: Game, owner: Player, token: AmbassadorToken, entrant: Player, to: string, sector: number, resume: 'none' | 'wormRide', ): boolean { 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 finishArrivalOverlap(g: Game, overlap: NonNullable) { requireRule(!g.pendingAmbassador && !g.pendingTerrorEntry, 'Both reactions must finish before resuming the original arrival.'); g.pendingArrivalOverlap = null; log(g, 'Both optional Ambassador and Terror reactions to the original entry have finished.'); if (overlap.resume === 'wormRide') nextWormRide(g); } /** The original entry is committed once; eligible overlapping owners act in storm order. */ function openTerritoryEntry( g: Game, entrant: Player, to: string, sector: number, amount: number, elite: number, cause: NonNullable['cause'], resume: 'none' | 'wormRide' = 'none', ) { // The arrival's own interaction (guild payment, advisor choice, storm // protection) settles first; the entry then opens from the automatic pass. if (g.response || g.decision || g.pendingTerrorEntry || g.pendingAmbassador || g.pendingArrivalOverlap) { (g.pendingArrivalReaction ??= []).push({ entrant: entrant.id, territory: to, sector, amount, elite, cause, resume, turn: g.turn, phase: g.phase, }); return true; } 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 terrorTokens = moritani && entrant.id !== moritani.id && entrant.id !== moritani.ally && !homeworldTerrorEntryBlock(g, entrant.id, amount, 'trigger') ? g.moritaniTerror?.tokens.filter(t => t.status === 'placed' && t.location === to) ?? [] : []; requireRule(!g.response && !g.decision && !g.pendingTerrorEntry && !g.pendingAmbassador, 'Finish the other arrival reaction before opening this entry.'); if (!terrorTokens.length) return openAmbassadorEntry(g, owner, token, entrant, to, sector, resume); requireRule(arrivalOverlapModeSupported(g) && !g.pendingArrivalOverlap && moritani && g.order.includes(owner.id) && g.order.includes(moritani.id), 'Ambassador and Terror overlap still needs a supported classic Ecaz/Moritani table.'); const first = g.order.indexOf(owner.id) < g.order.indexOf(moritani.id) ? 'ambassador' : 'terror'; const overlap: NonNullable = { event: crypto.randomUUID(), signature: '', turn: g.turn, phase: g.phase, entrant: entrant.id, territory: to, sector, amount, elite, cause, resume, ecaz: owner.id, moritani: moritani.id, first, stage: 'first', ambassadorToken: token.id, terrorTokens: terrorTokens.map(t => t.id), }; overlap.signature = arrivalOverlapSignature(overlap); g.pendingArrivalOverlap = overlap; log(g, `${owner.name} and ${moritani.name} both have optional reactions to ${entrant.name}'s entry in ${territory(to).name}; ${first === 'ambassador' ? owner.name : moritani.name} acts first in storm order.`); if (first === 'terror') return openTerrorEntry(g, entrant, to, sector, amount, elite, cause, 'none'); openAmbassadorEntry(g, owner, token, entrant, to, sector, 'none'); overlap.ambassadorEvent = g.pendingAmbassador!.event; return true; } function finishAmbassador(g: Game, deferResume = false) { const pending = g.pendingAmbassador!; if (pending.revivalEvent !== undefined || pending.victoryEvent !== undefined) { const arrival = pending.revivalEvent !== undefined ? g.homeworldRevivalReturn : g.homeworldVictoryReinforcement; requireRule(arrival && ambassadorPhaseAllowed(g), 'This Ambassador has lost its original Homeworld arrival completion.'); completeHomeworldArrivalAmbassador(arrival, pending.event); } 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; const overlap = g.pendingArrivalOverlap; if (overlap?.ambassadorEvent === pending.event) { requireRule(pending.entrant === overlap.entrant && pending.territory === overlap.territory && pending.turn === overlap.turn && pending.phase === overlap.phase && pending.resume === 'none', 'The Ambassador has lost its original overlapping arrival.'); if (overlap.stage === 'first' && overlap.first === 'ambassador') { overlap.stage = 'second'; requireRule(openTerrorEntry(g, getPlayer(g, overlap.entrant), overlap.territory, overlap.sector, overlap.amount, overlap.elite, overlap.cause, 'none'), 'The originally triggered Terror token is no longer in the stronghold.'); return; } requireRule(overlap.stage === 'second' && overlap.first === 'terror', 'The second Ambassador reaction is out of order.'); finishArrivalOverlap(g, overlap); return; } 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 && ambassadorPhaseAllowed(g) && (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 && (entry.revivalEvent !== undefined || entry.victoryEvent !== undefined || g.homeworldRevivalReturn?.ambassadors?.some((record) => record.event === entry.event) || g.homeworldVictoryReinforcement?.ambassadors?.some((record) => record.event === entry.event))) requireRule(ambassadorPhaseAllowed(g), 'This Ambassador has lost its original Homeworld revival arrival.'); 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 && validGameLocation(g, 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 && ambassadorPhaseAllowed(g) && (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, order.amount, ); 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); observeOccupation(g); 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 && spiritualAdvisorMaximum(g, bg.id) > 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 && !homeworldTerrorEntryBlock(g, bg.id, quote.amount, 'trigger') && 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, amount: 1, })) : []), ...Array.from({length: Math.max(1, spiritualAdvisorMaximum(g, player))}, (_, index) => ({accompany: false, territory: 'polar_sink', sector: 0, amount: index + 1})), ]; 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, amount: response.amount ?? 1, }), ); validateGuildAdvisorEntry(g, quote); const bg = getPlayer(g, quote.player); withdrawNativeReserves(g, bg, quote.amount, 0); place(bg, quote.territory, quote.sector, quote.amount); 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: quote.amount, elite: 0, }; log( g, `${bg.name} sent ${quote.amount} free reserve ${quote.amount === 1 ? 'force' : 'forces'} to ${territory(quote.territory).name}, sector ${quote.sector}, after the Guild Ambassador shipment${quote.amount === 2 ? ' using high-population Wallach IX' : ''}.`, { faction: bg.faction, name: 'Spiritual Advisors' }, ); if (guildAdvisorAmbassador(g, quote)) { const parent = g.pendingAmbassador!; const arrivalTag = parent.revivalEvent !== undefined ? 'revivalEvent' : parent.victoryEvent !== undefined ? 'victoryEvent' : undefined; const arrival = arrivalTag === 'revivalEvent' ? g.homeworldRevivalReturn : arrivalTag === 'victoryEvent' ? g.homeworldVictoryReinforcement : undefined; if (arrivalTag !== undefined) requireRule(arrival && ambassadorPhaseAllowed(g), 'This Ambassador accompaniment has lost its original Homeworld arrival.'); 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, quote.amount, 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, }; if (arrival && arrivalTag) { requireRule(validAmbassadorResume(g, g.pendingAmbassador!), 'The accompanying Ambassador has lost its completed Guild shipment.'); g.pendingAmbassador![arrivalTag] = arrival.event; appendHomeworldArrivalAmbassador(arrival, g.pendingAmbassador!, parent.event); } } else if ( !openTerrorEntry( g, bg, quote.territory, quote.sector, quote.amount, 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, entering: number, ): 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 && !homeworldTerrorEntryBlock(g, mover.id, entering, 'trigger') && 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, move.total, ); requireRule(!blocked, blocked ?? 'This arrival is unavailable.'); } function ambassadorArrivalChoices(g: Game, player: string, origin: string, to: string) { const blocked = ambassadorRelocationArrivalBlock(g, player, origin, to, 1); const largerBlocked = ambassadorRelocationArrivalBlock(g, player, origin, to, 3); return { blocked, maximum: !blocked && largerBlocked ? 2 : null }; } function ambassadorRelocationMovement(g: Game, player: string) { return { sources: fremenAmbassadorMovement(g, player).sources.map((source) => ({ ...source, destinations: source.destinations.map((destination) => ({ ...destination, ...ambassadorArrivalChoices( g, player, source.territory, destination.territory, ), })), })), }; } function commitAmbassadorRelocation(g: Game, move: FremenAmbassadorMove) { const { entry, beneficiary: p } = currentFremenAmbassador(g); validateAmbassadorRelocationArrival(g, move); if (move.ambassadorSandmaster) { const { key } = move.ambassadorSandmaster; g.spice[key]--; if (!g.spice[key]) delete g.spice[key]; p.spice++; log(g, `${p.name}'s Sandmaster collected 1 spice at ${territory(move.to).name} during Fremen Ambassador relocation, before arrival reactions.`, { faction: p.faction, name: 'Sandmaster collection' }); } 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) { const lockedTurn = Math.max(move.lockedTurn ?? 0, p.advisors?.[move.to]?.lockedTurn ?? 0); (p.advisors ??= {})[move.to] = lockedTurn ? { lockedTurn } : {}; } 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); } const EMPEROR_AMBASSADOR_POLICY = 'Provisional purchase composition: prove three own spice and retain it. No bank payment or reimbursement is invented; original card acquisition and entry continuation still resolve.'; function emperorAmbassadorSourceSignature(g: Game): string { const entry = g.pendingAmbassador!; const buyer = getPlayer(g, entry.beneficiary!); return JSON.stringify([entry.event, entry.owner, entry.entrant, entry.token, entry.territory, entry.sector, entry.turn, entry.phase, entry.effect, entry.beneficiary, entry.copyChoices, entry.resume, entry.wormRider, entry.guildAdvisorOrigin, entry.revivalEvent, entry.victoryEvent, g.active, buyer.spice, buyer.ally, g.nexusEmperorPurchaseHistory?.length ?? 0]); } function nexusEmperorAmbassadorIntegrity(g: Game): void { const entry = g.pendingAmbassador; const continuation = g.pendingTreacheryDiscard?.continuation; const decision = g.decision?.kind === 'nexusEmperorAmbassadorPurchase' ? g.decision : continuation?.kind === 'truthtranceDiscard' ? continuation.resume.decision : null; if (!entry?.nexusPurchase) { requireRule(entry?.stage !== 'purchase' && decision?.kind !== 'nexusEmperorAmbassadorPurchase', 'The original Ambassador purchase lost its buyer confirmation.'); return; } const source = entry.nexusPurchase; requireRule(Object.keys(source).sort().join(',') === 'event,signature' && entry.stage === 'purchase' && !entry.purchaseReceipt && emperorNexusAmbassadorPurchaseModeSupported(g) && decision?.kind === 'nexusEmperorAmbassadorPurchase' && decision.player === entry.beneficiary && decision.event === source.event && source.signature === emperorAmbassadorSourceSignature(g), 'The original Ambassador purchase lost its committed entry, price or beneficiary.'); const quote = nexusRule(() => quoteEmperorNexusAmbassadorPurchase(g, entry.beneficiary!)); requireRule(quote.event === source.event, 'The original Ambassador purchase event changed.'); } function nexusEmperorAmbassadorOffer(g: Game, id: string) { const entry = g.pendingAmbassador; if (!entry?.nexusPurchase || g.decision?.kind !== 'nexusEmperorAmbassadorPurchase' || g.decision.player !== id || entry.beneficiary !== id) return null; const quote = nexusRule(() => quoteEmperorNexusAmbassadorPurchase(g, id)); const blocked = g.truthtrance || pendingNexusTraitors(g) || g.pendingTreacheryDiscard ? 'Finish the original private question or card return before confirming this purchase.' : quote.blocked; return { ...quote, blocked, canUse: !blocked, policy: EMPEROR_AMBASSADOR_POLICY }; } function decideNexusEmperorAmbassadorPurchase(g: Game, p: Player, action: Action): void { const entry = g.pendingAmbassador; requireRule(entry?.nexusPurchase && action.type === 'nexusEmperorAmbassadorPurchase' && action.event === entry.nexusPurchase.event && typeof action.use === 'boolean' && Object.keys(action).sort().join(',') === 'event,type,use' && p.id === entry.beneficiary && g.decision?.kind === 'nexusEmperorAmbassadorPurchase' && g.decision.player === p.id && !g.truthtrance && !pendingNexusTraitors(g), 'Confirm only your current original Ambassador purchase with Pay or Use.'); nexusEmperorAmbassadorIntegrity(g); if (action.use) { const quote = nexusRule(() => quoteEmperorNexusAmbassadorPurchase(g, p.id)); requireRule(!quote.blocked, quote.blocked ?? 'This Emperor Nexus purchase is unavailable.'); } g.decision = null; finishRicheseAmbassadorPurchase(g, action.use); } function finishRicheseAmbassadorPurchase(g: Game, use = false): void { const entry = g.pendingAmbassador!; const buyer = getPlayer(g, entry.beneficiary!); if (buyer.spice < 3 || buyer.hand.length >= handLimit(buyer) || (!g.deck.length && !g.discard.length)) { requireRule(!use, 'This Emperor Nexus purchase is no longer available.'); delete entry.nexusPurchase; log(g, `${buyer.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.'); let nexusEvent: string | undefined; if (use) { const receipt = nexusRule(() => createEmperorNexusAmbassadorPurchase(g, buyer.id, card.id)); nexusEvent = receipt.event; g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!, buyer.id, g.players)); (g.nexusEmperorPurchaseHistory ??= []).push(receipt); (g.nexusEmperorPurchaseEvents ??= []).push(receipt.event); } else buyer.spice -= 3; delete entry.nexusPurchase; buyer.hand.push(card); const emperor = byFaction(g, 'emperor'); entry.purchaseReceipt = { buyer: buyer.id, card: card.id, amount: 3, emperor: emperor && emperor.id !== buyer.id ? emperor.id : null, stage: 'income', ...(nexusEvent ? { nexusEvent } : {}), }; log(g, use ? `${buyer.name} spent Emperor Nexus after proving three own spice, retained it and drew the original Richese Ambassador purchase card. No bank payment or reimbursement is created. Provisional purchase composition.` : `${buyer.name} paid 3 spice and drew the top Treachery Card through the Richese Ambassador. Its identity remains private.`, { faction: buyer.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); } /** 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 && ambassadorPhaseAllowed(g) && 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.', ); if (receipt.nexusEvent) { const original = g.nexusEmperorPurchaseHistory?.find(record => record.event === receipt.nexusEvent); requireRule(original && 'version' in original && original.version === 3 && original.entry === entry.event && original.token === entry.token && original.entrant === entry.entrant && original.owner === receipt.buyer && original.card === receipt.card && original.turn === entry.turn && original.phase === entry.phase && !receipt.emperor && response.kind !== 'emperorIncome', 'The retained-price Ambassador purchase lost its original consumed Nexus source.'); nexusRule(() => validateEmperorNexusPurchase(g, original)); } 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); const authority = g.homeworldOccupationPreview ? quoteOccupiedBiddingAuthority(g, 'harkonnenBonus') : null; if (buyer.faction === 'harkonnen' && (authority?.blocked || authority?.occupied ? !!authority?.blocked || !!authority?.bonusRecipients.length : buyer.hand.length < handLimit(buyer))) { entry.stage = receipt.stage = 'bonus'; g.response = { kind: 'harkonnenBonus', source: 'ambassador', intent: entry.event, owner: buyer.id, passed: [], }; offerOccupiedBonus(g, g.response); } else finishAmbassador(g); } /** A printed bonus consumes the original paid purchase's suffix, never a new buy. */ function offerOccupiedBonus(g: Game, response: ResponseWindow): boolean { if (!g.homeworldOccupationPreview || response.kind !== 'harkonnenBonus') return false; const authority = quoteOccupiedBiddingAuthority(g, 'harkonnenBonus'); if (response.source !== 'ambassador' && !g.currentAuctionSale?.occupiedEvent) return false; if (!authority.occupied && !authority.blocked) return false; if (response.source === 'ambassador') validateAmbassadorPurchaseResponse(g, response); else currentAuctionContinuationQuote(g, { kind: 'cancel', response }); requireRule(!g.pendingHomeworldOccupiedBonus, 'The original bonus is already awaiting its receiver.'); const event = crypto.randomUUID(); const proof = JSON.stringify({ producer: occupiedProducerProof(g, 'bonus', response), occupation: JSON.stringify(quoteStableHomeworldOccupation(g, 'giedi_prime')), controller: authority.controller, recipients: authority.bonusRecipients }); g.homeworldOccupiedPercentageLedger ??= createOccupiedPercentageState(); g.pendingHomeworldOccupiedBonus = { event, proof, response: structuredClone(response) }; g.response = null; g.decision = authority.controller && authority.bonusRecipients.length ? { kind: 'homeworldOccupiedBonus', player: authority.controller, event } : null; if (!authority.blocked && !authority.bonusRecipients.length) { g.pendingHomeworldOccupiedBonus = null; if (response.source === 'ambassador') finishAmbassador(g); else nextAuction(g); } return true; } function decideOccupiedBonus(g: Game, p: Player, action: Action) { const pending = g.pendingHomeworldOccupiedBonus; requireRule(pending && action.event === pending.event && Object.keys(action).sort().join(',') === 'event,recipient,type', 'Choose the original occupied purchase bonus recipient.'); const authority = homeworldRule(() => requireOccupiedBiddingController(g, 'harkonnenBonus', p.id)); homeworldRule(() => requireOccupiedBiddingBonusRecipient(g, action.recipient as string)); const saved = JSON.parse(pending.proof) as { producer: string; controller: string; recipients: string[]; occupation: string }; requireRule(saved.controller === authority.controller && saved.occupation === JSON.stringify(quoteStableHomeworldOccupation(g, 'giedi_prime')) && saved.producer === occupiedProducerProof(g, 'bonus', pending.response) && saved.recipients.includes(action.recipient as string), 'The original purchase or reciprocal bonus recipient changed.'); if (pending.response.source === 'ambassador') validateAmbassadorPurchaseResponse(g, pending.response); else currentAuctionContinuationQuote(g, { kind: 'cancel', response: pending.response }); const stock = [...g.deck, ...g.discard], physical = physicalTreacheryCards(g); requireRule(stock.every(card => physical.filter(other => other.id === card.id).length === 1), 'The original bonus draw stock has conflicting physical custody.'); const card = draw(g); requireRule(card, 'The original bonus requires one physical drawable card.'); getPlayer(g, action.recipient as string).hand.push(card); g.pendingHomeworldOccupiedBonus = null; g.decision = null; log(g, `${getPlayer(g, action.recipient as string).name} received the one private occupied Giedi Prime purchase bonus.`, { faction: 'harkonnen', name: 'Occupied purchase bonus' }); if (pending.response.source === 'ambassador') finishAmbassador(g); else nextAuction(g); } function projectedOccupiedBonus(g: Game) { const pending = g.pendingHomeworldOccupiedBonus; if (!pending) return null; const authority = quoteOccupiedBiddingAuthority(g, 'harkonnenBonus'); const saved = JSON.parse(pending.proof) as { controller: string | null; recipients: string[]; occupation: string }; const owner = saved.controller ? getPlayer(g, saved.controller) : null; const blocked = authority.blocked ?? (saved.occupation !== JSON.stringify(quoteStableHomeworldOccupation(g, 'giedi_prime')) ? 'The original occupied purchase bonus authority changed.' : null); return { event: pending.event, owner: owner?.id ?? '', ownerName: owner?.name ?? '', recipients: (blocked ? [] : authority.bonusRecipients.filter(id => saved.recipients.includes(id))) .map(id => { const p = getPlayer(g, id); return { player: id, name: p.name, faction: p.faction }; }), blocked }; } type OccupiedPercentageSuffix = | { kind: 'none'; decision: Decision | null } | { kind: 'auction'; quote: AuctionContinuationQuote } | { kind: 'auctionBonus' } | { kind: 'ambassadorBonus' } | { kind: 'richesePurchase'; resume: NonNullable['resume'] }; function occupiedProducerProof(g: Game, kind: OccupiedPercentageSource['kind'] | 'bonus', response?: ResponseWindow) { if (kind === 'fremen-collection') return JSON.stringify(g.fremenCollectionCredits); if (response?.source === 'ambassador') return JSON.stringify({ turn: g.turn, phase: g.phase, entry: g.pendingAmbassador }); if (response?.kind === 'richesePurchaseIncome') return JSON.stringify({ turn: g.turn, phase: g.phase, purchase: g.pendingRichesePurchaseIncome }); if (kind === 'guild-shipping') return JSON.stringify({ turn: g.turn, phase: g.phase, response, movement: g.players.map(p => ({ id: p.id, shipped: p.shipped, forces: p.forces, elites: p.elites, noField: p.noField })), homeworlds: g.homeworlds?.custody }); return JSON.stringify({ turn: g.turn, phase: g.phase, sale: g.currentAuctionSale, auction: g.auction, lot: g.richeseAuction, round: g.richeseBidding }); } function runOccupiedPercentageSuffix(g: Game, suffix: OccupiedPercentageSuffix) { if (suffix.kind === 'auction') commitAuctionContinuation(g, suffix.quote, 0); else if (suffix.kind === 'auctionBonus') auctionBonus(g); else if (suffix.kind === 'ambassadorBonus') ambassadorPurchaseBonus(g); else if (suffix.kind === 'richesePurchase') { g.pendingRichesePurchaseIncome = null; g.response = suffix.resume.response; g.decision = suffix.resume.decision; g.pendingKarama = suffix.resume.pendingKarama; } else if (suffix.kind === 'none') { g.decision = suffix.decision; } } function occupiedPercentageIntegrity(g: Game) { if (!g.homeworldOccupationPreview) { requireRule(g.homeworldOccupiedPercentageLedger === undefined && g.pendingHomeworldOccupiedPercentage === undefined && g.pendingHomeworldOccupiedBonus === undefined, 'Occupied source receipts require their original fresh profile.'); return; } requireRule(!g.spiceBankerIncomePreview || bankerIncomeProfile(g), 'Occupied percentage and deferred Banker routing require their supported classic Homeworld source profile.'); if (!g.homeworldOccupiedPercentageLedger) { requireRule(!g.pendingHomeworldOccupiedPercentage && !g.pendingHomeworldOccupiedBonus, 'The original occupied percentage ledger is missing.'); return; } homeworldRule(() => validateOccupiedPercentageState(g.homeworldOccupiedPercentageLedger!, g)); const pending = g.pendingHomeworldOccupiedPercentage; const unsettled = g.homeworldOccupiedPercentageLedger.receipts.filter(row => row.status !== 'settled'); requireRule(unsettled.length === Number(!!pending) && (!pending || unsettled[0].source.event === pending.source.event && JSON.stringify(unsettled[0].source) === JSON.stringify(pending.source)), 'The occupied source lost its exact unresolved receipt.'); if (pending) requireRule(pending.suffixSignature === JSON.stringify(pending.suffix), 'The exact occupied percentage suffix changed.'); } /** The original payer has already paid; reconcile native cash only once. */ function admitOccupiedPercentage(g: Game, source: OccupiedPercentageSource, alreadyCredited: number, suffix: OccupiedPercentageSuffix): boolean { if (!g.homeworldOccupationPreview) return false; occupiedPercentageIntegrity(g); g.homeworldOccupiedPercentageLedger ??= createOccupiedPercentageState(); requireRule(!g.pendingHomeworldOccupiedPercentage, 'Settle the original percentage allocation first.'); const result = homeworldRule(() => quoteOccupiedPercentageSource(g, source, g.homeworldOccupiedPercentageLedger!)); const native = getPlayer(g, source.native), balance = native.spice - alreadyCredited + result.receipt.nativeRetained; requireRule(Number.isSafeInteger(balance) && balance >= 0, 'The original percentage cash cannot be reconciled.'); native.spice = balance; g.homeworldOccupiedPercentageLedger = result.state; log(g, `${native.name}'s original ${source.kind === 'fremen-collection' ? 'total Collection' : 'paid income'} retained ${result.receipt.nativeRetained} spice; ${result.receipt.occupiedAmount} spice belongs to the separate occupied portion and ${result.receipt.bankRetained} spice remains in the bank.${result.receipt.blocked ? ' The printed allocation is blocked by its unresolved original occupation source.' : ''}`, { faction: native.faction, name: 'Original Homeworld income' }); if (result.receipt.status === 'settled') return false; g.pendingHomeworldOccupiedPercentage = { source: result.receipt.source, proof: source.kind === 'fremen-collection' ? source.collection.binding : source.payment.binding, suffix, suffixSignature: JSON.stringify(suffix) }; const owner = result.receipt.entitlement?.occupier; g.decision = owner ? { kind: 'homeworldOccupiedPercentage', player: owner, event: source.event } : null; return true; } function occupiedPaymentSource(g: Game, response: ResponseWindow, gross: number, canceled: boolean, diversion = 0): OccupiedPercentageSource { const kind = response.kind === 'guildIncome' ? 'guild-shipping' : 'emperor-treachery', sale = g.currentAuctionSale; const buyer = response.source === 'ambassador' ? g.pendingAmbassador!.purchaseReceipt!.buyer : response.kind === 'richesePurchaseIncome' ? g.pendingRichesePurchaseIncome!.buyer : sale?.winner; let contributions = response.occupiedPayment?.contributions ?? [{ payer: buyer!, amount: gross }]; if (response.kind === 'emperorIncome' && response.source !== 'ambassador' && sale) { contributions = sale.richeseContribution !== undefined ? [{ payer: sale.seller!, amount: gross }] : sale.occupiedContributions!; } const paid = contributions.reduce((sum, leg) => sum + leg.amount, 0); requireRule(diversion === 0 || diversion === 2 && !canceled && paid === gross + diversion, 'The occupied receipt lost its original Bureaucrat diversion.'); if (!diversion && paid !== gross) { requireRule(contributions.length === 1 && paid >= gross, 'The routed payment lost its eligible paid contributions.'); contributions = [{ ...contributions[0], amount: gross }]; } const id = response.occupiedPayment?.event ?? (response.source === 'ambassador' ? g.pendingAmbassador!.event : response.kind === 'richesePurchaseIncome' ? g.pendingRichesePurchaseIncome!.event! : sale!.occupiedEvent!); return { kind, event: crypto.randomUUID(), native: response.owner, amount: gross, payment: { id, binding: occupiedProducerProof(g, kind, response), paid: diversion ? paid : gross, contributions, recipient: response.owner, ...(diversion ? { bankDiversion: 2 as const } : {}) }, nativeIncome: canceled ? 'karama-canceled' : 'received' }; } function settleOccupiedPayment(g: Game, response: ResponseWindow, gross: number, canceled: boolean, suffix: OccupiedPercentageSuffix, diversion = 0): boolean { return !!g.homeworldOccupationPreview && admitOccupiedPercentage(g, occupiedPaymentSource(g, response, gross, canceled, diversion), 0, suffix); } function decideOccupiedPercentage(g: Game, p: Player, action: Action) { occupiedPercentageIntegrity(g); const pending = g.pendingHomeworldOccupiedPercentage!; requireRule(pending && action.event === pending.source.event && Object.keys(action).sort().join(',') === 'event,ownAmount,type', 'Choose the original occupied percentage event and allocation.'); const response = pending.source.kind === 'fremen-collection' ? undefined : JSON.parse(pending.source.payment.binding).response as ResponseWindow | undefined; const originalResponse = response ?? (pending.suffix.kind === 'ambassadorBonus' ? { source: 'ambassador' } as ResponseWindow : pending.suffix.kind === 'richesePurchase' ? { kind: 'richesePurchaseIncome' } as ResponseWindow : undefined); requireRule(occupiedProducerProof(g, pending.source.kind, originalResponse) === pending.proof, 'The original occupied percentage producer changed.'); const result = homeworldRule(() => allocateOccupiedPercentageReceipt(g.homeworldOccupiedPercentageLedger!, g, p.id, action.event as string, action.ownAmount as number, pending.source)); for (const credit of result.credits) requireRule(Number.isSafeInteger(getPlayer(g, credit.player).spice + credit.amount), 'Occupied allocation exceeds a wallet.'); g.homeworldOccupiedPercentageLedger = result.state; g.pendingHomeworldOccupiedPercentage = null; g.decision = null; for (const credit of result.credits) { getPlayer(g, credit.player).spice += credit.amount; if (pending.source.kind === 'fremen-collection') creditGiediCollection(g, credit.player, { kind: 'homeworld', amount: credit.amount }); log(g, `${getPlayer(g, credit.player).name} received ${credit.amount} spice from the original occupied percentage allocation.`, { faction: getPlayer(g, credit.player).faction, name: 'Occupied percentage income' }); } runOccupiedPercentageSuffix(g, pending.suffix); } function projectedOccupiedPercentage(g: Game) { const pending = g.pendingHomeworldOccupiedPercentage; if (!pending) return null; const row = g.homeworldOccupiedPercentageLedger!.receipts.find(row => row.source.event === pending.source.event)!; const card = HOMEWORLD_CARDS.find(card => card.id === (row.entitlement?.card ?? ({ 'emperor-treachery': 'kaitain', 'guild-shipping': 'junction', 'richese-income': 'richese', 'fremen-collection': 'southern_hemisphere' } as const)[row.source.kind]))!; const owner = row.entitlement ? getPlayer(g, row.entitlement.occupier) : null; const ally = row.entitlement?.ally ? getPlayer(g, row.entitlement.ally) : null; const current = quoteStableHomeworldOccupation(g, card.id); const blocked = row.blocked ?? current.blocked ?? (JSON.stringify(current.entitlement) !== JSON.stringify(row.entitlement) ? 'The original occupied percentage authority changed.' : null); return { event: row.source.event, world: `homeworld:${card.faction}`, name: card.name, owner: owner?.id ?? '', ownerName: owner?.name ?? '', ally: ally?.id ?? null, allyName: ally?.name ?? null, amount: row.occupiedAmount, minOwnAmount: ally ? 0 : row.occupiedAmount, maxOwnAmount: row.occupiedAmount, blocked }; } function recordFremenCollection(g: Game, player: string, id: string, amount: number) { if (!g.homeworldOccupationPreview || getPlayer(g, player).faction !== 'fremen' || amount === 0) return; const receipt = g.fremenCollectionCredits; if (!receipt) return; requireRule(receipt?.turn === g.turn && !receipt.complete && Number.isSafeInteger(amount) && amount > 0 && !receipt.collected.some(leg => leg.id === id), 'The original total Fremen Collection credit changed.'); receipt.collected.push({ id, amount }); } function completeOccupiedFremenCollection(g: Game) { const receipt = g.fremenCollectionCredits, native = byFaction(g, 'fremen'); if (!native || !receipt || receipt.complete || g.phase !== 7 || g.phaseOpening || g.decision || g.response || g.truthtrance || g.pendingHomeworldOccupiedIncome || g.pendingTreacheryDiscard || g.pendingNullentropy || g.pendingKarama || g.grummanCollection && g.grummanCollection.stage !== 'complete' || g.ecazCollection && g.ecazCollection.stage !== 'complete') return; receipt.complete = true; const amount = receipt.collected.reduce((sum, leg) => sum + leg.amount, 0); admitOccupiedPercentage(g, { kind: 'fremen-collection', native: native.id, event: crypto.randomUUID(), amount, collection: { id: receipt.event, binding: occupiedProducerProof(g, 'fremen-collection'), complete: true, collected: receipt.collected } }, amount, { kind: 'none', decision: null }); } /** The payer has already paid. Only credit the eligible faction share once. */ function creditFactionPayment(g: Game, ownerId: string, kind: 'shipment' | 'treachery', gross: number) { const payment = homeworldRule(() => quoteHomeworldPaymentIncome(g, ownerId, kind, gross)); const owner = getPlayer(g, ownerId); requireRule(Number.isSafeInteger(owner.spice + payment.income), 'The faction payment would overflow its spice balance.'); owner.spice += payment.income; return payment; } function currentFactionPayment(g: Game) { const response = g.response; if (!g.homeworlds?.custody || !response) return null; const kind = response.kind === 'guildIncome' ? 'shipment' : ['emperorIncome', 'richesePurchaseIncome'].includes(response.kind) ? 'treachery' : null; if (!kind) return null; if (response.kind === 'emperorIncome' && response.source === 'ambassador') validateAmbassadorPurchaseResponse(g, response); 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.'); } const gross = response.kind === 'emperorIncome' ? response.source === 'ambassador' ? response.amount! : g.currentAuctionSale ? auctionEmperorIncomeAmount(g.currentAuctionSale) : g.auction?.bid : response.amount; requireRule(gross !== undefined, 'The faction payment is missing its original amount.'); if (kind === 'shipment' && (Object.hasOwn(response, 'guildContributions') || Object.hasOwn(response, 'guildPaymentProof'))) { requireRule(Array.isArray(response.guildContributions) && response.guildPaymentProof === guildPaymentSignature(g, response.owner, gross, response.guildContributions), 'The saved Guild payment no longer matches its original contributor receipt.'); validateGuildPaymentContributions(g, gross, response.guildContributions); } const payment = homeworldRule(() => quoteHomeworldPaymentIncome(g, response.owner, kind, gross)); const stable = g.homeworldOccupationPreview ? homeworldRule(() => quoteStableHomeworldOccupation(g, kind === 'shipment' ? 'junction' : 'kaitain')) : null; return { owner: response.owner, kind, ...payment, ...(stable?.entitlement || stable?.blocked ? { income: Math.ceil(gross / 2), bank: 0 } : {}) }; } function validateGuildPaymentContributions(g: Game, gross: number, contributions: number[]) { if (!g.homeworlds?.custody) return; requireRule(Number.isSafeInteger(gross) && gross >= 0 && Array.isArray(contributions) && contributions.length <= 2 && contributions.every(amount => Number.isSafeInteger(amount) && amount >= 0) && contributions.reduce((sum, amount) => sum + amount, 0) === gross, 'Guild contributions must preserve the original eligible whole-invoice payment.'); } function shipmentIncomeContributions(g: Game, p: Player, cost: number, allyPayment: number) { const guild = byFaction(g, 'guild'); if (!guild || g.karamaShipping?.player === p.id) return []; return [p.id === guild.id ? 0 : cost - allyPayment, p.ally === guild.id ? 0 : allyPayment].filter(amount => amount > 0); } function checkShipmentIncomeFunding(g: Game, p: Player, cost: number, allyPayment: number) { const guild = byFaction(g, 'guild'); if (!guild) return; const amounts = shipmentIncomeContributions(g, p, cost, allyPayment); checkBureaucratContributors(g,g.karamaShipping?.player === p.id ? undefined : guild.id,[{payer:p.id,amount:p.id === guild.id ? 0 : cost-allyPayment}, ...(allyPayment ? [{payer:p.ally!,amount:p.ally === guild.id ? 0 : allyPayment}] : [])],allyPayment > 0); validateGuildPaymentContributions(g, amounts.reduce((sum, amount) => sum + amount, 0), amounts); } function guildPaymentResponse(g: Game, owner: string, amounts: number[],payer?:string, named?: { payer: string; amount: number }[]): ResponseWindow { const contributions = amounts.filter(amount => amount > 0); const amount = contributions.reduce((sum, value) => sum + value, 0); validateGuildPaymentContributions(g, amount, contributions); return { kind: 'guildIncome', owner, amount, passed: [], ...(g.homeworldOccupationPreview ? { occupiedPayment: { event: crypto.randomUUID(), contributions: named ?? [{ payer: payer!, amount }] } } : {}), ...(named ? stampBureaucratContributors(g,'shipment',owner,named,!payer) : contributions.length === 1 && payer ? stampBureaucratPayment(g,'shipment',payer,owner,amount) : {}), ...(g.homeworlds?.custody ? { guildContributions: contributions, guildPaymentProof: guildPaymentSignature(g, owner, amount, contributions) } : {}) }; } function guildPaymentSignature(g: Game, owner: string, amount: number, contributions: number[]) { return JSON.stringify({ turn: g.turn, phase: g.phase, owner, amount, contributions }); } function finishAmbassadorPurchaseResponse( g: Game, response: ResponseWindow, canceled: boolean, ) { const { receipt, buyer } = validateAmbassadorPurchaseResponse(g, response); if (response.kind === 'emperorIncome') { const payment = !canceled ? creditFactionPayment(g, response.owner, 'treachery', receipt.amount) : null; 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 ${payment!.income} spice from the three-spice Richese Ambassador purchase payment.${payment!.bank ? ` Low-population Kaitain leaves ${payment!.bank} spice in the bank.` : ''}`, { 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') { // Uniform timing must not expose a private Nexus face or private ability to pay. if (emperorNexusAmbassadorPurchaseModeSupported(g)) { const quote = nexusRule(() => quoteEmperorNexusAmbassadorPurchase(g, p.id)); entry.stage = 'purchase'; entry.nexusPurchase = { event: quote.event, signature: emperorAmbassadorSourceSignature(g) }; g.decision = { kind: 'nexusEmperorAmbassadorPurchase', player: p.id, event: quote.event }; } else finishRicheseAmbassadorPurchase(g); return; } if ( effect === 'ixians' || (effect === 'choam' && p.hand.some((c) => !ambassadorDiscardBlock(g, p, c, effect))) ) { entry.stage = 'cards'; g.decision = { kind: 'ecazAmbassador', player: p.id }; return; } if (effect === 'tleilaxu') { // First-death own identities include KH's separate token under base p17. // Repeat returns, shared discs and ordinary-quota policy stay separate. entry.stage = 'revival'; g.decision = { kind: 'ecazAmbassador', player: p.id }; log( g, `${p.name} may revive one first-death own leader or return up to four physical forces for free through the Tleilaxu Ambassador. Ordinary revival allowances and income are unchanged.`, { faction: 'tleilaxu', name: 'Tleilaxu Ambassador' }, ); 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 ecazAllianceQuote(g: Game, owner: string, entrant: string) { const blocked = homeworldAllianceReason(g, owner, entrant); requireRule(!blocked, blocked ?? 'This alliance is unavailable.'); try { return quoteEcazAlliance(g, owner, entrant); } catch (error) { if (error instanceof EcazAllianceError) throw new RuleError(error.message); throw error; } } function ecazAmbassadorLoanBlock(g: Game, owner: Player, entrant: Player) { if (g.advanced && byFaction(g, 'harkonnen')) return 'Advanced Duke loans with Harkonnen capture remain guarded.'; if (owner.ally !== entrant.id || entrant.ally !== owner.id) return 'The new Ecaz alliance is no longer current.'; const duke = g.dukeVidal; if (!duke || duke.leader.id !== DUKE_VIDAL_ID || duke.leader.faction !== 'ecaz' || duke.leader.strength !== 6 || duke.leader.dead || duke.leader.concealed || duke.leader.capturedBy || duke.leader.gholaBy) return 'Duke Vidal is unavailable for this loan.'; return duke.controller === owner.id ? null : ecazDukeAcquisitionBlock(g, owner.id); } function ecazAllianceIntegrity(g: Game) { const entry = g.pendingAmbassador; const continuation = g.pendingTreacheryDiscard?.continuation; const controls = [ g, g.pendingExchange, g.pendingRicheseGift?.resume, g.pendingNullentropy?.resume, g.pendingRichesePurchaseIncome?.resume, g.summonedWorm?.resume, continuation && 'resume' in continuation ? continuation.resume : null, ]; if (!entry) { requireRule( !controls.some((control) => control?.decision?.kind === 'ecazAmbassador'), 'The Ambassador decision has lost its entry event.', ); return; } if (entry.stage !== 'allianceReply' && entry.stage !== 'loan') return; const token = g.ecazAmbassadors?.tokens.find((t) => t.id === entry.token); requireRule( g.status === 'playing' && entry.turn === g.turn && entry.phase === g.phase && ambassadorPhaseAllowed(g) && typeof entry.event === 'string' && entry.event.length > 0 && entry.effect === 'ecaz' && entry.beneficiary === (entry.stage === 'loan' ? entry.owner : entry.entrant) && token?.effect === 'ecaz' && token.zone === 'supply' && token.location === null && controls.some( (control) => control?.decision?.kind === 'ecazAmbassador' && control.decision.player === (entry.stage === 'loan' ? entry.owner : entry.entrant), ), 'The Ecaz alliance reply has lost its original Ambassador or decision owner.', ); if (entry.stage === 'allianceReply') ecazAllianceQuote(g, entry.owner, entry.entrant); else requireRule(getPlayer(g, entry.owner).ally === entry.entrant && getPlayer(g, entry.entrant).ally === entry.owner, 'The Duke loan lost its accepted Ecaz alliance.'); } 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 === 'loan') { requireRule(typeof action.loan === 'boolean', 'Choose whether to lend Duke Vidal to the new ally.'); if (action.loan) { const blocked = ecazAmbassadorLoanBlock(g, owner, entrant); requireRule(!blocked, blocked ?? 'This Duke loan is unavailable.'); g.dukeVidal = acquireDuke(g.dukeVidal!, entrant.id, g.turn, 'ally'); log(g, `${owner.name} lent Duke Vidal to ${entrant.name} for this turn. The Basic prototype allows one battle; an unused living Duke is set aside at turn end.`, { faction: 'ecaz', name: 'Ambassador Duke loan' }); } finishAmbassador(g); return; } if (entry.stage === 'allianceReply') { requireRule( typeof action.accept === 'boolean', 'Accept or refuse the Ecaz alliance offer.', ); const quote = ecazAllianceQuote(g, owner.id, entrant.id); if (action.accept) { for (const member of quote.players) { if (member.id !== owner.id && member.id !== entrant.id) continue; const target = getPlayer(g, member.id); target.ally = member.ally; target.allySinceTurn = member.allySinceTurn; } g.allianceOffers = quote.allianceOffers; discardAllianceNexusCards(g, owner, entrant); g.ready = []; log( g, `${owner.name} and ${entrant.name} formed an alliance through the Ecaz Ambassador. Both were unallied and ${entrant.name} accepted. Their alliance abilities apply immediately; the entrant’s remaining actions resume.`, { faction: 'ecaz', name: 'Ambassador alliance' }, ); if (!ecazAmbassadorLoanBlock(g, owner, entrant)) { if (g.advanced) { g.dukeVidal = acquireDuke(g.dukeVidal!, entrant.id, g.turn, 'ally'); log(g, `${entrant.name} received the available Duke Vidal as part of accepting the Ecaz Ambassador alliance. No separate loan acknowledgement is required. The first version uses one-battle tenure; an unused living Duke is set aside at turn end.`, { faction: 'ecaz', name: 'Ambassador Duke grant' }); } else { entry.stage = 'loan'; entry.beneficiary = owner.id; g.decision = { kind: 'ecazAmbassador', player: owner.id }; return; } } } else log( g, `${entrant.name} refused the Ecaz Ambassador alliance. No alliance or Duke transfer occurred; the triggered token remains in Ecaz’s supply and the entrant’s remaining actions resume.`, ); finishAmbassador(g); return; } if (entry.stage === 'revival') { requireRule( Object.keys(action).every((key) => ['type', 'event', 'decline', 'forces', 'elite', 'leader'].includes(key), ), 'Choose one leader, a physical force return, or decline.', ); if (action.decline === true) { log( g, `${p.name} returned no forces through the Tleilaxu Ambassador.`, { faction: 'tleilaxu', name: 'Tleilaxu Ambassador' }, ); finishAmbassador(g); return; } if (action.leader !== undefined) { requireRule(action.forces === undefined && action.elite === undefined, 'Choose a leader or forces, not both.'); const selected = tleilaxuAmbassadorLeaderSelection(g, p, action.leader); requireRule(selected, 'Choose an eligible first-death own leader or Kwisatz Haderach from the Tanks.'); let name: string; if (selected === 'kwisatz') { const kwisatz = p.kwisatz!; kwisatz.dead = false; delete kwisatz.usedAt; kwisatz.revivalCycle = Math.max(p.revivalCycle, kwisatz.revivalCycle ?? 1) + 1; name = 'Kwisatz Haderach'; } else { selected.dead = false; delete selected.usedAt; name = selected.name; } log(g, `${p.name} revived ${name} for free through the Tleilaxu Ambassador. The ordinary leader allowance is unchanged.`, { faction: 'tleilaxu', name: 'Tleilaxu Ambassador' }); finishAmbassador(g); return; } const forces = integer(action.forces, 1, 4, 'Forces to return'); const elite = action.elite === undefined ? undefined : integer(action.elite, 0, 4, 'Special forces to return'); let quote; try { quote = quoteTleilaxuAmbassadorForces( { faction: p.faction, reserves: p.reserves, tanks: p.tanks, elites: p.elites ? { reserves: p.elites.reserves, tanks: p.elites.tanks, revived: p.elites.revived, } : undefined, }, forces, elite, ); } catch (error) { if (error instanceof TleilaxuAmbassadorForceError) throw new RuleError(error.message); throw error; } p.reserves = quote.after.reserves; p.tanks = quote.after.tanks; if (p.elites) { p.elites.reserves = quote.after.eliteReserves; p.elites.tanks = quote.after.eliteTanks; p.elites.revived = quote.eliteRevivedNext; } log( g, `${p.name} returned ${quote.amount} physical ${quote.amount === 1 ? 'force' : 'forces'} (${quote.elite} special) from the Tanks to its reserves for free through the Tleilaxu Ambassador. Ordinary revival allowances, prices and income are unchanged.`, { faction: 'tleilaxu', name: 'Tleilaxu Ambassador' }, ); finishAmbassador(g); return; } 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.', ); if (token.effect === 'ecaz' && action.choice === 'alliance') { requireRule( action.beneficiary === owner.id, 'Ecaz must offer its own Ambassador alliance.', ); ecazAllianceQuote(g, owner.id, entrant.id); 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.', ); entry.effect = 'ecaz'; entry.beneficiary = entrant.id; entry.stage = 'allianceReply'; g.ecazAmbassadors = triggerAmbassador(g.ecazAmbassadors!, token.id); g.decision = { kind: 'ecazAmbassador', player: entrant.id }; log( g, `${owner.name} offered an alliance to ${entrant.name} through the Ecaz Ambassador. The reusable token returned to supply; the five random Ambassadors are unchanged. Only the entrant can accept or refuse. Duke Vidal has not changed hands.`, ); return; } 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, entry.effect); 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, }); } } } /** Card interruptions keep the live entry; fresh discard owns its one suspended copy. */ function terrorEntryIntegrity(g: Game) { const continuation = g.pendingTreacheryDiscard?.continuation; const entries = [g.pendingTerrorEntry, (continuation?.kind === 'terrorDiscard' || continuation?.kind === 'terrorAtomics') ? continuation.entry : null]; for (const entry of entries) { if (!entry) continue; homeworldRule(() => validateTerrorEntrySignature(entry)); if (entry.entrySignature === undefined) continue; requireRule(entry.turn === g.turn && entry.phase === g.phase && g.players.some((p) => p.id === entry.entrant && p.faction !== 'moritani') && g.players.filter((p) => p.faction === 'moritani').length === 1, 'The saved Terror entry has lost its original turn or seated participants.'); if (entry.candidates) { const committedDiscard = (continuation?.kind === 'terrorDiscard' || continuation?.kind === 'terrorAtomics') && entry === continuation.entry; const reserved = entry.candidates.filter((id) => id !== entry.token || (!committedDiscard && ['select', 'offer', 'allianceResponse', 'allianceReply'].includes(entry.stage))); requireRule(reserved.every((id) => g.moritaniTerror?.tokens.filter((token) => token.id === id && token.status === 'placed' && token.location === entry.territory).length === 1), 'The stacked Terror choice has lost an original reserved token.'); if (committedDiscard) requireRule(g.moritaniTerror?.tokens.some((token) => token.id === entry.token && token.status === 'removed'), 'The stacked Terror discard has lost its consumed token.'); } } } /** Unknown occupation retains an owned Reveal/Decline window; it never authorizes revelation. */ function homeworldTerrorEntryBlock(g: Game, entrant: string, entering: number, mode: 'trigger' | 'reveal'): string | null { const owner = byFaction(g, 'moritani'); if (!owner) return null; const occupation = homeworldRule(() => quoteOccupiedHomeworldTerror(g, entrant)); if (occupation.status === 'prohibited' || mode === 'reveal' && occupation.blocked) return occupation.blocked; return homeworldRule(() => lowGrummanRevealBlock(g, owner.id, entering)); } /** 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 overlap = g.pendingArrivalOverlap; const originallyTriggered = !!overlap && overlap.stage === 'second' && overlap.first === 'ambassador' && overlap.entrant === entrant.id && overlap.territory === to && overlap.turn === g.turn && overlap.phase === g.phase && overlap.cause === cause; const tokens = g.moritaniTerror?.tokens.filter(t => t.status === 'placed' && t.location === to && (!originallyTriggered || overlap!.terrorTokens.includes(t.id))) ?? []; const token = tokens[0]; if (!moritani || !token || entrant.id === moritani.id || (entrant.id === moritani.ally && !originallyTriggered) || homeworldTerrorEntryBlock(g, entrant.id, amount, 'trigger')) 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, ...(tokens.length > 1 ? { candidates: tokens.map((t) => t.id) } : {}), entrant: entrant.id, territory: to, sector, amount, elite, cause, turn: g.turn, phase: g.phase, stage: tokens.length > 1 ? 'select' : 'offer', resume, ...(ambassadorEvent ? { ambassadorEvent } : {}), }; if (!TERROR_STRONGHOLDS.includes(to)) { const proofs = tokens.map(token => { const record = g.nexusMoritaniHistory?.find(r => r.stage === 'complete' && r.receipt.token === token.id && r.receipt.territory === to); requireRule(record && terrorLocationAllowed(g,token.id,to,record.receipt.event), 'The Terror token has lost its original Cunning placement.'); return [token.id,record.receipt.event]; }); g.pendingTerrorEntry.nexusPlacements = Object.fromEntries(proofs); } g.pendingTerrorEntry.entrySignature = terrorEntrySignature(g.pendingTerrorEntry); 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; const overlap = g.pendingArrivalOverlap; if (overlap && pending.entrant === overlap.entrant && pending.territory === overlap.territory && pending.turn === overlap.turn && pending.phase === overlap.phase && pending.cause === overlap.cause) { requireRule(pending.resume === 'none', 'The overlapping Terror must not resume its parent twice.'); if (overlap.stage === 'first' && overlap.first === 'terror') { overlap.stage = 'second'; const token = g.ecazAmbassadors?.tokens.find(t => t.id === overlap.ambassadorToken && t.zone === 'placed' && t.location === overlap.territory); requireRule(token, 'The originally triggered Ambassador token is no longer present.'); openAmbassadorEntry(g, getPlayer(g, overlap.ecaz), token, getPlayer(g, overlap.entrant), overlap.territory, overlap.sector, 'none'); overlap.ambassadorEvent = g.pendingAmbassador!.event; return; } requireRule(overlap.stage === 'second' && overlap.first === 'ambassador', 'The second Terror reaction is out of order.'); finishArrivalOverlap(g, overlap); return; } 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 { const homeworldBlock = homeworldTerrorEntryBlock(g, entry.entrant, entry.amount, 'reveal'); if (homeworldBlock) return homeworldBlock; if (kind === 'atomics') { try { currentAtomicsQuote(g, entry); return null; } catch (error) { if (error instanceof RuleError) return error.message; throw error; } } if (kind === 'robbery' || kind === 'sabotage' || kind === 'sneakAttack' || kind === 'extortion') 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; } 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')!; const homeworldBlock = homeworldAllianceReason(g, owner.id, entrant.id); if (homeworldBlock) return homeworldBlock; 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 blocked = homeworldAllianceReason(g, owner.id, entrant.id); requireRule(!blocked, blocked ?? 'This alliance is unavailable.'); 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; discardAllianceNexusCards(g, owner, entrant); 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.', ); if (entry.stage === 'select') { if (action.decline === true) { requireRule(action.token === undefined, 'Declining leaves every stacked token hidden.'); log(g, `${p.name} left all Terror tokens hidden after this entry.`); finishTerrorEntry(g); return; } requireRule(typeof action.token === 'string' && entry.candidates?.includes(action.token), 'Choose one of the original stacked Terror tokens.'); requireRule(action.reveal === undefined && action.alliance === undefined, 'Choose the token before deciding whether to reveal it or offer an alliance.'); entry.token = action.token; entry.stage = 'offer'; entry.selectionSignature = terrorSelectionSignature(entry); entry.entrySignature = terrorEntrySignature(entry); g.decision = { kind: 'moritaniTerror', player: p.id, entrant: entry.entrant, territory: entry.territory }; return; } 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); if (g.nexusMoritaniLocations) delete g.nexusMoritaniLocations[token.id]; 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 atomics = token.kind === 'atomics' ? currentAtomicsQuote(g, entry) : null; const blocked = atomics ? null : terrorRevealBlocked(g, entry, token.kind); requireRule(!blocked, blocked ?? 'This Terror effect is unavailable.'); g.moritaniTerror = revealTerror(g.moritaniTerror!, token.id); if (g.nexusMoritaniLocations) delete g.nexusMoritaniLocations[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 if (token.kind === 'extortion') { requireRule(!g.moritaniExtortion, 'An Extortion obligation is already pending.'); g.moritaniExtortion = extortionRule(() => reserveExtortion({ owner: owner.id, token: token.id, turn: g.turn, }, extortionContext(g))).state; log(g, 'Five bank spice is reserved for Moritani until Mentat Pause; it is not yet spendable.'); finishTerrorEntry(g); } else if (atomics) { resolveAtomics(g, entry, atomics); } 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); observeOccupation(g); // A positive Sneak Attack transfers Moritani's expressly off-planet // reserves onto the board, so it is classified as an off-planet // shipment for the Heighliners token. The token itself enforces its // working phase, its Guild exception and one accrual per turn. techIncome(g, 'heighliners', p); 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); // The Terror effect is the arrival itself, so the adopted order resolves // the committed entry first and then offers the Bene Gesserit reactions // the same off-planet reserve entry already uses for ordinary shipments. if (amount > 0) { const bg = byFaction(g, 'beneGesserit'); const followup = bg && bg.id !== p.id && spiritualAdvisorMaximum(g, bg.id) > 0 ? { shipment: p.id, destination: location(entry.territory, entry.sector) } : undefined; if (!intrusion(g, p, entry.territory, followup ? { followup } : undefined) && followup) g.decision = { kind: 'advisor', player: bg!.id, ...followup }; } } 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(() => quoteCombatBoard(g)); applyAdvisorReleases(g, quote.released); return battleOrderRule(() => quoteBattleChoosers(quote.battles, g.order, g.battleOrder?.priority)).choices; } function battleOrderRule(calculate: () => T): T { try { return calculate(); } catch (error) { if (error instanceof BattleChooserOrderError) throw new RuleError(error.message); throw error; } } function battleOrderIntegrity(g: Game) { requireRule(!!g.battleOrder === !!g.battleOrderUseEvents, 'The saved battle chooser order is missing its independent use events.'); if (!g.battleOrder) return; requireRule(g.status === 'playing' && g.phase === 6, 'Juice of Sapho battle scheduling expires when the Battle Phase ends.'); battleOrderRule(() => validateBattleChooserOrder(g.battleOrder!, g.order, g.turn, g.battleOrderUseEvents!)); const cards = physicalTreacheryCards(g).filter(card => card.id === 'richese-juice-of-sapho'); requireRule(cards.length === 1 && !!richeseCardDefinition(cards[0]) && cards[0].effect === 'juiceOfSapho', 'The played battle scheduling card has lost its unique physical custody.'); if (g.battle) requireRule(g.battle.chooser && [g.battle.attacker, g.battle.defender].includes(g.battle.chooser) && g.battle.chooserEvent === battleChooserEvent(g.turn, battleOrderAfterBattle(g), g.battleOrder.uses.length) && g.battle.chooser === [g.battle.attacker, g.battle.defender].sort((a, b) => g.battleOrder!.priority.indexOf(a) - g.battleOrder!.priority.indexOf(b))[0], 'The selected battle lost its original scheduling owner.'); else if (saphoBattleBoundary(g)) requireRule(g.active === battleOrderQuote(g).current, 'The active battle chooser contradicts the remaining scheduling priority.'); } function battleOrderAfterBattle(g: Game) { return g.lastBattleContext?.turn === g.turn ? g.lastBattleContext.event : null; } function battleOrderQuote(g: Game) { return battleOrderRule(() => quoteBattleChoosers(quoteCombatBoard(g).battles, g.order, g.battleOrder?.priority)); } function saphoBattleBoundary(g: Game) { return g.phase === 6 && saphoCleanWindow(g) && !g.battle && !g.pendingTreacheryDiscard && !g.pendingRichesePurchaseIncome && !g.pendingCapture && !g.pendingAuditor && !g.pendingFaceDance && !g.pendingTech && !g.pendingChoamBattleIncome && !g.moritaniRetention && !g.choamMarket; } function projectedBattleOrder(g: Game) { if (g.status !== 'playing' || g.phase !== 6) return null; const quote = battleOrderQuote(g); return {event: battleChooserEvent(g.turn, battleOrderAfterBattle(g), g.battleOrder?.uses.length ?? 0), phaseEvent: `battleOrder:${g.turn}`, remaining: quote.remaining, current: saphoBattleBoundary(g) ? quote.current : null, uses: g.battleOrder?.uses.map(({event, player, mode}) => ({event, player, mode})) ?? []}; } /** 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) && !battle.territory.startsWith('homeworld:') && 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' | 'battleOrder'; event: string; mode: 'first' | 'last'; } | { scope: 'battleAggressor'; event: string; mode: 'aggressor' }; 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 saphoMovementStarted(g: Game, p: Player): boolean { // A paid/reserved movement preparation also starts the combined turn. return !!( p.shipped || p.moved > 0 || g.hajr.includes(p.id) || g.karamaShipping || g.ornithopter || (g.choamMovement?.turn === g.turn && p.faction === 'choam') ); } 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)]; } const state: OrderedOpportunity = { event: `movement:${g.turn}`, eligible: g.order, completed: g.order.filter((id) => !remaining.includes(id)), remaining, current: g.active, currentStarted: saphoMovementStarted(g, active), 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 battle = g.battle; if (g.phase === 6 && battle?.event && battle.preLeader?.event === battle.event && !battle.preLeader.closed && !battle.preLeader.ready.includes(p.id) && !battle.revealed && Object.keys(battle.plans).length === 0 && !battle.voice && !battle.prescience && !battle.fullPlan && !battle.nexusInspection && !battle.truthPromises?.length && !g.pendingTreacheryDiscard && [battle.attacker, battle.defender].includes(p.id) && battleAggressor(g) !== p.id) options.push({scope: 'battleAggressor', event: saphoAggressorEvent(battle.event, g.turn, battle.saphoAggressor?.uses.length ?? 0), mode: 'aggressor'}); if (saphoBattleBoundary(g)) { const order = projectedBattleOrder(g)!; if (order.remaining.includes(p.id)) { if (order.current !== p.id) options.push({scope: 'battleOrder', event: order.event, mode: 'first'}); if (order.remaining.at(-1) !== p.id) options.push({scope: 'battleOrder', event: order.event, mode: 'last'}); } } 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 (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.remaining.some((id) => saphoMovementStarted(g, getPlayer(g, id))) && !( g.advanced && queue.remaining.some((id) => getPlayer(g, id).faction === '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 an available mode.', ); 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 === 'battleAggressor') { const b = g.battle!; const next = saphoAggressorRule(() => changeSaphoAggressor(b.saphoAggressor, b.saphoAggressorEvents, b, g.turn, p.id, option.event)); const used = discard(g, p, 'richese-juice-of-sapho'); b.saphoAggressor = next; (b.saphoAggressorEvents ??= []).push(option.event); log(g, `${p.name} discarded Juice of Sapho to become this battle’s aggressor before plans. They now win ordinary ties; a Habbanya Stronghold advantage still takes precedence. Battle participants and later battle choices stay unchanged.`, {faction: p.faction, name: 'Juice of Sapho'}); if (g.semutaPreview) { const continuation: SaphoAggressorDiscardContinuation = { kind: 'saphoAggressorDiscard', player: p.id, card: used.id, battleEvent: b.event!, saphoEvent: option.event!, resume: { response: g.response, decision: g.decision, pendingKarama: g.pendingKarama, phaseOpening: g.phaseOpening }, stateSignature: '', }; const entries = [{ card: used, discardedBy: p.id, publicFace: true }]; if (semutaOfferSupported(g, continuation, entries)) { continuation.stateSignature = saphoAggressorDiscardSignature(g, continuation); stageTreacheryDiscard(g, 'sapho:aggressor', entries, continuation); } } } else 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; const used = 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' }, ); if (g.semutaPreview) { const continuation: SaphoAuctionDiscardContinuation = { kind: 'saphoAuctionDiscard', player: p.id, card: used.id, event: lot.event, mode: option.mode, resume: { response: g.response, decision: g.decision, pendingKarama: g.pendingKarama, phaseOpening: g.phaseOpening }, stateSignature: '', }; const entries = [{ card: used, discardedBy: p.id, publicFace: true }]; if (semutaOfferSupported(g, continuation, entries)) { continuation.stateSignature = saphoAuctionDiscardSignature(g, continuation); stageTreacheryDiscard(g, 'sapho:auction', entries, continuation); return; } } settleRicheseLot(g); } else if (option.scope === 'battleOrder') { const next = battleOrderRule(() => reorderBattleChoosers({state: g.battleOrder, physicalOrder: g.order, turn: g.turn, afterBattle: battleOrderAfterBattle(g), pairs: quoteCombatBoard(g).battles, event: option.event, player: p.id, mode: option.mode})); const used = discard(g, p, 'richese-juice-of-sapho'); g.battleOrder = next; (g.battleOrderUseEvents ??= []).push(option.event); g.active = battleOrderQuote(g).current; log(g, `${p.name} discarded Juice of Sapho to choose their remaining battles ${option.mode} this Battle Phase. Other players may still choose to fight them earlier. Physical storm order and battle aggressor are unchanged.`, {faction: p.faction, name: 'Juice of Sapho'}); if (g.semutaPreview) { const continuation: SaphoBattleOrderDiscardContinuation = { kind: 'saphoBattleOrderDiscard', player: p.id, card: used.id, event: option.event!, mode: option.mode, resume: { response: g.response, decision: g.decision, pendingKarama: g.pendingKarama, phaseOpening: g.phaseOpening }, stateSignature: '', }; const entries = [{ card: used, discardedBy: p.id, publicFace: true }]; if (semutaOfferSupported(g, continuation, entries)) { continuation.stateSignature = saphoBattleOrderDiscardSignature(g, continuation); stageTreacheryDiscard(g, 'sapho:battleOrder', entries, continuation); } } } 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 }; const used = 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' }, ); if (g.semutaPreview) { const continuation: SaphoMovementDiscardContinuation = { kind: 'saphoMovementDiscard', player: p.id, card: used.id, event: option.event, mode: option.mode, previousActive: g.active!, resume: { response: g.response, decision: g.decision, pendingKarama: g.pendingKarama, phaseOpening: g.phaseOpening }, stateSignature: '', }; const entries = [{ card: used, discardedBy: p.id, publicFace: true }]; if (semutaOfferSupported(g, continuation, entries)) { continuation.stateSignature = saphoMovementDiscardSignature(g, continuation); stageTreacheryDiscard(g, 'sapho:movement', entries, continuation); return; } } // 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' | 'discoveryEntry'; discoveryEntry?: DiscoveryEntryArrivalChild; advisorAmbassadorEvent?: string; advisorRemaining?: string[]; advisorFollowup?: { shipment: string; destination: string }; }, ) { const bg = byFaction(g, 'beneGesserit')!; if (details.advisorFollowup && spiritualAdvisorMaximum(g, bg.id) > 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!); else if (details.advisorResume === 'discoveryEntry') { requireRule(details.discoveryEntry, 'The advisor reaction has lost its Discovery arrival.'); completeDiscoveryEntryArrival(g, details.discoveryEntry); } } function intrusion( g: Game, mover: Player, t: string, options: { wormRide?: boolean; discoveryEntry?: DiscoveryEntryArrivalChild; 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 decideChoamMarket(g: Game, decision: Extract, action: Action) { 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.', ); const card = owner.hand.find((c) => c.id === sale.card)!; const blocked = homeworldRule(() => homeworldWorthlessSaleBlock(g, owner.id, card)); requireRule(!blocked, blocked ?? 'This card sale is unavailable.'); market.sale = sale; 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); } } } 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 = g.biddingEnd ? null : { kind: 'choamMarket', player: g.choamMarket!.owner }; } function finishChoamMarket(g: Game) { if (g.biddingEnd || openBiddingEnd(g)) { const owner = g.choamMarket!.owner; if (!g.biddingEnd!.ready.includes(owner)) g.biddingEnd!.ready.push(owner); g.decision = null; return; } const resume = g.choamMarket!.resume; g.choamMarket = null; g.decision = null; if (resume === 'storm') advanceAfterStorm(g); else advancePhase(g); } function nextPhase(g: Game) { if (openBiddingEnd(g)) return; if (!openChoamMarket(g, 'phase')) advancePhase(g); } function openBiddingEnd(g: Game): boolean { const emperor = byFaction(g, 'emperor'); if (g.status !== 'playing' || g.phase !== 3 || !g.homeworlds?.custody || !emperor) return false; if (g.biddingEnd) return true; const choam = byFaction(g, 'choam'); g.biddingEnd = { event: crypto.randomUUID(), turn: g.turn, owners: [emperor.id, ...(choam ? [choam.id] : [])], ready: [] }; if (choam) g.choamMarket ??= { owner: choam.id, resume: 'phase', blocked: [] }; g.decision = null; g.active = null; log(g, 'Bidding is complete. Kaitain disposal and any CHOAM market actions share this closing opportunity; the owners may act in either order.'); return true; } function finishBiddingEnd(g: Game): boolean { if (!biddingEndQuiet(g)) return false; let changed = false; const end = g.biddingEnd!; for (const id of end.owners) { if (end.ready.includes(id) || !biddingEndPubliclyEmpty(g, id)) continue; end.ready.push(id); changed = true; log(g, `${getPlayer(g, id).name} has no cards and no incoming exchange is available. Their end-of-Bidding opportunity finishes automatically.`); } if (end.owners.every((id) => end.ready.includes(id))) { g.biddingEnd = null; g.choamMarket = null; advancePhase(g); return true; } return changed; } function decideBiddingEnd(g: Game, p: Player, action: Action) { const end = g.biddingEnd; requireRule(end && action.event === end.event && end.owners.includes(p.id), 'Choose the current end-of-Bidding opportunity for your faction.'); requireRule(biddingEndQuiet(g), 'Finish the pending interaction before an end-of-Bidding action.'); if (action.mode === 'ready') { requireRule(!end.ready.includes(p.id), 'You are already ready to finish Bidding.'); end.ready.push(p.id); log(g, `${p.name} is ready to finish Bidding. They may still act if another closing opportunity remains.`); } else if (action.mode === 'discard') { const quote = homeworldRule(() => quoteKaitainDiscards(g, p.id, action.cards)); requireRule(quote.cards.length > 0, 'Choose cards to discard, or finish your opportunity.'); for (const card of quote.cards) { const blocked = ambassadorDiscardBlock(g, p, card); requireRule(!blocked, blocked ?? 'This card is committed to another effect.'); } p.spice -= quote.cost; const entries = quote.cards.map((card) => ({ card: discard(g, p, card.id), discardedBy: p.id, publicFace: false })); log(g, `${p.name} paid ${quote.cost} spice to the bank and discarded ${entries.length} Treachery Card${entries.length === 1 ? '' : 's'} through high-population Kaitain. No new auction begins.`, { faction: p.faction, name: 'Kaitain disposal' }); stageTreacheryDiscard(g, 'kaitain', entries, { kind: 'kaitainDiscard', owner: p.id, event: end.event, cost: quote.cost, spiceAfter: p.spice }); } else { requireRule(p.faction === 'choam' && g.choamMarket?.owner === p.id && (action.mode === 'sell' || action.mode === 'trade'), 'Choose Kaitain disposal, a CHOAM market action or finish your opportunity.'); decideChoamMarket(g, { kind: 'choamMarket', player: p.id }, action); } } function advancePhase(g: Game) { if (g.phase === 8) requireRule(!g.moritaniExtortion, 'Settle Extortion before leaving Mentat Pause.'); 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 (closeNexusCards(g)) return; if (g.phase === 6) { delete g.battleOrder; delete g.battleOrderUseEvents; } if (g.phase === 7 && g.grummanCollection?.turn === g.turn && g.grummanCollection.stage === 'waiting') { g.grummanCollection.stage = 'complete'; g.grummanCollection.outcome = 'expired'; g.grummanCollection.signature = grummanCollectionSignature(g.grummanCollection); } if (g.phase === 8) requireRule(!g.moritaniExtortion, 'Settle Extortion before ending the turn.'); if (g.phase === 8) settleStrongholdOwnership(g); g.phase++; if (g.phase === 9) { if (g.dukeVidal) g.dukeVidal = expireDuke(g.dukeVidal, g.turn); observeOccupation(g, 'turnEnd'); g.turn++; g.recruits = null; delete g.guildRateBlocked; g.phase = 0; observeOccupation(g, 'turnStart'); } openPhase(g); } /** Open the same public window for every Ix table, independently of who holds Amal. */ function openPhase(g: Game, initialize = true) { if (g.phase === 1 && g.nexusCards && g.nexusCards.phase?.turn !== g.turn) g.nexusCards.phase = createNexusCardPhase(g.turn); if (initialize && g.phase === 4 && g.homeworlds) { g.homeworldRevival = homeworldRule(() => snapshotHomeworldRevival(g)); if (g.homeworldRevival?.tleilaxu.low) log(g, 'Tleilax began Revival at low population. Tleilaxu will receive no bank reward for other factions’ Free Revival this phase, even if Tleilax later returns to high population. Paid revival and Ghola income are unchanged.', { faction: 'tleilaxu', name: 'Tleilax low population' }); } 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; retireNexusChoamInsight(g); } 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].chooser; else { nextPhase(g); return; } } if (g.phase === 7) { collect(g); } if (g.phase === 8) { collectBankerIncome(g); drawHarkonnenNexusBetrayalReplacement(g); replaceMoritaniAssassinationTraitors(g); 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 finishMoritaniPlacement(g); } if (g.phase === 0 && g.turn > 1) { if (g.discoveryEnabled && g.discoveries) { g.discoveryEntry = nexusRule(() => createDiscoveryEntryRound({...g,discoveries:g.discoveries!},crypto.randomUUID())); continueDiscoveryEntry(g); } else beginTurnBoardMovement(g); } } function beginTurnBoardMovement(g: Game) { const ixians = byFaction(g, 'ixians'); if ( g.mobileStronghold?.location && ixians && !homeworldMobileStrongholdMovementBlock(g, ixians.id) && 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, homeworld = 0) { if (g.homeworlds) { const eligible = charityQuote(g, player); requireRule( eligible.total > 0 && amount === eligible.total && homeworld === eligible.homeworld, 'The saved Homeworld charity claim does not match its eligible ordinary and bank amounts.', ); } requireRule( Number.isSafeInteger(homeworld) && homeworld >= 0 && homeworld <= amount, 'The Homeworld charity payment is invalid.', ); const ordinary = amount - homeworld; 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 >= ordinary, 'CHOAM cannot fund this charity claim.', ); if (payer) payer.spice -= ordinary; player.spice += amount; if (homeworld) log( g, `${player.name} received ${ordinary} ordinary charity spice ${payer ? `from ${payer.name}` : 'from the Spice Bank'} and ${homeworld} additional bank spice for low Homeworld population.`, { faction: player.faction, name: 'Homeworld charity' }, ); 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) { g.stormDialers = []; g.stormPending = (byFaction(g, 'fremen') ? g.stormCard : null) ?? shuffle([1, 2, 3, 4, 5, 6])[0]; g.stormMovementSource = nexusRule(() => createStormSource(g.turn,'card',g.stormPending!)); g.stormCard = null; g.stormCardKnown = false; log(g, `Storm card revealed: ${g.stormPending} sectors.`, undefined, isStormCardDistance(g.stormPending) ? { kind: 'stormCard', distance: g.stormPending } : undefined); } 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 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); const hasCollectionControl = controls.some((control) => { const karama = control!.pendingKarama; const response = control!.response?.kind === 'worthlessKarama' && karama?.use.kind === 'cancel' ? karama.use.response : control!.response; return ( control!.decision?.kind === 'ecazSpice' || response?.kind === 'ecazCollection' ); }); const pending = g.ecazCollection; if (!pending) { requireRule( !hasCollectionControl, 'The shared collection decision has lost its collection event.', ); 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 && !hasCollectionControl, 'Completed collection cannot retain unresolved shared spice.', ); return; } 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 creditGiediCollection(g: Game, player: string, source: HomeworldCollectionSource) { if (!g.homeworlds?.custody || getPlayer(g, player).faction !== 'harkonnen') return; const quote = homeworldRule(() => quoteGiediCollectionReceipt( g, g.turn, player, [source], g.giediCollection, )); const p = getPlayer(g, player); requireRule(Number.isSafeInteger(p.spice + quote.amount), 'Giedi Prime collection would overflow the spice balance.'); p.spice += quote.amount; g.giediCollection = quote.receipt; if (quote.amount) log(g, 'Harkonnen received 2 spice from the bank: high-population Giedi Prime rewards positive desert or Homeworld Collection once this phase.', { faction: 'harkonnen', name: 'Giedi Prime collection' }); } /** Qualification history records facts only; disputed expiry and benefits remain separate. */ function observeOccupation(g: Game, cause: 'change' | 'turnStart' | 'turnEnd' = 'change') { if (g.homeworlds?.historyVersion !== 1 || !g.homeworldOccupationHistory) return; g.homeworldOccupationHistory = homeworldRule(() => observeHomeworldOccupation( g.homeworldOccupationHistory!, homeworldContext(g), g.homeworlds!.custody!, g.turn, cause, cause === 'change' ? `homeworld-change-${g.homeworldOccupationHistory!.sources[0].event}-${g.homeworldOccupationHistory!.sources.length}` : `homeworld-turn-${g.turn}-${cause}`)); syncTupileSlots(g); } function homeworldHistoryIntegrity(g: Game) { const initialized = g.homeworlds?.historyVersion === 1; requireRule(initialized === (g.homeworldOccupationHistory !== undefined), 'The Homeworld qualification history has lost its initialized record.'); if (initialized) { homeworldRule(() => validateHomeworldOccupationHistory(g.homeworldOccupationHistory!, homeworldContext(g), g.turn)); const choam = byFaction(g, 'choam'); requireRule(!!choam === (g.tupileIntelligence !== undefined), 'The original Tupile intelligence ledger is missing.'); if (g.tupileIntelligence) homeworldRule(() => validateTupileIntelligenceState(g.tupileIntelligence!, g.players, g.turn)); } else requireRule(g.tupileIntelligence === undefined, 'Tupile intelligence requires its original Homeworld history.'); homeworldOccupiedIncomeIntegrity(g); occupiedPercentageIntegrity(g); tupileSlotsIntegrity(g); requireRule(!g.homeworldEcazVictoryPreview || g.homeworldOccupationPreview === true && !!g.homeworlds && !!byFaction(g, 'ecaz'), 'The printed Ecaz Homeworld victory requires its original fresh native entry.'); } function homeworldOccupiedIncomeIntegrity(g: Game) { const pending = g.pendingHomeworldOccupiedIncome, history = g.homeworldOccupiedIncomeHistory; if (!g.homeworldOccupationPreview) { requireRule(pending === undefined && history === undefined, 'Occupied income requires its original fresh occupation profile.'); return; } requireRule(g.homeworldOccupationPreview === true && g.homeworlds && !g.nexusCards && (!g.leaderSkills || !g.expansions.length && homeworldLeaderSkillsProfile(g)) && !g.techTokens && !g.strongholdCards && !g.discoveryEnabled && Array.isArray(history) && pending !== undefined, 'The fresh occupation profile lost its original Homeworld module or income ledger.'); const turns = new Set(); for (const receipt of history) { requireRule(receipt.turn <= g.turn && !turns.has(receipt.turn) && receipt.cursor === receipt.queue.length, 'Occupied Collection was incomplete or repeated.'); homeworldRule(() => validateHomeworldOccupiedIncomeState(receipt, g, receipt.turn)); turns.add(receipt.turn); } const decision = g.decision?.kind === 'homeworldOccupiedIncome' ? g.decision : null; if (!pending) { requireRule(!decision, 'The occupied bank choice lost its original Collection receipt.'); return; } requireRule(g.status === 'playing' && g.phase === 7 && pending.turn === g.turn && !turns.has(pending.turn), 'Occupied bank income belongs to one original Collection phase.'); homeworldRule(() => validateHomeworldOccupiedIncomeState(pending, g, g.turn)); if (decision) { const row = pending.queue[pending.cursor]; requireRule(row && decision.event === pending.event && decision.world === row.world && decision.player === row.occupier, 'The bank choice lost its original world or beneficiary.'); } } function homeworldOccupiedIncomeAutomatic(g: Game): boolean { return !!(g.homeworldOccupationPreview && g.pendingHomeworldOccupiedIncome && g.status === 'playing' && g.phase === 7 && !g.phaseOpening && !g.decision && !g.response && !g.truthtrance && !g.pendingTreacheryDiscard && !g.pendingNullentropy && !g.pendingExchange && !g.pendingRicheseGift && !g.pendingKarama && (!g.ecazCollection || g.ecazCollection.stage === 'complete')); } function commitHomeworldOccupiedIncome(g: Game, actor: string, event: string, world: string, own: number) { const pending = g.pendingHomeworldOccupiedIncome!; const result = homeworldRule(() => quoteHomeworldOccupiedIncomeChoice(pending, g, actor, event, world, own)); for (const credit of result.credits) requireRule(Number.isSafeInteger(getPlayer(g, credit.player).spice + credit.amount), 'Occupied bank Collection would overflow a spice balance.'); g.pendingHomeworldOccupiedIncome = result.state; for (const credit of result.credits) { getPlayer(g, credit.player).spice += credit.amount; recordFremenCollection(g, credit.player, `${event}:${world}:${credit.player}`, credit.amount); creditGiediCollection(g, credit.player, { kind: 'homeworld', amount: credit.amount }); log(g, `${getPlayer(g, credit.player).name} received ${credit.amount} spice from ${world}'s occupied bank award.`, { faction: getPlayer(g, credit.player).faction, name: 'Occupied Homeworld Collection' }); } } function resumeHomeworldOccupiedIncome(g: Game) { if (!homeworldOccupiedIncomeAutomatic(g)) return; homeworldOccupiedIncomeIntegrity(g); while (g.pendingHomeworldOccupiedIncome) { const pending = g.pendingHomeworldOccupiedIncome; const row = pending.queue[pending.cursor]; if (!row) { g.homeworldOccupiedIncomeHistory!.push(pending); g.pendingHomeworldOccupiedIncome = null; return; } if (row.ally && row.spice > 0) { g.decision = { kind: 'homeworldOccupiedIncome', player: row.occupier, event: pending.event, world: row.world }; return; } commitHomeworldOccupiedIncome(g, row.occupier, pending.event, row.world, row.spice); } } function decideHomeworldOccupiedIncome(g: Game, p: Player, decision: Extract, action: Action) { homeworldOccupiedIncomeIntegrity(g); requireRule(decision.player === p.id && action.type === 'decision' && action.event === decision.event && action.world === decision.world && Object.keys(action).sort().join(',') === 'event,ownAmount,type,world', 'Choose only the original occupied bank event, world and own allocation.'); commitHomeworldOccupiedIncome(g, p.id, decision.event, decision.world, action.ownAmount as number); g.decision = null; resumeHomeworldOccupiedIncome(g); } function projectedHomeworldOccupiedIncome(g: Game, player: string) { if (g.decision?.kind !== 'homeworldOccupiedIncome' || !g.pendingHomeworldOccupiedIncome) return null; return homeworldRule(() => homeworldOccupiedIncomeOffer(g.pendingHomeworldOccupiedIncome!, g, player)); } function tupileSlotsIntegrity(g: Game, dispatch?: Extract) { const state = g.homeworldTupileState, pending = g.pendingHomeworldTupileCleanup; if (!g.homeworldTupilePreview) { requireRule(state === undefined && pending === undefined && g.players.every(player => player.tupileHandSlot === undefined), 'Occupied Tupile slots require their original fresh profile.'); return; } requireRule(g.homeworldOccupationPreview && state, 'The original Tupile lease ledger is missing.'); homeworldRule(() => validateOccupiedTupileState(state, g)); const lease = state.current === null ? null : state.leases[state.current]; for (const player of g.players) { const backed = !!lease?.holders.includes(player.id) && !state.cleanups.some(row => row.player === player.id && row.status === 'pending'); requireRule(player.tupileHandSlot === (backed ? true : undefined), 'The hand slot lost its original occupied Tupile grant.'); } if (pending) { const row = state.cleanups.find(row => row.event === pending.event); const decision = g.decision ?? dispatch; requireRule(row?.status === 'pending' && row.player === pending.player && pending.signature === JSON.stringify([pending.event, pending.player, pending.hand, pending.opening, g.turn, g.phase]) && JSON.stringify(pending.hand) === JSON.stringify(getPlayer(g, pending.player).hand.map(card => card.id)) && decision?.kind === 'homeworldTupileCleanup' && decision.player === pending.player && decision.event === pending.event && !g.response && !g.phaseOpening, 'The original Tupile hand cleanup or suspended opening changed.'); } else requireRule(g.decision?.kind !== 'homeworldTupileCleanup', 'The Tupile cleanup lost its original source.'); } function syncTupileSlots(g: Game) { if (!g.homeworldTupilePreview || g.status !== 'playing' || !g.homeworlds?.custody) return; const result = homeworldRule(() => syncOccupiedTupileLease(g, g.homeworldTupileState!)); if (result.authority.status === 'unknown') return; g.homeworldTupileState = result.state; for (const player of g.players) { if (result.slotHolders.includes(player.id)) player.tupileHandSlot = true; else delete player.tupileHandSlot; } } function tupileSourceBlock(g: Game): string | null { return g.homeworldTupilePreview && g.status === 'playing' && g.homeworlds?.custody ? homeworldRule(() => quoteOccupiedTupileAuthority(g)).blocked : null; } function tupileCleanupBoundary(g: Game): boolean { return g.status === 'playing' && !g.decision && !g.response && !g.truthtrance && !g.pendingKarama && !g.pendingTreacheryDiscard && !g.pendingNullentropy && !g.pendingExchange && !g.pendingRicheseGift && !g.pendingRichesePurchaseIncome && !g.battle; } function pendingTupileCleanup(g: Game) { const rows = g.homeworldTupileState?.cleanups; if (!rows) return null; for (const player of g.order) { const row = rows.find(row => row.player === player && row.status === 'pending'); if (row) return row; } return null; } function prepareTupileCleanup(g: Game) { if (!g.homeworldTupilePreview || g.pendingHomeworldTupileCleanup || !tupileCleanupBoundary(g) || tupileSourceBlock(g)) return; for (;;) { const row = pendingTupileCleanup(g); if (!row) return; const quote = homeworldRule(() => quoteOccupiedTupileCleanup(g, g.homeworldTupileState!, row.event)); if (quote.blocked) return; if (!quote.excess) { g.homeworldTupileState = homeworldRule(() => completeOccupiedTupileCleanup(g, g.homeworldTupileState!, row.event, row.player)); syncTupileSlots(g); continue; } const hand = getPlayer(g, row.player).hand.map(card => card.id), opening = g.phaseOpening ?? null; g.pendingHomeworldTupileCleanup = { event: row.event, player: row.player, hand, opening, signature: JSON.stringify([row.event, row.player, hand, opening, g.turn, g.phase]) }; g.phaseOpening = null; g.decision = { kind: 'homeworldTupileCleanup', player: row.player, event: row.event }; return; } } function tupileCleanupCards(g: Game, player: Player): string[] { return player.hand.filter(card => !transferCardBlock(g, player, card) && !giftReserved(g, player.id, card.id) && !retentionReservesCard(g, player.id, card.id)) .map(card => card.id); } function projectedTupileCleanup(g: Game, viewer: string) { const pending = g.pendingHomeworldTupileCleanup; if (!pending || pending.player !== viewer) return null; tupileSlotsIntegrity(g); const quote = homeworldRule(() => quoteOccupiedTupileCleanup(g, g.homeworldTupileState!, pending.event)); const player = getPlayer(g, pending.player), eligibleCards = tupileCleanupCards(g, player); return { event: pending.event, player: player.id, playerName: player.name, limit: quote.limit, excess: quote.excess, eligibleCards, blocked: quote.blocked ?? (eligibleCards.length < quote.excess ? 'The original committed cards must settle before this hand cleanup can complete.' : null) }; } /** Resume only the original cleanup ledger and source controls; cards are already discarded. */ function finishTupileCleanup(g: Game, cleanup: NonNullable, discarded: number) { const player = getPlayer(g, cleanup.player); g.homeworldTupileState = homeworldRule(() => completeOccupiedTupileCleanup(g, g.homeworldTupileState!, cleanup.event, player.id)); g.pendingHomeworldTupileCleanup = null; g.decision = null; g.phaseOpening = cleanup.opening; log(g, `${player.name} discarded ${discarded} Treachery Card${discarded === 1 ? '' : 's'} to the normal hand limit after losing the original occupied Tupile slot.`, { faction: player.faction, name: 'Tupile hand cleanup' }); syncTupileSlots(g); prepareTupileCleanup(g); } function decideTupileCleanup(g: Game, player: Player, decision: Extract, action: Action) { tupileSlotsIntegrity(g, decision); const pending = g.pendingHomeworldTupileCleanup!; requireRule(pending && player.id === pending.player && action.event === pending.event && Object.keys(action).sort().join(',') === 'cards,event,type' && Array.isArray(action.cards) && action.cards.every(id => typeof id === 'string') && new Set(action.cards).size === action.cards.length, 'Choose the distinct original held cards for this Tupile cleanup.'); const quote = homeworldRule(() => quoteOccupiedTupileCleanup(g, g.homeworldTupileState!, pending.event)); const selected = action.cards as string[], eligible = tupileCleanupCards(g, player), physical = physicalTreacheryCards(g); requireRule(!quote.blocked && selected.length === quote.excess && selected.every(id => eligible.includes(id) && physical.filter(card => card.id === id).length === 1), 'Discard exactly the normal-limit excess without consuming a reserved card.'); const entries = selected.map(id => ({ card: discard(g, player, id), discardedBy: player.id, publicFace: false })); if (g.semutaPreview && semutaHomeworldRoster(g) && entries.length > 0) { const continuation: TupileDiscardContinuation = { kind: 'tupileCleanupDiscard', cleanup: structuredClone(pending), cards: [...selected], stateSignature: '', }; g.pendingHomeworldTupileCleanup = null; g.decision = null; g.phaseOpening = null; continuation.stateSignature = tupileDiscardSignature(g, continuation); stageTreacheryDiscard(g, 'homeworld:tupileCleanup', entries, continuation); return; } finishTupileCleanup(g, pending, selected.length); } function tupileAutomatic(g: Game): boolean { if (!g.homeworldTupilePreview || g.pendingHomeworldTupileCleanup || g.status !== 'playing' || !g.homeworlds?.custody || tupileSourceBlock(g)) return false; const result = homeworldRule(() => syncOccupiedTupileLease(g, g.homeworldTupileState!)); return result.state.signature !== g.homeworldTupileState!.signature || !!(result.pending.length && tupileCleanupBoundary(g)); } function currentTupileOccupation(g: Game) { return g.homeworldTupilePreview ? homeworldRule(() => quoteOccupiedTupileAuthority(g)).status : tupileOccupationStatus(g.homeworldOccupationHistory, homeworldContext(g)); } function tupileIntelligenceBlock(g: Game): string | null { if (!g.homeworlds?.custody || !g.tupileIntelligence || !g.homeworldOccupationHistory) return 'This saved game lacks the original occupation and intelligence history required for Tupile.'; if (g.status !== 'playing') return 'Tupile intelligence is available during play.'; if (g.phaseOpening || g.response || g.decision || g.truthtrance || g.pendingKarama || g.pendingTreacheryDiscard || g.pendingNullentropy || g.pendingExchange || g.pendingRicheseGift || g.pendingRichesePurchaseIncome || g.battle?.revealed) return 'Finish the current response or committed action before requesting Tupile intelligence.'; return null; } function projectedTupileIntelligence(g: Game, id: string) { const owner = byFaction(g, 'choam'); if (!owner || owner.id !== id || !g.homeworlds?.custody) return null; const state = g.tupileIntelligence; const targets = homeworldRule(() => tupileIntelligenceTargets(homeworldContext(g), g.homeworlds!.custody!, id, state?.receipts.map((receipt) => receipt.faction) ?? [], currentTupileOccupation(g))); return {owner: id, blocked: tupileIntelligenceBlock(g), targets, receipts: (state?.receipts ?? []).map(({event, target, faction, category, spice, count, turn, phase}) => ({event, target, faction, category, spice, count, turn, phase}))}; } function requestTupileIntelligence(g: Game, p: Player, action: Action) { const blocked = tupileIntelligenceBlock(g); requireRule(!blocked, blocked ?? 'Tupile intelligence is unavailable.'); requireRule(p.faction === 'choam' && g.tupileIntelligence!.owner === p.id, 'Only CHOAM may request its private Tupile intelligence.'); requireRule(Object.keys(action).every((key) => ['type', 'target', 'category'].includes(key)) && typeof action.target === 'string' && (action.category === 'weapons' || action.category === 'defenses'), 'Choose one opposing faction and either weapons or defenses.'); const request = homeworldRule(() => quoteTupileIntelligenceRequest(homeworldContext(g), g.homeworlds!.custody!, p.id, g.tupileIntelligence!.receipts.map((receipt) => receipt.faction), currentTupileOccupation(g), action.target as string, action.category as TupileIntelligenceCategory)); const target = getPlayer(g, request.target); const answer = homeworldRule(() => quoteTupileIntelligenceAnswer(target.hand, target.spice, request.category)); g.tupileIntelligence = homeworldRule(() => appendTupileIntelligenceObservation(g.tupileIntelligence!, { event: crypto.randomUUID(), ...request, ...answer, turn: g.turn, phase: g.phase})); log(g, `${p.name} used Tupile intelligence against ${faction(target.faction).name}. The spice balance and chosen card count were recorded privately; this faction cannot be questioned again.`, {faction: 'choam', name: 'Tupile intelligence'}); } function grummanCollectionIntegrity(g: Game) { homeworldRule(() => validateGrummanCollection(g)); const frame = g.grummanCollection; const continuation = g.pendingTreacheryDiscard?.continuation; const contexts = [g, g.pendingExchange, g.pendingNullentropy?.resume, g.pendingRicheseGift?.resume, g.pendingRichesePurchaseIncome?.resume, continuation && 'resume' in continuation ? continuation.resume : null]; const decisions = contexts.flatMap((context) => context?.decision?.kind === 'grummanCollection' ? [context.decision] : []); requireRule(decisions.every((decision) => frame?.stage === 'choice' && decision.player === frame.player && decision.event === frame.event) && (frame?.stage !== 'choice' || decisions.length > 0), 'The Grumman Collection choice has lost its original phase opportunity.'); } function stageGrummanCollection(g: Game) { const owner = byFaction(g, 'moritani'); if (!owner || !g.homeworlds?.custody || g.grummanCollection?.turn === g.turn) return; const frame: GrummanCollection = { event: crypto.randomUUID(), turn: g.turn, player: owner.id, stage: 'waiting', signature: '' }; frame.signature = grummanCollectionSignature(frame); g.grummanCollection = frame; } function grummanCollectionAutomatic(g: Game): boolean { const frame = g.grummanCollection; return !!(frame?.stage === 'waiting' && frame.turn === g.turn && g.phase === 7 && !g.phaseOpening && !g.pendingHomeworldOccupiedIncome && !g.decision && !g.response && !g.truthtrance && !g.pendingTreacheryDiscard && !g.pendingNullentropy && !g.pendingExchange && !g.pendingRicheseGift && !g.pendingKarama && (!g.ecazCollection || g.ecazCollection.stage === 'complete') && homeworldRule(() => quoteGrummanCollection(g, frame.player)).high); } function resumeGrummanCollection(g: Game) { if (!grummanCollectionAutomatic(g)) return; const frame = g.grummanCollection!; frame.stage = 'choice'; frame.signature = grummanCollectionSignature(frame); g.decision = { kind: 'grummanCollection', player: frame.player, event: frame.event }; } function grummanCunningGrant(g: Game, frame: GrummanCollection, quote: ReturnType) { const owner = getPlayer(g, frame.player); if (g.nexusCards?.cards?.hands[owner.id] !== 'moritani') return null; return { event: JSON.stringify(['nexusMoritaniGrumman', g.turn, owner.id, frame.event]), blocked: owner.ally ? 'Nexus Cunning requires an unallied Moritani player.' : quote.blocked, prerequisite: quote.destinations[0] ?? null }; } function decideGrummanCollection(g: Game, p: Player, action: Action) { const frame = g.grummanCollection; requireRule(frame?.stage === 'choice' && frame.player === p.id && frame.event === action.event, 'Choose the current Grumman Collection opportunity.'); if (action.decline === true) { requireRule(action.token === undefined && action.territory === undefined && action.mode === undefined && action.nexus === undefined, 'Declining Grumman does not move a token, spend Cunning or collect spice.'); frame.outcome = 'decline'; log(g, `${p.name} declined Grumman’s optional Terror change; no bank spice was collected.`); } else { requireRule(action.mode === 'add' || action.mode === 'remove', 'Choose a Grumman token operation.'); requireRule(typeof action.token === 'string', 'Choose a physical Terror token.'); requireRule(Number.isSafeInteger(p.spice + 4), 'Grumman income would overflow the spice balance.'); if (action.mode === 'add' && action.nexus !== undefined) { const ordinary = homeworldRule(() => quoteGrummanCollection(g, p.id)); const grant = grummanCunningGrant(g, frame, ordinary); requireRule(grant && !grant.blocked && action.nexus === grant.event && grant.prerequisite, grant?.blocked ?? 'Choose the current Cunning modifier for an already eligible Grumman addition.'); requireRule(typeof action.territory === 'string', 'Choose a permitted Arrakis destination.'); const placed = nexusRule(() => quoteNexusMoritaniGrummanPlacement(g.moritaniTerror!, action.token as string, action.territory as string, grant.prerequisite!)); const receipt = nexusRule(() => createNexusMoritani(g, p.id, action.token as string, action.territory as string, { source: 'grumman', sourceEvent: frame.event, prerequisite: grant.prerequisite! })); const record = { receipt, stage: 'complete' as const, before: JSON.stringify(g.moritaniTerror), frame: JSON.stringify({ token: receipt.token, territory: receipt.territory, turn: receipt.turn, nexusEvent: receipt.event, sourceEvent: frame.event, prerequisite: grant.prerequisite }), signature: '' }; record.signature = nexusMoritaniRecordSignature(record); g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!, p.id, g.players)); (g.nexusMoritaniHistory ??= []).push(record); g.nexusMoritaniLast = { event: receipt.event, stage: record.stage }; g.moritaniTerror = placed; (g.nexusMoritaniLocations ??= {})[receipt.token] = { event: receipt.event, territory: receipt.territory }; frame.outcome = 'add'; frame.token = receipt.token; frame.territory = receipt.territory; frame.nexusEvent = receipt.event; p.spice += 4; log(g, `${p.name} used Moritani Nexus Cunning to add one hidden Terror token to ${territory(receipt.territory).name} from an eligible Grumman addition and received 4 spice from the bank. The separate Mentat placement is unchanged.`, { faction: p.faction, name: 'Grumman Collection' }); } else { requireRule(action.nexus === undefined, 'Returning a Terror token does not use Cunning.'); requireRule(action.mode !== 'remove' || action.territory === undefined, 'Returning a Terror token has no destination selection.'); const destination = typeof action.territory === 'string' ? action.territory : ''; requireRule(action.mode !== 'add' || !!destination, 'Choose a stronghold for the added token.'); const original = g.moritaniTerror!.tokens.find(token => token.id === action.token); const intent = action.mode === 'remove' ? { mode: 'remove' as const, token: action.token } : { mode: 'add' as const, token: action.token, destination }; const quote = homeworldRule(() => quoteGrummanCollectionAction(g, p.id, intent, random)); g.moritaniTerror = quote.state; p.spice += quote.amount; frame.outcome = action.mode; frame.token = action.token; frame.territory = action.mode === 'remove' ? original!.location! : destination; if (action.mode === 'remove') { if (g.nexusMoritaniLocations) delete g.nexusMoritaniLocations[action.token]; log(g, `${p.name} returned one unrevealed Terror token from ${territory(frame.territory).name} to hidden supply and received 4 spice from the bank. The separate Mentat placement is unchanged.`, { faction: p.faction, name: 'Grumman Collection' }); } else { log(g, `${p.name} added one hidden Terror token to ${territory(frame.territory).name}, which already held Terror, and received 4 spice from the bank. The separate Mentat placement remains available according to its own usage.`, { faction: p.faction, name: 'Grumman Collection' }); } } } frame.stage = 'complete'; frame.signature = grummanCollectionSignature(frame); } function projectedGrummanCollection(g: Game, player: string) { const frame = g.grummanCollection; if (frame?.stage !== 'choice') return null; const quote = homeworldRule(() => quoteGrummanCollection(g, frame.player)); const own = player === frame.player; const cunning = own ? grummanCunningGrant(g, frame, quote) : null; return { event: frame.event, player: frame.player, blocked: quote.blocked, removeBlocked: quote.removeBlocked, tokens: own ? quote.tokens.map(token => ({ id: token.id, kind: token.kind })) : [], destinations: own ? quote.destinations.map(id => ({ id, name: territory(id).name })) : [], removable: own ? quote.removable.map(token => ({ id: token.id, kind: token.kind, territory: token.location!, name: territory(token.location!).name })) : [], cunning: cunning ? { ...cunning, destinations: TERRITORIES.map(place => ({ id: place.id, name: place.name })) } : null }; } function commitCollection( g: Game, quote: CollectionQuote, canceled = false, ) { let discovery: DiscoveryCollectionQuote; try { discovery = quoteDiscoveryCollection(g, quote); } catch (error) { if (error instanceof DiscoveryCollectionError) throw new RuleError(error.message); throw error; } const sourceSpice = { ...g.spice }; applyAdvisorReleases(g, quote.released); g.spice = quote.spice; for (const receipt of discovery.receipts) { const p = getPlayer(g, receipt.player); p.spice = receipt.balance; recordFremenCollection(g, p.id, `ordinary:${p.id}`, receipt.strongholds + receipt.collected); 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.`, { faction: p.faction, name: 'Spice collection' }, ); } for (const effect of discovery.effects) { const p = getPlayer(g, effect.player); recordFremenCollection(g, p.id, `${effect.kind}:${effect.kind === 'orgiz' ? effect.location : 'bank'}:${p.id}`, effect.amount); if (effect.kind === 'cistern') log( g, `${faction(p.faction).name} received ${effect.amount} spice from the bank for occupying Cistern.`, { faction: p.faction, name: 'Cistern' }, ); else log( g, `${faction(p.faction).name} took ${effect.amount} spice from ${faction(getPlayer(g, effect.from).faction).name} through Orgiz Processing Station${g.advanced ? ' for collection in ' : ''}${g.advanced ? territory(splitLocation(effect.location).territory).name : ''}.`, { faction: p.faction, name: 'Orgiz Processing Station' }, ); } 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.`, ); } for (const receipt of discovery.receipts) creditGiediCollection(g, receipt.player, { kind: 'desert', amount: receipt.desert }); stageGrummanCollection(g); } function collect(g: Game) { if (g.homeworldOccupationPreview && byFaction(g, 'fremen') && g.fremenCollectionCredits?.turn !== g.turn) g.fremenCollectionCredits = { turn: g.turn, event: crypto.randomUUID(), collected: [], complete: false }; if (g.homeworldOccupationPreview && !g.pendingHomeworldOccupiedIncome && !g.homeworldOccupiedIncomeHistory!.some(receipt => receipt.turn === g.turn)) g.pendingHomeworldOccupiedIncome = homeworldRule(() => beginHomeworldOccupiedIncome(g, crypto.randomUUID())); if (g.grummanCollection?.turn === g.turn) { grummanCollectionIntegrity(g); return; } if (g.ecazCollection?.turn === g.turn) { ecazCollectionIntegrity(g); return; } if (g.giediCollection?.turn === g.turn) { homeworldRule(() => validateGiediCollection(g, g.turn, g.giediCollection)); 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; recordFremenCollection(g, receipt.ecaz, `shared:${receipt.territory}:${receipt.ecaz}`, receipt.ecazAmount); recordFremenCollection(g, receipt.ally, `shared:${receipt.territory}:${receipt.ally}`, receipt.allyAmount); creditGiediCollection(g, receipt.ecaz, { kind: 'desert', amount: receipt.ecazAmount }); creditGiediCollection(g, receipt.ally, { kind: 'desert', amount: 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' }, ); const balances = Object.fromEntries(g.players.map(player => [player.id,player.spice])); const transfers = nexusRule(() => quoteSharedOrgiz(g,receipt,balances,pending.sourceSpice)); for (const transfer of transfers) { getPlayer(g,transfer.from).spice -= transfer.amount; getPlayer(g,transfer.player).spice += transfer.amount; recordFremenCollection(g,transfer.player,`orgiz:shared:${transfer.location}:${transfer.player}`,transfer.amount); log(g, `${faction(getPlayer(g,transfer.player).faction).name} took ${transfer.amount} spice from ${faction(getPlayer(g,transfer.from).faction).name} through Orgiz Processing Station after the original shared territory allocation.`, {faction:getPlayer(g,transfer.player).faction,name:'Orgiz Processing Station'}); } } 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 progress = strongholdProgress(g).progress; const occupy = 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)), ); const homeworld = quote.ecazHomeworld && quote.ecazHomeworld.members.length === g.winner.length && quote.ecazHomeworld.members.every(id => g.winner.includes(id)) ? quote.ecazHomeworld : null; const fremen = !quote.ecazHomeworld && g.turn === 10 && !progress.some((row) => row.qualifies) ? fremenSpecialVictory(g) : null; const fremenWon = fremen?.qualifies && fremen.members.length === g.winner.length && fremen.members.every((id) => g.winner.includes(id)); const specialReason = fremenWon ? ` Fremen special victory: both protected sietches meet the occupation condition and no prohibited faction occupies Tuek’s Sietch.${fremen.sietches.some((site) => site.ecazCooccupation) ? ' Allied Ecaz and Fremen may co-occupy Sietch Tabr without blocking this victory.' : ''}` : ''; 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.` : ''}${homeworld ? ' High Ecaz Homeworld victory: the alliance jointly holds a stronghold and occupies Homeworlds of two other native factions.' : ''}${specialReason}`, ); } 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 inspectedPlanValue(p: Player, field: PlanField, value: string | number | null | undefined) { return battleCategoryInspectionValue(field, value, (field === 'weapon' || field === 'defense') && typeof value === 'string' ? cardOf(p, value) : undefined); } function inspectedPlanMatches(p: Player, field: PlanField, fixed: unknown, candidate: string | number | null | undefined) { return fixedBattleInspectionMatches(field, fixed as string | number | null, candidate, (field === 'weapon' || field === 'defense') && typeof candidate === 'string' ? cardOf(p, candidate) : undefined); } function harassWithdrawContext(g: Game, p: Player): HarassWithdrawContext { const b = g.battle!; const other = getPlayer(g, b.attacker === p.id ? b.defender : b.attacker); const combinedBlock = combinedCardBattleBlock(g); let blocked: string | null = null; if (!g.ecazTreachery) blocked = 'Enable the independent Ecaz Treachery Cards variant before using Harass & Withdraw.'; else if (g.status !== 'playing' || g.phase !== 6 || ![b.attacker, b.defender].includes(p.id)) blocked = 'Use Harass & Withdraw in your own battle.'; else if (g.homeworlds || g.nexusCards || g.leaderSkills || g.techTokens || g.strongholdCards || g.discoveryEnabled) blocked = 'Harass & Withdraw with other optional modules, including Homeworlds, is still being implemented.'; else if (g.players.some(player => player.faction === 'richese') || g.richeseCache !== undefined || g.richeseRemoved !== undefined) blocked = 'Harass & Withdraw with the Richese card family awaits the Stone Burner timing ruling.'; else if (combinedBlock) blocked = combinedBlock; else if (p.noField?.deployed?.location.territory === b.territory) blocked = 'Reveal your No-Field before using Harass & Withdraw.'; const locations = Object.fromEntries(Object.entries(p.forces) .filter(([key, amount]) => amount > 0 && splitLocation(key).territory === b.territory) .map(([key, amount]) => [key, { normal: amount - (p.elites?.forces[key] ?? 0), elite: p.elites?.forces[key] ?? 0 }])); const profile = ecazBattleProfile(g); const forces = profile?.lead === p.id ? p.id === profile.ecaz ? profile.ecazForces : profile.allyForces : combatForces(g, p, b.territory, profile?.lead === other.id ? getPlayer(g, profile.forceOwner) : other); return { blocked, forces, locations, ...(profile?.lead === p.id ? { occupy: { cardUser: p.id, ecaz: profile.ecaz, ally: profile.ally, canceled: profile.canceled, fixedEcazDial: profile.fixedEcazDial, variableForces: profile.forces, } } : {}) }; } function currentHarassWithdrawQuote(g: Game, p: Player, plan: Pick) { try { const context = harassWithdrawContext(g, p); const receipt = g.battle?.harassAllocation; const selection = receipt?.player === p.id && receipt.stage === 'selected' ? receipt.selection! : g.battle?.harassAllocationVersion === 1 ? defaultHarassWithdrawAllocation(context, plan.dial, plan.support) : undefined; const quote = quoteHarassWithdraw(context, plan.dial, plan.support, selection); validateReserveReturnCounters(p, quote.returned); return quote; } catch (error) { if (error instanceof HarassWithdrawError) throw new RuleError(error.message); throw error; } } function validateReserveReturnCounters(p: Player, returned: { normal: number; elite: number }, source = 'Harass & Withdraw') { requireRule(Number.isSafeInteger(p.reserves) && p.reserves >= 0 && Number.isSafeInteger(p.reserves + returned.normal + returned.elite) && (!p.elites || (Number.isSafeInteger(p.elites.reserves) && p.elites.reserves >= 0 && p.elites.reserves <= p.reserves && Number.isSafeInteger(p.elites.reserves + returned.elite))), `${source} needs valid reserve and elite counters before any forces return.`); } function harassWithdrawPreview(g: Game, p: Player): HarassWithdrawPreview | null { const b = g.battle; if (!b || ![b.attacker, b.defender].includes(p.id) || !p.hand.some(isHarassWithdraw)) return null; const context = harassWithdrawContext(g, p); return { ...context, card: HARASS_WITHDRAW_CARD, allowAllocation: b.harassAllocationVersion === 1, blocked: context.blocked ?? (b.revealed || b.plans[p.id] ? 'Your Battle Plan is already sealed.' : null) }; } 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.attacker, b.defender].some(owner => committedPlanElements(b, owner).some(element => element.field === 'leader')), 'Residual Poison must be played before either combatant commits a leader.', ); requireRule(!(b.nexusInspection?.mode === 'cunning' && b.nexusInspection.stage === 'answered'), 'Residual Poison after two inspected elements awaits the ruling for incompatible surviving commitments.'); 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); const used = 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' }, ); if (g.semutaPreview) { const continuation: ResidualPoisonDiscardContinuation = { kind: 'residualPoisonDiscard', player: p.id, target: target.id, victim: victim.id, battleEvent: b.event!, card: used.id, resume: { response: g.response, decision: g.decision, pendingKarama: g.pendingKarama, phaseOpening: g.phaseOpening }, stateSignature: '', }; const entries = [{ card: used, discardedBy: p.id, publicFace: true }]; if (semutaOfferSupported(g, continuation, entries)) { continuation.stateSignature = residualPoisonDiscardSignature(g, continuation); stageTreacheryDiscard(g, 'residualPoison', entries, continuation); return; } } reconcileChangedBattleInspections(g, 'the leader death'); if (b.preLeader && !b.preLeader.closed) b.preLeader.ready = b.preLeader.ready.filter((id) => id !== p.id); } function reconcileChangedBattleInspections(g: Game, cause: string) { const b = g.battle!; // Retire an inspection made impossible by the opposing effect. Testing on // a private copy without Truthtrance avoids releasing a still-feasible truth // answer solely because an obsolete inspection was left in the trial. const insight = b.prescience; if (insight && 'value' in insight && insight.field !== 'leader') { const trial = structuredClone(g); trial.battle!.truthPromises = []; // A different impossible disclosure must not falsely retire this one. // The real extra answer is tested afterward against any retained native // commitment; the trial keeps its signed first-answer relationship. if (trial.battle!.nexusInspection?.mode === 'cunning' && trial.battle!.nexusInspection.stage === 'answered' && trial.battle!.nexusInspection.field !== 'leader') trial.battle!.nexusInspection = nexusRule(() => reopenNexusInspection(battleInspectionContext(trial),trial.battle!.nexusInspection!)); const answering = getPlayer( trial, insight.player === b.attacker ? b.defender : b.attacker, ); if (!feasiblePrescience(trial, answering, insight.field, insight.value)) { if (b.nexusInspection?.mode === 'cunning') b.nexusInspection = nexusRule(() => reopenNexusNative(battleInspectionContext(g), b.nexusInspection!)); 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 ${cause}. Its owner must answer that same element again; the earlier private value is not revealed.`, ); } } const nexus = b.nexusInspection; if (nexus?.stage === 'answered' && nexus.field !== 'leader') { const trial = structuredClone(g); trial.battle!.truthPromises = []; if (!findReachableBattlePlan(trial, getPlayer(trial, nexus.target))) { b.nexusInspection = nexusRule(() => reopenNexusInspection(battleInspectionContext(g), nexus)); finishInspectionAnswers(g); log(g, `After ${cause}, the inspected element became impossible. Its owner must answer the same element again; the earlier private observation is retained.`); } } reconcileBattlePromises(g); } 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 b = g.battle!, t = b.territory, occupy = b.ecazOccupy; const owner = occupy?.lead === p.id && !occupy.canceled ? getPlayer(g, occupy.ally) : p; const enemy = occupy?.lead === opponent.id && !occupy.canceled ? getPlayer(g, occupy.ally) : opponent; const hiddenMarker = owner.noField?.deployed?.location.territory === t; const pool = combatForces(g, hiddenMarker ? { ...owner, noField: undefined } : owner, t, enemy); 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 stoneOccupyContext(g: Game): StoneBurnerContext | undefined { const b = g.battle!, profile = ecazBattleProfile(g); if (!profile || profile.canceled) return undefined; const side = { fixedEcazDial: profile.fixedEcazDial, ecazUndialed: Math.floor(profile.ecazForces.normal / 2) }; return profile.lead === b.attacker ? { attacker: side } : { defender: side }; } 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); const context = stoneOccupyContext(g); return ( stoneTimingBlock(g) ?? stonePublicPools(g, opponent, p) .map((pool) => stoneBurnerPlanBlock( planCombatForces(g, p, opponent), dial, support, pool, b.attacker === p.id ? 'attacker' : 'defender', battleTieWinner(g) === b.attacker ? 'attacker' : 'defender', context, ), ) .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); const context = stoneOccupyContext(g); const fixedDial = (b.attacker === target.id ? context?.attacker : context?.defender)?.fixedEcazDial ?? 0; for (const own of stonePublicPools(g, target, other)) { const supported = Array.from( { length: maxCombatDial(own) * 2 + 1 }, (_, n) => fixedDial + n / 2, ).some( (dial) => casualtyOptions(own, dial - fixedDial, 0).length && opponents.every( (opponent) => !stoneBurnerPlanBlock( own, dial, 0, opponent, b.attacker === target.id ? 'attacker' : 'defender', battleTieWinner(g) === b.attacker ? 'attacker' : 'defender', context, ), ), ); 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 = planCombatForces(g, p, opponent); const profile = ecazBattleProfile(g); const occupy = profile?.lead === p.id ? profile : null; const typedForces = g.advanced || p.faction === 'ixians' || !!occupy; const bankerSpice = input.bankerSpice === undefined ? 0 : input.bankerSpice; requireRule(typeof bankerSpice === 'number' && Number.isSafeInteger(bankerSpice) && bankerSpice >= 0 && bankerSpice <= 3, 'Choose zero to decline, or one through three spice for Spice Banker.'); 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' && (occupy ? nexusRule(() => quoteEcazOccupyDial(occupy, dial, support)).options : 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 - bankerSpice)), Math.max(0, battleSupportCost(g, p, support) - (p.spice - bankerSpice)), Math.min(battleSupportCost(g, p, support), battleAidFor(g, p)?.amount ?? 0), 'Battle ally payment', ); const plan: Plan = { ...(bankerSpice ? { bankerSpice } : {}), ...(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, }; if (b.ecazSpecialKarama?.owner === p.id) requireRule(ecazSpecialKaramaPlanAllowed(plan), 'The declared Ecaz special Karama requires neither a weapon nor a defense.'); 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); nexusRule(() => validateSpiceBankerSpend(battleLeaderSkills(g, p), l?.id, bankerSpice, p.spice, battleSupportCost(g, p, support) - allyPayment)); if (bankerSpice) requireRule(spiceBankerModeSupported(g), 'Spice Banker with expansion modules is still being integrated.'); requireRule(!l || !g.leaderSkills?.assignments.some((assignment) => assignment.owner === p.id && assignment.leader === l.id) || !!b.leaderSkillHidden?.[p.id], 'Move your skilled leader behind the shield before using it in your battle plan.'); 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); if (b.smugglerCollectionVersion === 1) { const smugglerPlan = { assignments: battleLeaderSkills(g, p), leader: l, weapon: w, defense: d, kwisatz: plan.kwisatz }; const block = smugglerBattlePlanBlock(smugglerPlan, smugglerBattleModeSupported(g), { territory: b.territory, spice: g.spice }); requireRule(!block, block ?? 'Smuggler collection is unavailable for this plan.'); if (smugglerPlan.assignments.some(a => a.skill === 'smuggler' && a.leader === l?.id)) nexusRule(() => smugglerBattlePile(b.territory, g.spice)); } const planetologistWeapon = canUsePlanetologistWeapon( g, p, plan.leader, w, ); requireRule( !plan.weapon || (w && battleCardSlotEligible('weapon', w, { planetologistWeapon })), 'Choose a weapon, Worthless card, eligible Planetologist Special, Harass & Withdraw, or Reinforcements.', ); requireRule( !plan.defense || (d && battleCardSlotEligible('defense', d)), 'Choose a defense, Worthless card, Harass & Withdraw, or Reinforcements.', ); 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( validBattleSlotPair(w, d, planetologistWeapon), 'Chemistry as a weapon needs another defense; Weirding Way as a defense needs another weapon.', ); if ([w, d].some(isReinforcements)) { requireRule(![w, d].some(card => isHarassWithdraw(card) || isStoneBurner(card)), 'Reinforcements cannot share this bounded plan with Harass & Withdraw or Stone Burner.'); currentReinforcementsCost(g, p); } if ([w, d].some(isHarassWithdraw)) currentHarassWithdrawQuote(g, p, plan); if (isMirrorWeapon(w)) { const blocked = mirrorWeaponModeBlock(g) ?? stonePlanBlock(g, p, dial, support); requireRule(!blocked, blocked ?? 'Mirror Weapon cannot resolve this commitment.'); } 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.', ); } for (const element of committedPlanElements(b, p.id)) requireRule( inspectedPlanMatches(p, element.field, element.value, plan[element.field]), 'Every element revealed by a battle inspection 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, turn: g.turn, ...(g.battle?.ecazOccupy ? { ecazOccupy: ecazBattleProfile(g)! } : {}), territoryIds: combatLocations(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 = []; const profile = ecazBattleProfile(g); for (const id of [b.attacker, b.defender]) { const planOwner = getPlayer(g, id); const forceOwner = profile?.lead === id ? profile.forceOwner : id; const p = getPlayer(g, forceOwner); const opponentId = id === b.attacker ? b.defender : b.attacker; const other = getPlayer(g, profile?.lead === opponentId ? profile.forceOwner : opponentId); if (g.advanced && planOwner.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') ) { const forces = combatForces(g, p, b.territory, other); if (forces.elite > 0 && forces.eliteStrength > 1) b.powerChecks.push({ kind: 'eliteStrength', owner: forceOwner }); } if (g.advanced && p.faction === 'fremen') b.powerChecks.push({ kind: 'fremenSupport', owner: forceOwner }); } 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', fresh = false) { const b = g.battle!; if (fresh && kind === 'voice' && g.nexusCards?.cards && !byFaction(g, 'beneGesserit') && !b.nexusBorrowedVoice && !b.voice && !b.prescience && !b.nexusInspection && !Object.keys(b.plans).length) { requireRule(b.event, 'Borrowed Voice requires the original battle event.'); b.nexusBorrowedVoice = createNexusBorrowedVoice({ turn: g.turn, battle: { event: b.event, attacker: b.attacker, defender: b.defender }, }); b.preparation = { kind: 'nexusVoice', owner: b.attacker, beneficiary: b.attacker }; return; } 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) { const target = b.attacker === beneficiary ? b.defender : b.attacker; const occupation = kind === 'voice' ? homeworldRule(() => quoteOccupiedHomeworldVoice(g, target)) : null; if (occupation?.status === 'prohibited') { battlePreparation(g, 'prescience'); return; } b.preparation = { kind, owner: owner.id, beneficiary, ...(occupation?.blocked ? { blocked: occupation.blocked } : {}) }; } 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 = inspectedPlanValue(getPlayer(g, target), b.prescience.field, priorPlan[b.prescience.field]) as string | number | null; 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) { const native = controlledLeaders(g, p).filter( (l) => l.id !== DUKE_VIDAL_ID && l.dead && !l.capturedBy, ); if (p.faction === 'ecaz' && !ecazDukeRevivalBlock(g, p.id)) native.push(g.dukeVidal!.leader); return native; } 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 eliteBlock = homeworldSardaukarGholaBlock(g, p.id); const eliteRemaining = eliteBlock ? 0 : Math.min( p.elites?.tanks ?? 0, eliteRevivalRemaining(p, g.advanced), ); 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, eliteBlock, available: reason === null, reason, }; } /** Old Aftermath already records its exact affected seats. Adopt those targets * without rewriting forces, hands, alliances or the original activation. */ function migrateAtomicsTargets(state: Game): Game { const aftermath = state.moritaniAtomics; if (!aftermath || (aftermath as unknown as { alliancePolicy?: unknown }).alliancePolicy !== 'unresolved') return state; const migrated = { ...state, moritaniAtomics: { ...aftermath, alliancePolicy: 'activation-targets' as const }, }; atomicsIntegrity(migrated); return migrated; } function atomicsIntegrity(g: Game) { const aftermath = g.moritaniAtomics; if (!aftermath) { requireRule(g.players.every(player => player.atomicsHandLimitPenalty === undefined), 'Atomics hand-limit penalties require the physical Aftermath.'); return; } const owner = byFaction(g, 'moritani'); const token = g.moritaniTerror?.tokens.find(row => row.kind === 'atomics'); requireRule(atomicsModeSupported(g) && owner?.id === aftermath.moritaniId && Number.isSafeInteger(aftermath.turn) && aftermath.turn >= 1 && aftermath.turn <= g.turn && TERROR_STRONGHOLDS.includes(aftermath.territory) && token?.status === 'removed' && token.location === null && aftermath.alliancePolicy === 'activation-targets' && (aftermath.allyAtActivation === null || g.players.some(player => player.id === aftermath.allyAtActivation && player.id !== owner.id)) && g.players.every(player => player.atomicsHandLimitPenalty === (player.id === owner.id || player.id === aftermath.allyAtActivation ? true : undefined)), 'Atomics Aftermath has lost its revealed token, original targets or hand-limit custody.'); } function marketGholaIntegrity(g: Game) { battleOrderIntegrity(g); saphoAggressorIntegrity(g); nexusCardsIntegrity(g); nexusEmperorSecretIntegrity(g); nexusEmperorBetrayalIntegrity(g); nexusFremenBetrayalIntegrity(g); nexusBgBetrayalIntegrity(g); nexusBorrowedVoiceIntegrity(g); nexusFremenRevivalIntegrity(g); nexusFremenCunningIntegrity(g); nexusFremenProtectionIntegrity(g); nexusEcazBetrayalIntegrity(g); nexusTraitorIntegrity(g); nexusFaceDancerIntegrity(g); nexusSuboidIntegrity(g); nexusAdvisorIntegrity(g); nexusSardaukarIntegrity(g); nexusChoamIntegrity(g); nexusMoritaniIntegrity(g); nexusGuildSecretIntegrity(g); nexusRicheseIntegrity(g); richesePairIntegrity(g); richeseBetrayalIntegrity(g); nexusGuildCunningIntegrity(g); traitorDeclarationIntegrity(g); nexusInspectionIntegrity(g); homeworldHistoryIntegrity(g); grummanCollectionIntegrity(g); homeworldVictoryReturnIntegrity(g); homeworldRevivalReturnIntegrity(g); atomicsIntegrity(g); terrorEntryIntegrity(g); homeworldRule(() => validateGiediCollection(g, g.turn, g.giediCollection)); currentFactionPayment(g); battleCardRolesIntegrity(g); winnerDiscardsIntegrity(g); const endError = biddingEndError(g); requireRule(!endError, endError ?? 'Invalid end-of-Bidding opportunity.'); const error = choamMarketGholaError(g); requireRule(!error, error ?? 'Invalid Ghola market interruption.'); } function resumeMarketGhola(g: Game) { if (g.homeworldRevivalReturn && g.homeworldRevivalReturn.stage !== 'complete') return; const pending = g.pendingChoamMarketGhola; if (!pending || pending.stage !== 'complete' || g.pendingTreacheryDiscard || g.pendingNullentropy || g.leaderSkills?.offers[pending.player] || g.response || g.decision || g.pendingKarama || g.truthtrance || g.phaseOpening || g.pendingRicheseGift || g.pendingExchange) return; nexusChoamTradeIntegrity(g); marketGholaIntegrity(g); g.response = structuredClone(pending.response); g.pendingChoamMarketGhola = null; } function playMarketGhola(g: Game, p: Player, action: Action) { requireRule(choamSaleGholaTiming(g) && !g.pendingChoamMarketGhola, 'Finish the current Ghola interruption first.'); const card = p.hand.find((c) => c.id === action.card && c.effect === 'ghola'); requireRule(card && !action.mode, 'Choose an owned Ghola card.'); const options = gholaOptions(g, p); requireRule(options.available, options.reason ?? 'Ghola is unavailable.'); requireRule(!g.pendingKarama && !g.pendingRicheseGift && !g.pendingExchange, 'Finish the nested exchange before playing Ghola.'); const reserved = ambassadorDiscardBlock(g, p, card); requireRule(!reserved, reserved ?? 'This Ghola is already committed.'); choamSaleCancellationQuote(g, g.response!); g.pendingChoamMarketGhola = { turn: g.turn, phase: g.phase, player: p.id, card: card.id, discardSequence: (g.treacheryDiscardSequence ?? 0) + 1, event: '', stage: 'discard', response: structuredClone(g.response!), market: structuredClone(g.choamMarket!), }; g.pendingChoamMarketGhola.event = choamGholaEvent(g.pendingChoamMarketGhola); g.response = null; applyGholaEffect(g, p, action, card.id); if (g.leaderSkills) { const pending = g.pendingChoamMarketGhola; const offer = g.leaderSkills.offers[p.id]; pending.leaderSkill = { leader: p.leaders.find(leader => leader.id === action.leader)?.id ?? null, offer: offer?.event ?? null, completed: !offer, signature: '', }; pending.leaderSkill.signature = choamGholaSkillSignature(pending); } if (g.response) (g.response as ResponseWindow).intent = g.pendingChoamMarketGhola.event; const used = discard(g, p, card.id); log(g, `${p.name} played ${card.name}. The declared CHOAM sale waits for this revival and its income to finish.`, { faction: p.faction, name: 'Ghola revival' }); stageOrdinaryCardDiscard(g, p, used); } function applyGholaEffect(g: Game, p: Player, action: Action, cardId?: string) { 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; // Ghola permits a return in another battle this turn (GF9 FAQ p.9). delete l.usedAt; if (l.id === DUKE_VIDAL_ID) g.dukeVidal = consumeDuke(g.dukeVidal!); if (g.pendingChoamMarketGhola && g.leaderSkills && !isAuditorLeader(l) && !l.gholaBy && p.leaders.some(leader => leader.id === l.id)) g.leaderSkills = skillRule(() => offerRevivedLeaderSkill( g.leaderSkills!, p.id, l.id, choamGholaSkillOfferEvent(g.pendingChoamMarketGhola!, l.id))); log( g, `${p.name} revived ${l.name} with Ghola.${l.id === DUKE_VIDAL_ID ? ' The living shared disc is set aside, unclaimed.' : ' 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); const eliteBlock = elite > 0 ? homeworldSardaukarGholaBlock(g, p.id) : null; requireRule(!eliteBlock, eliteBlock ?? 'Choose an eligible Ghola return.'); requireRule( elite <= eliteRevivalRemaining(p, g.advanced), 'Only one elite force may be revived per turn.', ); const group = { amount: n, elite, free: 0 }; const grant = requireHomeworldRevivalGrant(g, p, 'ghola', group); addNativeReserves(g, p, n, elite); p.tanks -= n; if (p.elites) { p.elites.tanks -= 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.`, ); if (cardId) stageHomeworldRevivalReturn(g, p, 'ghola', group, grant, cardId); } 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 }; unarmed?: boolean; } = {}, ): Plan | null { const b = g.battle!; const promises = options.promises ?? b.truthPromises ?? []; const fixed = options.prescience; const commitments = committedPlanElements(b, p.id); const accepts = (plan: Partial) => (!options.unarmed || (!plan.weapon && !plan.defense)) && commitments.every(element => inspectedPlanMatches(p, element.field, element.value, plan[element.field])) && respectsBattlePromises(promises, p.id, plan, p.hand) && (!fixed || inspectedPlanMatches(p, fixed.field, fixed.value, plan[fixed.field])); 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 = planCombatForces(g, p, opponent); const profile = ecazBattleProfile(g); const fixedDial = profile?.lead === p.id ? profile.fixedEcazDial : 0; const typed = g.advanced || p.faction === 'ixians'; const maxDial = fixedDial + (typed ? maxCombatDial(forces) : forces.normal + forces.elite); const numerical: { dial: number; support: number }[] = []; for (let dial = fixedDial; dial <= maxDial; dial += typed ? 0.5 : 1) for ( let support = 0; support <= (g.advanced && !forces.freeSupport ? Math.min(battleSupportBudget(g, p), maxCombatSupport(forces)) : 0); support++ ) { const n = { dial, support }; if ( accepts(n) && (!typed || casualtyOptions(forces, dial - fixedDial, 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( (card) => battleCardSlotEligible('weapon', card, { planetologistWeapon: true }), ) .map((card) => card.id), ]; const defenses = [ null, ...p.hand.filter((c) => battleCardSlotEligible('defense', 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 }) || !validBattleSlotPair( cardOf(p, weapon), cardOf(p, defense), canUsePlanetologistWeapon(g, p, leader, cardOf(p, weapon)), ) ) 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, g.advanced), ); 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) => canUseAsKaramaRole(g, p, c))) { 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. if (initial.battle?.nexusInspection?.stage === 'response') initial.battle.nexusInspection = allowNexusInspection(battleInspectionContext(initial), initial.battle.nexusInspection); if (currentNexusSardaukar(initial)?.stage === 'pending') finishNexusSardaukar(initial,false); 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 [ ...(node.state.status === 'playing' && node.state.phase === 6 && actor.faction === 'ixians' && !actor.ally && node.state.nexusCards?.cards?.hands[actor.id] === 'ixians' && node.state.nexusCards.phase?.stage !== 'drawing' && !node.state.pendingNullentropy && !node.state.pendingTreacheryDiscard && !pendingNexusTraitors(node.state) && !nexusSuboidsActive(node.state, node.state.nexusSuboidHistory, actor.id) ? [{ type: 'nexusSuboids', event: JSON.stringify(['nexusSuboids', node.state.turn, node.state.battle!.event, actor.id]) } as Action] : []), ...(actor.faction === 'emperor' && !nexusSardaukarEligibility(node.state,actor) && node.state.nexusCards?.phase?.stage !== 'drawing' && !node.state.pendingNullentropy && !node.state.pendingTreacheryDiscard && !pendingNexusTraitors(node.state) ? [{type:'nexusSardaukar',event:JSON.stringify(['nexusSardaukar',node.state.turn,node.state.battle!.event,actor.id])} as Action] : []), ...gholaPreparationActions(node.state, actor), ...cashInPreparationActions(node.state, actor), ]) { const trial = structuredClone(node.state), target = getPlayer(trial, p.id); try { if (action.type === 'nexusSuboids') commitNexusSuboids(trial, target); else if (action.type === 'nexusSardaukar') commitNexusSardaukar(trial,target,'active'); else 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 { validateSavedTruthtrance(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, ) && validShipmentClaim(promise, {allowConstants: promise.mixed !== undefined}) && 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( shipmentPromiseModeSupported(g) && g.status === 'playing' && g.phase === 5 && g.active === promise.player && shipmentAvailable(g,getPlayer(g,promise.player)), 'The saved shipment promise does not belong to a supported unused shipment opportunity.', ); } } /** A pending conversion may succeed. Resolve only its shipment effect in the * private reachability copy; the live table retains every opponent response. */ function projectPendingShipmentKarama(g: Game, player: string) { const pending = g.pendingKarama; if (g.response?.kind !== 'worthlessKarama' || !pending || g.response.owner !== pending.owner || pending.use.kind !== 'shipment' || pending.use.recipient !== player) return; g.response = null; g.pendingKarama = null; completeKarama(g, getPlayer(g, pending.owner), pending.use); } /** 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 || !shipmentAvailable(state,owner) ) return null; if (promises.every(p => matchesShipment(p, null) === p.answer)) return { actions: [{ type: 'endMovement' }] }; const initial = structuredClone(state); initial.truthtrance = null; projectPendingShipmentKarama(initial, owner.id); // 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(); const destinations = [...new Set(promises.flatMap(shipmentClaimDestinations))]; 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.elites, g.homeworlds, p.hand.map((c) => c.id), g.aid, g.karamaShipping, ]); if (visited.has(signature)) continue; visited.add(signature); for (const destination of destinations) 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 offer = nexusRicheseOffer(g,p.id), guildOffer = nexusGuildSecretAllyOffer(g,p.id); for (const nexus of [undefined, ...(offer && !offer.blocked && amount <= offer.maxForces ? [offer.event] : []), ...(guildOffer && !guildOffer.blocked ? [guildOffer.event] : [])]) { const guildSecretEvent = nexus && nexus === guildOffer?.event ? nexus : undefined; const price = {faction:p.faction,halfRate:shipmentHalfRate(g,p)}; const smuggler = nexus ? null : quoteSmugglerShipment(g, p.id, destination, amount); const cost = guildSecretEvent ? nexusRule(() => quoteNexusGuildSecretShipment(territory(destination).type,amount)).cost : nexus ? nexusRule(() => quoteNexusRicheseShipment(price,territory(destination).type,amount)).cost : reserveShipmentCost(price,territory(destination).type,amount - (smuggler ? 1 : 0)); try { const allyPayment = contribution(g, p, cost); const elite = eliteChoice( amount, p.reserves, p.elites?.reserves ?? 0, undefined, ); const sources = g.homeworlds?.custody ? nativeReserveSources( homeworldContext(g), g.homeworlds.custody, p.id, { normal: amount - elite, elite }, ) : null; const nativeSources = sources ? { homeworldSources: sources } : {}; const shipment: PendingShipment = { ...(smuggler ? { smuggler } : {}), ...(guildSecretEvent ? {guildSecretEvent} : nexus ? {nexusEvent:nexus} : {}), ...nativeSources, turn: g.turn, player: p.id, territory: destination, sector, amount, elite, cost, allyPayment, advisors: arrivalAsAdvisor(g, p, destination), }; const trial = nexus ? structuredClone(g) : g; if (nexus && !guildSecretEvent) recordNexusRicheseShipment(trial,shipment); validatePhysicalShipment(trial,shipment); return { actions: [ ...actions, { type: 'ship', ...(smuggler ? { smuggler: true } : {}), ...(nexus ? {nexus} : {}), territory: destination, sector, amount, elite, allyPayment, ...nativeSources, }, ], }; } 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; } } } } // Preparations use only owned cards, recoverable escrow and already-pending // conversion effects. 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) => canUseAsKaramaRole(g, p, c)); if (karama && !g.karamaShipping) preparations.push({ type: 'card', card: karama.id, mode: 'shipment', target: p.id, }); const ghola = gholaOptions(g, p); if (ghola.available && ghola.maxForces > 0) preparations.push({ type: 'card', card: ghola.cards[0], amount: ghola.maxForces, elite: Math.max(0, ghola.maxForces - (p.tanks - (p.elites?.tanks ?? 0))), }); for (const action of preparations) { try { const next = applyActionInner(g, p.id, action, 'shipmentPreparation'); projectPendingShipmentKarama(next, p.id); 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, mixed?: MixedShipmentExpression, ) { (g.shipmentPromises ??= []).push({ turn: g.turn, player: p.id, asker, ...parseShipmentClaim(claim, {allowConstants: mixed !== undefined}), ...(mixed ? {mixed} : {}), 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 }, ) { if (g.pendingKull || g.pendingRicheseBetrayal) return; 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 }, ) { if (g.pendingKull || g.pendingRicheseBetrayal) return; 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 profile = ecazBattleProfile(g); const forces = planCombatForces(g, p, getPlayer(g, b.attacker === p.id ? b.defender : b.attacker)); 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( (card) => battleCardSlotEligible('weapon', card, { planetologistWeapon: true }), ) .map((card) => card.id), ]; const defenses: (string | null)[] = [ null, ...p.hand.filter((c) => battleCardSlotEligible('defense', c)).map((c) => c.id), ]; for (const leader of field === 'leader' ? [value] : leaderIds) for (const weapon of field === 'weapon' ? weapons.filter(candidate => inspectedPlanMatches(p, field, value, candidate)) : weapons) for (const defense of field === 'defense' ? defenses.filter(candidate => inspectedPlanMatches(p, field, value, candidate)) : defenses) { const maxSupport = g.advanced && field === 'dial' ? Math.min(battleSupportBudget(g, p), maxCombatSupport(forces)) : 0; for (let support = 0; support <= maxSupport; support++) { try { const plan = validatePlan(g, p, { leader, weapon, defense, support, dial: field === 'dial' ? value : profile?.lead === p.id ? profile.fixedEcazDial : 0, }); if (inspectedPlanMatches(p, field, value, plan[field])) 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 && !b.homeworldDefensePassed?.includes(owner.id), '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 revealedWeaponChoices(g: Game) { const b = g.battle!; requireRule(b.revealed && !!b.plans[b.attacker] && !!b.plans[b.defender], 'Revealed weapon choices need both exact battle plans.'); const selected = (id: string) => { const p = getPlayer(g, id), plan = b.plans[id]; return { weapon: cardOf(p, plan.weapon), defense: cardOf(p, b.lateDefense?.[id] ?? plan.defense) }; }; const resolved = resolveBattleWeapons({ attacker: selected(b.attacker), defender: selected(b.defender) }); requireRule(!resolved.error, resolved.error ?? 'The revealed weapon choices are invalid.'); return resolved.choiceOrder.map((choice) => ({ ...choice, player: choice.side === 'attacker' ? b.attacker : b.defender, })); } function orderedRevealedWeaponChoices( g: Game, choices: ReturnType, ) { if (choices.some((choice) => choice.copiedFrom !== null)) return choices; return [ ...choices.filter((choice) => choice.kind === 'stoneBurner'), ...g.order.flatMap((id) => choices.filter((choice) => choice.kind === 'poisonTooth' && choice.player === id)), ]; } function revealedWeaponAnswered( b: Battle, choice: ReturnType[number], ) { return choice.kind === 'stoneBurner' ? b.stoneBurner?.[choice.player] !== undefined : b.poisonTooth?.[choice.player] !== undefined; } function battleSpecialWeaponIntegrity(g: Game) { const b = g.battle; if (!b) return; for (const id of [b.attacker, b.defender]) { const cardId = b.plans[id]?.weapon; if (cardId !== 'richese-stone-burner' && cardId !== 'richese-mirror-weapon') continue; const owner = getPlayer(g, id), card = cardOf(owner, cardId); const name = cardId === 'richese-stone-burner' ? 'Stone Burner' : 'Mirror Weapon'; if (cardId === 'richese-mirror-weapon') { const blocked = mirrorWeaponModeBlock(g); requireRule(!blocked, blocked ?? 'The committed Mirror Weapon mode is invalid.'); } requireRule(cardId === 'richese-stone-burner' ? isStoneBurner(card) : isMirrorWeapon(card), `The committed canonical ${name} weapon is missing from its reserved hand.`); requireRule(g.players.reduce((n, p) => n + p.hand.filter((c) => c.id === cardId).length, 0) === 1 && ![...g.deck, ...g.discard, ...(g.richeseCache ?? []), ...(g.auction?.cards ?? []), ...(g.ixSetupCards ?? [])].some((c) => c.id === cardId), `The committed ${name} physical card is duplicated.`); } const choices = b.revealed ? revealedWeaponChoices(g) : []; 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(['kill', 'ignore'].includes(mode) && choices.some((choice) => choice.player === id && choice.kind === 'stoneBurner' && choice.physicalId === b.plans[id]?.weapon), 'The saved Stone Burner choice has an invalid owner, mode, or physical weapon.'); } if (b.poisonTooth !== undefined) { requireRule(g.phase === 6 && b.revealed && !!b.poisonTooth && typeof b.poisonTooth === 'object' && !Array.isArray(b.poisonTooth), 'The saved Poison Tooth choice context is invalid.'); for (const [id, activated] of Object.entries(b.poisonTooth)) requireRule(typeof activated === 'boolean' && choices.some((choice) => choice.player === id && choice.kind === 'poisonTooth' && choice.physicalId === b.plans[id]?.weapon && (choice.copiedFrom === null || !!b.event)), 'The saved Poison Tooth choice has an invalid owner, answer, or physical weapon.'); } const ordered = orderedRevealedWeaponChoices(g, choices); let pendingSeen = false; for (const choice of ordered) { if (revealedWeaponAnswered(b, choice)) requireRule(!pendingSeen, 'A revealed weapon choice cannot precede its copied source.'); else pendingSeen = true; } if (g.decision?.kind === 'stoneBurner' || g.decision?.kind === 'poisonTooth') { const decision = g.decision; const choice = ordered.find((candidate) => !revealedWeaponAnswered(b, candidate)); requireRule(!!choice && choice.player === decision.player && choice.kind === decision.kind && choice.physicalId === b.plans[decision.player]?.weapon && (decision.event === undefined || decision.event === b.event) && (decision.physicalId === undefined || decision.physicalId === choice.physicalId) && (decision.copiedFrom === undefined || decision.copiedFrom === choice.copiedFrom) && (choice.copiedFrom === null || (decision.event === b.event && decision.physicalId === choice.physicalId && decision.copiedFrom === choice.copiedFrom)), 'The saved revealed weapon choice lost its physical card or battle event.'); } } function diplomatDefenseOffer(g: Game): (DiplomatDefenseQuote & { player: string }) | null { const b = g.battle; if (!b?.revealed || !g.leaderSkills || !diplomatDefenseModeSupported(g) || b.lateDefense && Object.keys(b.lateDefense).length) return null; for (const player of [b.attacker, b.defender]) { const p = getPlayer(g, player), plan = b.plans[player]; const other = getPlayer(g, b.attacker === player ? b.defender : b.attacker); const opposingPlan = b.plans[other.id]; if (!plan || !opposingPlan) continue; const quote = quoteDiplomatDefense({ assignments: g.leaderSkills.assignments.filter(a => a.owner === player && a.skill === 'diplomat' && p.leaders.some(l => l.id === a.leader && !l.dead && !l.capturedBy && !l.gholaBy)) .map(a => ({ skill: a.skill, leader: a.leader, faceUp: !b.leaderSkillHidden?.[player], captured: false })), selectedLeader: plan.leader, weapon: cardOf(p, plan.weapon), defense: cardOf(p, plan.defense), opposingDefense: cardOf(other, opposingPlan.defense), opposingWeapon: cardOf(other, opposingPlan.weapon), expandedDefenses: b.diplomatDefenseVersion === 2, }); if (quote) return { ...quote, player }; } return null; } function diplomatDefenseFrame(g: Game): string { const b = g.battle!; return JSON.stringify({ event: b.event, turn: g.turn, territory: b.territory, attacker: b.attacker, defender: b.defender, plans: b.plans, hidden: b.leaderSkillHidden, cards: [b.attacker, b.defender].map(id => { const p = getPlayer(g, id), plan = b.plans[id]; return [plan.weapon, plan.defense, plan.leader].map(selected => cardOf(p, selected) ?? null); }) }); } function diplomatDefenseSignature(receipt: DiplomatDefenseReceipt): string { return JSON.stringify({ ...receipt, signature: undefined }); } function diplomatDefenseIntegrity(g: Game) { const b = g.battle; const decisions = homeworldSavedDecisions(g).filter(d => d.kind === 'diplomatDefense'); if (b?.diplomatDefenseVersion === undefined && b?.diplomatDefense === undefined && b?.diplomatDefenseEvent === undefined && !decisions.length) return; requireRule(b && (b.diplomatDefenseVersion === 1 || b.diplomatDefenseVersion === 2), 'The saved Diplomat choice lost its original battle version.'); const receipt = b.diplomatDefense; const offer = diplomatDefenseOffer(g); if (!receipt) { requireRule(receipt === undefined && b.diplomatDefenseEvent === undefined && !decisions.length && !offer, 'The revealed Diplomat defense lost its original choice.'); return; } requireRule(g.status === 'playing' && g.phase === 6 && b.revealed && offer && receipt.battle === b.event && receipt.turn === g.turn && receipt.territory === b.territory && typeof receipt.event === 'string' && receipt.event.length > 0 && receipt.event === b.diplomatDefenseEvent && receipt.frame === diplomatDefenseFrame(g) && receipt.signature === diplomatDefenseSignature(receipt) && JSON.stringify({leader: receipt.leader, cards: receipt.cards, source: receipt.source, kind: receipt.kind, player: receipt.player}) === JSON.stringify(offer), 'The saved Diplomat defense changed its revealed plans, trainer, source or physical card choices.'); requireRule(['offered', 'copied', 'declined'].includes(receipt.stage) && (receipt.stage === 'copied' ? typeof receipt.card === 'string' && receipt.cards.includes(receipt.card) : receipt.card === null), 'The saved Diplomat defense changed its optional choice or selected Worthless card.'); const physical = physicalTreacheryCards(g); for (const card of [...receipt.cards, receipt.source]) requireRule(physical.filter(c => c.id === card).length === 1, 'The committed Diplomat cards must retain unique physical custody.'); requireRule(decisions.length === (receipt.stage === 'offered' ? 1 : 0), 'The saved Diplomat defense lost or reopened its unique public decision.'); if (receipt.stage === 'offered') requireRule(Object.keys(b.traitorCalls).length === 0, 'Diplomat defense must be chosen before any traitor decisions.'); for (const decision of decisions) requireRule(decision.player === receipt.player && decision.event === receipt.event && decision.source === receipt.source && JSON.stringify(decision.cards) === JSON.stringify(receipt.cards), 'The Diplomat decision no longer matches its original public choices.'); } function offerDiplomatDefense(g: Game): boolean { const b = g.battle!; if ((b.diplomatDefenseVersion !== 1 && b.diplomatDefenseVersion !== 2) || b.diplomatDefense) return false; const offer = diplomatDefenseOffer(g); if (!offer) return false; requireRule(b.event && !Object.keys(b.traitorCalls).length, 'Diplomat defense needs the current revealed battle before traitor decisions.'); const event = crypto.randomUUID(); const receipt: DiplomatDefenseReceipt = { ...offer, event, battle: b.event, turn: g.turn, territory: b.territory, frame: diplomatDefenseFrame(g), stage: 'offered', card: null, signature: '' }; receipt.signature = diplomatDefenseSignature(receipt); b.diplomatDefense = receipt; b.diplomatDefenseEvent = event; g.decision = { kind: 'diplomatDefense', player: offer.player, event, cards: [...offer.cards], source: offer.source }; log(g, `${getPlayer(g, offer.player).name} may use Diplomat to make one committed Worthless card copy the opposing ${diplomatDefenseLabel(offer.kind)} before traitor decisions.`, { faction: getPlayer(g, offer.player).faction, name: 'Diplomat defense' }); return true; } function harassAllocationOffer(g: Game) { const b = g.battle; if (!b?.revealed || b.harassAllocationVersion !== 1) return null; for (const id of [b.attacker, b.defender]) { const p = getPlayer(g, id), plan = b.plans[id]; if (![plan.weapon, plan.defense].includes(HARASS_WITHDRAW_CARD)) continue; const context = harassWithdrawContext(g, p); try { if (harassWithdrawNeedsAllocation(context, plan.dial, plan.support)) return { player: id, context, dial: plan.dial, support: plan.support }; } catch (error) { if (error instanceof HarassWithdrawError) throw new RuleError(error.message); throw error; } } return null; } function harassAllocationFrame(g: Game): string { const b = g.battle!; return JSON.stringify({ battle: b.event, turn: g.turn, territory: b.territory, attacker: b.attacker, defender: b.defender, plans: b.plans, offer: harassAllocationOffer(g) }); } function harassAllocationSignature(receipt: HarassAllocationReceipt): string { return JSON.stringify({ ...receipt, signature: undefined }); } function harassAllocationIntegrity(g: Game) { const b = g.battle, decisions = homeworldSavedDecisions(g).filter(d => d.kind === 'harassWithdraw'); if (b?.harassAllocationVersion === undefined && b?.harassAllocation === undefined && b?.harassAllocationEvent === undefined && !decisions.length) return; requireRule(b && b.harassAllocationVersion === 1, 'The saved withdrawal choice lost its original battle version.'); const receipt = b.harassAllocation, offer = harassAllocationOffer(g); if (!receipt) { requireRule(receipt === undefined && b.harassAllocationEvent === undefined && !decisions.length && !offer, 'The revealed Harass & Withdraw plan lost its physical allocation choice.'); return; } requireRule(g.status === 'playing' && g.phase === 6 && b.revealed && offer && receipt.player === offer.player && receipt.battle === b.event && receipt.turn === g.turn && typeof receipt.event === 'string' && receipt.event.length > 0 && receipt.event === b.harassAllocationEvent && receipt.frame === harassAllocationFrame(g) && receipt.signature === harassAllocationSignature(receipt) && ['offered', 'selected'].includes(receipt.stage), 'The saved withdrawal choice changed its revealed plan, force locations or event.'); requireRule(decisions.length === (receipt.stage === 'offered' ? 1 : 0), 'The saved withdrawal choice lost or reopened its unique decision.'); if (receipt.stage === 'offered') requireRule(receipt.selection === null && !Object.keys(b.traitorCalls).length, 'Choose undialed forces before any traitor declarations.'); else { requireRule(receipt.selection !== null, 'The saved withdrawal choice lost its selected counters.'); try { quoteHarassWithdraw(offer.context, offer.dial, offer.support, receipt.selection); } catch (error) { if (error instanceof HarassWithdrawError) throw new RuleError(error.message); throw error; } } for (const decision of decisions) requireRule(decision.player === receipt.player && decision.event === receipt.event && Object.keys(decision).every(key => ['kind', 'player', 'event'].includes(key)), 'The withdrawal decision no longer matches its original owner and event.'); } function offerHarassAllocation(g: Game): boolean { const b = g.battle!; if (b.harassAllocation || b.harassAllocationVersion !== 1) return false; const offer = harassAllocationOffer(g); if (!offer) return false; requireRule(b.event && !Object.keys(b.traitorCalls).length, 'Choose withdrawal counters immediately after reveal.'); const event = crypto.randomUUID(); const receipt: HarassAllocationReceipt = { player: offer.player, event, battle: b.event, turn: g.turn, frame: harassAllocationFrame(g), stage: 'offered', selection: null, signature: '' }; receipt.signature = harassAllocationSignature(receipt); b.harassAllocation = receipt; b.harassAllocationEvent = event; g.decision = { kind: 'harassWithdraw', player: offer.player, event }; log(g, `${getPlayer(g, offer.player).name} must identify all undialed forces returned by Harass & Withdraw before traitor declarations.`); return true; } function nextRevealedDecision(g: Game) { const b = g.battle!; if (offerHarassAllocation(g)) return; if (offerDiplomatDefense(g)) return; const ordered = orderedRevealedWeaponChoices(g, revealedWeaponChoices(g)); const choice = ordered.find((candidate) => !revealedWeaponAnswered(b, candidate)); if (choice) { if (choice.kind === 'stoneBurner' || choice.copiedFrom) requireRule(!!b.event, 'The revealed weapon battle event is missing.'); g.decision = choice.kind === 'stoneBurner' ? { kind: 'stoneBurner', player: choice.player, event: b.event!, physicalId: choice.physicalId, copiedFrom: choice.copiedFrom } : { kind: 'poisonTooth', player: choice.player, event: b.event, physicalId: choice.physicalId, copiedFrom: choice.copiedFrom }; } } /** Homeworld invaders have no traitor vote to hold their late-defense window. * Offer only a real held legal defense; empty windows resolve automatically. */ function homeworldRevealPending(g: Game): boolean { const b = g.battle; if (g.status !== 'playing' || g.phase !== 6 || !b?.revealed || !b.territory.startsWith('homeworld:') || g.decision || g.response || g.truthtrance || g.phaseOpening || g.pendingNullentropy || g.pendingTreacheryDiscard || g.pendingRicheseGift || g.pendingExchange || g.pendingKarama || g.summonedWorm) return false; return traitorVoters(g, b).every((id) => b.traitorCalls[id] !== undefined); } function advanceHomeworldReveal(g: Game): boolean { if (!homeworldRevealPending(g)) return false; const b = g.battle!; const voters = traitorVoters(g, b); const owner = g.order.find((id) => [b.attacker, b.defender].includes(id) && !voters.includes(id) && !b.homeworldDefensePassed?.includes(id) && (() => { const offer = portableSnooperView(g, getPlayer(g, id)); return offer && !offer.blocked; })()); if (owner) { requireRule(b.event, 'The Homeworld defense opportunity needs its battle event.'); g.decision = {kind: 'homeworldDefense', player: owner, event: b.event}; } else advanceBattleOutcome(g); return true; } function traitorVoters(g: Game, b: Battle) { const home = homeworldBattleLocation(g, b.territory); if (home) return [b.attacker, b.defender].filter((id) => id === home.native); const voters = [b.attacker, b.defender]; if (b.ecazOccupy?.lead) for (const owner of [b.ecazOccupy.ecaz, b.ecazOccupy.ally]) if (!voters.includes(owner)) voters.push(owner); 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) { if (b.ecazOccupy?.lead && [b.ecazOccupy.ecaz, b.ecazOccupy.ally].includes(id)) return b.ecazOccupy.lead; return [b.attacker, b.defender].includes(id) ? id : getPlayer(g, id).ally; } function pendingSmugglerBattle(g: Game): SmugglerBattleReceipt | null { const b = g.battle!; const frame = JSON.stringify({ plans: b.plans, hidden: b.leaderSkillHidden, skills: [b.attacker, b.defender].map(id => battleLeaderSkills(g, getPlayer(g, id))) }); const receipts = [b.attacker, b.defender].flatMap(id => { const p = getPlayer(g, id), plan = b.plans[id]; const opponent = getPlayer(g, id === b.attacker ? b.defender : b.attacker); const leader = controlledLeaders(g, p).find(l => l.id === plan.leader && !l.dead); const opposingLeader = controlledLeaders(g, opponent).find(l => l.id === b.plans[opponent.id].leader && !l.dead); const receipt = nexusRule(() => createSmugglerBattle({ event: b.event!, turn: g.turn, territory: b.territory, player: id, frame, spice: g.spice, supported: smugglerBattleModeSupported(g), plan: { assignments: battleLeaderSkills(g, p), leader: leader ? { id: leader.id, strength: battleLeaderStrength(leader, opposingLeader) } : undefined, weapon: cardOf(p, plan.weapon), defense: cardOf(p, plan.defense), kwisatz: plan.kwisatz } })); return receipt ? [receipt] : []; }); requireRule(receipts.length <= 1, 'Smuggler needs its unique physical skill card.'); return receipts[0] ?? null; } function smugglerBattleIntegrity(g: Game) { const b = g.battle; if (b?.smugglerCollectionVersion !== undefined || b?.smugglerCollection !== undefined) { requireRule(b && b.smugglerCollectionVersion === 1, 'The saved Smuggler collection lost its battle version.'); if (b.revealed) { let expected = pendingSmugglerBattle(g); if (expected && b.smugglerCollection?.allocations) expected = nexusRule(() => chooseSmugglerBattleAllocation(expected!, b.smugglerCollection!.allocations!)); requireRule(JSON.stringify(b.smugglerCollection) === JSON.stringify(expected), 'The saved Smuggler collection changed its original plans, leader or reveal-time spice.'); } else requireRule(b.smugglerCollection === undefined, 'Smuggler collection cannot precede public Battle Plans.'); } if (g.decision?.kind === 'smugglerCollection' || g.decision?.kind === 'sandmasterVictorySpice') { requireRule(b?.revealed && g.phase === 6 && b.event === g.decision.event && traitorVoters(g, b).every(id => b.traitorCalls[id] !== undefined), 'The pile choice needs the completed revealed battle outcome.'); const quote = currentBattleResolutionQuote(g); requireRule(JSON.stringify(g.decision) === JSON.stringify( g.decision.kind === 'smugglerCollection' ? quote.smugglerAllocation : quote.sandmasterSelection), 'The saved pile choice changed its owner, amount or sectors.'); } const context = g.lastBattleContext, receipt = context?.smugglerCollection; if (receipt) requireRule(receipt.event === context!.event && receipt.turn === context!.turn && receipt.territory === context!.territory && context!.combatants.includes(receipt.player) && ['collected', 'void'].includes(receipt.stage) && receipt.signature === smugglerBattleSignature(receipt), 'The completed Smuggler collection changed its original battle receipt.'); } /** 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), ...(b.ecazSpecialKarama?.owner === p.id ? { ecazSpecialKarama: true as const } : {}), leaderSkills: battleLeaderSkills(g, p), occupiedStrongholds: leaderSkillStrongholdCount(g, p), forces: planCombatForces(g, p, opponent), ...([cardOf(p, plan.weapon), cardOf(p, plan.defense)].some(isReinforcements) ? { reinforcementsReserves: { normal: p.reserves - (p.elites?.reserves ?? 0), elite: p.elites?.reserves ?? 0, } } : {}), ...([cardOf(p, plan.weapon), cardOf(p, plan.defense)].some(isHarassWithdraw) ? { harassWithdraw: harassWithdrawContext(g, p), ...(b.harassAllocation?.player === p.id && b.harassAllocation.stage === 'selected' ? { harassSelection: b.harassAllocation.selection! } : {}) } : {}), stronghold: strongholdEffect(g, p.id), lateDefense: b.lateDefense?.[p.id], ...(b.diplomatDefense?.stage === 'copied' && b.diplomatDefense.player === p.id ? { diplomatDefense: { leader: b.diplomatDefense.leader, card: b.diplomatDefense.card!, source: b.diplomatDefense.source, kind: b.diplomatDefense.kind } } : {}), 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, ...(b.ecazOccupy ? { ecazOccupy: ecazBattleProfile(g)! } : {}), aggressor: battleAggressor(g), typedCasualties: !!g.homeworlds, ...(homeworldBattleLocation(g, b.territory) ? { homeworld: currentHomeworldBattleRules(g, b.territory)! } : {}), 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: b.traitorDeclarations?.[id] ? [b.traitorDeclarations[id].identity] : 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, nexusMoritani: !!g.nexusCards?.cards && !byFaction(g, 'moritani'), }); for (const withdrawal of quote.harassWithdraw ?? []) validateReserveReturnCounters(getPlayer(g, withdrawal.player), withdrawal.returned); if (quote.sukGraduate) requireRule( ordinaryLeaderSkillModeSupported(g) && (nativeExpansionLeaderSkillsProfile(g) || g.players.every((p) => faction(p.faction).expansion === 'base')), 'Suk Graduate rescue with expansion factions or other optional modules is still being implemented.'); if (quote.rihani || quote.sandmaster) requireRule( ordinaryLeaderSkillModeSupported(g) && (nativeExpansionLeaderSkillsProfile(g) || g.players.every((p) => faction(p.faction).expansion === 'base')), 'These Leader Skill victory effects with expansion factions or other optional modules are still being integrated.'); const collection = b.smugglerCollection; const survives = !!collection && !(collection.player === b.attacker ? quote.leaderDeaths.attacker : quote.leaderDeaths.defender); const smugglerAllocation: Extract | null = collection && smugglerBattleAllocationRequired(collection, survives) ? { kind: 'smugglerCollection', event: b.event!, player: collection.player, territory: b.territory, amount: collection.amount, piles: collection.piles! } : null; const smuggler = collection && !smugglerAllocation ? nexusRule(() => settleSmugglerBattle(collection, survives, g.spice)) : null; if (collection && survives) requireRule(Number.isSafeInteger(getPlayer(g, collection.player).spice + collection.amount), 'Smuggler collection would overflow the spice balance.'); const afterCollection = smuggler?.spice ?? g.spice; const sandmasterPiles = quote.sandmaster && !smugglerAllocation ? nexusRule(() => sandmasterVictoryPiles(b.territory, afterCollection)) : []; const sandmasterSelection: Extract | null = sandmasterPiles.length > 1 && !b.sandmasterPile ? { kind: 'sandmasterVictorySpice', event: b.event!, player: quote.winner!, territory: b.territory, piles: sandmasterPiles } : null; if (quote.sandmaster && !smugglerAllocation && !sandmasterSelection) nexusRule(() => sandmasterVictorySpice(b.territory, afterCollection, b.sandmasterPile)); if (quote.rihani) { const winner = getPlayer(g, quote.winner!); nexusRule(() => beginRihani(nexusTraitorSnapshot(g), nexusTraitorUniverse(g), { event: b.event!, turn: g.turn, owner: winner.id, skill: quote.rihani! }, [...new Set([...(winner.revealedTraitors ?? []), ...quote.revelations.filter((r) => r.player === winner.id).map((r) => r.identity)])], () => 0)); } for (const id of quote.destroyedArmies) if (b.territory.startsWith('homeworld:')) quoteHomeworldLoss(g, id, b.territory, combatArmy(g, id, b.territory)); else validateBattleForceLoss(getPlayer(g, id), b.territory, Infinity); if (quote.winner && quote.result === 'normal' && b.territory.startsWith('homeworld:')) for (const losses of quote.casualties!.options) quoteHomeworldLoss(g, quote.winner, b.territory, { normal: losses.normal, elite: losses.elite }); else if (quote.winner && quote.result === 'normal') validateBattleForceLoss( getPlayer(g, quote.casualties?.owner ?? quote.winner), b.territory, quote.basicWinnerLosses ?? Math.max(...quote.casualties!.options.map((c) => c.normal + c.elite)), ); for (const loss of quote.fixedLosses ?? []) validateBattleForceLoss(getPlayer(g, loss.owner), b.territory, loss.normal + loss.elite); if (quote.homeworldExplosion) for (const losses of quote.homeworldExplosion.options) quoteHomeworldLoss(g, quote.homeworldExplosion.player, b.territory, losses); return { ...quote, smuggler, smugglerAllocation, sandmasterSelection }; } catch (error) { if ( error instanceof BattleResolutionQuoteError || error instanceof ForceLossPreflightError ) throw new RuleError(error.message); throw error; } } function diplomatRetreatBlocked(g: Game, key: string) { const sector = key === MOBILE_LOCATION ? splitLocation(g.mobileStronghold?.location ?? '').sector : splitLocation(key).sector; return sector > 0 && sector === g.storm; } function diplomatRetreatOffer(g: Game, quote: BattleResolutionQuote) { const b = g.battle!; if (b.diplomatRetreatVersion !== 1 || quote.result !== 'normal' || !quote.winner || b.territory.startsWith('homeworld:')) return null; const player = quote.winner === b.attacker ? b.defender : b.attacker; const p = getPlayer(g, player); const plan = b.plans[player]; const leader = controlledLeaders(g, p).find(candidate => candidate.id === plan.leader && !candidate.dead); if (!leader || (player === b.attacker ? quote.leaderDeaths.attacker : quote.leaderDeaths.defender) || !battleLeaderSkills(g, p).some(skill => skill.skill === 'diplomat' && skill.leader === leader.id && (!skill.faceUp || skill.captured)) || p.noField?.deployed?.location.territory === b.territory) return null; const opponent = getPlayer(g, quote.winner); const forces = combatForces(g, p, b.territory, opponent); const occupy = ecazBattleProfile(g); const combined = occupy?.lead === player && !occupy.canceled; const dial = combined ? player === occupy.ecaz ? occupy.fixedEcazDial : plan.dial - occupy.fixedEcazDial : plan.dial; const support = combined && player === occupy.ecaz ? g.advanced ? dial : 0 : plan.support; const strength = battleLeaderStrength(leader, controlledLeaders(g, opponent).find(candidate => candidate.id === b.plans[opponent.id].leader)); const origins = Object.entries(p.forces).filter(([key, count]) => count > 0 && splitLocation(key).territory === b.territory); const destinations = TERRITORIES.filter(candidate => candidate.type !== 'stronghold' && candidate.id !== b.territory && !g.players.some(side => Object.entries(side.forces).some(([key, count]) => count > 0 && splitLocation(key).territory === candidate.id))) .flatMap(candidate => candidate.sectors.map(sector => location(candidate.id, sector))) .sort() .map(key => { const available = origins.reduce((sum, [origin, count]) => { if (gameDistance(g, origin, key, candidate => diplomatRetreatBlocked(g, candidate)) !== 1) return sum; const elite = p.elites?.forces[origin] ?? 0; return { normal: sum.normal + count - elite, elite: sum.elite + elite }; }, { normal: 0, elite: 0 }); return { location: key, choices: diplomatRetreatChoices(forces, dial, support, strength, available) }; }) .filter(option => option.choices.length); return destinations.length ? { player, destinations } : null; } function diplomatRetreatFrame(g: Game) { const b = g.battle!; return JSON.stringify({ battle: b.event, turn: g.turn, territory: b.territory, plans: b.plans, hidden: b.leaderSkillHidden, traitors: b.traitorCalls, forces: g.players.map(p => [p.id, p.forces, p.elites?.forces]), skills: g.leaderSkills?.assignments, storm: g.storm }); } function diplomatRetreatIntegrity(g: Game) { const b = g.battle, receipt = b?.diplomatRetreat; const decision = g.decision; if (!receipt) { requireRule(decision?.kind !== 'diplomatRetreat', 'The Diplomat retreat lost its battle.'); return; } requireRule(b?.diplomatRetreatVersion === 1 && g.status === 'playing' && g.phase === 6 && b.revealed && b.event === receipt.battle && g.turn === receipt.turn && receipt.event && receipt.frame === diplomatRetreatFrame(g) && JSON.stringify(diplomatRetreatOffer(g, currentBattleResolutionQuote(g))) === JSON.stringify({ player: receipt.player, destinations: receipt.destinations }) && decision?.kind === 'diplomatRetreat' && decision.event === receipt.event && decision.player === receipt.player && JSON.stringify(decision.destinations) === JSON.stringify(receipt.destinations), 'The saved Diplomat retreat changed its battle, forces, outcome or legal destinations.'); } function advanceBattleOutcome(g: Game) { const b = g.battle!; const quote = currentBattleResolutionQuote(g); if (quote.smugglerAllocation || quote.sandmasterSelection) { g.decision = quote.smugglerAllocation ?? quote.sandmasterSelection; return; } if (b.diplomatRetreatVersion !== 1 || ![b.attacker, b.defender].some(id => battleLeaderSkills(g, getPlayer(g, id)).some(skill => skill.skill === 'diplomat' && b.plans[id].leader === skill.leader && (!skill.faceUp || skill.captured)))) { resolveBattle(g); return; } const offer = diplomatRetreatOffer(g, quote); if (!offer) { resolveBattle(g); return; } requireRule(b.event && !b.diplomatRetreat, 'The Diplomat retreat needs one current battle.'); const event = crypto.randomUUID(); b.diplomatRetreat = { ...offer, event, battle: b.event, turn: g.turn, frame: diplomatRetreatFrame(g) }; g.decision = { kind: 'diplomatRetreat', event, ...offer }; log(g, `${getPlayer(g, offer.player).name} may retreat surviving undialed forces with Diplomat before battle losses.`, { faction: getPlayer(g, offer.player).faction, name: 'Diplomat retreat' }); } function currentJacurutuIncome(g: Game, quote: ReturnType, retreat?: DiplomatRetreatSelection) { const b = g.battle!; if (b.territory !== JACURUTU_SIETCH || !g.discoveryEnabled || !quote.winner || !g.discoveries?.tokens.some(token => token.face === 'jacurutu-sietch' && token.status === 'placed' && token.revealedTurn !== null)) return null; const opponent = quote.winner ? getPlayer(g, quote.winner === b.attacker ? b.defender : b.attacker) : null; const forces = opponent ? combatForces(g, opponent, b.territory, getPlayer(g, opponent.id === b.attacker ? b.defender : b.attacker)) : null; const plan = opponent ? b.plans[opponent.id] : null; const withdrawal = quote.harassWithdraw?.find(entry => entry.player === opponent?.id); const withdrawalDial = withdrawal && forces && plan ? casualtyOptions(forces,plan.dial,plan.support).find(choice => choice.normal === withdrawal.remaining.normal && choice.elite === withdrawal.remaining.elite) : undefined; requireRule(!withdrawal || withdrawalDial, 'Harass & Withdraw must retain its original physical dial.'); return nexusRule(() => quoteJacurutuBattleIncome({ territory: b.territory, jacurutuRevealed: true, winner: quote.winner, opponent: opponent?.id ?? null, opponentForces: forces, opponentDial: plan?.dial ?? null, opponentSupport: plan?.support ?? null, sentToTanks: forces ? { normal: forces.normal - (withdrawal?.returned.normal ?? 0) - (retreat?.normal ?? 0), elite: forces.elite - (withdrawal?.returned.elite ?? 0) - (retreat?.elite ?? 0), } : null, selectedDial: b.jacurutuDial?.selectedDial ?? withdrawalDial, })); } function jacurutuDialIntegrity(g: Game) { const frame = g.battle?.jacurutuDial; const decisions = homeworldSavedDecisions(g).filter(decision => decision.kind === 'jacurutuDial'); if (!frame && !decisions.length) return; requireRule(frame && g.battle?.revealed && g.phase === 6 && frame.event.length > 0 && !frame.selectedDial && decisions.length === 1 && decisions[0].player === frame.owner && decisions[0].event === frame.event && !!g.battle.diplomatRetreat === !!frame.retreat, 'The Jacurutu dial choice must retain its original revealed battle and defeated owner.'); const income = currentJacurutuIncome(g, currentBattleResolutionQuote(g), frame.retreat); requireRule(income?.kind === 'allocationRequired' && income.opponent === frame.owner && income.winner === frame.winner && JSON.stringify(income.choices) === JSON.stringify(frame.choices), 'The Jacurutu dial choice changed its original counters, support or Tanks losses.'); } function resolveBattle(g: Game, retreat?: DiplomatRetreatSelection) { const b = g.battle!; requireRule(!!b.diplomatRetreat === !!retreat, 'Resolve the pending Diplomat retreat before battle losses.'); // Quote before any withdrawal: fixedLosses retains the original ceil(E/2) // physical commitment, never one recalculated from Ecaz's diminished board. const quote = currentBattleResolutionQuote(g); requireRule(!quote.smugglerAllocation && !quote.sandmasterSelection, 'Finish the owned battle spice-pile choice before resolving losses.'); 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; const losingPlayer = quote.winner ? quote.winner === a.id ? d : a : null; const jacurutuIncome = currentJacurutuIncome(g, quote, retreat); if (jacurutuIncome?.kind === 'allocationRequired') { b.jacurutuDial ??= { event: crypto.randomUUID(), owner: jacurutuIncome.opponent, winner: jacurutuIncome.winner, choices: jacurutuIncome.choices, ...(retreat ? {retreat} : {}) }; g.decision = {kind:'jacurutuDial',player:b.jacurutuDial.owner,event:b.jacurutuDial.event}; return; } if (b.jacurutuDial) { requireRule(b.jacurutuDial.selectedDial && jacurutuIncome?.kind === 'income', 'Commit the original physical Jacurutu dial before settling its battle.'); delete b.jacurutuDial; } for (const revelation of quote.revelations) { const holder = getPlayer(g, revelation.player), identity = revelation.identity; if (b.nexusHarkonnenAllowance?.provider === holder.id && b.traitorDeclarations?.[holder.id]?.identity === identity) continue; 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.`); if (holder.id === g.moritaniAssassinate?.owner) log(g, `${holder.name} revealed a Traitor Card normally and forfeited Assassinate Leaders for the rest of this game.`, {faction:'moritani',name:'Assassinate Leaders forfeited'}); } 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; const fixedSukLoss = winner && quote.sukGraduate ? quote.fixedLosses?.find(loss => loss.owner === winner.id) : undefined; const sardaukar = currentNexusSardaukar(g); if (sardaukar?.stage === 'active' && casualtyCommitment && winner?.id === sardaukar.receipt.owner) { sardaukar.casualties = {...structuredClone(casualtyCommitment),outcome:'pending'}; sardaukar.signature = nexusSardaukarSignature(sardaukar); } 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' }, ); } for (const payment of quote.spiceBankerPayments ?? []) { const player = getPlayer(g, payment.player); player.spice -= payment.amount; log(g, payment.waivedByTraitor ? `${player.name}'s successful traitor call waived the committed Spice Banker payment along with other Battle Plan spice.` : `${player.name} paid ${payment.amount} committed Spice Banker spice to the bank, separate from force support. The payment is spent win or lose; only a surviving skilled leader receives its strength bonus.`, { faction: player.faction, name: 'Spice Banker payment' }); } for (const cost of quote.reinforcements ?? []) { const player = getPlayer(g, cost.player); const current = currentReinforcementsCost(g, player); requireRule(cost.normal === current.normal && cost.elite === current.elite, 'Reinforcements lost its original physical reserve cost.'); player.reserves -= cost.normal + cost.elite; player.tanks += cost.normal + cost.elite; player.battleLosses += cost.normal + cost.elite; if (cost.elite) { requireRule(player.elites, 'Reinforcements lost its elite reserve custody.'); player.elites.reserves -= cost.elite; player.elites.tanks += cost.elite; } log(g, `${player.name} used Reinforcements: three reserve forces (${cost.elite} elite) went to the Tanks, and the dialed battle strength gained 2 without adding on-board forces or spice support.`, { faction: player.faction, name: 'Reinforcements' }); } if (quote.choamIncome) g.pendingChoamBattleIncome = { ...quote.choamIncome }; for (const [player, plan, canceled] of [[a, ap, dc], [d, dp, ac]] as const) if (canceled && [cardOf(player, plan.weapon), cardOf(player, plan.defense)].some(isHarassWithdraw)) log(g, `${player.name}'s Harass & Withdraw was canceled by an opposing traitor. No forces returned through this card.`); for (const withdrawal of quote.harassWithdraw ?? []) { const player = getPlayer(g, withdrawal.player); for (const [key, group] of Object.entries(withdrawal.locations)) { player.forces[key] -= group.normal + group.elite; if (!player.forces[key]) delete player.forces[key]; if (group.elite && player.elites) { player.elites.forces[key] -= group.elite; if (!player.elites.forces[key]) delete player.elites.forces[key]; } } player.reserves += withdrawal.returned.normal + withdrawal.returned.elite; if (player.elites) player.elites.reserves += withdrawal.returned.elite; log(g, `${player.name} used Harass & Withdraw to return ${withdrawal.returned.normal + withdrawal.returned.elite} undialed physical forces (${withdrawal.returned.elite} elite) to reserves before battle losses. Their leader still resolves normally.`); } if (quote.harassWithdraw?.length) observeOccupation(g); const explosion = quote.explosion; if (ac && dc) { dead(al); dead(dl); for (const owner of quote.destroyedArmies) killTerritory(g, getPlayer(g, owner), b.territory, Infinity, true); observeOccupation(g); log(g, 'Both leaders were traitors. Both armies were destroyed.'); } else if (ac || dc) { const l = ac ? dl : al; dead(l); winner!.spice += quote.bounty!.amount; for (const owner of quote.destroyedArmies) killTerritory(g, getPlayer(g, owner), b.territory, Infinity, true); observeOccupation(g); 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 player of quote.destroyedArmies) killTerritory(g, getPlayer(g, player), b.territory, Infinity, true); observeOccupation(g); 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, quote.homeworldExplosion ? `Lasgun and shield exploded. Invading armies were destroyed; the native faction loses ${quote.homeworldExplosion.amount} physical forces, limited by its Homeworld’s printed battle strength.` : '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; if (!stoneResult && quote.leaderSkillPenalties) for (const [target, amount] of [[a, quote.leaderSkillPenalties.attacker], [d, quote.leaderSkillPenalties.defender]] as const) if (amount) log(g, `${target.name}'s battle total fell by ${amount} because the opposing Bureaucrat counted ${amount} occupied strongholds. Advisors do not count; the leader's printed strength and bounty are unchanged.`, { faction: (target === a ? d : a).faction, name: 'Bureaucrat' }); if (!stoneResult && !quote.effects?.stunned) for (const [player, receipt, died] of [[a, quote.leaderSkillBonuses.attacker, ak], [d, quote.leaderSkillBonuses.defender, dk]] as const) for (const bonus of died ? [] : receipt.applied) log(g, `${player.name} gained ${bonus.amount} battle strength from ${leaderSkillCard(bonus.skill).name}${bonus.mode === 'normal' ? ' while their skilled leader remained face up' : ' by using their surviving skilled leader'}.`, {faction:player.faction,name:'Leader Skill'}); const loser = winner === a ? d : a; if (quote.bounty) winner!.spice += quote.bounty.amount; if (retreat?.destination) { let normal = retreat.normal, elite = retreat.elite; for (const key of Object.keys(loser.forces).sort()) { if (splitLocation(key).territory !== b.territory || (!normal && !elite) || gameDistance(g, key, retreat.destination, candidate => diplomatRetreatBlocked(g, candidate)) !== 1) continue; const availableElite = loser.elites?.forces[key] ?? 0; const takenNormal = Math.min(normal, loser.forces[key] - availableElite); const takenElite = Math.min(elite, availableElite); loser.forces[key] -= takenNormal + takenElite; if (!loser.forces[key]) delete loser.forces[key]; if (takenElite && loser.elites) { loser.elites.forces[key] -= takenElite; if (!loser.elites.forces[key]) delete loser.elites.forces[key]; } normal -= takenNormal; elite -= takenElite; } requireRule(!normal && !elite, 'The Diplomat retreat lost its undialed physical counters.'); loser.forces[retreat.destination] = (loser.forces[retreat.destination] ?? 0) + retreat.normal + retreat.elite; if (retreat.elite && loser.elites) loser.elites.forces[retreat.destination] = (loser.elites.forces[retreat.destination] ?? 0) + retreat.elite; log(g, `${loser.name} retreated ${retreat.normal} normal and ${retreat.elite} elite undialed forces with Diplomat to ${territory(splitLocation(retreat.destination).territory).name}.`, { faction: loser.faction, name: 'Diplomat retreat' }); observeOccupation(g); } for (const owner of quote.destroyedArmies) killTerritory(g, getPlayer(g, owner), b.territory, Infinity, true); observeOccupation(g); if (quote.basicWinnerLosses !== null) killTerritory(g, winner!, b.territory, quote.basicWinnerLosses, true); observeOccupation(g); log( g, stoneResult ? `${faction(winner!.faction).name} won in ${combatLocationName(g, b.territory)} by Stone Burner’s undialed physical tokens (${a.name} ${stoneResult.attacker.join(' or ')}, ${d.name} ${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 ${combatLocationName(g, b.territory)} (${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' } : { faction: winner!.faction, name: 'Battle' }, ); for (const loss of quote.fixedLosses ?? []) { if (loss === fixedSukLoss) continue; const owner = getPlayer(g, loss.owner); takeBattleLosses(g, owner, b.territory, { normal: loss.normal, elite: loss.elite, paidNormal: 0, paidElite: 0 }); log(g, `${owner.name} sent ${loss.normal + loss.elite} mandatory Occupy forces to the Tanks.`, { faction: 'ecaz', name: 'Occupy casualties' }); } if (quote.fixedLosses?.length) observeOccupation(g); } if (g.spiceBankerIncomePreview) for (const payment of quote.payments) { const player = getPlayer(g, payment.player); awardBankerIncome(g, bankerIncomePayment(g, 'battle-support', [ {payer: player.id, amount: payment.ownPayment, recipient: 'bank'}, ...(payment.allyPayment ? [{payer: player.ally!, amount: payment.allyPayment, recipient: 'bank' as const}] : []), ]), false, true); } if (jacurutuIncome?.kind === 'income' && jacurutuIncome.amount > 0) { winner!.spice += jacurutuIncome.amount; log( g, `${winner!.name} gained ${jacurutuIncome.amount} spice from the bank at Jacurutu Sietch for ${jacurutuIncome.amount} opposing undialed force${jacurutuIncome.amount === 1 ? '' : 's'} sent to the Tanks.`, { faction: winner!.faction, name: 'Jacurutu Sietch income' }, ); } 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'); const winningCoside = winner && b.ecazOccupy?.lead === winner.id ? [b.ecazOccupy.ecaz, b.ecazOccupy.ally] : winner ? [winner.id] : []; if (winner && tleilaxu && !winningCoside.includes(tleilaxu.id) && (!b.territory.startsWith('homeworld:') || homeworldBattleLocation(g, b.territory)!.native === tleilaxu.id)) { const winningPlan = winner.id === a.id ? ap : dp; g.pendingFaceDance = { player: tleilaxu.id, winner: winner.id, ...(winningCoside.length > 1 ? { winners: winningCoside } : {}), 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 playedCardRoles = g.homeworlds?.custody && byFaction(g, 'ecaz') ? battleDiscardRoles(g, b) : undefined; const winningDiscards = quote.discarded.filter((entry) => entry.player === winner?.id).map((entry) => entry.card); 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 && entry.player !== winner?.id, )) discarded.push({ card: discard(g, p, entry.card, playedCardRoles?.[p.id]?.[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]; const battleEvent = b.event ?? g.pendingAuditor?.event ?? crypto.randomUUID(); const inspection: NexusChoamInspectionReceipt | null = winner && g.nexusCards?.cards && !byFaction(g, 'choam') ? { event: battleEvent, opponent: winner.id === a.id ? d.id : a.id, usedCards: [...(winner.id === a.id ? quote.played.defender : quote.played.attacker)], stage: 'pending', signature: '', } : null; if (inspection) inspection.signature = nexusChoamInspectionSignature(inspection); const nexusRetention: NexusMoritaniRetentionReceipt | null = quote.retention?.source === 'nexus' ? { event: battleEvent, player: quote.retention.player, played: [...quote.retention.played], eligible: [...quote.retention.eligible], stage: 'pending', signature: '', } : null; if (nexusRetention) { nexusRetention.signature = nexusMoritaniRetentionSignature(nexusRetention); g.moritaniRetention!.event = battleEvent; } g.lastBattleContext = { event: battleEvent, turn: g.turn, territory: b.territory, combatants: [...g.lastBattle], winner: winner?.id ?? null, result: quote.result, ...(playedCardRoles ? { cardRoles: playedCardRoles } : {}), ...(sardaukar?.casualties ? {nexusSardaukarCasualties:sardaukar.receipt.event} : {}), ...(inspection ? { nexusChoamInspection: inspection } : {}), ...(nexusRetention ? { nexusMoritaniRetention: nexusRetention } : {}), }; recordMoritaniAssassinateOpportunity(g, b, winner, losingPlayer); if (quote.smuggler) { const { receipt, amount } = quote.smuggler; const collector = getPlayer(g, receipt.player); if (amount) { if (receipt.piles) for (const key of Object.keys(receipt.piles)) g.spice[key] = quote.smuggler!.spice[key]; else if (receipt.key) g.spice[receipt.key] = receipt.before - amount; } collector.spice += amount; g.lastBattleContext.smugglerCollection = receipt; log(g, receipt.stage === 'void' ? `${collector.name}'s Smuggler did not survive. Its pending collection is void; no spice was taken from ${combatLocationName(g, b.territory)}.` : `${collector.name}'s surviving Smuggler collected ${amount} spice from ${combatLocationName(g, b.territory)}. The reveal-time piles held ${receipt.before}; the leader's unmodified strength was ${receipt.strength}. ${receipt.before - amount} spice remains before any after-victory effects.`, { faction: collector.faction, name: 'Smuggler collection' }); } if (winner && quote.sandmaster) { const placement = nexusRule(() => sandmasterVictorySpice(b.territory, g.spice, b.sandmasterPile)); if (placement) { g.spice[placement.key] = placement.after; g.lastBattleContext.sandmaster = { leader: quote.sandmaster, ...placement }; log(g, `${winner.name}'s surviving Sandmaster added 3 spice to the existing pile in ${combatLocationName(g, b.territory)}, sector ${splitLocation(placement.key).sector}. The territory now contains ${placement.after} spice; this is board spice, not income.`, { faction: winner.faction, name: 'Sandmaster' }); } } if (winner && quote.rihani) { g.lastBattleContext.rihani = { skill: quote.rihani, cards: [...quote.winnerCards], completed: false, signature: '' }; g.lastBattleContext.rihani.signature = rihaniObligationSignature(g.lastBattleContext); } if (winner && quote.sukGraduate && casualtyCommitment) { requireRule(!g.pendingSukRescue, 'Finish the preceding Suk Graduate rescue.'); let commitment: SukRescueReceipt['commitment']; if (fixedSukLoss) { const forces = combatForces(g, winner, b.territory, losingPlayer!); const dial = fixedSukLoss.normal + fixedSukLoss.elite, support = g.advanced ? dial : 0; commitment = { forces, dial, support, options: casualtyOptions(forces, dial, support) }; } else commitment = structuredClone(casualtyCommitment); g.pendingSukRescue = { event: g.lastBattleContext.event, turn: g.turn, player: winner.id, territory: b.territory, skill: quote.sukGraduate, commitment, pool: sukForcePool(g, winner, b.territory), cards: [...quote.winnerCards], physical: sukPhysicalSignature(winner, g), losses: null, signature: '', ...(winner.faction === 'ixians' ? { eliteOrigins: true as const } : {}), ...(fixedSukLoss ? { occupyCasualties: { ...structuredClone(casualtyCommitment), owner: casualtyCommitment.owner! } } : {}), }; updateSukReceipt(g); } if (g.homeworlds?.custody && winner?.faction === 'atreides' && (quote.result === 'normal' || quote.result === 'traitor')) { requireRule(!g.homeworldVictoryReinforcement || g.homeworldVictoryReinforcement.stage === 'complete', 'Finish the previous Caladan reinforcement before resolving another battle.'); g.homeworldVictoryReinforcement = makeHomeworldVictoryReturn({ event: g.lastBattleContext.event, turn: g.turn, player: winner.id, territory: b.territory, result: quote.result, }); g.lastBattleContext.caladanReinforcement = { event: g.lastBattleContext.event, completed: false, stage: 'waiting', signature: homeworldVictoryObligationSignature(g.lastBattleContext, false, 'waiting'), }; } if (playedCardRoles) g.lastBattleContext.cardRolesSignature = battleCardRolesSignature(g.lastBattleContext); if (winner && winningDiscards.length) g.pendingWinnerDiscards = { event: g.lastBattleContext.event, turn: g.turn, territory: b.territory, player: winner.id, cards: winningDiscards, optional: [...quote.winnerCards], signature: '' }; if (g.pendingWinnerDiscards) g.pendingWinnerDiscards.signature = winnerDiscardSignature(g.pendingWinnerDiscards); if (g.pendingWinnerDiscards) { g.lastBattleContext.winnerDiscards = { cards: [...winningDiscards], completed: false, signature: '' }; g.lastBattleContext.winnerDiscards.signature = winnerDiscardObligationSignature(g.lastBattleContext); } if (b.territory.startsWith('homeworld:') && (casualtyCommitment || quote.homeworldExplosion)) { const owner = quote.homeworldExplosion?.player ?? winner!.id; g.homeworldBattleLoss = { event: g.lastBattleContext.event, territory: b.territory, player: owner, kind: quote.homeworldExplosion ? 'explosion' : 'winner', pool: combatArmy(g, owner, b.territory), options: (quote.homeworldExplosion?.options ?? casualtyCommitment!.options).map(({normal, elite}) => ({normal, elite})), ...(casualtyCommitment ? { commitment: { forces: {...casualtyCommitment.forces}, dial: casualtyCommitment.dial, support: casualtyCommitment.support } } : {}), }; } if (discarded.length) { g.lastBattleContext.mandatoryDiscard = { entries: discarded.map(entry => ({ card: entry.card.id, owner: entry.discardedBy })), signature: '', }; g.lastBattleContext.mandatoryDiscard.signature = battleMandatoryDiscardSignature(g.lastBattleContext); } 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 (g.lastBattleContext.moritaniAssassinate) g.lastBattleContext.moritaniAssassinate.continuation = JSON.stringify(continuation); if (discarded.length) stageTreacheryDiscard(g, 'battle:mandatory', discarded, continuation); else continueResolvedBattle(g, continuation); } function continueResolvedBattle( g: Game, continuation: Extract< NonNullable['continuation'], { kind: 'battleResolved' } >, ) { const assassination = g.moritaniAssassinate?.opportunities.find(r => r.stage === 'choice'); if (assassination) { requireRule(assassination.event === continuation.event && !g.moritaniAssassinateResume, 'The assassination opportunity must preserve its original battle cleanup.'); g.moritaniAssassinateResume = {event:assassination.event,continuation:structuredClone(continuation),signature:JSON.stringify(continuation)}; g.decision = {kind:'moritaniAssassinate',player:assassination.owner,event:assassination.event}; return; } const { cards, territory: to } = continuation; const winner = continuation.winner ? getPlayer(g, continuation.winner) : null; const losses = continuation.casualties?.options; const native = g.homeworldBattleLoss; if (native?.kind === 'explosion') { homeworldBattleLossIntegrity(g); homeworldSubstitutionIntegrity(g); homeworldDefenseIntegrity(g); homeworldShipmentIntegrity(g); if (native.options.length === 1) settleHomeworldExplosion(g, native.options[0], true); else g.decision = { kind: 'homeworldExplosion', event: native.event, player: native.player, territory: native.territory, options: native.options.map((p) => ({...p})), pool: {...native.pool} }; return; } if (winner && losses?.length === 1) { settleWinnerCasualties(g, winner, to, cards, losses[0], true, continuation.casualties?.owner); } else if (winner && losses) g.decision = { kind: 'battleLosses', player: winner.id, territory: to, options: losses, ...(continuation.casualties?.owner ? { forceOwner: continuation.casualties.owner } : {}), 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, forceOwner?: string, ) { if (g.pendingSukRescue) { const pending = g.pendingSukRescue; if (pending.occupyCasualties) { const allied = pending.occupyCasualties; requireRule(forceOwner === allied.owner && forceOwner !== p.id && allied.options.some(option => JSON.stringify(option) === JSON.stringify(choice)), 'Choose the original allied Occupy casualties before rescuing your own forces.'); takeBattleLosses(g, getPlayer(g, forceOwner), to, choice); delete pending.occupyCasualties; updateSukReceipt(g); observeOccupation(g); settleWinnerCasualties(g, p, to, cards, pending.commitment.options[0], automatic); return; } requireRule(pending.player === p.id && pending.territory === to && JSON.stringify(cards) === JSON.stringify(pending.cards) && !pending.losses && pending.commitment.options.some((o) => JSON.stringify(o) === JSON.stringify(choice)), 'Choose the original Suk Graduate casualty allocation.'); pending.losses = { ...choice }; updateSukReceipt(g); const options = sukRescueOptions(pending.skill, pending.pool, choice, pending.eliteOrigins); const reserveHomes = sukReserveHomes(g, p, to, options); if (options.length === 1 && !reserveHomes) settleSukRescue(g, options[0], true); else g.decision = { kind: 'sukRescue', player: p.id, event: pending.event, territory: to, mode: pending.skill.mode, options, ...(reserveHomes ? { reserveHomes } : {}) }; return; } if (to.startsWith('homeworld:')) homeworldBattleLossIntegrity(g); const sardaukar = g.nexusSardaukarHistory?.find(record => record.receipt.battle === g.lastBattleContext?.event && record.receipt.owner === p.id); if (sardaukar?.casualties) requireRule(sardaukar.casualties.outcome === 'pending' && sardaukar.casualties.options.some(option => JSON.stringify(option) === JSON.stringify(choice)), 'Choose an original Nexus Sardaukar casualty allocation.'); const physicalOwner = forceOwner ? getPlayer(g, forceOwner) : p; const losses = takeBattleLosses(g, physicalOwner, to, choice); if (sardaukar?.casualties) { sardaukar.casualties.outcome = 'complete'; sardaukar.signature = nexusSardaukarSignature(sardaukar); } observeOccupation(g); if (to.startsWith('homeworld:')) g.homeworldBattleLoss = null; log( g, `${physicalOwner.name} sent ${choice.normal} normal and ${choice.elite} elite forces from ${combatLocationName(g, to)} 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, ); stageIxSubstitution(g, physicalOwner, to, cards, losses, p.id); finishWinner(g, p, to, cards); } /** Stage faction substitution after actual Tank losses; skills finish before it opens. */ function stageIxSubstitution(g: Game, p: Player, to: string, cards: string[], losses: Record, winner = p.id) { const cyborgsLost = Object.values(losses).reduce((sum, count) => sum + count, 0); const survivingSuboids = to.startsWith('homeworld:') ? combatArmy(g, p.id, to).normal : 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' && cyborgsLost > 0 && survivingSuboids > 0) { g.pendingIxSubstitution = { player: p.id, territory: to, losses, cards, ...(winner !== p.id ? { winner } : {}), ...(to.startsWith('homeworld:') ? { homeworld: { pool: combatArmy(g, p.id, to), cyborgsLost, eliteTanks: p.elites!.tanks, normalTanks: p.tanks - p.elites!.tanks, battleLosses: p.battleLosses, } } : {}), }; if (g.leaderSkills) { const pending = g.pendingIxSubstitution; pending.receipt = { event: g.lastBattleContext!.event, turn: g.turn, physical: sukPhysicalSignature(p), signature: '' }; pending.receipt.signature = ixSubstitutionSignature(pending); g.lastBattleContext!.ixSubstitution = { signature: pending.receipt.signature, completed: false }; } } } function ixSubstitutionSignature(pending: NonNullable) { return JSON.stringify([pending.player, pending.territory, pending.losses, pending.cards, pending.receipt?.event, pending.receipt?.turn, pending.receipt?.physical, ...(pending.winner === undefined ? [] : [pending.winner]), ...(pending.sources === undefined ? [] : [pending.sources, pending.recover])]); } function ixSubstitutionIntegrity(g: Game) { const pending = g.pendingIxSubstitution, context = g.lastBattleContext, obligation = context?.ixSubstitution; if (!pending?.receipt && !obligation) { requireRule(!pending || !g.leaderSkills, 'The Ixian skill aftermath lost its substitution receipt.'); return; } if (obligation?.completed) { requireRule(!pending, 'The completed Ixian substitution cannot be replayed.'); return; } const owner = pending && g.players.find(p => p.id === pending.player); requireRule(pending?.receipt && owner?.faction === 'ixians' && obligation && !obligation.completed && context.event === pending.receipt.event && context.turn === pending.receipt.turn && g.turn === context.turn && context.winner === (pending.winner ?? pending.player) && context.territory === pending.territory && g.phase === 6 && !g.battle && g.status === 'playing' && pending.receipt.signature === obligation.signature && pending.receipt.signature === ixSubstitutionSignature(pending) && pending.receipt.physical === sukPhysicalSignature(owner) && pending.cards.every(id => getPlayer(g, pending.winner ?? pending.player).hand.some(card => card.id === id)), 'The saved Ixian substitution changed its original battle losses, cards or physical counters.'); const decisions = homeworldSavedDecisions(g); const selecting = decisions.filter(d => d.kind === 'ixSubstitution'); const continuation = g.pendingTreacheryDiscard?.continuation; const exchangeResponse = g.response?.kind === 'ixSubstitution' || (g.pendingKarama?.use.kind === 'cancel' && g.pendingKarama.use.response.kind === 'ixSubstitution') || (continuation?.kind === 'karamaBattleDiscard' && continuation.use.response.kind === 'ixSubstitution'); requireRule(selecting.every(d => d.player === pending.player && d.territory === pending.territory && JSON.stringify(d.losses) === JSON.stringify(pending.losses)) && (exchangeResponse ? !!pending.sources && !!pending.recover : !pending.sources && !pending.recover) && (exchangeResponse || selecting.length === 1 || decisions.some(d => d.kind === 'rihani' && d.player === pending.player)), 'The saved Ixian substitution lost its original choice or preceding skill exchange.'); } function completeIxSubstitution(g: Game) { g.pendingIxSubstitution = null; if (g.lastBattleContext?.ixSubstitution) g.lastBattleContext.ixSubstitution.completed = true; } function sukForcePool(g: Game, p: Player, territory: string): SukForceGroup[] { if (territory.startsWith('homeworld:')) { const { normal, elite } = combatArmy(g, p.id, territory); return [{ key: territory, normal, elite }]; } return Object.entries(p.forces).filter(([key]) => splitLocation(key).territory === territory) .map(([key, total]) => ({ key, normal: total - (p.elites?.forces[key] ?? 0), elite: p.elites?.forces[key] ?? 0 })); } function sukPhysicalSignature(p: Player, g?: Game) { return JSON.stringify({ forces: p.forces, elites: p.elites ?? null, reserves: p.reserves, tanks: p.tanks, battleLosses: p.battleLosses, ...(g?.homeworlds?.custody ? { homeworlds: g.homeworlds.custody } : {}) }); } function sukReserveHomes(g: Game, player: Player, territory: string, options: readonly SukRescueOption[]): Extract['reserveHomes'] { if (!g.homeworlds?.custody || !homeworldLeaderSkillsProfile(g) || !options.some(option => option.normal + option.elite > (option.kept ? 1 : 0))) return; const homes = homeworldRule(() => homeworldForceGroups(homeworldContext(g), g.homeworlds!.custody!)); if (homes.some(home => home.id === territory && home.native === player.id)) return; const native = homes.filter(home => home.native === player.id); if (native.length > 1) return native.map(home => ({ id: home.id, name: combatLocationName(g, home.id), secondary: !!home.secondary, })); } function updateSukReceipt(g: Game) { const pending = g.pendingSukRescue!; pending.signature = sukReceiptSignature(pending); g.lastBattleContext!.sukRescue = { signature: pending.signature, completed: false }; } function sukRescueIntegrity(g: Game) { const pending = g.pendingSukRescue, context = g.lastBattleContext; const decisions = homeworldSavedDecisions(g).filter((d) => d.kind === 'sukRescue'); if (!pending) { requireRule(!decisions.length && (!context?.sukRescue || context.sukRescue.completed), 'The saved Suk Graduate rescue receipt is missing.'); return; } const player = g.players.find((p) => p.id === pending.player); requireRule(player && g.leaderSkills && g.phase === 6 && !g.battle && g.leaderSkills.assignments.some((a) => a.skill === 'suk-graduate' && a.leader === pending.skill.leader) && context?.event === pending.event && context.turn === pending.turn && g.turn === pending.turn && context.result === 'normal' && context.winner === pending.player && context.territory === pending.territory && context.sukRescue?.signature === pending.signature && !context.sukRescue.completed && pending.signature === sukReceiptSignature(pending) && pending.physical === sukPhysicalSignature(player, g) && (pending.eliteOrigins === undefined || pending.eliteOrigins === true && player.faction === 'ixians') && JSON.stringify(pending.pool) === JSON.stringify(sukForcePool(g, player, pending.territory)) && pending.commitment.forces.normal === pending.pool.reduce((sum, group) => sum + group.normal, 0) && pending.commitment.forces.elite === pending.pool.reduce((sum, group) => sum + group.elite, 0) && JSON.stringify(pending.commitment.options) === JSON.stringify(casualtyOptions(pending.commitment.forces, pending.commitment.dial, pending.commitment.support)) && pending.commitment.options.length > 0 && pending.cards.every((id) => player.hand.some((c) => c.id === id)), 'The saved Suk Graduate rescue no longer matches its battle, cards or physical forces.'); if (pending.losses) { requireRule(pending.commitment.options.some((o) => JSON.stringify(o) === JSON.stringify(pending.losses)) && decisions.length === 1 && decisions.every((d) => d.player === pending.player && d.event === pending.event && d.territory === pending.territory && d.mode === pending.skill.mode && JSON.stringify(d.options) === JSON.stringify(sukRescueOptions(pending.skill, pending.pool, pending.losses!, pending.eliteOrigins)) && JSON.stringify(d.reserveHomes) === JSON.stringify(sukReserveHomes(g, player, pending.territory, d.options))), 'The saved Suk Graduate rescue choices differ from the committed casualties.'); } else { const lossDecision = homeworldSavedDecisions(g).find((d) => d.kind === 'battleLosses' && d.player === pending.player); const discard = g.pendingTreacheryDiscard?.continuation; const commitment = pending.occupyCasualties ?? pending.commitment; requireRule(!decisions.length && (lossDecision ? lossDecision.kind === 'battleLosses' && lossDecision.territory === pending.territory && JSON.stringify(lossDecision.options) === JSON.stringify(commitment.options) && (!pending.occupyCasualties || lossDecision.forceOwner === pending.occupyCasualties.owner) && JSON.stringify(lossDecision.cards) === JSON.stringify(pending.cards) : discard?.kind === 'battleResolved' && discard.event === pending.event && JSON.stringify(discard.casualties) === JSON.stringify(commitment)), 'The Suk Graduate rescue lost its casualty continuation.'); } } function settleSukRescue(g: Game, option: SukRescueOption, automatic: boolean, destinations?: SukReserveDestinations) { const pending = g.pendingSukRescue!; const player = getPlayer(g, pending.player); requireRule(pending.losses && pending.physical === sukPhysicalSignature(player, g), 'The Suk Graduate rescue no longer has its committed physical counters.'); const sardaukar = g.nexusSardaukarHistory?.find(record => record.receipt.battle === pending.event && record.receipt.owner === pending.player); if (sardaukar?.casualties) requireRule(sardaukar.casualties.outcome === 'pending' && JSON.stringify(pending.commitment) === JSON.stringify({forces:sardaukar.casualties.forces, dial:sardaukar.casualties.dial,support:sardaukar.casualties.support,options:sardaukar.casualties.options}) && sardaukar.casualties.options.some(choice => JSON.stringify(choice) === JSON.stringify(pending.losses)), 'The Suk rescue lost its original Nexus Sardaukar casualty allocation.'); let physical: SukPhysicalRescueQuote | undefined; if (homeworldLeaderSkillsProfile(g)) { try { physical = homeworldRule(() => quoteSukPhysicalRescue(homeworldLossContext(g), g.homeworlds!.custody!, { player: player.id, territory: pending.territory, skill: pending.skill, pool: pending.pool, losses: pending.losses!, option, destinations, eliteOrigins: pending.eliteOrigins, })); } catch (error) { if (error instanceof SukPhysicalRescueError) throw new RuleError(error.message); throw error; } } else requireRule(destinations === undefined, 'Suk reserve destinations require the original Homeworld composition.'); const quote = physical ? null : quoteSukRescue(pending.skill, pending.pool, pending.losses, option, pending.eliteOrigins); if (!physical || !pending.territory.startsWith('homeworld:')) for (const group of physical?.removed ?? quote!.removed) { player.forces[group.key] -= group.normal + group.elite; if (!player.forces[group.key]) delete player.forces[group.key]; if (player.elites) { player.elites.forces[group.key] = (player.elites.forces[group.key] ?? 0) - group.elite; if (!player.elites.forces[group.key]) delete player.elites.forces[group.key]; } } if (physical) { commitHomeworldResources(g, physical); if (pending.territory.startsWith('homeworld:')) g.homeworldBattleLoss = null; g.lastBattleContext!.sukRescue!.physical = physical.receipt; } else { player.reserves += quote!.reserves.normal + quote!.reserves.elite; player.tanks += quote!.tanks.normal + quote!.tanks.elite; // User ruling 7 October 2026: rescued counters still count toward the seven // battle losses that activate Kwisatz Haderach, so the full dialed casualty // allocation is added, not only the counters that reach the Tanks. player.battleLosses += pending.losses.normal + pending.losses.elite; if (player.elites) { player.elites.reserves += quote!.reserves.elite; player.elites.tanks += quote!.tanks.elite; } } if (sardaukar?.casualties) { sardaukar.casualties.outcome = 'complete'; sardaukar.signature = nexusSardaukarSignature(sardaukar); } g.pendingSukRescue = null; g.lastBattleContext!.sukRescue!.completed = true; const native = physical?.receipt.source === 'native-homeworld'; const returned = native ? 0 : option.normal + option.elite - (option.kept ? 1 : 0); const tanks = physical?.receipt.tanks ?? quote!.tanks; const kept = native ? `${option.normal + option.elite} saved counters remained in their original Homeworld reserve pool` : option.kept ? option.kept.key.startsWith('homeworld:') ? `1 remained on ${combatLocationName(g, option.kept.key)}` : `1 remained in sector ${splitLocation(option.kept.key).sector}` : 'none remained in the battle territory'; log(g, `${player.name}'s Suk Graduate saved ${option.normal} ordinary and ${option.elite} elite forces: ${kept}, ${returned} returned to reserves, and ${tanks.normal + tanks.elite} casualties went to the Tanks.${automatic ? ' The only legal rescue was applied automatically.' : ''}`, { faction: player.faction, name: 'Suk Graduate rescue' }); observeOccupation(g); if (pending.eliteOrigins) stageIxSubstitution(g, player, pending.territory, pending.cards, physical ? physical.eliteTanks! : quote!.eliteTanks!); finishWinner(g, player, pending.territory, pending.cards); } function winnerDiscardSignature(pending: NonNullable) { const { event, turn, territory, player, cards, optional } = pending; return JSON.stringify({ event, turn, territory, player, cards, optional }); } function winnerDiscardObligationSignature(context: NonNullable) { return JSON.stringify({ event: context.event, turn: context.turn, territory: context.territory, player: context.winner, cards: context.winnerDiscards?.cards, completed: context.winnerDiscards?.completed }); } function winnerDiscardsIntegrity(g: Game) { const pending = g.pendingWinnerDiscards; const context = g.lastBattleContext; const obligation = context?.winnerDiscards; if (obligation) { requireRule(obligation.signature === winnerDiscardObligationSignature(context!) && Array.isArray(obligation.cards) && obligation.cards.length > 0 && new Set(obligation.cards).size === obligation.cards.length && typeof obligation.completed === 'boolean' && (obligation.completed ? !pending : !!pending && JSON.stringify(pending.cards) === JSON.stringify(obligation.cards)), 'The saved mandatory winner discard obligation lost its original queue or completion.'); } if (!pending) return; const owner = g.players.find((p) => p.id === pending.player); requireRule(g.status === 'playing' && g.phase === 6 && pending.turn === g.turn && obligation && !obligation.completed && pending.signature === winnerDiscardSignature(pending) && context?.event === pending.event && context.turn === g.turn && context.territory === pending.territory && context.winner === pending.player && owner && Array.isArray(pending.cards) && pending.cards.length > 0 && Array.isArray(pending.optional) && new Set([...pending.cards, ...pending.optional]).size === pending.cards.length + pending.optional.length && [...pending.cards, ...pending.optional].every((id) => typeof id === 'string' && owner.hand.some((card) => card.id === id)), 'The saved mandatory winner discards no longer match their battle or physical cards.'); } function rihaniObligationSignature(context: NonNullable) { return JSON.stringify([context.event, context.turn, context.winner, context.territory, context.rihani?.skill, context.rihani?.cards, context.rihani?.completed, ...(context.rihani?.faceDanceStarted === undefined ? [] : [context.rihani.faceDanceStarted])]); } function pendingRihani(g: Game) { return g.rihaniHistory?.find((r) => r.stage !== 'complete') ?? null; } function rihaniIntegrity(g: Game) { const context = g.lastBattleContext, obligation = context?.rihani; const decisions = homeworldSavedDecisions(g).filter((d) => d.kind === 'rihani'); const history = g.rihaniHistory ?? [], pending = pendingRihani(g); if (history.length) { requireRule(g.leaderSkills && new Set(history.map((r) => r.event)).size === history.length, 'Rihani history requires its original skill module and distinct battles.'); const universe = nexusTraitorUniverse(g); for (const receipt of history) nexusRule(() => validateRihani(receipt, universe)); } requireRule(history.filter((r) => r.stage !== 'complete').length <= 1, 'Finish the preceding Rihani exchange.'); if (pending) { requireRule(obligation && !obligation.completed && context.event === pending.event && context.winner === pending.owner && context.turn === pending.turn && g.turn === pending.turn && g.status === 'playing' && g.phase === 6 && !g.battle && JSON.stringify(obligation.skill) === JSON.stringify(pending.skill) && JSON.stringify(getPlayer(g, pending.owner).revealedTraitors ?? []) === JSON.stringify(pending.used) && decisions.length === 1 && decisions[0].event === pending.event && decisions[0].player === pending.owner && decisions[0].stage === pending.stage, 'The saved Rihani exchange lost its battle or owned choice.'); nexusRule(() => validateRihani(pending, nexusTraitorUniverse(g), nexusTraitorSnapshot(g))); } else requireRule(!decisions.length, 'The Rihani choice has lost its physical draw receipt.'); if (!obligation) return; requireRule(obligation.faceDanceStarted === undefined || (obligation.faceDanceStarted === true && obligation.completed), 'Invalid saved Rihani aftermath boundary.'); requireRule(obligation.signature === rihaniObligationSignature(context!), 'The saved Rihani victory obligation changed.'); const receipt = history.find((r) => r.event === context!.event); if (obligation.completed) { requireRule(receipt?.stage === 'complete' && receipt.owner === context!.winner && receipt.turn === context!.turn && JSON.stringify(receipt.skill) === JSON.stringify(obligation.skill), 'The completed Rihani victory lost its receipt.'); if (g.phase === 6 && context!.turn === g.turn && !g.battle) { if (obligation.faceDanceStarted) nexusRule(() => validateNexusTraitorSnapshot(nexusTraitorSnapshot(g), nexusTraitorUniverse(g))); else nexusRule(() => validateRihani(receipt, nexusTraitorUniverse(g), nexusTraitorSnapshot(g))); } } else { const winner = g.players.find((p) => p.id === context!.winner); requireRule(winner && g.status === 'playing' && g.phase === 6 && !g.battle && context!.turn === g.turn && g.leaderSkills?.assignments.some((a) => a.skill === 'rihani-decipherer' && a.leader === obligation.skill.leader) && obligation.cards.every((id) => winner.hand.some((c) => c.id === id)) && (receipt ? receipt === pending : !!g.pendingTreacheryDiscard || !!g.pendingSukRescue || homeworldSavedDecisions(g).some((d) => d.kind === 'battleLosses' && d.player === winner.id)), 'The Rihani victory lost its skilled leader, cards or preceding casualty settlement.'); } } function completeRihaniObligation(g: Game) { g.lastBattleContext!.rihani!.completed = true; g.lastBattleContext!.rihani!.signature = rihaniObligationSignature(g.lastBattleContext!); } function startRihaniVictory(g: Game, winner: Player, territory: string, cards: string[]): boolean { const context = g.lastBattleContext, obligation = context?.rihani; if (!obligation || obligation.completed) return false; requireRule(context.winner === winner.id && context.territory === territory && JSON.stringify(obligation.cards) === JSON.stringify(cards) && !g.rihaniHistory?.some((r) => r.event === context.event), 'Resume the original Rihani victory before winner card cleanup.'); const receipt = nexusRule(() => beginRihani(nexusTraitorSnapshot(g), nexusTraitorUniverse(g), { event: context.event, turn: g.turn, owner: winner.id, skill: obligation.skill }, winner.revealedTraitors ?? [], random)); (g.rihaniHistory ??= []).push(receipt); applyNexusTraitorSnapshot(g, receipt.state); if (receipt.peeked.length) log(g, `${winner.name}'s Rihani Decipherer privately inspected two random Traitor Deck cards and shuffled them back. The identities are in that player's private skill history.`, { faction: winner.faction, name: 'Rihani Decipherer' }); if (receipt.stage === 'complete') { completeRihaniObligation(g); if (receipt.skill.skilled && !receipt.eligible.length) log(g, `${winner.name} has no ${winner.faction === 'tleilaxu' ? 'unrevealed Face Dancer' : 'unused held Traitor Card'} to exchange, so Rihani offers no draw.`); return false; } g.decision = { kind: 'rihani', event: receipt.event, player: winner.id, stage: 'offer' }; return true; } function actRihani(g: Game, player: Player, decision: Extract, action: Action) { const receipt = pendingRihani(g), context = g.lastBattleContext!; requireRule(receipt?.owner === player.id && receipt.event === action.event && receipt.event === decision.event, 'Choose only the current Rihani exchange.'); let next: RihaniReceipt; if (decision.stage === 'offer') { requireRule(typeof action.draw === 'boolean' && Object.keys(action).every((key) => ['type', 'event', 'draw'].includes(key)), 'Decide whether to draw before seeing the two new Traitors.'); next = nexusRule(() => chooseRihaniDraw(receipt, nexusTraitorUniverse(g), nexusTraitorSnapshot(g), action.draw as boolean)); } else { requireRule(Array.isArray(action.cards) && action.cards.length === 2 && action.cards.every((id) => typeof id === 'string') && Object.keys(action).every((key) => ['type', 'event', 'cards'].includes(key)), 'Choose one newly drawn and one unused old Traitor.'); const cards = action.cards as string[]; next = nexusRule(() => finishRihani(receipt, nexusTraitorUniverse(g), nexusTraitorSnapshot(g), cards[0], cards[1], random)); } g.rihaniHistory![g.rihaniHistory!.findIndex((r) => r.event === receipt.event)] = next; applyNexusTraitorSnapshot(g, next.state); if (next.stage === 'return') { g.decision = { kind: 'rihani', event: next.event, player: player.id, stage: 'return' }; log(g, `${player.name} drew two private ${player.faction === 'tleilaxu' ? 'Face Dancer' : 'Traitor'} Cards through Rihani and must keep one by revealing an ${player.faction === 'tleilaxu' ? 'unrevealed' : 'unused'} old card.`); } else { completeRihaniObligation(g); if (next.given) { const name = next.given === CHEAP_HERO_TRAITOR ? 'Cheap Hero / Heroine' : g.players.flatMap((p) => p.leaders).find((l) => l.id === next.given)?.name; log(g, `${player.name} revealed the unused ${name ?? next.given} ${player.faction === 'tleilaxu' ? 'Face Dancer' : 'Traitor Card'} for Rihani and returned it with the unkept new card to the shuffled Traitor Deck. The kept card remains private.`); } else log(g, `${player.name} declined Rihani's optional Traitor exchange.`); finishWinner(g, player, context.territory, context.rihani!.cards); } } function projectedRihani(g: Game, owner: string) { if (!g.leaderSkills) return null; const pending = pendingRihani(g); return { history: (g.rihaniHistory ?? []).filter((r) => r.owner === owner).map((r) => ({ event: r.event, turn: r.turn, peeked: [...r.peeked], drawn: [...r.drawn], kept: r.kept, given: r.given, })), pending: pending ? { event: pending.event, owner: pending.owner, stage: pending.stage, ...(pending.owner === owner && pending.stage === 'return' ? { drawn: [...pending.drawn], eligible: [...pending.eligible] } : {}), } : null }; } function finishWinner(g: Game, winner: Player, t: string, cards: string[]) { if (startRihaniVictory(g, winner, t, cards)) return; const assassination = g.moritaniAssassinate?.opportunities.find(r => r.stage === 'skills'); if (assassination) { const context = g.lastBattleContext!; requireRule(assassination.event === context.event && assassination.opponent === winner.id && assassination.territory === t && context.result === 'normal', 'Finish the original winner’s skills before its assassination response.'); assassination.stage = 'choice'; syncMoritaniAssassinateReceipt(g, assassination); const continuation = { kind: 'battleResolved' as const, event: context.event, result: 'normal' as const, combatants: context.combatants, territory: t, winner: winner.id, cards, }; context.moritaniAssassinate!.continuation = JSON.stringify(continuation); continueResolvedBattle(g, continuation); return; } if (g.pendingIxSubstitution) { const pending = g.pendingIxSubstitution; requireRule((pending.winner ?? pending.player) === winner.id && pending.territory === t && JSON.stringify(pending.cards) === JSON.stringify(cards) && !pending.sources && !pending.recover, 'Resume the original Ixian substitution after Leader Skills.'); g.decision = { kind: 'ixSubstitution', player: pending.player, territory: t, losses: pending.losses }; return; } if (g.pendingWinnerDiscards) { winnerDiscardsIntegrity(g); const pending = g.pendingWinnerDiscards; requireRule(pending.player === winner.id && pending.territory === t && JSON.stringify(pending.optional) === JSON.stringify(cards), 'The mandatory winner discard must resume its original cleanup.'); const entries = pending.cards.map((id) => ({ card: discard(g, winner, id, cleanupDiscardRole(g, winner, id)), discardedBy: winner.id, publicFace: true })); g.pendingWinnerDiscards = null; if (g.lastBattleContext?.winnerDiscards) { g.lastBattleContext.winnerDiscards.completed = true; g.lastBattleContext.winnerDiscards.signature = winnerDiscardObligationSignature(g.lastBattleContext); } stageTreacheryDiscard(g, 'battle:winnerMandatory', entries, { kind: 'winnerMandatoryDiscard', event: pending.event, player: winner.id, territory: t, optional: [...cards], commitment: structuredClone(pending) }); return; } const context = g.lastBattleContext; const moritani = byFaction(g, 'moritani'); if (g.advanced && context && moritani && !moritani.specialKaramaUsed && context.winner === winner.id && context.combatants.includes(moritani.id) && moritani.id !== winner.id && cards.length && g.moritaniSpecialKarama?.source.event !== context.event) { g.moritaniSpecialKarama = { source: { event: context.event, turn: g.turn, territory: t, owner: moritani.id, opponent: winner.id }, cards: [...cards], optional: [...cards], stage: 'offer', }; if (currentMoritaniSpecialKaramaQuote(g)) { g.decision = { kind: 'moritaniSpecialKarama', player: moritani.id, event: context.event }; return; } g.moritaniSpecialKarama.stage = 'done'; } 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: combatLocations(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(() => quoteCombatBoardContinuation(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 homeworldVictoryWitness(frame: HomeworldVictoryReturn) { return { event: frame.event, turn: frame.turn, player: frame.player, territory: frame.territory, result: frame.result }; } function homeworldVictoryReturnIntegrity(g: Game) { homeworldRule(() => validateHomeworldVictoryReturn(g)); const frame = g.homeworldVictoryReinforcement; if (frame?.stage === 'arrival' || frame?.stage === 'complete') homeworldRule(() => validateHomeworldArrival(g, frame, 'victoryEvent')); const continuation = g.pendingTreacheryDiscard?.continuation; const contexts = [g, g.pendingExchange, g.pendingNullentropy?.resume, g.pendingRicheseGift?.resume, g.pendingRichesePurchaseIncome?.resume, continuation && 'resume' in continuation ? continuation.resume : null]; const decisions = contexts.flatMap((context) => context?.decision?.kind === 'caladanReinforcement' ? [context.decision] : []); requireRule(decisions.every((decision) => frame?.stage === 'choice' && decision.player === frame.player && decision.event === frame.event), 'The Caladan choice has lost its original victory.'); if (frame?.stage === 'choice') requireRule(decisions.length > 0, 'The Caladan victory has lost its reinforcement choice.'); if (frame?.stage === 'waiting') requireRule(!!g.pendingTreacheryDiscard || !!g.pendingNullentropy || !!g.truthtrance || !!g.pendingKarama || contexts.some((context) => !!context?.decision || !!context?.response), 'The Caladan victory has lost its preceding battle cleanup choice.'); } function completeHomeworldVictoryReturn(g: Game) { const frame = g.homeworldVictoryReinforcement!; frame.stage = 'complete'; const context = g.lastBattleContext!; requireRule(context.caladanReinforcement?.event === frame.event, 'The Caladan reinforcement has lost its battle completion.'); context.caladanReinforcement.completed = true; context.caladanReinforcement.stage = 'complete'; context.caladanReinforcement.signature = homeworldVictoryObligationSignature(context, true, 'complete'); } function stampHomeworldVictoryStage(g: Game) { const frame = g.homeworldVictoryReinforcement!; const context = g.lastBattleContext!; requireRule(context.caladanReinforcement?.event === frame.event, 'The Caladan reinforcement has lost its original stage obligation.'); context.caladanReinforcement.stage = frame.stage; context.caladanReinforcement.signature = homeworldVictoryObligationSignature(context, context.caladanReinforcement.completed, frame.stage); } function openHomeworldVictoryReturn(g: Game, faceDancePending: boolean) { const frame = g.homeworldVictoryReinforcement; if (!frame || frame.stage === 'complete' || frame.event !== g.lastBattleContext?.event) return false; requireRule(frame.stage === 'waiting', 'Finish the Caladan reinforcement before resuming battle cleanup.'); const quote = homeworldRule(() => quoteHomeworldVictoryReinforcement(g, homeworldVictoryWitness(frame))); if (quote.amount === 0) { frame.destination = 'decline'; frame.ambassadors = []; frame.arrivalSignature = homeworldArrivalSignature(frame); completeHomeworldVictoryReturn(g); if (quote.blocked) log(g, `${getPlayer(g, frame.player).name} received no Caladan victory reinforcement: ${quote.blocked}`); return false; } const blocked = faceDancePending ? 'Caladan reinforcement and Face Dancer replacement await an ordering ruling. You may leave the reserve force at home.' : quote.blocked; const offer = { population: quote.population, survivors: quote.survivors, blocked }; frame.offer = { ...offer, signature: homeworldVictoryOfferSignature(frame, offer) }; frame.stage = 'choice'; stampHomeworldVictoryStage(g); g.decision = { kind: 'caladanReinforcement', player: frame.player, event: frame.event }; return true; } function decideHomeworldVictoryReturn(g: Game, p: Player, action: Action) { const frame = g.homeworldVictoryReinforcement; requireRule(frame?.stage === 'choice' && frame.player === p.id && frame.event === action.event, 'Choose the current Caladan victory reinforcement.'); if (action.decline === true) { requireRule(action.amount === undefined && action.destination === undefined, 'Leaving the reserve force at home does not select a quantity or destination.'); frame.destination = 'decline'; frame.ambassadors = []; frame.arrivalSignature = homeworldArrivalSignature(frame); completeHomeworldVictoryReturn(g); log(g, `${p.name} left the optional Caladan victory reinforcement in reserves.`); finishBattle(g); return; } requireRule(!frame.offer?.blocked, frame.offer?.blocked ?? 'This reinforcement needs its ordering ruling.'); requireRule((action.amount === undefined || action.amount === 1) && typeof action.destination === 'string', 'Caladan adds exactly one reserve force to the battle location.'); const quote = homeworldRule(() => quoteHomeworldVictoryReinforcementDestination(g, homeworldVictoryWitness(frame), action.destination as string)); const seat = quote.transfer.players.find((seat) => seat.id === p.id)!; p.reserves = seat.reserves; g.homeworlds!.custody = quote.transfer.state; observeOccupation(g); frame.destination = quote.selected.id; frame.stage = 'arrival'; frame.ambassadors = []; frame.arrivalSignature = homeworldArrivalSignature(frame); stampHomeworldVictoryStage(g); if (quote.selected.territory) { place(p, quote.selected.territory, quote.selected.sector!, 1); openTerritoryEntry(g, p, quote.selected.territory, quote.selected.sector!, 1, 0, 'caladanReinforcement'); if (g.pendingAmbassador) { g.pendingAmbassador.victoryEvent = frame.event; appendHomeworldArrivalAmbassador(frame, g.pendingAmbassador); } } log(g, `${p.name} added one force from Caladan reserves to ${quote.selected.name} after winning the battle. The force joins an existing surviving army; no spice or shipment allowance was spent.`, { faction: p.faction, name: 'Caladan victory reinforcement' }); } function resumeHomeworldVictoryReturn(g: Game) { const frame = g.homeworldVictoryReinforcement; if (frame?.stage !== 'arrival' || g.pendingTreacheryDiscard || g.pendingNullentropy || g.truthtrance || g.pendingExchange || g.pendingRicheseGift || g.pendingAmbassador || g.pendingTerrorEntry || g.pendingKarama || g.decision || g.response) return; completeHomeworldVictoryReturn(g); finishBattle(g); } function projectedHomeworldVictoryReturn(g: Game, player: string) { const frame = g.homeworldVictoryReinforcement; if (frame?.stage !== 'choice') return null; const quote = homeworldRule(() => quoteHomeworldVictoryReinforcement(g, homeworldVictoryWitness(frame))); return { event: frame.event, player: frame.player, territory: frame.territory, amount: 1 as const, blocked: frame.offer?.blocked ?? quote.blocked, destinations: player === frame.player ? quote.destinations : [], }; } 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; const rihani = g.lastBattleContext?.rihani; if (next.kind === 'faceDance' && rihani && !rihani.faceDanceStarted) { // Winner cleanup is complete. Face Dance may now reveal or recycle cards; // the earlier exchange remains immutable history, not current deck custody. requireRule(rihani.completed, 'Finish Rihani before Face Dance.'); rihaniIntegrity(g); rihani.faceDanceStarted = true; rihani.signature = rihaniObligationSignature(g.lastBattleContext!); } if ((next.kind === 'faceDance' || next.kind === 'board') && openHomeworldVictoryReturn(g, next.kind === 'faceDance')) return; const choamInspection = g.lastBattleContext?.nexusChoamInspection; if (next.kind === 'board' && choamInspection?.stage === 'pending') { choamInspection.stage = 'offer'; choamInspection.signature = nexusChoamInspectionSignature(choamInspection); g.decision = { kind: 'nexusChoamInspection', player: g.lastBattleContext!.winner!, event: choamInspection.event, }; return; } if (next.kind === 'moritaniRetention' && next.source === 'nexus') { const receipt = g.lastBattleContext?.nexusMoritaniRetention; requireRule(!!receipt && receipt.event === next.event && ['pending', 'offer'].includes(receipt.stage), 'The Moritani Nexus loser window lost its battle.'); receipt.stage = 'offer'; receipt.signature = nexusMoritaniRetentionSignature(receipt); } 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].chooser; 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) && combatLocations(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.pendingKull?.resume, g.pendingTreacheryDiscard?.continuation.kind === 'nullentropyDiscard' || g.pendingTreacheryDiscard?.continuation.kind === 'truthtranceDiscard' || g.pendingTreacheryDiscard?.continuation.kind === 'karamaBattleDiscard' ? 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: combatLocations(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 battle 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.', ); if (pending.source === 'nexus') { const receipt = g.lastBattleContext?.nexusMoritaniRetention; requireRule(receipt?.stage === 'offer' && receipt.event === pending.event && receipt.player === player.id && pending.stage === 'choose', 'Finish the original losing battle before using Moritani Nexus.'); if (keep !== null) { const offer = quoteNexusMoritaniRetention(g, player.id, { kind: 'moritaniRetention', owner: pending.owner, player: player.id, territory: pending.territory, cards: pending.eligible, source: 'nexus', event: pending.event, }); requireRule(offer?.canKeep, 'Moritani Secret Ally needs its unallied holder and a retainable played card.'); g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!, player.id, g.players)); } receipt.kept = keep; receipt.stage = 'complete'; receipt.signature = nexusMoritaniRetentionSignature(receipt); } const discarded = pending.played .filter((id) => id !== keep) .map((id) => ({ card: discard(g, player, id, cleanupDiscardRole(g, player, id)), discardedBy: player.id, publicFace: true, })); log( g, keep ? `${player.name} retained ${cardOf(player, keep)!.name} through ${pending.source === 'nexus' ? 'Moritani Nexus Secret Ally' : 'the Moritani alliance'}.` : `${player.name} retained no battle card through ${pending.source === 'nexus' ? 'the postbattle Nexus choice' : '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 currentHomeworldRevivalGrant( g: Game, p: Player, source: HomeworldRevivalDeploymentSource, group: HomeworldRevivalDeploymentGroup, ) { if (!g.homeworlds?.custody || !['fremen', 'tleilaxu'].includes(p.faction)) return null; const deposit = homeworldRule(() => quoteNativeReserveDeposit( homeworldContext(g), g.homeworlds!.custody!, p.id, { normal: group.amount - group.elite, elite: group.elite }, )); const seat = deposit.players.find((seat) => seat.id === p.id)!; const after = { advanced: g.advanced, homeworlds: { custody: deposit.state }, players: g.players.map((other) => other.id !== p.id ? other : { ...p, reserves: seat.reserves, ...(p.elites ? { elites: { ...p.elites, reserves: seat.eliteReserves } } : {}), }), }; return homeworldRule(() => quoteHomeworldRevivalDeployment(g, after, p.id, source, group)); } function requireHomeworldRevivalGrant( g: Game, p: Player, source: HomeworldRevivalDeploymentSource, group: HomeworldRevivalDeploymentGroup, ) { requireRule(!g.homeworldRevivalReturn || g.homeworldRevivalReturn.stage === 'complete', 'Finish the previous revived group’s placement before another revival.'); const grant = currentHomeworldRevivalGrant(g, p, source, group); requireRule(!grant?.blocked, grant?.blocked ?? 'This revival placement needs its timing ruling.'); return grant; } function stageHomeworldRevivalReturn( g: Game, p: Player, source: HomeworldRevivalDeploymentSource, group: HomeworldRevivalDeploymentGroup, grant: ReturnType, card?: string, ) { if (!grant) return; g.homeworldRevivalReturn = homeworldRule(() => makeHomeworldRevivalReturn({ event: crypto.randomUUID(), turn: g.turn, phase: g.phase, player: p.id, source, group, quote: grant, ...(card ? { card } : {}), })); stampHomeworldRevivalProgress(g); } function stampHomeworldRevivalProgress(g: Game) { const frame = g.homeworldRevivalReturn!; if (frame.progressVersion === 1) g.homeworldRevivalProgress = makeHomeworldRevivalProgress(frame); } function homeworldRevivalReturnIntegrity(g: Game) { const frame = g.homeworldRevivalReturn; homeworldRule(() => validateHomeworldRevivalReturn(g, frame)); const continuation = g.pendingTreacheryDiscard?.continuation; const contexts = [g, g.pendingExchange, g.pendingNullentropy?.resume, g.pendingRicheseGift?.resume, g.pendingRichesePurchaseIncome?.resume, continuation && 'resume' in continuation ? continuation.resume : null]; const decisions = contexts.flatMap((context) => context?.decision?.kind === 'homeworldRevivalDeployment' ? [context.decision] : []); requireRule(decisions.every((decision) => frame?.stage === 'choice' && decision.player === frame.player && decision.event === frame.event), 'The revival placement decision has lost its original returned group.'); if (frame?.stage === 'choice') requireRule(decisions.length > 0, 'The returned group has lost its owned placement choice.'); } function completeHomeworldRevivalReturn(g: Game) { const frame = g.homeworldRevivalReturn!; requireRule(!frame.ambassadors?.some((entry) => !entry.completed), 'Finish the revived group’s Ambassador opportunity before its original revival income.'); frame.stage = 'complete'; stampHomeworldRevivalProgress(g); g.response = structuredClone(frame.resumeResponse ?? null); } function resumeHomeworldRevivalReturn(g: Game) { const frame = g.homeworldRevivalReturn; if (!frame || frame.stage === 'complete' || frame.stage === 'choice' || g.pendingTreacheryDiscard || g.pendingNullentropy || g.truthtrance || g.pendingExchange || g.pendingRicheseGift || g.pendingAmbassador || g.pendingTerrorEntry || g.pendingKarama || g.decision) return; if (frame.stage === 'arrival') { if (!g.response) completeHomeworldRevivalReturn(g); return; } // The original revival/card has committed. Hold its independent income until // the optional physical placement and any Ambassador children are complete. requireRule(!g.response || g.response.kind === 'revivalIncome', 'Finish the original revival continuation before placement.'); frame.resumeResponse = structuredClone(g.response); frame.resumeSignature = homeworldRevivalResumeSignature(frame.resumeResponse); g.response = null; frame.stage = 'choice'; stampHomeworldRevivalProgress(g); const destinations = homeworldRule(() => homeworldRevivalDestinations(g, frame.player, frame.quote)); if (!destinations.length || destinations.every((d) => d.blocked && !d.blocked.includes('await'))) { frame.destination = 'decline'; frame.ambassadors = []; frame.arrivalSignature = homeworldRevivalArrivalSignature(frame); completeHomeworldRevivalReturn(g); log(g, `${getPlayer(g, frame.player).name} left the revived group in reserves because no placement destination is available.`); return; } g.decision = { kind: 'homeworldRevivalDeployment', player: frame.player, event: frame.event }; } function decideHomeworldRevivalReturn(g: Game, p: Player, action: Action) { const frame = g.homeworldRevivalReturn; requireRule(frame?.stage === 'choice' && frame.player === p.id && action.event === frame.event, 'Choose the current returned group’s placement.'); if (action.decline === true) { requireRule(action.destination === undefined && action.amount === undefined, 'Leaving the group in reserves does not select a destination or quantity.'); frame.destination = 'decline'; frame.ambassadors = []; frame.arrivalSignature = homeworldRevivalArrivalSignature(frame); completeHomeworldRevivalReturn(g); log(g, `${p.name} left the newly revived group in reserves.`); return; } const amount = frame.quote.normal + frame.quote.elite; requireRule((action.amount === undefined || action.amount === amount) && typeof action.destination === 'string', 'Place the entire eligible revived group in one destination.'); const quote = homeworldRule(() => quoteHomeworldRevivalDestination(g, p.id, frame.quote, action.destination as string)); const seat = quote.transfer.players.find((seat) => seat.id === p.id)!; p.reserves = seat.reserves; if (p.elites) p.elites.reserves = seat.eliteReserves; g.homeworlds!.custody = quote.transfer.state; observeOccupation(g); frame.destination = quote.selected.id; frame.stage = 'arrival'; stampHomeworldRevivalProgress(g); frame.ambassadors = []; frame.arrivalSignature = homeworldRevivalArrivalSignature(frame); if (quote.selected.territory) { place(p, quote.selected.territory, quote.selected.sector!, amount, frame.quote.elite); openTerritoryEntry(g, p, quote.selected.territory, quote.selected.sector!, amount, frame.quote.elite, 'homeworldRevival'); if (g.pendingAmbassador) { g.pendingAmbassador.revivalEvent = frame.event; appendHomeworldRevivalAmbassador(frame, g.pendingAmbassador); } } log(g, `${p.name} placed ${amount} newly revived ${frame.quote.kind === 'fedaykin' ? 'Fedaykin' : 'free forces'} in ${quote.selected.name}. No additional revival payment or shipment allowance was used.`, { faction: p.faction, name: frame.quote.kind === 'fedaykin' ? 'Southern Hemisphere revival' : 'Tleilax free revival' }); } function projectedHomeworldRevivalReturn(g: Game, player: string) { const frame = g.homeworldRevivalReturn; if (frame?.stage !== 'choice') return null; return { event: frame.event, player: frame.player, kind: frame.quote.kind, normal: frame.quote.normal, elite: frame.quote.elite, blocked: frame.quote.blocked, destinations: player === frame.player ? homeworldRule(() => homeworldRevivalDestinations(g, player, frame.quote)) : [], }; } function homeworldRevivalChoiceBlocks(g: Game, p: Player) { const blocks: { source: 'normal' | 'ghola'; amount: number; elite: number; reason: string }[] = []; if (!g.homeworlds?.custody || !['fremen', 'tleilaxu'].includes(p.faction)) return blocks; for (const source of ['normal', 'ghola'] as const) { const maximum = source === 'normal' ? forceRevivalRemaining(g, p) : Math.min(5, p.tanks); for (let amount = 1; amount <= maximum; amount++) for (let elite = Math.max(0, amount - (p.tanks - (p.elites?.tanks ?? 0))); elite <= Math.min(amount, eliteRevivalRemaining(p, g.advanced)); elite++) { const free = source === 'normal' ? forceRevivalQuote(g, p, amount, elite).free : 0; const grant = currentHomeworldRevivalGrant(g, p, source, { amount, elite, free }); if (grant?.blocked) blocks.push({ source, amount, elite, reason: grant.blocked }); } } return blocks; } 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 && !( actor.id !== tleilaxu.id && homeworldRule(() => tleilaxuHomeworldFreeIncomeBlocked(g)) )); 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; } const deploymentGrant = revival.kind === 'forces' ? requireHomeworldRevivalGrant(g, p, revival.nexusEmperorEvent ? 'nexusEmperor' : revival.emperorExtra ? 'emperorExtra' : 'normal', { amount: revival.amount!, elite: revival.elite ?? 0, free: revival.free, }) : null; const incomePayment = g.spiceBankerIncomePreview && revival.cost > 0 ? bankerIncomePayment(g, revival.emperorExtra ? 'emperor-extra-revival' : revival.kind === 'forces' ? 'force-revival' : revival.kind === 'kwisatz' ? 'kh-revival' : 'leader-revival', [{payer: payer.id, amount: revival.cost, recipient: quote.nextResponse?.kind === 'revivalIncome' && quote.nextResponse.owner !== payer.id ? 'player' : 'bank'}]) : undefined; payer.spice -= revival.cost; if (incomePayment?.legs[0]?.recipient === 'bank') awardBankerIncome(g, incomePayment); if (revival.kind === 'forces') { const n = revival.amount!, elite = revival.elite ?? 0; const source = revival.nexusEmperorEvent ? 'nexusEmperor' : revival.emperorExtra ? 'emperorExtra' : 'normal'; const group = { amount: n, elite, free: revival.free }; addNativeReserves(g, p, n, elite); p.tanks -= n; if (revival.emperorExtra) g.emperorExtra[p.id] = (g.emperorExtra[p.id] ?? 0) + n; else if (!revival.nexusEmperorEvent) { // 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.revived += elite; } finishNexusEmperorRevival(g, revival, 'returned'); 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.${g.homeworlds ? ` ${revival.free} were free; ${n - revival.free} were paid. ${payer.name} paid ${revival.cost} spice. Future revival requests use the resulting Homeworld population.` : ''}`, { faction: p.faction, name: 'Revival' }); stageHomeworldRevivalReturn(g, p, source, group, deploymentGrant); } 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.`, { faction: p.faction, name: 'Revival' }); } else { const sharedDuke = revival.kind === 'leader' && revival.leader === DUKE_VIDAL_ID; const leader = sharedDuke ? g.dukeVidal!.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; if (sharedDuke) g.dukeVidal = consumeDuke(g.dukeVidal!); // Automatic Karama response chains may commit after the action's before/after if (revival.kind === 'leader' && g.leaderSkills && !isAuditorLeader(leader) && !leader.capturedBy && p.leaders.some(own => own.id === leader.id)) { g.leaderSkills = skillRule(() => offerRevivedLeaderSkill( g.leaderSkills!, p.id, leader.id, crypto.randomUUID())); } log( g, `${p.name} revived ${leader.name}${revival.kind === 'foreignGhola' ? ' as a ghola' : ''}.${sharedDuke ? ' The living shared disc is set aside, unclaimed.' : ''}`, { faction: p.faction, name: 'Revival' }, ); } 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; if (quote.nextResponse.kind === 'revivalIncome') { Object.assign(g.response, stampBureaucratPayment(g,'revival',payer.id,quote.nextResponse.owner,revival.cost)); // The bank-paid free-revival award is not part of this payer's invoice. if (incomePayment?.legs[0]?.recipient === 'player') g.response.spiceBankerIncomePayment = incomePayment; } } } function beginRevival(g: Game, revival: PendingRevival) { requireRule( !revivalPrevented(g, revival.player), 'Tleilaxu prevented this faction’s normal revivals for this turn.', ); if (revival.kind === 'forces') requireHomeworldRevivalGrant(g, getPlayer(g, revival.player), revival.nexusEmperorEvent ? 'nexusEmperor' : revival.emperorExtra ? 'emperorExtra' : 'normal', { amount: revival.amount!, elite: revival.elite ?? 0, free: revival.free }); g.pendingRevival = revival; const choam = byFaction(g, 'choam'); if ( choam && choam.id !== revival.player && revival.free > 0 && !revival.emperorExtra && !revival.nexusEmperorEvent ) { 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, g.homeworldEcazVictoryPreview ? g : undefined); } 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(() => quoteCombatBoard(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 completeChoamKulon(g: Game, owner: Player) { g.choamMovement = { turn: g.turn, bonus: (g.choamMovement?.turn === g.turn ? g.choamMovement.bonus : 0) + 1, }; log(g, `${owner.name} used Kulon to move one extra territory this turn.`); } function completeChoamLaLaLa(g: Game, target: string) { g.revivalRules ??= newRevivalRules(); (g.revivalRules.freeBlocked ??= []).push(target); log(g, `${getPlayer(g, target).name} cannot take free force revival this phase.`); } function completeChoamBaliset(g: Game, target: string, location: string) { (g.choamBaliset ??= []).push({ turn: g.turn, player: target, territory: location }); log(g, `${getPlayer(g, target).name} cannot move into CHOAM's ${territory(location).name} this phase; shipment remains possible.`); } function completeChoamJubba(g: Game, owner: Player, location: string) { (g.stormResolution!.choamProtected ??= []).push(location); log(g, `${owner.name} protected its own forces in ${territory(location).name} for this storm movement with Jubba Cloak.`); } function completeChoamGamont( g: Game, owner: Player, targetId: string, from: string, elite: number, noFieldEvent?: string, ) { const target = getPlayer(g, targetId); if (noFieldEvent) revealPlayerNoField(g, target, 'gamont'); const returned = (target.forces[from] ?? 0) > 0; if (returned) { removeGroup(target, [[from, 1]], { [from]: elite }); target.reserves++; if (target.elites) target.elites.reserves += elite; } observeOccupation(g); settleAdvisors(g); log(g, returned ? `${owner.name} sent one of ${target.name}’s forces to reserves with Trip to Gamont.` : `${owner.name} used Trip to Gamont to reveal ${target.name}’s No-Field. No force was present to return; the card is still used.`); } function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettlement) { const bureaucratDiversion = settlement?.amount; karamaConversionIntegrity(g); currentFactionPayment(g); const response = g.response!; if (g.pendingKull?.stage === 'counter' && response.kind === 'choamWorthless') kullIntegrity(g); if (response.kind === 'nexusPrescience') validateNexusInspectionResponse(g, response); if (response.kind === 'nexusAdvisorFlip') validateNexusAdvisorResponse(g, response); if (response.kind === 'nexusSardaukar') validateNexusSardaukarResponse(g, response); if (response.kind === 'nexusFremenCunning') currentFremenCunningResponse(g, response); if (response.kind === 'nexusEcazBetrayal') currentEcazBetrayalResponse(g, response); if (response.kind === 'nexusGuildCunning') validateGuildCunningResponse(g,response); if (response.kind === 'guildRate') { if (g.pendingGuildTransport) validateGuildTransportRateResponse(g, response); else validateGuildRateResponse(g, response); } if (response.kind === 'moritaniPlacement') { // Both outcomes need the same physical inventory and declared source. // This detached denial quote is only validation here; allowance still // executes placeTerror and checks current placement feasibility below. try { quotePlacementCancellation(g, response); } catch (error) { if (error instanceof PlacementCancellationError) throw new RuleError(error.message); throw error; } } 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 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; if (!canceled && bureaucratDiversion === undefined && offerBureaucratPayment(g,response.bureaucratPayment,{kind:'response'})) return; g.response = null; if (g.homeworldOccupationPreview && (response.occupiedPayment || response.source === 'ambassador' || response.kind === 'richesePurchaseIncome' && g.pendingRichesePurchaseIncome?.event || response.kind === 'emperorIncome' && g.currentAuctionSale?.occupiedEvent) && ['emperorIncome', 'guildIncome', 'richesePurchaseIncome'].includes(response.kind)) { const gross = (response.kind === 'emperorIncome' ? response.source === 'ambassador' ? response.amount! : auctionEmperorIncomeAmount(g.currentAuctionSale!) : response.amount!) - (bureaucratDiversion ?? 0); const suffix: OccupiedPercentageSuffix = response.source === 'ambassador' ? { kind: 'ambassadorBonus' } : response.kind === 'emperorIncome' ? { kind: 'auctionBonus' } : response.kind === 'richesePurchaseIncome' ? { kind: 'richesePurchase', resume: g.pendingRichesePurchaseIncome!.resume } : { kind: 'none', decision: g.decision }; const incomePayment = response.kind === 'emperorIncome' ? g.currentAuctionSale?.spiceBankerIncomePayment : response.spiceBankerIncomePayment; const pending = settleOccupiedPayment(g, response, gross, canceled, suffix, bureaucratDiversion ?? 0); if (incomePayment) { const receipt = g.homeworldOccupiedPercentageLedger!.receipts.at(-1); requireRule(receipt?.source.native === response.owner && receipt.source.amount === gross, 'The final Bank payment lost its original Homeworld income receipt.'); awardFinalFactionBankerIncome(g, incomePayment, receipt!.bankRetained, settlement); } if (!pending) runOccupiedPercentageSuffix(g, suffix); return; } if (offerOccupiedBonus(g, response)) return; if (response.kind === 'ecazOccupy') { const b = g.battle, occupy = b?.ecazOccupy; requireRule(b && occupy?.lead && occupy.ecaz === response.owner && !b.revealed && !Object.keys(b.plans).length, 'Resolve Occupy before native powers and plans.'); occupy.canceled = canceled; ecazBattleProfile(g); createMentatQuestion(g); beginStrongholdBattle(g); log(g, canceled ? 'Karama canceled the combined dial: the selected lead now dials only their own forces; the other allied army contributes zero.' : 'Ecaz Occupy proceeds with its mandatory free contribution and the ally’s physical force pool.', { faction: 'ecaz', name: 'Occupy' }); return; } if (response.kind === 'guildRate') { if (g.pendingGuildTransport) finishGuildTransportRateResponse(g, response, canceled); else finishGuildRateResponse(g, response, canceled); return; } if (response.kind === 'nexusEcazBetrayal') { finishEcazBetrayalResponse(g, response, canceled); return; } if (response.kind === 'nexusFremenCunning') { finishFremenCunningResponse(g, response, canceled); return; } if (response.kind === 'nexusGuildCunning') { settleGuildCunning(g,response,canceled); return; } if (response.kind === 'nexusAdvisorFlip') { finishNexusAdvisors(g, canceled); return; } if (response.kind === 'nexusSardaukar') { finishNexusSardaukar(g,canceled); return; } 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; finishNexusChoam(g,g.pendingChoamWorthless!,'canceled'); g.pendingChoamWorthless = declaration.pendingChoamWorthless; g.choamWorthlessBlocked = declaration.blocked; log(g, 'CHOAM’s Worthless card effect was prevented for this phase. The declared Treachery card is retained. Any Nexus card spent on the declaration remains spent.'); 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; if (g.pendingKull?.stage === 'counter' && declaration.effect === 'kull') resumeKullAttempt(g, false); return; } if (auctionCancellation) { if (response.kind === 'emperorIncome') awardBankerIncome(g, g.currentAuctionSale?.spiceBankerIncomePayment, true); 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.', ); if (canceled && shipment.richesePair) finishRichesePairShipment(g, shipment, 'stopped'); 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.${shipment.richesePair ? ' The Richese Nexus card remains spent.' : ''}`, ); } 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.', ); finishNexusMoritani(g,true); g.moritaniTerror = placementCancellation.receipt.terror; log( g, 'Moritani’s Terror placement or relocation was prevented for this turn.', ); } else { try { const placed = pending.nexusEvent ? quoteNexusMoritaniPlacement(g.moritaniTerror,pending.token,pending.territory,g.turn) : placeTerror(g.moritaniTerror,pending.token,pending.territory,g.turn); finishNexusMoritani(g,false); g.moritaniTerror = placed; if (pending.nexusEvent) (g.nexusMoritaniLocations ??= {})[pending.token] = {event:pending.nexusEvent,territory:pending.territory}; else if (g.nexusMoritaniLocations) delete g.nexusMoritaniLocations[pending.token]; } 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 && !g.moritaniExtortion) 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)) ) { const used = discard(g, choam, pending.card); finishNexusChoam(g,pending,'complete'); if (pending.effect === 'kull') { g.kullRestrictions = [...activeKullRestrictions(g.kullRestrictions, g.turn, g.phase), { player: g.pendingKull!.owner, turn: g.turn, phase: g.phase }]; log(g, `${getPlayer(g, g.pendingKull!.owner).name} cannot activate Karama for this phase. The attempted card stays held.`); resumeKullAttempt(g, true); return; } if (pending.effect === 'kulon') { if (g.semutaPreview && (used.name === 'Kulon' || pending.nexusEvent) && !pending.storm && !pending.revival && !pending.movement && !pending.mentat) { const continuation: ChoamKulonDiscardContinuation = { kind: 'choamKulonDiscard', player: choam.id, card: used.id, before: g.choamMovement?.turn === g.turn ? g.choamMovement.bonus : 0, stateSignature: '', }; if (pending.nexusEvent) continuation.nexusEvent = pending.nexusEvent; const entries = [{ card: used, discardedBy: choam.id, publicFace: pending.nexusEvent === undefined }]; if (semutaOfferSupported(g, continuation, entries)) { continuation.stateSignature = choamKulonDiscardSignature(g, continuation); stageTreacheryDiscard(g, 'choam:kulon', entries, continuation); return; } } completeChoamKulon(g, choam); } else if (pending.effect === 'jubba') { if (g.semutaPreview && (used.name === 'Jubba Cloak' || pending.nexusEvent) && pending.storm && !pending.revival && !pending.movement && !pending.mentat) { const continuation: ChoamJubbaDiscardContinuation = { kind: 'choamJubbaDiscard', player: choam.id, card: used.id, territory: pending.location!, stateSignature: '', }; if (pending.nexusEvent) continuation.nexusEvent = pending.nexusEvent; const entries = [{ card: used, discardedBy: choam.id, publicFace: pending.nexusEvent === undefined }]; if (semutaOfferSupported(g, continuation, entries)) { continuation.stateSignature = choamJubbaDiscardSignature(g, continuation); stageTreacheryDiscard(g, 'choam:jubba', entries, continuation); return; } } completeChoamJubba(g, choam, pending.location!); } else if (pending.effect === 'baliset') { if (g.semutaPreview && (used.name === 'Baliset' || pending.nexusEvent) && !pending.storm && !pending.revival && !pending.mentat && (!pending.movement || (g.pendingChoamMove && !g.pendingChoamMove.source && !g.pendingChoamMove.ornithopterEvent && !g.pendingChoamMove.ornithopterRange && !g.pendingChoamMove.discoveryFlight))) { const movement = pending.movement ? g.pendingChoamMove : null; if (movement) g.pendingChoamMove = null; const continuation: ChoamBalisetDiscardContinuation = { kind: 'choamBalisetDiscard', player: choam.id, card: used.id, target: pending.target!, territory: pending.location!, stateSignature: '', }; if (pending.nexusEvent) continuation.nexusEvent = pending.nexusEvent; if (movement) continuation.stoppedMovement = true; const entries = [{ card: used, discardedBy: choam.id, publicFace: pending.nexusEvent === undefined }]; if (semutaOfferSupported(g, continuation, entries)) { continuation.stateSignature = choamBalisetDiscardSignature(g, continuation); stageTreacheryDiscard(g, 'choam:baliset', entries, continuation); return; } if (movement) g.pendingChoamMove = movement; } completeChoamBaliset(g, pending.target!, pending.location!); 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') { if (g.semutaPreview && (used.name === 'Trip to Gamont' || pending.nexusEvent) && !pending.storm && !pending.revival && !pending.movement) { const continuation: ChoamGamontDiscardContinuation = { kind: 'choamGamontDiscard', player: choam.id, card: used.id, target: pending.target!, location: pending.location!, stateSignature: '', }; if (pending.nexusEvent) continuation.nexusEvent = pending.nexusEvent; if (pending.elite === 1) continuation.elite = 1; if (pending.noFieldEvent) continuation.noFieldEvent = pending.noFieldEvent; if (pending.mentat) continuation.mentat = true; const entries = [{ card: used, discardedBy: choam.id, publicFace: pending.nexusEvent === undefined }]; if (semutaOfferSupported(g, continuation, entries)) { continuation.stateSignature = choamGamontDiscardSignature(g, continuation); stageTreacheryDiscard(g, 'choam:gamont', entries, continuation); return; } } completeChoamGamont(g, choam, pending.target!, pending.location!, pending.elite!, pending.noFieldEvent); } else { if (g.semutaPreview && (used.name === 'La La La' || pending.nexusEvent) && !pending.storm && !pending.movement && !pending.mentat && (!pending.revival || (g.pendingRevival?.kind === 'forces' && g.pendingRevival.player === pending.target))) { const revival = pending.revival ? g.pendingRevival : null; if (revival) g.pendingRevival = null; const continuation: ChoamLaLaLaDiscardContinuation = { kind: 'choamLaLaLaDiscard', player: choam.id, card: used.id, target: pending.target!, stateSignature: '', }; if (pending.nexusEvent) continuation.nexusEvent = pending.nexusEvent; if (revival) continuation.stoppedRevival = true; const entries = [{ card: used, discardedBy: choam.id, publicFace: pending.nexusEvent === undefined }]; if (semutaOfferSupported(g, continuation, entries)) { continuation.stateSignature = choamLaLaLaDiscardSignature(g, continuation); stageTreacheryDiscard(g, 'choam:laLaLa', entries, continuation); return; } if (revival) g.pendingRevival = revival; } completeChoamLaLaLa(g, pending.target!); 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 { finishNexusChoam(g,pending,'fizzled'); 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') { const round = validateRicheseCacheCancellation(g, response); if (canceled) { // Restore the frozen count, retaining the independent Black Market deduction. // Older saved declarations lack the additive before-cache field. round.normalCount = round.normalCountBeforeCache ?? Math.max(0, round.normalCount! + Number(richeseCacheRuns(g))); round.cacheCanceled = true; log( g, 'The compulsory Richese cache auction was canceled; one ordinary lot is restored.', ); prepareRicheseNormal(g); } else if (round.position === 'first' && richeseCacheRuns(g)) 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: [], }; if (g.response?.kind === 'atreidesAuction') openOccupiedAuctionInspection(g); else settleRicheseLot(g); } } else if (response.kind === 'ixAuction' || response.kind === 'ixTechnology') { if (!canceled && offerIxianNexusBetrayal(g, response)) return; finishIxianNativeAttempt(g, response, canceled); } 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 legal replacement; independent ornithopters and Planetologist 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!; // Cancellation only releases the declaration; its uncommitted army or route // need not remain executable. An allowed move must still be legal now. if (!canceled) validateMovementOrder(g, getPlayer(g, move.player), move); 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 for this move. Forces remain in place and no movement was spent. Independent ornithopters and Planetologist still apply to a legal replacement.`, ); } else offerChoamMovement(g, move); } else if (response.kind === 'ixSubstitution') { ixSubstitutionCancellationQuote(g, response); const pending = g.pendingIxSubstitution!; const player = getPlayer(g, pending.player); if (!canceled && pending.homeworld) { homeworldSubstitutionIntegrity(g); homeworldDefenseIntegrity(g); homeworldShipmentIntegrity(g); const amount = Object.values(pending.sources!).reduce((a, b) => a + b, 0); const quote = quoteHomeworldSubstitution(homeworldLossContext(g), g.homeworlds!.custody!, { location: pending.territory, player: player.id, amount, cyborgsLost: pending.homeworld.cyborgsLost, }); commitHomeworldResources(g, quote); log(g, `${player.name} exchanged ${amount} surviving Suboids for the same number of Cyborgs lost at ${combatLocationName(g, pending.territory)}.`); } else 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.`, ); } observeOccupation(g); completeIxSubstitution(g); if (canceled) { log( g, 'Karama prevented Ixian substitution. Original cyborg casualties remain in the Tanks; no surviving suboids were exchanged.', ); } finishWinner(g, getPlayer(g, pending.winner ?? pending.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! - (bureaucratDiversion ?? 0); awardBankerIncome(g, response.spiceBankerIncomePayment, canceled); if (g.pendingChoamMarketGhola && g.pendingChoamMarketGhola.event === response.intent) g.pendingChoamMarketGhola.stage = 'complete'; } 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; resumeChoamMarket(g); log( g, 'The declared CHOAM card sale was canceled; the card stays in hand.', ); return; } const saleCard = owner.hand.find((card) => card.id === sale.card); const saleBlock = saleCard ? homeworldRule(() => homeworldWorthlessSaleBlock(g, owner.id, saleCard)) : null; const quoted = quoteSale(owner.hand, sale.card, sale.witness); if (quoted?.price === sale.price && !saleBlock) { const used = discard(g, owner, sale.card); owner.spice += sale.price; log(g, `${owner.name} received ${sale.price} spice from the card sale.`); delete market.sale; if (g.semutaPreview) { const continuation: ChoamSaleDiscardContinuation = { kind: 'choamSaleDiscard', owner: owner.id, card: used.id, witness: sale.witness ?? null, price: sale.price, spiceAfter: owner.spice, stateSignature: '', }; const entries = [{ card: used, discardedBy: owner.id, publicFace: true }]; if (semutaOfferSupported(g, continuation, entries)) { continuation.stateSignature = choamSaleDiscardSignature(g, continuation); stageTreacheryDiscard(g, 'choamSale', entries, continuation); return; } } } else { log( g, saleBlock ? 'Tupile is now high population, so the declared Worthless sale cannot finish. The card stays in hand and no spice is paid.' : '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); // User ruling 7 October 2026: CHOAM's phase-opening income is a // qualifying collection, so a low-population native Homeworld adds its // bank bonus under the same Inflation multiplier as ordinary charity. const multiplier = charityMultiplier(g); const amount = 2 * g.players.length * multiplier + homeworldLowBonus(g, choam.id) * multiplier; 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, response.charityHomeworld ?? 0, ); } 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; const capturedSkill = g.leaderSkills?.assignments.find((a) => a.leader === l.id); // This unique card and its leader were publicly assigned. The expansion // requires the card to accompany the captive, identifying that capture. if (capturedSkill) delete l.concealed; g.decision = { kind: 'capturedLeader', player: capture.player, owner: owner.id, leader: l.id, controller: capture.loser, }; log( g, capturedSkill ? `${getPlayer(g, capture.player).name} captured ${l.name} with ${leaderSkillCard(capturedSkill.skill).name}. Its known skill travels with the leader; only its battle effect is available to the captor.` : `${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 (response.kind === 'nexusPrescience') { settleNexusInspectionResponse(g, response, canceled); } else if (isCombatResponseKind(response.kind)) { commitCombatResponseQuote( g, currentCombatResponseQuote(g, { kind: 'response', response: { kind: response.kind, owner: response.owner }, canceled, }), ); if (response.kind === 'voice' && g.battle?.nexusBorrowedVoice?.stage === 'declared') g.battle.nexusBorrowedVoice = finishNexusBorrowedVoice(g.battle.nexusBorrowedVoice, 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.homeworldOccupationPreview) { if (!g.homeworldAuctionInspection) openOccupiedAuctionInspection(g); const saved = g.homeworldAuctionInspection!, occupation = quoteStableHomeworldOccupation(g, 'caladan'); requireRule(saved.lot === originalAuctionInspectionLot(g) && saved.occupation === JSON.stringify(occupation), 'The original Caladan inspection source changed.'); saved.occupiedKnown = !!occupation.entitlement && !occupation.blocked; } if (g.richeseAuction) { g.richesePeekKnown = !canceled; settleRicheseLot(g); } else g.auction!.peekKnown = terminal?.kind === 'atreidesAuction' ? terminal.known : !canceled; } else if (response.kind === 'atreidesSpice') { const blocked = homeworldRule(() => homeworldMovementForesightBlock(g, response.owner)); requireRule(!blocked, blocked ?? 'Caladan prevents this foresight.'); 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!, 'additional'); } 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') { const payment = !canceled ? creditFactionPayment(g, response.owner, 'shipment', response.amount!-(bureaucratDiversion ?? 0)) : null; awardFinalFactionBankerIncome(g, response.spiceBankerIncomePayment, canceled ? response.amount! : payment!.bank, settlement); 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 ${payment!.income} spice paid by other factions for shipment.${payment!.bank ? ` Low-population Junction leaves ${payment!.bank} spice in the bank.` : ''} 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; else if (offerHarkonnenNexusBetrayal(g,response.owner,response)) return; if ( traitorVoters(g, b).every((voter) => b.traitorCalls[voter] !== undefined) ) advanceBattleOutcome(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.', ); const payment = !canceled ? creditFactionPayment(g, response.owner, 'treachery', 3) : null; 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 ${payment!.income} spice from another faction’s Treachery Card purchase payment.${payment!.bank ? ` Low-population Kaitain leaves ${payment!.bank} spice in the bank.` : ''}`, { 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 ? auctionEmperorIncomeAmount(g.currentAuctionSale) : g.auction!.bid; const payment = creditFactionPayment(g, owner.id, 'treachery', amount-(bureaucratDiversion ?? 0)); awardFinalFactionBankerIncome(g, g.currentAuctionSale?.spiceBankerIncomePayment, payment.bank, settlement); log( g, `${owner.name} received ${payment.income} spice from another faction’s ${amount}-spice paid auction bid.${payment.bank ? ` Low-population Kaitain leaves ${payment.bank} spice in the bank.` : ''}`, { 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); const target = splitLocation(response.location ?? 'polar_sink:0'); const amount = integer(response.amount === undefined ? 1 : response.amount, 1, spiritualAdvisorMaximum(g, owner.id, target.territory), 'Spiritual Advisor forces'); const advisors = arrivalAsAdvisor( g, owner, target.territory, undefined, target.territory !== 'polar_sink', ); allowedEntry(g, owner, target.territory, target.sector, false, advisors); withdrawNativeReserves(g, owner, amount, 0); place(owner, target.territory, target.sector, amount); techIncome(g, 'heighliners', owner); if (advisors) { owner.advisors ??= {}; owner.advisors[target.territory] = { lockedTurn: g.turn }; } log( g, `${owner.name} sent ${amount} free ${amount === 1 ? 'force' : 'forces'} to ${territory(target.territory).name}${amount === 2 ? ' using high-population Wallach IX' : ''}.`, {faction: owner.faction, name: 'Spiritual Advisors'}, ); openTerritoryEntry( g, owner, target.territory, target.sector, amount, 0, 'advisor', ); } else throw new RuleError('Unknown faction response.'); } function homeworldAllianceReason(g: Game, first: string, second: string) { return g.homeworlds?.custody ? homeworldRule(() => homeworldAllianceBlock(homeworldContext(g), g.homeworlds!.custody!, first, second)) : null; } function homeworldShipmentAutomatic(g: Game): boolean { const decision = g.decision; if (decision?.kind !== 'homeworldShipmentGuild' || g.response || g.truthtrance || g.phaseOpening || g.pendingNullentropy || g.pendingTreacheryDiscard || g.pendingKarama || g.pendingExchange || g.pendingRicheseGift || g.summonedWorm) return false; homeworldShipmentIntegrity(g); const guild = getPlayer(g, decision.player); return !guild.hand.some((card) => { if (!canUseAsKaramaRole(g, guild, card)) return false; try { prepareSpecialKaramaIntent(g, guild.id, {type: 'card', mode: 'special', card: card.id}); return true; } catch (error) { if (error instanceof RuleError) return false; throw error; } }); } function homeworldShipmentEvent(g: Game, p: Player, route?: 'arrakis'): string { if (route === 'arrakis') return JSON.stringify([homeworldShipmentEvent(g, p), route, g.storm, p.forces, p.elites?.forces ?? {}, g.mobileStronghold?.location ?? null]); return JSON.stringify([g.turn, g.phase, g.active, p.id, p.shipped, homeworldContext(g), g.homeworlds?.custody, g.players.map((seat) => [seat.id, seat.ally]), ...(currentGuildCunning(g,p.id) ? [currentGuildCunning(g,p.id)!.receipt.event] : [])]); } function homeworldShipmentBlock(g: Game, p: Player, checkPromises = true): string | null { if (!g.homeworlds?.custody) return 'Homeworld shipment requires the Homeworld module.'; if (g.status !== 'playing' || g.phase !== 5 || g.active !== p.id || !shipmentAvailable(g,p)) return 'Use your unused shipment during your own Shipment and Movement turn.'; if (g.truthtrance || g.response || g.decision || g.phaseOpening || g.pendingNullentropy || g.pendingTreacheryDiscard || g.pendingShipment || g.pendingHomeworldShipment || g.pendingExchange || g.pendingRicheseGift || g.battle) return 'Finish the current interaction before another Homeworld shipment.'; if (g.karamaShipping?.player === p.id) return 'Homeworld shipment using a purchased Karama rate awaits its card-scope integration.'; const richese = byFaction(g, 'richese'); if (richese && !noFieldAllyOfferBlock(g, richese) && !(g.richeseAllyOpportunity?.turn === g.turn && g.richeseAllyOpportunity.recipient === p.id)) return 'Wait for Richese to offer or pass its allied shipment opportunity.'; try { if (checkPromises) checkShipmentPromises(g, p, null); } catch (error) { if (error instanceof RuleError) return error.message; throw error; } return null; } function guildHomeworldShipmentBlock(g: Game, p: Player): string | null { if (p.faction !== 'guild') return 'Only Guild has this Arrakis-to-Homeworld shipment permission.'; return homeworldShipmentBlock(g, p); } function junctionTransportWindow(g: Game) { const owner = homeworldRule(() => junctionSponsor(g)); const recipient = g.players.find((seat) => seat.id === g.active); if (!owner || !recipient || owner === recipient.id || g.phase !== 5 || g.status !== 'playing' || recipient.shipped) return null; const offer = currentJunctionOffer(g); const blocked = homeworldShipmentBlock(g, recipient, false); return {owner, recipient: recipient.id, offer, blocked, canOffer: !blocked, offerEvent: junctionOfferEvent(g), event: JSON.stringify([homeworldShipmentEvent(g, recipient, 'arrakis'), offer])}; } function quoteJunctionDeclaration(g: Game, p: Player, declaration: JunctionTransportDeclaration, automaticFunding = false) { requireRule(declaration.player === p.id && declaration.turn === g.turn, 'This Junction shipment lost its original player or turn.'); const action = declaration; const option = junctionTransportWindow(g); requireRule(option && option.recipient === p.id && !option.blocked && option.offer, option?.blocked ?? 'Wait for Guild to offer Junction transport during your unused shipment.'); requireRule(action.event === option.event && action.offer === option.offer.event, 'This Junction offer or physical source selection has changed.'); const rate = option.offer.rate; const destination = stringField(action.destination); const context = {...homeworldContext(g), storm: g.storm, discoveries:g.discoveries, players: homeworldContext(g).players.map((seat) => ({...seat, ally: getPlayer(g, seat.id).ally})), mobileStronghold: g.mobileStronghold?.location ?? null, board: {[p.id]: {forces: p.forces, eliteForces: p.elites?.forces ?? {}, advisors: p.advisors}}}; const quote = homeworldRule(() => quoteJunctionTransport(context, g.homeworlds!.custody!, {player: p.id, sponsor: option.owner, rate, destination, sources: action.sources as HomeworldShipmentIntent['sources']})); const arrival = quote.destinationKind === 'arrakis' ? splitLocation(destination) : null; const nativeDeparture = quote.originKind === 'homeworld' && quote.sources.every((source) => source.key === `homeworld:${p.faction}` || (p.faction === 'emperor' && source.key === 'homeworld:emperor:salusa')); const promise = nativeDeparture && arrival ? {territory: arrival.territory, amount: quote.amount} : null; checkShipmentPromises(g, p, promise); const sourceLock = quote.originKind === 'arrakis' ? p.advisors?.[quote.origin]?.lockedTurn : undefined; const advisors = arrival ? arrivalAsAdvisor(g, p, arrival.territory, quote.originKind === 'arrakis' ? quote.origin : undefined) : false; if (arrival) { requireRule(arrival.territory !== MOBILE_STRONGHOLD || p.faction === 'ixians', 'Only Ixians may ship directly into the mobile stronghold.'); requireRule(advisors || sourceLock !== g.turn || !g.players.some((other) => other.id !== p.id && at(other, arrival.territory)), 'New advisors cannot become fighters this turn.'); allowedEntry(g, p, arrival.territory, arrival.sector, false, advisors); } const allyPayment = contribution(g, p, quote.cost, automaticFunding ? undefined : action.allyPayment); requireRule(automaticFunding || allyPayment === declaration.allyPayment, 'The original Junction funding changed.'); const ordinaryIncome = !!arrival && nativeDeparture && p.faction !== 'fremen'; const contributions = [ordinaryIncome ? quote.cost - allyPayment : 0, p.ally === option.owner ? 0 : allyPayment]; validateGuildPaymentContributions(g, contributions.reduce((sum, amount) => sum + amount, 0), contributions); return {option, rate, quote, arrival, nativeDeparture, promise, sourceLock, advisors, allyPayment, destination}; } function commitJunctionTransport(g: Game, p: Player, declaration: JunctionTransportDeclaration, settlement?: {recipient: string | null}) { const {option, rate, quote, arrival, nativeDeparture, promise, sourceLock, advisors, allyPayment, destination} = quoteJunctionDeclaration(g, p, declaration); if (!settlement && beginGuildBetrayal(g, {source: 'junction', transport: declaration})) return; // Junction permission and tariff are Homeworld effects and resist Karama. // All validation precedes this atomic settlement; there is no special-stop frame. payWithAlly(g, p, quote.cost, allyPayment); if (settlement?.recipient) getPlayer(g, settlement.recipient).spice += quote.cost; g.homeworlds!.custody = quote.state; p.forces = quote.boardForces; if (p.elites) p.elites.forces = quote.boardEliteForces; for (const seat of quote.players) { const owner = getPlayer(g, seat.id); owner.reserves = seat.reserves; if (owner.elites) owner.elites.reserves = seat.eliteReserves; } observeOccupation(g); if (arrival) { place(p, arrival.territory, arrival.sector, quote.amount, quote.elite); if (advisors) { const lockedTurn = Math.max(sourceLock ?? 0, p.advisors?.[arrival.territory]?.lockedTurn ?? 0); (p.advisors ??= {})[arrival.territory] = lockedTurn ? { lockedTurn } : {}; } else if (p.advisors) delete p.advisors[arrival.territory]; } p.shipped = true; g.junctionOffer = null; finishShipmentPromises(g, p, promise); // Native Fremen reinforcement is on Arrakis; all other world departures // are off-planet. Homeworld destinations independently trigger E3 technology. const offPlanet = quote.originKind === 'homeworld' && !(nativeDeparture && p.faction === 'fremen'); if (!arrival || offPlanet) techIncome(g, 'heighliners', p); const ordinaryIncome = !!arrival && nativeDeparture && p.faction !== 'fremen'; const income = (ordinaryIncome ? quote.cost - allyPayment : 0) + (p.ally === option.owner ? 0 : allyPayment); if (!settlement?.recipient && income > 0) g.response = guildPaymentResponse(g, option.owner, [ordinaryIncome ? quote.cost - allyPayment : 0, p.ally === option.owner ? 0 : allyPayment], undefined, [...(ordinaryIncome ? [{ payer: p.id, amount: quote.cost - allyPayment }] : []), ...(p.ally !== option.owner && allyPayment ? [{ payer: p.ally!, amount: allyPayment }] : [])]); if (!settlement?.recipient) settleBankerShipmentIncome(g, p, quote.cost, allyPayment, { own: ordinaryIncome && p.id !== option.owner, ally: p.ally !== option.owner }); const sourceName = quote.originKind === 'arrakis' ? territory(quote.origin).name : quote.sources.map((s) => combatLocationName(g, s.key)).join(' and '); log(g, `${p.name} accepted ${getPlayer(g, option.owner).name}’s Junction ${rate}-price offer and transported ${quote.amount} physical forces (${quote.elite} special) from ${sourceName} to ${arrival ? `${territory(arrival.territory).name}, sector ${arrival.sector}` : combatLocationName(g, destination)} for ${quote.cost} spice (${quote.cost - allyPayment} own, ${allyPayment} pledged). The shipment is used; movement remains available.`, {faction: 'guild', name: 'Junction transport'}); if (arrival) { const bg = byFaction(g, 'beneGesserit'); const followup = offPlanet && bg && bg.id !== p.id && spiritualAdvisorMaximum(g, bg.id) > 0 ? {shipment: p.id, destination} : undefined; if (!intrusion(g, p, arrival.territory, followup ? { followup } : undefined) && followup) g.decision = {kind: 'advisor', player: bg!.id, ...followup}; openTerritoryEntry(g, p, arrival.territory, arrival.sector, quote.amount, quote.elite, 'shipment'); } } function performJunctionTransport(g: Game, p: Player, action: Action) { requireRule(Object.keys(action).every(key => ['type', 'event', 'offer', 'destination', 'sources', 'allyPayment'].includes(key)), 'Use the offered tariff and explicit physical forces without concealed tokens.'); // Decode once into a source-specific declaration. Saved continuations are // never actions and never dispatch through the action engine. const declaration: JunctionTransportDeclaration = { player: p.id, turn: g.turn, event: stringField(action.event), offer: stringField(action.offer), destination: stringField(action.destination), sources: action.sources as HomeworldShipmentIntent['sources'], allyPayment: typeof action.allyPayment === 'number' ? action.allyPayment : 0, }; const quote = quoteJunctionDeclaration(g, p, declaration, action.allyPayment === undefined); declaration.allyPayment = action.allyPayment === undefined ? quote.allyPayment : contribution(g, p, quote.quote.cost, action.allyPayment); commitJunctionTransport(g, p, declaration); } function homeworldShipmentQuote(g: Game, intent: HomeworldShipmentIntent & {route?: 'arrakis';guildSecretEvent?:string}) { requireRule(g.homeworlds?.custody, 'Homeworld shipment requires saved physical custody.'); const context = { ...homeworldContext(g), players: homeworldContext(g).players.map((seat) => ({...seat, ally: getPlayer(g, seat.id).ally})), }; const order = {player: intent.player, destination: intent.destination, sources: intent.sources}; if (intent.route === 'arrakis') { const quote = homeworldRule(() => quoteGuildHomeworldShipment({...context, storm: g.storm, discoveries:g.discoveries, mobileStronghold: g.mobileStronghold?.location ?? null, board: Object.fromEntries(g.players.map((p) => [p.id, {forces: p.forces, eliteForces: p.elites?.forces ?? {}}])), }, g.homeworlds!.custody!, order)); return {...quote, sources: quote.boardSources, sourceNames: [territory(quote.origin).name]}; } const quote = homeworldRule(() => quoteHomeworldShipment(context, g.homeworlds!.custody!, order)); if (intent.guildSecretEvent) { validateGuildSecretDeclaration(g,intent.player,intent.guildSecretEvent); quote.cost = nexusRule(() => quoteNexusGuildSecretShipment('homeworld',quote.amount)).cost; } return {...quote, boardForces: undefined, boardEliteForces: undefined, sourceNames: quote.sources.map((s) => combatLocationName(g, s.homeworld))}; } function validateHomeworldShipment(g: Game, shipment: PendingHomeworldShipment) { if (shipment.guildSecretEvent) requireRule(!shipment.route && !shipment.guildNexusEvent, 'Guild Secret Ally discounts independently legal world-to-world routes only.'); if (shipment.guildNexusEvent) validateGuildCunningShipment(g,shipment.player,shipment.guildNexusEvent,'homeworld',shipment); const p = getPlayer(g, shipment.player); requireRule(g.status === 'playing' && g.phase === 5 && g.active === p.id && shipmentAvailable(g,p) && shipment.turn === g.turn && (shipment.route === undefined || shipment.route === 'arrakis') && shipment.event === homeworldShipmentEvent(g, p, shipment.route), 'This Homeworld declaration no longer matches its unused shipment and physical custody.'); requireRule(g.karamaShipping?.player !== p.id, 'Homeworld shipment using a purchased Karama rate awaits its card-scope integration.'); const quote = homeworldShipmentQuote(g, shipment); requireRule(shipment.amount === quote.amount && shipment.elite === quote.elite && shipment.cost === quote.cost && JSON.stringify(shipment.pools) === JSON.stringify(quote.sources), 'The saved Homeworld shipment price or typed source pools changed.'); integer(shipment.allyPayment, 0, quote.cost, 'Shipment ally payment'); contribution(g, p, quote.cost, shipment.allyPayment); checkShipmentPromises(g, p, null); return quote; } function homeworldShipmentIntegrity(g: Game) { homeworldMobilityIntegrity(g); homeworldRule(() => junctionOfferIntegrity(g)); const decisions = homeworldSavedDecisions(g).filter((d) => d.kind === 'homeworldShipmentGuild'); const shipment = g.pendingHomeworldShipment; if (!shipment && !decisions.length) return; requireRule(shipment && decisions.length > 0 && !g.pendingShipment, 'The saved Homeworld shipment needs its original Guild decision.'); validateHomeworldShipment(g, shipment); const guild = byFaction(g, 'guild'); requireRule(g.advanced && guild && !guild.specialKaramaUsed, 'This Homeworld shipment has no available Guild interception.'); for (const d of decisions) requireRule(d.player === guild.id && d.shipper === shipment.player && d.destination === shipment.destination && d.amount === shipment.amount && d.event === shipment.event, 'The saved Guild decision differs from its Homeworld declaration.'); } function commitHomeworldShipment(g: Game, shipment: PendingHomeworldShipment, settlement?: {recipient: string | null}) { const quote = validateHomeworldShipment(g, shipment); if (!settlement && beginGuildBetrayal(g, {source: 'homeworld', shipment})) return; if (shipment.guildSecretEvent) recordGuildSecretShipment(g,shipment.player,shipment.guildSecretEvent,'homeworld',shipment); if (shipment.guildNexusEvent) finishGuildCunningShipment(g,shipment.player,'homeworld',shipment); const p = getPlayer(g, shipment.player); payWithAlly(g, p, quote.cost, shipment.allyPayment); if (settlement?.recipient) getPlayer(g, settlement.recipient).spice += quote.cost; g.homeworlds!.custody = quote.state; if (shipment.route === 'arrakis') { p.forces = quote.boardForces!; if (p.elites) p.elites.forces = quote.boardEliteForces!; } for (const seat of quote.players) { const owner = getPlayer(g, seat.id); owner.reserves = seat.reserves; if (owner.elites) owner.elites.reserves = seat.eliteReserves; } observeOccupation(g); p.shipped = true; g.pendingHomeworldShipment = null; // The E3 FAQ explicitly includes Homeworld shipment, including Fremen. techIncome(g, 'heighliners', p); // Off-planet destinations do not create the Guild's ordinary onto-Dune income. // November FAQ contributor routing remains independent of that trigger. const guild = byFaction(g, 'guild'); const income = guild && p.ally !== guild.id ? shipment.allyPayment : 0; if (!settlement?.recipient && guild && income > 0) g.response = guildPaymentResponse(g, guild.id, [income], undefined, [{ payer: p.ally!, amount: income }]); if (!settlement?.recipient) settleBankerShipmentIncome(g, p, quote.cost, shipment.allyPayment, { own: false, ally: !!guild && p.ally !== guild.id }); finishShipmentPromises(g, p, null); log(g, `${p.name} shipped ${quote.amount} physical forces (${quote.elite} special) from ${quote.sourceNames.join(' and ')} to ${combatLocationName(g, shipment.destination)} for ${quote.cost} spice (${quote.cost - shipment.allyPayment} own, ${shipment.allyPayment} pledged). This uses their shipment; movement remains available.${shipment.guildSecretEvent ? ' Guild Secret Ally is spent for this half-price shipment; the payment goes to the bank.' : ''}`, {faction: shipment.guildSecretEvent ? 'guild' : p.faction, name: shipment.guildSecretEvent ? 'Secret Ally Homeworld shipment' : 'Homeworld shipment'}); } function declareHomeworldShipment(g: Game, p: Player, intent: HomeworldShipmentIntent & {route?: 'arrakis';guildSecretEvent?:string}, allyPayment?: unknown) { const quote = homeworldShipmentQuote(g, intent); const shipment: PendingHomeworldShipment = {...intent, ...(currentGuildCunning(g,p.id) ? {guildNexusEvent:currentGuildCunning(g,p.id)!.receipt.event} : {}), sources: structuredClone(intent.sources), event: homeworldShipmentEvent(g, p, intent.route), turn: g.turn, amount: quote.amount, elite: quote.elite, cost: quote.cost, allyPayment: contribution(g, p, quote.cost, allyPayment), pools: quote.sources}; bindGuildCunningShipment(g,p,'homeworld',shipment); const guild = byFaction(g, 'guild'); if (g.advanced && guild && !guild.specialKaramaUsed) { g.pendingHomeworldShipment = shipment; g.decision = {kind: 'homeworldShipmentGuild', player: guild.id, shipper: p.id, destination: intent.destination, amount: quote.amount, event: shipment.event}; log(g, `${p.name} declared ${quote.amount} physical forces for ${combatLocationName(g, intent.destination)}. Payment and departure await the Guild interception decision.`); } else commitHomeworldShipment(g, shipment); } function noFieldSkillProof(shipment: PendingShipment): string { return JSON.stringify({ ...shipment, noFieldSkillProof: undefined }); } function validateLeaderSkillNoFieldShipment(g: Game, shipment: PendingShipment) { requireRule(g.leaderSkills && smugglerNoFieldModeSupported(g) && shipment.noField && !shipment.alliedNoField && !shipment.smuggler && !shipment.source && !shipment.guildSecretEvent && !shipment.guildNexusEvent && !shipment.nexusEvent && shipment.homeworldSources === undefined && shipment.ambassadorEvent === undefined, 'Smuggler No-Field shipping requires an own Richese marker without other shipment routes or modules.'); requireRule(typeof shipment.noFieldSkillProof === 'string' && shipment.noFieldSkillProof === noFieldSkillProof(shipment), 'The saved No-Field shipment changed its original marker or optional Smuggler companion.'); const p = getPlayer(g, shipment.player); requireRule(g.status === 'playing' && g.phase === 5 && g.active === p.id && shipmentAvailable(g, p) && shipment.turn === g.turn && p.faction === 'richese' && p.noField && p.noFieldEvent === shipment.noField.event && p.noFieldBlockedTurn !== g.turn && !p.noField.deployed && shipment.amount === 1 && shipment.elite === 0 && shipment.advisors === false, 'The saved No-Field shipment lost its current unused opportunity or concealed marker.'); integer(shipment.sector, 0, 18, 'No-Field shipment sector'); requireRule(shipment.territory !== MOBILE_STRONGHOLD, 'Richese cannot ship into the mobile stronghold.'); allowedEntry(g, p, shipment.territory, shipment.sector, false, false); noFieldRule(() => deployRicheseNoField(p.noField!, { tokenId: shipment.noField!.tokenId, controller: p.id, location: { territory: shipment.territory, sector: shipment.sector }, })); if (shipment.smugglerCompanion !== undefined) requireRule( JSON.stringify(shipment.smugglerCompanion) === JSON.stringify(quoteSmugglerNoField(g, p.id, shipment.territory)) && shipment.smugglerCompanion !== null, 'The saved Smuggler No-Field companion lost its living trainer, reserve force or empty destination.', ); const cost = reserveShipmentCost({ faction: p.faction, halfRate: shipmentHalfRate(g, p) }, territory(shipment.territory).type, 1); requireRule(Number.isSafeInteger(shipment.cost) && shipment.cost === cost, 'A No-Field with an optional Smuggler force keeps the price of one marker.'); integer(p.spice, 0, Number.MAX_SAFE_INTEGER, 'Current No-Field shipment spice'); integer(shipment.allyPayment, 0, cost, 'No-Field 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 No-Field contributor is no longer your mutual ally.'); contribution(g, p, cost, shipment.allyPayment); } /** Find a No-Field response while another card has temporarily suspended it. */ function savedNoFieldResponses(g: Game): ResponseWindow[] { 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]; return contexts.flatMap(context => { const karama = context && 'pendingKarama' in context ? context.pendingKarama as Game['pendingKarama'] : null; return [context?.response, karama?.use.kind === 'cancel' ? karama.use.response : null]; }).filter((response): response is ResponseWindow => !!response); } /** Bind both private stages, also while a card effect has saved their controls. */ function leaderSkillNoFieldIntegrity(g: Game): boolean { const responses = savedNoFieldResponses(g); const decisions = homeworldSavedDecisions(g); const shipment = g.pendingShipment; if (g.leaderSkills && shipment?.alliedNoField) { requireRule(ordinaryLeaderSkillModeSupported(g) && !shipment.noField && shipment.smuggler === undefined && shipment.smugglerCompanion === undefined && shipment.noFieldSkillProof === undefined, 'Allied No-Field skill composition retains its original ordinary shipment only.'); const initial = responses.filter(r => r.kind === 'richeseNoField'); const guild = decisions.filter(d => d.kind === 'guildShipment'); requireRule(initial.length + guild.length === 1, 'The allied No-Field must retain its original cancellation or Guild continuation.'); for (const response of initial) requireRule(noFieldCancellationQuote(g, response)?.kind === 'allied', 'The allied No-Field lost its original owner, recipient or token declaration.'); for (const decision of guild) validateGuildShipmentDecision(g, decision); return true; } const pair = !!g.leaderSkills && !!shipment?.richesePair; if (!(pair || shipment?.smugglerCompanion !== undefined || shipment?.noFieldSkillProof !== undefined || (g.leaderSkills && shipment?.noField) || responses.some(r => r.noFieldSkillProof !== undefined) || decisions.some(d => 'noFieldSkillProof' in d))) return false; requireRule(shipment, 'The saved No-Field response lost its original shipment.'); if (pair) currentRichesePairShipment(g, shipment); else validateLeaderSkillNoFieldShipment(g, shipment); const initial = responses.filter(r => r.kind === 'richeseNoField'); const guild = decisions.filter(d => d.kind === 'guildShipment'); requireRule(initial.length + guild.length === 1 && responses.every(r => r.noFieldSkillProof === undefined || r.kind === 'richeseNoField') && decisions.every(d => !('noFieldSkillProof' in d) || d.kind === 'guildShipment'), 'The saved No-Field shipment lost its unique cancellation response or Guild decision.'); for (const response of initial) requireRule(response.owner === shipment.player && response.noFieldSkillProof === shipment.noFieldSkillProof, 'The No-Field response changed its original declaration.'); for (const decision of guild) validateGuildShipmentDecision(g, decision); return true; } function validatePhysicalShipment(g: Game, shipment: PendingShipment) { requireRule(!atomicsShipmentBlocked(g.moritaniAtomics, shipment.territory), 'Atomics Aftermath permanently blocks shipments into this territory.'); // The pair has its original signed two-token validator below; skill proofs bind single markers. if (!shipment.richesePair && (shipment.smugglerCompanion !== undefined || shipment.noFieldSkillProof !== undefined || (g.leaderSkills && shipment.noField))) validateLeaderSkillNoFieldShipment(g, shipment); if (shipment.smuggler) requireRule( !shipment.source && !shipment.noField && !shipment.alliedNoField && !shipment.guildSecretEvent && !shipment.guildNexusEvent && !shipment.nexusEvent && ordinaryLeaderSkillModeSupported(g), 'Smuggler shipping with expansion modules is still being integrated.', ); if (shipment.guildSecretEvent) { validateGuildSecretDeclaration(g,shipment.player,shipment.guildSecretEvent); requireRule(!shipment.nexusEvent && !shipment.guildNexusEvent && !shipment.noField && !shipment.alliedNoField && !shipment.source, 'Guild Secret Ally needs its own ordinary physical reserve shipment.'); } if (shipment.guildNexusEvent) validateGuildCunningShipment(g,shipment.player,shipment.guildNexusEvent,'reserve',shipment); if (shipment.nexusEvent !== undefined) currentNexusRicheseShipment(g,shipment); if (shipment.richesePair) currentRichesePairShipment(g, shipment); requireRule( !(shipment.noField || shipment.alliedNoField) || shipment.homeworldSources === undefined, 'A No-Field shipment uses its own physical source allocation.', ); if (shipment.source === 'ambassador') { validateAmbassadorShipmentOrder(g, shipment); return; } // A marker's effective one-force declaration is not a reserve withdrawal. // Its separate custody contract still requires a currently revealed destination. if (shipment.noField || shipment.alliedNoField) { requireRule(validGameLocation(g, shipment.territory, shipment.sector), 'A No-Field shipment requires a current board destination.'); return; } const p = getPlayer(g, shipment.player); requireRule( g.status === 'playing' && g.phase === 5 && g.active === p.id && shipmentAvailable(g,p) && (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); if (g.homeworlds?.custody) homeworldRule(() => quoteNativeReserveWithdrawal( homeworldContext(g), g.homeworlds!.custody!, p.id, { normal: n - shipment.elite, elite: shipment.elite }, shipment.homeworldSources, ), ); else requireRule( shipment.homeworldSources === undefined, 'Homeworld source selection requires the Homeworld module.', ); 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, !shipment.guildSecretEvent && g.advanced && p.faction === 'fremen', shipment.advisors, !shipment.noField && !shipment.alliedNoField && isAlliedShipmentVisit(g, p, shipment.territory), ); if (p.faction === 'fremen' && !shipment.guildSecretEvent) 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 price = {faction:p.faction,halfRate:shipmentHalfRate(g,p)}; if (shipment.smuggler) requireRule( !shipment.guildSecretEvent && !shipment.nexusEvent && JSON.stringify(shipment.smuggler) === JSON.stringify(quoteSmugglerShipment(g, p.id, shipment.territory, n)), 'The saved Smuggler shipment lost its living trainer, empty destination or physical amount.', ); const cost = shipment.guildSecretEvent ? nexusRule(() => quoteNexusGuildSecretShipment(territory(shipment.territory).type,n)).cost : shipment.nexusEvent ? nexusRule(() => quoteNexusRicheseShipment(price,territory(shipment.territory).type,n)).cost : reserveShipmentCost(price,territory(shipment.territory).type,n - (shipment.smuggler ? 1 : 0)); 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.noFieldSkillProof === shipment.noFieldSkillProof && decision.amount === (shipment.alliedNoField ? 1 : shipment.amount), 'The Guild decision does not match the pending shipment.', ); validatePhysicalShipment(g, shipment); return shipment; } const CLASSIC_FACTIONS: Partial> = { atreides: true, harkonnen: true, emperor: true, fremen: true, guild: true, beneGesserit: true, }; /** The independent Karama rate is a card benefit, not Guild's cancelable rate. */ function guildRateModeSupported(g: Game): boolean { return !g.expansions.length && !g.homeworlds && !g.leaderSkills && !g.nexusCards && !g.discoveryEnabled && !g.techTokens && !g.strongholdCards && g.players.every(player => CLASSIC_FACTIONS[player.faction] === true); } function ordinaryGuildRateFullCost(g: Game, shipment: PendingShipment): number | null { const guild = byFaction(g, 'guild'); const p = getPlayer(g, shipment.player); if (!guild || (p.id !== guild.id && !(p.ally === guild.id && guild.ally === p.id)) || !guildRateModeSupported(g) || g.karamaShipping?.player === p.id || (g.guildRateBlocked?.turn === g.turn && g.guildRateBlocked.player === p.id) || shipment.source || shipment.noField || shipment.alliedNoField || shipment.smuggler || shipment.smugglerCompanion || shipment.guildSecretEvent || shipment.guildNexusEvent || shipment.nexusEvent || shipment.richesePair) return null; const fullCost = reserveShipmentCost({ faction: p.faction, halfRate: false }, territory(shipment.territory).type, shipment.amount); return shipment.cost < fullCost ? fullCost : null; } function openGuildRate(g: Game, shipment: PendingShipment): boolean { const fullCost = ordinaryGuildRateFullCost(g, shipment); if (fullCost === null) return false; // A validated older Guild-stop declaration may lack this newer turn stamp. shipment.turn ??= g.turn; const event = crypto.randomUUID(); shipment.guildRateEvent = event; g.pendingShipment = shipment; g.response = { kind: 'guildRate', owner: byFaction(g, 'guild')!.id, passed: [], guildRateEvent: event, intent: `${getPlayer(g, shipment.player).name} declared ${shipment.amount} reserve forces to ${territory(shipment.territory).name} at the Guild rate of ${shipment.cost} spice. Karama can remove this discount before any payment or arrival. The original forces would cost ${fullCost} at full price; if the original authorized split cannot pay that amount, the declaration returns unused and a replacement this turn uses full price.`, }; log(g, `${getPlayer(g, shipment.player).name} declared a Guild-rate reserve shipment; payment and arrival wait for the rate response.`); return true; } function validateGuildRateResponse(g: Game, response: ResponseWindow) { const shipment = g.pendingShipment; const guild = byFaction(g, 'guild'); const p = shipment && getPlayer(g, shipment.player); const fullCost = shipment ? ordinaryGuildRateFullCost(g, shipment) : null; requireRule(response.kind === 'guildRate' && guild && p && shipment && g.status === 'playing' && g.phase === 5 && g.active === p.id && !p.shipped && response.owner === guild.id && typeof shipment.guildRateEvent === 'string' && shipment.guildRateEvent.length > 0 && response.guildRateEvent === shipment.guildRateEvent && shipment.turn === g.turn && fullCost !== null && shipment.cost === reserveShipmentCost({ faction: p.faction, halfRate: true }, territory(shipment.territory).type, shipment.amount), 'The saved Guild-rate response lost its original physical shipment.'); return { shipment, fullCost }; } function guildRateIntegrity(g: Game) { const responses = savedNoFieldResponses(g).filter(response => response.kind === 'guildRate'); const expected = Number(!!g.pendingShipment?.guildRateEvent) + Number(!!g.pendingGuildTransport); requireRule(expected <= 1 && responses.length === expected, 'The saved Guild-rate shipment lost its single Karama response.'); if (responses.length) { if (g.pendingGuildTransport) validateGuildTransportRateResponse(g, responses[0]); else validateGuildRateResponse(g, responses[0]); } const blocked = g.guildRateBlocked; if (blocked) requireRule(blocked.turn === g.turn && g.players.some(p => p.id === blocked.player), 'The canceled Guild discount belongs to another turn or player.'); } function finishGuildRateResponse(g: Game, response: ResponseWindow, canceled: boolean) { const { shipment, fullCost } = validateGuildRateResponse(g, response); const shipper = getPlayer(g, shipment.player); g.pendingShipment = null; delete shipment.guildRateEvent; if (!canceled) { commitShipment(g, shipment); return; } g.guildRateBlocked = { turn: g.turn, player: shipper.id }; const bounds = shipmentPaymentBounds(fullCost, shipper.spice, aidFor(g, shipper)?.amount ?? 0); if (shipment.allyPayment < bounds.minimum || shipment.allyPayment > bounds.maximum) { log(g, `Karama removed Guild's half-price rate from ${shipper.name}'s declaration. Its original approved payment split cannot fund the full price, so no spice or forces were spent and shipment remains available at full price this turn.`); return; } shipment.cost = fullCost; checkShipmentIncomeFunding(g, shipper, fullCost, shipment.allyPayment); commitShipment(g, shipment); delete g.guildRateBlocked; log(g, `Karama removed Guild's half-price rate. ${shipper.name} paid the full ${fullCost} spice for the unchanged shipment.`); } function guildTransportSignature(intent: PendingGuildTransport): string { return JSON.stringify([intent.event, intent.turn, intent.player, intent.fromReserves, intent.group, intent.eliteGroup, intent.origin, intent.to, intent.sector, intent.amount, intent.elite, intent.cost, intent.allyPayment, intent.advisors]); } function ordinaryGuildTransportFullCost(g: Game, p: Player, frame: GuildTransportOrder): number | null { const guild = byFaction(g, 'guild'); if (!guild || !guildRateModeSupported(g) || (p.id !== guild.id && !(p.ally === guild.id && guild.ally === p.id)) || g.karamaShipping?.player === p.id || (g.guildRateBlocked?.turn === g.turn && g.guildRateBlocked.player === p.id)) return null; const fullCost = guildShipmentCost( frame.to === 'reserves' ? 'reserves' : territory(frame.to).type, frame.amount, false); return frame.turn === g.turn && frame.cost < fullCost ? fullCost : null; } function openGuildTransportRate(g: Game, p: Player, frame: GuildTransportOrder, fromReserves: boolean): boolean { const fullCost = ordinaryGuildTransportFullCost(g, p, frame); if (fullCost === null) return false; const intent: PendingGuildTransport = { ...frame, fromReserves, event: crypto.randomUUID(), signature: '', }; intent.signature = guildTransportSignature(intent); g.pendingGuildTransport = intent; g.response = { kind: 'guildRate', owner: byFaction(g, 'guild')!.id, passed: [], guildRateEvent: intent.event, intent: `${p.name} declared ${frame.amount} forces from ${fromReserves ? 'southern reserves' : territory(frame.origin).name} to ${frame.to === 'reserves' ? 'reserves' : territory(frame.to).name} at the Guild transport rate of ${frame.cost} spice. Karama can remove only the discount before payment or force transfer. The same physical transport would cost ${fullCost} at full price; if its original approved payment split cannot cover that, it returns unused and a replacement this turn uses full price.`, }; log(g, `${p.name} declared Guild-rate transport; payment and physical transfer wait for the Karama response.`); return true; } function validateGuildTransportRateResponse(g: Game, response: ResponseWindow) { const intent = g.pendingGuildTransport; const guild = byFaction(g, 'guild'); const shipper = intent && g.players.find(p => p.id === intent.player); const fullCost = intent && shipper ? ordinaryGuildTransportFullCost(g, shipper, intent) : null; requireRule(response.kind === 'guildRate' && intent && guild && shipper && g.status === 'playing' && g.phase === 5 && g.active === shipper.id && !shipper.shipped && intent.turn === g.turn && typeof intent.event === 'string' && intent.event.length > 0 && response.owner === guild.id && response.guildRateEvent === intent.event && typeof intent.fromReserves === 'boolean' && Array.isArray(intent.group) && intent.group.every(([key, n]) => typeof key === 'string' && key.length > 0 && Number.isSafeInteger(n) && n > 0) && intent.eliteGroup && typeof intent.eliteGroup === 'object' && !Array.isArray(intent.eliteGroup) && typeof intent.origin === 'string' && intent.origin.length > 0 && typeof intent.to === 'string' && intent.to.length > 0 && Number.isSafeInteger(intent.sector) && Number.isSafeInteger(intent.amount) && intent.amount > 0 && Number.isSafeInteger(intent.elite) && intent.elite >= 0 && intent.elite <= intent.amount && Number.isSafeInteger(intent.allyPayment) && intent.allyPayment >= 0 && intent.allyPayment <= intent.cost && typeof intent.advisors === 'boolean' && (intent.fromReserves ? intent.origin === 'reserves' && shipper.faction === 'fremen' && intent.group.length === 0 && Object.keys(intent.eliteGroup).length === 0 && intent.to !== 'reserves' : intent.group.length > 0 && intent.group.every(([key]) => splitLocation(key).territory === intent.origin) && intent.group.reduce((sum, [, n]) => sum + n, 0) === intent.amount && Object.keys(intent.eliteGroup).length === intent.group.length && intent.group.every(([key, n]) => Number.isSafeInteger(intent.eliteGroup[key]) && intent.eliteGroup[key] >= 0 && intent.eliteGroup[key] <= n) && intent.group.reduce((sum, [key]) => sum + intent.eliteGroup[key], 0) === intent.elite) && (intent.to !== 'reserves' || shipper.faction === 'guild') && typeof intent.signature === 'string' && intent.signature === guildTransportSignature(intent) && fullCost !== null && intent.cost === guildShipmentCost( intent.to === 'reserves' ? 'reserves' : territory(intent.to).type, intent.amount), 'The saved Guild transport lost its original physical rate response.'); return { intent, shipper, fullCost }; } function nativeGuildTransportPermission(g: Game, p: Player): boolean { if (p.faction === 'guild') return true; const guild = byFaction(g, 'guild'); return !!guild && p.ally === guild.id && guild.ally === p.id; } function validateGuildTransportForCommit( g: Game, p: Player, frame: GuildTransportOrder, fromReserves: boolean, ) { const { origin, group, eliteGroup, to, sector, amount, elite, cost, allyPayment, advisors } = frame; requireRule(g.status === 'playing' && g.phase === 5 && frame.turn === g.turn && frame.player === p.id && g.active === p.id && shipmentAvailable(g, p), 'This Guild transport no longer belongs to an unused shipment opportunity.'); requireRule(nativeGuildTransportPermission(g, p), 'Native Guild transport requires the Guild or its reciprocal ally.'); if (fromReserves) { requireRule(p.faction === 'fremen' && origin === 'reserves' && group.length === 0 && Object.keys(eliteGroup).length === 0, 'The southern-reserve transport lost its original source.'); integer(amount, 1, p.reserves, 'Forces'); requireRule(eliteChoice(amount, p.reserves, p.elites?.reserves ?? 0, elite) === elite, 'The original southern-reserve elite allocation is unavailable.'); } else { const quoted = forceGroup(g, p, { type: 'guildShip', forces: Object.fromEntries(group), eliteForces: eliteGroup }); requireRule(JSON.stringify(quoted.group) === JSON.stringify(group) && JSON.stringify(quoted.eliteGroup) === JSON.stringify(eliteGroup) && quoted.origin === origin && quoted.elite === elite && quoted.total === amount && group.every(([key]) => splitLocation(key).sector !== g.storm), 'The original Guild transport force group changed or entered the storm.'); } requireRule((fromReserves || to === 'reserves' || to !== origin) && (to !== 'reserves' || p.faction === 'guild') && (to !== MOBILE_STRONGHOLD || p.faction === 'ixians') && advisors === (to !== 'reserves' && arrivalAsAdvisor(g, p, to)), 'The original Guild transport destination or advisor stance is unavailable.'); const sourceLock = p.advisors?.[origin]?.lockedTurn; requireRule(to === 'reserves' || advisors || sourceLock !== g.turn || !g.players.some(other => other.id !== p.id && at(other, to)), 'New advisors cannot become fighters this turn.'); if (to !== 'reserves') allowedEntry(g, p, to, sector, false, advisors); const halfRate = g.karamaShipping?.player === p.id || !(g.guildRateBlocked?.turn === g.turn && g.guildRateBlocked.player === p.id); requireRule(cost === guildShipmentCost( to === 'reserves' ? 'reserves' : territory(to).type, amount, halfRate), 'The declared Guild transport price no longer matches its rate.'); contribution(g, p, cost, allyPayment); checkShipmentIncomeFunding(g, p, cost, allyPayment); checkShipmentPromises(g, p, fromReserves ? { territory: to, amount } : null); } function commitGuildTransport( g: Game, p: Player, frame: GuildTransportOrder, fromReserves: boolean, guildSecretEvent?: string, settlement?: {recipient: string | null}, ) { requireRule(!g.guildBetrayalPreview || !guildSecretEvent, 'Guild Betrayal native transport does not combine with a Guild Secret Ally producer.'); if (!guildSecretEvent) validateGuildTransportForCommit(g, p, frame, fromReserves); if (!settlement && beginGuildBetrayal(g, {source: 'guildTransport', frame, fromReserves})) return; const { origin, group, eliteGroup, to, sector, amount, elite, cost, allyPayment, advisors } = frame; const sourceLock = p.advisors?.[origin]?.lockedTurn; if (guildSecretEvent) recordGuildSecretShipment(g, p.id, guildSecretEvent, 'cross', { ...frame, guildSecretEvent }); bindGuildCunningShipment(g, p, 'guild', frame); payWithAlly(g, p, cost, allyPayment); if (settlement?.recipient) getPlayer(g, settlement.recipient).spice += cost; 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 (!settlement?.recipient && guild && guildPayment > 0) g.response = guildPaymentResponse(g, guild.id, shipmentIncomeContributions(g, p, cost, allyPayment), allyPayment ? undefined : p.id, [{ payer: p.id, amount: p.id === guild.id ? 0 : cost - allyPayment }, ...(allyPayment && p.ally !== guild.id ? [{ payer: p.ally!, amount: allyPayment }] : [])]); settleBankerShipmentIncome(g, p, cost, allyPayment); if (fromReserves) { p.reserves -= amount; if (p.elites) p.elites.reserves -= elite; } else removeGroup(p, group, eliteGroup); if (to === 'reserves') { // User ruling 7 October 2026: the Nexus Secret Ally reserve return is an // authorized native-reserve return, so it deposits into Homeworld custody. addNativeReserves(g, p, amount, elite); } else { place(p, to, sector, amount, elite); if (advisors) { const lockedTurn = Math.max(sourceLock ?? 0, p.advisors?.[to]?.lockedTurn ?? 0); (p.advisors ??= {})[to] = lockedTurn ? { lockedTurn } : {}; } else if (p.advisors) delete p.advisors[to]; } observeOccupation(g); finishShipmentPromises(g, p, fromReserves ? { territory: to, amount } : null); finishGuildCunningShipment(g, p.id, 'guild', frame); p.shipped = true; g.karamaShipping = null; log(g, `${p.name} used Guild transport for ${amount} forces.${guildSecretEvent ? ` Guild Secret Ally is spent; ${cost} spice was paid to the bank. Their ordinary movement remains available.` : ''}`, guildSecretEvent ? { faction: 'guild', name: 'Secret Ally transport' } : { faction: p.faction, name: 'Guild transport' }); if (to !== 'reserves') { intrusion(g, p, to); if (origin !== to) openTerritoryEntry(g, p, to, sector, amount, elite, 'guildTransport'); } } function finishGuildTransportRateResponse(g: Game, response: ResponseWindow, canceled: boolean) { const { intent, shipper, fullCost } = validateGuildTransportRateResponse(g, response); g.pendingGuildTransport = null; if (canceled) g.guildRateBlocked = { turn: g.turn, player: shipper.id }; const frame: GuildTransportOrder = { turn: intent.turn, player: intent.player, origin: intent.origin, group: intent.group, eliteGroup: intent.eliteGroup, to: intent.to, sector: intent.sector, amount: intent.amount, elite: intent.elite, cost: canceled ? fullCost : intent.cost, allyPayment: intent.allyPayment, advisors: intent.advisors, }; try { validateGuildTransportForCommit(g, shipper, frame, intent.fromReserves); } catch (error) { if (!(error instanceof RuleError)) throw error; log(g, `${shipper.name}'s Guild transport declaration returned without a payment or force transfer; its exact original source, destination or approved funding can no longer complete at ${canceled ? 'the full canceled rate' : 'the Guild rate'}.${canceled ? ' The spent Karama leaves a replacement at full price for this turn.' : ''}`); return; } commitGuildTransport(g, shipper, frame, intent.fromReserves); if (canceled) { delete g.guildRateBlocked; log(g, `Karama removed Guild's half-price transport rate. ${shipper.name} paid the full ${fullCost} spice for the unchanged physical transport.`); } } function offerShipment(g: Game, shipment: PendingShipment) { validatePhysicalShipment(g, shipment); validateShipmentArrival(g, shipment); checkShipmentIncomeFunding(g, getPlayer(g, shipment.player), shipment.cost, shipment.allyPayment); 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, ...(shipment.noFieldSkillProof ? { noFieldSkillProof: shipment.noFieldSkillProof } : {}), }; log( g, `${p.name} declared a shipment of ${shipment.noField ? `one concealed No-Field${shipment.smugglerCompanion ? ' and one free Smuggler force' : ''}` : `${n} forces`} to ${territory(to).name}, sector ${s}.${shipment.nexusEvent ? ` Richese Secret Ally is spent; the shipment is priced as one force (${shipment.cost} spice) if allowed.` : ''}`, ); } else if (!openGuildRate(g, shipment)) commitShipment(g, shipment); } function commitShipment(g: Game, shipment: PendingShipment, settlement?: {recipient: string | null}) { if (shipment.source === 'ambassador') { commitAmbassadorShipment(g, shipment); return; } validatePhysicalShipment(g, shipment); validateShipmentArrival(g, shipment); if (!settlement && beginGuildBetrayal(g, {source: 'reserve', shipment})) return; if (shipment.guildSecretEvent) recordGuildSecretShipment(g,shipment.player,shipment.guildSecretEvent,'reserve',shipment); finishNexusRicheseShipment(g,shipment,'shipped'); finishRichesePairShipment(g, shipment, 'shipped'); if (shipment.guildNexusEvent) finishGuildCunningShipment(g,shipment.player,'reserve',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); let pairRevealed: ReturnType['revealed'] | null = null; if (shipment.noField) { requireRule( p.faction === 'richese' && p.noField && p.noFieldEvent === shipment.noField.event, 'This No-Field shipment is no longer current.', ); if (shipment.richesePair) { const pair = noFieldRule(() => deployRicheseNoFieldPair(p.noField!, { tokenId: shipment.noField!.tokenId, controller: p.id, location: { territory: to, sector: s }, }, shipment.richesePair!.revealedTokenId, p.reserves)); p.noField = pair.state; pairRevealed = pair.revealed; } else 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 (settlement?.recipient) getPlayer(g, settlement.recipient).spice += cost; if (pairRevealed) { p.reserves -= pairRevealed.forces; if (pairRevealed.forces) place(p, to, s, pairRevealed.forces); observeOccupation(g); } let homeworldOrigins = ''; if (!shipment.noField) { if (!g.advanced && !shipment.alliedNoField && n > 0 && isAlliedShipmentVisit(g, p, to)) g.basicAlliedShipment = { player: p.id, ally: p.ally!, turn: g.turn, territory: to, baseline: alliedShipmentPhysicalPool(p, to), }; const receipts = withdrawNativeReserves( g, p, n, elite, shipment.homeworldSources, ); homeworldOrigins = receipts .map((receipt) => { const card = HOMEWORLD_CARDS.find((c) => receipt.homeworld === 'homeworld:emperor:salusa' ? c.id === 'salusa_secundus' : c.faction === p.faction && c.id !== 'salusa_secundus', )!; return `${receipt.before.normal - receipt.after.normal} normal and ${receipt.before.elite - receipt.after.elite} special forces from ${card.name}`; }) .join('; '); if (n > 0) place(p, to, s, n, elite); } if (shipment.smugglerCompanion) { withdrawNativeReserves(g, p, 1, 0); place(p, to, s, 1, 0); } 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 || !!(shipment.alliedNoField && alliedNoFieldShipmentPricing(g, p).bankOnly), }); if (!settlement?.recipient && guild && guildPayment > 0) g.response = guildPaymentResponse(g, guild.id, shipmentIncomeContributions(g, p, cost, allyPayment), allyPayment ? undefined : p.id, [{ payer: p.id, amount: p.id === guild.id ? 0 : cost - allyPayment }, ...(allyPayment && p.ally !== guild.id ? [{ payer: p.ally!, amount: allyPayment }] : [])]); settleBankerShipmentIncome(g, p, cost, allyPayment); 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 ? pairRevealed ? `${p.name} shipped two No-Fields with Richese Nexus Cunning to ${territory(to).name}, sector ${s}, for ${cost} spice. The faceup ${pairRevealed.value} token placed ${pairRevealed.forces} physical forces from reserves${pairRevealed.forces < pairRevealed.value ? ' (only the available reserves)' : ''}; the other token remains concealed and counts as one force.` : `${p.name} shipped one concealed No-Field to ${territory(to).name}, sector ${s}, for ${cost} spice. ${shipment.smugglerCompanion ? 'One free Smuggler force left reserves and arrived beside the marker; the concealed token remains unchanged until reveal.' : 'It counts as one force; physical reserves remain unchanged until reveal.'}` : `${p.name} shipped ${n} forces to ${territory(to).name}, sector ${s}.${shipment.guildSecretEvent ? ` Guild Secret Ally is spent; Guild shipping prices cost ${cost} spice paid to the bank.` : ''}${shipment.nexusEvent ? ` Richese Secret Ally priced the ${n} physical forces as one: ${cost} spice.` : ''}${homeworldOrigins ? ` Homeworld sources: ${homeworldOrigins}. Total shipment cost: ${cost} spice.` : ''}`, shipment.noField || shipment.alliedNoField ? { faction: 'richese', name: 'No-Field shipment' } : shipment.guildSecretEvent ? {faction:'guild',name:'Secret Ally shipment'} : shipment.nexusEvent ? {faction:'richese',name:'Secret Ally shipment'} : shipment.smuggler ? undefined : { faction: p.faction, name: 'Shipment' }, ); if (shipment.smuggler) log(g, `${p.name}'s Smuggler included one free accompanying force: ${n} physical forces left reserves, priced as ${n - 1} for ${cost} spice. The destination was empty before arrival.`, { faction: p.faction, name: 'Smuggler shipment' }); const bg = byFaction(g, 'beneGesserit'); const followup = p.faction !== 'fremen' && bg && bg.id !== p.id && spiritualAdvisorMaximum(g, bg.id) > 0 ? { shipment: p.id, destination: location(to, s) } : undefined; if (!intrusion(g, p, to, followup ? { followup } : undefined) && 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 + (shipment.smugglerCompanion ? 1 : 0) + (pairRevealed?.forces ?? 0), 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) { const blocked = homeworldRule(() => homeworldMobileStrongholdMovementBlock(g, p.id)); requireRule(!blocked, blocked ?? 'The mobile stronghold cannot move.'); 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; sandmasterPiles?: string[] }, ) { const p = getPlayer(g, move.player); const blocked = homeworldRule(() => homeworldMobileStrongholdMovementBlock(g, p.id)); requireRule(!blocked, blocked ?? 'The mobile stronghold cannot move.'); const skillPiles = move.sandmasterPiles !== undefined ? nexusRule(() => validateSandmasterStronghold(g, p.id, move.route, move.sandmasterPiles, move.collect)) : sandmasterLeader(g, p.id) ? Object.values(sandmasterCollectionPiles(g, { interior: move.route })).filter(piles => piles.length === 1).map(piles => piles[0]) : []; let collected = 0; if (move.collect) { // User ruling 7 October 2026: passengers remaining inside a relocated HMS // interior count as entering the outside territories the stronghold points // into, so the Sandmaster skill collects there before the faction's own // traversed-sector collection. // Unselected older queued moves retain their original unambiguous-pile path. for (const pile of skillPiles) { g.spice[pile]--; if (!g.spice[pile]) delete g.spice[pile]; p.spice++; log( g, `${p.name}'s Sandmaster collected 1 spice as the stronghold entered ${territory(splitLocation(pile).territory).name}. This territory grants at most one collection during this relocation.`, { faction: p.faction, name: 'Sandmaster collection' }, ); } 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.`, { faction: p.faction, name: 'Hidden Mobile Stronghold' }, ); } function choamWorthlessBlocked(g: Game, card: string) { return ( g.choamWorthlessBlocked?.turn === g.turn && g.choamWorthlessBlocked.phase === g.phase && g.choamWorthlessBlocked.cards.includes(card) ); } function currentGuildCunning(g: Game, owner?: string) { const last = g.nexusGuildCunningHistory?.at(-1); return last && last.receipt.turn === g.turn && (!owner || last.receipt.owner === owner) && ['pending','secondShipment','extraMove'].includes(last.stage) ? last : null; } function guildCunningParent(g: Game) { return JSON.stringify([g.active,g.order,g.movementRemaining,g.guildTimingGranted,g.guildTimingLocked,g.saphoMovementLast ?? null]); } function guildCunningSignature(record: NonNullable[number]) { return JSON.stringify([record.receipt.signature,record.stage,record.parent,record.movesAfter,record.shipment ?? null]); } function saveGuildCunning(g: Game, record: NonNullable[number]) { record.signature = guildCunningSignature(record); g.nexusGuildCunningLast = {event:record.receipt.event,stage:record.stage}; } function projectedNexusGuildCunning(g: Game, owner: string) { const p = g.players.find(p => p.id === owner); if (!p || p.faction !== 'guild') return null; const record = currentGuildCunning(g,owner); if (record) return {offer:null,active:{event:record.receipt.event,stage:record.stage as 'pending'|'secondShipment'|'extraMove', movesLeft:record.stage === 'extraMove' ? nexusRule(() => nexusGuildCunningMoves(record.receipt,g.hajr.includes(owner),record.movesAfter)) : 0, hajrAvailable:record.stage !== 'pending' && !g.hajr.includes(owner)}}; if (g.nexusCards?.cards?.hands[owner] !== 'guild') return null; let blocked: string | null = null; if (g.guildBetrayalPreview) blocked = 'This payment audit profile supports the original native shipment, not a Cunning second-shipment overlay.'; else if (p.ally) blocked = 'Guild Cunning requires an unallied native Guild player.'; else if (g.status !== 'playing' || g.phase !== 5 || g.active !== owner) blocked = 'Use Cunning when finishing your own Shipment and Movement turn.'; else if (g.response || g.decision || g.truthtrance || g.phaseOpening || g.pendingKarama || g.pendingTreacheryDiscard || g.pendingNullentropy || g.pendingShipment || g.pendingHomeworldShipment || g.nexusTraitorPending || g.nexusCards.phase?.stage === 'drawing') blocked = 'Finish the current interaction before declaring a second shipment.'; else if (g.ornithopter || p.moved > (g.hajr.includes(owner) ? 2 : 1)) blocked = 'Cunning combined with the physical Ornithopter movement card awaits its timing ruling.'; else if (g.nexusGuildCunningHistory?.some(r => r.receipt.turn === g.turn && r.receipt.owner === owner)) blocked = 'This turn’s Guild Cunning opportunity is already used.'; return {offer:{event:JSON.stringify(['nexusGuildCunning',g.turn,owner]),blocked},active:null}; } function declareGuildCunning(g: Game, p: Player, event: unknown) { const offer = projectedNexusGuildCunning(g,p.id)?.offer; requireRule(offer && !offer.blocked && event === offer.event,offer?.blocked ?? 'This Guild Cunning opportunity is stale.'); const receipt = nexusRule(() => createNexusGuildCunning(g,p.id,{shipped:p.shipped,moved:p.moved,hajrUsed:g.hajr.includes(p.id)})); const record = {receipt,stage:'pending' as const,parent:guildCunningParent(g),movesAfter:0,signature:''}; g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!,p.id,g.players)); (g.nexusGuildCunningHistory ??= []).push(record); saveGuildCunning(g,record); g.karamaShipping = null; g.response = {kind:'nexusGuildCunning',owner:p.id,intent:receipt.event,passed:[]}; log(g,`${p.name} finished the ordinary shipment and movement opportunity and played Guild Cunning for an immediate second paid shipment. The Nexus is spent; Hajr is required for any further movement.`); } function validateGuildCunningResponse(g: Game, response: ResponseWindow) { const record = currentGuildCunning(g,response.owner); requireRule(record?.stage === 'pending' && response.kind === 'nexusGuildCunning' && response.intent === record.receipt.event && record.parent === guildCunningParent(g), 'Guild Cunning has lost its original combined-turn response.'); return record; } function settleGuildCunning(g: Game, response: ResponseWindow, canceled: boolean) { const record = validateGuildCunningResponse(g,response); record.stage = canceled ? 'canceled' : 'secondShipment'; saveGuildCunning(g,record); log(g,canceled ? 'Karama prevented Guild Cunning. The first shipment and movement remain completed and the Nexus remains spent.' : 'Guild Cunning permits one immediate paid shipment using normal Guild routes. Ordinary movement is finished; an unused Hajr move remains possible afterward.', canceled ? undefined : {faction:'guild',name:'Cunning: second shipment'}); if (canceled) finishMovementTurn(g,record.receipt.owner); } function bindGuildCunningShipment(g: Game, p: Player, kind: 'reserve'|'guild'|'homeworld', frame: unknown) { const record = currentGuildCunning(g,p.id); if (!record) return; requireRule(record.stage === 'secondShipment' && !record.shipment, 'Guild Cunning already has its second shipment.'); record.shipment = {kind,frame:JSON.stringify(frame),stage:'pending'}; saveGuildCunning(g,record); } function validateGuildCunningShipment(g: Game, owner: string, event: string, kind: 'reserve'|'guild'|'homeworld', frame: unknown) { const record = currentGuildCunning(g,owner); requireRule(record?.stage === 'secondShipment' && record.receipt.event === event && record.shipment?.stage === 'pending' && record.shipment.kind === kind && record.shipment.frame === JSON.stringify(frame), 'Guild Cunning has lost its original second shipment.'); return record; } function finishGuildCunningShipment(g: Game, owner: string, kind: 'reserve'|'guild'|'homeworld', frame: unknown, outcome:'complete'|'stopped' = 'complete') { const record = currentGuildCunning(g,owner); if (!record) return; validateGuildCunningShipment(g,owner,record.receipt.event,kind,frame); record.shipment!.stage = outcome; record.stage = 'extraMove'; saveGuildCunning(g,record); } function nexusGuildCunningIntegrity(g: Game) { const continuation = g.pendingTreacheryDiscard?.continuation; const contexts = [g,g.pendingExchange,g.pendingNullentropy?.resume,g.pendingRicheseGift?.resume,g.pendingRichesePurchaseIncome?.resume,g.summonedWorm?.resume,g.pendingKull?.resume, continuation && 'resume' in continuation ? continuation.resume : null]; const responses = contexts.flatMap(c => { if (!c) return []; const karama = 'pendingKarama' in c ? c.pendingKarama as Game['pendingKarama'] : null; return [c.response,karama?.use?.kind === 'cancel' ? karama.use.response : null]; }).filter(r => r?.kind === 'nexusGuildCunning'); const history = g.nexusGuildCunningHistory; if (history === undefined) { requireRule(!g.nexusGuildCunningLast && !responses.length && !g.pendingShipment?.guildNexusEvent && !g.pendingHomeworldShipment?.guildNexusEvent, 'Guild Cunning has lost its saved second-shipment history.'); return; } requireRule(g.nexusCards?.cards && Array.isArray(history) && history.length > 0,'Guild Cunning requires its original Nexus module and history.'); const events = new Set(); for (const [index,record] of history.entries()) { requireRule(record && typeof record === 'object' && Object.keys(record).every(k => ['receipt','stage','parent','movesAfter','shipment','signature'].includes(k)) && ['pending','secondShipment','extraMove','completed','canceled'].includes(record.stage) && typeof record.parent === 'string' && record.signature === guildCunningSignature(record) && !events.has(record.receipt?.event) && Number.isSafeInteger(record.movesAfter) && record.movesAfter >= 0 && record.movesAfter <= 1 && (['completed','canceled'].includes(record.stage) || index === history.length - 1),'Guild Cunning has a changed or duplicated allowance record.'); nexusRule(() => validateNexusGuildCunning(g,record.receipt)); if (record.shipment?.kind === 'declined') requireRule(Object.keys(record.shipment).sort().join(',') === 'kind,stage' && record.shipment.stage === 'declined' && ['extraMove','completed'].includes(record.stage), 'Guild Cunning has changed its declined second shipment.'); else if (record.shipment) requireRule(Object.keys(record.shipment).sort().join(',') === 'frame,kind,stage' && ['reserve','guild','homeworld'].includes(record.shipment.kind) && typeof record.shipment.frame === 'string' && ['pending','complete','stopped'].includes(record.shipment.stage) && (record.shipment.stage === 'pending' ? record.stage === 'secondShipment' : ['extraMove','completed'].includes(record.stage)), 'Guild Cunning has changed its second shipment outcome.'); requireRule(record.stage !== 'extraMove' || (record.shipment && record.shipment.stage !== 'pending'), 'Guild Cunning has lost its completed second shipment.'); requireRule(record.stage !== 'pending' || (!record.shipment && !record.movesAfter), 'Guild Cunning changed before its response completed.'); requireRule(!record.movesAfter || record.stage === 'extraMove' || record.stage === 'completed','Guild Cunning has moved before its second shipment.'); events.add(record.receipt.event); } const last = history.at(-1)!; requireRule(JSON.stringify(g.nexusGuildCunningLast) === JSON.stringify({event:last.receipt.event,stage:last.stage}), 'Guild Cunning has lost its latest allowance marker.'); const active = currentGuildCunning(g); requireRule(['completed','canceled'].includes(last.stage) || active, 'Guild Cunning has left its original turn unfinished.'); requireRule((active?.stage === 'pending') === !!responses.length, 'Guild Cunning has lost or reopened its native response.'); if (!active) { requireRule(!g.pendingShipment?.guildNexusEvent && !g.pendingHomeworldShipment?.guildNexusEvent,'Guild Cunning has reopened a completed shipment.'); return; } const owner = getPlayer(g,active.receipt.owner); requireRule(g.status === 'playing' && g.phase === 5 && g.active === owner.id && active.parent === guildCunningParent(g) && owner.moved === active.receipt.before.moved + active.movesAfter && owner.shipped === (active.stage === 'extraMove' && (active.shipment?.kind !== 'declined' || active.movesAfter > 0) ? true : active.receipt.before.shipped) && g.nexusCards.cards.discard.includes('guild'), 'Guild Cunning changed its original active player, queue, shipment or movement allowance.'); nexusRule(() => nexusGuildCunningMoves(active.receipt,g.hajr.includes(owner.id),active.movesAfter)); for (const response of responses) validateGuildCunningResponse(g,response!); if (active.shipment?.stage === 'pending') { if (active.shipment.kind === 'reserve') { requireRule(g.pendingShipment?.guildNexusEvent === active.receipt.event, 'Guild Cunning lost its pending reserve shipment.'); validateGuildCunningShipment(g,owner.id,active.receipt.event,'reserve',g.pendingShipment); const decisions = homeworldSavedDecisions(g).filter(d => d.kind === 'guildShipment'); requireRule(decisions.length > 0,'Guild Cunning lost its shipment interception decision.'); for (const d of decisions) validateGuildShipmentDecision(g,d); } else if (active.shipment.kind === 'homeworld') { requireRule(g.pendingHomeworldShipment?.guildNexusEvent === active.receipt.event,'Guild Cunning lost its pending Homeworld shipment.'); validateGuildCunningShipment(g,owner.id,active.receipt.event,'homeworld',g.pendingHomeworldShipment); homeworldShipmentIntegrity(g); } else throw new RuleError('Guild Cunning retained an unfinished immediate cross-shipment.'); } else requireRule(!g.pendingShipment?.guildNexusEvent && !g.pendingHomeworldShipment?.guildNexusEvent, 'Guild Cunning has a shipment without its original pending record.'); } function nexusGuildSecretAllyOffer(g: Game, owner: string) { const p = g.players.find(p => p.id === owner); if (!p || g.nexusCards?.cards?.hands[owner] !== 'guild' || byFaction(g,'guild')) return null; let blocked: string | null = null; if (p.ally) blocked = 'Guild Secret Ally requires an unallied holder.'; else if (g.status !== 'playing' || g.phase !== 5 || g.active !== owner || !shipmentAvailable(g,p)) blocked = 'Use Guild Secret Ally for your unused shipment before moving.'; else if (g.response || g.decision || g.truthtrance || g.phaseOpening || g.pendingKarama || g.pendingNullentropy || g.pendingTreacheryDiscard || g.nexusTraitorPending || g.nexusCards.phase?.stage === 'drawing') blocked = 'Finish the current interaction before declaring this shipment.'; else if (g.nexusGuildSecretHistory?.some(r => r.receipt.owner === owner && r.receipt.turn === g.turn)) blocked = 'Guild Secret Ally has already been used this turn.'; return {event:JSON.stringify(['nexusGuildSecretAlly',g.turn,owner]),blocked}; } function validateGuildSecretDeclaration(g: Game, owner: string, event: unknown) { const offer = nexusGuildSecretAllyOffer(g,owner); requireRule(offer && !offer.blocked && event === offer.event, offer?.blocked ?? 'This Guild Secret Ally shipment is unavailable or stale.'); return offer.event; } function guildSecretSignature(record: NonNullable[number]) { return JSON.stringify([record.receipt.signature,record.kind,record.frame]); } function recordGuildSecretShipment(g: Game, owner: string, event: string, kind: 'reserve'|'cross'|'homeworld', frame: object) { validateGuildSecretDeclaration(g,owner,event); const receipt = nexusRule(() => createNexusGuildSecretAlly(g,owner)); const record = {receipt,kind,frame:JSON.stringify(frame),signature:''}; record.signature = guildSecretSignature(record); g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!,owner,g.players)); (g.nexusGuildSecretHistory ??= []).push(record); g.nexusGuildSecretLast = {event}; } function nexusGuildSecretIntegrity(g: Game) { const history = g.nexusGuildSecretHistory; requireRule(!g.pendingShipment?.guildSecretEvent && !g.pendingHomeworldShipment?.guildSecretEvent, 'Absent-Guild Secret Ally cannot retain a native Guild interception.'); if (history === undefined) { requireRule(!g.nexusGuildSecretLast,'Guild Secret Ally has lost its original shipment history.'); return; } requireRule(g.nexusCards?.cards && Array.isArray(history) && history.length > 0, 'Guild Secret Ally requires its original Nexus module and shipment history.'); const events = new Set(); for (const record of history) { requireRule(record && Object.keys(record).sort().join(',') === 'frame,kind,receipt,signature' && ['reserve','cross','homeworld'].includes(record.kind) && typeof record.frame === 'string' && record.receipt && record.signature === guildSecretSignature(record) && !events.has(record.receipt.event), 'Guild Secret Ally has a changed or duplicated shipment record.'); nexusRule(() => validateNexusGuildSecretAlly(g,record.receipt)); const frame = nexusRule(() => JSON.parse(record.frame)) as Record; requireRule(frame && typeof frame === 'object' && frame.guildSecretEvent === record.receipt.event && frame.player === record.receipt.owner && frame.turn === record.receipt.turn && !frame.nexusEvent && !frame.guildNexusEvent && !frame.noField && !frame.alliedNoField && !frame.source, 'Guild Secret Ally has changed its original shipment source.'); const destination = record.kind === 'homeworld' ? 'homeworld' : record.kind === 'cross' && frame.to === 'reserves' ? 'reserves' : territory(String(record.kind === 'cross' ? frame.to : frame.territory)).type; const quote = nexusRule(() => quoteNexusGuildSecretShipment(destination,frame.amount as number)); requireRule(frame.cost === quote.cost && frame.allyPayment === 0 && Number.isSafeInteger(frame.elite) && Number(frame.elite) >= 0 && Number(frame.elite) <= quote.physicalAmount, 'Guild Secret Ally has changed its original payment or typed allocation.'); events.add(record.receipt.event); } requireRule(JSON.stringify(g.nexusGuildSecretLast) === JSON.stringify({event:history.at(-1)!.receipt.event}), 'Guild Secret Ally has lost its latest shipment outcome.'); } function nexusRicheseOffer(g: Game, owner: string) { const p = g.players.find(p => p.id === owner); if (!p || g.nexusCards?.cards?.hands[owner] !== 'richese' || byFaction(g,'richese')) return null; let blocked: string | null = null; if (g.guildBetrayalPreview) blocked = 'This payment audit profile supports the original native shipment, not a Richese Secret Ally shipment overlay.'; else if (p.ally) blocked = 'Richese Secret Ally requires an unallied holder.'; else if (g.status !== 'playing' || g.phase !== 5 || g.active !== owner || p.shipped) blocked = 'Use Secret Ally for your unused reserve shipment before moving.'; else if (g.response || g.decision || g.truthtrance || g.phaseOpening || g.pendingKarama || g.pendingNullentropy || g.pendingTreacheryDiscard || g.nexusTraitorPending || g.nexusCards.phase?.stage === 'drawing') blocked = 'Finish the current interaction before declaring this shipment.'; else if (p.reserves < 1) blocked = 'You need physical forces in reserves.'; return {event:JSON.stringify(['nexusRichese',g.turn,owner]),blocked,maxForces:Math.min(5,p.reserves)}; } const RICHESE_CUNNING_ROSTER: Partial> = { atreides: true, harkonnen: true, emperor: true, fremen: true, beneGesserit: true, guild: true, richese: true, choam: true, }; function richeseCunningModeSupported(g: Game) { const mixed = mixedE3NexusProfile(g) || mixedE1E2NexusProfile(g) || (!!g.leaderSkills && (mixedE3NexusLeaderSkillsProfile(g) || mixedE1E2NexusLeaderSkillsProfile(g))); if (mixed) return !g.sandtrout; const skills = !!g.leaderSkills && (pairedNexusLeaderSkillsProfile(g) || singleE1E2NexusLeaderSkillsProfile(g)); return pairedDiscoveryNexusProfile(g) || singleE1E2DiscoveryNexusProfile(g) || (!!g.homeworlds && (skills || pairedNexusModulesProfile(g) || singleE1E2NexusProfile(g))) || (!g.homeworlds && (!g.leaderSkills || skills) && (!g.discoveryEnabled || skills) && !g.ecazTreachery && !g.sandtrout && g.players.every(player => RICHESE_CUNNING_ROSTER[player.faction] === true)); } function nexusRicheseCunningOffer(g: Game, owner: string) { const p = g.players.find(player => player.id === owner); if (!p || p.faction !== 'richese' || g.nexusCards?.cards?.hands[owner] !== 'richese') return null; let blocked: string | null = null; if (!richeseCunningModeSupported(g)) blocked = 'This two-token shipment supports classic factions, Richese and CHOAM without other optional modules.'; else if (p.ally) blocked = 'Nexus Cunning requires an unallied Richese holder.'; else if (g.status !== 'playing' || g.phase !== 5 || g.active !== owner || !shipmentAvailable(g, p)) blocked = 'Use Cunning for your unused shipment before movement.'; else if (nexusCleanPlayBlocked(g) || g.karamaShipping) blocked = 'Finish the current interaction or Karama shipment before using Cunning.'; else blocked = noFieldShipBlock(g, p); return { event: JSON.stringify(['nexusRicheseCunning', g.turn, owner, p.noFieldEvent]), blocked, }; } function richesePairSignature(record: NonNullable[number]) { return JSON.stringify([record.event, record.owner, record.turn, record.phase, record.tokenEvent, record.frame, record.stage]); } function currentRichesePairShipment(g: Game, shipment: PendingShipment) { const pair = shipment.richesePair; const record = g.nexusRicheseCunningHistory?.at(-1); requireRule(pair && record?.stage === 'pending' && record.event === pair.event && record.frame === JSON.stringify(shipment) && record.owner === shipment.player && record.turn === g.turn && record.phase === 5 && record.tokenEvent === shipment.noField?.event && record.signature === richesePairSignature(record) && g.nexusCards?.cards?.discard.includes('richese') && !shipment.nexusEvent && !shipment.guildNexusEvent && !shipment.guildSecretEvent && !shipment.alliedNoField && !shipment.source && !shipment.smuggler && !shipment.smugglerCompanion && !shipment.homeworldSources && !shipment.noFieldSkillProof && shipment.amount === 1 && shipment.elite === 0 && shipment.advisors === false && shipment.allyPayment === 0 && richeseCunningModeSupported(g), 'Richese Cunning lost its original two-token shipment or physical Nexus card.'); const p = getPlayer(g, shipment.player); requireRule(p.faction === 'richese' && !p.ally && p.noField && p.noFieldEvent === record.tokenEvent && !p.noField.deployed && g.status === 'playing' && g.phase === 5 && g.active === p.id && shipmentAvailable(g, p), 'Richese Cunning lost its unused native shipment.'); const cost = reserveShipmentCost({ faction: p.faction, halfRate: false }, territory(shipment.territory).type, 1); requireRule(shipment.cost === cost && Number.isSafeInteger(shipment.sector) && validGameLocation(g, shipment.territory, shipment.sector), 'Richese Cunning lost its original one-marker price or currently revealed destination.'); noFieldRule(() => validateRicheseNoFieldPair(p.noField!, { tokenId: shipment.noField!.tokenId, controller: p.id, location: { territory: shipment.territory, sector: shipment.sector }, }, pair.revealedTokenId, p.reserves)); return record; } function recordRichesePairShipment(g: Game, shipment: PendingShipment) { const p = getPlayer(g, shipment.player); const offer = nexusRicheseCunningOffer(g, p.id); requireRule(offer && !offer.blocked && offer.event === shipment.richesePair?.event, offer?.blocked ?? 'Richese Cunning is unavailable or stale.'); const record: NonNullable[number] = { event: offer.event, owner: p.id, turn: g.turn, phase: 5, tokenEvent: shipment.noField!.event, frame: JSON.stringify(shipment), stage: 'pending', signature: '', }; record.signature = richesePairSignature(record); g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!, p.id, g.players)); (g.nexusRicheseCunningHistory ??= []).push(record); g.nexusRicheseCunningLast = { event: record.event, stage: record.stage }; } function finishRichesePairShipment(g: Game, shipment: PendingShipment, stage: 'shipped' | 'stopped') { if (!shipment.richesePair) return; const record = currentRichesePairShipment(g, shipment); record.stage = stage; record.signature = richesePairSignature(record); g.nexusRicheseCunningLast = { event: record.event, stage }; } function richesePairIntegrity(g: Game) { const history = g.nexusRicheseCunningHistory; const pending = g.pendingShipment; if (history === undefined) { requireRule(!g.nexusRicheseCunningLast && !pending?.richesePair, 'Richese Cunning lost its saved shipment history.'); return; } requireRule(g.nexusCards?.cards && Array.isArray(history) && history.length > 0, 'Richese Cunning needs its physical Nexus module and history.'); const events = new Set(); for (const [index, record] of history.entries()) { requireRule(record && typeof record === 'object' && Object.keys(record).sort().join(',') === 'event,frame,owner,phase,signature,stage,tokenEvent,turn' && typeof record.owner === 'string' && typeof record.tokenEvent === 'string' && Number.isSafeInteger(record.turn) && record.turn > 0 && record.turn <= g.turn && record.phase === 5 && ['pending', 'shipped', 'stopped'].includes(record.stage) && typeof record.frame === 'string' && record.signature === richesePairSignature(record) && !events.has(record.event) && (record.stage !== 'pending' || index === history.length - 1) && record.event === JSON.stringify(['nexusRicheseCunning', record.turn, record.owner, record.tokenEvent]), 'Richese Cunning changed its original card, owner or shipment receipt.'); const frame = JSON.parse(record.frame) as PendingShipment; requireRule(frame && typeof frame === 'object' && !Array.isArray(frame) && Object.keys(frame).sort().join(',') === 'advisors,allyPayment,amount,cost,elite,noField,player,richesePair,sector,territory,turn' && frame.richesePair?.event === record.event && frame.player === record.owner && frame.noField?.event === record.tokenEvent && frame.turn === record.turn && typeof frame.noField.tokenId === 'string' && typeof frame.richesePair.revealedTokenId === 'string' && g.players.some(player => player.id === record.owner && player.faction === 'richese' && player.noField?.tokens.some(token => token.id === frame.noField?.tokenId) && player.noField?.tokens.some(token => token.id === frame.richesePair?.revealedTokenId)), 'Richese Cunning changed its two physical token identities.'); events.add(record.event); } const last = history.at(-1)!; requireRule(JSON.stringify(g.nexusRicheseCunningLast) === JSON.stringify({ event: last.event, stage: last.stage }) && (last.stage === 'pending') === !!pending?.richesePair, 'Richese Cunning lost or reopened its latest shipment.'); if (last.stage === 'pending') { requireRule(pending && pending.richesePair?.event === last.event, 'Richese Cunning lost its pending shipment.'); currentRichesePairShipment(g, pending); const responses = savedNoFieldResponses(g).filter(response => response.kind === 'richeseNoField'); const decisions = homeworldSavedDecisions(g).filter(decision => decision.kind === 'guildShipment'); requireRule(responses.length + decisions.length === 1, 'Richese Cunning lost its unique Karama response or Guild interception decision.'); for (const response of responses) requireRule(response.owner === pending.player && response.noFieldSkillProof === undefined, 'Richese Cunning changed its native No-Field response.'); for (const decision of decisions) validateGuildShipmentDecision(g, decision); } } function shipmentHalfRate(g: Game, p: Player) { return g.karamaShipping?.player === p.id || ((p.faction === 'guild' || byFaction(g,'guild')?.id === p.ally) && !(g.guildRateBlocked?.turn === g.turn && g.guildRateBlocked.player === p.id)); } function nexusRicheseSignature(record: NonNullable[number]) { return JSON.stringify([record.receipt.signature,record.stage,record.frame,record.halfRate]); } function currentNexusRicheseShipment(g: Game, shipment: PendingShipment) { const record = g.nexusRicheseHistory?.find(r => r.receipt.event === shipment.nexusEvent); requireRule(record?.stage === 'pending' && record.frame === JSON.stringify(shipment) && record.receipt.owner === shipment.player && record.receipt.turn === g.turn && !getPlayer(g,shipment.player).ally && record.signature === nexusRicheseSignature(record) && !shipment.noField && !shipment.alliedNoField && !shipment.source, 'Richese Secret Ally has lost its original physical shipment declaration.'); nexusRule(() => validateNexusRichese(g,record.receipt)); return record; } function recordNexusRicheseShipment(g: Game, shipment: PendingShipment) { const p = getPlayer(g,shipment.player); const receipt = nexusRule(() => createNexusRichese(g,p.id)); requireRule(shipment.nexusEvent === receipt.event, 'This Richese Secret Ally selection is stale.'); const record = {receipt,stage:'pending' as const,frame:JSON.stringify(shipment),halfRate:shipmentHalfRate(g,p),signature:''}; record.signature = nexusRicheseSignature(record); g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!,p.id,g.players)); (g.nexusRicheseHistory ??= []).push(record); g.nexusRicheseLast = {event:receipt.event,stage:'pending'}; } function finishNexusRicheseShipment(g: Game, shipment: PendingShipment, stage: 'shipped'|'stopped') { if (!shipment.nexusEvent) return; const record = currentNexusRicheseShipment(g,shipment); record.stage = stage; record.signature = nexusRicheseSignature(record); g.nexusRicheseLast = {event:record.receipt.event,stage}; } function nexusRicheseIntegrity(g: Game) { const history = g.nexusRicheseHistory, pending = g.pendingShipment; if (history === undefined) { requireRule(!g.nexusRicheseLast && !pending?.nexusEvent, 'Richese Secret Ally has lost its saved shipment history.'); return; } requireRule(g.nexusCards?.cards && Array.isArray(history) && history.length > 0, 'Richese Secret Ally requires its original Nexus module and history.'); const events = new Set(); for (const [index,record] of history.entries()) { requireRule(record && typeof record === 'object' && Object.keys(record).sort().join(',') === 'frame,halfRate,receipt,signature,stage' && ['pending','shipped','stopped'].includes(record.stage) && typeof record.frame === 'string' && typeof record.halfRate === 'boolean' && record.signature === nexusRicheseSignature(record) && !events.has(record.receipt?.event) && (record.stage !== 'pending' || index === history.length - 1), 'Richese Secret Ally has a changed or duplicated shipment record.'); nexusRule(() => validateNexusRichese(g,record.receipt)); const frame = nexusRule(() => JSON.parse(record.frame)) as PendingShipment; requireRule(frame && frame.nexusEvent === record.receipt.event && frame.player === record.receipt.owner && frame.turn === record.receipt.turn && !frame.noField && !frame.alliedNoField && !frame.source && Object.keys(frame).every(k => ['nexusEvent','turn','player','territory','sector','amount','elite','cost','allyPayment','advisors','homeworldSources'].includes(k)), 'Richese Secret Ally has changed its original shipment source.'); const quote = nexusRule(() => quoteNexusRicheseShipment({faction:getPlayer(g,frame.player).faction,halfRate:record.halfRate},territory(frame.territory).type,frame.amount)); requireRule(frame.cost === quote.cost && frame.allyPayment === 0 && Number.isSafeInteger(frame.elite) && frame.elite >= 0 && frame.elite <= frame.amount && typeof frame.advisors === 'boolean' && territory(frame.territory).sectors.includes(frame.sector), 'Richese Secret Ally has changed its original physical allocation or price.'); events.add(record.receipt.event); } const last = history.at(-1)!; requireRule(JSON.stringify(g.nexusRicheseLast) === JSON.stringify({event:last.receipt.event,stage:last.stage}) && (last.stage === 'pending') === !!pending?.nexusEvent, 'Richese Secret Ally has lost or reopened its latest shipment.'); if (last.stage === 'pending') { requireRule(pending && pending.nexusEvent === last.receipt.event && g.nexusCards.cards.discard.includes('richese'), 'Richese Secret Ally has lost its pending shipment or spent Nexus card.'); currentNexusRicheseShipment(g,pending); const decisions = homeworldSavedDecisions(g).filter(d => d.kind === 'guildShipment'); requireRule(decisions.length > 0, 'Richese Secret Ally has lost its Guild interception decision.'); for (const decision of decisions) validateGuildShipmentDecision(g,decision); } } function nexusMoritaniOffer(g: Game, owner: string, decision: Game['decision'] = g.decision) { const p = g.players.find(p => p.id === owner); if (!p || p.faction !== 'moritani' || g.nexusCards?.cards?.hands[p.id] !== 'moritani') return null; const tokens = g.moritaniTerror?.tokens.filter(t => t.status === 'available') ?? []; let blocked: string | null = null; if (p.ally) blocked = 'Nexus Cunning requires an unallied Moritani player.'; else if (g.status !== 'playing' || g.phase !== 8 || decision?.kind !== 'moritaniPlacement' || decision.player !== p.id) blocked = 'Use Cunning when placing a supply token during your Mentat opportunity.'; else if (g.response || g.truthtrance || g.phaseOpening || g.pendingKarama || g.pendingNullentropy || g.pendingTreacheryDiscard) blocked = 'Finish the current interaction before declaring this placement.'; else if (g.moritaniTerror?.placementTurn === g.turn) blocked = 'This turn’s placement opportunity is already used.'; else if (!tokens.length) blocked = 'No supply token is available. Cunning does not enhance ordinary relocation.'; return {event:JSON.stringify(['nexusMoritani',g.turn,p.id]),blocked,tokens, destinations:TERRITORIES.map(t => t.id)}; } function nexusMoritaniIntegrity(g: Game) { const history = g.nexusMoritaniHistory, pending = g.pendingMoritaniPlacement; if (history === undefined) { requireRule(!g.nexusMoritaniLast && !pending?.nexusEvent, 'Moritani Cunning has lost its saved placement history.'); } else { requireRule(g.nexusCards?.cards && Array.isArray(history) && history.length > 0, 'Moritani Cunning requires its original Nexus module and history.'); const events = new Set(); for (const [index,record] of history.entries()) { requireRule(record && typeof record === 'object' && Object.keys(record).sort().join(',') === 'before,frame,receipt,signature,stage' && ['pending','complete','canceled'].includes(record.stage) && typeof record.before === 'string' && typeof record.frame === 'string' && record.signature === nexusMoritaniRecordSignature(record) && !events.has(record.receipt?.event) && (record.stage !== 'pending' || index === history.length - 1), 'Moritani Cunning has a changed or duplicated placement record.'); nexusRule(() => validateNexusMoritani(g,record.receipt)); const before = nexusRule(() => JSON.parse(record.before)) as TerrorState; if (record.receipt.source === 'grumman') { const receipt = record.receipt; nexusRule(() => quoteNexusMoritaniGrummanPlacement(before, receipt.token, receipt.territory, receipt.prerequisite)); requireRule(record.stage === 'complete' && record.frame === JSON.stringify({ token: receipt.token, territory: receipt.territory, turn: receipt.turn, nexusEvent: receipt.event, sourceEvent: receipt.sourceEvent, prerequisite: receipt.prerequisite }), 'Moritani Cunning has changed its original Grumman addition source.'); if (receipt.turn === g.turn) requireRule(g.grummanCollection?.event === receipt.sourceEvent && g.grummanCollection.stage === 'complete' && g.grummanCollection.outcome === 'add' && g.grummanCollection.token === receipt.token && g.grummanCollection.territory === receipt.territory && g.grummanCollection.nexusEvent === receipt.event, 'Moritani Cunning has lost its completed current Grumman opportunity.'); } else { nexusRule(() => quoteNexusMoritaniPlacement(before,record.receipt.token,record.receipt.territory,record.receipt.turn)); requireRule(record.frame === JSON.stringify({token:record.receipt.token,territory:record.receipt.territory,turn:record.receipt.turn,nexusEvent:record.receipt.event}), 'Moritani Cunning has a changed original placement frame.'); } const original = before.tokens.find(t => t.id === record.receipt.token)!; const current = g.moritaniTerror?.tokens.find(t => t.id === record.receipt.token); requireRule(!current || current.kind === original.kind, 'Moritani Cunning has changed the original physical token face.'); events.add(record.receipt.event); } const last = history.at(-1)!; requireRule(JSON.stringify(g.nexusMoritaniLast) === JSON.stringify({event:last.receipt.event,stage:last.stage}) && (last.stage === 'pending') === !!pending?.nexusEvent, 'Moritani Cunning has lost or reopened its latest placement.'); if (last.stage === 'pending') { 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 => { const karama = context && 'pendingKarama' in context ? context.pendingKarama as Game['pendingKarama'] : null; return [context?.response,karama?.use.kind === 'cancel' ? karama.use.response : null]; }).filter(r => r?.kind === 'moritaniPlacement'); requireRule(pending && pending.nexusEvent === last.receipt.event && pending.token === last.receipt.token && pending.territory === last.receipt.territory && pending.turn === last.receipt.turn && g.turn === last.receipt.turn && g.phase === 8 && JSON.stringify(pending) === last.frame && JSON.stringify(g.moritaniTerror) === last.before && responses.length > 0 && responses.every(r => r!.owner === last.receipt.owner) && g.nexusCards.cards.discard.includes('moritani'), 'Moritani Cunning no longer matches its original token, target or response.'); } } if (g.nexusMoritaniLocations !== undefined) { requireRule(g.nexusMoritaniLocations && typeof g.nexusMoritaniLocations === 'object' && !Array.isArray(g.nexusMoritaniLocations), 'Moritani Cunning has an invalid live placement map.'); for (const [id,placement] of Object.entries(g.nexusMoritaniLocations)) { const token = g.moritaniTerror?.tokens.find(t => t.id === id); requireRule(placement && Object.keys(placement).sort().join(',') === 'event,territory' && token?.status === 'placed' && token.location === placement.territory && terrorLocationAllowed(g,id,placement.territory,placement.event), 'Moritani Cunning has a stale or changed live placement.'); } } for (const token of g.moritaniTerror?.tokens ?? []) { if (token.status === 'placed') requireRule(token.location && terrorLocationAllowed(g,token.id,token.location), 'The Terror token has no valid original placement for this territory.'); } const continuation = g.pendingTreacheryDiscard?.continuation; for (const entry of [g.pendingTerrorEntry, continuation?.kind === 'terrorDiscard' || continuation?.kind === 'terrorAtomics' ? continuation.entry : null]) if (entry) requireRule(terrorEntryLocationAllowed(g,entry), 'The Terror arrival has lost its original Cunning placement permission.'); } function recordNexusMoritani(g: Game, owner: string, token: string, to: string) { const receipt = nexusRule(() => createNexusMoritani(g,owner,token,to)); g.pendingMoritaniPlacement = {token,territory:to,turn:g.turn,nexusEvent:receipt.event}; const record = {receipt,stage:'pending' as const,before:JSON.stringify(g.moritaniTerror),frame:JSON.stringify(g.pendingMoritaniPlacement),signature:''}; record.signature = nexusMoritaniRecordSignature(record); g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!,owner,g.players)); (g.nexusMoritaniHistory ??= []).push(record); g.nexusMoritaniLast = {event:receipt.event,stage:record.stage}; } function finishNexusMoritani(g: Game, canceled: boolean) { const pending = g.pendingMoritaniPlacement; if (!pending?.nexusEvent) return; const record = g.nexusMoritaniHistory?.find(r => r.receipt.event === pending.nexusEvent); requireRule(record?.stage === 'pending' && record.frame === JSON.stringify(pending) && record.before === JSON.stringify(g.moritaniTerror), 'Moritani Cunning has lost its original placement outcome.'); record.stage = canceled ? 'canceled' : 'complete'; record.signature = nexusMoritaniRecordSignature(record); g.nexusMoritaniLast = {event:record.receipt.event,stage:record.stage}; } function nexusChoamSignature(record: NonNullable[number]) { return JSON.stringify([record.receipt.signature, record.stage, record.frame, record.parent]); } function nexusChoamParent(g: Game, pending: NonNullable) { return JSON.stringify({ movement: pending.movement ? g.pendingChoamMove : undefined, revival: pending.revival ? g.pendingRevival : undefined, storm: pending.storm ? g.stormResolution : undefined, mentat: pending.mentat ? g.choamMentatPending : undefined, kull: pending.effect === 'kull' ? { event: g.pendingKull?.event, signature: g.pendingKull?.signature, selection: g.pendingKull?.selection, } : undefined, }); } function nexusChoamIntegrity(g: Game) { const continuation = g.pendingTreacheryDiscard?.continuation; const controls = [g, g.pendingExchange, g.pendingNullentropy?.resume, g.pendingRicheseGift?.resume, g.pendingRichesePurchaseIncome?.resume, g.summonedWorm?.resume, continuation && 'resume' in continuation ? continuation.resume : null]; const responseList = (contexts: typeof controls) => contexts.flatMap(context => { const karama = context && 'pendingKarama' in context ? context.pendingKarama as Game['pendingKarama'] : null; return [context?.response, karama?.use.kind === 'cancel' ? karama.use.response : null]; }).filter((response): response is ResponseWindow => response?.kind === 'choamWorthless'); const transactions = [ { state: g, pending: g.pendingChoamWorthless, responses: responseList(controls) }, ...(g.pendingKull ? [{ state: kullSource(g, g.pendingKull), pending: g.pendingKull.worthless, responses: responseList([g.pendingKull.resume]) }] : []), ]; const declared = transactions.filter(transaction => transaction.pending?.nexusEvent); const history = g.nexusChoamHistory; if (history === undefined) { requireRule(!g.nexusChoamLast && !declared.length, 'CHOAM Cunning has lost its original saved history.'); return; } requireRule(g.nexusCards?.cards && Array.isArray(history) && history.length > 0, 'CHOAM Cunning history requires its original Nexus module.'); const events = new Set(); for (const [index, record] of history.entries()) { requireRule(record && typeof record === 'object' && Object.keys(record).sort().join(',') === 'frame,parent,receipt,signature,stage' && ['pending','canceled','complete','fizzled'].includes(record.stage) && typeof record.frame === 'string' && typeof record.parent === 'string' && record.signature === nexusChoamSignature(record) && !events.has(record.receipt?.event) && (record.stage !== 'pending' || index === history.length - 1), 'CHOAM Cunning has a changed or duplicated outcome record.'); nexusRule(() => validateNexusChoam(g,record.receipt)); events.add(record.receipt.event); } const last = history.at(-1)!; requireRule(JSON.stringify(g.nexusChoamLast) === JSON.stringify({event:last.receipt.event,stage:last.stage}), 'CHOAM Cunning has lost its latest completed or pending outcome.'); requireRule((last.stage === 'pending') === (declared.length > 0) && declared.every(transaction => transaction.pending?.nexusEvent === last.receipt.event), 'CHOAM Cunning has lost or reopened its declared effect.'); if (last.stage === 'pending') { for (const { state, pending, responses } of declared) { requireRule(pending && pending.owner === last.receipt.owner && pending.card === last.receipt.card && pending.effect === last.receipt.effect && g.turn === last.receipt.turn && g.phase === last.receipt.phase && JSON.stringify(pending) === last.frame && nexusChoamParent(state,pending) === last.parent && responses.length > 0 && g.nexusCards.cards.discard.includes('choam'), 'CHOAM Cunning no longer matches its original card, selected effect or response.'); for (const response of responses) { try { quoteChoamWorthlessCancellation(state,response); } catch(error) { if (error instanceof ChoamWorthlessCancellationError) throw new RuleError(error.message); throw error; } } } } } function recordNexusChoam(g: Game, p: Player, card: Card, effect: NexusChoamEffect) { const receipt = nexusRule(() => createNexusChoam(g,p.id,g.phase,card.id,effect)); const record = {receipt,stage:'pending' as const,frame:JSON.stringify(g.pendingChoamWorthless),parent:nexusChoamParent(g,g.pendingChoamWorthless!),signature:''}; record.signature = nexusChoamSignature(record); g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!,p.id,g.players)); (g.nexusChoamHistory ??= []).push(record); g.nexusChoamLast = {event:receipt.event,stage:record.stage}; } function finishNexusChoam(g: Game, pending: NonNullable, stage: 'canceled' | 'complete' | 'fizzled') { if (!pending.nexusEvent) return; const record = g.nexusChoamHistory?.find(record => record.receipt.event === pending.nexusEvent); requireRule(record?.stage === 'pending' && record.frame === JSON.stringify(pending) && record.parent === nexusChoamParent(g,pending), 'CHOAM Cunning has lost its original declared effect.'); record.stage = stage; record.signature = nexusChoamSignature(record); g.nexusChoamLast = {event:record.receipt.event,stage}; } function choamPowerPlays(g: Game, p: Player) { const printed = p.hand.filter(card => card.kind === 'worthless' && Object.values(CHOAM_NEXUS_EFFECTS).includes(card.name as typeof CHOAM_NEXUS_EFFECTS[NexusChoamEffect]) && !choamWorthlessBlocked(g,card.id)) .map(card => ({source:'printed' as const,card,effect:Object.entries(CHOAM_NEXUS_EFFECTS).find(([,name]) => name === card.name)![0] as NexusChoamEffect, blocked:card.name === 'Kull Wahad' ? 'Kull Wahad is reactive: use the Kull opportunity when another player attempts Karama in the explicit preview.' : null})); if (p.faction !== 'choam' || p.ally || g.nexusCards?.cards?.hands[p.id] !== 'choam') return printed; return [...printed, ...p.hand.flatMap(card => (Object.keys(CHOAM_NEXUS_EFFECTS) as NexusChoamEffect[]).map(effect => { let blocked: string | null = null; if (effect === 'kull') blocked = 'Kull Wahad via CHOAM Cunning is unavailable in this printed-card preview.'; else if (g.status !== 'playing' || g.truthtrance || g.response || g.phaseOpening || g.pendingKarama || g.pendingNullentropy || g.pendingTreacheryDiscard || g.choamMarket) blocked = 'Finish the current interaction before declaring CHOAM Cunning.'; else if (choamWorthlessBlocked(g,card.id)) blocked = 'This card’s special-effect use is blocked for this phase.'; else if (giftReserved(g,p.id,card.id)) blocked = 'This physical card is reserved for a pending gift.'; else if (({kulon:5,laLaLa:4,gamont:8,baliset:5,jubba:0} as const)[effect] !== g.phase) blocked = 'Use this special effect in its normal phase.'; else if (g.decision && !(g.decision.player === p.id && ['choamStorm','choamFreeRevival','choamMovement','choamMentat'].includes(g.decision.kind))) blocked = 'Finish the current decision before declaring this effect.'; else if (effect === 'kulon' && (g.active !== p.id || p.moved >= movesAllowed(g,p))) blocked = 'Use Kulon before an available move on your own turn.'; else if (effect === 'jubba' && !(g.decision?.kind === 'choamStorm' && g.decision.player === p.id)) blocked = 'Use Jubba Cloak in your moving-storm response.'; return {source:'nexus' as const,card,effect,event:JSON.stringify(['nexusChoam',g.turn,g.phase,p.id,card.id,effect]),blocked}; }))]; } 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 nexus = action.nexus !== undefined; const card = p.hand.find((c) => c.id === action.card && (nexus || c.kind === 'worthless')); requireRule( card && !choamWorthlessBlocked(g, card.id), 'Choose an owned Worthless card whose effect is not blocked this phase.', ); let effectName = card.name; if (nexus) { requireRule(Object.keys(action).every(key => ['type','mode','card','nexus','effect','target','territory','amount','from','elite'].includes(key)), 'Choose one physical card, special effect and its current target.'); const play = choamPowerPlays(g,p).find(play => play.source === 'nexus' && play.card.id === card.id && play.effect === action.effect && 'event' in play && play.event === action.nexus); requireRule(play && !play.blocked, play?.blocked ?? 'Choose your current CHOAM Nexus Cunning effect.'); effectName = CHOAM_NEXUS_EFFECTS[play.effect]; } requireRule(!giftReserved(g,p.id,card.id), 'This physical card is reserved for a pending gift.'); let effect: 'kulon' | 'laLaLa' | 'gamont' | 'baliset' | 'jubba'; let target: string | undefined; let key: string | undefined; let elite: number | undefined; let noFieldEvent: string | undefined; if (effectName === 'Kulon') { 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 (effectName === '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 (effectName === '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 (effectName === '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( validGameLocation(g, 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( effectName === '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 } : {}), ...(nexus ? {nexusEvent:action.nexus as string} : {}), }; if (nexus) recordNexusChoam(g,p,card,effect); 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: effectName, passed: [], }; log(g, nexus ? `${p.name} spent CHOAM Nexus Cunning and declared ${effectName}. Its chosen Treachery card stays private and is discarded only if the effect occurs; Karama may prevent this native advantage.` : `${p.name} declared ${effectName} for its special effect.`); } function expansionSpecialKaramaRule(calculate: () => T): T { try { return calculate(); } catch (error) { if (error instanceof EcazSpecialKaramaError || error instanceof MoritaniSpecialKaramaError) throw new RuleError(error.message); throw error; } } function actualSpecialKaramaCards(g: Game, p: Player) { return p.hand.filter(card => card.kind === 'special' && card.effect === 'karama' && !karamaSpendingBlock(g, p, card)); } function ecazSpecialKaramaContext(g: Game, p: Player) { const b = g.battle; return { status: g.status, phase: g.phase, advanced: g.advanced, owner: p, cards: actualSpecialKaramaCards(g, p), battle: b ? { event: b.event, attacker: b.attacker, defender: b.defender, revealed: b.revealed, ownPlan: b.plans[p.id], declaration: b.ecazSpecialKarama } : null }; } function ecazSpecialKaramaView(g: Game, p: Player) { if (!g.advanced || p.faction !== 'ecaz' || !g.battle) return null; const quote = quoteEcazSpecialKarama(ecazSpecialKaramaContext(g, p)); if (!quote.declared && (g.response || g.decision || g.battle.preparation || (g.battle.preLeader && !g.battle.preLeader.closed))) quote.blocked = 'Finish the current battle preparation or response before declaring.'; if (!quote.blocked && !findLegalBattlePlan(g, p, { unarmed: true })) quote.blocked = 'Existing battle commitments leave no legal unarmed plan.'; return quote; } function currentMoritaniSpecialKaramaQuote(g: Game) { const pending = g.moritaniSpecialKarama; const context = g.lastBattleContext; if (!pending || pending.stage !== 'offer' || !context || context.event !== pending.source.event || context.turn !== g.turn) return null; const owner = getPlayer(g, pending.source.owner); const winner = getPlayer(g, pending.source.opponent); const cards = pending.cards.map(id => winner.hand.find(card => card.id === id)); requireRule(cards.every(card => !!card), 'The public played cards changed before Moritani’s choice.'); return expansionSpecialKaramaRule(() => quoteMoritaniSpecialKarama({ advanced: g.advanced, phase: g.phase, battlePresent: !!g.battle, turn: g.turn, owner, used: !!owner.specialKaramaUsed, battle: context, karamas: actualSpecialKaramaCards(g, owner), playedCards: cards as Card[], heldPlayedIds: pending.cards, keepableIds: pending.cards, })); } function resumeMoritaniSpecialKarama(g: Game) { const pending = g.moritaniSpecialKarama!; requireRule(g.lastBattleContext?.event === pending.source.event && g.lastBattleContext.winner === pending.source.opponent, 'Resume the original Moritani winner-card opportunity.'); pending.stage = 'done'; g.decision = null; if (pending.optional.length) g.decision = { kind: 'battleCards', player: pending.source.opponent, territory: pending.source.territory, cards: [...pending.optional] }; else finishBattle(g); } /** 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; nexusEvent?: string } | { kind: 'ixians'; route: string[]; collect: boolean; sandmasterPiles?: string[] } | { kind: 'tleilaxu'; target: string; revival: PendingRevival } | { kind: 'fremen'; territory: string } | { kind: 'atreides'; target: string; battle: Pick; } | { kind: 'guild'; target: string; shipment: PendingShipment } | { kind: 'guildHomeworld'; target: string; shipment: PendingHomeworldShipment } | { kind: 'emperorForces'; amount: number; elite: number } | { kind: 'emperorLeader'; leader: string } | { kind: 'harkonnen'; target: string; amount: number } | { kind: 'ecaz'; event: string } | { kind: 'moritani'; source: MoritaniSpecialKaramaSource; keep: string[]; discard: string[] } ); /** 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); requireRule(action.emperorNexus === undefined || p.faction === 'richese', 'Emperor Nexus retention is only available on the original Richese special purchase.'); // 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 && canUseAsKaramaRole(g, p, c), ); requireRule(card, 'Choose a Karama card in your hand.'); const activationBlock = karamaSpendingBlock(g, p, card); requireRule(!activationBlock, activationBlock ?? 'This Karama is committed elsewhere.'); const base = { owner: p.id, card: card.id, turn: g.turn, phase: g.phase }; if (p.faction === 'ecaz') { requireRule(!g.response && !g.decision && !g.battle?.preparation && (!g.battle?.preLeader || g.battle.preLeader.closed), 'Finish battle preparation before declaring the Ecaz special Karama.'); const selection = expansionSpecialKaramaRule(() => quoteEcazSpecialKaramaDeclaration( ecazSpecialKaramaContext(g, p), { event: stringField(action.event), card: card.id })); requireRule(!!findLegalBattlePlan(g, p, { unarmed: true }), 'Your existing Voice, inspection or Truthtrance commitments leave no legal unarmed plan.'); return { ...base, kind: 'ecaz', event: selection.event }; } if (p.faction === 'moritani') { const quote = currentMoritaniSpecialKaramaQuote(g); requireRule(quote && g.decision?.kind === 'moritaniSpecialKarama' && g.decision.player === p.id, 'Use the current Moritani post-loss card opportunity.'); const choice = expansionSpecialKaramaRule(() => validateMoritaniSpecialKaramaChoice( quote, g.moritaniSpecialKarama!.source, p.id, action)); return { ...base, kind: 'moritani', source: choice.source, keep: choice.keep, discard: choice.discard }; } 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), 'The final exchanged hand must not exceed its hand limit.', ); 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(action.emperorNexus === undefined || action.emperorNexus === true, 'Choose the joint Emperor Nexus purchase explicitly, without coercion.'); if (action.emperorNexus === true) { const quote = nexusRule(() => quoteEmperorNexusSpecialPurchase(g, p.id)); requireRule(!quote.blocked, quote.blocked ?? 'This Emperor Nexus purchase is unavailable.'); requireRule(typeof action.nexusEvent === 'string' && action.nexusEvent === quote.event && Object.keys(action).sort().join(',') === 'acquire,card,emperorNexus,mode,nexusEvent,type', 'Use only the current original special-purchase event and exact joint costs.'); return { ...base, kind: 'richese', acquire, nexusEvent: quote.event }; } requireRule(action.nexusEvent === undefined, 'A Nexus event requires the explicit joint Emperor Nexus purchase.'); 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) => canUseAsKaramaRole(g, p, c) && !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); const sandmasterPiles = nexusRule(() => validateSandmasterStronghold( g, p.id, route, action.sandmasterPiles, action.collect !== false)); return { ...base, kind: 'ixians', route: [...route], collect: action.collect !== false, ...(action.sandmasterPiles !== undefined ? { sandmasterPiles } : {}), }; } 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; const homeworldBlock = homeworldRevivalKaramaBlock(g, target); requireRule( !homeworldBlock, homeworldBlock ?? '', ); 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( !battleNoFieldPlan(g, 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') { if (g.decision?.kind === 'homeworldShipmentGuild') { requireRule(!g.response && g.decision.player === p.id, 'Wait for your Homeworld shipment interception decision.'); homeworldShipmentIntegrity(g); const shipment = g.pendingHomeworldShipment!; requireRule(action.target === undefined || action.target === shipment.player, 'Stop the player who declared this Homeworld shipment.'); return {...base, kind: 'guildHomeworld', target: shipment.player, shipment: structuredClone(shipment)}; } 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, g.advanced), '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.'); const physical = physicalTreacheryCards(g); requireRule([...p.hand, ...target.hand].every(held => physical.filter(current => current.id === held.id).length === 1), 'The Harkonnen exchange requires distinct physical cards in their original custody.'); 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 'ecaz': return { ...action, event: intent.event }; case 'moritani': return { ...action, event: intent.source.event, keep: [...intent.keep], discard: [...intent.discard] }; case 'richese': return { ...action, acquire: intent.acquire, ...(intent.nexusEvent ? { emperorNexus: true, nexusEvent: intent.nexusEvent } : {}) }; case 'choam': return { ...action, cards: [...intent.cards] }; case 'ixians': return { ...action, route: [...intent.route], collect: intent.collect, ...(intent.sandmasterPiles !== undefined ? { sandmasterPiles: [...intent.sandmasterPiles] } : {}) }; case 'tleilaxu': case 'atreides': case 'guild': case 'guildHomeworld': 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 === 'guildHomeworld' && prepared.kind === 'guildHomeworld') requireRule(intent.target === prepared.target && sameDeclaration(intent.shipment, prepared.shipment), 'The original Homeworld 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.', ); if (intent.kind === 'ecaz' && prepared.kind === 'ecaz') requireRule(intent.event === prepared.event, 'The Ecaz battle event changed before execution.'); if (intent.kind === 'moritani' && prepared.kind === 'moritani') requireRule(sameDeclaration(intent.source, prepared.source) && JSON.stringify(intent.keep) === JSON.stringify(prepared.keep) && JSON.stringify(intent.discard) === JSON.stringify(prepared.discard), 'The original Moritani card selection changed before execution.'); if (intent.kind === 'richese' && prepared.kind === 'richese') requireRule(intent.nexusEvent === prepared.nexusEvent, 'The original Emperor Nexus special-purchase event changed before execution.'); const p = getPlayer(g, intent.owner); const card = p.hand.find((candidate) => candidate.id === intent.card)!; const converted = card.effect === 'truthtrance'; if (converted) log( g, `${p.name} used the physical Truthtrance as Karama for their special faction power.`, ); if (intent.kind === 'ecaz') { discard(g, p, card.id); p.specialKaramaUsed = true; g.battle!.ecazSpecialKarama = { owner: p.id, card: card.id, event: intent.event }; log(g, `${p.name} declared the once-game unarmed Ecaz special Karama. The revealed disc-value difference adds provisional virtual dial strength, without additional physical losses.`, { faction: 'ecaz', name: 'Special Karama' }); } else if (intent.kind === 'moritani') { const pending = g.moritaniSpecialKarama!; discard(g, p, card.id); p.specialKaramaUsed = true; const winner = getPlayer(g, intent.source.opponent); pending.optional = pending.cards.filter(id => !intent.keep.includes(id) && !intent.discard.includes(id)); const entries = intent.discard.map(id => ({ card: discard(g, winner, id, cleanupDiscardRole(g, winner, id)), discardedBy: winner.id, publicFace: true })); log(g, `${p.name} used special Karama to force ${intent.keep.length} played card(s) kept and ${intent.discard.length} discarded. Mandatory named-card disposal remains the provisional precedence.`, { faction: 'moritani', name: 'Special Karama' }); if (entries.length) { pending.stage = 'discard'; g.decision = null; stageTreacheryDiscard(g, 'battle:winner', entries, { kind: 'battleCleanup', event: pending.source.event, combatants: [...g.lastBattle], territory: pending.source.territory, player: winner.id, source: 'winner', played: [...intent.discard], kept: [] }); } else resumeMoritaniSpecialKarama(g); } else if (intent.kind === 'richese') { const selected = g.richeseCache!.findIndex((c) => c.id === intent.acquire); if (intent.nexusEvent) { const receipt = nexusRule(() => createEmperorNexusSpecialPurchase(g, p.id, card.id, intent.acquire)); requireRule(receipt.event === intent.nexusEvent, 'The original joint special-purchase source changed before disposal.'); g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!, p.id, g.players)); (g.nexusEmperorPurchaseHistory ??= []).push(receipt); (g.nexusEmperorPurchaseEvents ??= []).push(receipt.event); } discard(g, p, card.id); if (!intent.nexusEvent) p.spice -= 3; p.hand.push(...g.richeseCache!.splice(selected, 1)); p.specialKaramaUsed = true; log( g, intent.nexusEvent ? `${p.name} spent Karama and Emperor Nexus after proving three own spice, retained it and acquired one privately chosen Richese cache card. No bank payment, reimbursement or extra bonus is created. Provisional purchase composition; the original once-use still applies.` : `${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 = { ...(g.homeworldOccupationPreview ? { event: crypto.randomUUID(), buyer: p.id } : {}), 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, ...(intent.sandmasterPiles !== undefined ? { sandmasterPiles: intent.sandmasterPiles } : {}), }); } else if (intent.kind === 'tleilaxu') { const target = intent.target; discard(g, p, card.id); p.specialKaramaUsed = true; g.revivalPrevention = { player: target, turn: g.turn }; if (g.pendingRevival?.nexusEmperorEvent) finishNexusEmperorRevival(g, g.pendingRevival, 'denied'); 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, event: crypto.randomUUID(), 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, 'summoned'); } 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 === 'guildHomeworld') { const shipper = getPlayer(g, intent.target); discard(g, p, card.id); p.specialKaramaUsed = true; if (g.pendingHomeworldShipment?.guildNexusEvent) finishGuildCunningShipment(g,shipper.id,'homeworld',g.pendingHomeworldShipment,'stopped'); shipper.shipped = true; g.pendingHomeworldShipment = null; g.decision = null; finishShipmentPromises(g, shipper, null); log(g, `${p.name} used special Karama to stop ${shipper.name}’s interplanetary shipment. No forces arrive or spice is paid; their shipment is used and movement remains available.`); } 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; } if (g.pendingShipment?.guildNexusEvent) finishGuildCunningShipment(g,shipper.id,'reserve',g.pendingShipment,'stopped'); if (g.pendingShipment) finishNexusRicheseShipment(g,g.pendingShipment,'stopped'); if (g.pendingShipment) finishRichesePairShipment(g, g.pendingShipment, 'stopped'); 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; addNativeReserves(g, p, n, elite); p.tanks -= n; if (p.elites) { p.elites.tanks -= 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); recordHarkonnenInspection(g, p.id, target.id, 'draw', 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) { const intent = prepareSpecialKaramaIntent(g, p.id, action); const card = p.hand.find(card => card.id === intent.card)!; if (!beginKullAttempt(g, p, card, { kind: 'special', use: intent })) executeSpecialKaramaIntent(g, intent); } /** 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) => canUseAsKaramaRole(g, p, c) && !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; } } const nexus = nexusRule(() => quoteEmperorNexusSpecialPurchase(g, p.id)); return { cards, karamas, blocked: cards.length ? null : blocked, payee: byFaction(g, 'emperor')?.id ?? null, emperorNexus: { ...nexus, canUse: !nexus.blocked, policy: 'Provisional purchase composition: spend the original Karama and Emperor Nexus together, after proving three own uncommitted spice. Keep that spice; no bank payment, reimbursement or fake auction is created. Native capacity, once-use, reserved cards and enclosing continuation remain unchanged.' }, }; } type CompletedMovement = Pick< MovementOrder, 'player' | 'origin' | 'origins' | 'total' | 'to' | 'sector' | 'elite' > & { noField: boolean }; type MovementOrder = { sandmaster?: SandmasterMovement; ambassadorSandmaster?: FremenAmbassadorMove['ambassadorSandmaster']; source?: 'ambassador'; ambassadorEvent?: string; player: string; group: [string, number][]; eliteGroup: Record; elite: number; origin: string; origins?: string[]; planetologist?: PlanetologistMovement; total: number; to: string; sector: number; advisors: boolean; wantsFighters: boolean; lockedTurn?: number; noField?: { tokenId: string; event: string; from: string }; ornithopterEvent?: string; ornithopterRange?: boolean; discoveryFlight?: DiscoveryFlightReceipt; }; 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 { state = migrateAtomicsTargets(state); state = migrateFremenNexusRevival(state); if (state.spiceBankerIncomePreview) { const keysBySource: Record = { bid: ['type','amount','allyPayment'], passBid: ['type'], ship: ['type','territory','sector','amount','elite','allyPayment','smuggler'], guildShip: ['type','from','forces','eliteForces','territory','sector','amount','elite','allyPayment'], revive: ['type','amount','elite'], reviveLeader: ['type','leader'], reviveKwisatz: ['type'], emperorRevival: ['type','amount','elite'], passResponse: ['type'], traitorCall: ['type','call','leader'], }; const allowed = action.type === 'ship' && action.noField !== undefined && getPlayer(state, id).faction === 'richese' ? ['type','territory','sector','amount','elite','allyPayment','smuggler','noField','event'] : action.type === 'decision' && state.decision?.kind === 'auctionPayment' ? ['type','karama','card'] : keysBySource[action.type]; requireRule(!allowed || Object.keys(action).every(key => allowed.includes(key)), 'Use only the original native payment fields; Spice Banker income is automatic.'); requireRule(!Object.keys(action).some(key => ['actor','bankerIncome','paymentSource','spiceBankerIncome','bankLegs','trainer'].includes(key)), 'Spice Banker payment authority comes only from the original native transaction.'); } harkonnenNexusBetrayalIntegrity(state); ixianNexusBetrayalIntegrity(state); ixianNexusReplacementIntegrity(state); guildBetrayalIntegrity(state); state = migratePrintedKullCounter(state); richeseBetrayalIntegrity(state); nexusEmperorPaymentIntegrity(state); nexusEmperorSellerIntegrity(state); nexusEmperorAmbassadorIntegrity(state); kullIntegrity(state); advancedPreviewIntegrity(state); harkonnenExchangeIntegrity(state); validateEcazLoyalty(state); ecazTreacheryIntegrity(state); leaderSkillsIntegrity(state); moritaniExtortionIntegrity(state); guildRateIntegrity(state); arrivalOverlapIntegrity(state); discoveryIntegrity(state); greatMakerIntegrity(state); nexusChoamTradeIntegrity(state); nexusChoamInspectionIntegrity(state); nexusMoritaniRetentionIntegrity(state); nexusChoamBetrayalIntegrity(state); nexusEcazInquiryIntegrity(state); nexusEcazDukeIntegrity(state); richesePairIntegrity(state); marketGholaIntegrity(state); homeworldRule(() => homeworldGameIntegrity(state)); homeworldBattleLossIntegrity(state); homeworldSubstitutionIntegrity(state); homeworldDefenseIntegrity(state); homeworldShipmentIntegrity(state); karamaConversionIntegrity(state); treacheryDiscardIntegrity(state); shipmentPromiseIntegrity(state); saphoMovementIntegrity(state); ambassadorRelocationIntegrity(state); ecazCollectionIntegrity(state); ecazAllianceIntegrity(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); if (state.pendingTreacheryDiscard.reaction && ['semutaPass', 'semutaCommit', 'semutaSelect'].includes(action?.type)) { const g = structuredClone(state); applySemutaReaction(g, id, action); treacheryDiscardIntegrity(g); return g.pendingTreacheryDiscard?.reaction ? g : normalizeAutomaticGame(g); } requireRule( action?.type === 'advanceBots', 'Finish the committed discard continuation before another game action.', ); return normalizeAutomaticGame(state); } const g = applyActionInner(state, id, action); harkonnenNexusBetrayalIntegrity(g); if (g.pendingNexusHarkonnenBetrayal) return g; ixianNexusBetrayalIntegrity(g); if (g.pendingNexusIxianBetrayal) return g; ixianNexusReplacementIntegrity(g); if (g.pendingNexusIxianReplacement) return g; if (g.pendingGuildBetrayal) {guildBetrayalIntegrity(g); return g;} if (g.pendingRicheseBetrayal) {richeseBetrayalIntegrity(g);return g;} if (g.pendingNexusEmperorPayment || g.pendingNexusEmperorSellerPurchase || g.pendingAmbassador?.nexusPurchase) { finishActionContinuations(g); nexusEmperorPaymentIntegrity(g); nexusEmperorSellerIntegrity(g); nexusEmperorAmbassadorIntegrity(g); return g; } if (g.pendingKull) { settleAutomaticContinuations(g); if (g.pendingKull) { kullIntegrity(g); marketGholaIntegrity(g); return g; } } finishLeaderSkillCustody(g, state); observeOccupation(g); homeworldRule(() => homeworldGameIntegrity(g)); homeworldBattleLossIntegrity(g); homeworldSubstitutionIntegrity(g); homeworldDefenseIntegrity(g); homeworldShipmentIntegrity(g); karamaConversionIntegrity(g); ornithopterIntegrity(g); lateDefenseIntegrity(g); battleSpecialWeaponIntegrity(g); saphoAggressorIntegrity(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); if (g.pendingTreacheryDiscard?.reaction) { treacheryDiscardIntegrity(g); return g; } harkonnenExchangeIntegrity(g); finishLeaderSkillCustody(g, state); leaderSkillsIntegrity(g); reconcileBattlePromises(g, { actor: id, action }); reconcileShipmentPromises(g, { actor: id, action }); settleAutomaticContinuations(g); observeOccupation(g); discoveryIntegrity(g); greatMakerIntegrity(g); nexusChoamTradeIntegrity(g); nexusChoamInspectionIntegrity(g); nexusMoritaniRetentionIntegrity(g); nexusChoamBetrayalIntegrity(g); nexusEcazInquiryIntegrity(g); nexusEcazDukeIntegrity(g); richesePairIntegrity(g); richeseBetrayalIntegrity(g); nexusEmperorPaymentIntegrity(g); nexusEmperorSellerIntegrity(g); nexusEmperorAmbassadorIntegrity(g); marketGholaIntegrity(g); homeworldRule(() => homeworldGameIntegrity(g)); homeworldBattleLossIntegrity(g); homeworldSubstitutionIntegrity(g); homeworldDefenseIntegrity(g); homeworldShipmentIntegrity(g); harkonnenExchangeIntegrity(g); moritaniExtortionIntegrity(g); guildRateIntegrity(g); arrivalOverlapIntegrity(g); guildBetrayalIntegrity(g); harkonnenNexusBetrayalIntegrity(g); return g; } function finishActionContinuations(g: Game) { if (g.pendingNexusHarkonnenBetrayal) return; if (g.pendingNexusIxianBetrayal) return; if (g.pendingGuildBetrayal) return; if (g.pendingRicheseBetrayal) return; if (g.pendingKull) return; finishLeaderSkillCustody(g); finishMandatorySkillVisibility(g); if (g.pendingNullentropy) return; // Losing cleanup may finish casualties and produce the winner's mandatory // discard. Retire that next physical batch before exposing optional choices. for (let i = 0; g.pendingTreacheryDiscard && !g.pendingTreacheryDiscard.reaction && i < 16; i++) finishTreacheryDiscard(g); if (g.pendingTreacheryDiscard?.reaction) return; requireRule(!g.pendingTreacheryDiscard, 'The automatic discard chain did not finish.'); if (g.pendingNexusEmperorPayment) return; if (g.pendingNexusEmperorSellerPurchase || g.pendingAmbassador?.nexusPurchase) return; if (pendingNexusTraitors(g)) return; observeOccupation(g); resumeHomeworldRevivalReturn(g); resumeHomeworldVictoryReturn(g); resumeMarketGhola(g); resumeHomeworldOccupiedIncome(g); resumeGrummanCollection(g); completeOccupiedFremenCollection(g); if (!g.truthtrance && !g.decision && !g.response) advanceSetup(g); if (g.status === 'playing' && g.leaderSkills && !g.decision && !g.response && !g.truthtrance && !g.phaseOpening) { const owner = g.order.find((id) => g.leaderSkills!.offers[id]); if (owner) g.decision = {kind:'leaderSkillRevival',player:owner,event:g.leaderSkills.offers[owner].event}; } 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); if (g.status === 'playing' && g.phase === 8 && g.moritaniExtortion?.stage === 'reserved' && g.moritaniTerror?.placementTurn === g.turn && !g.decision && !g.response && !g.truthtrance && !g.phaseOpening && !g.pendingKarama && !g.pendingTerrorEntry && !g.pendingAmbassador) finishMoritaniPlacement(g); } function settleAutomaticContinuations(g: Game) { if (g.pendingNexusHarkonnenBetrayal) return; if (g.pendingNexusIxianBetrayal) return; if (g.pendingNexusIxianReplacement) return; if (g.pendingGuildBetrayal) return; if (g.pendingRicheseBetrayal) return; if (g.pendingNexusEmperorPayment) return; if (g.pendingNexusEmperorSellerPurchase || g.pendingAmbassador?.nexusPurchase) return; if (g.pendingKull?.stage === 'offer') return; if (pendingNexusTraitors(g)) return; if (g.pendingNullentropy) return; for (let iteration = 0; iteration < 128; iteration++) { if (g.pendingNexusHarkonnenBetrayal) return; if (g.pendingNexusIxianBetrayal) return; if (g.pendingGuildBetrayal) return; if (g.pendingNexusIxianReplacement) return; if (g.pendingRicheseBetrayal) return; if (g.pendingNexusEmperorPayment) return; if (g.pendingNexusEmperorSellerPurchase || g.pendingAmbassador?.nexusPurchase) return; syncTupileSlots(g); prepareTupileCleanup(g); if (g.pendingHomeworldTupileCleanup || tupileSourceBlock(g)) return; if (g.truthtrance || g.phaseOpening || g.status === 'finished') return; if (g.pendingArrivalReaction?.length && !g.response && !g.decision && !g.pendingTerrorEntry && !g.pendingAmbassador && !g.pendingArrivalOverlap) { const deferred = g.pendingArrivalReaction.shift()!; if (!g.pendingArrivalReaction.length) g.pendingArrivalReaction = null; const entrant = g.players.find(p => p.id === deferred.entrant); requireRule(!!entrant, 'The deferred arrival entry has lost its entrant.'); openTerritoryEntry(g, entrant!, deferred.territory, deferred.sector, deferred.amount, deferred.elite, deferred.cause, deferred.resume); continue; } const response = g.response; const before = JSON.stringify(g); const bonus = g.pendingHomeworldOccupiedBonus; if (bonus && g.decision?.kind === 'homeworldOccupiedBonus') { const authority = quoteOccupiedBiddingAuthority(g, 'harkonnenBonus'); const saved = JSON.parse(bonus.proof) as { occupation: string; producer: string }; if (!authority.blocked && !authority.bonusRecipients.length && saved.occupation === JSON.stringify(quoteStableHomeworldOccupation(g, 'giedi_prime')) && saved.producer === occupiedProducerProof(g, 'bonus', bonus.response)) { if (bonus.response.source === 'ambassador') validateAmbassadorPurchaseResponse(g, bonus.response); else currentAuctionContinuationQuote(g, { kind: 'cancel', response: bonus.response }); g.pendingHomeworldOccupiedBonus = null; g.decision = null; log(g, 'The original purchase produced no occupied bonus card: its actual recipients have no hand space or no drawable stock remains.', { faction: 'harkonnen', name: 'Occupied purchase bonus' }); if (bonus.response.source === 'ambassador') finishAmbassador(g); else nextAuction(g); continue; } } if (g.pendingHomeworldOccupiedPercentage || g.pendingHomeworldOccupiedBonus && !g.decision) return; if (response?.kind === 'ixAuction' && g.homeworldOccupationPreview && quoteOccupiedBiddingAuthority(g, 'ixAuction').blocked) return; 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) && (emperorBetrayalWindow(g) || bgBetrayalWindow(g) || responseCancelCards(g, p, response).length > 0), ) ) return; finishResponse(g, false); } else if (!finishAutomaticDecision(g)) return; finishActionContinuations(g); if (g.pendingNexusHarkonnenBetrayal) return; if (g.pendingNexusIxianBetrayal) return; if (g.pendingGuildBetrayal) return; if (g.pendingRicheseBetrayal) return; if (g.pendingNexusEmperorPayment) return; if (g.pendingNexusEmperorSellerPurchase || g.pendingAmbassador?.nexusPurchase) return; // 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 { state = migrateAtomicsTargets(state); state = migrateFremenNexusRevival(state); harkonnenNexusBetrayalIntegrity(state); ixianNexusBetrayalIntegrity(state); ixianNexusReplacementIntegrity(state); guildBetrayalIntegrity(state); state = migratePrintedKullCounter(state); richeseBetrayalIntegrity(state); nexusEmperorPaymentIntegrity(state); nexusEmperorSellerIntegrity(state); nexusEmperorAmbassadorIntegrity(state); kullIntegrity(state); advancedPreviewIntegrity(state); harkonnenExchangeIntegrity(state); validateEcazLoyalty(state); ecazTreacheryIntegrity(state); leaderSkillsIntegrity(state); moritaniExtortionIntegrity(state); guildRateIntegrity(state); arrivalOverlapIntegrity(state); discoveryIntegrity(state); greatMakerIntegrity(state); nexusChoamTradeIntegrity(state); nexusChoamInspectionIntegrity(state); nexusMoritaniRetentionIntegrity(state); nexusChoamBetrayalIntegrity(state); nexusEcazInquiryIntegrity(state); nexusEcazDukeIntegrity(state); richesePairIntegrity(state); marketGholaIntegrity(state); homeworldRule(() => homeworldGameIntegrity(state)); homeworldBattleLossIntegrity(state); homeworldSubstitutionIntegrity(state); homeworldDefenseIntegrity(state); homeworldShipmentIntegrity(state); karamaConversionIntegrity(state); treacheryDiscardIntegrity(state); shipmentPromiseIntegrity(state); saphoMovementIntegrity(state); ambassadorRelocationIntegrity(state); ecazCollectionIntegrity(state); ecazAllianceIntegrity(state); const g = structuredClone(state); syncTupileSlots(g); prepareTupileCleanup(g); if (g.pendingHomeworldTupileCleanup || tupileSourceBlock(g)) return g; if (g.pendingNexusHarkonnenBetrayal) return g; if (g.pendingNexusIxianBetrayal) return g; if (g.pendingNexusIxianReplacement) return g; if (g.pendingGuildBetrayal) return g; if (g.pendingRicheseBetrayal) return g; if (g.pendingNexusEmperorPayment || g.pendingNexusEmperorSellerPurchase || g.pendingAmbassador?.nexusPurchase) { finishActionContinuations(g); nexusEmperorPaymentIntegrity(g); nexusEmperorSellerIntegrity(g); nexusEmperorAmbassadorIntegrity(g); return g; } if (g.pendingKull?.stage === 'offer') return g; ornithopterIntegrity(g); lateDefenseIntegrity(g); battleSpecialWeaponIntegrity(g); strongholdIntegrity(g); auditorIntegrity(g); if (g.pendingNullentropy) return g; normalizeCardNames(g); normalizeBattle(g); finishActionContinuations(g); prepareTupileCleanup(g); if (g.pendingHomeworldTupileCleanup || tupileSourceBlock(g)) return g; if (g.pendingTreacheryDiscard?.reaction) { treacheryDiscardIntegrity(g); return g; } reconcileBattlePromises(g); reconcileShipmentPromises(g); settleAutomaticContinuations(g); observeOccupation(g); discoveryIntegrity(g); greatMakerIntegrity(g); nexusChoamTradeIntegrity(g); nexusChoamInspectionIntegrity(g); nexusMoritaniRetentionIntegrity(g); nexusChoamBetrayalIntegrity(g); nexusEcazInquiryIntegrity(g); nexusEcazDukeIntegrity(g); richesePairIntegrity(g); richeseBetrayalIntegrity(g); nexusEmperorPaymentIntegrity(g); nexusEmperorSellerIntegrity(g); nexusEmperorAmbassadorIntegrity(g); marketGholaIntegrity(g); prepareTupileCleanup(g); homeworldRule(() => homeworldGameIntegrity(g)); homeworldBattleLossIntegrity(g); homeworldSubstitutionIntegrity(g); homeworldDefenseIntegrity(g); homeworldShipmentIntegrity(g); harkonnenExchangeIntegrity(g); moritaniExtortionIntegrity(g); guildRateIntegrity(g); arrivalOverlapIntegrity(g); harkonnenNexusBetrayalIntegrity(g); return g; } function applyActionInner( state: Game, id: string, action: Action, execution: 'live' | 'shipmentPreparation' = 'live', ): Game { tupileSlotsIntegrity(state); const tupileBlock = tupileSourceBlock(state); requireRule(!tupileBlock, tupileBlock ?? 'The occupied Tupile source is unresolved.'); if (state.homeworldTupilePreview && state.status === 'playing' && action?.type !== 'advanceBots') { const current = homeworldRule(() => syncOccupiedTupileLease(state, state.homeworldTupileState!)); requireRule(current.state.signature === state.homeworldTupileState!.signature, 'Resume the original occupied Tupile source before another action.'); } requireRule(!state.pendingHomeworldTupileCleanup || action?.type === 'advanceBots' || action?.type === 'decision' && id === state.pendingHomeworldTupileCleanup.player && action.event === state.pendingHomeworldTupileCleanup.event, 'Finish the original occupied Tupile hand cleanup before another action.'); ecazTreacheryIntegrity(state); const g = structuredClone(state); if ((g.pendingNexusEmperorPayment || g.pendingNexusEmperorSellerPurchase || g.pendingAmbassador?.nexusPurchase) && !g.truthtrance && !pendingNexusTraitors(g)) { const p = getPlayer(g, id); const opensTruth = action?.type === 'card' && !action.mode && p.hand.some(card => card.id === action.card && (card.effect === 'truthtrance' || Array.isArray(action.shrineTruthtrance) && action.shrineTruthtrance.includes(card.id))); if (!opensTruth) { if (action?.type === 'advanceBots') return g; if (g.pendingNexusEmperorSellerPurchase) decideNexusEmperorSellerPurchase(g, p, action); else if (g.pendingAmbassador?.nexusPurchase) decideNexusEmperorAmbassadorPurchase(g, p, action); else decideNexusEmperorPayment(g, p, action); return g; } } if (g.pendingNexusHarkonnenBetrayal) { getPlayer(g,id); if (action?.type === 'advanceBots') return g; decideHarkonnenNexusBetrayal(g,id,action); return g; } if (g.pendingNexusIxianBetrayal) { getPlayer(g,id); if (action?.type === 'advanceBots') return g; decideIxianNexusBetrayal(g,id,action); return g; } if (g.pendingNexusIxianReplacement) { getPlayer(g,id); if (action?.type === 'advanceBots') return g; decideIxianNexusReplacement(g,id,action); return g; } if (g.pendingGuildBetrayal) { const player = getPlayer(g, id); if (action?.type === 'advanceBots') return g; decideGuildBetrayal(g, player, action); return g; } if (g.pendingRicheseBetrayal) { const p = getPlayer(g,id); if (action?.type === 'advanceBots') return g; decideRicheseBetrayal(g,p,action); return g; } if (g.biddingEnd && action.type !== 'advanceBots' && !(action.type === 'biddingEnd' && action.mode === 'ready')) g.biddingEnd.ready = []; ornithopterIntegrity(g); lateDefenseIntegrity(g); battleSpecialWeaponIntegrity(g); strongholdIntegrity(g); auditorIntegrity(g); if (g.pendingNullentropy && !['nexusTraitorDraw', 'nexusTraitorReturn', 'nexusFaceDancers'].includes(action?.type)) { if (action?.type === 'advanceBots') return g; requireRule(!pendingNexusTraitors(g), 'Finish the private Nexus card return before continuing the paid search.'); 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(!g.pendingHomeworldOccupiedPercentage || g.decision?.kind === 'homeworldOccupiedPercentage', 'The original displaced percentage source is blocked.'); requireRule(!g.pendingHomeworldOccupiedBonus || g.decision?.kind === 'homeworldOccupiedBonus', 'The original displaced bonus source is blocked.'); requireRule(!(g.homeworldOccupationPreview && g.richeseBidding?.stage === 'cacheOffer' && !g.decision && quoteOccupiedBiddingAuthority(g, 'richeseCache').blocked), 'The original displaced cache-card source is blocked.'); requireRule( action && typeof action.type === 'string', 'Action type is required.', ); requireRule(g.status !== 'finished', 'This game has ended.'); const t = action.type; requireRule(t !== 'nexusIxianReplacementPass' && t !== 'nexusIxianReplacementUse', 'This purchased-card choice is no longer pending.'); requireRule(t !== 'guildBetrayalPass' && t !== 'guildBetrayalUse', 'This Guild Betrayal opportunity is no longer pending.'); requireRule(t !== 'richeseBetrayalPass' && t !== 'richeseBetrayalUse', 'This Richese Betrayal opportunity is no longer pending.'); if (g.pendingKull) { if (t === 'advanceBots') return g; if (g.pendingKull.stage === 'offer') { decideKull(g, p, action); return g; } requireRule(t === 'passResponse' || (t === 'card' && action.mode === 'cancel'), 'Finish the Kull counter response before another action.'); } else requireRule(t !== 'kullDecision', 'This Kull opportunity is no longer pending.'); if (t === 'advanceBots') return g; requireRule(!g.bureaucratPayments?.pending || t === 'decision','Resolve the pending Bureaucrat payment before another action.'); requireRule(!(g.battle?.mentatQuestion?.stage === 'name' || g.battle?.mentatQuestion?.stage === 'reveal') || t === 'decision', 'Resolve the current Mentat question before another action.'); if (t === 'nexusCardChoice') { decideNexusCard(g, p, action); return g; } requireRule(g.nexusCards?.phase?.stage !== 'drawing', 'Finish the closing Nexus card choices first.'); if (t === 'nexusTraitorDraw') { playNexusTraitorDraw(g, p, action); return g; } if (t === 'nexusFaceDancers') { playNexusFaceDancers(g, p, action); return g; } if (t === 'nexusTleilaxuRevival') { playNexusTleilaxuRevival(g,p,action); return g; } if (t === 'nexusBorrowedVoice') { playNexusBorrowedVoice(g, p, action); return g; } if (t === 'nexusFremenProtection') { playNexusFremenProtection(g, p, action); return g; } if (t === 'nexusSuboids') { playNexusSuboids(g, p, action); return g; } if (t === 'nexusAdvisors') { playNexusAdvisors(g, p, action); return g; } if (t === 'nexusSardaukar') { playNexusSardaukar(g,p,action); return g; } if (t === 'nexusTraitorReturn') { requireRule(!g.truthtrance, 'Finish the active Truthtrance before returning Nexus cards.'); finishNexusTraitorReturn(g, p, action); return g; } if (t === 'discovery') { playDiscovery(g, p, action); return g; } if (t === 'nexusChoamTrade') { playNexusChoamTrade(g, p, action); return g; } if (t === 'nexusChoamBetrayal') { playNexusChoamBetrayal(g, p, action); return g; } if (t === 'nexusEcazInquiry') { playNexusEcazInquiry(g, p, action); return g; } if (t === 'nexusEcazDuke') { playNexusEcazDuke(g, p, action); return g; } if (t === 'nexusEmperorRevive') { playNexusEmperorRevive(g,p,action); return g; } if (t === 'nexusEmperorPurchase') { playNexusEmperorPurchase(g,p,action); return g; } if (t === 'nexusEmperorBetrayal') { playNexusEmperorBetrayal(g,p,action); return g; } if (t === 'nexusBgBetrayal') { playNexusBgBetrayal(g,p,action); return g; } if (t === 'nexusFremenRevival') { playNexusFremenRevival(g,p,action); return g; } if (t === 'nexusMoritaniBetrayal') { playMoritaniBetrayal(g, p, action); return g; } if (t === 'nexusEcazBetrayal') { playEcazBetrayal(g, p, action); return g; } if (t === 'nexusFremenBetrayal') { playNexusFremenBetrayal(g, p, action); return g; } if (t === 'nexusAtreides') { playNexusAtreides(g, p, action); 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, cardBlock: transferCardBlock, }) ) return g; } catch (error) { if (error instanceof TruthError || error instanceof PlanClaimError) throw new RuleError(error.message); throw error; } requireRule(!pendingNexusTraitors(g), 'Finish the private Nexus card return before continuing play.'); if (g.pendingNexusEmperorPayment) { if (action.type === 'advanceBots') return g; decideNexusEmperorPayment(g, p, action); return g; } if (g.pendingNexusEmperorSellerPurchase) { if (action.type === 'advanceBots') return g; decideNexusEmperorSellerPurchase(g, p, action); return g; } if (g.pendingAmbassador?.nexusPurchase) { if (action.type === 'advanceBots') return g; decideNexusEmperorAmbassadorPurchase(g, p, action); return g; } if (t === 'card' && action.card === RECRUITS_CARD_ID) { requireRule( Object.keys(action).sort().join(',') === 'card,type', 'Play Recruits without an additional mode or selection.', ); const card = p.hand.find((held) => held.id === RECRUITS_CARD_ID); requireRule(card, 'The physical Recruits card is not in your hand.'); const blocked = recruitsPlayBlock(g, p, card); requireRule(!blocked, blocked ?? 'Recruits is unavailable.'); discard(g, p, card.id); for (const player of g.players) player.freeForcesRevived ??= 0; g.recruits = { turn: g.turn, player: p.id, card: RECRUITS_CARD_ID }; log( g, `${p.name} played Recruits. Current free revival rates are doubled and the ordinary force revival limit is seven for this turn.`, { faction: p.faction, name: 'Recruits' }, ); return g; } if (t === 'card' && action.card === 'richese-juice-of-sapho') { playSapho(g, p, action); return g; } if (t === 'tupileIntelligence') { requestTupileIntelligence(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' }, ); if (g.semutaPreview && !g.pendingTreacheryDiscard) { const continuation: DistransDiscardContinuation = { kind: 'distransDiscard', owner: p.id, recipient: recipient.id, transferred: result.transferred.id, card: result.discarded.id, resume: { response: g.response, decision: g.decision, pendingKarama: g.pendingKarama, phaseOpening: g.phaseOpening }, stateSignature: '', }; const entries = [{ card: result.discarded, discardedBy: p.id, publicFace: true }]; if (semutaOfferSupported(g, continuation, entries)) { continuation.stateSignature = distransDiscardSignature(g, continuation); stageTreacheryDiscard(g, 'distrans', entries, continuation); } } 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 (t === 'card' && choamSaleGholaTiming(g) && p.hand.some((card) => card.id === action.card && card.effect === 'ghola')) { playMarketGhola(g, p, action); return g; } if (t === 'biddingEnd') { decideBiddingEnd(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.', ); const before = g.semutaPreview ? g.players.map(player => ({ player: player.id, spice: player.spice })) : null; const used = 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.`, ); if (before) { const continuation: AmalDiscardContinuation = { kind: 'amalDiscard', player: p.id, card: used.id, before, resume: { response: g.response, decision: g.decision, pendingKarama: g.pendingKarama, phaseOpening: g.phaseOpening }, stateSignature: '', }; const entries = [{ card: used, discardedBy: p.id, publicFace: true }]; if (semutaOfferSupported(g, continuation, entries)) { g.phaseOpening = null; continuation.stateSignature = amalDiscardSignature(g, continuation); stageTreacheryDiscard(g, 'amal', entries, continuation); } } } 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 (g.decision?.kind === 'ecazBattleLead') { const decision = g.decision, b = g.battle!, occupy = b.ecazOccupy!; requireRule(t === 'decision' && id === decision.player && action.event === decision.event && typeof action.lead === 'string' && decision.choices.includes(action.lead) && Object.keys(action).every(key => ['type', 'event', 'lead'].includes(key)), 'Choose Ecaz or its current ally to lead this combined battle.'); const previous = b.attacker === occupy.ecaz || b.attacker === occupy.ally ? b.attacker : b.defender; if (b.attacker === previous) b.attacker = action.lead; else b.defender = action.lead; occupy.lead = action.lead; // The selected lead supplies normal aggressor priority, not the scheduling ally. if (g.order.indexOf(b.attacker) > g.order.indexOf(b.defender)) [b.attacker, b.defender] = [b.defender, b.attacker]; ecazBattleProfile(g); g.decision = null; g.response = { kind: 'ecazOccupy', owner: occupy.ecaz, passed: [] }; log(g, `${getPlayer(g, action.lead).name} leads the combined Ecaz battle, using their own leaders, cards and spice.`, { faction: 'ecaz', name: 'Occupy' }); return g; } if ( t === 'offerRicheseNoField' && g.decision?.kind === 'richeseAllyOpportunity' && g.decision.player === id ) g.decision = null; if (t === 'leaderSkillVisibility' || (t === 'leaderSkill' && g.status === 'playing')) { const decision = g.decision; requireRule(decision && (decision.kind === 'leaderSkillVisibility' || decision.kind === 'leaderSkillRevival') && decision.player === id && decision.event === action.event, 'This Leader Skill decision is not waiting for you.'); if (t === 'leaderSkillVisibility') { requireRule(decision.kind === 'leaderSkillVisibility' && g.battle?.event === decision.event && typeof action.hide === 'boolean', 'Choose whether your skilled leader remains face up or moves behind the shield.'); requireRule(Object.keys(action).every((key) => ['type','event','hide'].includes(key)), 'Choose only the current skill visibility decision.'); (g.battle.leaderSkillHidden ??= {})[id] = action.hide; g.decision = null; log(g, `${p.name} ${action.hide ? 'moved their skilled leader and card behind the shield; it may still be a bluff' : 'kept their skilled leader and card face up; that leader cannot be in their Battle Plan'}.`, {faction:p.faction,name:'Leader Skill'}); if (decision.resumePowers !== false) beginBattlePowers(g); } else { requireRule(decision.kind === 'leaderSkillRevival' && g.leaderSkills, 'No revived leader is awaiting a skill choice.'); requireRule(Object.keys(action).every((key) => ['type','event','mode','skill','leader'].includes(key)), 'Choose only the current revival skill action.'); if (action.mode === 'draw') g.leaderSkills = skillRule(() => drawRevivedLeaderSkills(g.leaderSkills!, id, stringField(action.event))); else if (action.mode === 'decline') g.leaderSkills = skillRule(() => declineRevivedLeaderSkill(g.leaderSkills!, id, stringField(action.event))); else { requireRule(action.mode === undefined, 'Choose draw, decline or a dealt skill.'); const skill = stringField(action.skill); g.leaderSkills = skillRule(() => chooseLeaderSkill(g.leaderSkills!, id, stringField(action.event), skill, stringField(action.leader), skillEligibleLeaders(g,id), random)); log(g, `${p.name} assigned ${leaderSkillCard(action.skill as Parameters[0]).name} to a newly revived leader.`, {faction:p.faction,name:'Leader Skill'}); } const marketGhola = g.pendingChoamMarketGhola; if (marketGhola?.player === id && marketGhola.leaderSkill?.offer === decision.event && !g.leaderSkills.offers[id]) { marketGhola.leaderSkill.completed = true; marketGhola.leaderSkill.signature = choamGholaSkillSignature(marketGhola); } g.decision = null; if (g.battle && !g.battle.revealed && !g.battle.plans[id] && [g.battle.attacker,g.battle.defender].includes(id) && g.leaderSkills.assignments.some((a) => a.owner === id) && g.battle.leaderSkillHidden?.[id] === undefined) g.decision = {kind:'leaderSkillVisibility',player:id,event:g.battle.event!,resumePowers:false}; } return g; } if (g.decision) { const decision = g.decision; requireRule(decision.kind !== 'nexusFremenProtection', 'Use only the original worm protection event with Use or Pass.'); requireRule(decision.kind !== 'nexusEmperorPayment', 'Use only the original allied-purchase event with Use or Pass.'); requireRule(decision.kind !== 'nexusEmperorSellerPurchase', 'Use only the original seller-lot event with Pay or Use.'); requireRule(decision.kind !== 'nexusEmperorAmbassadorPurchase', 'Use only the original Ambassador-purchase event with Pay or Use.'); if (decision.kind === 'nexusIxianReplacement') { requireRule(false, 'Use only the current purchased-card event with Use or Pass.'); } requireRule( t === 'decision' && decision.player === id, 'Waiting for the player with the pending decision.', ); g.decision = null; requireRule(decision.kind !== 'leaderSkillVisibility' && decision.kind !== 'leaderSkillRevival', 'Use the Leader Skills controls for this decision.'); if (decision.kind === 'moritaniAssassinate') {actMoritaniAssassinate(g,decision,action);return g;} if (decision.kind === 'moritaniExtortion') { const obligation = g.moritaniExtortion; const pay = action.pay; requireRule(g.phase === 8 && obligation?.stage === 'payment' && obligation.queue[obligation.cursor] === id && decision.event === extortionEvent(obligation) && action.event === decision.event && typeof pay === 'boolean' && Object.keys(action).every(key => ['type', 'event', 'pay'].includes(key)), 'Choose the current Extortion opportunity in storm order.'); const result = extortionRule(() => answerExtortion(obligation, id, pay, extortionContext(g))); if (result.transfer?.from === 'player') { p.spice -= result.transfer.amount; getPlayer(g, result.transfer.to).spice += result.transfer.amount; log(g, `${p.name} paid Moritani three spice to prevent Extortion recovery.`); } else log(g, `${p.name} declined the Extortion payment.`); g.moritaniExtortion = result.state; if (result.recover !== null) settleMoritaniExtortion(g, result.state, result.recover); else { const next = result.state.queue[result.state.cursor]; g.decision = { kind: 'moritaniExtortion', player: next, event: extortionEvent(result.state) }; } return g; } if (decision.kind === 'mentatQuestion') { actMentatQuestion(g, decision, action); return g; } if (decision.kind === 'bureaucratPayment') {actBureaucratPayment(g,action);return g;} if (decision.kind === 'homeworldRevivalDeployment') { decideHomeworldRevivalReturn(g, p, action); return g; } if (decision.kind === 'caladanReinforcement') { decideHomeworldVictoryReturn(g, p, action); return g; } 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 === 'richeseCacheTerms' || 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.', ); requireRule(decision.source === pending.source, 'The battle-card retention source changed.'); if (pending.source === 'nexus') { const offer = quoteNexusMoritaniRetention(g, id, decision); requireRule(offer?.event === action.event && Object.keys(action).every(key => ['type', 'event', 'keep'].includes(key)) && (action.keep === null || offer?.canKeep === true), 'Choose whether to spend Moritani Nexus after this loss.'); finishMoritaniRetention(g, action.keep as string | null); return g; } 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 === 'grummanCollection') { decideGrummanCollection(g, p, action); return g; } if (decision.kind === 'homeworldOccupiedPercentage') { decideOccupiedPercentage(g, p, action); return g; } if (decision.kind === 'homeworldOccupiedBonus') { decideOccupiedBonus(g, p, action); return g; } if (decision.kind === 'homeworldOccupiedIncome') { decideHomeworldOccupiedIncome(g, p, decision, action); return g; } if (decision.kind === 'homeworldTupileCleanup') { decideTupileCleanup(g, p, decision, action); 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 && validGameLocation(g, 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); const nexus = action.nexus !== undefined; if (nexus) { const offer = nexusMoritaniOffer(g,p.id,decision); requireRule(offer && !offer.blocked && offer.event === action.nexus && Object.keys(action).every(key => ['type','token','territory','nexus'].includes(key)) && offer.tokens.some(t => t.id === token) && offer.destinations.includes(to), offer?.blocked ?? 'Choose your current Moritani Cunning supply placement.'); } try { if (nexus) quoteNexusMoritaniPlacement(g.moritaniTerror,token,to,g.turn); else placeTerror(g.moritaniTerror, token, to, g.turn); } catch (error) { throw new RuleError( error instanceof Error ? error.message : 'Invalid Terror placement.', ); } if (nexus) recordNexusMoritani(g,p.id,token,to); else g.pendingMoritaniPlacement = { token, territory: to, turn: g.turn }; if (nexus) log(g, `${p.name} played Moritani Nexus Cunning for this hidden Terror placement. Karama may prevent the placement; the Nexus remains spent.`); 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, true); return g; } if (decision.kind === 'nexusChoamInspection') { const offer = quoteNexusChoamInspection(g, id, decision); requireRule( offer?.event === decision.event && action.event === decision.event && typeof action.inspect === 'boolean' && Object.keys(action).every(key => ['type', 'event', 'inspect'].includes(key)), 'Choose whether to use CHOAM Secret Ally for this resolved victory.', ); const receipt = g.lastBattleContext!.nexusChoamInspection!; if (action.inspect) { requireRule(offer.canInspect, 'The CHOAM Secret Ally needs its unallied holder and an unused opposing card.'); const opponent = getPlayer(g, receipt.opponent); const card = nexusRule(() => sampleNexusChoamInspection(opponent.hand, receipt.usedCards, random)); g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!, id, g.players)); receipt.inspected = card.id; g.nexusChoamInsight = { event: receipt.event, viewer: id, target: opponent.id, turn: g.turn, card, }; log(g, `${p.name} spent CHOAM Nexus Secret Ally to privately inspect one unused card held by ${opponent.name} after winning the battle. The inspected face remains private.`, { faction: p.faction, name: 'CHOAM Nexus inspection' }); } receipt.stage = 'complete'; receipt.signature = nexusChoamInspectionSignature(receipt); g.decision = null; finishBattle(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', true); combatResponses(g); return g; } if ( decision.kind === 'choamMarket' || decision.kind === 'choamTradeReply' || decision.kind === 'choamTradeConfirm' ) { decideChoamMarket(g, decision, action); } 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 && (g.homeworldOccupationPreview ? homeworldRule(() => requireOccupiedBiddingController(g, 'ixAuction', p.id)).provider === byFaction(g, 'ixians')!.id : p.faction === 'ixians'), 'The auction pool is not available.', ); if (g.ixAuction.authority) requireRule(g.ixAuction.authority === JSON.stringify(quoteStableHomeworldOccupation(g, 'ix')), 'The original Ixian pool authority changed before selection.'); 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.homeworldOccupationPreview ? { controller: p.id } : {}) }; 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); const sandmasterPiles = nexusRule(() => validateSandmasterStronghold( g, p.id, route, action.sandmasterPiles, action.collect !== false)); g.pendingMobileMove = { player: p.id, route, collect: action.collect !== false, ...(action.sandmasterPiles !== undefined ? { sandmasterPiles } : {}), }; 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) { completeIxSubstitution(g); finishWinner(g, getPlayer(g, pending.winner ?? pending.player), 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 = pending.territory.startsWith('homeworld:') ? { [pending.territory]: combatArmy(g, p.id, pending.territory).normal } : 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.', ); if (pending.receipt) { pending.receipt.signature = ixSubstitutionSignature(pending); g.lastBattleContext!.ixSubstitution!.signature = pending.receipt.signature; } 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 blocked = faceDanceReturnBlock(g, winner.id); requireRule(!blocked, blocked ?? 'This Face Dance return is unavailable.'); const home = homeworldBattleLocation(g, decision.territory); requireRule(!home || home.native === p.id, 'Only the native faction may call a Face Dancer on a Homeworld.'); const winningArmies = (decision.winners ?? [winner.id]).map(owner => getPlayer(g, owner)); const army = combatArmy(g, winner.id, decision.territory); const maximum = home ? army.normal + army.elite : winningArmies.reduce((sum, owner) => sum + at(owner, decision.territory), 0); 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 = home ? 0 : integer(action.sector, 0, 18, 'Sector'); requireRule(home || validGameLocation(g, 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') { if (!home) p.reserves -= source.count; } else { p.forces[source.key] -= source.count; if (!p.forces[source.key]) delete p.forces[source.key]; } } if (home) { const destination = `homeworld:${winner.faction}`; const external = selected.filter((source) => source.key !== 'reserves').reduce((n, source) => n + source.count, 0); const quote = quoteHomeworldCustody(homeworldContext(g), g.homeworlds!.custody!, [ { homeworld: home.id, player: winner.id, withdraw: army, deposit: {normal: 0, elite: 0} }, { homeworld: destination, player: winner.id, withdraw: {normal: 0, elite: 0}, deposit: army }, { homeworld: home.id, player: p.id, withdraw: {normal: 0, elite: 0}, deposit: {normal: external, elite: 0} }, ]); g.homeworlds!.custody = quote.state; for (const update of quote.players) { const owner = getPlayer(g, update.id); owner.reserves = update.reserves; if (owner.elites) owner.elites.reserves = update.eliteReserves; } } else { for (const owner of winningArmies) for (const key of Object.keys(owner.forces).filter( (key) => splitLocation(key).territory === decision.territory, )) { owner.reserves += owner.forces[key]; if (owner.elites) { owner.elites.reserves += owner.elites.forces[key] ?? 0; delete owner.elites.forces[key]; } delete owner.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 && !home) place(p, decision.territory, sector, total); observeOccupation(g); dancer.revealed = true; log( g, `${p.name} revealed a Face Dancer and replaced ${total} forces in ${combatLocationName(g, decision.territory)}.`, ); 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 === 'diplomatRetreat') { const receipt = g.battle?.diplomatRetreat; const destination = action.destination; const normal = action.normal, elite = action.elite; requireRule(Object.keys(action).every(key => ['type', 'event', 'destination', 'normal', 'elite'].includes(key)) && receipt?.event === decision.event && action.event === decision.event && receipt.player === id && (destination === null && normal === 0 && elite === 0 || typeof destination === 'string' && receipt.destinations.some(option => option.location === destination && option.choices.some(choice => choice.normal === normal && choice.elite === elite))), 'Choose a legal empty adjacent territory and undialed force count, or decline this Diplomat retreat.'); g.decision = null; if (destination === null) log(g, `${p.name} declined the Diplomat retreat.`, { faction: p.faction, name: 'Diplomat retreat' }); resolveBattle(g, { destination: destination as string | null, normal: normal as number, elite: elite as number }); } else if (decision.kind === 'jacurutuDial') { const frame = g.battle?.jacurutuDial; requireRule(frame && frame.owner === id && frame.event === decision.event && action.event === decision.event && Object.keys(action).every(key => ['type','event','allocation'].includes(key)) && typeof action.allocation === 'number' && Number.isSafeInteger(action.allocation) && action.allocation >= 0 && action.allocation < frame.choices.length && !frame.selectedDial, 'Identify the physical counters represented by your current revealed Jacurutu dial.'); const chosen = frame.choices[action.allocation]; frame.selectedDial = jacurutuDialSelection(frame,action.allocation)!; log(g, `${p.name} identified ${chosen.dialed.normal} normal and ${chosen.dialed.elite} elite physical counters represented by their Jacurutu dial; the battle winner receives ${chosen.amount} spice for opposing undialed counters actually sent to the Tanks.`, {faction:p.faction,name:'Jacurutu physical dial'}); resolveBattle(g, frame.retreat); } else if (decision.kind === 'smugglerCollection') { const receipt = g.battle?.smugglerCollection; requireRule(receipt?.event === decision.event && receipt.player === id && action.event === decision.event && action.allocations !== undefined && Object.keys(action).every(key => ['type', 'event', 'allocations'].includes(key)), 'Choose the exact Smuggler amount from its offered reveal-time piles.'); g.battle!.smugglerCollection = nexusRule(() => chooseSmugglerBattleAllocation( receipt!, action.allocations as Record)); g.decision = null; advanceBattleOutcome(g); } else if (decision.kind === 'sandmasterVictorySpice') { requireRule(g.battle?.event === decision.event && action.event === decision.event && typeof action.key === 'string' && decision.piles.some(pile => pile.key === action.key) && Object.keys(action).every(key => ['type', 'event', 'key'].includes(key)), 'Choose one existing pile for this surviving Sandmaster victory.'); g.battle!.sandmasterPile = action.key as string; g.decision = null; advanceBattleOutcome(g); } else if (decision.kind === 'harassWithdraw') { const receipt = g.battle?.harassAllocation, offer = harassAllocationOffer(g); requireRule(Object.keys(action).every(key => ['type', 'event', 'returns'].includes(key)) && action.event === decision.event && receipt?.event === decision.event && receipt.player === id && receipt.stage === 'offered' && offer && action.returns !== undefined, 'Choose all undialed forces for this exact revealed Harass & Withdraw plan.'); let quote; try { quote = quoteHarassWithdraw(offer.context, offer.dial, offer.support, action.returns as HarassWithdrawSelection); } catch (error) { if (error instanceof HarassWithdrawError) throw new RuleError(error.message); throw error; } validateReserveReturnCounters(p, quote.returned); receipt.selection = structuredClone(quote.locations); receipt.stage = 'selected'; receipt.signature = harassAllocationSignature(receipt); log(g, `${p.name} selected ${quote.returned.normal} ordinary and ${quote.returned.elite} elite undialed forces for Harass & Withdraw: ${Object.entries(quote.locations).map(([key, group]) => `${key} (${group.normal} ordinary, ${group.elite} elite)`).join('; ')}. The return waits for traitor declarations; an opposing successful call cancels it.`); nextRevealedDecision(g); } else if (decision.kind === 'diplomatDefense') { const receipt = g.battle?.diplomatDefense; requireRule(Object.keys(action).every(key => ['type', 'event', 'card'].includes(key)) && action.event === decision.event && receipt?.event === decision.event && receipt.player === id && receipt.stage === 'offered' && (action.card === null || typeof action.card === 'string' && receipt.cards.includes(action.card)), 'Choose one offered Worthless card or decline this exact Diplomat defense.'); receipt.card = action.card as string | null; receipt.stage = receipt.card ? 'copied' : 'declined'; receipt.signature = diplomatDefenseSignature(receipt); log(g, receipt.card ? `${p.name} used Diplomat: ${cardOf(p, receipt.card)!.name} copies the opposing ${diplomatDefenseLabel(receipt.kind)} for this battle and must be discarded afterward. The original plans and physical card identities remain unchanged.` : `${p.name} declined to copy the opposing defense with Diplomat.`, { faction: p.faction, name: 'Diplomat defense' }); nextRevealedDecision(g); } else if (decision.kind === 'stoneBurner') { requireRule( Object.keys(action).every((key) => ['type', 'event', 'mode'].includes(key)) && (action.mode === 'kill' || action.mode === 'ignore') && g.battle?.revealed && action.event === decision.event && action.event === g.battle.event, 'Choose Stone Burner’s mode for this exact revealed battle.', ); const choice = revealedWeaponChoices(g).find((candidate) => candidate.kind === 'stoneBurner' && candidate.player === id); requireRule(!!choice && choice.physicalId === (decision.physicalId ?? choice.physicalId) && choice.copiedFrom === (decision.copiedFrom ?? null) && g.battle.stoneBurner?.[id] === undefined, 'Stone Burner’s mode is already chosen or its physical weapon changed.'); (g.battle.stoneBurner ??= {})[id] = action.mode; log(g, `${p.name} chose ${choice.copiedFrom ? 'Mirror Weapon copying ' : ''}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(Object.keys(action).every((key) => ['type', 'event', 'activate'].includes(key)) && g.battle?.revealed && typeof action.activate === 'boolean' && (decision.copiedFrom === null || decision.copiedFrom === undefined || action.event === decision.event) && (action.event === undefined || action.event === decision.event) && (decision.event === undefined || decision.event === g.battle.event), 'Choose whether to activate Poison Tooth for this exact revealed battle.'); const choice = revealedWeaponChoices(g).find((candidate) => candidate.kind === 'poisonTooth' && candidate.player === id); requireRule(!!choice && choice.physicalId === (decision.physicalId ?? choice.physicalId) && choice.copiedFrom === (decision.copiedFrom ?? null) && g.battle.poisonTooth?.[id] === undefined, 'Poison Tooth is already chosen or its physical weapon changed.'); (g.battle.poisonTooth ??= {})[id] = action.activate; log(g, `${p.name} ${action.activate ? 'activated' : 'declined to activate'} ${ choice.copiedFrom ? 'Mirror Weapon copying ' : ''}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 === 'homeworldShipmentGuild') { requireRule(action.allow === true && action.event === decision.event, 'Allow this exact Homeworld declaration, or use the Guild special Karama.'); const shipment = g.pendingHomeworldShipment; requireRule(shipment && decision.player === byFaction(g, 'guild')?.id && decision.event === shipment.event && decision.shipper === shipment.player && decision.destination === shipment.destination && decision.amount === shipment.amount, 'The Guild decision no longer matches this Homeworld shipment.'); commitHomeworldShipment(g, shipment); } else if (decision.kind === 'guildShipment') { requireRule( action.allow === true, 'Allow the shipment, or play the Guild special Karama.', ); const shipment = validateGuildShipmentDecision(g, decision); try { quoteShipmentArrival(g, shipment, true); } catch (error) { if (!(error instanceof MovementArrivalError)) throw error; requireRule( recoverableUnsupportedShipment(shipment), error.message, ); g.pendingShipment = null; g.decision = null; log( g, `${getPlayer(g, shipment.player).name}'s uncommitted shipment returned for another choice. ${error.message} No forces, spice, cards or shipment allowance were spent.`, ); return g; } if (!openGuildRate(g, shipment)) { 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') { finishHarkonnenExchange(g, decision, action.returnCards); } 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 === 'homeworldDefense') { const b = g.battle; requireRule(b?.revealed && b.territory.startsWith('homeworld:') && b.event === decision.event && action.event === decision.event && [b.attacker, b.defender].includes(id) && !traitorVoters(g, b).includes(id) && !b.homeworldDefensePassed?.includes(id), 'This Homeworld late-defense choice is no longer current.'); requireRule(typeof action.use === 'boolean', 'Use Portable Snooper or decline its late-defense opportunity.'); if (action.use) { const card = p.hand.find(isPortableSnooper); requireRule(card, 'The late defense needs your held Portable Snooper.'); validatePortableSnooper(g, p, card.id, decision.event); (b.lateDefense ??= {})[id] = card.id; log(g, `${p.name} added Portable Snooper after the Homeworld battle plans were revealed.`, {faction: p.faction, name: 'Portable Snooper'}); } (b.homeworldDefensePassed ??= []).push(id); advanceHomeworldReveal(g); } else if (decision.kind === 'homeworldExplosion') { homeworldBattleLossIntegrity(g); homeworldSubstitutionIntegrity(g); homeworldDefenseIntegrity(g); homeworldShipmentIntegrity(g); requireRule(action.event === decision.event, 'This Homeworld casualty choice belongs to a different battle.'); const choice = decision.options[integer(action.choice, 0, decision.options.length - 1, 'Casualty choice')]; settleHomeworldExplosion(g, choice, false); } else if (decision.kind === 'sukRescue') { requireRule(action.event === decision.event && Object.keys(action).every((key) => ['type', 'event', 'choice', 'destinations'].includes(key)), 'Choose only the current Suk Graduate rescue.'); const option = decision.options[integer(action.choice, 0, decision.options.length - 1, 'Rescue choice')]; settleSukRescue(g, option, false, action.destinations as SukReserveDestinations | undefined); } else if (decision.kind === 'rihani') { actRihani(g, p, decision, action); } 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, false, decision.forceOwner); } 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.discoveryEntry ? ('discoveryEntry' as const) : decision.wormRide ? ('wormRide' as const) : undefined, advisorAmbassadorEvent: decision.ambassadorEvent, discoveryEntry: decision.discoveryEntry, }; 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), amount: quote.amount, 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) { const target = action.accompany === true && g.advanced ? decision.destination : 'polar_sink:0'; requireRule(target, 'This shipment has no accompanying destination.'); const dest = splitLocation(target); const amount = integer(action.amount === undefined ? 1 : action.amount, 1, spiritualAdvisorMaximum(g, p.id, dest.territory), 'Spiritual Advisor forces'); 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), amount, }; } 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 === 'nexusFremenCunningOffer') { const pending = g.nexusFremenCunningOffer; requireRule(pending && pending.owner === id && pending.occurrence.event === decision.event && action.event === decision.event && typeof action.accept === 'boolean' && Object.keys(action).every(key => ['type', 'event', 'accept'].includes(key)), 'Choose whether to use the current empty-worm Nexus opportunity.'); g.nexusFremenCunningOffer = null; if (action.accept) { requireRule(nexusRule(() => quoteFremenCunning(g, id, pending.occurrence)), 'Fremen Cunning needs your own unallied Nexus card and a desert force source.'); g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!, id, g.players)); g.nexusFremenCunningRides ??= []; g.nexusFremenCunningRides.push({ occurrence: pending.occurrence, authorization: { event: decision.event, turn: g.turn, owner: id }, stage: 'pending', }); g.response = { kind: 'nexusFremenCunning', owner: id, intent: decision.event, location: pending.occurrence.territory, passed: [] }; log(g, `${p.name} spent Fremen Nexus Cunning on an empty worm appearance; a Karama response precedes the remote ride.`, { faction: p.faction, name: 'Fremen Nexus Cunning' }); } else wormSurvival(g, pending.occurrence.territory); } 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 === 'discoveryDiscard') { decideDiscoveryDiscard(g,p,action); } else if (decision.kind === 'discoveryEntry') { decideDiscoveryEntry(g,p,action); } else if (decision.kind === 'ecologicalStorm') { requireRule(g.ecologicalStorm && Object.keys(action).every(key => ['type','event','delta'].includes(key)), 'Choose only the current station storm adjustment.'); g.ecologicalStorm = nexusRule(() => chooseDiscoveryStorm(g,g.ecologicalStorm!,p.id,action.event,action.delta)); g.stormPending = nexusRule(() => discoveryStormDistance(g.ecologicalStorm!)); log(g, `${p.name} ${action.delta === 0 ? 'kept' : action.delta === 1 ? 'increased' : 'decreased'} storm movement ${action.delta === 0 ? 'at' : 'by one, to'} ${g.stormPending} sectors using Ecological Testing Station.`, {faction:p.faction,name:'Ecological Testing Station'}); if (g.ecologicalStorm.stage === 'choose') g.decision = {kind:'ecologicalStorm',player:g.ecologicalStorm.owner,event:g.ecologicalStorm.event}; else moveStorm(g,g.stormPending); } else if (decision.kind === 'greatMakerVote') { decideGreatMakerVote(g,p,action); } else if (decision.kind === 'greatMakerRide') { decideGreatMakerRide(g,p,action); } else if (decision.kind === 'nexusFremenCunningRide') { const ride = g.nexusFremenCunningRides?.find(row => row.stage === 'select' && row.occurrence.event === decision.event); requireRule(ride && ride.authorization.owner === id && action.event === decision.event && typeof action.accept === 'boolean', 'Choose the pending Fremen Cunning remote worm ride.'); if (action.accept) { requireRule(Object.keys(action).every(key => ['type', 'event', 'accept', 'source', 'forces', 'territory', 'sector'].includes(key)), 'Choose only this Cunning source, forces and destination.'); const selection = { source: stringField(action.source), forces: action.forces as FremenCunningSelection['forces'], destination: { territory: stringField(action.territory), sector: integer(action.sector, 0, 18, 'Sector'), }, }; const chosen = nexusRule(() => validateFremenCunningSelection( g, id, ride.occurrence, selection, ride.authorization)); allowedEntry(g, p, selection.destination.territory, selection.destination.sector); const entries = Object.entries(selection.forces); removeGroup(p, entries.map(([key, count]) => [key, count.normal + count.elite] as [string, number]), Object.fromEntries(entries.map(([key, count]) => [key, count.elite]))); place(p, selection.destination.territory, selection.destination.sector, chosen.total, chosen.elite); ride.stage = 'complete'; log(g, `${p.name} used Fremen Nexus Cunning to ride ${chosen.total} forces from ${territory(selection.source).name} to ${territory(selection.destination.territory).name}.`, { faction: p.faction, name: 'Fremen Nexus Cunning' }); const intruded = intrusion(g, p, selection.destination.territory, { wormRide: true }); if (openTerritoryEntry(g, p, selection.destination.territory, selection.destination.sector, chosen.total, chosen.elite, 'wormRide', 'wormRide') || intruded) return g; } else { requireRule(Object.keys(action).every(key => ['type', 'event', 'accept'].includes(key)), 'Declining Cunning does not move forces.'); ride.stage = 'complete'; log(g, `${p.name} declined the Fremen Nexus remote ride.`); } nextWormRide(g); } else if (decision.kind === 'wormRide') { requireRule( typeof action.accept === 'boolean', 'Choose whether to ride Shai-Hulud.', ); requireRule(action.sandmasterCollect === undefined || typeof action.sandmasterCollect === 'boolean', 'Choose whether to collect spice with Sandmaster.'); requireRule(action.sandmasterPile === undefined || (action.sandmasterCollect === true && typeof action.sandmasterPile === 'string'), 'Choose a Sandmaster destination pile only when collecting.'); requireRule(action.accept || !action.sandmasterCollect, 'Sandmaster collection requires an accepted worm ride.'); 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); const sandmaster = action.sandmasterCollect ? sandmasterWormCollection(g, p.id, to, sector, decision, action.sandmasterPile as string | undefined) : null; if (action.sandmasterCollect) { requireRule(sandmaster && !sandmaster.blocked && sandmaster.key, sandmaster?.blocked ?? 'Sandmaster collection needs the living native trainer.'); requireRule(sandmaster.piles.length <= 1 || typeof action.sandmasterPile === 'string', 'Choose the destination spice pile for Sandmaster collection.'); } 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 && validGameLocation(g, 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}.`, ); if (sandmaster) { g.spice[sandmaster.key!] = sandmaster.before - 1; p.spice++; log(g, `${p.name}'s Sandmaster collected 1 spice on arriving in ${territory(to).name} by worm. ${sandmaster.before - 1} spice remains in that pile. This ride does not consume normal movement.`, { faction: p.faction, name: 'Sandmaster collection' }); } 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 if (decision.kind === 'moritaniSpecialKarama') { requireRule(g.moritaniSpecialKarama?.stage === 'offer' && decision.event === action.event && decision.event === g.moritaniSpecialKarama.source.event && action.decline === true, 'Decline only the current Moritani special Karama opportunity.'); resumeMoritaniSpecialKarama(g); } else if (decision.kind === 'battleCards') { 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, cleanupDiscardRole(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); } else { const unhandled: never = decision; throw new Error(`Unsupported pending decision: ${String(unhandled)}`); } return g; } if (g.status === 'lobby') { if (t === 'rules') { requireRule(id === g.host, 'Only the host can change the rules mode.'); requireRule(typeof action.advanced === 'boolean' && Object.keys(action).every(key => key === 'type' || key === 'advanced'), 'Choose Basic or Advanced preview rules.'); if (g.advanced === action.advanced) return g; if (action.advanced) { const blocked = baseStartProfileBlock(g); requireRule(!blocked, blocked ?? 'This configuration cannot use Advanced preview.'); } const removedStrongholds = !action.advanced && !!g.strongholdCards; if (removedStrongholds) { requireRule(JSON.stringify(g.strongholdCards) === JSON.stringify(createStrongholdCards()), 'Only unused lobby Stronghold Cards can be cleared when choosing Basic rules.'); g.strongholdCards = null; } g.advanced = action.advanced; g.players.forEach(player => (player.ready = !!player.bot)); log(g, `${p.name} selected ${g.advanced ? 'Advanced preview' : 'Basic'} rules.${removedStrongholds ? ' Stronghold Cards were disabled because they require Advanced rules.' : ''} Human readiness was cleared; AI seats remain ready.`); return g; } if (t === 'configureBot') { const positions = normalizedPlayerPositions(g); const quote = quoteLobbyBotConfiguration( { status: g.status, host: g.host, players: g.players, expansions: g.expansions, positions, }, id, action, ); if (!quote.ok) requireRule(false, quote.reason); if (!quote.changed) return g; const configuration = quote.configuration; const bot = getPlayer(g, configuration.target); const previous = { name: bot.name, difficulty: bot.bot!, faction: bot.faction, position: positions[bot.id], }; bot.bot = configuration.difficulty; if (bot.faction !== configuration.faction) { bot.faction = configuration.faction; bot.name = `${faction(bot.faction).name} AI`; bot.elites = bot.faction === 'ixians' ? { reserves: 7, tanks: 0, forces: {}, revived: 0 } : undefined; bot.leaders = leaders(bot.faction); } positions[bot.id] = configuration.position; g.playerPositions = positions; g.players.forEach((player) => (player.ready = !!player.bot)); const changes: string[] = []; if (previous.difficulty !== bot.bot) changes.push(`difficulty ${previous.difficulty} to ${bot.bot}`); if (previous.faction !== bot.faction) changes.push( `faction ${faction(previous.faction).name} to ${faction(bot.faction).name}`, ); if (previous.position !== configuration.position) changes.push( `player circle ${previous.position} to ${configuration.position}`, ); log( g, `${p.name} configured ${previous.name}: ${changes.join('; ')}. Human readiness was cleared; AI seats remain ready.`, ); return g; } 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 === 'homeworlds') { requireRule(id === g.host, 'Only the host can change optional rules.'); requireRule( typeof action.enabled === 'boolean', 'Choose whether to use Homeworlds.', ); g.homeworlds = action.enabled ? { custody: null } : null; g.players.forEach((player) => (player.ready = !!player.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.'); requireRule(Object.keys(action).every(key => key === 'type' || key === 'advancedPreview') && (action.advancedPreview === undefined || action.advancedPreview === true), 'Start this table, or explicitly choose Advanced preview.'); start(g, action.advancedPreview === true); 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 = !!x.bot)); 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'] : g.setupStage === 'leaderSkills' ? ['leaderSkill'] : ['fremenSetup', 'ecazSetup', 'advisorSetup']; requireRule( expected.includes(t) && setupPending(g).includes(id), `Wait for the current setup step: ${g.setupStage}.`, ); } if (t === 'leaderSkill') { requireRule(Object.keys(action).every((key) => ['type','event','skill','leader'].includes(key)), 'Choose only your dealt skill and its eligible leader.'); const skill = stringField(action.skill), leader = stringField(action.leader); g.leaderSkills = skillRule(() => chooseLeaderSkill(g.leaderSkills!, id, stringField(action.event), skill, leader, skillEligibleLeaders(g, id), random)); log(g, `${p.name} assigned ${leaderSkillCard(skill as Parameters[0]).name} to ${p.leaders.find((l) => l.id === leader)!.name}.`, { faction: p.faction, name: 'Leader Skill' }); } else 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 && validGameLocation(g, 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 === 'ecazSetup') { requireRule(g.setupStage === 'forces' && p.faction === 'ecaz' && p.reserves === 20 && Object.keys(p.forces).length === 0 && Object.keys(action).every(key => ['type', 'placements'].includes(key)), 'Place Ecaz starting forces once during the current force setup.'); const placements = nexusRule(() => quoteEcazStartingForces(action.placements)); for (const [key, amount] of Object.entries(placements)) { const loc = splitLocation(key); place(p, loc.territory, loc.sector, amount); } p.reserves -= ECAZ_START_FORCES; log(g, `${p.name} placed six starting forces in Imperial Basin and retained fourteen in reserves.`, { faction: p.faction, name: 'Starting forces' }); } 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) && validGameLocation(g, 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') { const blocked = bribeTimingBlock(g); requireRule(!blocked, blocked ?? 'Bribes are unavailable.'); 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'); checkBureaucratContributors(g,target.id,[{payer:p.id,amount:n}]); p.spice -= n; log( g, `${p.name} paid a ${n}-spice bribe to ${target.name}; its recipient share becomes spendable at the next Mentat Pause.`, ); const source = stampBureaucratPayment(g,'bribe',p.id,target.id,n).bureaucratPayment; if (!offerBureaucratPayment(g,source,{kind:'bribe'})) target.bribes += n; 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.stormMovementSource = nexusRule(() => createStormSource(g.turn,'dials',g.stormPending!)); 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.', ); const target = action.target ? stringField(action.target) : null; const formerAlly = p.ally ? getPlayer(g, p.ally).name : null; const formerOffer = g.allianceOffers[id]; const formerOfferName = g.players.find((player) => player.id === formerOffer)?.name; let other: Player | null = null; if (target !== null) { other = getPlayer(g, target); requireRule(other.id !== id, 'Choose another player.'); const blocked = homeworldAllianceReason(g, id, other.id); requireRule(!blocked, blocked ?? 'This alliance is unavailable.'); } const quote = nexusRule(() => quoteNexusAlliance( { players: g.players.map((player) => ({ id: player.id, ally: player.ally, })), offers: g.allianceOffers, actor: id, target, }, ), ); for (const player of g.players) player.ally = quote.allies[player.id]; g.allianceOffers = quote.offers; if (target === null) { log(g, formerAlly ? `${p.name} broke the alliance with ${formerAlly}; both are unallied.` : formerOffer ? `${p.name} withdrew the alliance offer to ${formerOfferName ?? 'a player no longer seated'} and remains unallied.` : `${p.name} is unallied.`); } else if (quote.formed) { requireRule(other, 'The alliance partner is no longer seated.'); p.allySinceTurn = other.allySinceTurn = g.turn; discardAllianceNexusCards(g, p, other); log(g, `${p.name} and ${other.name} formed an alliance.`); } else { log(g, `${p.name} offered an alliance to ${other!.name}; both players must agree before an alliance forms.`); } g.ready = []; return g; } if (t === 'charity') { const advancedBG = g.advanced && p.faction === 'beneGesserit'; const quote = charityQuote(g, p); const amount = quote.total; 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, ...(g.homeworlds ? { charityHomeworld: quote.homeworld } : {}), }; else payCharity(g, p, amount, quote.homeworld); 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); if (amount !== null) { const parties = auctionBureaucratParties(g,p,amount,allyPayment,lot.owner); checkBureaucratContributors(g,parties.payee,parties.shares,allyPayment > 0); } 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') { const quotedBid = quoteNormalAuctionBid({ currentBid: a.bid, amount: action.amount, maximum: (g.kullPreview ? p.hand.some(card => canUseAsKaramaRole(g, p, card)) : !!karamaCard(g, p)) ? Number.MAX_SAFE_INTEGER : p.spice + (aidFor(g, p)?.amount ?? 0), }); if (!quotedBid.ok) throw new RuleError(quotedBid.reason); requireRule(!g.kullPreview || (p.id === byFaction(g, 'choam')?.id && !kullBlocksKarama(g.kullRestrictions, g.turn, g.phase, p.id)) || quotedBid.amount <= p.spice + (aidFor(g, p)?.amount ?? 0), 'Kull preview defers an unfunded Karama-dependent overbid before acceptance.'); a.bid = quotedBid.amount; a.allyPayment = a.bid <= p.spice + (aidFor(g, p)?.amount ?? 0) ? contribution(g, p, a.bid, action.allyPayment) : 0; const parties = auctionBureaucratParties(g,p,a.bid,a.allyPayment); checkBureaucratContributors(g,parties.payee,parties.shares,a.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, g.advanced), '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.', ); requireRule( g.recruits?.turn !== g.turn, 'Grant Fremen ally revival before Recruits; changing the free rate after activation awaits a rules ruling.', ); 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.', ); requireRule(action.leader !== DUKE_VIDAL_ID, 'Only Ecaz may revive Duke Vidal, including with Ghola.'); 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, g.advanced), '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; const uncancelableLimit = Math.max( g.recruits?.turn === g.turn ? 7 : Math.max(3, freeRevivalRate(g, p)), g.revivalRules?.nexusTleilaxu?.includes(p.id) ? 5 : 0); if (choamBenefit && (n > quote.free || p.revived + n > uncancelableLimit)) checks.push('choamRevival'); if (!choamBenefit && p.revived + n > uncancelableLimit) 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); const duke = t === 'reviveLeader' && action.leader === DUKE_VIDAL_ID; if (duke) { requireRule(p.faction === 'ecaz', 'Only Ecaz may revive Duke Vidal.'); const blocked = ecazDukeRevivalBlock(g, p.id); requireRule(!blocked, blocked ?? 'Duke Vidal is unavailable for revival.'); } const dukeQuote = duke ? quoteEcazDukeRevival(g, id, !!revivalDiscount(g, p)) : null; const l = t === 'reviveLeader' ? duke ? g.dukeVidal!.leader : 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 = dukeQuote?.normalCost ?? l?.strength ?? 2; const cost = dukeQuote?.cost ?? (revivalDiscount(g, p) ? Math.ceil(normalCost / 2) : normalCost); requireRule(p.spice >= cost, 'Not enough spice.'); if (cost < normalCost) checks.push('revivalDiscount'); // Duke may be revived independently even while later six-disc native // cycles are gated; that request must not erase an opened first cohort. if (!duke || options.cycle > p.revivalCycle) p.revivalCycle = options.cycle; 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', 'homeworldShip', 'guildHomeworldShip', 'junctionShip', 'move', 'emperorHomeworldMove', '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 === 'offerJunctionTransport') { const option = junctionTransportWindow(g); requireRule(option && option.owner === id && option.canOffer, option?.blocked ?? 'Only high-population Junction can offer transport during another faction’s unused shipment.'); requireRule(action.event === option.offerEvent && (action.rate === 'half' || action.rate === 'full') && Object.keys(action).every((key) => ['type', 'event', 'rate'].includes(key)), 'Choose half or full price for the current Junction opportunity.'); g.junctionOffer = {event: crypto.randomUUID(), turn: g.turn, owner: id, recipient: option.recipient, rate: action.rate}; log(g, `${p.name} offered ${getPlayer(g, option.recipient).name} ${action.rate}-price Junction transport for their current shipment. They may use the offer or choose their ordinary actions.`); return g; } if (t === 'junctionShip') { performJunctionTransport(g, p, action); return g; } if (t === 'homeworldShip' || t === 'guildHomeworldShip') { const guildSecretEvent = action.nexus === undefined ? undefined : validateGuildSecretDeclaration(g,id,action.nexus); requireRule(!guildSecretEvent || t === 'homeworldShip', 'Guild Secret Ally does not grant arbitrary Arrakis-to-Homeworld routes.'); const route = t === 'guildHomeworldShip' ? 'arrakis' as const : undefined; const blocked = route ? guildHomeworldShipmentBlock(g, p) : homeworldShipmentBlock(g, p); requireRule(!blocked, blocked ?? 'Homeworld shipment is unavailable.'); requireRule(Object.keys(action).every((key) => ['type', 'event', 'destination', 'sources', 'allyPayment', ...(guildSecretEvent ? ['nexus'] : [])].includes(key)), 'Choose explicit typed source groups and a Homeworld destination without concealed tokens.'); requireRule(action.event === homeworldShipmentEvent(g, p, route), 'This Homeworld source selection is stale.'); declareHomeworldShipment(g, p, {player: id, destination: stringField(action.destination), sources: action.sources as HomeworldShipmentIntent['sources'], ...(route ? {route} : {}), ...(guildSecretEvent ? {guildSecretEvent} : {})}, action.allyPayment); return g; } if (t === 'ship') { requireRule( g.phase === 5 && g.active === id && shipmentAvailable(g,p), 'You may ship once, before your movement.', ); let nexusEvent: string | undefined; let guildSecretEvent: string | undefined; let richesePair: PendingShipment['richesePair']; if (action.revealedToken !== undefined) { const offer = nexusRicheseCunningOffer(g, id); requireRule(offer && !offer.blocked && action.nexus === offer.event, offer?.blocked ?? 'Richese Cunning is unavailable or stale.'); requireRule(Object.keys(action).sort().join(',') === 'allyPayment,event,nexus,noField,revealedToken,sector,territory,type', 'Choose exactly two physical No-Field tokens and a shipment destination.'); richesePair = { event: offer.event, revealedTokenId: stringField(action.revealedToken) }; } else if (action.nexus !== undefined && action.nexus === nexusGuildSecretAllyOffer(g,id)?.event) { guildSecretEvent = validateGuildSecretDeclaration(g,id,action.nexus); requireRule(Object.keys(action).every(k => ['type','territory','sector','amount','elite','homeworldSources','allyPayment','nexus'].includes(k)), 'Guild Secret Ally needs an ordinary physical reserve shipment.'); } else if (action.nexus !== undefined) { const offer = nexusRicheseOffer(g,id); requireRule(offer && !offer.blocked && action.nexus === offer.event, offer?.blocked ?? 'This Richese Secret Ally shipment is unavailable or stale.'); requireRule(Object.keys(action).every(k => ['type','territory','sector','amount','elite','homeworldSources','allyPayment','nexus'].includes(k)), 'Richese Secret Ally requires an ordinary physical reserve shipment.'); nexusEvent = offer.event; } checkShipmentPromises(g, p, { territory: String(action.territory), amount: richesePair ? 1 : 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, !guildSecretEvent && g.advanced && p.faction === 'fremen', advisors, action.noField === undefined && isAlliedShipmentVisit(g, p, to)); 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); if (richesePair) noFieldRule(() => validateRicheseNoFieldPair(p.noField!, { tokenId, controller: id, location: { territory: to, sector: s }, }, richesePair.revealedTokenId, p.reserves)); else 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! }; } requireRule(!richesePair || noField, 'Richese Cunning needs a concealed native No-Field.'); 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 price = {faction:p.faction,halfRate:shipmentHalfRate(g,p)}; requireRule(action.smuggler === undefined || typeof action.smuggler === 'boolean', 'Choose whether to use Smuggler.'); const smuggler = action.smuggler === true && !noField && !guildSecretEvent && !nexusEvent ? quoteSmugglerShipment(g, id, to, n) : null; const smugglerCompanion = action.smuggler === true && noField && !guildSecretEvent && !nexusEvent ? quoteSmugglerNoField(g, id, to) : null; requireRule(action.smuggler !== true || smuggler || smugglerCompanion, 'Smuggler needs a living face-up native trainer and an empty territory, with two ordinary shipment forces or one reserve companion beside your No-Field.'); const cost = guildSecretEvent ? nexusRule(() => quoteNexusGuildSecretShipment(territory(to).type,n)).cost : nexusEvent ? nexusRule(() => quoteNexusRicheseShipment(price,territory(to).type,n)).cost : reserveShipmentCost(price,territory(to).type,n - (smuggler ? 1 : 0)); if (p.faction === 'fremen' && !guildSecretEvent) { 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 = { ...(smuggler ? { smuggler } : {}), ...(smugglerCompanion ? { smugglerCompanion } : {}), ...(currentGuildCunning(g,id) ? {guildNexusEvent:currentGuildCunning(g,id)!.receipt.event} : {}), ...(nexusEvent ? {nexusEvent} : {}), ...(guildSecretEvent ? {guildSecretEvent} : {}), ...(richesePair ? { richesePair } : {}), turn: g.turn, player: id, territory: to, sector: s, amount: n, elite, cost, allyPayment, advisors, ...(action.homeworldSources === undefined ? {} : { homeworldSources: action.homeworldSources as NativeReserveSelections, }), ...(noField ? { noField } : {}), }; if (noField && g.leaderSkills && !richesePair) { shipment.noFieldSkillProof = noFieldSkillProof(shipment); validatePhysicalShipment(g, shipment); } if (nexusEvent) recordNexusRicheseShipment(g,shipment); bindGuildCunningShipment(g,p,'reserve',shipment); if (shipment.noField) { // This cancellation window bypasses offerShipment, so reject known // unsupported arrival combinations before saving its private intent. validateShipmentArrival(g, shipment); checkShipmentIncomeFunding(g, p, cost, allyPayment); if (richesePair) recordRichesePairShipment(g, shipment); g.pendingShipment = shipment; g.response = { kind: 'richeseNoField', owner: p.id, passed: [], ...(shipment.noFieldSkillProof ? { noFieldSkillProof: shipment.noFieldSkillProof } : {}) }; log( g, `${p.name} declared a concealed No-Field shipment${richesePair ? ' with Richese Nexus Cunning and one faceup token' : ''}${smugglerCompanion ? ' with one free Smuggler force' : ''} to ${territory(to).name}, sector ${s}.${richesePair ? ' The Nexus card is spent; payment and both token effects await the responses.' : ''}`, ); } else offerShipment(g, shipment); return g; } if (t === 'guildShip') { const guildSecretEvent = action.nexus === undefined ? undefined : validateGuildSecretDeclaration(g,id,action.nexus); if (guildSecretEvent) requireRule(Object.keys(action).every(k => ['type','from','forces','eliteForces','amount','elite','territory','sector','allyPayment','nexus'].includes(k)), 'Guild Secret Ally cross-shipment needs explicit physical forces.'); const homeworldTarget = action.territory ?? 'reserves'; if (g.homeworlds?.custody && p.faction === 'guild' && (homeworldTarget === 'reserves' || (typeof homeworldTarget === 'string' && homeworldTarget.startsWith('homeworld:')))) { const blocked = guildHomeworldShipmentBlock(g, p); requireRule(!blocked, blocked ?? 'Guild Homeworld shipment is unavailable.'); requireRule(action.noField === undefined && (action.sector === undefined || action.sector === 0), 'A Homeworld arrival has no planet sector or concealed token.'); const group = forceGroup(g, p, action); const sources = Object.fromEntries(group.group.map(([key, amount]) => [key, {normal: amount - (group.eliteGroup[key] ?? 0), elite: group.eliteGroup[key] ?? 0}])); declareHomeworldShipment(g, p, {route: 'arrakis', player: id, destination: homeworldTarget === 'reserves' ? 'homeworld:guild' : homeworldTarget as string, sources}, action.allyPayment); return g; } 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 && shipmentAvailable(g,p) && (nativeGuildTransportPermission(g, p) || !!guildSecretEvent), 'Guild shipment is not available.', ); const fromReserves = action.from === 'reserves'; requireRule(!guildSecretEvent || !fromReserves, 'Use the reserve shipment selector for paid Guild Secret Ally reinforcements.'); 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(g, 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(fromReserves || to === 'reserves' || to !== origin, 'Cross-shipment requires a different destination territory.'); requireRule( to !== 'reserves' || p.faction === 'guild' || !!guildSecretEvent, '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 halfRate = g.karamaShipping?.player === id || !(g.guildRateBlocked?.turn === g.turn && g.guildRateBlocked.player === id); const cost = guildShipmentCost( to === 'reserves' ? 'reserves' : territory(to).type, n, halfRate); requireRule( p.spice + (aidFor(g, p)?.amount ?? 0) >= cost, 'Not enough spice.', ); const allyPayment = contribution(g, p, cost, action.allyPayment); checkShipmentIncomeFunding(g, p, cost, allyPayment); const frame: GuildTransportOrder = { turn: g.turn, player: id, origin, group, eliteGroup, to, sector, amount: n, elite, cost, allyPayment, advisors, }; if (!guildSecretEvent && openGuildTransportRate(g, p, frame, fromReserves)) return g; commitGuildTransport(g, p, frame, fromReserves, guildSecretEvent); return g; } if (t === 'emperorHomeworldMove') { const blocked = emperorHomeworldMoveBlock(g, p); requireRule(!blocked, blocked ?? 'Homeworld movement is unavailable.'); requireRule( Object.keys(action).every((key) => ['type', 'event', 'origin', 'normal', 'elite'].includes(key), ), 'Choose only the source Homeworld and physical force types.', ); requireRule( action.event === emperorHomeworldMoveEvent(g, p), 'This Homeworld movement selection is stale.', ); const quote = homeworldRule(() => quoteEmperorHomeworldMove( { ...homeworldContext(g), status: g.status, phase: g.phase, currentPlayer: g.active, movesLeft: movesAllowed(g, p) - p.moved, }, g.homeworlds!.custody!, id, { origin: stringField(action.origin) as EmperorHomeworld, forces: { normal: integer(action.normal, 0, 20, 'Normal forces'), elite: integer(action.elite, 0, 5, 'Sardaukar'), }, }, ), ); g.homeworlds!.custody = quote.state; observeOccupation(g); finishShipmentPromises(g, p, null); p.shipped = true; p.moved++; const name = (world: string) => world === 'homeworld:emperor' ? 'Kaitain' : 'Salusa Secundus'; const shifts = quote.populations.after .filter((world) => world.native === id) .map( (world) => `${name(world.location)} now has ${world.population} ${world.card === 'salusa_secundus' ? 'Sardaukar' : 'native reserves'} (${world.side} population)`, ) .join('; '); log( g, `${p.name} moved ${quote.forces.normal} normal forces and ${quote.forces.elite} Sardaukar from ${name(quote.origin)} to ${name(quote.destination)}. This spends one movement and no spice; total reserves are unchanged. ${shifts}.`, ); return g; } if (t === 'move') { requireRule(action.discoveryFlight === undefined, 'Choose a carried Discovery Ornithopter token; saved flight receipts are internal.'); if (action.noField !== undefined) { const blocked = homeworldRule(() => homeworldNoFieldMovementBlock(g, id)); requireRule(!blocked, blocked ?? 'The No-Field cannot 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 skillLeader = planetologistLeader(g, id); requireRule(action.origins === undefined && (action.planetologist === undefined || (skillLeader && (action.planetologist === 'range' || action.planetologist === 'gather'))), 'Choose an available Planetologist movement mode.'); const planetologist: PlanetologistMovement | undefined = action.planetologist === undefined ? undefined : {leader: skillLeader!, mode: action.planetologist as PlanetologistMovement['mode']}; const { group, eliteGroup, elite, origin, origins, total: n, noField, sourceKeys, } = forceGroup(g, p, action, planetologist?.mode === 'gather' ? 2 : 1); const to = stringField(action.territory), s = integer(action.sector, 0, 18, 'Sector'); const {advisors, lockedTurn, wantsFighters} = movementStance(g, p, origins, to, action.fighters === true); requireRule( origins.every(source => !balisetPrevents(g, id, source, 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.', ); // Reject known unsupported arrivals before any movement-card event or // native movement-prevention window can commit this declaration. validateMovementArrival(g, { player: id, group, eliteGroup, elite, origin, origins, total: n, to, sector: s, advisors, wantsFighters, lockedTurn, }); 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); 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 move: MovementOrder = { player: id, group, eliteGroup, elite, origin, ...(planetologist ? {planetologist} : {}), ...(planetologist?.mode === 'gather' ? {origins} : {}), total: n, to, sector: s, advisors, wantsFighters, lockedTurn, ...(noField ? { noField } : {}), ...(flight ? { ornithopterEvent: flight.event, ornithopterRange: flight.mode === 'range3', } : {}), }; if (action.discoveryOrnithopter !== undefined) move.discoveryFlight = nexusRule(() => quoteDiscoveryFlight(g, action.discoveryOrnithopter, move)); validatePlanetologistMove(g, p, move); const speed = movementOrderRange(g, p, move); if (action.sandmaster !== undefined) { move.sandmaster = nexusRule(() => quoteSandmasterMovement(g, id, sandmasterOrder(g, p, move), action.sandmaster)); validateSandmasterOrder(g, p, move); } requireRule( sourceKeys.every( (key) => gameDistance(g, key, location(to, s), (k) => pathBlocked(g, p, k, isAdvisor(p, splitLocation(key).territory)), ) <= movementOrderRange(g, p, move, splitLocation(key).territory), ), planetologist?.mode === 'gather' ? 'Each selected origin must have an unblocked route within its own movement range.' : `That destination is blocked or more than ${speed} territories away.`, ); validateFlightSelection(g, p, move); if ( !move.ornithopterRange && !move.discoveryFlight && p.faction === 'ixians' && elite > 0 && !(p.ixMovementBlocked?.turn === g.turn && p.ixMovementBlocked.move === p.moved) && !fighterCount(p, 'arrakeen') && !fighterCount(p, 'carthag') && (move.sandmaster ? Object.values(move.sandmaster.routes).some(route => sandmasterRouteDistance(g, route) > 1) : group.some(([key]) => selectedOriginElites(group, eliteGroup, splitLocation(key).territory) > 0 && gameDistance(g, key, location(to, s), (k) => pathBlocked(g, p, k, isAdvisor(p, splitLocation(key).territory)), ) > planetologistRange(1, planetologist?.mode))) ) { g.pendingIxMove = move; g.response = { kind: 'ixMovement', owner: id, location: location(to, s), amount: n, elite, passed: [], }; } else if ( !move.ornithopterRange && !move.discoveryFlight && p.faction === 'fremen' && p.fremenNexusMovementBlockedTurn !== g.turn && !(p.fremenMovementBlocked?.turn === g.turn && p.fremenMovementBlocked.move === p.moved) && !fighterCount(p, 'arrakeen') && !fighterCount(p, 'carthag') && (move.sandmaster ? Object.values(move.sandmaster.routes).some(route => sandmasterRouteDistance(g, route) > 1) : group.some( ([key]) => gameDistance(g, key, location(to, s), (k) => pathBlocked(g, p, k, isAdvisor(p, origin))) > planetologistRange(1, planetologist?.mode))) ) { g.pendingFremenMove = { turn: g.turn, move: p.moved, order: move }; g.response = { kind: 'fremenMovement', ...(move.sandmaster ? {sandmasterProof: JSON.stringify(move.sandmaster)} : {}), owner: id, location: location(to, s), amount: n, passed: [], }; } else offerChoamMovement(g, move); return g; } if (t === 'nexusGuildSkipShipment') { const record = currentGuildCunning(g,id); requireRule(g.status === 'playing' && g.phase === 5 && g.active === id && record?.stage === 'secondShipment' && !record.shipment && action.event === record.receipt.event && Object.keys(action).every(k => ['type','event'].includes(k)), 'Only the current unused Guild Cunning shipment may be declined.'); finishShipmentPromises(g,p,null); record.shipment = {kind:'declined',stage:'declined'}; record.stage = 'extraMove'; saveGuildCunning(g,record); log(g,`${p.name} declined the second Guild shipment. No forces or spice moved; only an unused Hajr movement remains available.`); return g; } if (t === 'endMovement') { finishShipmentPromises(g, p, null); requireRule( g.phase === 5 && g.active === id, 'Wait for your movement turn.', ); if (action.nexus !== undefined) { declareGuildCunning(g,p,action.nexus); return g; } // Reject unsupported concealed custody before retiring a played movement // card and parking its native ending continuation. requireAlliedSeparationCustody(g, p); 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.chooser === id && b.territory === action.territory && (b.attacker === id ? b.defender : b.attacker) === action.target, ); requireRule(choice, 'Choose one of your unresolved battles.'); g.auditorInsight = null; retireNexusChoamInsight(g); const ecaz = byFaction(g, 'ecaz'); const ally = ecaz?.ally ? getPlayer(g, ecaz.ally) : null; const connected = ecaz && ally && occupyArmiesConnected(g, ecaz, ally, choice.territory); const combined = (g.advanced || g.ecazOccupyPreview === true) && ecaz && ally?.ally === ecaz.id && connected && !isAdvisor(ally, choice.territory) && [choice.attacker, choice.defender].some(seat => seat === ecaz.id || seat === ally.id); const noFieldPlayers = g.players.filter(player => player.noField?.deployed?.location.territory === choice.territory && (player.id === choice.attacker || player.id === choice.defender || (g.advanced && connected && ecaz && ally?.ally === ecaz.id && player.id === ally.id && (choice.attacker === ecaz.id || choice.defender === ecaz.id)))).map(player => player.id); let mixedNoFieldPlayers: string[] | undefined; for (const playerId of noFieldPlayers) { const mixed = Object.entries(getPlayer(g, playerId).forces).some( ([key, n]) => n > 0 && splitLocation(key).territory === choice.territory, ); if (!mixed) continue; requireRule(!combined, 'Combined Occupy No-Field battle dialing awaits a ruling. Ordinary mixed battles use the visible provisional pool.'); (mixedNoFieldPlayers ??= []).push(playerId); } if (combined) { requireRule(ecazOccupyCompositionSupported(g), 'Combined Occupy requires source-selected native factions; optional overlays remain gated.'); } const battleEvent = crypto.randomUUID(); g.battle = { ...choice, chooserEvent: battleChooserEvent(g.turn, battleOrderAfterBattle(g), g.battleOrder?.uses.length ?? 0), event: battleEvent, ...(g.leaderSkills ? { smugglerCollectionVersion: 1 as const } : {}), ...(g.leaderSkills && mentatQuestionModeSupported(g) ? { mentatQuestionVersion: 1 as const } : {}), ...(g.leaderSkills && diplomatDefenseModeSupported(g) ? { diplomatDefenseVersion: 2 as const } : {}), ...(g.leaderSkills && ordinaryLeaderSkillModeSupported(g) ? { diplomatRetreatVersion: 1 as const } : {}), ...(g.ecazTreachery ? { harassAllocationVersion: 1 as const } : {}), ...(byFaction(g, 'richese') ? { preLeader: { event: battleEvent, ready: [], closed: false } } : {}), ...(noFieldPlayers.length ? { noFieldPlayers } : {}), ...(mixedNoFieldPlayers ? { mixedNoFieldPlayers } : {}), prepared: true, plans: {}, revealed: false, traitorCalls: {}, }; if (combined) { g.battle.ecazOccupy = { event: battleEvent, ecaz: ecaz!.id, ally: ally!.id, lead: null, canceled: false }; g.decision = { kind: 'ecazBattleLead', player: ecaz!.id, event: battleEvent, choices: [ecaz!.id, ally!.id] }; } else { createMentatQuestion(g); beginStrongholdBattle(g); } log( g, `${p.name} chose the battle between ${getPlayer(g, choice.attacker).name} and ${getPlayer(g, choice.defender).name} in ${combatLocationName(g, choice.territory)}. ${getPlayer(g, choice.attacker).name} remains the aggressor.`, ); return g; } if (t === 'declineBattlePower') { const b = g.battle; requireRule( b?.preparation?.owner === id && !['nexusVoice', 'prescienceAnswer', 'nexusPrescienceAnswer'].includes(b.preparation.kind), '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' || !battleNoFieldPlan(g, 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 === 'nexusPrescienceAnswer') { const b = g.battle, record = b?.nexusInspection; requireRule(b?.preparation?.kind === 'nexusPrescienceAnswer' && b.preparation.owner === id && record?.stage === 'answer' && record.target === id && action.event === record.event && Object.keys(action).sort().join(',') === 'event,type,value', 'This Nexus answer does not match your current inspection.'); requireRule(record.mode !== 'cunning' || record.field !== 'dial' || !battleNoFieldPlan(g, id), 'Atreides may not inspect the number dialed in a No-Field battle.'); const value = record.field === 'dial' ? typeof action.value === 'number' ? action.value : NaN : action.value === null || action.value === '' ? null : stringField(action.value); answerCurrentNexusInspection(g, value); 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' || !battleNoFieldPlan(g, id), 'Atreides may not inspect the number dialed in a No-Field battle.', ); const value = field === 'dial' ? g.advanced || p.faction === 'ixians' || b.ecazOccupy?.lead === p.id ? typeof action.value === 'number' ? action.value : NaN : integer(action.value, 0, (combatArmy(g, p.id, b.territory).normal + combatArmy(g, p.id, b.territory).elite), '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.', ); if (b.nexusInspection?.mode === 'cunning') b.nexusInspection = nexusRule(() => answerNexusNative(battleInspectionContext(g), b.nexusInspection!, value)); b.prescience.value = value; finishInspectionAnswers(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.', ); const target = b.attacker === b.preparation.beneficiary ? b.defender : b.attacker; b.voice = quoteVoiceCommand(g, target, action.kind, 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 (g.advanced && b.plans[id].leader === null) log(g, `${p.name} announced a Battle Plan without a leader or Cheap Hero; no battle cards can be played.`); 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.'); if (b.smugglerCollectionVersion === 1) { b.smugglerCollection = pendingSmugglerBattle(g); if (b.smugglerCollection) log(g, `${getPlayer(g, b.smugglerCollection.player).name}'s Smuggler may collect ${b.smugglerCollection.amount} spice from the reveal-time pile if the leader survives. That spice is not yet spendable.`); } 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 (g.nexusCards?.cards || b.ecazOccupy) ensureTraitorDeclarations(g); 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.', ); } if (action.call && b.traitorDeclarationVersion) b.traitorDeclarations![id] = nexusRule(() => createTraitorDeclaration(traitorDeclarationContext(g), id)); b.traitorCalls[id] = action.call; if (action.call && g.moritaniAssassinate?.owner === id) { requireRule(b.event && !g.moritaniAssassinateCallEvents!.includes(b.event), 'This normal traitor call was already recorded.'); g.moritaniAssassinate.normalTraitorCall = true; g.moritaniAssassinateCallEvents!.push(b.event); } if ( action.call && p.faction === 'harkonnen' && ![b.attacker, b.defender].includes(id) && !(b.ecazOccupy && [b.ecazOccupy.ecaz, b.ecazOccupy.ally].includes(id)) ) { g.response = { kind: 'harkonnenTraitor', owner: id, passed: [] }; return g; } if (action.call && offerHarkonnenNexusBetrayal(g,id)) return g; if ( traitorVoters(g, b).every((voter) => b.traitorCalls[voter] !== undefined) ) if (!b.territory.startsWith('homeworld:')) advanceBattleOutcome(g); else advanceHomeworldReveal(g); return g; } if (t === 'card') { const c = p.hand.find((c) => c.id === action.card); requireRule( c && (c.kind === 'special' || canUseAsKaramaRole(g, p, c)), 'Select a special card in your hand.', ); if ( canUseAsKaramaRole(g, p, c) && action.mode === 'shipment' ) { const recipient = getPlayer(g, stringField(action.target ?? id)); requireRule( g.phase === 5 && g.active === recipient.id && shipmentAvailable(g,recipient), '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 ( canUseAsKaramaRole(g, p, 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.', ); const used = discard(g, p, c.id); log( g, `${p.name} played Thumper in place of the next spice-card reveal.`, ); if (g.semutaPreview) { const continuation: ThumperDiscardContinuation = { kind: 'thumperDiscard', player: p.id, card: used.id, resume: { response: g.response, decision: g.decision, pendingKarama: g.pendingKarama, phaseOpening: g.phaseOpening }, stateSignature: '', }; const entries = [{ card: used, discardedBy: p.id, publicFace: true }]; if (semutaOfferSupported(g, continuation, entries)) { continuation.stateSignature = thumperDiscardSignature(g, continuation); stageTreacheryDiscard(g, 'thumper', entries, continuation); return g; } } blowSpice(g, true); return g; } const availability = ordinaryCardAvailability( { status: g.status, phase: g.phase, active: g.active, me: id, nexusGuildCunning: projectedNexusGuildCunning(g,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, c.id); } 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.stormMovementSource = nexusRule(() => createStormSource(g.turn,'weather',g.stormPending!)); 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) startStormMovement(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) { state = migrateAtomicsTargets(state); state = migrateFremenNexusRevival(state); harkonnenNexusBetrayalIntegrity(state); ixianNexusBetrayalIntegrity(state); ixianNexusReplacementIntegrity(state); guildBetrayalIntegrity(state); state = migratePrintedKullCounter(state); richeseBetrayalIntegrity(state); nexusEmperorPaymentIntegrity(state); nexusEmperorSellerIntegrity(state); nexusEmperorAmbassadorIntegrity(state); kullIntegrity(state); advancedPreviewIntegrity(state); harkonnenExchangeIntegrity(state); validateEcazLoyalty(state); ecazTreacheryIntegrity(state); leaderSkillsIntegrity(state); moritaniExtortionIntegrity(state); guildRateIntegrity(state); arrivalOverlapIntegrity(state); discoveryFlightIntegrity(state); discoveryIntegrity(state); greatMakerIntegrity(state); nexusChoamTradeIntegrity(state); nexusChoamInspectionIntegrity(state); nexusMoritaniRetentionIntegrity(state); nexusChoamBetrayalIntegrity(state); nexusEcazInquiryIntegrity(state); nexusEcazDukeIntegrity(state); richesePairIntegrity(state); marketGholaIntegrity(state); homeworldRule(() => homeworldGameIntegrity(state)); homeworldBattleLossIntegrity(state); homeworldSubstitutionIntegrity(state); homeworldDefenseIntegrity(state); homeworldShipmentIntegrity(state); karamaConversionIntegrity(state); treacheryDiscardIntegrity(state); shipmentPromiseIntegrity(state); saphoMovementIntegrity(state); ambassadorRelocationIntegrity(state); ecazCollectionIntegrity(state); ecazAllianceIntegrity(state); battleSpecialWeaponIntegrity(state); const g = structuredClone(state); normalizeCardNames(g); settleAdvisors(g); normalizeBattle(g); const me = getPlayer(g, id); const choamPlayer = byFaction(g, 'choam'); let victoryProgress: StrongholdProgress[] = []; let fremenVictory: FremenVictoryProgress | null = null; let ecazHomeworldVictory: EcazHomeworldVictoryProgress | null = null; if (g.status === 'playing' || g.status === 'finished') { try { victoryProgress = strongholdProgress(state).progress; fremenVictory = fremenSpecialVictory(state); if (g.homeworldEcazVictoryPreview) ecazHomeworldVictory = ecazHomeworldVictoryProgress(state); } 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; } } // A concealed capture may change the real death count without revealing it // to the native owner. Derive guidance from the same projected discs that // the seat can inspect; action validation still uses authoritative custody. const visibleRevivalGame = me.faction === 'ecaz' && g.dukeVidal ? { ...g, dukeVidal: { ...g.dukeVidal, leader: projectLeader(g, g.dukeVidal.leader, id) } } : g; const leaderRevivals = leaderRevivalOptions(visibleRevivalGame, { ...me, leaders: me.leaders.map((leader) => projectLeader(g, leader, id)) }); const b = g.battle; const ixAuthority = g.homeworldOccupationPreview ? quoteOccupiedBiddingAuthority(g, 'ixAuction') : null; const ixPoolStale = !!(g.homeworldOccupationPreview && g.ixAuction?.authority && g.ixAuction.authority !== JSON.stringify(quoteStableHomeworldOccupation(g, 'ix'))); const ixPoolAuthorized = !g.homeworldOccupationPreview || !ixPoolStale && !ixAuthority?.blocked && ixAuthority?.controller === id && g.decision?.kind === 'ixAuction' && g.decision.player === id; const cacheAuthority = g.homeworldOccupationPreview && g.richeseBidding?.stage === 'cacheOffer' ? quoteOccupiedBiddingAuthority(g, 'richeseCache') : null; const cacheStale = !!(cacheAuthority && g.richeseBidding?.cacheAuthority && g.richeseBidding.cacheAuthority !== JSON.stringify(quoteStableHomeworldOccupation(g, 'richese'))); 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, ) || committedPlanElements(b, id).length > 0) ? findReachableBattlePlan(g, me) : null; return { harkonnenExchangeInspection: projectedHarkonnenInspection(g, id), ecazPoisonIncome: (g.ecazPoisonIncome ?? []).filter((income) => income.player === id) .map(({turn, phase, amount, count}) => ({turn, phase, amount, count})), biddingEnd: g.biddingEnd ? { event: g.biddingEnd.event, owners: [...g.biddingEnd.owners], ready: [...g.biddingEnd.ready], canAct: biddingEndQuiet(g) && g.biddingEnd.owners.includes(id), kaitain: { owner: byFaction(g, 'emperor')!.id, eligible: homeworldRule(() => highKaitainDiscardsAvailable(g, byFaction(g, 'emperor')!.id)) } } : null, combatLocations: combatLocations(g), battleChoices: g.status === 'playing' && g.phase === 6 ? battleOrderQuote(g).choices : [], battleOrder: projectedBattleOrder(g), guildAmbassadorAdvisorChoices: guildAdvisorChoices(g, id), truthShipmentSupported: shipmentPromiseModeSupported(g), truthShipmentAnswers: g.truthtrance?.stage === 'answer' && g.truthtrance.question?.target === id && (g.truthtrance.question.kind === 'shipment' || g.truthtrance.question.kind === 'mixedShipment') ? shipmentTruthAnswers(g, me, truthShipmentClaim(g,me,g.truthtrance.question)) : null, // Mixed compiled claims contain private past-clause answers. The complete // original question and whole answer remain public through truthHistory. shipmentPromises: (g.shipmentPromises ?? []).filter(p => !p.mixed || p.player === id), shipmentCompletion: !g.pendingTreacheryDiscard && !g.response && !g.pendingKarama && liveShipmentPromises(g.shipmentPromises ?? [], id, g.turn).some( (p) => matchesShipment(p,null) !== 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, truthKnowledgeOf(g, me)) : null, schema: g.schema, botsPending: g.botsPending ?? false, semutaReaction: projectedSemutaReaction(g, me), semutaPreview: g.semutaPreview === true, automaticContinuationPending: (!!g.pendingTreacheryDiscard && !g.pendingTreacheryDiscard.reaction) || homeworldRevealPending(g) || homeworldShipmentAutomatic(g) || homeworldOccupiedIncomeAutomatic(g) || grummanCollectionAutomatic(g) || tupileAutomatic(g), 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), leaderSkills: projectedLeaderSkills(g, id), bureaucrat: projectedBureaucrat(g, id), spiceBankerIncomePreview: g.spiceBankerIncomePreview === true, homeworldOccupationPreview: g.homeworldOccupationPreview === true, homeworldTupilePreview: g.homeworldTupilePreview === true, homeworldEcazVictoryPreview: g.homeworldEcazVictoryPreview === true, spiceBankerIncome: g.spiceBankerIncomePreview ? bankerIncomeRule(() => projectBankerIncome(g.spiceBankerIncome!, bankerIncomeContext(g))) : null, bribeOptions: projectedBribes(g, me), mentat: projectedMentat(g, id), rihani: projectedRihani(g, id), advanced: g.advanced, advancedPreview: g.advancedPreview === true, dukeVidal: g.dukeVidal ? { ...g.dukeVidal, leader: projectLeader(g, g.dukeVidal.leader, id) } : null, moritaniTerror: g.moritaniTerror ? projectTerror(g.moritaniTerror, me.faction === 'moritani') : null, moritaniAtomics: g.moritaniAtomics ? { territory: g.moritaniAtomics.territory, turn: g.moritaniAtomics.turn, moritaniId: g.moritaniAtomics.moritaniId, allyAtActivation: g.moritaniAtomics.allyAtActivation } : null, arrivalOverlapMode: arrivalOverlapModeSupported(g), arrivalOverlap: g.pendingArrivalOverlap ? { first: g.pendingArrivalOverlap.first, active: g.pendingArrivalOverlap.stage === 'first' ? g.pendingArrivalOverlap.first : g.pendingArrivalOverlap.first === 'ambassador' ? 'terror' : 'ambassador', entrant: g.pendingArrivalOverlap.entrant, territory: g.pendingArrivalOverlap.territory, } : null, extortion: { deferred: g.moritaniExtortion?.stage === 'reserved' ? 5 : 0, pending: g.moritaniExtortion?.stage === 'payment' && g.decision?.kind === 'moritaniExtortion' ? { event: g.decision.event, owner: g.moritaniExtortion.owner, player: g.decision.player, amount: 3 as const, canPay: me.id === g.decision.player ? me.spice >= 3 : null, } : null, }, moritaniPendingPlacement: me.faction === 'moritani' ? (g.pendingMoritaniPlacement ?? null) : null, grummanCollection: projectedGrummanCollection(g, id), homeworldOccupiedIncome: projectedHomeworldOccupiedIncome(g, id), homeworldOccupiedPercentage: projectedOccupiedPercentage(g), homeworldOccupiedBonus: projectedOccupiedBonus(g), homeworldTupileCleanup: projectedTupileCleanup(g, id), homeworldTupileBlocked: tupileSourceBlock(g), tupileIntelligence: projectedTupileIntelligence(g, id), 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' && entry.stage === 'select' ? { candidates: entry.candidates!.map((id) => { const candidate = g.moritaniTerror!.tokens.find((t) => t.id === id)!; return { token: id, kind: candidate.kind, canReveal: !terrorRevealBlocked(g, entry, candidate.kind), canOfferAlliance: !terrorAllianceBlocked(g, entry, candidate.kind), revealBlocked: terrorRevealBlocked(g, entry, candidate.kind) ?? undefined }; }) } : {}), ...(me.faction === 'moritani' && token && entry.stage !== 'select' ? { 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' || g.ixAuctionKnown?.controller === id || g.decision?.kind === 'ixAuction' && g.decision.player === id ? { blocked: ixAuthority?.blocked ?? (ixPoolStale ? 'The original Ixian pool authority changed.' : null), setup: me.faction === 'ixians' ? g.ixSetupCards ?? null : null, pool: ixPoolAuthorized && g.ixAuction?.cards.length ? [...g.ixAuction.cards].sort((a, b) => a.id.localeCompare(b.id)) : null, known: g.ixAuctionKnown?.turn === g.turn && (!g.homeworldOccupationPreview || g.ixAuctionKnown.controller === id) ? [...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, ...ambassadorArrivalChoices( g, id, 'reserves', destination.territory, ), })), }; })() : null, movement: entry.stage === 'move' && id === entry.beneficiary ? ambassadorRelocationMovement(g, id) : null, allianceOffer: entry.stage === 'offer' && token.effect === 'ecaz' && id === owner.id ? { blocked: homeworldAllianceReason(g, owner.id, entrant.id) ?? ecazAllianceBlock(g, owner.id, entrant.id) } : null, dukeAcquisition: entry.stage === 'offer' && token.effect === 'ecaz' && id === owner.id ? { blocked: ambassadorEffectBlock(g, 'ecaz', owner, entrant) } : null, dukeLoan: entry.stage === 'loan' && id === owner.id ? { blocked: ecazAmbassadorLoanBlock(g, 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, entry.effect), })) : [], revival: entry.stage === 'revival' && id === entry.beneficiary ? { maximum: Math.min(4, me.tanks), tanks: me.tanks, eliteTanks: me.elites?.tanks ?? 0, eliteRevived: me.elites?.revived ?? 0, leaders: tleilaxuAmbassadorLeaderChoices(g, me), blocked: ambassadorEffectBlock( g, 'tleilaxu', me, entrant, ) ?? null, } : null, }; })() : 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, nexusChoamInsight: g.nexusChoamInsight?.viewer === id ? structuredClone(g.nexusChoamInsight) : null, mobileStronghold: g.mobileStronghold ?? null, mobileRoute: g.pendingMobileMove ? { route: g.pendingMobileMove.route, collect: g.pendingMobileMove.collect, sandmasterPiles: g.pendingMobileMove.sandmasterPiles, } : null, techTokens: g.techTokens ?? null, ecazLoyalty: g.ecazLoyalty?.card ? { player: g.ecazLoyalty.player, card: g.ecazLoyalty.card } : null, ecazOccupyPreview: !!g.ecazOccupyPreview, strongholdCards: g.strongholdCards ?? null, discoveries: discoveryChoices(g,id,g.phase === 7 && grummanCollectionAutomatic(g)), jacurutuDial: g.decision?.kind === 'jacurutuDial' && g.decision.player === id && g.battle?.jacurutuDial ? {event:g.battle.jacurutuDial.event,owner:g.battle.jacurutuDial.owner,winner:g.battle.jacurutuDial.winner, choices:structuredClone(g.battle.jacurutuDial.choices)} : null, ecologicalStorm: g.decision?.kind === 'ecologicalStorm' && g.decision.player === id && g.ecologicalStorm?.stage === 'choose' ? {...nexusRule(() => discoveryStormOffer(g,g.ecologicalStorm))!,event:g.ecologicalStorm.event} : null, discoveryEntry: g.discoveryEntry?.stage === 'choose' && g.decision?.kind === 'discoveryEntry' && g.decision.player === id ? {...nexusRule(() => quoteDiscoveryEntry({...g,discoveries:g.discoveries!},g.discoveryEntry!,id)),event:g.decision.event} : null, greatMaker: g.greatMaker ? { event:g.greatMaker.event, turn:g.greatMaker.turn, stage:g.greatMaker.stage, votes:structuredClone(g.greatMaker.votes), order:[...g.greatMaker.order], ride:greatMakerRideOptions(g,id) } : null, nexusCards: projectedNexusCards(g, id), nexusHarkonnenBetrayalPreview: !!g.nexusHarkonnenBetrayalPreview, nexusHarkonnenBetrayalReaction: harkonnenNexusBetrayalView(g,id), nexusIxianBetrayalPreview: !!g.nexusIxianBetrayalPreview, nexusIxianBetrayalReaction: ixianNexusBetrayalView(g,id), nexusIxianReplacementPreview: !!g.nexusIxianReplacementPreview, nexusIxianReplacement: ixianNexusReplacementView(g,id), nexusChoamTrade: currentNexusChoamTrade(g, id), nexusChoamInspection: quoteNexusChoamInspection(g, id), nexusChoamBetrayal: quoteNexusChoamBetrayal(g, id, g.grummanCollection?.stage === 'waiting' && grummanCollectionAutomatic(g)), nexusEcazInquiry: { offer: quoteEcazInquiry(g, id, g.grummanCollection?.stage === 'waiting' && grummanCollectionAutomatic(g)), history: ecazInquiryHistory(g, id), }, nexusEcazDuke: nexusRule(() => quoteNexusEcazDuke(g, id)), nexusEmperorSecretAlly: nexusEmperorSecretAllyOffer(g,id), nexusEmperorBetrayal: nexusEmperorBetrayalOffer(g, id), nexusEmperorPayment: nexusEmperorPaymentOffer(g, id), nexusEmperorSellerPurchase: nexusEmperorSellerOffer(g, id), nexusEmperorAmbassadorPurchase: nexusEmperorAmbassadorOffer(g, id), nexusBgBetrayal: nexusBgBetrayalOffer(g, id), nexusBorrowedVoice: nexusBorrowedVoiceOffer(g, id), nexusFremenRevival: fremenNexusRevivalOffer(g,id), nexusFremenProtection: nexusFremenProtectionOffer(g, id), nexusMoritani: nexusMoritaniOffer(g,id), nexusMoritaniBetrayal: moritaniBetrayalOffer(g,id, g.grummanCollection?.stage === 'waiting' && grummanCollectionAutomatic(g)), nexusEcazBetrayal: ecazBetrayalOffer(g, id, g.grummanCollection?.stage === 'waiting' && grummanCollectionAutomatic(g)), nexusFremenBetrayal: fremenBetrayalOffer(g, id, g.grummanCollection?.stage === 'waiting' && grummanCollectionAutomatic(g)), nexusRichese: nexusRicheseOffer(g,id), nexusRicheseCunning: nexusRicheseCunningOffer(g, id), nexusGuildSecretAlly: nexusGuildSecretAllyOffer(g,id), nexusGuildCunning: projectedNexusGuildCunning(g,id), nexusTraitors: projectedNexusTraitors(g, id), nexusTleilaxu: projectedNexusTleilaxu(g, id), nexusSuboids: projectedNexusSuboids(g, id), nexusAdvisors: projectedNexusAdvisors(g, id), nexusSardaukar: projectedNexusSardaukar(g,id), nexusAtreides: nexusAtreidesOffer(g, id), homeworldRevivalDeployment: projectedHomeworldRevivalReturn(g, id), caladanReinforcement: projectedHomeworldVictoryReturn(g, id), homeworldRevivalBlocks: homeworldRevivalChoiceBlocks(g, me), homeworlds: g.homeworlds ? { worlds: homeworldRule(() => homeworldTable(g)) } : null, homeworldMove: g.homeworlds?.custody && g.advanced && me.faction === 'emperor' ? { event: emperorHomeworldMoveEvent(g, me), blocked: emperorHomeworldMoveBlock(g, me), remaining: Math.max(0, movesAllowed(g, me) - me.moved), } : null, homeworldShipment: g.homeworlds?.custody ? {event: homeworldShipmentEvent(g, me), blocked: homeworldShipmentBlock(g, me)} : null, guildHomeworldShipment: g.homeworlds?.custody && me.faction === 'guild' ? {event: homeworldShipmentEvent(g, me, 'arrakis'), blocked: guildHomeworldShipmentBlock(g, me)} : null, junctionTransport: junctionTransportWindow(g), homeworldMobility: { foresightBlocked: homeworldRule(() => homeworldMovementForesightBlock(g, me.id)), mobileStrongholdBlocked: homeworldRule(() => homeworldMobileStrongholdMovementBlock(g, me.id)), noFieldMovementBlocked: homeworldRule(() => homeworldNoFieldMovementBlock(g, me.id)), advisorSinkMaximum: spiritualAdvisorMaximum(g, me.id), advisorAccompanyMaximum: spiritualAdvisorMaximum(g, me.id, 'arrakeen'), }, expansions: g.expansions, ecazTreachery: !!g.ecazTreachery, 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.pendingNexusHarkonnenBetrayal ? (() => {const remaining = g.pendingNexusHarkonnenBetrayal.required.filter(seat => !g.pendingNexusHarkonnenBetrayal!.passed.includes(seat)); return remaining.length === 1 ? remaining[0] : null;})() : g.pendingNexusIxianBetrayal ? (() => {const remaining = g.pendingNexusIxianBetrayal.required.filter(seat => !g.pendingNexusIxianBetrayal!.passed.includes(seat)); return remaining.length === 1 ? remaining[0] : null;})() : g.active, movementRemaining: g.movementRemaining ?? null, guildTimingLocked: g.guildTimingLocked ?? false, ready: g.ready, spice: g.spice, // Public physical draw piles only; auction pools and private holdings stay separate. drawPiles: g.status === 'lobby' ? null : { treachery: g.deck.length, spice: g.spiceDeck.length }, 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.nexusCards?.phase?.stage !== 'drawing' && !g.spiceSequence && !g.spiceResolution && !g.spiceWindow && !g.nexus, spiceDiscardTop: g.spiceDiscard.map((pile) => pile.at(-1) ?? null), allianceOffers: g.allianceOffers, homeworldAllianceBlocks: Object.fromEntries(g.players.filter((p) => p.id !== id) .flatMap((p) => { const reason = homeworldAllianceReason(g, id, p.id); return reason ? [[p.id, reason]] : []; })), 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, fremenVictory, ecazHomeworldVictory, shieldWallDestroyed: g.shieldWallDestroyed, stormDialers: g.stormDialers, stormSubmitted: Object.keys(g.stormDials), karamaShipping: g.karamaShipping ? { player: g.karamaShipping.player, owner: g.karamaShipping.owner } : null, guildRateCanceled: g.guildRateBlocked?.turn === g.turn && g.guildRateBlocked.player === id, stormPending: g.stormPending ?? null, advancedAllySeparation: advancedAllySeparationView(g, me), basicAlliedShipment: basicAlliedShipmentView(g, me), 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, guildBetrayalPreview: !!g.guildBetrayalPreview, guildBetrayalReaction: g.pendingGuildBetrayal ? (() => { const frame = g.pendingGuildBetrayal; const hasPassed = frame.passed.includes(id); const canPass = frame.required.includes(id) && !hasPassed; const eligible = nexusRule(() => guildBetrayalEligible(g.nexusCards!.cards!, g.players, id)); const blocked = eligible ? guildBetrayalUseBlock(g, id) : null; return {event: frame.invoice.event, shipper: frame.invoice.shipper, canPass, hasPassed, canUse: canPass && eligible && !blocked, blocked}; })() : null, richeseBetrayalPreview: !!g.richeseBetrayalPreview, richeseBetrayalReaction: g.pendingRicheseBetrayal ? (() => { const receipt = g.pendingRicheseBetrayal.receipt, invoice = receipt.invoice; const hasPassed = receipt.passed.includes(id); const canPass = receipt.required.includes(id) && !hasPassed; const blocked = richeseBetrayalUseBlock(g,id); return {event:receipt.event,kind:invoice.kind,target:invoice.target,buyer:invoice.buyer, source:invoice.source,price:invoice.price,canPass,hasPassed,canUse:canPass && !blocked,blocked}; })() : null, kullPreview: !!g.kullPreview, nexusKullPreview: !!g.nexusKullPreview, kullCounterEvent: g.pendingKull?.stage === 'counter' ? g.pendingKull.event : null, karamaBlocked: g.kullPreview && kullBlocksKarama(g.kullRestrictions, g.turn, g.phase, id) ? 'Kull Wahad prevents your Karama activations for this phase.' : null, kullReaction: g.pendingKull?.stage === 'offer' ? { event: g.pendingKull.event, player: g.pendingKull.player, target: g.pendingKull.owner, intent: g.pendingKull.intent.kind === 'ordinary' ? g.pendingKull.intent.use.kind : 'special', plays: id === g.pendingKull.player ? kullPlays(g, me) : [], canDecline: id === g.pendingKull.player, blocked: null as string | null } : null, 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), ), plays: choamPowerPlays(g,me), } : 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.decision?.kind === 'moritaniExtortion' || 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) => canUseAsKaramaRole(g, me, c), ), } : null, choamMarket: g.choamMarket ? { owner: g.choamMarket.owner, ...(g.choamMarket.owner === id ? { sales: saleOptions(me.hand, g.choamMarket.blocked).filter((sale) => !homeworldRule(() => homeworldWorthlessSaleBlock( g, me.id, me.hand.find((card) => card.id === sale.card)!, )), ), worthlessSaleBlocked: homeworldRule(() => homeworldWorthlessSaleBlock(g, me.id, { kind: 'worthless' }), ), 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: { ...charityQuote(g, me), 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, incomeHomeworld: choamPlayer ? homeworldLowBonus(g, choamPlayer.id) : 0, }, moritaniAssassinate: projectedMoritaniAssassinate(g,id), moritaniRetention: g.moritaniRetention ?? null, nexusMoritaniRetention: quoteNexusMoritaniRetention(g, id), moritaniRetentionCard: g.moritaniRetention?.keep ? (cardOf( getPlayer(g, g.moritaniRetention.player), g.moritaniRetention.keep, ) ?? null) : null, decision: g.pendingGuildBetrayal || g.pendingRicheseBetrayal || g.pendingKull?.stage === 'offer' ? null : g.decision?.kind === 'capturedLeader' && !g.leaderSkills?.assignments.some((a) => a.leader === (g.decision as Extract).leader) && ![g.decision.player, g.decision.controller ?? g.decision.owner].includes( id, ) ? { ...g.decision, leader: '', owner: '' } : g.decision?.kind === 'faceDance' ? { ...g.decision, ...(faceDanceReturnBlock(g, g.decision.winner) ? {blocked: faceDanceReturnBlock(g, g.decision.winner)!} : {}) } : g.decision?.kind === 'guildShipment' && g.decision.noFieldSkillProof !== undefined ? { ...g.decision, noFieldSkillProof: undefined } : (g.decision ?? null), response: g.pendingGuildBetrayal || g.pendingRicheseBetrayal || g.pendingKull?.stage === 'offer' ? null : g.response ? { ...g.response, ...(g.response.spiceBankerIncomePayment ? {spiceBankerIncomePayment: undefined} : {}), ...(g.response.bureaucratPayment ? {bureaucratPayment:undefined,bureaucratPaymentEvent:undefined} : {}), ...(g.response.guildContributions ? { guildContributions: undefined } : {}), ...(g.response.guildPaymentProof ? { guildPaymentProof: undefined } : {}), ...(g.response.occupiedPayment ? { occupiedPayment: undefined } : {}), ...(g.response.sandmasterProof ? { sandmasterProof: undefined } : {}), ...(g.response.noFieldSkillProof ? { noFieldSkillProof: undefined } : {}), 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, paymentIncome: currentFactionPayment(g), responseControls: g.pendingRicheseBetrayal || g.pendingKull?.stage === 'offer' ? undefined : 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 ?? [], recruitsPreview: recruitsPreview(g, me), 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, specialKaramaBlock: g.decision?.kind === 'revivalStop' ? homeworldRevivalKaramaBlock(g, g.decision.recipient) : null, leaders: leaderRevivals.leaders, kwisatz: leaderRevivals.kwisatz, cycleBlock: leaderRevivals.cycleBlock, ...(leaderRevivals.auditorCyclePolicy ? { auditorCyclePolicy: leaderRevivals.auditorCyclePolicy } : {}), dukeBlocked: leaderRevivals.dukeBlocked, prevented: revivalPrevented(g, me.id), eliteRemaining: eliteRevivalRemaining(me, g.advanced), limit: normalForceRevivalLimit(g, me), forcesRemaining: forceRevivalRemaining(g, me), discount: revivalDiscount(g, me), choamBlocked: !!g.revivalRules?.choamBlocked, freeRemaining: freeRevivalRemaining(g, me), homeworldBonus: homeworldLowBonus(g, me.id), tleilaxuHomeworldIncomeBlocked: homeworldRule(() => tleilaxuHomeworldFreeIncomeBlocked(g), ), 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.fremenNexusMovementBlockedTurn === g.turn || (p.fremenMovementBlocked?.turn === g.turn && p.fremenMovementBlocked.move === p.moved), fremenNexusMovementBlocked: p.fremenNexusMovementBlockedTurn === g.turn, fremenNexusWormBlocked: p.fremenNexusWormBlockedTurn === g.turn, 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; const recipientSeat = g.players.find(player => player.id === recipient); const pricingPolicy = recipientSeat ? alliedNoFieldShipmentPricing(g, recipientSeat).policy : null; 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, ...(pricingPolicy ? { pricingPolicy } : {}), }; } 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, ...(pricingPolicy ? { pricingPolicy } : {}), }; } })(), }; })(), richeseGift: richeseGiftView(g, me), distrans: distransView(g, me), nullentropy: nullentropyView(g, me), ornithopter: ornithopterView(g, me), discoveryOrnithopter: discoveryFlightOffer(g, id, { movesAllowed: movesAllowed(g, me), blocked: g.response || g.decision || g.pendingKarama || g.phaseOpening || g.pendingTreacheryDiscard || g.pendingNullentropy ? 'Finish the current response or decision before declaring movement.' : null, }), 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), ...(me.faction === 'ecaz' && g.advanced && g.battle ? { ecazSpecialKarama: ecazSpecialKaramaView(g, me) } : {}), ...(g.moritaniSpecialKarama?.source.owner === me.id && g.moritaniSpecialKarama.stage === 'offer' ? { moritaniSpecialKarama: currentMoritaniSpecialKaramaQuote(g) } : {}), 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, cacheTerms: g.richeseBidding.cacheTerms ?? null, offerBlocked: cacheAuthority?.blocked ?? (cacheStale ? 'The original cache choice authority changed.' : null), cache: g.richeseBidding.owner === id || cacheAuthority && !cacheStale && !cacheAuthority.blocked && g.decision?.kind === 'richeseCache' && g.decision.player === id && cacheAuthority.controller === 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.homeworldOccupationPreview && g.homeworldAuctionInspection?.lot === originalAuctionInspectionLot(g) && g.homeworldAuctionInspection.occupiedKnown && id !== g.homeworldAuctionInspection.native && g.homeworldAuctionInspection.audience.includes(id)) ? (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, inspectionBlocked: g.homeworldOccupationPreview ? quoteOccupiedBiddingAuthority(g, 'atreidesInspection').blocked : null, } : 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, inspectionBlocked: g.homeworldOccupationPreview ? quoteOccupiedBiddingAuthority(g, 'atreidesInspection').blocked : null, card: (me.faction === 'atreides' && g.auction.peekKnown) || (g.homeworldOccupationPreview && g.homeworldAuctionInspection?.lot === originalAuctionInspectionLot(g) && g.homeworldAuctionInspection.occupiedKnown && id !== g.homeworldAuctionInspection.native && g.homeworldAuctionInspection.audience.includes(id)) ? g.auction.cards[g.auction.index] : null, } : null, battle: b ? { locationName: combatLocationName(g, b.territory), ecazOccupy: b.ecazOccupy ? { ...b.ecazOccupy, profile: !b.revealed && b.ecazOccupy.lead && battleConcealedForces(g, b.ecazOccupy.lead) && id !== b.ecazOccupy.lead ? null : ecazBattleProfile(g) } : null, native: homeworldBattleLocation(g, b.territory)?.native ?? null, nativeBattleStrength: homeworldBattleLocation(g, b.territory)?.nativeBattleStrength ?? 0, opponentForces: [b.attacker, b.defender].includes(id) && !battleConcealedForces(g, b.attacker === id ? b.defender : b.attacker) ? planCombatForces(g, getPlayer(g, b.attacker === id ? b.defender : b.attacker), me) : null, strongholdCopy: b.strongholdCopy ?? null, strongholdEffects: Object.fromEntries( [b.attacker, b.defender].map((player) => [ player, strongholdEffect(g, player), ]), ) as Record, tieWinner: battleTieWinner(g), noFieldPlayers: [b.attacker, b.defender].filter(player => battleNoFieldPlan(g, player)), ...(b.mixedNoFieldPlayers?.includes(id) ? { mixedNoField: true as const } : {}), ownForces: [b.attacker, b.defender].includes(id) ? planCombatForces(g, me, 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), occupy: stoneOccupyContext(g), opponentPools: stonePublicPools( g, getPlayer(g, b.attacker === id ? b.defender : b.attacker), me, ), } : null, lateDefense: b.revealed ? (b.lateDefense ?? {}) : {}, smugglerCollectionEnabled: b.smugglerCollectionVersion === 1, smugglerCollectionSupported: smugglerBattleModeSupported(g), smugglerCollection: b.revealed && b.smugglerCollection ? { player: b.smugglerCollection.player, leader: b.smugglerCollection.leader, amount: b.smugglerCollection.amount, before: b.smugglerCollection.before } : null, diplomatDefense: b.revealed && b.diplomatDefense ? { player: b.diplomatDefense.player, event: b.diplomatDefense.event, leader: b.diplomatDefense.leader, cards: [...b.diplomatDefense.cards], source: b.diplomatDefense.source, kind: b.diplomatDefense.kind, stage: b.diplomatDefense.stage, card: b.diplomatDefense.card } : null, harassAllocation: b.revealed && b.harassAllocation && (b.harassAllocation.player === id || b.harassAllocation.stage === 'selected') ? { event: b.harassAllocation.event, context: harassWithdrawContext(g, getPlayer(g, b.harassAllocation.player)), dial: b.plans[b.harassAllocation.player].dial, support: b.plans[b.harassAllocation.player].support, selection: b.harassAllocation.selection ? structuredClone(b.harassAllocation.selection) : null } : null, 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, aggressor: battleAggressor(g), chooser: b.chooser ?? 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), leaderless: g.advanced ? Object.keys(b.plans).filter(playerId => b.plans[playerId].leader === null) : [], plans: b.revealed ? b.plans : b.plans[id] ? { [id]: b.plans[id] } : {}, traitorSubmitted: Object.keys(b.traitorCalls), traitorVoters: traitorVoters(g, b), preparation: b.preparation && g.homeworldOccupationPreview && b.preparation.kind === 'voice' ? { ...b.preparation, blocked: homeworldRule(() => quoteOccupiedHomeworldVoice(g, b.attacker === b.preparation!.beneficiary ? b.defender : b.attacker)).blocked ?? undefined } : b.preparation ?? null, nexusInspection: b.nexusInspection ? { event: b.nexusInspection.event, mode: b.nexusInspection.mode, owner: b.nexusInspection.owner, target: b.nexusInspection.target, field: b.nexusInspection.field, stage: b.nexusInspection.stage, } : null, nexusInsights: projectedNexusInsights(g, id), harassWithdraw: harassWithdrawPreview(g, me), reinforcements: reinforcementsPreview(g, me), ownCommitments: committedPlanElements(b, id), 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)) ? (() => { const target = getPlayer(g, b.prescience!.player === b.attacker ? b.defender : b.attacker); const value = inspectedPlanValue(target, b.prescience!.field, b.prescience!.value); return { field: b.prescience!.field, value, label: g.players.flatMap(p => [...p.leaders, ...p.hand]) .concat(g.dukeVidal ? [g.dukeVidal.leader] : []) .find(item => item.id === value)?.name ?? String(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 & { seatAiDelegations?: import('../lib/seat-ai-delegation').SeatAiDelegationView[]; roomControl?: import('../lib/room-control').RoomControl; };