'use client'; import { integerInputValue } from '../lib/integer-input'; import { EcazSetup } from './ecaz-setup'; import { StormCardInspector, StormCardLogInspector } from './storm-cards'; import { KwisatzInspector } from './kwisatz-inspector'; import { ForceInspector } from './force-inspector'; import { FactionInspector } from './faction-inspector'; import { EcazLoyaltyCard } from './ecaz-loyalty'; import { DrawPiles } from './draw-piles'; import { HandBrowser } from './hand-browser'; import { LobbyBotControls } from './lobby-bot-controls'; import { AdvancedPreviewNotice, RulesetControls } from './ruleset-controls'; import { IxRicheseTechnology } from './ix-richese-technology'; import { nexusGuildCunningAction, nexusGuildCunningActive, nexusGuildMovementAvailable, nexusGuildShipmentAvailable, nexusGuildSkipShipmentAction } from '@/game/nexus-guild-cunning-options'; import { matchesShipment, liveShipmentPromises, } from '@/game/shipment-promises'; import { ShipmentPromises } from './shipment-promises'; import { HomeworldShipment } from './homeworld-shipment'; import { HomeworldRevivalDeployment } from './homeworld-revival-deployment'; import { CaladanReinforcement } from './caladan-reinforcement'; import { GrummanCollection } from './grumman-collection'; import { TupileIntelligence } from './tupile-intelligence'; import { NexusCards } from './nexus-cards'; import { NexusTraitors } from './nexus-traitors'; import { GreatMakerDecision } from './great-maker'; import { DiscoveryPanel, DiscoveryDiscardDecision } from './discoveries'; import { DiscoveryEntryDecision } from './discovery-entry'; import { DiscoveryStormDecision } from './discovery-storm'; import { NexusChoamTrade } from './nexus-choam-trade'; import { NexusMoritaniBetrayal } from './nexus-moritani-betrayal'; import { buildRevision, buildRevisionLabel } from '@/lib/build-revision'; import { NexusEcazBetrayal } from './nexus-ecaz-betrayal'; import { NexusTleilaxu } from './nexus-tleilaxu'; import { NexusSuboids } from './nexus-suboids'; import { NexusAdvisors } from './nexus-advisors'; import { NexusSardaukar } from './nexus-sardaukar'; import { TerrorBoardMarkers } from './terror-board-markers'; import { homeworldRevivalActionBlock } from '@/game/homeworld-revival-deployment-options'; import { GuildHomeworldShipment } from './guild-homeworld-shipment'; import { JunctionTransport } from './junction-transport'; import { BiddingEnd } from './bidding-end'; import { homeworldShipmentPaymentBlock } from '@/game/homeworld-payment-options'; import { HomeworldTable, NativeShipmentChoice, EmperorHomeworldMovement, } from './homeworld-table'; import { nativeShipmentSources } from '@/game/homeworld-options'; import { botBattleChoices } from '@/game/bot-battle-choices'; import { specialForceName, maxCombatDial, maxCombatSupport } from '@/game/combat'; import type { NativeReserveSelections } from '@/game/homeworld-native-reserves'; import { StrongholdCardGallery, StrongholdCopyChoice, } from './stronghold-cards'; import { EcazPlacement, AmbassadorSupply } from './ecaz-ambassadors'; import { EcazEntry, AmbassadorInsights } from './ecaz-entry'; import { RicheseAuctionDecision, RicheseAuctionLot } from './richese-auctions'; import { RicheseSpecialKarama } from './richese-special-karama'; import { RicheseGift } from './richese-gift'; import { Distrans } from './distrans'; import { JuiceOfSapho } from './juice-of-sapho'; import { LeaderSkillsPanel } from './leader-skills'; import { LeaderSkillBattleGuide } from './leader-skill-battle-guide'; import { smugglerBattleModeSupported, smugglerBattlePlanBlock } from '@/game/smuggler-battle'; import { RihaniChoice, RihaniHistory } from './rihani-decipherer'; import { MentatQuestion, MentatHistory } from './mentat-question'; import { BureaucratPayment } from './bureaucrat-payment'; import { Bribes } from './bribes'; import { SukGraduatePanel } from './suk-graduate'; import { NullentropyBox, NullentropySearch } from './nullentropy-box'; import { OrnithopterMovement } from './ornithopter-movement'; import { DiscoveryOrnithopterMovement } from './discovery-flight-movement'; import { PlanetologistMovement } from './planetologist-movement'; import { SandmasterMovement } from './sandmaster-movement'; import { SandmasterWormChoice } from './sandmaster-worm'; import { sandmasterWormCollection } from '@/game/sandmaster-worm'; import { ResidualPoison, BattleLeaderOpportunity } from './residual-poison'; import { PortableSnooper } from './portable-snooper'; import { RicheseNoFieldControls, NoFieldBoardMarkers, } from './richese-no-field'; import { RicheseAlliedNoFieldControls, RicheseAlliedNoFieldDecision, } from './richese-allied-no-field'; import { presenceByLocation } from '@/game/force-presence'; import { AutomaticActionNotice } from './automatic-action-notice'; import { RoomControlNotice } from './room-control-notice'; import { TableSounds } from './table-sounds'; import { EcazSpice } from './ecaz-spice'; import { VictoryProgress } from './victory-progress'; import { tableActionOwner } from '@/game/table-turn'; import { DukeVidal } from './duke-vidal'; import { CardInspector, CardRules } from './card-inspector'; import { HarkonnenExchangeInspection } from './harkonnen-exchange-inspection'; import { Recruits } from './recruits'; import { ecazTreacheryDefinition } from '@/game/ecaz-cards'; import { ordinaryCardAvailability } from '@/game/card-availability'; import { cardPresentation, richeseCardActionBlock, } from '@/game/card-presentation'; import { richeseCardDefinition } from '@/game/richese-cards'; import { SpiceCardInspector, spiceCardTitle } from './spice-card-inspector'; import { BattleWheel } from './battle-wheel'; import { LeaderInspector, type LeaderDisplayIdentity, type LeaderInspectorProps, } from './leader-inspector'; import { ChoamStorm } from './choam-storm'; import { ChoamBaliset } from './choam-baliset'; import { ChoamGamont } from './choam-gamont'; import { ChoamWorthless } from './choam-worthless'; import { ChoamBattleFunding } from './choam-battle-funding'; import { AuditorDecision, AuditorInsight } from './choam-auditor'; import { ChoamCashIn } from './choam-cash-in'; import { ChoamMarket } from './choam-market'; import { BattlePromises } from './battle-promises'; import { Truthtrance, TruthHistory } from './truthtrance'; import { forceRevivalPrice, eliteRevivalRemaining, paidForceRevivalCost, } from '@/game/revival'; import { IxTechnology } from './ix-technology'; import { MobileStronghold } from './mobile-stronghold'; import { IxSubstitution } from './ix-substitution'; import { controlsLeader } from '@/game/leader-control'; import { leaderStrengthLabel, harvesterAvailable } from '@/game/cards'; import { RevivalCommerce } from './revival-commerce'; import { FaceDanceDecision } from './face-dance-decision'; import { CHEAP_HERO_TRAITOR, matchingTraitor } from '@/game/traitors'; import { battleCardLabel, isStoneBurner, } from '@/game/battle-cards'; import { validBattleSlotPair } from '@/game/battle-card-slots'; import { stoneBurnerPlanBlock } from '@/game/stone-burner'; import { TECH_TOKENS, ownedTech } from '@/game/tech-tokens'; import { canUseAsKaramaRole, canUseAsTruthtranceRole, } from '@/game/shrine'; import { LeaderPortrait } from './leader-portrait'; import { ShipmentQuote } from './shipment-quote'; import { reserveShipmentCost } from '@/game/shipment-price'; import { quoteSmugglerShipment } from '@/game/smuggler-shipment'; import { SmugglerShipmentChoice } from './smuggler-shipment'; import { nexusGuildSecretAllyAction, nexusGuildSecretAllyQuote } from '@/game/nexus-guild-secret-ally-options'; import { nexusRicheseAction, nexusRicheseQuote } from '@/game/nexus-richese-options'; import { guildTransportQuote } from '@/game/transport-quote'; import { fremenReserveEntry, botGroundMoveAllowed } from '@/game/bot-mobility'; import { location as boardLocation } from '@/game/board'; import { fighterCount, isAdvisor } from '@/game/advisors'; import { useState } from 'react'; import { ArrowRight, Wind, Coins, Copy, Check, Swords, ZoomIn, ZoomOut, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { BattlePreparation, battlePlanCommitments, battlePlanCardValue, battlePlanCardOptions, bindBattlePlanCommitments, NexusInspectionHistory } from './battle-preparation'; import { HarassWithdrawGuide, harassWithdrawControlState } from './harass-withdraw'; import { HarassWithdrawChoice } from './harass-withdraw-choice'; import { PrivateBattlePlan } from './private-battle-plan'; import { SpiceBankerControl, spiceBankerControlState } from './spice-banker'; import { canUsePlanetologistBattleSpecial } from '../game/leader-skill-combat'; import type { Card } from '../game/cards'; import { RevealedBattle } from './revealed-battle'; import { DiplomatDefense } from './diplomat-defense'; import { EliteCount } from './elite-count'; import { HelpTip } from './help-tip'; import { PHASE_HELP, phaseRuleId } from '@/game/reference'; import { DIFFICULTIES, BOT_DESCRIPTIONS, type Difficulty, } from '@/game/bot-profiles'; import { FACTIONS, PHASES, faction } from '@/game/catalog'; import { FREMEN_START_LOCATIONS, TERRITORIES, gameTerritories, MOBILE_STRONGHOLD, territory, splitLocation, } from '@/game/board'; import type { Action, GameView } from '@/game/engine'; import { PLAYER_CIRCLE_SECTORS } from '@/game/player-positions'; import { SeatAutopilot } from './seat-autopilot'; import { MoritaniEntry, MoritaniPlacement, MoritaniTerrorSupply, } from './moritani-terror'; import { TERROR_DEFINITIONS } from '@/game/moritani-terror'; import { MoritaniRetention } from './moritani-retention'; import { MoritaniAssassinate, MoritaniAssassinateHistory } from './moritani-assassinate'; export function GameTable({ game: g, send, busy: transportBusy, onExit, exitDisabled = false, }: { game: GameView; send: (a: Action) => Promise; busy: boolean; onExit: () => void; exitDisabled?: boolean; }) { const me = g.players.find((p) => p.id === g.me)!; const outgoingAlliance = g.allianceOffers[me.id]; const outgoingAllianceName = g.players.find((p) => p.id === outgoingAlliance)?.name; const shipmentAvailable = nexusGuildShipmentAvailable(g); const movementAvailable = nexusGuildMovementAvailable(g); const guildCunning = nexusGuildCunningActive(g); const guildCunningAction = g.nexusGuildCunning?.offer ? nexusGuildCunningAction(g, g.nexusGuildCunning.offer.event) : null; const combatName = (id: string) => g.combatLocations?.find((place) => place.id === id)?.name ?? gameTerritories(g).find((place) => place.id === id)?.name ?? id; const turnOwner = tableActionOwner(g); const turnOwnerName = g.players.find((p) => p.id === turnOwner)?.name; const setupStage = g.setupStage; const setupFremen = g.players.find((p) => p.faction === 'fremen'); const setupEcaz = g.players.find((p) => p.faction === 'ecaz'); const setupSisterhood = g.players.find((p) => p.faction === 'beneGesserit'); const setupPendingNames = (g.setupPending ?? []) .filter((id) => id !== me.id) .map((id) => g.players.find((p) => p.id === id)?.name) .filter(Boolean) .join(', '); const waitingForFremen = !!setupFremen && setupFremen.reserves !== 10; const waitingForEcaz = setupStage === 'forces' && !!setupEcaz && setupEcaz.reserves === 20; const setupStatus = setupStage === 'leaderSkills' ? g.leaderSkills?.offer ? 'Keep one of your two Leader Skill cards and assign it to an eligible leader. Traitor selection follows when every faction has chosen.' : `Your skill is assigned. Waiting for ${setupPendingNames || 'the remaining players'} to choose.` : setupStage === 'prediction' ? setupSisterhood?.id === me.id ? 'Seal your secret prediction before traitors or treachery cards are dealt.' : `Waiting for ${setupSisterhood?.name ?? 'Bene Gesserit'} to seal the secret prediction.` : setupStage === 'traitors' ? me.traitorChoices?.length ? 'Choose one of your dealt traitors. Every player completes this step before starting forces are placed.' : setupPendingNames ? `Your traitors are settled. Waiting for ${setupPendingNames} to choose.` : 'Your traitors are settled. The remaining selections are finishing.' : setupStage === 'forces' ? waitingForFremen ? setupFremen.id === me.id ? g.advanced && setupSisterhood ? 'Place your ten starting forces. Bene Gesserit places its advisor afterward.' : g.leaderSkills ? 'Place your ten starting forces. Starting cards were already dealt before skill assignment.' : 'Place your ten starting forces. Starting cards are dealt automatically after placement.' : `Waiting for ${setupFremen.name} to place the Fremen starting forces.` : waitingForEcaz ? setupEcaz.id === me.id ? 'Place your six starting forces among the sectors of Imperial Basin. Fourteen remain in reserves.' : `Waiting for ${setupEcaz.name} to place the Ecaz starting forces.` : g.advanced && setupSisterhood && !setupSisterhood.advisorSetup ? setupSisterhood.id === me.id ? 'Choose a printed board territory and sector for your starting advisor.' : `Waiting for ${setupSisterhood.name} to place the starting advisor.` : g.leaderSkills ? 'Starting placements are finishing. The Storm phase opens automatically.' : 'Starting placements are finishing. Cards are dealt and the Storm phase opens automatically.' : 'Complete your starting choices. Play begins when everyone is ready.'; const automaticEvents = g.log.flatMap((entry) => entry.automatic ? [ { id: `${g.code}:${entry.seq}`, seq: entry.seq, faction: entry.automatic.faction, name: entry.automatic.name, }, ] : [], ); const busy = transportBusy || (!!g.roomControl?.paused || !!g.roomControl?.closed) || !!me.autopilot || g.automaticContinuationPending; const traitorBattle = g.battle; const traitorBeneficiary = traitorBattle && [traitorBattle.attacker, traitorBattle.defender].includes(me.id) ? me.id : me.ally; const traitorPlan = traitorBattle?.revealed ? traitorBattle.plans[ traitorBattle.attacker === traitorBeneficiary ? traitorBattle.defender : traitorBattle.attacker ] : undefined; const canRevealTraitor = !!( traitorBattle?.traitorVoters.includes(me.id) && traitorPlan && !traitorPlan.kwisatz && matchingTraitor( me.traitors ?? [], traitorPlan.leader, traitorBattle.cards.find((card) => card.id === traitorPlan.leader), ) ); const noTraitorReason = traitorPlan?.kwisatz ? 'Kwisatz Haderach prevents this leader from turning traitor.' : 'You do not hold the opposing leader as a traitor.'; const [guildSecretEvent, setGuildSecretEvent] = useState(''); const useGuildSecret = !!g.nexusGuildSecretAlly && guildSecretEvent === g.nexusGuildSecretAlly.event; const guildTransport = useGuildSecret || me.faction === 'guild' || g.players.some((p) => p.faction === 'guild' && p.id === me.ally); const [botFaction, setBotFaction] = useState(''); const [botDifficulty, setBotDifficulty] = useState('Medium'); const availableBotFactions = FACTIONS.filter( (f) => (f.expansion === 'base' || g.expansions.includes(f.expansion)) && !g.players.some((p) => p.faction === f.id), ); const nextBotFaction = availableBotFactions.some((f) => f.id === botFaction) ? botFaction : (availableBotFactions[0]?.id ?? ''); const [selectedId, setSelected] = useState(() => gameTerritories(g).some((location) => location.id === g.battle?.territory) ? g.battle!.territory : 'arrakeen', ); const selected = gameTerritories(g).some((location) => location.id === selectedId) ? selectedId : 'arrakeen'; const [sector, setSector] = useState(10); const [amount, setAmount] = useState(1); const [richeseShipmentEvent, setRicheseShipmentEvent] = useState(''); const useRicheseShipment = !!g.nexusRichese && richeseShipmentEvent === g.nexusRichese.event; const [useSmuggler, setUseSmuggler] = useState(true); const [bidDraft, setBidDraft] = useState({ auction: '', value: '' }); const minimumBid = (g.auction?.bid ?? 0) + 1; const auctionKey = `${g.code}/${g.turn}/${g.auction?.remaining ?? 0}/${minimumBid}`; const bidText = bidDraft.auction === auctionKey ? bidDraft.value : String(minimumBid); const bidAmount = integerInputValue(bidText); const maximumBid = me.hand?.some((c) => canUseAsKaramaRole(g, me, c), ) ? Number.MAX_SAFE_INTEGER : (me.spice ?? 0) + g.aid.available; const [eliteAmount, setEliteAmount] = useState(0); const [homeworldDraft, setHomeworldDraft] = useState<{ key: string; sources: NativeReserveSelections; } | null>(null); const homeworldSourceKey = JSON.stringify([ g.code, g.turn, g.phase, g.active, g.homeworlds, amount, eliteAmount, ]); const homeworldSources = homeworldDraft?.key === homeworldSourceKey ? homeworldDraft.sources : nativeShipmentSources(g, amount, eliteAmount); const [freeCyborgFirst, setFreeCyborgFirst] = useState(true); const [allyEliteAmount, setAllyEliteAmount] = useState(0); const [eliteForces, setEliteForces] = useState>({}); const [eliteSetup, setEliteSetup] = useState>({}); const [moveAsFighters, setMoveAsFighters] = useState(false); const [source, setSource] = useState(''); const [target, setTarget] = useState(''); const [leader, setLeader] = useState(''); const [weapon, setWeapon] = useState(''); const [defense, setDefense] = useState(''); const [dial, setDial] = useState(0); const [stormDial, setStormDial] = useState(0); const stormDialValue = Math.max( g.turn === 1 ? 0 : 1, Math.min( g.turn === 1 ? 20 : 3, Number.isFinite(stormDial) ? Math.trunc(stormDial) : 0, ), ); const ownBattleForces = g.battle?.ownForces; const battleForces = ownBattleForces ? ownBattleForces.normal + ownBattleForces.elite : g.battle ? fighterCount(me, g.battle.territory) : 0; const battleDialMaximum = ownBattleForces ? maxCombatDial(ownBattleForces) : g.advanced || me.faction === 'ixians' ? 40 : battleForces; const battleDialStep = g.advanced || me.faction === 'ixians' ? 0.5 : 1; // A draft carried from a previous battle must show and submit the same bounded value. const battleDial = Number.isFinite(dial) ? Math.max( 0, Math.min( Math.round(dial / battleDialStep) * battleDialStep, battleDialMaximum, ), ) : 0; const [support, setSupport] = useState(0); const [bankerSpice, setBankerSpice] = useState(0); const strongholdBankSupport = g.battle?.strongholdEffects[me.id] === 'arrakeen' ? 2 : 0; const battleSupportMaximum = Math.min( (me.spice ?? 0) + g.aid.available + strongholdBankSupport, ownBattleForces ? maxCombatSupport(ownBattleForces) : Number.POSITIVE_INFINITY, ); const battleSupport = Number.isFinite(support) ? Math.max(0, Math.min(Math.trunc(support), battleSupportMaximum)) : 0; const [kwisatz, setKwisatz] = useState(false); const [weatherDistance, setWeatherDistance] = useState(0); const [turn, setTurn] = useState(1); const [copied, setCopied] = useState(false); const [zoom, setZoom] = useState(1); const [panel, setPanel] = useState<'hand' | 'leaders' | 'log' | 'rules'>( 'hand', ); const [fremen, setFremen] = useState>({ [FREMEN_START_LOCATIONS[0]]: 10, }); const [aidAmount, setAidAmount] = useState(0); const [allyPayment, setAllyPayment] = useState(''); const [combineSectors, setCombineSectors] = useState(false); const [moveNoField, setMoveNoField] = useState(false); const [moveForces, setMoveForces] = useState>({}); const [rideForces, setRideForces] = useState>({}); const [collectSandmasterRide, setCollectSandmasterRide] = useState(true); const sandmasterRide = sandmasterWormCollection(g, me.id, selected, sector); const [exchangeCards, setExchangeCards] = useState>( {}, ); const [discardCards, setDiscardCards] = useState>({}); const stormLosses = g.decision?.kind === 'stormLosses' ? g.decision : null; const committed = battlePlanCommitments(g); const selectedBattleLeader = committed.leader ? String(committed.leader.value ?? '') : leader; const paidBattleSupport = Math.max(0, battleSupport - strongholdBankSupport); const selectedBattleAllyPayment = allyPayment === '' ? Math.max(0, paidBattleSupport - (me.spice ?? 0)) : Math.max( 0, Math.min( Math.trunc(Number(allyPayment)), paidBattleSupport, g.aid.available, ), ); const ownBattleSupport = Math.max( 0, paidBattleSupport - selectedBattleAllyPayment, ); const bankerControl = spiceBankerControlState( g, selectedBattleLeader, ownBattleSupport, ); const battleBankerSpice = Math.max( 0, Math.min(Math.trunc(bankerSpice), bankerControl.maximum), ); const planetologistSpecial = (card: Card) => canUsePlanetologistBattleSpecial({ assignments: g.leaderSkills?.assignments.filter(a => a.controller === me.id) ?? [], selectedLeader: committed.leader ? String(committed.leader.value ?? '') : leader, card, }); const selectedWeaponId = battlePlanCardValue(g, 'weapon', weapon); const selectedDefenseId = battlePlanCardValue(g, 'defense', defense); const selectedBattleWeapon = me.hand?.find(c => c.id === selectedWeaponId); const selectedBattleDefense = me.hand?.find(c => c.id === selectedDefenseId); const selectedBattleKwisatz = kwisatz && !!me.kwisatz?.active && !me.kwisatz.dead && !g.battle?.kwisatzBlocked && (!me.kwisatz.usedAt || me.kwisatz.usedAt === g.battle?.territory); const smugglerPlanReason = g.battle?.smugglerCollectionEnabled ? smugglerBattlePlanBlock({ assignments: g.leaderSkills?.assignments.filter(a => a.controller === me.id) ?? [], leader: me.leaders.find(l => l.id === selectedBattleLeader), weapon: selectedBattleWeapon, defense: selectedBattleDefense, kwisatz: selectedBattleKwisatz, }, smugglerBattleModeSupported(g), { territory: g.battle.territory, spice: g.spice }) : null; const battlePairValid = validBattleSlotPair(selectedBattleWeapon, selectedBattleDefense, !!selectedBattleWeapon && planetologistSpecial(selectedBattleWeapon)); const harassControl = harassWithdrawControlState(g.battle?.harassWithdraw, selectedWeaponId === 'ecaz-harass-withdraw' || selectedDefenseId === 'ecaz-harass-withdraw', committed.dial ? Number(committed.dial.value) : battleDial, me.faction === 'fremen' && !g.battle?.fremenSupportBlocked ? 0 : battleSupport); const selectedStone = me.hand?.find( (c) => c.id === selectedWeaponId && isStoneBurner(c), ); const stoneContext = g.battle?.stoneBurnerContext; const stonePlanReason = !selectedStone ? null : (stoneContext?.blocked ?? (!ownBattleForces || !stoneContext?.opponentPools.length ? 'Stone Burner needs a supported physical force pool.' : (stoneContext.opponentPools .map((pool) => stoneBurnerPlanBlock( ownBattleForces, committed.dial ? Number(committed.dial?.value) : battleDial, battleSupport, pool, g.battle!.attacker === me.id ? 'attacker' : 'defender', g.battle!.tieWinner === g.battle!.attacker ? 'attacker' : 'defender', ), ) .find((reason) => reason !== null) ?? null))); const payment = !me.ally || allyPayment === '' ? {} : { allyPayment: Number(allyPayment) }; const shipmentProblems: string[] = []; if (useGuildSecret && g.nexusGuildSecretAlly?.blocked) shipmentProblems.push(g.nexusGuildSecretAlly.blocked); if (useRicheseShipment && g.nexusRichese?.blocked) shipmentProblems.push(g.nexusRichese.blocked); const shipmentMaximum = useRicheseShipment ? Math.min(me.reserves, 5, g.nexusRichese!.maxForces) : me.reserves; if (homeworldSources) { const homes = g.homeworlds!.worlds!.filter((w) => w.native === me.id); if ( homes.some((home) => (['normal', 'elite'] as const).some((kind) => { const n = homeworldSources[home.id]?.[kind]; return ( !Number.isSafeInteger(n) || n < 0 || n > home.forces[me.id][kind] ); }), ) ) shipmentProblems.push( 'Choose available whole numbers of normal forces and Sardaukar from each Homeworld.', ); } if ( liveShipmentPromises(g.shipmentPromises, me.id, g.turn).some( (p) => matchesShipment(p, { territory: selected, amount }) !== p.answer, ) ) shipmentProblems.push( 'This shipment conflicts with your Truthtrance answer. Choose a count and destination that honor your shipment promises.', ); const selectedDestinationInStorm = sector !== 0 && sector === g.storm; if (selectedDestinationInStorm && !(!useGuildSecret && g.advanced && me.faction === 'fremen')) shipmentProblems.push( `Sector ${sector} of ${territory(selected).name} is in the storm. Choose a different sector or territory.`, ); if (!useGuildSecret && me.faction === 'fremen' && !fremenReserveEntry(selected)) shipmentProblems.push( `${territory(selected).name} is beyond the Fremen reinforcement area. Choose the Great Flat or a territory within two territories of it.`, ); const validShipmentAmount = Number.isInteger(amount) && amount > 0 && amount <= shipmentMaximum; if (!validShipmentAmount) shipmentProblems.push( `Choose a whole number from 1 to ${shipmentMaximum} available reserves.`, ); if (me.elites && validShipmentAmount) { const minimum = Math.max(0, amount - (me.reserves - me.elites.reserves)); const maximum = Math.min(amount, me.elites.reserves); if ( !Number.isInteger(eliteAmount) || eliteAmount < minimum || eliteAmount > maximum ) shipmentProblems.push( `Choose ${minimum} to ${maximum} elite forces from reserves.`, ); } const shipmentRate = { faction: me.faction, halfRate: me.faction === 'guild' || g.players.some((p) => p.faction === 'guild' && p.id === me.ally) || g.karamaShipping?.player === me.id, }; const smugglerOffer = validShipmentAmount && !useGuildSecret && !useRicheseShipment ? quoteSmugglerShipment(g, me.id, selected, amount) : null; const smugglerShipment = useSmuggler ? smugglerOffer : null; const shipmentCost = validShipmentAmount ? useGuildSecret ? (nexusGuildSecretAllyQuote(g, selected, amount)?.cost ?? null) : useRicheseShipment ? (nexusRicheseQuote(g, selected, amount)?.cost ?? null) : reserveShipmentCost(shipmentRate, territory(selected).type, amount - (smugglerShipment ? 1 : 0)) : null; if (useGuildSecret && shipmentCost === null && !g.nexusGuildSecretAlly?.blocked) shipmentProblems.push('Choose a current Guild Secret Ally shipment.'); if (useRicheseShipment && shipmentCost === null && !g.nexusRichese?.blocked) shipmentProblems.push('Choose a current Richese Secret Ally shipment of up to five physical forces.'); const selectedAllyPayment = shipmentCost === null ? 0 : !me.ally || allyPayment === '' ? Math.max(0, shipmentCost - (me.spice ?? 0)) : Number(allyPayment); const validShipmentShare = shipmentCost !== null && Number.isInteger(selectedAllyPayment) && selectedAllyPayment >= 0 && selectedAllyPayment <= shipmentCost; if (shipmentCost !== null) { if (validShipmentShare) { const blocked = homeworldShipmentPaymentBlock(g, shipmentCost, selectedAllyPayment); if (blocked) shipmentProblems.push(blocked); } if (!validShipmentShare) shipmentProblems.push( `Ally payment must be a whole number from 0 to ${shipmentCost}.`, ); else { if (selectedAllyPayment > g.aid.available) shipmentProblems.push( `Only ${g.aid.available} pledged ally spice is available.`, ); if (shipmentCost - selectedAllyPayment > (me.spice ?? 0)) shipmentProblems.push( 'Your spice does not cover your selected share. Adjust the ally payment or ship fewer forces.', ); } } const shipmentQuote = validShipmentShare ? { cost: shipmentCost!, normalCost: reserveShipmentCost( { ...shipmentRate, halfRate: false }, territory(selected).type, amount, ), ownPayment: shipmentCost! - selectedAllyPayment, pledgedPayment: selectedAllyPayment, } : null; const physicalMovementGroup = combineSectors ? { eliteForces, forces: Object.fromEntries( Object.entries(moveForces).filter( ([key]) => splitLocation(key).territory === splitLocation(source).territory, ), ), } : { from: source, amount, elite: eliteAmount }; const ownNoField = g.richeseNoField?.owner === me.id ? g.richeseNoField.private?.deployed : null; const markerAtSource = !!ownNoField && !!source && ownNoField.location.territory === splitLocation(source).territory; const includesNoField = moveNoField && markerAtSource && !g.homeworldMobility?.noFieldMovementBlocked; const physicalDraftEntries = combineSectors ? Object.entries(moveForces).filter( ([key]) => splitLocation(key).territory === splitLocation(source).territory, ) : source ? [[source, amount] as const] : []; const validMarkerGroup = physicalDraftEntries.every( ([key, count]) => Number.isSafeInteger(count) && count >= 0 && count <= (me.forces[key] ?? 0), ); const selectedPhysicalForces = Object.fromEntries( physicalDraftEntries.filter(([, count]) => count > 0), ); const markerPhysicalTotal = Object.values(selectedPhysicalForces).reduce( (total, count) => total + count, 0, ); const movementGroup = includesNoField && ownNoField ? { forces: selectedPhysicalForces, ...(me.elites ? { eliteForces } : {}), noField: ownNoField.tokenId, event: g.richeseNoField!.event, } : physicalMovementGroup; const ordinaryMovementAction: Action = { type: 'move', fighters: moveAsFighters && isAdvisor(me, splitLocation(source).territory), ...movementGroup, territory: selected, sector, ...(includesNoField ? {} : { amount }), }; const transportAction: Action = { type: 'guildShip', ...(useGuildSecret ? {nexus: guildSecretEvent} : {}), ...payment, ...movementGroup, territory: selected, sector, amount, }; const southernTransportAction: Action = { type: 'guildShip', from: 'reserves', elite: eliteAmount, territory: selected, sector, amount, ...payment, }; const returnTransportAction: Action = { type: 'guildShip', ...(useGuildSecret ? {nexus: guildSecretEvent} : {}), ...payment, ...movementGroup, territory: 'reserves', amount, }; const transportPreview = guildTransport && shipmentAvailable ? guildTransportQuote(g, transportAction) : null; const southernTransportPreview = guildTransport && !useGuildSecret && shipmentAvailable && me.faction === 'fremen' ? guildTransportQuote(g, southernTransportAction) : null; const returnTransportPreview = guildTransport && shipmentAvailable && (me.faction === 'guild' || useGuildSecret) ? guildTransportQuote(g, returnTransportAction) : null; const canceledFremenRouteBlocked = me.fremenMovementBlocked && physicalDraftEntries.some( ([key, count]) => count > 0 && !botGroundMoveAllowed(g, me, key, boardLocation(selected, sector), 0), ); const act = (a: Action) => void send(a); const choose = (id: string) => { const location = gameTerritories(g).find((candidate) => candidate.id === id); if (!location) return; setSelected(id); setSector(location.sectors[0]); }; const leaderName = (id: string) => id === CHEAP_HERO_TRAITOR ? 'Cheap Hero / Heroine' : (g.allLeaders.find((l) => l.id === id)?.name ?? id); const identityFor = (id: string): LeaderDisplayIdentity | undefined => { if (id === CHEAP_HERO_TRAITOR) return { name: 'Cheap Hero / Heroine', factionName: 'Any faction', strength: 0, cheapHero: true, }; const known = g.allLeaders.find((l) => l.id === id); return known ? { id: known.id, name: known.name, factionName: faction(known.faction).name, strength: leaderStrengthLabel(known), } : undefined; }; const inspectIdentity = (id: string, kind: LeaderInspectorProps['kind']) => { const identity = identityFor(id); const assassination = kind === 'traitor' ? g.moritaniAssassinate?.history.find(entry => entry.card === id) : undefined; return identity ? ( ) : null; }; const actionButton = ( text: string, a: Action, disabled = false, describedBy?: string, ) => { const promises = liveShipmentPromises(g.shipmentPromises, me.id, g.turn); const shipmentBlocked = g.phase === 5 && shipmentAvailable && ['ship', 'guildShip', 'move', 'endMovement'].includes(a.type) && promises.some( (p) => matchesShipment( p, a.type === 'ship' || (a.type === 'guildShip' && a.from === 'reserves') ? { territory: String(a.territory), amount: Number(a.amount) } : null, ) !== p.answer, ); return ( ); }; const destination = ( <> ); const amountInput = ( <> {me.elites && ( )} ); const sourceInput = ( ); return (
Math.max(last, entry.seq), -1), automaticSequence: automaticEvents.reduce((last, entry) => Math.max(last, entry.seq), -1), }} /> {g.status === 'lobby' ? 'Gathering players' : `Turn ${g.turn} / 10`} Git revision: {buildRevisionLabel}
{g.status === 'lobby' ? 'WAR COUNCIL' : g.status === 'setup' ? 'PREPARING THE TABLE' : g.status === 'finished' ? 'THE FATE OF ARRAKIS' : PHASES[g.phase]?.toUpperCase()} {g.status === 'lobby' ? `${g.players.length} / 6 seats filled` : g.status === 'finished' ? g.winner .map( (id) => faction(g.players.find((p) => p.id === id)!.faction).name, ) .join(' + ') || 'No winner' : g.automaticContinuationPending ? 'Completing the card action' : g.truthtrance ? 'Truthtrance in progress' : g.phaseOpening ? 'Phase-opening decisions' : g.response ? 'Karama response in progress' : g.status === 'setup' ? setupStatus : turnOwnerName ? `${turnOwnerName} to act` : 'Simultaneous decisions'}
{g.advanced && g.status !== 'lobby' && } {g.status !== 'lobby' && ( )} {g.sandtrout && (

Sandtrout is waiting: the next Shai-Hulud card will be suppressed. An immediate territory replacement receives double spice.

)} {g.spicePeek && (
Private foresight · Atreides

{spiceCardTitle(g.spicePeek)}

Only you can see this upcoming card.

)}
Storm · Sector {g.storm}
{PLAYER_CIRCLE_SECTORS.map((circleSector, index) => { const angle = ((110 - (circleSector - 1) * 20) * Math.PI) / 180; const player = g.players.find( (p) => g.playerPositions[p.id] === index + 1, ); return ( {`Player circle ${index + 1}, sector ${circleSector}: ${player ? `${player.name} · ${faction(player.faction).name}` : 'Empty'}`} {index + 1} ); })} {g.mobileStronghold?.location && ( )} {gameTerritories(g).map((t) => ( choose(t.id)} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); choose(t.id); } }} > p.join(',')).join(' ')} /> {t.name} ))} {Array.from({ length: 18 }, (_, i) => { const a = (Math.PI / 180) * (120 - i * 20); return ( {i + 1} ); })} {gameTerritories(g).flatMap((t) => { const present = g.players.filter((p) => Object.keys(p.forces).some( (k) => splitLocation(k).territory === t.id && p.forces[k], ), ); return present.map((p, i) => ( {isAdvisor(p, t.id) && ( )} {Object.entries(p.forces) .filter(([k]) => splitLocation(k).territory === t.id) .reduce((n, [, v]) => n + v, 0)} )); })} {Object.entries(g.spice) .filter(([, n]) => n > 0) .map(([k, n]) => { const t = territory(splitLocation(k).territory); return ( ◆ {n} ); })}
{territory(selected).type.toUpperCase()}

{territory(selected).name}

{selected === MOBILE_STRONGHOLD && g.mobileStronghold?.location ? `Points at ${territory(splitLocation(g.mobileStronghold.location).territory).name}, sector ${splitLocation(g.mobileStronghold.location).sector}` : `Sectors ${territory(selected).sectors.join(' · ')}`}
{g.players.flatMap((p) => Object.entries(p.forces) .filter(([k]) => splitLocation(k).territory === selected) .map(([k, n]) => ( {faction(p.faction).name}: {n}{' '} {isAdvisor(p, selected) ? 'advisors' : 'forces'} in sector{' '} {splitLocation(k).sector} )), )} {g.players .filter( (p) => p.noField?.deployed?.location.territory === selected, ) .map((p) => ( {faction(p.faction).name}: concealed No-Field in sector{' '} {p.noField!.deployed!.location.sector} · counts as one force ))}
{g.status !== 'lobby' && (
{g.playedOrnithopter && (

Ornithopter in play

{ g.players.find( (p) => p.id === g.playedOrnithopter!.player, )?.name }{' '} ·{' '} {g.playedOrnithopter.mode === 'range3' ? 'Three-territory group' : 'Two different groups'}{' '} · {g.playedOrnithopter.completed} completed

)}

Spice cards on the table

{g.spiceDiscardTop .slice(0, g.advanced ? 2 : 1) .map((card, index) => (
{g.advanced ? `Spice pile ${index + 1}` : 'Spice pile'}{' '} · top card {card ? ( <> {spiceCardTitle(card)} ) : (

No face-up card yet.

)}
))}
)}
{g.inflation && (

Inflation · {g.inflation.side === 'double' ? 'DOUBLE' : 'CANCEL'} ·{' '} {g.phase === 8 && g.inflation.updatedTurn === g.turn ? 'Next turn’s charity' : 'This turn’s charity'} {g.inflation.side === 'double' ? ' · Bribes prohibited' : ''} {g.inflation.flipped ? ' · Removed at the next Mentat Pause' : ' · Flips at the next Mentat Pause'}

)}
{panel === 'hand' ? ( <> {setupStage ? g.leaderSkills ? 'Starting Treachery Cards are dealt after prediction and before skill assignment.' : 'Your starting Treachery cards remain undealt until all starting force placement is complete.' : g.status === 'lobby' ? 'Your opening cards are dealt after starting force placement.' : 'Your Treachery hand is empty.'}

}> {(c) => { const richese = richeseCardDefinition(c); const presentation = cardPresentation(c); const ecaz = ecazTreacheryDefinition(c); const block = (ecaz ? ecaz.card.effect === 'recruits' ? g.recruitsPreview?.play?.blocked ?? 'Use the Recruits panel during Revival to play this card.' : ecaz.card.effect === 'harassWithdraw' ? g.battle?.harassWithdraw?.blocked ?? 'Use either battle-card slot to play Harass & Withdraw.' : 'This card’s battle effect is still being implemented.' : null) ?? (richese?.card.effect === 'distrans' ? 'Use the Distrans transfer panel above to choose a recipient and another card.' : richese?.card.effect === 'nullentropyBox' ? 'Use the Nullentropy Box panel above to begin the paid search.' : richeseCardActionBlock(c)) ?? (canUseAsKaramaRole(g, me, c) ? g.response ? !g.responseControls?.cancelCards.includes(c.id) ? 'This card cannot cancel the current power.' : null : g.decision ? 'Finish the pending decision before taking an auction card.' : g.phase !== 3 || !g.auction || g.richeseAuction ? 'The Karama purchase option needs an ordinary auction in progress.' : (me.hand?.length ?? 0) >= me.handLimit ? 'Your hand must have room before a Karama auction purchase.' : null : null); const gholaBlock = c.effect !== 'ghola' ? null : !g.ghola.available ? g.ghola.reason : !g.ghola.cards.includes(c.id) ? 'This card is reserved for the Black Market auction.' : leader ? ( leader === 'kwisatz' ? g.ghola.kwisatz : g.ghola.leaders.some((l) => l.id === leader) ) ? null : 'Choose a currently eligible leader.' : !Number.isSafeInteger(amount) || amount < 1 || amount > g.ghola.maxForces ? `Choose between 1 and ${g.ghola.maxForces} eligible forces.` : !Number.isSafeInteger(eliteAmount) || eliteAmount < Math.max( 0, amount - (me.tanks - (me.elites?.tanks ?? 0)), ) || eliteAmount > Math.min(amount, g.ghola.eliteRemaining) ? 'Choose an eligible number of elite forces.' : null; const availability = block || gholaBlock ? { available: false as const, reason: block || gholaBlock!, } : ordinaryCardAvailability( g, c.id, c.effect === 'weather' ? { amount: weatherDistance } : undefined, ); return (
{richese ? presentation.category : battleCardLabel(c.kind)}

{c.name}

{availability && !availability.available && (

{availability.reason}

)} {!availability && g.status === 'setup' && !g.response && c.effect !== 'truthtrance' && (

Finish setup before using this card.

)} {richese ? presentation.role === 'weapon' ? '†' : presentation.role === 'defense' ? '◇' : '✧' : c.kind === 'special' ? '✧' : c.kind === 'shield' || c.kind === 'snooper' ? '◇' : '†'} {canUseAsTruthtranceRole(g, me, c) && ( <>

{c.effect === 'karama' ? 'Shrine lets you use this physical Karama as Truthtrance while you occupy it. ' : ''} Ask another player a public yes/no question, including during another player’s decision. Competing plays follow storm order.

{(me.hand?.filter( (other) => canUseAsTruthtranceRole(g, me, other), ).length ?? 0) > 1 && ( )} )} {c.effect === 'amal' && (

At a phase opening, every faction returns half its available spice to the bank, rounded up. Play before everyone passes.

)} {c.effect === 'thumper' && (

Play before the first spice draw to resolve Shai-Hulud without drawing a card. Fremen protection, rides, Nexus and a waiting Sandtrout apply.

)} {c.effect === 'ghola' && ( <> {g.ghola.eliteBlock && (

{g.ghola.eliteBlock}

)} {homeworldRevivalActionBlock(g, { type: 'card', card: c.id, amount, elite: eliteAmount, leader: leader || undefined }) && (

{homeworldRevivalActionBlock(g, { type: 'card', card: c.id, amount, elite: eliteAmount, leader: leader || undefined })}

)} {me.elites && ( )} )} {g.advanced && canUseAsKaramaRole(g, me, c) && !me.specialKaramaUsed && me.faction === 'harkonnen' && g.phase === 3 && ( <>

Once per game: take up to four unseen cards, inspect them, then return the same number.

{actionButton( 'Use special Karama · exchange', { type: 'card', mode: 'special', card: c.id, target, amount, }, !target, )} )} {g.advanced && canUseAsKaramaRole(g, me, c) && !me.specialKaramaUsed && me.faction === 'fremen' && g.phase === 1 && ( <>

Once per game: summon Shai-Hulud in a sand territory. Resolve protection now; Nexus and rides follow the spice blow.

{actionButton( 'Spend Karama · summon Shai-Hulud', { type: 'card', mode: 'special', card: c.id, territory: selected, }, !TERRITORIES.some( (t) => t.id === selected && t.type === 'sand', ), )} )} {g.advanced && canUseAsKaramaRole(g, me, c) && !me.specialKaramaUsed && me.faction === 'emperor' && g.phase === 4 && ( <>

Once per game: revive up to three forces or one leader for free.

{me.elites && ( )} {actionButton( 'Use special Karama · forces', { type: 'card', mode: 'special', card: c.id, amount, elite: eliteAmount, }, !me.tanks, )} {me.leaders .filter( (l) => l.dead && !l.capturedBy && !l.gholaBy, ) .map((l) => (
{actionButton( `Revive ${l.name} with special Karama`, { type: 'card', mode: 'special', card: c.id, leader: l.id, }, )}
))} )} {c.effect === 'weather' && ( )} {canUseAsKaramaRole(g, me, c) && g.phase === 5 && g.active && (g.active === me.id ? shipmentAvailable : !g.players.find((p) => p.id === g.active)?.shipped) && ( )} {(c.effect !== 'truthtrance' || canUseAsKaramaRole(g, me, c)) && (c.kind === 'special' || canUseAsKaramaRole(g, me, c)) && ( )}
); }}
{!!me.faceDancers?.length && (

Your Face Dancers

{me.faceDancers.map((c) => (
{leaderName(c.leader)} ·{' '} {c.revealed ? 'Revealed' : 'Secret'} {inspectIdentity(c.leader, 'faceDancer')} {g.phase === 8 && !c.revealed && !me.faceDancerReplaced && actionButton( 'Replace this Face Dancer', { type: 'replaceFaceDancer', leader: c.leader }, !!g.phaseOpening || !!g.response || !!g.decision, )}
))}
)} {!!me.traitors?.length && (
YOUR TRAITORS {me.traitors.map((id) => (
{leaderName(id)} {inspectIdentity(id, 'traitor')}
))}
)} ) : panel === 'leaders' ? ( <>

Inspect your leader identities here. Choose and commit a leader through the battle controls when making a plan.

{me.leaders.map((l) => (
Leader strength {leaderStrengthLabel(l)} } fallback={
Leader strength {leaderStrengthLabel(l)}
} />

{faction(l.faction).name}

{l.name}

{l.dead ? 'In the tanks' : !controlsLeader(me, l) ? 'Controlled by another faction' : l.usedAt ? 'Used in a battle this turn' : 'Ready'}

))}
) : panel === 'log' ? (
    {[...g.log].reverse().map((e) => (
  1. {String(e.seq).padStart(3, '0')}

    {e.text.replace( /used Karama to cancel fremenMovement\.$/, 'used Karama to cancel Fremen movement.', )}

  2. ))}
) : (

Rules verification in progress

This build implements a playable portion of the basic game. It is not yet a fully rules-compliant edition.

Still in progress: expansion factions and modules, advanced rules, several special cards, optional faction reactions, ally support, auction deadlines, and a printed-board connectivity audit.

Open the rules coverage ledger
)}
{g.strongholdCards && (
Inspect Stronghold Cards
)}
); }