Files
Dune/components/game-table.tsx
T

6558 lines
329 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { TraitorDecisionPromises } from './traitor-decision-promises';
import { MovementPromises } from './movement-promises';
import { MovementExpressionPromises } from './movement-expression-promises';
import { MixedMovementPromises } from './mixed-movement-promises';
import { HomeworldEcazDuke } from './homeworld-ecaz-duke';
import { integerInputValue } from '../lib/integer-input';
import { normalAuctionHandBlock } from '@/game/normal-auction';
import { EcazSetup } from './ecaz-setup';
import { StormCardInspector, StormCardLogInspector } from './storm-cards';
import { stormCardDistance } from '@/game/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 { EcazOccupyLeadChoice, EcazOccupyBattleSummary, EcazOccupyDialExplanation } from './ecaz-occupy';
import { ecazOccupyOwnProfile, ecazOccupyPlanControl } from '@/game/ecaz-occupy-options';
import { nexusGuildCunningAction, nexusGuildCunningActive, nexusGuildMovementAvailable, nexusGuildShipmentAvailable, nexusGuildSkipShipmentAction } from '@/game/nexus-guild-cunning-options';
import { nexusCoreProfile } from '@/game/faction-module-profile';
import {
matchesShipment,
liveShipmentPromises,
} from '@/game/shipment-promises';
import { ShipmentPromises } from './shipment-promises';
import { AlliancePromises } from './alliance-promises';
import { BribePromises } from './bribe-promises';
import { BribeExpressionPromises } from './bribe-expression-promises';
import { RevivalPromises } from './revival-promises';
import { RevivalExpressionPromises } from './revival-expression-promises';
import { LeaderRevivalPromises } from './leader-revival-promises';
import { BidPromises } from './bid-promises';
import { HomeworldShipment } from './homeworld-shipment';
import { HomeworldRevivalDeployment } from './homeworld-revival-deployment';
import { CaladanReinforcement } from './caladan-reinforcement';
import { GrummanCollection } from './grumman-collection';
import { HomeworldOccupiedIncome } from './homeworld-occupied-income';
import { HomeworldOccupiedPercentage } from './homeworld-occupied-percentage';
import { HomeworldOccupiedBonus } from './homeworld-occupied-bonus';
import { HomeworldTupileCleanup } from './homeworld-tupile-cleanup';
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 { JacurutuDial } from './jacurutu-dial';
import { NexusChoamTrade, NexusChoamInspectionDecision, NexusChoamInspectionInsight } from './nexus-choam-trade';
import { NexusChoamBetrayal } from './nexus-choam-betrayal';
import { NexusMoritaniBetrayal } from './nexus-moritani-betrayal';
import { buildRevision, buildRevisionLabel } from '@/lib/build-revision';
import { NexusEcazBetrayal } from './nexus-ecaz-betrayal';
import { NexusFremenBetrayal } from './nexus-fremen-betrayal';
import { NexusFremenRideBetrayal } from './nexus-fremen-ride-betrayal';
import { NexusEcazInquiry } from './nexus-ecaz-inquiry';
import { NexusEcazDuke } from './nexus-ecaz-duke';
import { NexusTleilaxu } from './nexus-tleilaxu';
import { NexusSuboids } from './nexus-suboids';
import { NexusAdvisors } from './nexus-advisors';
import { NexusSardaukar } from './nexus-sardaukar';
import { TerrorBoardMarkers } from './terror-board-markers';
import { AmbassadorBoardMarkers } from './ambassador-board-markers';
import { CharityClaim } from './charity-claim';
import { FremenRevivalGrant } from './fremen-revival-grant';
import { AdvisorPreparation } from './advisor-preparation';
import { AtreidesFullPlan } from './atreides-full-plan';
import { TleilaxuSpecialKarama } from './tleilaxu-special-karama';
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 {
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 { reinforcementsBattleForces } from '@/game/reinforcements-homeworld';
import { ReinforcementsSources, reinforcementsSourceLabel } from './reinforcements-sources';
import {
StrongholdCardGallery,
StrongholdCopyChoice,
} from './stronghold-cards';
import { EcazPlacement, AmbassadorSupply } from './ecaz-ambassadors';
import { EcazEntry, AmbassadorInsights } from './ecaz-entry';
import { EcazSpecialKarama } from './ecaz-special-karama';
import { MoritaniSpecialKarama } from './moritani-special-karama';
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 { BattleOutcomeWindowControls } from './battle-outcome-window';
import { LeaderSkillsPanel } from './leader-skills';
import { LeaderSkillBattleGuide } from './leader-skill-battle-guide';
import { smugglerBattlePlanBlock } from '@/game/smuggler-battle';
import { SmugglerBattleCollection } from './smuggler-battle-collection';
import { SandmasterVictorySpice } from './sandmaster-victory-spice';
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 { SemutaReaction } from './semuta-reaction';
import { ChoamKull } from './choam-kull';
import { NexusRicheseBetrayal } from './nexus-richese-betrayal';
import { NexusGuildBetrayal } from './nexus-guild-betrayal';
import { NexusIxianReplacement } from './nexus-ixian-replacement';
import { NexusIxianBetrayal } from './nexus-ixian-betrayal';
import { NexusHarkonnenBetrayal } from './nexus-harkonnen-betrayal';
import { OrnithopterMovement } from './ornithopter-movement';
import { DiscoveryOrnithopterMovement } from './discovery-flight-movement';
import { PlanetologistMovement } from './planetologist-movement';
import { selectedOriginElites, advisorFlipMovementBlocked } from '@/game/planetologist-movement';
import { SandmasterMovement } from './sandmaster-movement';
import { SandmasterWormChoice } from './sandmaster-worm';
import { sandmasterWormCollection, sandmasterCunningCollection } from '@/game/sandmaster-worm';
import { fremenCunningPublicDestinationBlock } from '@/game/nexus-fremen-cunning';
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 { IxHomeworldRevivalBonus } from './ix-homeworld-revival';
import { controlsLeader } from '@/game/leader-control';
import { leaderStrengthLabel, harvesterAvailable } from '@/game/cards';
import { RevivalCommerce } from './revival-commerce';
import { RevivalTurn } from './revival-turn';
import { FaceDanceDecision } from './face-dance-decision';
import { CHEAP_HERO_TRAITOR, matchingTraitor } from '@/game/traitors';
import {
battleCardLabel,
isStoneBurner,
isMirrorWeapon,
} from '@/game/battle-cards';
import { validBattleSlotPair, usesNativeBattleSpecial } from '@/game/battle-card-slots';
import { stoneBurnerPlanBlock, stoneBurnerWithdrawalContext } from '@/game/stone-burner';
import { StoneBurnerAllocation } from './stone-burner-allocation';
import { mirrorWeaponModeBlock } from '@/game/mirror-weapon-mode';
import { TECH_TOKENS, ownedTech } from '@/game/tech-tokens';
import {
canUseAsKaramaRole,
canUseAsTruthtranceRole,
} from '@/game/shrine';
import { kullPurposeBlock, kullSpecialPurpose, type KullPurpose } from '@/game/kull-purpose';
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, botMovementRange, groundMoveRouteDistance } from '@/game/bot-mobility';
import { isAlliedShipmentVisit } from '@/game/allied-separation';
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 { SpiceBankerIncome } from './spice-banker-income';
import { canUsePlanetologistBattleSpecial, planetologistSpecialNeedsChoice } from '../game/leader-skill-combat';
import type { Card } from '../game/cards';
import { RevealedBattle } from './revealed-battle';
import { DiplomatDefense } from './diplomat-defense';
import { DiplomatRetreat } from './diplomat-retreat';
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';
import { MoritaniExtortion, MoritaniExtortionNotice } from './moritani-extortion';
import { AdvancedAllySeparation } from './advanced-ally-separation';
import { NexusEmperorPayment } from './nexus-emperor-payment';
import { NexusEmperorSellerPurchase } from './nexus-emperor-seller-purchase';
import { NexusEmperorAmbassadorPurchase } from './nexus-emperor-ambassador-purchase';
export function GameTable({
game: g,
send,
busy: transportBusy,
onExit,
exitDisabled = false,
}: {
game: GameView;
send: (a: Action) => Promise<void>;
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 coreNexusPreview = nexusCoreProfile(g) &&
(g.guildBetrayalPreview || g.nexusIxianReplacementPreview || g.nexusHarkonnenBetrayalPreview || g.nexusIxianBetrayalPreview);
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 reactionBusy =
transportBusy || !!g.roomControl?.paused || !!g.roomControl?.closed || !!me.autopilot;
const ixianBetrayalOwnsControls = !!g.nexusIxianBetrayalReaction &&
!g.response && !g.decision && !g.truthtrance && !g.phaseOpening &&
!g.automaticContinuationPending && !g.nexusCards?.waiting.length && !g.nexusTraitors?.pending;
const harkonnenBetrayalOwnsControls = !!g.nexusHarkonnenBetrayalReaction &&
!g.response && !g.decision && !g.truthtrance && !g.phaseOpening &&
!g.automaticContinuationPending && !g.nexusCards?.waiting.length && !g.nexusTraitors?.pending;
const busy = reactionBusy || !!g.nexusFremenRideBetrayal || g.automaticContinuationPending || !!g.semutaReaction || !!g.kullReaction || !!g.richeseBetrayalReaction || !!g.guildBetrayalReaction || !!g.nexusIxianReplacement || ixianBetrayalOwnsControls || harkonnenBetrayalOwnsControls;
const reactionOwnsControls = !!g.nexusFremenRideBetrayal || !!g.kullReaction || !!g.kullCounterEvent || !!g.richeseBetrayalReaction || !!g.guildBetrayalReaction || !!g.nexusIxianReplacement || ixianBetrayalOwnsControls || harkonnenBetrayalOwnsControls;
const canSpendKarama = (card: Card, purpose: KullPurpose) =>
!kullPurposeBlock(g, purpose) && canUseAsKaramaRole(g, me, card);
const canActivateKarama = (card: Card) => canSpendKarama(card, kullSpecialPurpose(me.faction));
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?.traitorCallChoices?.includes(true) ?? !!(
traitorBattle?.traitorVoters.includes(me.id) &&
traitorPlan &&
!traitorPlan.kwisatz &&
matchingTraitor(
me.traitors ?? [],
traitorPlan.leader,
traitorBattle.cards.find((card) => card.id === traitorPlan.leader),
)
);
const canDeclineTraitor = traitorBattle?.traitorCallChoices?.includes(false) ?? true;
const noTraitorReason = traitorBattle?.ownTraitorPromises?.some(promise => !promise.answer && !promise.fulfilled && !promise.released)
? 'Your accepted Truthtrance answer requires declining this Traitor declaration.' : 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<Difficulty>('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<Record<string, number>>({});
const [eliteSetup, setEliteSetup] = useState<Record<string, number>>({});
const [moveAsFighters, setMoveAsFighters] = useState(false);
const [source, setSource] = useState('');
const [target, setTarget] = useState('');
const [leader, setLeader] = useState('');
const [weapon, setWeapon] = useState('');
const [planetologistDraft, setPlanetologistDraft] = useState<{ key: string; enabled: boolean } | null>(null);
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 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 committed = battlePlanCommitments(g);
const selectedBattleLeader = committed.leader ? String(committed.leader.value ?? '') : leader;
const battleSkills = g.leaderSkills?.assignments.filter(a => a.controller === me.id) ?? [];
const planetologistSpecial = (card: Card) => canUsePlanetologistBattleSpecial({
assignments: battleSkills, selectedLeader: selectedBattleLeader, card,
});
const planetologistRoleKey = `${g.battle?.event ?? ''}:${selectedBattleLeader}:${selectedWeaponId}`;
const planetologistEligible = !!selectedBattleWeapon && planetologistSpecial(selectedBattleWeapon);
const planetologistRoleChoice = planetologistEligible && planetologistSpecialNeedsChoice(selectedBattleWeapon);
const planetologistAlternate = planetologistRoleChoice &&
planetologistDraft?.key === planetologistRoleKey && planetologistDraft.enabled;
const planetologistWeapon = planetologistEligible && (!planetologistRoleChoice || planetologistAlternate);
const reinforcementsSelected = (selectedWeaponId === 'ecaz-reinforcements' && !planetologistWeapon) ||
selectedDefenseId === 'ecaz-reinforcements';
const harassSelected = (selectedWeaponId === 'ecaz-harass-withdraw' && !planetologistWeapon) ||
selectedDefenseId === 'ecaz-harass-withdraw';
const [reinforcementsSourceDraft, setReinforcementsSourceDraft] = useState<{
key: string; index: number;
} | null>(null);
const reinforcementsSourceChoices = g.battle?.reinforcements?.homeworldSources;
const reinforcementsSourceKey = reinforcementsSourceChoices
? `${g.code}:${me.id}:${g.battle?.event ?? ''}:${JSON.stringify(reinforcementsSourceChoices)}` : '';
const reinforcementsSourceIndex = reinforcementsSourceDraft?.key === reinforcementsSourceKey
? Math.min(reinforcementsSourceDraft.index, Math.max(0, (reinforcementsSourceChoices?.length ?? 1) - 1)) : 0;
const selectedReserveSource = reinforcementsSourceChoices?.[reinforcementsSourceIndex];
const rawOwnBattleForces = g.battle?.ownForces;
const ownBattleForces = rawOwnBattleForces && reinforcementsSelected &&
selectedReserveSource && g.battle?.native === me.id
? reinforcementsBattleForces(rawOwnBattleForces, selectedReserveSource[g.battle!.territory])
: rawOwnBattleForces;
const occupyProfile = ecazOccupyOwnProfile(g);
const battleDialMinimum = occupyProfile?.fixedEcazDial ?? 0;
const battleFreeSupport = ownBattleForces?.freeSupport ??
(me.faction === 'fremen' && !g.battle?.fremenSupportBlocked);
const battleForceOwnerName = occupyProfile
? g.players.find((player) => player.id === occupyProfile.forceOwner)?.name ?? occupyProfile.forceOwner
: me.name;
const battleForces = ownBattleForces
? ownBattleForces.normal + ownBattleForces.elite
: g.battle
? fighterCount(me, g.battle.territory)
: 0;
const battleDialMaximum = occupyProfile?.maxDial ?? (ownBattleForces
? maxCombatDial(ownBattleForces)
: g.advanced || me.faction === 'ixians'
? 40
: battleForces);
const battleDialStep = g.advanced || ownBattleForces?.normalFixedHalf || 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(
battleDialMinimum,
Math.min(
Math.round(dial / battleDialStep) * battleDialStep,
battleDialMaximum,
),
)
: battleDialMinimum;
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<Record<string, number>>({
[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<Record<string, number>>({});
const [rideForces, setRideForces] = useState<Record<string, number>>({});
const [collectSandmasterRide, setCollectSandmasterRide] = useState(true);
const [sandmasterRidePile, setSandmasterRidePile] = useState<{ target: string; key: string } | null>(null);
const sandmasterRideTarget = JSON.stringify([g.turn,
g.decision?.kind === 'wormRide' || g.decision?.kind === 'nexusFremenCunningRide' ? g.decision : null,
source, selected, sector]);
const baseSandmasterRide = g.decision?.kind === 'nexusFremenCunningRide'
? sandmasterCunningCollection(g, me.id, source, selected, sector)
: sandmasterWormCollection(g, me.id, selected, sector);
const selectedRidePile = sandmasterRidePile?.target === sandmasterRideTarget
? baseSandmasterRide?.piles.find(pile => pile.key === sandmasterRidePile.key) : null;
const sandmasterRide = selectedRidePile && baseSandmasterRide
? { ...baseSandmasterRide, key: selectedRidePile.key, before: selectedRidePile.before } : baseSandmasterRide;
const cunningDestinationBlocked = g.decision?.kind === 'nexusFremenCunningRide' && source
? fremenCunningPublicDestinationBlock(g, me.id, source, { territory: selected, sector }) : null;
const [exchangeCards, setExchangeCards] = useState<Record<string, boolean>>(
{},
);
const [discardCards, setDiscardCards] = useState<Record<string, boolean>>({});
const stormLosses = g.decision?.kind === 'stormLosses' ? g.decision : null;
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 reinforcementsPlanReason = !reinforcementsSelected ? null :
!g.battle?.reinforcements ? 'Reinforcements is unavailable in this battle.' :
g.battle.reinforcements.blocked ??
(g.homeworlds && !selectedReserveSource ? 'Choose the exact native Reinforcements reserve sources.' : null);
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,
}, g.battle.smugglerCollectionSupported, { territory: g.battle.territory, spice: g.spice }) : null;
const occupyControl = ecazOccupyPlanControl(g,
committed.dial ? Number(committed.dial.value) : battleDial,
battleFreeSupport ? 0 : battleSupport);
const battlePairValid = validBattleSlotPair(selectedBattleWeapon, selectedBattleDefense,
planetologistWeapon);
const harassControl = harassWithdrawControlState(g.battle?.harassWithdraw,
harassSelected,
committed.dial ? Number(committed.dial.value) : battleDial,
battleFreeSupport ? 0 : battleSupport);
const selectedSpecialWeapon = me.hand?.find(
(c) => c.id === selectedWeaponId && (isStoneBurner(c) || isMirrorWeapon(c)),
);
const mirrorSelected = isMirrorWeapon(selectedSpecialWeapon);
const stoneContext = g.battle?.stoneBurnerContext;
const stoneCandidateContext = selectedSpecialWeapon &&
harassSelected
? stoneBurnerWithdrawalContext(stoneContext?.occupy,
g.battle!.attacker === me.id ? 'attacker' : 'defender', g.battle?.harassWithdraw?.occupy)
: stoneContext?.occupy;
const stonePlanReason = !selectedSpecialWeapon
? null
: ((mirrorSelected ? mirrorWeaponModeBlock(g) : 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',
stoneCandidateContext,
stoneContext?.allowAllocations,
),
)
.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: g.karamaShipping?.player === me.id ||
(!g.guildRateCanceled &&
(me.faction === 'guild' ||
g.players.some((p) => p.faction === 'guild' && p.id === me.ally))),
};
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)
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 advisorFlipRouteBlocked = ordinaryMovementAction.fighters === true && advisorFlipMovementBlocked(g, me);
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 movementTarget = boardLocation(selected, sector);
const selectedMovementElite = combineSectors
? selectedOriginElites(physicalDraftEntries, eliteForces, splitLocation(source).territory)
: eliteAmount;
const ordinaryMovementRange = botMovementRange(g, me, selectedMovementElite);
const ordinaryMovementRouteBlocked = physicalDraftEntries.some(([key, count]) =>
count > 0 && groundMoveRouteDistance(g, me, key, movementTarget) > ordinaryMovementRange) ||
(!!includesNoField && !!ownNoField && groundMoveRouteDistance(g, me,
boardLocation(ownNoField.location.territory, ownNoField.location.sector), movementTarget) > ordinaryMovementRange);
const act = (a: Action) => {
// Control of one's own seat is independent of every gameplay response.
if (a.type === 'setAutopilot') { void send(a); return; }
const harkonnenReaction = g.nexusHarkonnenBetrayalReaction;
if (harkonnenReaction && harkonnenBetrayalOwnsControls) {
if (reactionBusy || g.status !== 'playing' || !harkonnenReaction.canPass || harkonnenReaction.hasPassed ||
(a.type !== 'nexusHarkonnenBetrayalPass' && a.type !== 'nexusHarkonnenBetrayalUse') ||
a.event !== harkonnenReaction.event ||
(a.type === 'nexusHarkonnenBetrayalUse' && (!harkonnenReaction.canUse || harkonnenReaction.blocked))) return;
}
const ixianReaction = g.nexusIxianBetrayalReaction;
if (ixianReaction && ixianBetrayalOwnsControls) {
if (reactionBusy || g.status !== 'playing' || !ixianReaction.canPass || ixianReaction.hasPassed ||
(a.type !== 'nexusIxianBetrayalPass' && a.type !== 'nexusIxianBetrayalUse') ||
a.event !== ixianReaction.event ||
(a.type === 'nexusIxianBetrayalUse' && (!ixianReaction.canUse || ixianReaction.blocked))) return;
}
const guildReaction = g.guildBetrayalReaction;
if (guildReaction) {
if (reactionBusy || g.status !== 'playing' || !guildReaction.canPass || guildReaction.hasPassed ||
(a.type !== 'guildBetrayalPass' && a.type !== 'guildBetrayalUse') ||
a.event !== guildReaction.event ||
(a.type === 'guildBetrayalUse' && (!guildReaction.canUse || guildReaction.blocked))) return;
}
const replacement = g.nexusIxianReplacement;
if (replacement && a.type !== 'setAutopilot') {
if (reactionBusy || g.status !== 'playing' || !replacement.canPass || replacement.buyer !== me.id ||
(a.type !== 'nexusIxianReplacementPass' && a.type !== 'nexusIxianReplacementUse') ||
a.event !== replacement.event ||
(a.type === 'nexusIxianReplacementUse' && (!replacement.canUse || replacement.blocked))) return;
}
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 ? (
<LeaderInspector kind={kind} identity={identity}
assassination={assassination ? assassination.stage === 'replaced' ? 'retired' : 'revealed' : undefined} />
) : 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,
);
const atomicsShipmentBlocked =
a.type === 'ship' && a.territory === g.moritaniAtomics?.territory;
return (
<Button
className="game-action"
aria-describedby={
[
describedBy,
shipmentBlocked ? 'shipment-promise-guidance' : undefined,
atomicsShipmentBlocked ? 'atomics-shipment-guidance' : undefined,
]
.filter(Boolean)
.join(' ') || undefined
}
disabled={busy || !!g.truthtrance || !!g.nexusTraitors?.pending || disabled || shipmentBlocked || atomicsShipmentBlocked}
onClick={() => act(a)}
>
{text}
<ArrowRight size={15} />
</Button>
);
};
const destination = (
<>
<label>
Destination
<select value={selected} onChange={(e) => choose(e.target.value)}>
{gameTerritories(g).map((t) => (
<option key={t.id} value={t.id}>
{t.name}
</option>
))}
</select>
</label>
<label htmlFor="destination-sector">
Sector <HelpTip topic="sector" />
<select
id="destination-sector"
value={sector}
onChange={(e) => setSector(Number(e.target.value))}
>
{territory(selected).sectors.map((s) => (
<option key={s} value={s}>
{s === 0
? selected === MOBILE_STRONGHOLD
? 'Inside stronghold'
: selected === 'polar_sink'
? 'Polar sink'
: 'Inside location · sector 0'
: s}
{s === g.storm ? ' · Storm' : ''}
</option>
))}
</select>
</label>
</>
);
const amountInput = (
<>
<label htmlFor="forces">
{includesNoField ? 'Physical forces accompanying No-Field' : 'Forces'}
<Input
id="forces"
type="number"
min={includesNoField ? 0 : 1}
max={useRicheseShipment && !me.shipped ? shipmentMaximum : 20}
value={amount}
onChange={(e) => setAmount(Number(e.target.value))}
/>
</label>
{me.elites && (
<EliteCount
value={eliteAmount}
onChange={setEliteAmount}
max={Math.min(amount, me.faction === 'ixians' ? 7 : 5)}
label={me.faction === 'ixians' ? 'Cyborgs' : 'Elite forces'}
/>
)}
</>
);
const sourceInput = (
<label>
From
<select
value={source}
onChange={(e) => {
const next = e.target.value;
setSource(next);
const markerOnly =
!!ownNoField &&
`${ownNoField.location.territory}:${ownNoField.location.sector}` ===
next &&
!(me.forces[next] > 0);
setMoveNoField(markerOnly);
if (markerOnly) setAmount(0);
else if (amount === 0) setAmount(1);
}}
>
<option value="">Select your forces or No-Field</option>
{Object.entries(presenceByLocation(me))
.filter(([, n]) => n > 0)
.map(([k, n]) => (
<option key={k} value={k}>
{territory(splitLocation(k).territory).name} ·{' '}
{splitLocation(k).sector} ({me.forces[k] ?? 0} physical forces
{n > (me.forces[k] ?? 0) ? ' + concealed No-Field' : ''})
</option>
))}
</select>
</label>
);
return (
<main className="table-shell live-table">
<header className="masthead">
<button
className="wordmark wordmark-button"
onClick={onExit}
disabled={transportBusy || exitDisabled}
aria-label="Return to lobby"
>
DUNE<span>ARRAKIS TABLE</span>
</button>
<div className="masthead-right">
<AutomaticActionNotice key={g.code} events={automaticEvents} />
<TableSounds key={`sound:${g.code}`} frame={{
room: g.code,
phase: `${g.status}:${g.turn}:${g.phase}`,
sequence: g.log.reduce((last, entry) => Math.max(last, entry.seq), -1),
automaticSequence: automaticEvents.reduce((last, entry) => Math.max(last, entry.seq), -1),
}} />
<span className="status-dot" />
{g.status === 'lobby' ? 'Gathering players' : `Turn ${g.turn} / 10`}
<span className="build-revision" title={buildRevision}>
Git revision: <code>{buildRevisionLabel}</code>
</span>
<Button
variant="outline"
onClick={async () => {
try {
await navigator.clipboard.writeText(
`${window.location.origin}/?room=${g.code}`,
);
setCopied(true);
} catch {
setCopied(false);
}
}}
>
{copied ? <Check size={15} /> : <Copy size={15} />} {g.code}
</Button>
</div>
</header>
<RoomControlNotice control={g.roomControl} />
<div className="game-banner">
<span className="eyebrow">
{g.status === 'lobby'
? 'WAR COUNCIL'
: g.status === 'setup'
? 'PREPARING THE TABLE'
: g.status === 'finished'
? 'THE FATE OF ARRAKIS'
: PHASES[g.phase]?.toUpperCase()}
</span>
<span>
{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'
: harkonnenBetrayalOwnsControls
? 'Declared Harkonnen traitor acknowledgement'
: ixianBetrayalOwnsControls
? 'Native Ixian advantage acknowledgement'
: g.guildBetrayalReaction
? 'Shipment payment acknowledgement'
: g.richeseBetrayalReaction
? 'Richese auction acknowledgement'
: g.kullReaction
? 'CHOAM Karama response'
: g.semutaReaction
? 'Fresh discard response'
: 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'}
</span>
</div>
{g.advanced && g.status !== 'lobby' && <AdvancedPreviewNotice compact />}
{g.homeworldOccupationPreview && (
<output className="notice block">
Homeworld occupation development preview · printed bank Collection,
percentage income, original Bidding effects, immediate ally sharing
and occupied native defenses are connected. Advanced occupation benefits
are retained until the occupier’s last force leaves; Basic lifecycle and
unresolved competing-owner cases remain guarded. Not complete or certified Homeworld rules.{' '}
<a href="/rules?topic=homeworlds#homeworlds">Preview rules and limits</a>
</output>
)}
{g.homeworldEcazVictoryPreview && (
<output className="notice block">
Partial · Ecaz High Homeworld victory development preview, enabled only
for this fresh preview game. Public progress is separate from ordinary
stronghold and Ecaz three-joint-stronghold victory; it is evaluated only
at the Mentat victory check. Unresolved source cases remain guarded.
Not complete or certified Homeworld rules.
</output>
)}
{g.homeworldTupilePreview && (
<output className="notice block">
Occupied Tupile development preview · the occupier and reciprocal ally
each gain one extra hand slot. A player who loses it must discard
eligible held cards to the normal limit.{' '}
{g.advanced ? <>Advanced retains occupation benefits until the occupier’s
last own force leaves; contests, native high population and turn changes
do not end it.</> : <>Basic provisionally gives the slot to the current
sole foreign controller. Departure, another foreign army or native high
population ends it. If you regain control, finish your earlier required
discards before using the extra slot again.</>}{' '}
Continuous control never adds a second slot. No extra card or spice is granted.
Not complete or certified Homeworld rules.{' '}
<a href="/rules?topic=homeworlds#homeworlds">Preview rules and limits</a>
</output>
)}
{g.homeworldTupileBlocked && (
<output className="notice block">{g.homeworldTupileBlocked}</output>
)}
{g.homeworldTupileCleanup?.blocked && (g.decision?.kind !== 'homeworldTupileCleanup' || g.decision.player !== g.me || g.decision.event !== g.homeworldTupileCleanup.event) && (
<HomeworldTupileCleanup game={g} act={act} busy={true} />
)}
{g.homeworldOccupiedPercentage?.blocked && (g.decision?.kind !== 'homeworldOccupiedPercentage' || g.decision.player !== g.me) && (
<HomeworldOccupiedPercentage game={g} act={act} busy={true} />
)}
{g.homeworldOccupiedBonus?.blocked && (g.decision?.kind !== 'homeworldOccupiedBonus' || g.decision.player !== g.me) && (
<HomeworldOccupiedBonus game={g} act={act} busy={true} />
)}
{g.richeseBidding?.stage === 'cacheOffer' && g.richeseBidding.offerBlocked && !g.decision && (
<output className="notice block" aria-label="Blocked Richese cache choice">
{g.richeseBidding.offerBlocked}
</output>
)}
{g.semutaPreview && (
<p className="notice" role="status">
{g.homeworlds ? (
<>
Semuta Drug with Homeworlds development preview · original Richese,
CHOAM and classic occupation setup, with no other optional modules.
Paid high-Kaitain closing discards, occupied Tupile lost-slot cleanup,
paid Nullentropy Box search, clean Distrans transfer, definite
Truthtrance answers and used ordinary special cards offer Semuta.
Completed legal CHOAM market sales also offer their used card;
recovery preserves the already credited two or three spice.
Final Ornithopter disposal keeps the completed original groups;
finite Once Around and clean unstarted movement Sapho costs keep
the changed queue before its original next actor continues.
Accepted losing-card, mandatory winning-card and selected winner
disposal are separate fresh events. Recovery keeps the original
casualties and paid native deployments; combat does not repeat.
Clean Residual Poison keeps its one native random leader death
without a bounty. Early Sapho aggressor and remaining Battle
chooser costs keep their original participants and finite priority.
Native printed Kulon, Baliset, La La La, Trip to Gamont and Jubba
costs also pause before their original effect. Printed Karama
canceling these native powers or Guild, Emperor auction or Richese
purchase income remains supported.
Printed Karama canceling CHOAM opening income or Inflation and
normal-lot purchase/payment costs keep the original denial,
spent placement attempt or one zero-spice acquisition.
Native Advanced BG extra-charity and allied Emperor gift/extra-
revival cancellation costs also retain the consumed claim and
original ally, casualties and unawarded benefit.
Printed Karama canceling native capture, Auditor or quoted battle
income also keeps accepted combat, paid support and original
cleanup; the canceled reward does not occur.
Private discarded faces stay hidden until commitment; a simultaneous
group requires one selection. Claim or Continue resumes the same
original source once without undoing payment or force commitment.
Canceling native income does not cancel
the printed occupied share: its original receiver still allocates it
before Movement, the next auction lot or saved purchase controls.
Other Homeworld producers and Basic unresolved Tupile loss remain
guarded. Not complete or certified Homeworld rules.{' '}
</>
) : (
<>
Semuta Drug development preview · bounded fresh-discard reactions,
not complete Richese or certified expansion rules. Fresh bare Basic
Richese starts enable these reactions with their selected native
families; older games are unchanged. Clean CHOAM and Ixian Ambassador
discards, including BG copies, now pause after ordinary shipment or
movement entry. CHOAM keeps its already-earned spice; Ixians draw their
one replacement after the reaction. Other nested arrivals remain
unfinished. Original Tech Tokens
may remain in supported fresh 3–6-seat CHOAM/Ix profiles; the original
winner’s award follows card cleanup and precedes Face Dance. Original
Stronghold Cards may remain in Advanced 2–6-seat profiles and are claimed
only at the end of Mentat. The explicit Leader Skills entry retains all
fourteen original skills and native training. The explicit Nexus entry
retains all twelve original Nexus cards and qualifying closing draws,
never a starting Nexus hand. CHOAM Secret Ally trade, native CHOAM
Betrayal and the five ordinary Cunning fuel discards can react before
their original effects or receipts finish. Cunning fuel keeps its
printed identity and private face; Kull retains its separate guard.
A printed Karama canceling those native powers, the Emperor’s shared
gift/revival, Guild shipment income, paid Emperor auction income or
Richese special-purchase income also reacts before the original
cancellation. The denied effect never applies; already-paid shipments
and purchases are not undone. Taking Karama cannot restore the
canceled power or refund a payment.
Homeworlds, Discovery and other overlays stay guarded.
Native Moritani
support is Basic only: clean shipment/movement Sabotage and Robbery
overflow plus native losing-ally card-retention cleanup; other arrival
continuations remain guarded.{' '}
</>
)}
<a href="/rules?topic=card-richese-semuta-drug#card-richese-semuta-drug">Preview rules and limits</a>
</p>
)}
{g.nexusKullPreview ? (
<p className="notice" role="status">
CHOAM Nexus Cunning · Kull Wahad development preview. Any eligible
Treachery Card can pay for the pending Karama interception; accepted
declarations spend the Nexus even if a distinct Karama prevents Kull.
This is not complete CHOAM or certified Nexus rules.{' '}
<a href="/rules?topic=nexus-choam-kull#nexus-choam-kull">Preview rules and limits</a>
</p>
) : g.kullPreview && (
<p className="notice" role="status">
Kull Wahad development preview · provisional interception timing, not
complete CHOAM or certified expansion rules.{' '}
<a href="/rules?topic=choam-kull#choam-kull">Preview rules and limits</a>
</p>
)}
{g.richeseBetrayalPreview && (
<p className="notice" role="status">
Richese Nexus Betrayal development preview · public cache self-purchase
veto and ordinary other-buyer sale diversion only; not complete Richese
or certified Nexus rules.{' '}
<a href="/rules?topic=nexus-richese-betrayal#nexus-richese-betrayal">Preview rules and limits</a>
</p>
)}
{coreNexusPreview && (
<p className="notice" role="status">
Nexus core reactions preview · Basic/Advanced classic factions with base33 or
independent36, or selected expansion decks with the independent three-card
variant, permitting registered classic and selected native seats. Optional all14
Leader Skills and Homeworlds compose.
Own-World Reinforcements fees provisionally spend ordinary non-role counters first,
reducing temporary Sardaukar roles only when converted counters pay.
Original Tech (3+ seats) and Advanced Strongholds (2+ seats) may compose. Native reactions
retain original card, force and payment authority. Acknowledgement timing and
purchase/bonus ordering remain provisional. Other modules and complete Nexus
acceptance remain separate.{' '}
<a href="/rules?topic=nexus-cards#nexus-cards">Preview rules and limits</a>
</p>
)}
{g.guildBetrayalPreview && !coreNexusPreview && (
<p className="notice" role="status">
Guild Nexus Betrayal development preview · whole funded shipment
payment replacement, including your own payment and occupied Junction.
Classic factions with native Guild and Nexus; optional genuinely
seeded Homeworlds only. Not complete Guild or certified Nexus rules.{' '}
<a href="/rules?topic=nexus-guild-betrayal#nexus-guild-betrayal">Preview rules and limits</a>
</p>
)}
{g.nexusIxianReplacementPreview && !coreNexusPreview && (
<p className="notice" role="status">
Ixian Nexus purchased-card replacement development preview · original supported
standalone Tleilaxu or Richese entries. Normal paid/Karama and supported native
Richese purchases retain their exact source; replacement-before-bonus and cache
interpretations remain provisional. Not complete Ixian or certified Nexus rules.{' '}
<a href="/rules?topic=nexus-ixian-replacement#nexus-ixian-replacement">Preview rules and limits</a>
</p>
)}
{g.nexusIxianBetrayalPreview && !coreNexusPreview && (
<p className="notice" role="status">
Ixian Nexus Betrayal development preview · native Ixian Bidding extra-card
inspection/draw and Advanced Technology exchange prevention. Native Ixians,
permitted classic/Tleilaxu seats, physical Ixian Treachery and Nexus only.
Native Karama responses finish first. Not complete Ixian or certified Nexus rules.{' '}
<a href="/rules?topic=nexus-ixian-betrayal#nexus-ixian-betrayal">Preview rules and limits</a>
</p>
)}
{g.nexusHarkonnenBetrayalPreview && !coreNexusPreview && (
<p className="notice" role="status">
Harkonnen Nexus Betrayal development preview · cancel an actually declared native Harkonnen
traitor call, return that physical card to the Traitor Deck, and draw one private replacement
automatically at this turn’s Mentat Pause. Classic Basic/Advanced, native Harkonnen, base
Treachery and Nexus only. Native allied Karama counters finish first. Not complete Harkonnen
or certified Nexus rules.{' '}
<a href="/rules?topic=nexus-harkonnen-betrayal#nexus-harkonnen-betrayal">Preview rules and limits</a>
</p>
)}
{g.status !== 'lobby' && (
<nav className="phase-track" aria-label="Turn phases">
{PHASES.map((p, i) => (
<span
key={p}
className={i === g.phase ? 'current' : i < g.phase ? 'done' : ''}
>
<b>{i + 1}</b>
{p}
</span>
))}
</nav>
)}
{g.sandtrout && (
<p className="notice">
Sandtrout is waiting: the next Shai-Hulud card will be suppressed. An
immediate territory replacement receives double spice.
</p>
)}
{g.spicePeek && (
<section
className="private-spice-preview"
aria-label="Your private spice foresight"
>
<div>
<span className="eyebrow">Private foresight · Atreides</span>
<h3>{spiceCardTitle(g.spicePeek)}</h3>
<p className="fine">Only you can see this upcoming card.</p>
</div>
<SpiceCardInspector card={g.spicePeek} context="Private foresight" />
</section>
)}
<SeatAutopilot game={g} act={act} busy={transportBusy} />
<DukeVidal game={g} />
<EcazLoyaltyCard loyalty={g.ecazLoyalty} />
<MoritaniTerrorSupply game={g} />
<MoritaniExtortionNotice game={g} />
{g.arrivalOverlap && (
<section className="notice" aria-label="Ambassador and Terror response order">
<p>
{g.players.find((player) => player.id === g.arrivalOverlap!.entrant)?.name ?? 'The entrant'}
{' '}entered {territory(g.arrivalOverlap.territory).name}. Ecaz Ambassador
and Moritani Terror resolve in this table’s provisional storm order:{' '}
{g.arrivalOverlap.first === 'ambassador' ? 'Ecaz, then Moritani' : 'Moritani, then Ecaz'}.
{g.semutaReaction
? ' The current fresh-discard response must finish before the next arrival decision.'
: <> {g.arrivalOverlap.active === 'ambassador' ? 'Ecaz' : 'Moritani'} decides now.</>}
{' '}The original entry is not repeated.
</p>
</section>
)}
<MoritaniAssassinateHistory game={g} />
<AmbassadorSupply game={g} />
<AmbassadorInsights key={`${g.code}-${g.me}`} game={g} />
<AuditorInsight game={g} />
<NexusChoamInspectionInsight game={g} />
<div className="play-grid">
<aside className="players-panel">
<div className="eyebrow">THE GREAT HOUSES</div>
{g.players.map((p) => (
<div
key={p.id}
className={`player-card ${p.id === turnOwner ? 'active-player' : ''}`}
style={
{
'--faction-color': faction(p.faction).color,
} as React.CSSProperties
}
>
<div className="player-heading">
<span className="sigil">{faction(p.faction).sigil}</span>
<span>
<strong>{faction(p.faction).name}</strong>
<small>
{p.name}
{p.id === g.me ? ' · You' : ''}
{p.bot ? ` · ${p.bot}` : ''}
{p.autopilot ? ` · ${p.autopilot} autopilot` : ''}
</small>
</span>
{p.ready && g.status === 'lobby' && <Check size={16} />}
</div>
<p className="fine">
Player circle {g.playerPositions[p.id]} · Sector{' '}
{PLAYER_CIRCLE_SECTORS[g.playerPositions[p.id] - 1]}
</p>
{p.id === me.id && g.status === 'lobby' && (
<div className="player-circle-choice">
<label htmlFor="player-circle">Your player circle</label>
<select
id="player-circle"
aria-describedby="player-circle-help"
value={g.playerPositions[p.id]}
disabled={busy}
onChange={(event) =>
act({
type: 'seatPosition',
position: Number(event.target.value),
})
}
>
{PLAYER_CIRCLE_SECTORS.map((circleSector, index) => {
const occupant = g.players.find(
(player) =>
player.id !== me.id &&
g.playerPositions[player.id] === index + 1,
);
return (
<option
key={circleSector}
value={index + 1}
disabled={!!occupant}
>
Circle {index + 1} · Sector {circleSector}
{occupant ? ` · ${occupant.name}` : ''}
</option>
);
})}
</select>
<span className="fine" id="player-circle-help">
Circles determine storm order. Changing yours resets
readiness.
</span>
</div>
)}
{p.bot && g.status === 'lobby' && g.host === me.id && (
<LobbyBotControls g={g} target={p.id} busy={busy} act={act} />
)}
<div className="player-stats">
<span>{p.reserves} reserves</span>
<span>{p.tanks} tanks</span>
{p.noField?.lastUsed !== undefined &&
p.noField.lastUsed !== null && (
<span>No-Field {p.noField.lastUsed} · last used</span>
)}
{p.elites && (
<span>
{Object.values(p.elites.forces).reduce((a, b) => a + b, 0)}{' '}
elite on Dune
</span>
)}
</div>
<ForceInspector player={p} worlds={g.homeworlds?.worlds} />
<FactionInspector key={`${p.faction}:${g.advanced}`} factionId={p.faction} advanced={g.advanced} />
{p.revealedTraitors.length > 0 && (
<p className="fine">
Revealed traitors:{' '}
{p.revealedTraitors.map(leaderName).join(', ')}
</p>
)}
{!!p.revealedFaceDancers.length && (
<p className="fine">
Revealed Face Dancers:{' '}
{p.revealedFaceDancers.map(leaderName).join(', ')}
</p>
)}
{g.techTokens &&
ownedTech(g.techTokens, p.id).map((id) => (
<div className="fine" key={id}>
{TECH_TOKENS.find((t) => t.id === id)!.name}
{g.techTokens![id].spice > 0 &&
` · ${g.techTokens![id].spice} spice at phase end`}
</div>
))}
{p.ally && (
<div className="fine">
Allied with{' '}
{
faction(g.players.find((x) => x.id === p.ally)!.faction)
.name
}
</div>
)}
</div>
))}
{g.status === 'lobby' &&
g.host === me.id &&
g.players.length < 6 &&
availableBotFactions.length > 0 && (
<section className="bot-controls" aria-label="Add an AI player">
<h3>Add an AI player</h3>
<label>
Faction
<select
value={nextBotFaction}
onChange={(e) => setBotFaction(e.target.value)}
>
{availableBotFactions.map((f) => (
<option value={f.id} key={f.id}>
{f.name}
</option>
))}
</select>
</label>
<label>
Difficulty
<select
value={botDifficulty}
onChange={(e) =>
setBotDifficulty(e.target.value as Difficulty)
}
>
{DIFFICULTIES.map((d) => (
<option key={d}>{d}</option>
))}
</select>
</label>
<p className="fine">{BOT_DESCRIPTIONS[botDifficulty]}</p>
{actionButton('Add AI player', {
type: 'addBot',
faction: nextBotFaction,
difficulty: botDifficulty,
})}
</section>
)}
{me.leaders.some((l) => l.capturedBy) && (
<p className="fine">
{me.leaders
.filter((l) => l.capturedBy)
.map(
(l) =>
`${l.name}: ${l.capturedBy === me.id ? 'your captive' : 'captured by Harkonnen'}`,
)
.join(' · ')}
</p>
)}
{me.leaders.some((l) => l.gholaBy) && (
<p className="fine">
Gholas:{' '}
{me.leaders
.filter((l) => l.gholaBy)
.map(
(l) =>
`${l.name} · ${l.gholaBy === me.id ? 'your leader pool' : 'Tleilaxu leader pool'}${l.dead ? ' · in the tanks' : ''}`,
)
.join(' / ')}
</p>
)}
{me.kwisatz && (
<div className="space-y-2"><p className="fine">
Kwisatz Haderach:{' '}
{me.kwisatz.dead
? 'awaiting revival'
: me.kwisatz.active
? 'awakened'
: `${me.kwisatz.losses} of 7 battle losses`}
{me.kwisatz.usedAt
? ` · used in ${combatName(me.kwisatz.usedAt)} this turn`
: ''}
</p>
<KwisatzInspector state={me.kwisatz} usedTerritoryName={me.kwisatz.usedAt ? combatName(me.kwisatz.usedAt) : undefined} />
</div>
)}
{g.stormForecast !== null && (
<div className="space-y-2">
<p className="fine">Your storm forecast: {g.stormForecast} sectors next turn.</p>
<StormCardInspector distance={g.stormForecast} context="forecast" />
</div>
)}
<div className="resource-readout">
<Coins size={18} />
<strong>{me.spice ?? 0}</strong>
<span>
Your spice <HelpTip topic="spice" />
</span>
</div>
<div className="fine">
Private hands and spice are visible only to their owner.
</div>
</aside>
<section className="board-column">
<SpiceBankerIncome game={g} />
<DrawPiles piles={g.drawPiles} />
<div className="board-toolbar">
<span>
<Wind size={15} />
Storm · Sector {g.storm}
</span>
<div>
<Button
variant="ghost"
aria-label="Zoom out"
onClick={() => setZoom(Math.max(1, zoom - 0.25))}
>
<ZoomOut size={16} />
</Button>
<Button
variant="ghost"
aria-label="Zoom in"
onClick={() => setZoom(Math.min(2.5, zoom + 0.25))}
>
<ZoomIn size={16} />
</Button>
</div>
</div>
<div className="board-viewport">
<svg
className="game-board"
style={{ width: `${zoom * 100}%`, maxWidth: 'none' }}
viewBox="-7 -13 1200 1200"
role="img"
aria-label="Arrakis territory map"
>
<defs>
<pattern
id="terrain"
width="1095"
height="1095"
patternUnits="userSpaceOnUse"
>
<image
href="/art/arrakis-terrain.png"
x="45"
y="40"
width="1095"
height="1095"
opacity="0.36"
/>
</pattern>
<radialGradient id="sand">
<stop offset="0%" stopColor="#66593b" />
<stop offset="100%" stopColor="#353b2b" />
</radialGradient>
</defs>
<circle cx="593" cy="587" r="535" fill="url(#sand)" />
<circle cx="593" cy="587" r="535" fill="url(#terrain)" />
{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 (
<g
key={`player-circle-${index}`}
className="board-player-circle"
transform={`translate(${593 + Math.cos(angle) * 562},${587 + Math.sin(angle) * 562})`}
>
<title>{`Player circle ${index + 1}, sector ${circleSector}: ${player
? `${player.name} · ${faction(player.faction).name}`
: 'Empty'}`}</title>
<circle
r="21"
fill={player ? faction(player.faction).color : '#171b17'}
stroke={player?.id === me.id ? '#fff3d6' : '#aa9162'}
strokeWidth={player?.id === me.id ? 3 : 1.5}
/>
<text
textAnchor="middle"
dominantBaseline="central"
fill="#fff3d6"
fontSize="17"
fontWeight="700"
>
{index + 1}
</text>
</g>
);
})}
{g.mobileStronghold?.location && (
<line
x1="1045"
y1="150"
x2={
territory(
splitLocation(g.mobileStronghold.location).territory,
).center[0]
}
y2={
territory(
splitLocation(g.mobileStronghold.location).territory,
).center[1]
}
stroke="#a3c8c8"
strokeDasharray="7 7"
opacity="0.65"
pointerEvents="none"
/>
)}
{gameTerritories(g).map((t) => (
<g
key={t.id}
className={`territory ${t.type} ${selected === t.id ? 'selected' : ''}`}
role="button"
tabIndex={0}
aria-label={`Select ${t.name}`}
onClick={() => choose(t.id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
choose(t.id);
}
}}
>
<polygon
points={t.points.map((p) => p.join(',')).join(' ')}
/>
<text x={t.center[0]} y={t.center[1]} textAnchor="middle">
{t.name}
</text>
</g>
))}
{Array.from({ length: 18 }, (_, i) => {
const a = (Math.PI / 180) * (120 - i * 20);
return (
<g key={i}>
<line
x1={593}
y1={587}
x2={593 + Math.cos(a) * 535}
y2={587 + Math.sin(a) * 535}
stroke="#dccb9666"
strokeDasharray="3 8"
pointerEvents="none"
/>
<text
x={593 + Math.cos(a - Math.PI / 18) * 516}
y={587 + Math.sin(a - Math.PI / 18) * 516}
className="sector-label"
>
{i + 1}
</text>
</g>
);
})}
<g
className="storm-overlay"
style={{
transform: `rotate(${-(g.storm - 1) * 20}deg)`,
transformOrigin: '593px 587px',
}}
pointerEvents="none"
>
<path
d="M593 587 L325.5 1050.3 A535 535 0 0 0 500.1 1113.9 Z"
fill="#d3a24655"
stroke="#d7b066"
strokeWidth="2"
/>
</g>
{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) => (
<g
key={`${t.id}-${p.id}`}
pointerEvents="none"
className={`force-token${isAdvisor(p, t.id) ? ' advisor-token' : ''}`}
aria-label={`${p.name}: ${isAdvisor(p, t.id) ? 'advisors' : 'fighters'}`}
transform={`translate(${t.center[0] + (i - (present.length - 1) / 2) * 27},${t.center[1] + 23})`}
>
<circle r="13" fill={faction(p.faction).color} />
{isAdvisor(p, t.id) && (
<circle
r="16"
fill="none"
stroke="#eee8d6"
strokeDasharray="3 3"
strokeWidth="2"
/>
)}
<text textAnchor="middle" y="5">
{Object.entries(p.forces)
.filter(([k]) => splitLocation(k).territory === t.id)
.reduce((n, [, v]) => n + v, 0)}
</text>
</g>
));
})}
<NoFieldBoardMarkers players={g.players} />
<TerrorBoardMarkers tokens={g.moritaniTerror?.tokens ?? []} atomics={g.moritaniAtomics} />
<AmbassadorBoardMarkers tokens={g.ambassadors?.tokens ?? []} />
{Object.entries(g.spice)
.filter(([, n]) => n > 0)
.map(([k, n]) => {
const t = territory(splitLocation(k).territory);
return (
<g
key={battleCardLabel(k)}
pointerEvents="none"
transform={`translate(${t.center[0]},${t.center[1] - 25})`}
>
<rect
x="-17"
y="-12"
width="34"
height="20"
rx="4"
fill="#d3a94e"
/>
<text textAnchor="middle" y="3" className="spice-amount">
◆ {n}
</text>
</g>
);
})}
</svg>
</div>
<HomeworldTable game={g} />
<div className="territory-detail">
<div>
<span className="eyebrow">
{territory(selected).type.toUpperCase()}
</span>
<h3>{territory(selected).name}</h3>
</div>
<span className="fine">
{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(' · ')}`}
</span>
</div>
<div className="territory-units">
{g.players.flatMap((p) =>
Object.entries(p.forces)
.filter(([k]) => splitLocation(k).territory === selected)
.map(([k, n]) => (
<span
key={`${p.id}-${k}`}
style={{ color: faction(p.faction).color }}
>
{faction(p.faction).name}: {n}{' '}
{isAdvisor(p, selected) ? 'advisors' : 'forces'} in sector{' '}
{splitLocation(k).sector}
</span>
)),
)}
{g.players
.filter(
(p) => p.noField?.deployed?.location.territory === selected,
)
.map((p) => (
<span
key={`no-field-detail-${p.id}`}
style={{ color: faction(p.faction).color }}
>
{faction(p.faction).name}: concealed No-Field in sector{' '}
{p.noField!.deployed!.location.sector} · counts as one force
</span>
))}
</div>
{g.status !== 'lobby' && (
<section
className="spice-card-table"
aria-label="Visible spice cards"
>
{g.playedOrnithopter && (
<article className="notice" aria-label="Played movement card">
<h3>Ornithopter in play</h3>
<p>
{
g.players.find(
(p) => p.id === g.playedOrnithopter!.player,
)?.name
}{' '}
·{' '}
{g.playedOrnithopter.mode === 'range3'
? 'Three-territory group'
: 'Two different groups'}{' '}
· {g.playedOrnithopter.completed} completed
</p>
<CardInspector card={g.playedOrnithopter.card} />
</article>
)}
<h3>Spice cards on the table</h3>
<div className="spice-card-fronts">
{g.spiceDiscardTop
.slice(0, g.advanced ? 2 : 1)
.map((card, index) => (
<article key={index} className="spice-card-front">
<span className="eyebrow">
{g.advanced ? `Spice pile ${index + 1}` : 'Spice pile'}{' '}
· top card
</span>
{card ? (
<>
<strong>{spiceCardTitle(card)}</strong>
<SpiceCardInspector
card={card}
context={`Public spice pile ${index + 1}`}
/>
</>
) : (
<p className="fine">No face-up card yet.</p>
)}
</article>
))}
</div>
</section>
)}
</section>
<aside className="action-panel" id="table-decisions" tabIndex={-1}>
<div className="eyebrow">
{g.status === 'finished' ? 'FINAL OUTCOME' : 'YOUR NEXT DECISION'}
</div>
{g.battle?.revealed && <a className="battle-display-link" href="#revealed-battle-plans">Compare revealed battle plans</a>}
<PrivateBattlePlan game={g} />
<NexusFremenRideBetrayal game={g} act={act} busy={reactionBusy} />
<HomeworldEcazDuke game={g} act={act} busy={reactionBusy || !!g.roomControl?.paused || !!g.roomControl?.closed || !!me.autopilot} />
<MovementPromises game={g} act={act} busy={reactionBusy} />
<MovementExpressionPromises game={g} send={act} busy={reactionBusy} />
<MixedMovementPromises game={g} send={act} busy={reactionBusy} />
{!reactionOwnsControls && g.semutaReaction && <SemutaReaction game={g} act={act} busy={reactionBusy} />}
{g.karamaBlocked && <p className="notice" role="status">{g.karamaBlocked}</p>}
{g.karamaPurposeBlocks?.map(({purpose, message}) => <p key={purpose} className="notice" role="status">{message}</p>)}
{!reactionOwnsControls && <>
<NexusCards game={g} act={act} busy={busy} />
<Recruits game={g} act={act} busy={busy} />
<AdvisorPreparation game={g} act={act} busy={transportBusy || !!g.roomControl?.paused || !!g.roomControl?.closed || !!me.autopilot} />
{g.decision?.kind !== 'fullPlanOffer' && <AtreidesFullPlan game={g} act={act} busy={transportBusy || !!g.roomControl?.paused || !!g.roomControl?.closed || !!me.autopilot} />}
{g.decision?.kind !== 'revivalStop' && <TleilaxuSpecialKarama game={g} act={act} busy={transportBusy || !!g.roomControl?.paused || !!g.roomControl?.closed || !!me.autopilot} />}
<NexusTraitors game={g} act={act} busy={transportBusy || (!!g.roomControl?.paused || !!g.roomControl?.closed) || !!me.autopilot} />
<DiscoveryPanel game={g} act={act} busy={transportBusy || (!!g.roomControl?.paused || !!g.roomControl?.closed) || !!me.autopilot} />
<LeaderSkillsPanel skills={g.leaderSkills} leaders={g.allLeaders} players={g.players} act={act} busy={transportBusy || (!!g.roomControl?.paused || !!g.roomControl?.closed) || !!me.autopilot} />
<RihaniHistory game={g} />
<MentatHistory game={g} />
<NexusChoamTrade game={g} act={act} busy={transportBusy || (!!g.roomControl?.paused || !!g.roomControl?.closed) || !!me.autopilot} />
<NexusChoamBetrayal game={g} act={act} busy={transportBusy || !!g.roomControl?.paused || !!g.roomControl?.closed || !!me.autopilot} />
<NexusMoritaniBetrayal game={g} act={act} busy={transportBusy || !!g.roomControl?.paused || !!g.roomControl?.closed || !!me.autopilot} />
<NexusEcazBetrayal game={g} act={act} busy={transportBusy || !!g.roomControl?.paused || !!g.roomControl?.closed || !!me.autopilot} />
<NexusFremenBetrayal game={g} act={act} busy={transportBusy || !!g.roomControl?.paused || !!g.roomControl?.closed || !!me.autopilot} />
<NexusEcazInquiry game={g} act={act} busy={transportBusy || !!g.roomControl?.paused || !!g.roomControl?.closed || !!me.autopilot} />
<NexusEcazDuke game={g} act={act} busy={busy} />
<NexusTleilaxu game={g} act={act} busy={transportBusy || (!!g.roomControl?.paused || !!g.roomControl?.closed) || !!me.autopilot} />
<NexusSuboids game={g} act={act} busy={transportBusy || (!!g.roomControl?.paused || !!g.roomControl?.closed) || !!me.autopilot} />
<NexusAdvisors game={g} act={act} busy={busy} />
<NexusSardaukar game={g} act={act} busy={busy} />
{!g.nexusCards?.waiting.length && <TupileIntelligence game={g} act={act} busy={busy} />}
{g.biddingEnd && <BiddingEnd game={g} act={act} busy={busy} />}
{g.junctionTransport && [g.junctionTransport.owner, g.junctionTransport.recipient].includes(me.id) && (
<JunctionTransport key={me.id === g.junctionTransport.owner ? g.junctionTransport.offerEvent : g.junctionTransport.event} game={g} act={act} busy={busy} />
)}
<RicheseNoFieldControls
game={g}
act={act}
busy={busy}
destination={selected}
sector={sector}
allyPayment={allyPayment}
/>
<RicheseAlliedNoFieldControls
game={g}
act={act}
busy={busy}
destination={selected}
sector={sector}
onSectorChange={setSector}
/>
</>}
{g.status === 'playing' && g.phase < 5 && (
<ShipmentPromises game={g} act={act} busy={busy} />
)}
{g.status === 'playing' && <BribePromises game={g} send={act} busy={busy} />}
{g.status === 'playing' && <BribeExpressionPromises game={g} send={act} busy={busy} />}
{g.status === 'playing' && <AlliancePromises game={g} send={act} busy={busy} />}
{g.status === 'playing' && <RevivalExpressionPromises game={g} send={act} busy={busy} />}
{g.status === 'playing' && <LeaderRevivalPromises game={g} send={act} busy={busy} />}
{g.status === 'playing' && <RevivalPromises game={g} send={act} busy={busy} />}
{g.status === 'playing' && <BidPromises game={g} send={act} busy={busy} />}
{g.guildBetrayalReaction ? (
<NexusGuildBetrayal game={g} act={act} busy={reactionBusy} />
) : g.richeseBetrayalReaction ? (
<NexusRicheseBetrayal game={g} act={act} busy={reactionBusy} />
) : g.kullReaction ? (
<ChoamKull game={g} act={act} busy={reactionBusy} />
) : g.nexusIxianReplacement ? (
<NexusIxianReplacement game={g} act={act} busy={reactionBusy} />
) : ixianBetrayalOwnsControls ? (
<NexusIxianBetrayal game={g} act={act} busy={reactionBusy} />
) : harkonnenBetrayalOwnsControls ? (
<NexusHarkonnenBetrayal game={g} act={act} busy={reactionBusy} />
) : g.nexusCards?.waiting.length ? (
<p className="muted">The next phase begins when the remaining Nexus card choices are finished.</p>
) : g.truthtrance ? (
<Truthtrance
key={`${g.truthtrance.queue[0]?.card}-${g.truthtrance.stage}`}
game={g}
act={act}
busy={busy}
/>
) : g.status === 'lobby' ? (
<>
<h2>Assemble the houses</h2>
<p className="muted">
Share the invite above. Every player chooses a faction and
confirms they are ready.
</p>
<RulesetControls
advanced={g.advanced}
disabled={busy}
lobby
host={g.host === me.id}
onChange={(advanced) => act({ type: 'rules', advanced })}
/>
<label>
Your faction
<select
value={me.faction}
disabled={busy}
onChange={(e) =>
act({ type: 'faction', faction: e.target.value })
}
>
{FACTIONS.filter(
(f) =>
f.expansion === 'base' ||
g.expansions.includes(f.expansion),
).map((f) => (
<option
disabled={g.players.some(
(p) => p.id !== me.id && p.faction === f.id,
)}
value={f.id}
key={f.id}
>
{f.name}
</option>
))}
</select>
</label>
<label className="checkbox-row">
<input
type="checkbox"
checked={!!g.techTokens}
disabled={busy || g.host !== me.id}
onChange={(e) =>
act({ type: 'techTokens', enabled: e.target.checked })
}
/>
Tech tokens · optional, 3+ players{' '}
<HelpTip topic="techTokens" />
</label>
<label className="checkbox-row">
<input
type="checkbox"
checked={!!g.strongholdCards}
disabled={busy || g.host !== me.id || !g.advanced}
onChange={(e) =>
act({ type: 'strongholdCards', enabled: e.target.checked })
}
/>
Stronghold Cards · Advanced rules required
</label>
{g.lobbyModules && ([
['leaderSkills', 'Leader Skills · all fourteen skill cards'],
['ecazTreachery', 'Ecaz Treachery Cards · Recruits, Reinforcements, Harass & Withdraw'],
['nexusCards', 'Nexus Cards'],
['discoveries', 'Discoveries'],
] as const).map(([module, label]) => (
<label className="checkbox-row" key={module}>
<input
type="checkbox"
checked={g.lobbyModules![module]}
disabled={busy || g.host !== me.id}
onChange={(e) => act({ type: 'moduleOption', module, enabled: e.target.checked })}
/>
{label} · preview
</label>
))}
<label className="checkbox-row">
<input
type="checkbox"
checked={!!g.homeworlds}
disabled={busy || g.host !== me.id}
onChange={(e) => act({ type: 'homeworlds', enabled: e.target.checked })}
/>
Homeworlds · preview
</label>
{actionButton(
me.ready ? 'Not ready' : g.advanced ? 'Ready for Advanced preview' : 'Ready to play',
{ type: 'ready' },
)}
{g.host === me.id &&
actionButton(
g.advanced ? 'Begin Advanced preview' : 'Begin game',
g.advanced
? { type: 'start', advancedPreview: true }
: { type: 'start' },
g.players.length < (g.techTokens ? 3 : 2) ||
!g.players.every((p) => p.ready),
)}
<p className="development-note">
Playable core under development. Advanced and expansion factions
are unfinished previews; some interactions may still need fixes.
</p>
</>
) : g.status === 'setup' && !g.decision ? (
<>
<h2>
{setupStage === 'leaderSkills'
? 'Assign your Leader Skill'
: setupStage === 'prediction'
? 'Seal the prediction'
: setupStage === 'traitors'
? 'Choose your traitor'
: setupStage === 'forces'
? 'Place starting forces'
: 'Secrets & allegiances'}
</h2>
<p className="muted" role="status">
{setupStatus}
</p>
{setupStage && (
<p className="fine">
{g.leaderSkills
? 'Setup order: prediction, starting Treachery Cards, Leader Skills, traitors, starting forces, then Storm.'
: 'Setup order: prediction, traitors, starting forces, then the automatic treachery-card deal and Storm phase.'}
</p>
)}
<EcazSetup game={g} act={act} busy={busy} />
{(!setupStage || setupStage === 'forces') &&
g.advanced &&
me.faction === 'beneGesserit' &&
!me.advisorSetup && (
<>
<p className="muted">
After the other starting force choices, choose a territory and sector on
the map for your starting advisor. It becomes a fighter if
alone.
</p>
{destination}
{(waitingForFremen || waitingForEcaz) && (
<p className="fine" id="advisor-setup-wait">
{waitingForFremen ? setupFremen!.name : setupEcaz!.name} must finish placing starting forces
before you place an advisor.
</p>
)}
{selected === MOBILE_STRONGHOLD && (
<p className="fine">
Choose a territory on the printed board for your
starting advisor.
</p>
)}
{actionButton(
'Place starting advisor',
{ type: 'advisorSetup', territory: selected, sector },
waitingForFremen ||
waitingForEcaz ||
me.reserves !== 20 ||
selected === MOBILE_STRONGHOLD,
waitingForFremen || waitingForEcaz ? 'advisor-setup-wait' : undefined,
)}
</>
)}
{(!setupStage || setupStage === 'traitors') &&
!!me.traitorChoices?.length && (
<>
<p className="muted">
Keep one traitor. Your selection remains secret.
</p>
<div className="setup-traitor-choices">
{me.traitorChoices.map((id) => (
<div key={id} className="traitor-choice">
<strong>{leaderName(id)}</strong>
{inspectIdentity(id, 'traitor')}
{actionButton(`Keep ${leaderName(id)}`, {
type: 'traitor',
leader: id,
})}
</div>
))}
</div>
</>
)}
{(!setupStage || setupStage === 'forces') &&
me.faction === 'fremen' &&
me.reserves === 20 && (
<>
<p>Distribute ten forces.</p>
{FREMEN_START_LOCATIONS.map((key) => {
const loc = splitLocation(key);
return (
<label key={key} htmlFor={`fremen-${key}`}>
{territory(loc.territory).name} · Sector {loc.sector}
<Input
type="number"
min={0}
max={10}
id={`fremen-${key}`}
value={fremen[key] ?? 0}
onChange={(e) =>
setFremen({
...fremen,
[key]: Number(e.target.value),
})
}
/>
</label>
);
})}
{me.elites &&
FREMEN_START_LOCATIONS.map((key) => (
<EliteCount
key={key}
label={`Fedaykin in ${territory(splitLocation(key).territory).name}, sector ${splitLocation(key).sector}`}
max={Math.min(3, fremen[key] ?? 0)}
value={eliteSetup[key] ?? 0}
onChange={(n) =>
setEliteSetup({ ...eliteSetup, [key]: n })
}
/>
))}
<p className="fine">
{Object.values(fremen).reduce((a, b) => a + b, 0)} / 10
forces assigned
</p>
{actionButton(
'Place forces',
{
type: 'fremenSetup',
placements: fremen,
elitePlacements: eliteSetup,
},
Object.values(fremen).reduce((a, b) => a + b, 0) !== 10,
)}
</>
)}
{(!setupStage || setupStage === 'prediction') &&
me.faction === 'beneGesserit' &&
!me.prediction && (
<>
<label>
Predicted victor
<select
value={target}
onChange={(e) => setTarget(e.target.value)}
>
<option value="">Choose a faction</option>
{g.players
.filter((p) => p.id !== me.id)
.map((p) => (
<option key={p.id} value={p.faction}>
{faction(p.faction).name}
</option>
))}
</select>
</label>
<label htmlFor="predicted-turn">
Predicted turn
<Input
id="predicted-turn"
type="number"
min={1}
max={10}
value={turn}
onChange={(e) => setTurn(Number(e.target.value))}
/>
</label>
{actionButton(
'Seal prediction',
{ type: 'predict', faction: target, turn },
!g.players.some(
(p) => p.id !== me.id && p.faction === target,
) ||
!Number.isInteger(turn) ||
turn < 1 ||
turn > 10,
)}
</>
)}
{!setupStage && (
<p className="fine">
Play begins when everyone has completed their starting
choices.
</p>
)}
</>
) : g.status === 'finished' ? (
<>
<h2>
{g.winner.includes(me.id)
? 'Arrakis is yours.'
: 'The struggle is over.'}
</h2>
<p className="notice">
{g.winner
.map(
(id) =>
faction(g.players.find((p) => p.id === id)!.faction).name,
)
.join(' and ')}{' '}
won on turn {g.turn}.
</p>
<p className="muted">
The battle record remains available below.
</p>
</>
) : g.phaseOpening ? (
<>
<span className="eyebrow">Phase opening</span>
<h2>Before {PHASES[g.phase]}</h2>
<p>
Play Amal now, or pass to begin this phase. Phase effects wait
until everyone has passed.
</p>
{me.hand
?.filter((c) => c.effect === 'amal')
.map((c) => (
<div key={c.id}>
<p>
Amal returns half of every faction’s available spice to
the bank, rounded up. Your loss:{' '}
{Math.ceil((me.spice ?? 0) / 2)} spice.
</p>
{actionButton(
'Play Amal',
{ type: 'card', card: c.id },
g.phaseOpening!.passed.includes(me.id),
)}
</div>
))}
{actionButton(
'Pass phase opening',
{ type: 'ready' },
g.phaseOpening.passed.includes(me.id),
)}
<p className="fine">
{g.phaseOpening.passed.length} / {g.players.length} passed.
Playing Amal asks everyone to confirm again.
</p>
</>
) : g.response ? (
<>
<span className="eyebrow">Power response</span>
<h2>
{
{
moritaniAlliance: 'Moritani · Enemy of My Enemy',
moritaniRetention: 'Moritani · allied card retention',
moritaniDuke: 'Moritani · Duke Vidal acquisition',
ecazPlacement: 'Ecaz Ambassador placement',
ecazCollection: 'Ecaz stronghold collection',
ecazOccupy: 'Ecaz combined-army Occupy',
moritaniPlacement:
'Moritani Terror placement or relocation',
choamRevival: 'CHOAM force revival',
revivalLimit: 'Expanded force revival',
revivalDiscount: 'Half-price revival',
earlyRevival: 'Early leader revival',
revivalIncome: 'Tleilaxu revival payment',
foreignGhola: 'Tleilaxu foreign ghola revival',
ixSubstitution: 'Suboid casualty substitution',
ixMovement: 'Cyborg movement',
fremenMovement: 'Fremen movement',
ixAuction: 'Ixian auction inspection',
richeseAuction: 'Richese cache auction',
richeseBlackMarket: 'Richese Black Market offer',
richesePurchaseIncome: 'Emperor card-purchase income',
richeseGift: 'Richese allied card gift',
ixTechnology: 'Ixian auction substitution',
ixAllyCard: 'Ixian allied purchase replacement',
mobileStronghold: 'Mobile stronghold relocation',
faceDancerReplacement: 'Replace an unrevealed Face Dancer',
voice: 'The Voice',
prescience: 'Prescience',
nexusPrescience: 'Atreides Nexus Cunning',
emperorIncome:
g.response.source === 'ambassador'
? 'Emperor card-purchase income'
: 'Emperor auction income',
harkonnenBonus: 'Harkonnen bonus card',
harkonnenTraitor: 'Harkonnen ally traitor',
guildIncome: 'Guild shipment income',
guildRate: 'Guild half-price shipment rate',
guildTransportPermission: 'Guild cross/return transport permission',
richeseNoField: 'Richese No-Field shipment',
advisor: 'Spiritual advisor',
emperorGift: 'Emperor spice gift',
emperorRevival: 'Emperor extra revival',
fremenRevivalGrant: 'Fremen allied free revival',
bgCharity: 'Bene Gesserit charity',
choamCharity: 'CHOAM charity income',
choamInflation: 'CHOAM Inflation placement',
choamSale: 'CHOAM card sale',
choamWorthless: 'CHOAM Worthless card effect',
choamBattleIncome: 'CHOAM battle force income',
choamAudit: 'CHOAM Auditor inspection',
choamBattleAid: 'CHOAM allied combat funding',
worthlessKarama: 'Worthless card as Karama',
advisorFlip: 'Bene Gesserit token flip',
nexusAdvisorFlip: 'Bene Gesserit Nexus advisor conversion',
nexusSardaukar: 'Emperor Nexus temporary Sardaukar',
eliteStrength: 'Elite battle strength',
fremenSupport: 'Fremen full strength without spice',
capture: 'Harkonnen leader capture',
kwisatz: 'Kwisatz Haderach battle protection',
guildTiming: 'Guild shipment and movement timing',
nexusGuildCunning: 'Guild Cunning second shipment',
nexusFremenCunning: 'Fremen Cunning remote worm ride',
nexusEcazBetrayal: 'Ecaz Nexus forced ally return',
atreidesAuction: 'Atreides auction foresight',
atreidesSpice: 'Atreides spice foresight',
stormPeek: 'Fremen storm foresight',
stormProtection: 'Fremen storm survival',
wormPlacement: 'Fremen additional worm placement',
wormSurvival: 'Fremen worm survival',
wormAllyProtection: 'Fremen allied worm protection',
}[g.response.kind]
}
</h2>
<EcazOccupyBattleSummary game={g} />
{g.paymentIncome?.low && (
<p className="notice">
Of the {g.paymentIncome.gross} spice payment,{' '}
{g.players.find((player) => player.id === g.paymentIncome?.owner)?.name}
{' '}receives {g.paymentIncome.income}. Low-population{' '}
{g.paymentIncome.kind === 'shipment' ? 'Junction' : 'Kaitain'}
{' '}leaves {g.paymentIncome.bank} spice in the bank. The payer’s cost stays the same.
</p>
)}
{g.response.automaticAdvisorFlip && (
<p className="notice">
{g.response.automaticAdvisorFlip.producer === 'guildTransport'
? 'The transport and payment are complete. These advisors are now alone and must become fighters.'
: g.response.automaticAdvisorFlip.producer === 'gamont'
? 'Trip to Gamont is complete and its card use stays spent. These advisors are now alone and must become fighters before victory is checked.'
: g.response.automaticAdvisorFlip.producer === 'battleLosses'
? 'Battle losses and mandatory card disposal are complete. These surviving advisors are now alone and must become fighters before the remaining cleanup.'
: g.response.automaticAdvisorFlip.producer === 'homeworldTransport'
? 'The Homeworld transport and payment are complete. These advisors are now alone and must become fighters before any remaining arrival.'
: g.response.automaticAdvisorFlip.producer === 'faceDance'
? 'Face Dance is complete: the winning army has returned, the replacement is placed and the Face Dancer is revealed. These surviving advisors are now alone and must become fighters before aftermath continues.'
: g.response.automaticAdvisorFlip.producer === 'storm'
? `Storm casualties in sector ${g.response.automaticAdvisorFlip.storm.sector} are complete. These surviving advisors are now alone and must become fighters before the remaining storm traversal.`
: g.response.automaticAdvisorFlip.producer === 'specialRelocation'
? `${g.response.automaticAdvisorFlip.relocation.kind === 'ambassador' ? 'Fremen Ambassador relocation' : 'The free Discovery force transfer'} is complete. These advisors are now alone and must become fighters before the original arrival continues.`
: g.response.automaticAdvisorFlip.producer === 'spiritualArrival'
? 'The free Spiritual Advisor packet has arrived and its reserves and any earned income stay committed. These advisors are now alone and must become fighters before the original arrival continues.'
: 'The ground movement is complete. These advisors are now alone and must become fighters.'}
{' '}Karama prevents only this conversion; the completed action and its costs stay committed.
{' '}Provisional rule: a prevented group stays advisors while continuously alone,
until another faction returns or the group leaves.
</p>
)}
{g.response.advisorMatchingSource && (
<p className="notice">
The incoming group must become {g.response.advisorMatchingSource.advisors ? 'advisors' : 'fighters'} to match your forces already in the destination.
{' '}This {g.response.advisorMatchingSource.kind === 'movement' ? 'movement' : 'shipment'} has not happened yet.
{' '}Karama prevents this group from matching here during this action opportunity. Residents keep their type; no forces, shipment payment or unused action are spent.
{' '}Any already-played movement or declaration card keeps its original use. Choose a legal replacement if matching is prevented.
</p>
)}
{g.response.kind==='advisorFlip'&&g.response.advisorGatherMatchingSource&&(
<p className="notice">
Gathered group {g.response.advisorGatherMatchingPosition?.current} of {g.response.advisorGatherMatchingPosition?.total}:
{' '}the incoming forces from {territory(g.response.advisorGatherMatchingSource.origin).name} must match your residents.
{' '}Neither origin has moved, changed type or collected spice. Every required group response must allow the same original gather before it moves once.
{' '}Preventing one group leaves both origins and the movement unused, while this prevented packet here remains unavailable. Choose a legal replacement.
</p>
)}
{g.response.kind==='advisorFlip'&&g.response.advisorTransportMatchingSource&&(
<p className="notice">
The incoming transport group must match your residents before payment or force transfer.
{' '}Preventing matching leaves the shipment unused, while this same packet here stays unavailable for the original opportunity.
{' '}Choose another legal packet or destination.
{g.response.advisorTransportMatchingSource.producer==='junction'
? ' The Junction permission and tariff remain immune; the original payment and transfer follow allowance.'
: ' Original transport permission, price and payment follow separately if matching is allowed.'}
</p>
)}
{g.response.kind==='advisorFlip'&&g.response.advisorAmbassadorMatchingSource&&(
<p className="notice">
The incoming Fremen Ambassador group must match your resident forces. No selected force has moved or collected spice yet.
{' '}Preventing matching restores the original relocation choice and blocks this same packet here for this Ambassador event.
{' '}The trigger stays committed; your ordinary shipment and movement remain unchanged.
</p>
)}
{g.response.kind==='advisorFlip'&&g.response.spiritualAdvisorSource&&(
<p className="notice">
The free advisor packet must become fighters to match your Polar Sink residents. No free force has arrived yet.
{' '}Preventing matching restores your original free-shipment choice; that same packet here remains unavailable for this opportunity.
{' '}The other faction’s completed shipment stays committed. Choose another legal packet or decline.
</p>
)}
{g.response.kind === 'advisor' && (
<p className="notice">
{g.players.find((player) => player.id === g.response?.owner)?.name} declared {g.response.amount ?? 1} free {(g.response.amount ?? 1) === 1 ? 'force' : 'forces'} to {territory(splitLocation(g.response.location ?? 'polar_sink:0').territory).name}.
{' '}Allow the arrival or cancel Spiritual Advisors. Canceling prevents the whole declared group.
{g.response.spiritualAdvisorSource&&<> Matching or an alone conversion may follow separately. The other faction’s shipment is already committed.</>}
{g.response.spiritualAdvisorSource?.revivalEvent&&<> The revived group stays placed; this response does not cancel its revival.</>}
</p>
)}
{g.response.kind === 'moritaniRetention' &&
g.moritaniRetentionCard && (
<div className="notice">
<p>
{
g.players.find(
(player) => player.id === g.moritaniRetention?.player,
)?.name
}{' '}
has declared {g.moritaniRetentionCard.name} to retain
after losing the battle. Canceling this power discards all
of that player’s played cards.
</p>
<CardInspector card={g.moritaniRetentionCard} />
</div>
)}
{g.response.kind === 'moritaniPlacement' &&
g.moritaniPendingPlacement && (
<p className="notice">
Your private declaration:{' '}
{(() => {
const token = g.moritaniTerror?.tokens.find(
(t) => t.id === g.moritaniPendingPlacement!.token,
);
return token && 'kind' in token
? TERROR_DEFINITIONS[token.kind].name
: 'Terror token';
})()}{' '}
into {territory(g.moritaniPendingPlacement.territory).name}.
The token has not moved yet.
</p>
)}
{g.response.kind === 'choamInflation' && (
<p className="muted">
CHOAM will{' '}
{g.response.intent === 'double' ? 'double' : 'cancel'} next
turn’s charity. The following turn uses the opposite effect.
Karama can stop this placement; the later flip and removal are
mandatory.
</p>
)}
{g.response.kind === 'choamWorthless' && (
<p className="notice">
CHOAM declared {g.response.intent}.{' '}
{g.response.intent === 'Kull Wahad' && (
<>The attempted card is reserved. Use a different eligible Karama to prevent Kull and resume the original play; allowing Kull retains that attempted card but stops its actor’s Karama activations for this phase. </>
)}
{g.response.location &&
(g.response.intent === 'Jubba Cloak'
? `Protect CHOAM’s forces in ${territory(splitLocation(g.response.location).territory).name} during this storm movement. `
: g.response.intent === 'Baliset'
? `Prevent ${g.players.find((p) => p.id === g.response?.recipient)?.name} from moving into ${territory(splitLocation(g.response.location).territory).name}. `
: `Selected: ${g.players.find((p) => p.id === g.response?.recipient)?.name}, ${territory(splitLocation(g.response.location).territory).name}, sector ${splitLocation(g.response.location).sector}, ${g.response.elite ? 'elite' : 'ordinary'} force. `)}
Karama prevents this card’s effect for this phase and leaves
the card in hand.
</p>
)}
{g.response.kind === 'choamBattleIncome' && (
<p className="notice">
CHOAM is due {g.response.amount} spice from other players’
force payments. Karama sends this battle’s income to the bank.
</p>
)}
{g.response.kind === 'choamBattleAid' && (
<p className="notice">
Karama can prevent CHOAM funding its ally’s forces for this
battle. The ally will then plan using its own spice.
</p>
)}
{g.response.kind === 'choamRevival' && (
<p className="muted">
CHOAM revives paid forces for one spice each with no normal
quantity limit. Karama restores the three-force limit and
two-spice price for this phase. Tleilaxu permission can still
allow five forces.
</p>
)}
{g.response.kind === 'advisorFlip' && g.response.advisorResume === 'preparation' && (
<p className="notice">Bene Gesserit declared native advisor preparation before the first shipment. Karama can prevent this territory’s flip. The current phase and its original auction, readiness or movement control continue afterward.</p>
)}
{g.response.kind === 'fremenRevivalGrant' && (
<p className="notice">
Fremen offered their current ally the ordinary free-revival rate of three.
Karama can prevent this grant before it takes effect. Recruits uses the
current valid rate after settlement, with prior returns and payments retained.
Response timing and prevention of this grant for the current Revival are provisional.
</p>
)}
{g.response.kind === 'choamCharity' && (
<p className="muted">
CHOAM receives{' '}
{2 * g.players.length * g.charity.multiplier +
g.charity.incomeHomeworld * g.charity.multiplier}{' '}
spice before anyone claims charity
{g.charity.incomeHomeworld > 0
? ', including the low-population Homeworld bank bonus'
: ''}
. Karama cancels this income and sends this turn’s charity
payments to the Spice Bank.
</p>
)}
{g.response.kind === 'mobileStronghold' && g.mobileRoute && (
<>
<p>
The Ixians declared this route before the storm. Karama
cancels both the relocation and its spice collection.
</p>
<ol>
{g.mobileRoute.route.map((key) => {
const loc = splitLocation(key);
return (
<li key={key}>
{territory(loc.territory).name} · sector {loc.sector}
</li>
);
})}
</ol>
<p className="fine">
Spice collection{' '}
{g.mobileRoute.collect ? 'requested' : 'declined'}.
</p>
</>
)}
{g.response.kind === 'fremenMovement' && g.response.location && (
<p className="notice">
{g.response.amount} Fremen forces will move to{' '}
{territory(splitLocation(g.response.location).territory).name}
, sector {splitLocation(g.response.location).sector}, using
their two-territory movement advantage. If canceled, the
forces remain in place and Fremen may choose a one-territory
move.
</p>
)}
{g.response.kind === 'ixMovement' && g.response.location && (
<p className="notice">
{g.response.amount} forces, including {g.response.elite}{' '}
cyborgs, moving to{' '}
{territory(splitLocation(g.response.location).territory).name}
, sector {splitLocation(g.response.location).sector}.
</p>
)}
<p className="muted">
{g.players.find((p) => p.id === g.response?.owner)?.name} is
using this power. Another faction may play Karama to cancel this
use.
</p>
{g.response.kind === 'eliteStrength' &&
g.nexusCards &&
g.players.some((player) => player.id === g.response?.owner && player.faction === 'emperor') && (
<p className="notice">
Every seat may respond before the Emperor’s Sardaukar strength is locked.
A private Emperor Nexus Betrayal may suppress it without moving starred forces.
</p>
)}
{g.response.kind === 'voice' &&
g.nexusCards &&
g.players.some((player) => player.id === g.response?.owner && player.faction === 'beneGesserit') && (
<p className="notice">
Every seat may respond to this Voice before plans are sealed.
A private Bene Gesserit Nexus Betrayal may prevent it.
</p>
)}
{g.response.kind === 'revivalIncome' &&
g.response.amount !== undefined && (
<p className="notice">
{g.response.amount} spice is payable to Tleilaxu after this
response.
</p>
)}
{g.response.source === 'ambassadorRevival' && (
<p className="notice">
The free Ambassador revival is already complete. Canceling
Tleilaxu’s bank income leaves the revived pieces in play.
Provisional rule: one free-revival reward per beneficiary
per turn, shared with ordinary free revival. Ghola cards
retain their separate income.
</p>
)}
{g.response.source === 'ambassador' && (
<p className="notice">
The Richese Ambassador purchase is already paid and its card
has been drawn. Canceling this benefit leaves that purchase
unchanged.
</p>
)}
{g.response.kind === 'advisorFlip' && g.response.advisorResume === 'planetologistGather' && (
<p className="notice">
Provisional mixed Planetologist gather: allow one common fighter
arrival before either group moves, or cancel the native advisor
flip. Cancellation keeps both groups and the movement allowance
unchanged, with flipping blocked for that movement opportunity.
</p>
)}
{g.response.kind === 'nexusGuildCunning' && <p className="notice">
Allow one second Guild shipment at its normal price, or cancel this extra opportunity.
The completed first shipment and movement stay unchanged; the Nexus is already spent.
</p>}
{g.response.kind === 'nexusFremenCunning' && <p className="notice">
The Fremen Nexus card was spent for a remote ride after this Nexus. Karama can prevent that ride, but the original worm still resolves.
</p>}
{g.response.kind === 'nexusEcazBetrayal' && <p className="notice">
Ecaz Nexus Betrayal was spent to return Ecaz’s ally’s forces from {territory(g.response.location!).name}.
Karama can prevent that return; the card stays spent.
</p>}
{g.response.intent &&
g.response.kind !== 'nexusGuildCunning' &&
g.response.source !== 'ambassador' &&
g.response.kind !== 'nexusAdvisorFlip' &&
g.response.kind !== 'nexusSardaukar' &&
g.response.kind !== 'nexusEcazBetrayal' &&
g.response.kind !== 'faceDancerReplacement' && (
<p className="notice">{g.response.intent}</p>
)}
{g.nexusTraitors?.pending ? (
<p className="notice">
Finish the private Nexus Traitor card return before responding to this power.
</p>
) : (
<>
{me.hand
?.filter((c) => g.responseControls?.cancelCards.includes(c.id))
.map((c) => (
<Button
key={c.id}
className="game-action"
disabled={busy}
onClick={() =>
act({ type: 'card', card: c.id, mode: 'cancel' })
}
>
{c.kind === 'worthless'
? `Use ${c.name} as Karama`
: 'Cancel with Karama'}
</Button>
))}
{(g.responseControls?.cancelCards.length ||
(g.nexusCards && (
(g.response.kind === 'eliteStrength' &&
g.players.some((player) => player.id === g.response?.owner && player.faction === 'emperor')) ||
(g.response.kind === 'voice' &&
g.players.some((player) => player.id === g.response?.owner && player.faction === 'beneGesserit'))
))) &&
!g.responseControls?.hasPassed ? (
actionButton('Allow this power', { type: 'passResponse' })
) : (
<p className="muted">
{g.responseControls?.hasPassed
? 'You have allowed this power. Waiting for the response to finish.'
: 'Waiting for the response to finish.'}
</p>
)}
</>
)}
</>
) : g.decision ? (
<>
<span className="eyebrow">Player decision</span>
<h2>
{g.decision.kind === 'ecazBattleLead'
? g.decision.lowEcaz ? 'Low Ecaz · opponent chooses the lead' : 'Ecaz · choose the combined-army lead'
: g.decision.kind === 'caladanReinforcement'
? 'Caladan victory reinforcement'
: g.decision.kind === 'grummanCollection'
? 'Grumman Collection'
: g.decision.kind === 'homeworldOccupiedIncome'
? 'Occupied Homeworld bank income'
: g.decision.kind === 'homeworldOccupiedPercentage'
? 'Occupied Homeworld percentage income'
: g.decision.kind === 'homeworldOccupiedBonus'
? 'Occupied Giedi Prime bonus card'
: g.decision.kind === 'homeworldTupileCleanup'
? 'Occupied Tupile · return to the normal hand limit'
: g.decision.kind === 'leaderSkillVisibility'
? 'Position your skilled leader'
: g.decision.kind === 'mentatQuestion'
? g.decision.stage === 'name' ? 'Mentat · name a weapon' : 'Mentat · privately show a card'
: g.decision.kind === 'bureaucratPayment'
? 'Bureaucrat · redirect a payment'
: g.decision.kind === 'leaderSkillRevival'
? 'Choose a skill for the revived leader'
: g.decision.kind === 'rihani'
? g.decision.stage === 'offer' ? 'Rihani Decipherer — optional draw' : 'Choose your Traitor exchange'
: g.decision.kind === 'sukRescue'
? 'Save your battle casualties'
: g.decision.kind === 'smugglerCollection'
? 'Choose Smuggler collection sectors'
: g.decision.kind === 'sandmasterVictorySpice'
? 'Choose Sandmaster victory spice sector'
: g.decision.kind === 'jacurutuDial'
? 'Identify your Jacurutu physical dial'
: g.decision.kind === 'harassWithdraw'
? 'Choose undialed forces to return'
: g.decision.kind === 'diplomatRetreat'
? 'Retreat undialed forces'
: g.decision.kind === 'diplomatDefense'
? 'Copy an opposing defense'
: g.decision.kind === 'homeworldRevivalDeployment'
? 'Revival deployment'
: g.decision.kind === 'ecazSpice'
? 'Shared spice collection'
: g.decision.kind === 'stoneBurner'
? g.decision.copiedFrom ? 'Mirror Weapon copying Stone Burner' : 'Stone Burner'
: g.decision.kind === 'nullentropy'
? 'Private Nullentropy Box search'
: g.decision.kind === 'richeseAllyOpportunity'
? 'Offer allied No-Field shipment'
: g.decision.kind === 'richeseAllyShipment'
? 'Allied No-Field shipment'
: [
'richeseBlackMarket',
'richeseDeclaration',
'richeseCache',
'richeseCacheTerms',
'richeseUnbid',
].includes(g.decision.kind)
? 'Richese auction decision'
: g.decision.kind === 'ecazAmbassador'
? 'Ecaz · Ambassador entry effect'
: g.decision.kind === 'ecazPlacement'
? 'Ecaz · Ambassador placement'
: g.decision.kind === 'moritaniAssassinate'
? 'Moritani · Assassinate Leaders'
: g.decision.kind === 'moritaniRetention'
? g.decision.source === 'nexus' ? 'Moritani Nexus · after-loss choice' : 'Moritani · allied card retention'
: g.decision.kind === 'moritaniTerror'
? 'Moritani · Terror entry reaction'
: g.decision.kind === 'moritaniExtortion'
? 'Moritani · Extortion payment'
: g.decision.kind === 'moritaniSetup'
? 'Moritani · final deployment'
: g.decision.kind === 'moritaniPlacement'
? 'Moritani · hidden Terror placement'
: g.decision.kind === 'choamStorm'
? 'CHOAM storm protection'
: g.decision.kind === 'choamMovement'
? 'CHOAM movement response'
: g.decision.kind === 'choamMentat'
? 'CHOAM’s final Mentat opportunity'
: g.decision.kind ===
'choamFreeRevival'
? 'CHOAM free revival response'
: g.decision.kind ===
'choamBattleFunding'
? 'CHOAM battle funding'
: g.decision.kind ===
'choamMarket'
? 'CHOAM · end of phase'
: g.decision.kind ===
'choamTradeReply' ||
g.decision.kind ===
'choamTradeConfirm'
? 'Allied card exchange'
: g.decision.kind ===
'ixSetup'
? 'Choose your starting technology'
: g.decision.kind ===
'ixAuction'
? 'Prepare the auction'
: g.decision.kind ===
'ixTechnology'
? 'Substitute an auction card'
: g.decision
.kind ===
'ixAllyCard'
? 'Keep or replace your purchase'
: g.decision
.kind ===
'mobileStronghold'
? g.decision
.placement
? 'Place your stronghold'
: 'Relocate before the storm'
: g.decision
.kind ===
'ixSubstitution'
? 'Retain lost cyborgs'
: g.decision
.kind ===
'revivalStop'
? 'A revival awaits clearance'
: g.decision
.kind ===
'faceDance'
? 'A hidden allegiance'
: g
.decision
.kind ===
'poisonTooth'
? 'Activate Poison Tooth?'
: g
.decision
.kind ===
'techToken'
? 'Claim a tech token'
: g
.decision
.kind ===
'fullPlanOffer'
? 'See the entire battle plan'
: g
.decision
.kind ===
'guildShipment' || g.decision.kind === 'homeworldShipmentGuild'
? 'A shipment awaits clearance'
: g
.decision
.kind ===
'handExchange'
? 'Choose cards to return'
: g
.decision
.kind ===
'captureOffer' ||
g
.decision
.kind ===
'capturedLeader'
? 'A captive leader'
: g
.decision
.kind ===
'guildTiming'
? 'Choose when to act'
: g
.decision
.kind ===
'stormLosses'
? 'Survive the storm'
: g
.decision
.kind ===
'wormPlacement'
? 'Place an additional worm'
: g
.decision
.kind ===
'battleLosses'
? 'Choose your casualties'
: g
.decision
.kind ===
'auctionPayment'
? 'Settle your bid'
: [
'advisor',
'intrusion',
'advisorBattle',
].includes(
g
.decision
.kind,
)
? 'Advisors & fighters'
: g
.decision
.kind ===
'wormProtection' || g.decision.kind === 'nexusFremenProtection'
? 'Protection of Shai-Hulud'
: g
.decision
.kind ===
'discoveryEntry'
? 'Enter a Discovery location'
: g.decision.kind === 'ecologicalStorm'
? 'Ecological Testing Station'
: g.decision.kind === 'nexusFremenCunningOffer'
? 'Empty worm: Fremen Cunning'
: g.decision.kind === 'nexusFremenCunningRide'
? 'Fremen Cunning remote ride'
: g.decision.kind === 'nexusEmperorAmbassadorPurchase'
? 'Richese Ambassador purchase'
: g.decision.kind === 'nexusEmperorSellerPurchase'
? 'Richese lot payment'
: g.decision.kind === 'nexusEmperorPayment'
? 'Emperor allied-purchase funding'
: g
.decision
.kind ===
'wormRide'
? 'Ride the sandworm'
: 'Spoils of battle'}
</h2>
{g.decision.kind === 'capturedLeader' &&
(g.decision.controller ?? g.decision.owner) === me.id && (
<p className="notice">
Your leader {leaderName(g.decision.leader)} was captured.
</p>
)}
{g.decision.kind === 'choamMovement' &&
g.decision.player !== me.id && (
<p className="notice">
{
g.players.find(
(p) =>
p.id ===
(g.decision?.kind === 'choamMovement'
? g.decision.mover
: ''),
)?.name
}{' '}
has declared a move of {g.decision.amount} forces into{' '}
{combatName(g.decision.territory)}, sector{' '}
{g.decision.sector}. CHOAM must allow the move or respond
with Baliset; no forces have moved yet.
</p>
)}
{g.decision.kind === 'choamStorm' &&
g.decision.player !== me.id && (
<p className="notice">
The storm is waiting for CHOAM to choose protection.
Threatened forces:{' '}
{g.decision.territories
.map(
(t) => `${territory(t.territory).name} (${t.amount})`,
)
.join(', ')}
. No storm casualties have been applied.
</p>
)}
{g.decision.player !== me.id ? (
<p className="muted">
Waiting for{' '}
{g.players.find((p) => p.id === g.decision?.player)?.name} to
complete this decision.
</p>
) : g.decision.kind === 'nexusIxianReplacement' ? (
<NexusIxianReplacement game={g} act={act} busy={reactionBusy} />
) : g.decision.kind === 'nullentropy' ? (
<NullentropySearch game={g} act={act} busy={busy} />
) : g.decision.kind === 'richeseAllyOpportunity' ? (
<>
<p className="fine">
Your ally’s shipment is waiting for your optional No-Field
offer. Choose the private token, destination and payer in
the controls above, or let your ally ship normally.
</p>
{actionButton('Let ally ship normally', {
type: 'decision',
decline: true,
})}
</>
) : g.decision.kind === 'richeseAllyShipment' ? (
<RicheseAlliedNoFieldDecision
key={g.richeseNoField?.allyOffer?.event}
game={g}
act={act}
busy={busy}
/>
) : g.decision.kind === 'richeseBlackMarket' ||
g.decision.kind === 'richeseDeclaration' ||
g.decision.kind === 'richeseCache' ||
g.decision.kind === 'richeseCacheTerms' ||
g.decision.kind === 'richeseUnbid' ? (
<RicheseAuctionDecision
key={`${g.richeseBidding?.event}-${g.decision.kind}-${g.me}`}
game={g}
act={act}
busy={busy}
/>
) : g.decision.kind === 'moritaniAssassinate' ? (
<MoritaniAssassinate key={g.decision.event} game={g} act={act} busy={busy} />
) : g.decision.kind === 'moritaniSpecialKarama' ? (
<MoritaniSpecialKarama quote={g.moritaniSpecialKarama ?? null} act={act} busy={busy}
declineAction={{ type: 'decision', event: g.decision.event, decline: true }} />
) : g.decision.kind === 'moritaniRetention' ? (
<MoritaniRetention game={g} act={act} busy={busy} />
) : g.decision.kind === 'moritaniTerror' ? (
<MoritaniEntry game={g} act={act} busy={busy} />
) : g.decision.kind === 'moritaniSetup' ? (
<>
<p>
Place six starting forces in one unoccupied territory.
Select a territory and sector on the map. Fourteen forces
remain in reserves.
</p>
<p className="muted">
Advisors also occupy a territory. The Hidden Mobile
Stronghold cannot be chosen.
</p>
{actionButton(
'Place six starting forces',
{ type: 'decision', territory: selected, sector },
!TERRITORIES.some(
(t) => t.id === selected && t.sectors.includes(sector),
) ||
g.players.some((p) =>
Object.entries(p.forces).some(
([key, n]) =>
n > 0 && splitLocation(key).territory === selected,
),
),
)}
</>
) : g.decision.kind === 'ecazAmbassador' ? (
<EcazEntry
key={`${g.ambassadorEntry?.event}-${g.ambassadorEntry?.stage}-${g.me}`}
game={g}
act={act}
busy={busy}
/>
) : g.decision.kind === 'mentatQuestion' ? (
<MentatQuestion key={`${g.decision.event}-${g.decision.stage}-${g.me}`} game={g} act={act} busy={busy} />
) : g.decision.kind === 'bureaucratPayment' ? (
<BureaucratPayment game={g} act={act} busy={busy} />
) : g.decision.kind === 'leaderSkillVisibility' || g.decision.kind === 'leaderSkillRevival' ? (
<p className="muted">Use the Leader Skills controls above to make your choice.</p>
) : g.decision.kind === 'homeworldRevivalDeployment' ? (
<HomeworldRevivalDeployment game={g} act={act} busy={busy} />
) : g.decision.kind === 'caladanReinforcement' ? (
<CaladanReinforcement game={g} act={act} busy={busy} />
) : g.decision.kind === 'grummanCollection' ? (
<GrummanCollection game={g} act={act} busy={busy} />
) : g.decision.kind === 'homeworldOccupiedIncome' ? (
<HomeworldOccupiedIncome offer={g.homeworldOccupiedIncome} act={act} busy={busy} />
) : g.decision.kind === 'homeworldOccupiedPercentage' ? (
<HomeworldOccupiedPercentage game={g} act={act} busy={busy} />
) : g.decision.kind === 'homeworldOccupiedBonus' ? (
<HomeworldOccupiedBonus game={g} act={act} busy={busy} />
) : g.decision.kind === 'homeworldTupileCleanup' ? (
<HomeworldTupileCleanup game={g} act={act} busy={busy} />
) : g.decision.kind === 'ecazSpice' ? (
g.ecazSpice?.allocation ? (
<EcazSpice
key={`${g.ecazSpice.event}-${g.ecazSpice.allocation.index}-${g.ecazSpice.allocation.player}`}
event={g.ecazSpice.event}
allocation={g.ecazSpice.allocation}
me={me.id}
players={g.players}
send={send}
busy={busy}
/>
) : (
<p>Waiting for the current spice allocation.</p>
)
) : g.decision.kind === 'ecazPlacement' ? (
<EcazPlacement game={g} act={act} busy={busy} />
) : g.decision.kind === 'moritaniPlacement' ? (
<MoritaniPlacement game={g} act={act} busy={busy} />
) : g.decision.kind === 'moritaniExtortion' ? (
<MoritaniExtortion game={g} act={act} busy={busy} />
) : g.decision.kind === 'choamStorm' ? (
<ChoamStorm game={g} act={act} busy={busy} />
) : g.decision.kind === 'choamMovement' ? (
<ChoamBaliset game={g} act={act} busy={busy} />
) : g.decision.kind === 'choamMentat' ? (
<ChoamGamont game={g} act={act} busy={busy} />
) : g.decision.kind === 'choamFreeRevival' ? (
<ChoamWorthless game={g} act={act} busy={busy} />
) : g.decision.kind === 'choamBattleFunding' ? (
<ChoamBattleFunding game={g} act={act} busy={busy} />
) : g.decision.kind === 'choamMarket' ||
g.decision.kind === 'choamTradeReply' ||
g.decision.kind === 'choamTradeConfirm' ? (
<ChoamMarket game={g} act={act} busy={busy} />
) : g.decision.kind === 'ixSetup' ||
g.decision.kind === 'ixAuction' ||
g.decision.kind === 'ixTechnology' ||
g.decision.kind === 'ixAllyCard' ? (
<IxTechnology
key={`${g.turn}-${g.decision.kind}`}
game={g}
act={act}
busy={busy}
/>
) : g.decision.kind === 'mobileStronghold' ? (
<MobileStronghold
key={`${g.turn}-${g.mobileStronghold?.location}`}
game={g}
act={act}
busy={busy}
/>
) : g.decision.kind === 'ixSubstitution' ? (
<IxSubstitution game={g} act={act} busy={busy} />
) : g.decision.kind === 'revivalStop' ? (
<>
<p>
{
g.players.find(
(p) =>
g.decision?.kind === 'revivalStop' &&
p.id === g.decision.recipient,
)?.name
}{' '}
declared a{' '}
{g.decision.revival === 'forces' ? 'force' : 'leader'}{' '}
revival. Allow it or use your once-per-game special Karama
to prevent their further force and leader revivals during
this Revival Phase, including Ghola.
</p>
{g.decision.source === 'nexusEmperor' && (
<p className="notice">
Provisional policy: this pending Emperor Nexus extra return also
requires your clearance. Prevention returns no counters or revival
income; both the Nexus and your special Karama remain spent.
</p>
)}
<TleilaxuSpecialKarama game={g} act={act} busy={busy} />
{actionButton('Allow this revival', {
type: 'decision',
decline: true,
})}
</>
) : g.decision.kind === 'nexusChoamInspection' ? (
<NexusChoamInspectionDecision game={g} act={act} busy={busy} />
) : g.decision.kind === 'choamAudit' ||
g.decision.kind === 'choamAuditPayment' ? (
<AuditorDecision game={g} act={act} busy={busy} />
) : g.decision.kind === 'ecazBattleLead' ? (
<EcazOccupyLeadChoice game={g} act={act} busy={busy} />
) : g.decision.kind === 'strongholdCopy' ? (
<StrongholdCopyChoice
event={g.decision.event}
choices={g.decision.choices}
act={act}
busy={busy}
/>
) : g.decision.kind === 'stoneBurner' ? (
<>
<h3>Choose {g.decision.copiedFrom ? 'Mirror Weapon’s copied Stone Burner' : 'Stone Burner'} effect</h3>
<p className="muted">
Choose whether both leaders die or surviving leaders
contribute no strength. Both choices compare undialed
physical force tokens; the choice does not change that
comparison. Other weapon effects and traitor precedence
still apply.
</p>
{g.decision.copiedFrom && <p className="fine">Your physical Mirror Weapon copied the opposing Stone Burner. Your choice is independent of its owner’s; winning retains Mirror as Mirror.</p>}
<p className="fine">
Provisional Harass &amp; Withdraw timing: compare undialed
forces remaining after a successful withdrawal. An opposing
successful Traitor cancels that withdrawal.
</p>
{Object.values(g.battle?.plans ?? {}).some(plan => plan && usesNativeBattleSpecial(plan, 'ecaz-reinforcements')) && (
<p className="fine">
Reinforcements still pays its original three-reserve cost.
Its +2 normal-score bonus does not add undialed physical
tokens or change Stone Burner’s comparison.
</p>
)}
{g.expansions.includes('ix') && <p className="fine">
Provisional original Stone/Tooth order: Stone chooses first,
then uncopied Tooth. A Mirror copy still chooses before its
original, as printed. Traitor decisions follow all choices.
</p>}
<a href="/rules?topic=stone-burner">
Read Stone Burner rules
</a>
{actionButton('Kill both leaders', {
type: 'decision',
event: g.decision.event,
mode: 'kill',
})}
{actionButton('Ignore surviving leader strength', {
type: 'decision',
event: g.decision.event,
mode: 'ignore',
})}
</>
) : g.decision.kind === 'smugglerCollection' ? (
<SmugglerBattleCollection key={g.decision.event} offer={g.decision} act={act} busy={busy} />
) : g.decision.kind === 'sandmasterVictorySpice' ? (
<SandmasterVictorySpice offer={g.decision} act={act} busy={busy} />
) : g.decision.kind === 'jacurutuDial' && g.jacurutuDial ? (
<JacurutuDial offer={g.jacurutuDial}
onChoose={allocation => act({type:'decision',event:g.jacurutuDial!.event,allocation})} busy={busy} />
) : g.decision.kind === 'harassWithdraw' ? (
<>
<HarassWithdrawChoice key={g.decision.event} game={g} act={act} busy={busy} />
</>
) : g.decision.kind === 'diplomatRetreat' ? (
<DiplomatRetreat key={g.decision.event} game={g} act={act} busy={busy} />
) : g.decision.kind === 'diplomatDefense' ? (
<>
<DiplomatDefense game={g} act={act} busy={busy} />
</>
) : g.decision.kind === 'poisonTooth' ? (
<>
{g.decision.copiedFrom && <p className="fine">Your physical Mirror Weapon copied the opposing Poison Tooth. Choose its activation independently; a winning Mirror remains Mirror.</p>}
<p className="muted">
Choose after reviewing both plans. Activating attacks both
leaders, including yours. Chemistry as a defense protects;
Snoopers do not. Traitor decisions follow.
</p>
{g.expansions.includes('ix') && <p className="fine">
A Mirror copy chooses before the original Tooth.
With uncopied Stone and Tooth, this prototype provisionally
chooses Stone first. Both answers precede Traitor decisions.
</p>}
{actionButton('Activate Poison Tooth', {
type: 'decision',
event: g.decision.event,
activate: true,
})}
{actionButton('Leave it unused', {
type: 'decision',
event: g.decision.event,
activate: false,
})}
</>
) : g.decision.kind === 'faceDance' ? (
<FaceDanceDecision
key={`${g.turn}-${g.decision.territory}-${g.decision.winner}-${g.decision.leader}`}
game={g}
act={act}
busy={busy}
/>
) : g.decision.kind === 'techToken' ? (
<>
<p className="muted">
Take one tech token from the defeated faction. Holding all
three counts as one stronghold for victory.
</p>
<HelpTip topic="techTokens" />
{g.decision.choices.map((token) => (
<div key={token}>
{actionButton(
`Take ${TECH_TOKENS.find((t) => t.id === token)!.name}`,
{ type: 'decision', token },
)}
</div>
))}
</>
) : g.decision.kind === 'fullPlanOffer' ? (
<>
<AtreidesFullPlan game={g} act={act} busy={busy} />
{actionButton('Keep special power for later', {
type: 'decision',
decline: true,
})}
</>
) : g.decision.kind === 'fullPlanRead' ? (
<p className="muted">
Restoring the saved battle continuation. Your inspection
remains available above; no confirmation is needed.
</p>
) : g.decision.kind === 'homeworldShipmentGuild' ? (
<>
<p className="muted">
{g.players.find((p) => p.id === (g.decision?.kind === 'homeworldShipmentGuild' ? g.decision.shipper : ''))?.name} declares {g.decision.amount} forces to {combatName(g.decision.destination)}. Allow this shipment or spend your once-per-game special Karama to stop it.
</p>
<HelpTip topic="guildShipment" />
{actionButton('Allow Homeworld shipment', { type: 'decision', event: g.decision.event, allow: true })}
{me.hand?.filter(canActivateKarama).map((c) => (
<div key={c.id}>{actionButton('Spend Karama · stop shipment', { type: 'card', mode: 'special', card: c.id },
!!g.guildShipmentStopPromiseBlock, g.guildShipmentStopPromiseBlock ? 'guild-stop-promise-guidance' : undefined)}</div>
))}
{g.guildShipmentStopPromiseBlock && <p id="guild-stop-promise-guidance" role="status">{g.guildShipmentStopPromiseBlock}</p>}
</>
) : g.decision.kind === 'guildShipment' ? (
<>
<p className="muted">
{
g.players.find(
(p) =>
p.id ===
(g.decision?.kind === 'guildShipment'
? g.decision.shipper
: ''),
)?.name
}{' '}
declares {g.decision.amount} forces to{' '}
{combatName(g.decision.territory)}, sector{' '}
{g.decision.sector}. Allow this shipment or spend your
once-per-game special Karama to stop it.
</p>
<HelpTip topic="guildShipment" />
{actionButton('Allow shipment', {
type: 'decision',
allow: true,
})}
{me.hand
?.filter(canActivateKarama)
.map((c) => (
<div key={c.id}>
{actionButton('Spend Karama · stop shipment', {
type: 'card',
mode: 'special',
card: c.id,
}, !!g.guildShipmentStopPromiseBlock,
g.guildShipmentStopPromiseBlock ? 'guild-stop-promise-guidance' : undefined)}
</div>
))}
{g.guildShipmentStopPromiseBlock && <p id="guild-stop-promise-guidance" role="status">{g.guildShipmentStopPromiseBlock}</p>}
</>
) : g.decision.kind === 'handExchange' ? (
<>
<p className="muted">
Inspect your combined hand, then return exactly{' '}
{g.decision.count} cards to{' '}
{
g.players.find(
(p) =>
p.id ===
(g.decision?.kind === 'handExchange'
? g.decision.target
: ''),
)?.name
}
. You may return cards you just took.
</p>
<HelpTip topic="handExchange" />
{me.hand?.map((c) => (
<label className="checkbox-row" key={c.id}>
<input
type="checkbox"
checked={!!exchangeCards[c.id]}
onChange={(e) =>
setExchangeCards({
...exchangeCards,
[c.id]: e.target.checked,
})
}
/>
{c.name}
</label>
))}
{actionButton(
'Return selected cards',
{
type: 'decision',
returnCards:
me.hand
?.filter((c) => exchangeCards[c.id])
.map((c) => c.id) ?? [],
},
me.hand?.filter((c) => exchangeCards[c.id]).length !==
g.decision.count,
)}
</>
) : g.decision.kind === 'intrusion' ? (
<>
<p className="muted">
Another faction entered {combatName(g.decision.territory)}.
You may turn every fighter there into an advisor. A Karama
response resolves before the flip.
</p>
{actionButton('Become advisors', {
type: 'decision',
accept: true,
})}
{actionButton('Remain fighters', {
type: 'decision',
accept: false,
})}
</>
) : g.decision.kind === 'advisorBattle' ? (
<>
<p className="muted">
Before anyone ships, choose where your advisors will fight.
Each territory has its own Karama response.
</p>
{g.decision.territories.map((t) => (
<div key={t}>
{actionButton(`Prepare in ${territory(t).name}`, {
type: 'decision',
accept: true,
territory: t,
})}
</div>
))}
{actionButton('Keep remaining advisors peaceful', {
type: 'decision',
accept: false,
})}
</>
) : g.decision.kind === 'captureOffer' ? (
<>
<p className="muted">
You may capture a random eligible surviving leader from the
defeated faction. Inspect the captive before choosing
whether to retain or execute them.
</p>
{actionButton('Capture a leader', {
type: 'decision',
accept: true,
})}
{actionButton('Decline capture', {
type: 'decision',
accept: false,
})}
</>
) : g.decision.kind === 'capturedLeader' ? (
<>
<p className="notice">
{leaderName(g.decision.leader)} · strength{' '}
{
me.leaders.find(
(l) =>
l.id ===
(g.decision?.kind === 'capturedLeader'
? g.decision.leader
: ''),
)?.strength
}
</p>
<p className="muted">
Retain this leader for one battle, or execute them for two
spice. A survivor returns to their faction after use. If all
your own leaders are dead, a retained captive returns
immediately.
</p>
{g.decision.leader === 'duke-vidal' && (
<p className="fine">
Duke remains the one shared Ecaz disc. A surviving captive
battle use sets him aside; execution sends him to Ecaz’s
Tanks. Only Ecaz may revive him. The provisional captured
return policy preserves unused, unexpired prior custody
on an early release. Ecaz Nexus reclaim ends captivity.
</p>
)}
{actionButton('Retain captive', {
type: 'decision',
mode: 'keep',
})}
{actionButton('Execute · gain 2 spice', {
type: 'decision',
mode: 'execute',
})}
</>
) : g.decision.kind === 'guildTiming' ? (
<>
<p className="muted">
Take your complete shipment and movement now, or let{' '}
{
g.players.find(
(p) =>
p.id ===
(g.decision?.kind === 'guildTiming'
? g.decision.following
: ''),
)?.name
}{' '}
act first. You can decide again before the next player
starts.
</p>
{actionButton('Ship and move now', {
type: 'decision',
take: true,
})}
{actionButton('Wait', { type: 'decision', take: false })}
</>
) : g.decision.kind === 'stormLosses' ? (
<>
<p className="muted">
Lose {g.decision.amount} forces in{' '}
{territory(splitLocation(g.decision.key).territory).name},
sector {splitLocation(g.decision.key).sector}. Choose how
many Fedaykin are among the losses.
</p>
{Array.from(
{
length: stormLosses!.maxElite - stormLosses!.minElite + 1,
},
(_, i) => stormLosses!.minElite + i,
).map((elite) => (
<div key={elite}>
{actionButton(
`Lose ${elite} Fedaykin + ${stormLosses!.amount - elite} ordinary`,
{ type: 'decision', elite },
)}
</div>
))}
</>
) : g.decision.kind === 'wormPlacement' ? (
<>
<p className="muted">
An additional worm appeared in this spice blow. Choose a
sand territory for its attack.
</p>
<label>
Sand territory
<select
value={
territory(selected).type === 'sand' ? selected : ''
}
onChange={(e) => choose(e.target.value)}
>
<option value="" disabled>
Choose sand territory
</option>
{TERRITORIES.filter((t) => t.type === 'sand').map((t) => (
<option key={t.id} value={t.id}>
{t.name}
</option>
))}
</select>
</label>
{actionButton(
'Place worm',
{ type: 'decision', accept: true, territory: selected },
territory(selected).type !== 'sand',
)}
{actionButton('Do not place this worm', {
type: 'decision',
accept: false,
})}
</>
) : g.decision.kind === 'homeworldDefense' ? (
<>
<h3>Late poison defense</h3>
<p className="muted">
The Traitor window is complete. You may add Portable Snooper
before this Homeworld battle resolves. It protects against
ordinary poison, not Poison Tooth.
</p>
{me.hand
?.filter(
(card) =>
richeseCardDefinition(card)?.card.effect ===
'portableSnooper',
)
.map((card) => (
<div key={card.id}>
<CardRules card={card} />
<CardInspector card={card} />
</div>
))}
{actionButton('Use Portable Snooper', {
type: 'decision',
event: g.decision.event,
use: true,
})}
{actionButton('Keep current defense', {
type: 'decision',
event: g.decision.event,
use: false,
})}
</>
) : g.decision.kind === 'homeworldExplosion' ? (
<>
<h3>Native explosion casualties</h3>
<p className="muted">
The Lasgun–shield explosion limits your native losses at{' '}
{combatName(g.decision.territory)} to the Homeworld’s
printed battle strength. Choose the physical counters sent
to the Tanks; your other native forces remain here.
</p>
<p className="fine">
Available: {g.decision.pool.normal}{' '}
{me.faction === 'ixians' ? 'Suboid' : 'ordinary'} and{' '}
{g.decision.pool.elite} {specialForceName(me.faction)}{' '}
counters.
</p>
{g.decision.options.map((option, choice) => (
<div key={choice}>
{actionButton(
`Lose ${option.normal} ${me.faction === 'ixians' ? 'Suboid' : 'ordinary'} + ${option.elite} ${specialForceName(me.faction)} counters`,
{
type: 'decision',
event:
g.decision?.kind === 'homeworldExplosion'
? g.decision.event
: undefined,
choice,
},
)}
</div>
))}
</>
) : g.decision.kind === 'sukRescue' ? (
<SukGraduatePanel key={g.decision.event} decision={g.decision} act={act} busy={transportBusy || (!!g.roomControl?.paused || !!g.roomControl?.closed) || !!me.autopilot} />
) : g.decision.kind === 'rihani' ? (
<RihaniChoice key={g.decision.event} game={g} act={act} busy={transportBusy || (!!g.roomControl?.paused || !!g.roomControl?.closed) || !!me.autopilot} />
) : g.decision.kind === 'battleLosses' ? (
<>
<p className="muted">
At {combatName(g.decision.territory)}, choose forces to lose
that match your revealed strength and spice support.
</p>
{g.decision.options.map((option, choice) => (
<div key={choice}>
{actionButton(
`Lose ${option.normal} ${me.faction === 'ixians' ? 'suboids' : 'ordinary'} + ${option.elite} ${me.faction === 'ixians' ? 'cyborgs' : 'elite'}`,
{ type: 'decision', choice },
)}
</div>
))}
</>
) : g.decision.kind === 'nexusEmperorPayment' ? (
<NexusEmperorPayment game={g} send={act} busy={transportBusy || !!g.roomControl?.paused || !!g.roomControl?.closed || !!me.autopilot} />
) : g.decision.kind === 'nexusEmperorSellerPurchase' ? (
<NexusEmperorSellerPurchase game={g} act={act} busy={transportBusy || !!g.roomControl?.paused || !!g.roomControl?.closed || !!me.autopilot} />
) : g.decision.kind === 'nexusEmperorAmbassadorPurchase' ? (
<NexusEmperorAmbassadorPurchase game={g} act={act} busy={transportBusy || !!g.roomControl?.paused || !!g.roomControl?.closed || !!me.autopilot} />
) : g.decision.kind === 'auctionPayment' ? (
<>
<p className="muted">
You won the auction for {g.auction?.bid} spice. Pay the bid
or spend a Karama to take the card without paying spice.
</p>
{actionButton(
'Pay with spice',
{ type: 'decision', karama: false },
(g.auction?.bid ?? 0) > (me.spice ?? 0) + g.aid.available,
)}
{me.hand
?.filter((card) => canSpendKarama(card, 'auction-free-purchase'))
.map((c) => (
<div key={c.id}>
{actionButton(
`Spend ${c.name}${c.kind === 'worthless' ? ' as Karama' : ''}`,
{ type: 'decision', karama: true, card: c.id },
)}
</div>
))}
</>
) : g.decision.kind === 'advisor' ? (
<>
<p className="muted">
{g.decision.revivalEvent
? 'Tleilaxu’s free-revived group has arrived from off-planet. You may send'
: 'Another faction has shipped from off-planet. You may send'}
{' '}
up to {g.homeworldMobility?.advisorSinkMaximum ?? 1} reserve forces to the Polar Sink for free.
</p>
{g.advanced&&<p className="notice">Retained fighters outside the Polar Sink require Polar Sink or decline, unless Intrusion already changed them to advisors. Polar matching and an alone conversion have separate responses.</p>}
{(g.spiritualAdvisorChoices??g.guildAmbassadorAdvisorChoices) ? (
(g.spiritualAdvisorChoices??g.guildAmbassadorAdvisorChoices)!.choices.map((choice) => {
const reasonId = `guild-advisor-${choice.accompany}-${choice.territory}-${choice.sector}-${choice.amount}`;
return (
<div key={reasonId}>
{actionButton(
choice.accompany
? `Accompany · ${territory(choice.territory).name} · sector ${choice.sector}`
: `Send ${choice.amount} to Polar Sink`,
{
type: 'decision',
accept: true,
accompany: choice.accompany,
territory: choice.territory,
sector: choice.sector,
amount: choice.amount,
},
!!choice.blocked,
choice.blocked ? reasonId : undefined,
)}
{choice.blocked && (
<p id={reasonId} className="muted">
{choice.blocked}
</p>
)}
</div>
);
})
) : (
<>
{g.advanced &&
g.decision.destination &&
territory(
splitLocation(g.decision.destination).territory,
).sectors.map((s) => (
<div key={s}>
{actionButton(
`Accompany · sector ${s}`,
{
type: 'decision',
accept: true,
accompany: true,
sector: s,
},
s !== 0 && s === g.storm,
)}
</div>
))}
{Array.from({length: g.homeworldMobility?.advisorSinkMaximum ?? 1}, (_, index) => (
<div key={index}>{actionButton(`Send ${index + 1} to Polar Sink`, {
type: 'decision', accept: true, amount: index + 1,
})}</div>
))}
</>
)}
{actionButton('Decline shipment', {
type: 'decision',
accept: false,
})}
</>
) : g.decision.kind === 'stoneAllocation' ? (
g.battle?.stoneAllocation && <StoneBurnerAllocation event={g.decision.event}
totals={g.battle.stoneAllocation.totals}
onCommit={undialed => act({type:'decision',event:g.battle!.stoneAllocation!.event,undialed})}
busy={transportBusy || !!g.roomControl?.paused || !!g.roomControl?.closed || !!me.autopilot} />
) : g.decision.kind === 'discoveryEntry' ? (
<DiscoveryEntryDecision game={g} act={act} busy={transportBusy || (!!g.roomControl?.paused || !!g.roomControl?.closed) || !!me.autopilot} />
) : g.decision.kind === 'ecologicalStorm' ? (
<DiscoveryStormDecision game={g} act={act} busy={transportBusy || (!!g.roomControl?.paused || !!g.roomControl?.closed) || !!me.autopilot} />
) : g.decision.kind === 'discoveryDiscard' ? (
<DiscoveryDiscardDecision game={g} act={act} busy={transportBusy || (!!g.roomControl?.paused || !!g.roomControl?.closed) || !!me.autopilot} />
) : g.decision.kind === 'greatMakerVote' || g.decision.kind === 'greatMakerRide' ? (
<GreatMakerDecision game={g} act={act} busy={transportBusy || (!!g.roomControl?.paused || !!g.roomControl?.closed) || !!me.autopilot} />
) : g.decision.kind === 'nexusFremenProtection' ? (
<section aria-label="Fremen Nexus worm protection" className="space-y-3">
<p className="muted">
Shai-Hulud has appeared in {combatName(g.nexusFremenProtection?.territory ?? '')}.
Threatened unallied seats with a held Nexus acknowledge in storm order
before any forces are devoured, regardless of the secret card.
</p>
{g.nexusFremenProtection?.blocked ? (
<p className="notice">{g.nexusFremenProtection.blocked}</p>
) : <>
{g.nexusFremenProtection?.canUse && <>
<p className="notice">Provisional policy: protect only your forces from this worm.
Spice and other factions remain exposed. This independent protection
has no additional Karama response and grants no worm ride.</p>
{actionButton('Spend Nexus · Protect my forces', {
type: 'nexusFremenProtection', event: g.nexusFremenProtection.event, use: true,
})}
</>}
{g.nexusFremenProtection && actionButton('Continue without worm protection', {
type: 'nexusFremenProtection', event: g.nexusFremenProtection.event, use: false,
})}
</>}
</section>
) : g.decision.kind === 'wormProtection' ? (
<>
<p className="muted">
Shai-Hulud has appeared in{' '}
{combatName(g.decision.territory)}. You may protect your
ally’s forces from being devoured.
</p>
{actionButton('Protect my ally', {
type: 'decision',
accept: true,
})}
{actionButton('Do not protect', {
type: 'decision',
accept: false,
})}
</>
) : g.decision.kind === 'nexusFremenCunningOffer' ? (
<>
<p className="muted">An empty sandworm appearance can trigger Fremen Cunning.
If you hold your own Fremen Nexus Card, spend it now to arrange a remote
ride from one occupied desert territory after this Nexus. The worm still resolves normally.</p>
{g.nexusCards?.card === 'fremen' && actionButton('Spend Fremen Cunning', {
type: 'decision', event: g.decision.event, accept: true,
})}
{actionButton('Continue without Cunning', {
type: 'decision', event: g.decision.event, accept: false,
})}
</>
) : g.decision.kind === 'nexusFremenCunningRide' ? (
<>
<p className="muted">The Nexus has ended. Choose one occupied desert territory,
typed forces and a legal destination for the extra ride. The original
worm appearance has already resolved; this does not consume normal movement.</p>
<label>Desert source
<select value={source} onChange={e => {
setSource(e.target.value);
setRideForces({});
setEliteForces({});
}}>
<option value="">Choose source</option>
{[...new Set(Object.entries(me.forces)
.filter(([key, count]) => count > 0 &&
splitLocation(key).sector !== g.storm &&
TERRITORIES.some(t => t.id === splitLocation(key).territory && t.type === 'sand'))
.map(([key]) => splitLocation(key).territory))].map(id =>
<option key={id} value={id}>{combatName(id)}</option>)}
</select>
</label>
{Object.entries(me.forces).filter(([key, count]) =>
count > 0 && splitLocation(key).territory === source &&
splitLocation(key).sector !== g.storm).map(([key, count]) =>
<div key={key}>
<label>Sector {splitLocation(key).sector} · {count} forces
<Input type="number" min={0} max={count}
value={rideForces[key] ?? 0}
onChange={e => setRideForces({
...rideForces, [key]: Number(e.target.value),
})} />
</label>
{me.elites && (
<EliteCount label={`Fedaykin from sector ${splitLocation(key).sector}`}
max={Math.min(rideForces[key] ?? 0, me.elites.forces[key] ?? 0)}
value={eliteForces[key] ?? 0}
onChange={n => setEliteForces({ ...eliteForces, [key]: n })} />
)}
</div>)}
{destination}
{cunningDestinationBlocked && <p className="notice">{cunningDestinationBlocked}</p>}
<SandmasterWormChoice quote={sandmasterRide} collect={collectSandmasterRide}
onChange={setCollectSandmasterRide}
onPileChange={key => setSandmasterRidePile({ target: sandmasterRideTarget, key })} />
{actionButton('Ride remotely', {
type: 'decision', event: g.decision.event, accept: true,
source, territory: selected, sector,
...(sandmasterRide?.blocked === null
? { sandmasterCollect: collectSandmasterRide,
...(collectSandmasterRide ? { sandmasterPile: sandmasterRide.key } : {}) } : {}),
forces: Object.fromEntries(Object.entries(rideForces)
.filter(([key, count]) => count > 0 && splitLocation(key).territory === source)
.map(([key, count]) => [key, {
normal: count - (eliteForces[key] ?? 0),
elite: eliteForces[key] ?? 0,
}])),
}, !source || !!cunningDestinationBlocked || !Object.entries(rideForces).some(([key, count]) =>
count > 0 && splitLocation(key).territory === source))}
{actionButton('Decline remote ride', {
type: 'decision', event: g.decision.event, accept: false,
})}
</>
) : g.decision.kind === 'wormRide' ? (
<>
<p className="muted">
The Nexus has concluded. Move some or all of your forces
from {combatName(g.decision.territory)} to a legal
territory. This does not use your normal movement.
</p>
{Object.entries(me.forces)
.filter(
([key, n]) =>
n > 0 &&
splitLocation(key).territory ===
(g.decision as { territory: string }).territory,
)
.map(([key, n]) => (
<label key={key}>
From sector {splitLocation(key).sector} · {n} forces
<Input
type="number"
min={0}
max={n}
disabled={splitLocation(key).sector === g.storm}
value={rideForces[key] ?? 0}
onChange={(e) =>
setRideForces({
...rideForces,
[key]: Number(e.target.value),
})
}
/>
</label>
))}
{destination}
{me.elites &&
Object.entries(rideForces)
.filter(
([key]) =>
splitLocation(key).territory ===
(g.decision as { territory: string }).territory,
)
.map(([key, n]) => (
<EliteCount
key={key}
label={`Fedaykin from sector ${splitLocation(key).sector}`}
max={Math.min(n, me.elites!.forces[key] ?? 0)}
value={eliteForces[key] ?? 0}
onChange={(n) =>
setEliteForces({ ...eliteForces, [key]: n })
}
/>
))}
<SandmasterWormChoice quote={sandmasterRide} collect={collectSandmasterRide}
onChange={setCollectSandmasterRide}
onPileChange={key => setSandmasterRidePile({ target: sandmasterRideTarget, key })} />
{actionButton('Ride Shai-Hulud', {
type: 'decision',
...(sandmasterRide?.blocked === null
? { sandmasterCollect: collectSandmasterRide,
...(collectSandmasterRide ? { sandmasterPile: sandmasterRide.key } : {}) } : {}),
eliteForces,
accept: true,
territory: selected,
sector,
forces: Object.fromEntries(
Object.entries(rideForces).filter(
([key]) =>
splitLocation(key).territory ===
(g.decision as { territory: string }).territory,
),
),
})}
{actionButton('Stay in this territory', {
type: 'decision',
accept: false,
})}
</>
) : g.decision.kind === 'battleCards' && (
<>
<p className="muted">
You may keep or discard each card played in this battle.
Select the cards you want to discard.
</p>
<div className="visible-component-list">
{g.decision.cards.map((id) => {
const card = me.hand?.find((c) => c.id === id);
return (
<div key={id}>
<label className="decision-checkbox">
<input
type="checkbox"
checked={discardCards[id] ?? false}
onChange={(e) =>
setDiscardCards({
...discardCards,
[id]: e.target.checked,
})
}
/>
Discard {card?.name ?? id}
</label>
{card && <CardInspector card={card} />}
</div>
);
})}
</div>
{actionButton('Confirm card choices', {
type: 'decision',
discard: g.decision.cards.filter((id) => discardCards[id]),
})}
</>
)}
</>
) : (
<>
<h2>{PHASES[g.phase]}</h2>
{g.mentatVictoryPending && (
<p className="notice">
Victory will be checked after Mentat actions, the closing
market and CHOAM’s final Trip to Gamont opportunity.
</p>
)}
<details className="phase-guidance">
<summary>What happens in this phase?</summary>
<p>{PHASE_HELP[g.phase]}</p>
<a
href={`/rules#${phaseRuleId(g.phase)}`}
target="_blank"
rel="noreferrer"
>
Open phase guide ↗
</a>
</details>
<ChoamBaliset game={g} act={act} busy={busy} />
<ChoamGamont game={g} act={act} busy={busy} />
<ChoamWorthless game={g} act={act} busy={busy} />
<ChoamBattleFunding game={g} act={act} busy={busy} />
{me.ally && [3, 5].includes(g.phase) && (
<details className="alliance-funding">
<summary>
Alliance funding · {g.aid.available} spice available
</summary>
<p className="muted">
Set aside spice for your ally’s purchases this phase.
Unspent spice returns when the phase ends. Your current
pledge is {g.aid.pledged}.
</p>
<label htmlFor="ally-pledge">
New unspent pledge
<Input
id="ally-pledge"
type="number"
min={0}
max={(me.spice ?? 0) + g.aid.pledged}
value={aidAmount}
onChange={(e) => setAidAmount(Number(e.target.value))}
/>
</label>
{actionButton('Set ally funding', {
type: 'pledgeAid',
amount: aidAmount,
})}
<label htmlFor="ally-payment">
Ally’s share of your next payment
<Input
id="ally-payment"
type="number"
min={0}
max={g.aid.available}
value={allyPayment}
placeholder="Automatic: cover any shortfall"
onChange={(e) => setAllyPayment(e.target.value)}
/>
</label>
<p className="fine">
Leave blank to use your own spice first.
</p>
</details>
)}
{g.phase === 0 && g.stormPending !== null && (
<>
<p className="notice">
The storm is set to move {g.stormPending} sectors. Play
Weather Control or Family Atomics before confirming.
</p>
{g.stormRevealed && (
<p className="fine">
Revealed dials:{' '}
{Object.values(g.stormRevealed).join(' + ')}
</p>
)}
{g.stormDialers.length === 0 && !g.stormRevealed && (
<StormCardLogInspector component={g.log.findLast(
entry => stormCardDistance(entry.component) !== null,
)?.component} />
)}
{actionButton(
g.ready.includes(me.id)
? 'Waiting for the table'
: 'Confirm storm movement',
{ type: 'ready' },
g.ready.includes(me.id),
)}
</>
)}
{g.phase === 0 && g.stormPending === null && g.stormDialers.length > 0 && (
<>
<p className="muted">
The two storm dialers choose secretly. Their sum moves the
storm.
</p>
{g.stormDialers.includes(me.id) &&
!g.stormSubmitted.includes(me.id) ? (
<>
<label htmlFor="storm-dial">
Storm dial
<Input
id="storm-dial"
type="number"
min={g.turn === 1 ? 0 : 1}
max={g.turn === 1 ? 20 : 3}
value={stormDialValue}
onChange={(e) => setStormDial(Number(e.target.value))}
/>
</label>
{actionButton('Lock storm dial', {
type: 'stormDial',
amount: stormDialValue,
})}
</>
) : (
<p className="fine">Waiting for secret storm dials.</p>
)}
</>
)}
{g.phase === 1 && g.spiceWindow && (
<p className="notice">
{g.spiceWindow.amount} spice revealed in{' '}
{territory(g.spiceWindow.territory).name}, sector{' '}
{g.spiceWindow.sector}.
{g.spiceWindow.sector === g.storm
? ' The storm destroys this blow.'
: ''}
{g.spiceWindow.harvesters
? ` Harvester has been played ${g.spiceWindow.harvesters} time(s).`
: ''}
{harvesterAvailable(g.spiceWindow)
? ' You may play Harvester before accepting the blow.'
: ' This blow is closed to Harvester.'}
</p>
)}
{g.phase === 1 &&
g.players.some((player) => player.faction === 'fremen' && player.fremenNexusWormBlocked) && (
<p className="notice">
Fremen Nexus Betrayal was spent before the spice blow.
Fremen cannot ride worms this turn; ordinary worm effects
and Nexus negotiations still resolve.
</p>
)}
{g.phase === 1 && g.nexus && !g.spiceWindow && (
<>
<p className="muted">
A sandworm has opened a Nexus. Alliances may change now.
</p>
{outgoingAlliance && !me.ally && (
<div className="nexus-offer">
<p className="notice">
Your alliance offer is to {outgoingAllianceName ?? 'a player no longer seated'}.
You remain unallied until both players agree.
</p>
{actionButton('Withdraw your alliance offer', { type: 'alliance' })}
</div>
)}
<label>
Alliance partner
<select
value={target}
onChange={(e) => setTarget(e.target.value)}
>
<option value="">Choose a player</option>
{g.players
.filter((p) => p.id !== me.id)
.map((p) => (
<option value={p.id} key={p.id}
disabled={!!g.homeworldAllianceBlocks?.[p.id]}>
{p.name}
{g.homeworldAllianceBlocks?.[p.id] ? ` · ${g.homeworldAllianceBlocks[p.id]}` : ''}
{g.allianceOffers[p.id] === me.id ? ' · Invited you' : ''}
</option>
))}
</select>
</label>
{g.homeworldAllianceBlocks?.[target] && <p className="notice">{g.homeworldAllianceBlocks[target]}</p>}
{actionButton('Propose / accept alliance', {
type: 'alliance',
target,
}, !target || !!g.homeworldAllianceBlocks?.[target])}
{me.ally &&
actionButton('Break alliance', { type: 'alliance' })}
</>
)}
{g.phase === 1 && !g.nexus && !g.spiceWindow && (
<p className="muted">
Ready the table to reveal the next spice blow.
</p>
)}
{g.phase === 2 && <CharityClaim game={g} act={act} busy={busy} />}
{g.phase === 3 && g.richeseAuction && (
<RicheseAuctionLot
key={`${g.richeseAuction.event}-${g.me}`}
game={g}
act={act}
busy={busy}
/>
)}
{g.phase === 3 && !g.richeseAuction && g.auction && (
<>
<div className="auction-card">
<span className="eyebrow">
{g.auction.remaining} CARDS REMAIN
</span>
<h3>{g.auction.card?.name ?? 'Unknown treachery'}</h3>
{g.auction.card && <CardInspector card={g.auction.card} />}
{g.auction.inspectionBlocked && (
<output className="notice block">{g.auction.inspectionBlocked}</output>
)}
{!!g.ixTechnology?.known.length && (
<details>
<summary>Your inspected pool · order unknown</summary>
<ul className="visible-component-list">
{g.ixTechnology.known.map((c) => (
<li key={c.id}>
<span>{c.name}</span>
<CardInspector card={c} />
</li>
))}
</ul>
<p className="fine">
This records the pool you inspected, including cards
already auctioned; it does not reveal the next card or
other hands.
</p>
</details>
)}
<strong>
{g.auction.bid} <small>spice</small>
</strong>
</div>
{g.auction.active === me.id ? (
<>
<label htmlFor="your-bid">
Your bid
<Input
id="your-bid"
type="number"
min={minimumBid}
max={maximumBid}
value={bidText}
onChange={(e) =>
setBidDraft({
auction: auctionKey,
value: e.target.value,
})
}
/>
</label>
<p className="fine">
Minimum bid: {minimumBid} spice.
{normalAuctionHandBlock(me.hand?.length ?? me.handCount ?? me.handLimit, me.handLimit) &&
' Your Treachery hand is full; pass this bid.'}
{maximumBid < minimumBid &&
' You cannot afford a higher bid; pass this card.'}
</p>
{actionButton(
'Raise bid',
{
type: 'bid',
amount: bidAmount,
...payment,
},
!!normalAuctionHandBlock(me.hand?.length ?? me.handCount ?? me.handLimit, me.handLimit) ||
!Number.isSafeInteger(bidAmount) ||
bidAmount < minimumBid ||
bidAmount > maximumBid,
)}
{actionButton('Pass this bid', { type: 'passBid' })}
</>
) : (
<p className="muted">
Waiting for{' '}
{g.players.find((p) => p.id === g.auction!.active)?.name}.
</p>
)}
</>
)}
{me.faction === 'emperor' && me.ally && (
<details className="phase-guide">
<summary>Share wealth with your ally</summary>
<p className="fine">
Spice becomes available immediately after the Karama
response.
</p>
<label htmlFor="emperor-gift">
Spice
<Input
id="emperor-gift"
type="number"
min={1}
max={me.spice ?? 0}
value={aidAmount}
onChange={(e) => setAidAmount(Number(e.target.value))}
/>
</label>
{actionButton(
'Share spice',
{ type: 'emperorGift', amount: aidAmount },
aidAmount < 1,
)}
</details>
)}
{g.phase === 4 && (
<>
<RevivalTurn game={g} act={act} busy={busy} />
{g.revival.prevented && (
<p className="notice">
Tleilaxu prevented your force and leader revivals for
the rest of this Revival, including Ghola Treachery.
The Emperor’s native special Karama remains immune;
this prevention does not stop Ghola outside Revival.
</p>
)}
<RevivalCommerce game={g} act={act} busy={busy} />
{g.revival.homeworldBonus > 0 && (
<p className="notice">
Low Homeworld population adds one to your free revival
rate. The next request uses your population after this
group returns.
{g.revival.freeBlocked
? ' La La La currently prevents taking any free revivals.'
: ''}
</p>
)}
{g.revival.tleilaxuHomeworldIncomeBlocked && (
<p className="notice">
Tleilax started Revival at low population. Tleilaxu
receives no bank reward for other factions’ free revivals
this phase; paid revival and Ghola income remain separate.
</p>
)}
{g.revival.freeBlocked && (
<p className="notice">
La La La prevents your free force revivals for this phase.
{me.faction === 'fremen' &&
!g.players.some((p) => p.faction === 'tleilaxu')
? ' Fremen cannot purchase normal force revivals without Tleilaxu in the game.'
: ' Normal paid revivals remain available.'}
</p>
)}
<p className="fine">
{g.revival.limit === 20
? 'No normal force revival limit'
: `Up to ${g.revival.limit} force revivals`}{' '}
· {g.revival.freeRemaining} free remaining ·{' '}
{me.faction === 'fremen' &&
!g.players.some((p) => p.faction === 'tleilaxu')
? 'Fremen force revival is free only without Tleilaxu'
: me.faction === 'choam' && !g.revival.choamBlocked
? 'One spice per paid force'
: 'Standard force prices'}
{g.revival.discount
? ' · Tleilaxu half-price discount'
: ''}
</p>
<FremenRevivalGrant game={g} act={act} busy={busy} />
{g.freeRevival.includes(me.id) && !g.revival.freeBlocked && (
<p className="muted">
Fremen’s grant is active. Your current free allowance above includes Recruits and any applicable bonuses.
</p>
)}
{amountInput}
{me.faction === 'ixians' && (
<label>
Use free revival for
<select
value={freeCyborgFirst ? 'cyborg' : 'suboid'}
onChange={(e) =>
setFreeCyborgFirst(e.target.value === 'cyborg')
}
>
<option value="cyborg">Cyborgs first</option>
<option value="suboid">Suboids first</option>
</select>
<span className="fine">
Paid cyborgs cost three spice; suboids cost two. Cyborgs
do not share the one-per-turn Sardaukar/Fedaykin limit.
</span>
</label>
)}
{me.faction === 'ixians' && (
<IxHomeworldRevivalBonus
enabled={g.revival.ixSuboidBonusEnabled}
blocked={g.revival.freeBlocked}
amount={amount}
elite={eliteAmount}
free={Math.min(amount, g.revival.freeRemaining)}
freeElite={freeCyborgFirst
? Math.min(amount, g.revival.freeRemaining, eliteAmount)
: Math.max(0, Math.min(amount, g.revival.freeRemaining) - (amount - eliteAmount))}
normalTanks={me.tanks - (me.elites?.tanks ?? 0)}
/>
)}
{me.faction === 'emperor' &&
me.ally &&
(() => {
const ally = g.players.find((p) => p.id === me.ally)!;
const cost = paidForceRevivalCost(
ally,
amount,
ally.elites ? allyEliteAmount : 0,
);
return (
<div className="notice">
<h3>Extra revivals for {ally.name}</h3>
{ally.elites && (
<EliteCount
label={
ally.faction === 'ixians'
? 'Ally cyborgs'
: 'Ally elite forces'
}
value={allyEliteAmount}
onChange={setAllyEliteAmount}
max={Math.min(
amount,
eliteRevivalRemaining(ally, g.advanced),
)}
/>
)}
<p className="fine">
You pay {cost} spice. These forces use your ally’s
separate extra-revival allowance.
</p>
{actionButton(
'Pay for extra ally revivals',
{
type: 'emperorRevival',
amount,
elite: ally.elites ? allyEliteAmount : 0,
},
cost > (me.spice ?? 0) ||
(!!g.revivalOrder && g.revivalOrder.remaining[0] !== ally.id) ||
amount > 3 - (g.emperorExtra[ally.id] ?? 0),
)}
</div>
);
})()}
{g.revival.kwisatz &&
actionButton(
`Revive Kwisatz Haderach · ${g.revival.kwisatz.cost} spice`,
{
type: 'reviveKwisatz',
},
!!g.revival.orderBlocked || !g.revival.kwisatz.affordable,
)}
{(g.emperorExtra[me.id] ?? 0) > 0 && (
<p className="fine">
The Emperor revived {g.emperorExtra[me.id]} extra forces
for you. Your normal revival allowance is separate.
</p>
)}
{actionButton(
'Revive forces',
{
type: 'revive',
amount,
elite: eliteAmount,
freeElite:
me.faction === 'ixians' && !freeCyborgFirst
? Math.max(
0,
Math.min(amount, g.revival.freeRemaining) -
(amount - eliteAmount),
)
: undefined,
},
!!g.revival.orderBlocked || g.revival.prevented ||
!me.tanks ||
eliteAmount > g.revival.eliteRemaining ||
amount > g.revival.forcesRemaining ||
!!homeworldRevivalActionBlock(g, { type: 'revive', amount, elite: eliteAmount }),
)}
{eliteAmount > g.revival.eliteRemaining && (
<p className="fine" aria-live="polite">
{g.revival.eliteRemaining} special-force revivals remain this turn.
Reduce the selected elite count.
</p>
)}
{homeworldRevivalActionBlock(g, { type: 'revive', amount, elite: eliteAmount }) && (
<p className="fine" aria-live="polite">
{homeworldRevivalActionBlock(g, { type: 'revive', amount, elite: eliteAmount })}
</p>
)}
{me.faction === 'fremen' &&
amount > g.revival.forcesRemaining ? (
<p className="fine">
Choose no more than {g.revival.forcesRemaining} available
free force revivals. Emperor-funded extras and card
effects are separate.
</p>
) : (
<p className="fine">
Selected force revival costs{' '}
{
forceRevivalPrice(
me.faction,
amount,
eliteAmount,
Math.min(amount, g.revival.freeRemaining),
g.revival.discount,
me.faction === 'ixians' && !freeCyborgFirst
? Math.max(
0,
Math.min(amount, g.revival.freeRemaining) -
(amount - eliteAmount),
)
: undefined,
g.revival.choamBlocked,
).cost
}{' '}
spice.
</p>
)}
{g.revival.auditorCyclePolicy && (
<p className="notice">{g.revival.auditorCyclePolicy}</p>
)}
{g.revival.leaders.length > 0
? (() => {
const selectedRevival = g.revival.leaders.find(
(choice) => choice.id === leader,
);
return (
<>
<label htmlFor="normal-revival-leader">
Leader
<select
id="normal-revival-leader"
value={selectedRevival?.id ?? ''}
onChange={(e) => setLeader(e.target.value)}
>
<option value="">
Choose an eligible leader
</option>
{g.revival.leaders.map((choice) => (
<option value={choice.id} key={choice.id}>
{choice.name} · {choice.cost} spice
{choice.id === 'duke-vidal' ? ' · strength 6, set aside after revival' : ''}
{choice.early ? ' · early revival' : ''}
{!choice.affordable
? ' · insufficient spice'
: ''}
</option>
))}
</select>
</label>
{actionButton(
'Revive leader',
{
type: 'reviveLeader',
leader: selectedRevival?.id ?? '',
},
!!g.revival.orderBlocked || !selectedRevival?.affordable,
)}
</>
);
})()
: (me.leaders.some((l) => l.dead && l.faction === me.faction) ||
(me.faction === 'ecaz' && g.dukeVidal?.leader.dead)) && (
<p className="fine">
{me.leaderRevived && me.faction !== 'tleilaxu'
? 'You have used your one leader revival for this phase.'
: g.revival.prevented
? 'Tleilaxu prevented your normal revivals this turn.'
: g.revival.dukeBlocked && me.faction === 'ecaz' && g.dukeVidal?.leader.dead
? g.revival.dukeBlocked
: 'No leader is currently eligible for normal revival.'}
</p>
)}
{g.revival.ecazCyclePolicy && <p className="fine">{g.revival.ecazCyclePolicy}</p>}
</>
)}
{g.phase === 5 && (
<ShipmentPromises game={g} act={act} busy={busy} />
)}
{g.phase === 5 && g.active === me.id && g.homeworldShipment && shipmentAvailable && (
<HomeworldShipment key={g.homeworldShipment.event} game={g} act={act} busy={busy} />
)}
{g.phase === 5 && g.active === me.id && g.guildHomeworldShipment && shipmentAvailable && (
<GuildHomeworldShipment key={g.guildHomeworldShipment.event} game={g} act={act} busy={busy} />
)}
{g.phase === 5 && g.homeworldMove && (
<EmperorHomeworldMovement
key={g.homeworldMove.event}
game={g}
act={act}
busy={busy}
/>
)}
{g.phase === 5 &&
me.faction === 'choam' &&
g.choamMovementBonus > 0 && (
<p className="notice">
Kulon: +{g.choamMovementBonus} territory of movement range
this turn. Storm and entry rules still apply.
</p>
)}
{g.phase === 5 &&
g.balisetRestrictions
.filter((b) => b.player === me.id)
.map((b) => (
<p className="notice" key={b.territory}>
Baliset: movement into{' '}
{
gameTerritories(g).find((t) => t.id === b.territory)
?.name
}{' '}
is blocked while CHOAM occupies it. Shipment remains
possible.
</p>
))}
{g.phase === 5 &&
me.faction === 'fremen' &&
me.fremenMovementBlocked && (
<p className="notice">
{me.fremenNexusMovementBlocked
? 'Fremen Nexus Betrayal suppresses your two-territory movement advantage for this turn. Available ornithopters still use their normal range.'
: 'Karama limited this move to one territory. Your forces have not moved yet. Available ornithopters still allow their normal range; a later extra move can use your two-territory advantage again.'}
</p>
)}
{g.phase === 5 && me.faction === 'ixians' && (
<p className="fine">
Select a cyborg to take accompanying suboids two territories.{' '}
<HelpTip topic="ixTransport" />
{me.ixMovementBlocked &&
' Cyborg speed was canceled for this move; use one territory or available ornithopters.'}
</p>
)}
{g.phase === 5 &&
(g.active === me.id ? (
<>
{g.advanced &&
me.faction === 'ixians' &&
!me.specialKaramaUsed &&
g.mobileStronghold?.location &&
(me.forces['hidden_mobile_stronghold:0'] ?? 0) > 0 &&
me.hand
?.filter(canActivateKarama)
.slice(0, 1)
.map((c) => (
<details key={c.id}>
<summary>
Special Karama: relocate stronghold
</summary>
<MobileStronghold
key={`${g.turn}-${g.mobileStronghold?.location}-${c.id}`}
game={g}
act={act}
busy={busy}
card={c.id}
/>
</details>
))}
{!me.shipped && g.nexusGuildSecretAlly && (
<div className="notice">
<label className="decision-checkbox min-h-11">
<input type="checkbox" checked={useGuildSecret} disabled={busy || !!g.nexusGuildSecretAlly.blocked}
onChange={event => setGuildSecretEvent(event.target.checked ? g.nexusGuildSecretAlly!.event : '')} />
Use Guild Nexus Secret Ally
</label>
<p>Spend the Nexus for this shipment at Guild prices: one spice per two forces to a stronghold or reserves, rounded up, or one per force elsewhere. Cross-shipment and return use the same shipment opportunity; normal movement remains available.</p>
{me.faction === 'fremen' && <p>Leave this unchecked for free Fremen reinforcement within its normal range. Selecting the Nexus pays Guild prices and permits destinations beyond that range, outside the storm.</p>}
{g.homeworlds?.worlds?.length && <p>Returning Arrakis forces to native Homeworld reserves with this card awaits a ruling.</p>}
{g.nexusGuildSecretAlly.blocked && <p>{g.nexusGuildSecretAlly.blocked}</p>}
</div>
)}
{!me.shipped && g.nexusRichese && (
<div className="notice">
<label className="decision-checkbox min-h-11">
<input type="checkbox" checked={useRicheseShipment}
disabled={busy || !!g.nexusRichese.blocked}
onChange={(event) => setRicheseShipmentEvent(event.target.checked ? g.nexusRichese!.event : '')} />
Use Richese Nexus Secret Ally
</label>
<p>Spend the Nexus to ship up to five physical forces from reserves at the price of one. This uses your ordinary shipment; movement remains available.</p>
{g.nexusRichese.blocked && <p>{g.nexusRichese.blocked}</p>}
{useRicheseShipment && <p>Physical forces selected: {amount}. Forces charged: 1. Ordinary destination and faction restrictions still apply.</p>}
</div>
)}
{destination}
{isAlliedShipmentVisit(g, me, selected) && (
<p className="notice">
{g.advanced
? 'Advanced allied transit: ship ordinary forces here, then depart before finishing. Supplied p15 sends all your forces left sharing with an ally to Tanks, including old forces; advisor, Polar Sink and native Ecaz coexistence exemptions remain.'
: 'Basic allied transit: ship ordinary forces here, then move the visiting group out before finishing your turn. Provisional fallback: new visitors left behind go to Tanks, even if departure is prevented. This does not waive the older allied co-occupation deadline.'}
{' '}Storm and stronghold capacity still apply.
</p>
)}
{amountInput}
{g.karamaShipping?.player === me.id && (
<p className="fine">
Karama shipment: pay Guild rates to the bank. Choose
your shipment below.
</p>
)}
{shipmentAvailable && (
<>
<NativeShipmentChoice
game={g}
amount={amount}
elite={eliteAmount}
sources={homeworldSources}
busy={busy}
onChange={(sources) =>
setHomeworldDraft({
key: homeworldSourceKey,
sources,
})
}
/>
<ShipmentQuote
id="reserve-shipment-quote"
physicalForces={useRicheseShipment || smugglerShipment ? amount : undefined}
quote={shipmentQuote}
funding={{
ownSpice: me.spice ?? 0,
pledgedSpice: g.aid.available,
}}
unavailableReasons={shipmentProblems}
/>
<SmugglerShipmentChoice quote={smugglerOffer} use={useSmuggler} busy={busy || !!g.truthtrance} onChange={setUseSmuggler} />
<Button
className="game-action"
aria-describedby="reserve-shipment-quote"
disabled={
busy ||
!!g.truthtrance ||
shipmentProblems.length > 0
}
onClick={() => {
const shipment: Action = {
type: 'ship', elite: eliteAmount, ...payment,
territory: selected, sector, amount,
...(smugglerOffer ? { smuggler: useSmuggler } : {}),
...(homeworldSources ? { homeworldSources } : {}),
};
const action = useGuildSecret ? nexusGuildSecretAllyAction(g, guildSecretEvent, shipment) : useRicheseShipment
? nexusRicheseAction(g, richeseShipmentEvent, shipment) : shipment;
if (action) act(action);
}}
>
Ship from reserves <ArrowRight size={15} />
</Button>
</>
)}
{sourceInput}
{markerAtSource && ownNoField && (
<label className="decision-checkbox">
<input
type="checkbox"
checked={includesNoField}
disabled={busy || !!g.homeworldMobility?.noFieldMovementBlocked}
onChange={(event) =>
setMoveNoField(event.target.checked)
}
/>
Include concealed No-Field from sector{' '}
{ownNoField.location.sector}
</label>
)}
{includesNoField && (
<p className="fine">
Moving {markerPhysicalTotal} physical forces and one
concealed No-Field. Choose zero physical forces to move
only the marker. Its hidden value stays unchanged.
</p>
)}
{markerAtSource && g.homeworldMobility?.noFieldMovementBlocked && (
<p className="fine">{g.homeworldMobility.noFieldMovementBlocked} Physical forces may still move without the token.</p>
)}
<label className="decision-checkbox">
<input
type="checkbox"
checked={combineSectors}
onChange={(e) => setCombineSectors(e.target.checked)}
/>
Combine forces from this territory’s sectors
</label>
{combineSectors &&
source &&
Object.entries(me.forces)
.filter(
([key, n]) =>
n > 0 &&
splitLocation(key).territory ===
splitLocation(source).territory,
)
.map(([key, n]) => (
<label key={key}>
Sector {splitLocation(key).sector} · {n} forces
<Input
type="number"
min={0}
max={n}
value={moveForces[key] ?? 0}
disabled={splitLocation(key).sector === g.storm}
onChange={(e) =>
setMoveForces({
...moveForces,
[key]: Number(e.target.value),
})
}
/>
</label>
))}
{combineSectors &&
me.elites &&
Object.entries(me.forces)
.filter(
([key]) =>
splitLocation(key).territory ===
splitLocation(source).territory,
)
.map(([key]) => (
<EliteCount
key={key}
label={`Elite from sector ${splitLocation(key).sector}`}
max={Math.min(
moveForces[key] ?? 0,
me.elites!.forces[key] ?? 0,
)}
value={eliteForces[key] ?? 0}
onChange={(n) =>
setEliteForces({ ...eliteForces, [key]: n })
}
/>
))}
{g.advanced &&
isAdvisor(me, splitLocation(source).territory) && (
<label className="checkbox-row">
<input
type="checkbox"
checked={moveAsFighters}
onChange={(e) =>
setMoveAsFighters(e.target.checked)
}
/>
Request a flip to fighters after entering an occupied
territory
</label>
)}
{advisorFlipRouteBlocked && (
<p className="notice" id="movement-advisor-flip-unavailable">
Advisor flipping was canceled for this movement. Turn off
the fighter request or choose a group that is already fighting.
</p>
)}
{!g.ornithopter?.active && (
<>
{actionButton(
includesNoField
? 'Move selected forces and No-Field'
: 'Move forces',
ordinaryMovementAction,
!source ||
canceledFremenRouteBlocked ||
ordinaryMovementRouteBlocked ||
advisorFlipRouteBlocked ||
selectedDestinationInStorm ||
(me.moved ?? 0) >= (me.movesAllowed ?? 1) ||
(includesNoField
? !validMarkerGroup
: (me.forces[source] ?? 0) === 0),
advisorFlipRouteBlocked
? 'movement-advisor-flip-unavailable'
: selectedDestinationInStorm
? 'movement-storm-unavailable'
: ordinaryMovementRouteBlocked
? 'movement-route-unavailable'
: canceledFremenRouteBlocked
? 'movement-range-unavailable'
: undefined,
)}
{canceledFremenRouteBlocked &&
!selectedDestinationInStorm && (
<p
id="movement-range-unavailable"
className="fine"
role="status"
>
This route is blocked or beyond your available
range. Choose a reachable destination within one
territory, or use available ornithopters.
</p>
)}
{ordinaryMovementRouteBlocked && !selectedDestinationInStorm && (
<p id="movement-route-unavailable" className="fine" role="status">
Movement unavailable: this force group has no unblocked
ground route within its ordinary range. Choose a reachable
source or destination outside the storm and blocked strongholds.
</p>
)}
{selectedDestinationInStorm && (
<p
id="movement-storm-unavailable"
className="fine"
role="status"
>
Movement unavailable: sector {sector} of{' '}
{territory(selected).name} is in the storm. Choose a
different sector or territory.
</p>
)}
<SandmasterMovement
game={g}
move={source ? ordinaryMovementAction : null}
act={act}
busy={busy}
unavailableReason={
!movementAvailable && !g.ornithopter?.active
? 'No ordinary movement is currently available.'
: !g.ornithopter?.active && (me.moved ?? 0) >= (me.movesAllowed ?? 1)
? 'No ordinary movement remains this turn.'
: selectedDestinationInStorm
? 'Choose a destination outside the storm.'
: liveShipmentPromises(
g.shipmentPromises,
me.id,
g.turn,
).some(promise => matchesShipment(promise,null) !== promise.answer)
? 'Complete the promised shipment before moving.'
: null
}
/>
</>
)}
<OrnithopterMovement
game={g}
act={act}
busy={busy}
move={
!source ||
!movementAvailable ||
advisorFlipRouteBlocked ||
(me.moved ?? 0) >= (me.movesAllowed ?? 1) ||
(includesNoField
? !validMarkerGroup
: (me.forces[source] ?? 0) === 0)
? null
: {
type: 'move',
fighters:
moveAsFighters &&
isAdvisor(me, splitLocation(source).territory),
...movementGroup,
territory: selected,
sector,
...(includesNoField ? {} : { amount }),
}
}
/>
<DiscoveryOrnithopterMovement
game={g}
act={act}
busy={busy}
move={
!source || !movementAvailable || advisorFlipRouteBlocked ||
(includesNoField ? !validMarkerGroup : (me.forces[source] ?? 0) === 0)
? null
: {
type: 'move',
fighters: moveAsFighters && isAdvisor(me, splitLocation(source).territory),
...movementGroup,
territory: selected,
sector,
...(includesNoField ? {} : { amount }),
}
}
/>
<PlanetologistMovement
game={g}
act={act}
busy={busy}
destination={selected}
sector={sector}
unavailableReason={
!movementAvailable && !g.ornithopter?.active
? 'No ordinary movement is currently available.'
: !g.ornithopter?.active && (me.moved ?? 0) >= (me.movesAllowed ?? 1)
? 'No ordinary movement remains this turn.'
: selectedDestinationInStorm
? 'Choose a destination outside the storm.'
: liveShipmentPromises(
g.shipmentPromises,
me.id,
g.turn,
).some(promise => matchesShipment(promise,null) !== promise.answer)
? 'Complete the promised shipment before moving.'
: null
}
/>
<p className="fine">
{guildCunning?.stage === 'secondShipment'
? 'Choose or decline the second shipment before any Hajr movement.'
: guildCunning?.stage === 'extraMove'
? `${guildCunning.movesLeft} movement remaining from unused Hajr or the active card. No ordinary movement is granted.`
: (me.moved ?? 0) < (me.movesAllowed ?? 1)
? `${(me.movesAllowed ?? 1) - (me.moved ?? 0)} movement remaining.`
: 'Movement complete. Finish your turn when your shipment is also settled.'}
</p>
{g.ornithopter && <p className="fine">Provisional card composition: Ornithopter replaces one unused movement. Its two groups must be different; Hajr adds one separate normal move and may move those forces again.</p>}
{g.advanced && me.hand?.some(card => card.effect === 'hajr') && <p className="fine">Play Hajr immediately after a completed Force Movement and its required arrival decisions, before your next voluntary action.</p>}
{!!me.hajrPlays && <p className="fine">{me.hajrPlays} Hajr play{me.hajrPlays === 1 ? '' : 's'} this turn. Each legal play grants one additional movement. A recovered Hajr must return to your hand; Advanced requires a fresh post-move opportunity.</p>}
{transportPreview && (
<>
<ShipmentQuote
id="guild-transport-quote"
label={`Guild transport to ${territory(selected).name}`}
{...transportPreview}
/>
{actionButton(
'Guild transport',
transportAction,
transportPreview.unavailableReasons.length > 0,
'guild-transport-quote',
)}
{southernTransportPreview && (
<>
<ShipmentQuote
id="southern-transport-quote"
label={`Southern reserves to ${territory(selected).name}`}
{...southernTransportPreview}
/>
{actionButton(
'Cross-ship southern reserves',
southernTransportAction,
southernTransportPreview.unavailableReasons
.length > 0,
'southern-transport-quote',
)}
</>
)}
{returnTransportPreview && (
<>
<ShipmentQuote
id="return-transport-quote"
label="Return to reserves"
{...returnTransportPreview}
/>
{actionButton(
'Return to reserves',
returnTransportAction,
returnTransportPreview.unavailableReasons.length >
0,
'return-transport-quote',
)}
</>
)}
</>
)}
{g.basicAlliedShipment && (
<p className="notice">
Move your new visitors out of {g.basicAlliedShipment.territory} before finishing.
Provisional Basic policy: finishing now sends {g.basicAlliedShipment.normal} normal
{' '}and {g.basicAlliedShipment.elite} special visiting forces to Tanks, not your ally’s forces.
</p>
)}
<AdvancedAllySeparation quote={g.advancedAllySeparation} />
{g.nexusGuildCunning?.offer && (
<section className="notice">
<p>Guild Cunning ends your ordinary shipment and movement, then offers one second shipment at normal Guild prices. Only an unused Hajr move can follow it. Provisional card combination: Ornithopter can replace that unused Hajr movement; declaring Cunning abandons any unfinished current card group.</p>
{g.nexusGuildCunning.offer.blocked && <p>{g.nexusGuildCunning.offer.blocked}</p>}
{actionButton('Finish ordinary turn and declare Guild Cunning',
guildCunningAction ?? {type: 'endMovement'}, !guildCunningAction)}
</section>
)}
{guildCunning && <p className="notice">{guildCunning.stage === 'secondShipment'
? 'Guild Cunning: choose your second shipment, or skip it and retain an unused Hajr move. This does not reopen ordinary movement.'
: guildCunning.stage === 'extraMove'
? 'The second shipment is settled. Use an unused Hajr move, replace it with Ornithopter, finish an active card group, or finish your turn.'
: 'Guild Cunning awaits its cancellation response.'}</p>}
{guildCunning?.stage === 'secondShipment' && actionButton('Skip second shipment',
nexusGuildSkipShipmentAction(g, guildCunning.event) ?? {type: 'endMovement'},
!nexusGuildSkipShipmentAction(g, guildCunning.event))}
{actionButton(guildCunning?.stage === 'secondShipment' ? 'Decline second shipment and finish' :
guildCunning ? 'Finish Guild turn' : 'Finish shipment & movement', {type: 'endMovement'}, false,
g.advancedAllySeparation?.territories.length ? 'advanced-ally-separation' : undefined)}
</>
) : (
<p className="muted">
Waiting for {g.players.find((p) => p.id === g.active)?.name}{' '}
to ship and move.
</p>
))}
{g.phase === 6 && !g.battle && g.battleOrder && g.battleOrder.uses.length > 0 && (
<p className="notice">
Remaining battle-choice order: {g.battleOrder.remaining.map(id =>
g.players.find(player => player.id === id)?.name ?? id).join(' → ')}.
{' '}Only unresolved battles can be chosen. Each battle keeps its own aggressor and tie rules.
</p>
)}
{g.phase === 6 && !g.battle && g.active === me.id && (
<>
<p className="muted">
Choose one of your unresolved battles.
</p>
{botBattleChoices(g).map((battle) => (
<div
key={`${String(battle.territory)}-${String(battle.target)}`}
>
{actionButton(
`Fight ${g.players.find((p) => p.id === battle.target)?.name} · ${combatName(String(battle.territory))}`,
battle,
)}
</div>
))}
</>
)}
{g.phase === 8 && me.faction === 'choam' && (
<>
<h3>Inflation</h3>
{g.inflationUsed ? (
<p className="muted">Your Inflation token has been used.</p>
) : g.inflationAttempted ? (
<p className="muted">
Placement was canceled. You may try again at a later
Mentat Pause.
</p>
) : (
<>
<p className="muted">
Set next turn’s charity. The token flips at the next
Mentat Pause, then leaves the game at the following one.
You may save it by marking ready.
</p>
{actionButton('Double, then cancel', {
type: 'choamInflation',
side: 'double',
})}
{actionButton('Cancel, then double', {
type: 'choamInflation',
side: 'cancel',
})}
</>
)}
</>
)}
{g.battle && (
<>
<p className="battle-location">
<Swords size={17} />
{g.battle.locationName ?? combatName(g.battle.territory)}
</p>
<p className="fine">
Aggressor: {g.players.find(p => p.id === g.battle!.aggressor)?.name}.
{' '}Tied battle: {g.players.find(p => p.id === g.battle!.tieWinner)?.name}
{g.battle.tieWinner !== g.battle.aggressor ? ' (Habbanya Stronghold advantage)' : ''}.
</p>
<EcazOccupyBattleSummary game={g} />
{g.battle.outcomeWindow && !g.battle.outcomeWindow.closed && (
<BattleOutcomeWindowControls window={g.battle.outcomeWindow} me={g.me}
players={g.players} busy={busy || !!g.decision || !!g.response || !!g.truthtrance || !!g.phaseOpening}
onReady={event => act({type: 'battleOutcomeReady', event})} />
)}
{!g.battle.revealed && g.battle.leaderless.length > 0 && (
<p className="notice block" role="status">
{g.battle.leaderless.map(id => g.players.find(p => p.id === id)?.name).join(', ')}
{' '}announced a Battle Plan without a leader or Cheap Hero.
No battle cards can be played; other plan choices remain sealed.
</p>
)}
{!!g.battle.native && (
<p className="notice">
Only the native faction may reveal Traitors or Face
Dancers here.
{[g.battle.attacker, g.battle.defender].includes(
g.battle.native,
) && (
<>
{' '}
{
g.players.find((p) => p.id === g.battle?.native)
?.name
}{' '}
adds +{g.battle.nativeBattleStrength} native strength
in ordinary strength comparisons, separately from the
dial.
</>
)}
</p>
)}
{g.battle.eliteBlocked.length > 0 && (
<p className="notice">
Karama:{' '}
{g.battle.eliteBlocked
.map((id) => g.players.find((p) => p.id === id)?.name)
.join(', ')}{' '}
counts elite tokens as ordinary forces in this battle.
</p>
)}
{g.battle.fremenSupportBlocked && (
<p className="notice">
Karama: Fremen must support tokens with spice for full
strength in this battle.
</p>
)}
<NexusInspectionHistory game={g} />
{(!g.battle.preLeader || g.battle.preLeader.closed) && (
<BattlePromises
act={act}
game={g}
busy={busy}
fill={(plan) => {
setDial(plan.dial);
setSupport(plan.support);
setBankerSpice(plan.bankerSpice ?? 0);
setAllyPayment(
plan.allyPayment === undefined
? ''
: String(plan.allyPayment),
);
setLeader(plan.leader ?? '');
setWeapon(plan.weapon ?? '');
setDefense(plan.defense ?? '');
setPlanetologistDraft({
key: `${g.battle?.event ?? ''}:${plan.leader ?? ''}:${plan.weapon ?? ''}`,
enabled: plan.planetologistSpecial === true,
});
setKwisatz(!!plan.kwisatz);
if (plan.reinforcementsSources) {
const chosen = JSON.stringify(plan.reinforcementsSources);
const index = reinforcementsSourceChoices?.findIndex(choice => JSON.stringify(choice) === chosen);
if (index !== undefined && index >= 0)
setReinforcementsSourceDraft({ key: reinforcementsSourceKey, index });
}
}}
/>
)}
{g.battle.preLeader && !g.battle.preLeader.closed ? (
<BattleLeaderOpportunity game={g} act={act} busy={busy} />
) : g.battle.preparation ? (
<BattlePreparation
key={`${g.battle.event}-${g.battle.preparation.kind}-${g.battle.preparation.kind === 'nexusPrescienceAnswer' ? g.battle.nexusInspection?.field : g.battle.prescience?.field ?? ''}`}
game={g}
send={send}
busy={busy}
/>
) : g.battle.fullPlan &&
!g.battle.submitted.includes(g.battle.fullPlan.target) &&
g.battle.fullPlan.target !== me.id ? (
<p className="notice">
Waiting for{' '}
{
g.players.find(
(p) => p.id === g.battle!.fullPlan!.target,
)?.name
}{' '}
to commit the plan requested by Atreides.
</p>
) : [g.battle.attacker, g.battle.defender].includes(me.id) ? (
g.battle.revealed ? (
<>
{g.battle.smugglerCollection && <p className="notice">
{g.players.find(p => p.id === g.battle!.smugglerCollection!.player)?.name}’s Smuggler has {g.battle.smugglerCollection.amount} spice pending from the reveal-time piles.
A surviving leader collects unless an actual zero-strength weapon effect resolves. Collection settles before other battle cleanup, even after defeat; a partial collection across several piles requires its owner’s sector choice. This reveal-time cap is not yet spendable.
</p>}
{!g.battle.traitorVoters.includes(me.id) && (
<p className="notice">
Only the native faction may declare Traitors here.
Wait for that decision; any legal invader late defense
follows through its own original control.
</p>
)}
{g.battle.traitorVoters.includes(me.id) && !g.battle.traitorSubmitted.includes(me.id) && (
<>
<TraitorDecisionPromises game={g} />
{actionButton(
'Reveal traitor',
{
type: 'traitorCall',
call: true,
},
!canRevealTraitor,
)}
{!canRevealTraitor && (
<p className="fine">{noTraitorReason}</p>
)}
{actionButton('No traitor — resolve', {
type: 'traitorCall',
call: false,
}, !canDeclineTraitor)}
</>
)}
</>
) : (
<>
<LeaderSkillBattleGuide game={g} leader={committed.leader ? String(committed.leader.value ?? '') : leader} />
{smugglerPlanReason && <p className="notice">{smugglerPlanReason}</p>}
{g.battle.insight && (
<p className="notice">
{committed[g.battle.insight.field]
? 'Your committed element'
: 'Prescience'}
: {g.battle.insight.field} ={' '}
{g.battle.insight.label}
</p>
)}
{g.battle.voice && (
<p className="notice">
Voice:{' '}
{g.battle.voice.must ? 'must play' : 'cannot play'}{' '}
{battleCardLabel(g.battle.voice.kind)}.
</p>
)}
{!g.battle.submitted.includes(me.id) ? (
<>
<label htmlFor="forces-dialed">
{occupyProfile ? 'Total forces dialed' : 'Forces dialed'} <HelpTip topic="dial" />
{me.faction === 'ixians' && (
<HelpTip topic="ixForces" />
)}
</label>
<BattleWheel
id="forces-dialed"
min={battleDialMinimum}
sliderLabel={occupyProfile ? 'Total forces dialed slider' : 'Forces dialed slider'}
max={battleDialMaximum}
step={battleDialStep}
value={
committed.dial
? Number(committed.dial?.value)
: battleDial
}
disabled={busy || !!committed.dial}
onChange={setDial}
/>
<EcazOccupyDialExplanation game={g}
dial={committed.dial ? Number(committed.dial.value) : battleDial}
support={battleFreeSupport ? 0 : battleSupport} />
<p className="fine">
{g.battle.noFieldPlayers.includes(me.id)
? g.battle.mixedNoField
? `Provisional ${g.advanced ? 'Advanced' : 'Basic'} mixed No-Field ${occupyProfile ? 'variable ' : ''}pool: ${battleForces} fighters, combining forces already here with the token's reserve-limited materialization. ${occupyProfile ? 'Fixed Ecaz strength is separate. ' : ''}This estimate is private; the mixed-pool formula is not an adjudicated publisher ruling.`
: `${battleForceOwnerName}’s concealed No-Field can materialize ${battleForces} physical forces from current reserves. This estimate is private.`
: occupyProfile
? `${battleForceOwnerName} supplies ${battleForces} physical fighters for the variable dial.`
: `You have ${battleForces} fighting forces here.`}
</p>
{!!ownBattleForces?.temporaryElite && (
<p className="notice">
{ownBattleForces.temporaryElite} ordinary counters count as Sardaukar for this battle. Their losses are ordinary counters, not starred Sardaukar.
</p>
)}
{ownBattleForces?.normalFreeSupport && (
<p className="notice">
The variable army’s Suboids fight at full strength without spice support this turn. Spice support here applies only to Cyborgs.
</p>
)}
{ownBattleForces?.eliteFreeSupport && (
<p className="notice">
Salusa Secundus is at high population. The variable army’s
Sardaukar fight at their full current strength
without spice support; normal forces still
require support.
</p>
)}
{g.advanced && !battleFreeSupport && (
<label htmlFor="battle-spice">
Spice support
{strongholdBankSupport > 0 && (
<span className="fine">
{' '}
· Bank pays{' '}
{Math.min(
battleSupport,
strongholdBankSupport,
)}
; personal and allied payment{' '}
{Math.max(
0,
battleSupport - strongholdBankSupport,
)}
</span>
)}
<Input
id="battle-spice"
type="number"
min={0}
max={battleSupportMaximum}
value={battleSupport}
onChange={(e) =>
setSupport(Number(e.target.value))
}
/>
</label>
)}
{g.advanced && g.aid.available > 0 && (
<>
<p className="fine">
CHOAM has reserved {g.aid.available} spice for
your support. Leave its share blank to cover
only your shortfall.
</p>
<label htmlFor="battle-ally-payment">
CHOAM’s share of this support payment
<Input
id="battle-ally-payment"
type="number"
min={0}
max={Math.min(
battleSupport,
g.aid.available,
)}
value={allyPayment}
onChange={(e) =>
setAllyPayment(e.target.value)
}
/>
</label>
</>
)}
<label htmlFor="battle-leader">
Leader <HelpTip topic="leader" />
<select
id="battle-leader"
value={
committed.leader
? String(committed.leader?.value ?? '')
: leader
}
disabled={!!committed.leader}
onChange={(e) => setLeader(e.target.value)}
>
<option value="">No leader</option>
{me.leaders
.filter(
(l) =>
!l.dead &&
controlsLeader(me, l) &&
(!l.usedAt ||
l.usedAt === g.battle?.territory),
)
.map((l) => (
<option key={l.id} value={l.id}>
{l.name} · {leaderStrengthLabel(l)}
</option>
))}
{me.hand
?.filter((c) => c.kind === 'hero')
.map((c) => (
<option key={c.id} value={c.id}>
{c.name} · 0
</option>
))}
</select>
</label>
<SpiceBankerControl
game={g}
selectedLeader={selectedBattleLeader}
ownSupport={ownBattleSupport}
value={battleBankerSpice}
onChange={setBankerSpice}
disabled={busy}
/>
{[
['Weapon', weapon, setWeapon],
['Defense', defense, setDefense],
].map(([name, v, fn]) => (
<label
key={String(name)}
htmlFor={`battle-${String(name).toLowerCase()}`}
>
{String(name)}{' '}
<HelpTip
topic={
name === 'Weapon' ? 'weapon' : 'defense'
}
/>
<select
id={`battle-${String(name).toLowerCase()}`}
value={
battlePlanCardValue(g, name === 'Weapon' ? 'weapon' : 'defense', v as string)
}
disabled={
!!committed[name === 'Weapon' ? 'weapon' : 'defense'] &&
committed[name === 'Weapon' ? 'weapon' : 'defense']!.value !== null
}
onChange={(e) =>
(fn as (s: string) => void)(e.target.value)
}
>
<option value="">None</option>
{battlePlanCardOptions(g, name === 'Weapon' ? 'weapon' : 'defense',
selectedBattleLeader, selectedWeaponId, selectedDefenseId)
.map((c) => (
<option key={c.id} value={c.id}>
{c.name}{name === 'Weapon' && planetologistSpecial(c)
? planetologistSpecialNeedsChoice(c) ? ' · normal or Planetologist role' : ' · Planetologist +2, discard after battle'
: ''}
</option>
))}
</select>
</label>
))}
{planetologistRoleChoice && (
<label className="my-3 flex items-start gap-3">
<input type="checkbox" checked={!!planetologistAlternate}
onChange={event => setPlanetologistDraft({ key: planetologistRoleKey, enabled: event.target.checked })} />
<span>Use {selectedBattleWeapon!.name} as Planetologist Special (+2 leader; no normal card effect)</span>
</label>
)}
{!(planetologistWeapon && selectedWeaponId === 'ecaz-harass-withdraw') &&
<HarassWithdrawGuide preview={g.battle.harassWithdraw} state={harassControl} />}
{g.homeworlds && g.battle.harassWithdraw && !(planetologistWeapon && selectedWeaponId === 'ecaz-harass-withdraw') && (
<p className="notice">
Homeworld timing: actual undialed counters
return to your native reserve Homeworlds, never to
the Tanks. In Advanced, Emperor normal forces return
to Kaitain and Sardaukar to Salusa; Basic retains
its original single primary World. Your original
dial/support stay committed across population
thresholds. Harass cannot be played on any own World.
</p>
)}
{g.battle.reinforcements && !(planetologistWeapon && selectedWeaponId === 'ecaz-reinforcements') && (
<p className="notice">
Reinforcements may occupy either card slot but is neither a weapon nor a defense.
It adds 2 to battle strength without changing the physical dial or spice support.
The cost is three reserve forces sent to the Tanks on any revealed outcome.
Available for that quote: {g.battle.reinforcements.normal} normal,{' '}
{g.battle.reinforcements.elite} elite.
The card is discarded even after a traitor call or explosion.
{g.battle.reinforcements.blocked && ` ${g.battle.reinforcements.blocked}`}
</p>
)}
{reinforcementsSelected && reinforcementsSourceChoices && (
<ReinforcementsSources game={g} choices={reinforcementsSourceChoices}
selected={reinforcementsSourceIndex} busy={busy}
onChange={index => setReinforcementsSourceDraft({ key: reinforcementsSourceKey, index })} />
)}
{g.battle.reinforcements?.noField && !(planetologistWeapon && selectedWeaponId === 'ecaz-reinforcements') && <p className="fine">
Provisional native order: your No-Field first materializes{' '}
{g.battle.reinforcements.noField.materializes} forces, leaving{' '}
{g.battle.reinforcements.noField.reservesAfterReveal} own reserves.
Three must remain after that reveal for Reinforcements;
a later Harass return cannot fund or change its original cost.
</p>}
{reinforcementsSelected &&
harassSelected && (
<p className="fine">
Provisional combined timing: pay the original three-reserve
Reinforcements cost before Harass &amp; Withdraw returns any
undialed forces. Returned reserves cannot fund or change that
cost. Both physical cards are discarded after use.
</p>
)}
{reinforcementsPlanReason && <p role="status" className="notice">{reinforcementsPlanReason}</p>}
{!battlePairValid && <p role="status" className="notice">Choose two compatible battle cards. A single physical card cannot fill both slots.</p>}
{selectedBattleWeapon && planetologistWeapon && (
<p className="notice">
{selectedBattleWeapon.name} adds 2 to the surviving Planetologist leader.
It makes no weapon attack and does not use its normal effect.
Discard it after this battle, even if you win.
</p>
)}
{g.expansions.includes('ix') &&
(selectedSpecialWeapon || selectedBattleWeapon?.kind === 'poisonTooth') && <p className="fine">
Provisional original Stone/Tooth order: Stone chooses
first, then uncopied Tooth. Mirror copies still choose
before their originals, as printed.
</p>}
{selectedSpecialWeapon && (
<div className="my-3 flex flex-col gap-3">
<p>
{mirrorSelected
? 'Mirror Weapon copies the opposing revealed weapon attack, not its physical card. If the copied attack is Stone Burner, it uses undialed physical tokens; any copied post-reveal choice is yours. A winning Mirror remains physical Mirror, even if it copies activated Poison Tooth or Artillery.'
: 'Stone Burner compares undialed physical tokens, not dialed strength. Its leader effect is chosen after both plans are revealed.'}
</p>
{reinforcementsSelected && (
<p className="fine">
Reinforcements still spends three original reserves.
Its +2 normal-score bonus cannot increase the undialed
physical tokens compared by original or copied Stone Burner.
</p>
)}
<CardInspector card={selectedSpecialWeapon} />
{stonePlanReason && (
<p className="notice" role="status">
{stonePlanReason}
</p>
)}
</div>
)}
{me.leaders.some((l) => l.capturedBy) && (
<p className="fine">
{me.leaders
.filter((l) => l.capturedBy)
.map(
(l) =>
`${l.name}: ${l.capturedBy === me.id ? 'your captive' : 'captured by Harkonnen'}`,
)
.join(' · ')}
</p>
)}
{me.kwisatz && (
<div className="space-y-2"><label>
<input
type="checkbox"
checked={kwisatz}
onChange={(e) => setKwisatz(e.target.checked)}
disabled={
!me.kwisatz.active ||
me.kwisatz.dead ||
g.battle.kwisatzBlocked ||
(!!me.kwisatz.usedAt &&
me.kwisatz.usedAt !== g.battle.territory)
}
/>
Use Kwisatz Haderach (+2; prevents traitors)
</label>
<KwisatzInspector state={me.kwisatz} usedTerritoryName={me.kwisatz.usedAt ? combatName(me.kwisatz.usedAt) : undefined} battle={{ territory: g.battle.territory, blocked: g.battle.kwisatzBlocked }} />
</div>
)}
{g.battle.fullPlan?.target === me.id && (
<p className="notice">
This commits your complete plan for Atreides’
private inspection. Your opponent’s plan stays
hidden until the public reveal.
</p>
)}
{actionButton(
g.battle.fullPlan?.target === me.id
? 'Commit plan for inspection'
: 'Seal battle plan',
bindBattlePlanCommitments(g, {
type: 'battlePlan',
...(planetologistAlternate ? { planetologistSpecial: true } : {}),
...(battleBankerSpice
? { bankerSpice: battleBankerSpice }
: {}),
...(g.aid.available > 0 && allyPayment !== ''
? { allyPayment: Number(allyPayment) }
: {}),
support:
battleFreeSupport
? 0
: battleSupport,
kwisatz: selectedBattleKwisatz,
dial: battleDial,
leader,
weapon,
defense,
...(reinforcementsSelected && selectedReserveSource
? { reinforcementsSources: selectedReserveSource } : {}),
}),
!!occupyControl?.blocked || !!stonePlanReason || !!harassControl.blocked || !!smugglerPlanReason || !!reinforcementsPlanReason || !battlePairValid,
)}
</>
) : (
<>
<p className="muted">
Your plan is sealed. Waiting for your opponent.
</p>
{!!g.battle.plans[me.id]?.bankerSpice && (
<p className="fine">
Spice Banker: {g.battle.plans[me.id].bankerSpice}{' '}
spice committed.
</p>
)}
{g.battle.plans[me.id]?.planetologistSpecial && (
<p className="fine">Planetologist alternate Special: +2 if the leader survives; no normal card effect.</p>
)}
{g.battle.plans[me.id]?.reinforcementsSources && (
<p className="fine">Sealed reserve fee: {reinforcementsSourceLabel(g, g.battle.plans[me.id].reinforcementsSources!)}</p>
)}
</>
)}
</>
)
) : g.battle.revealed &&
g.battle.traitorVoters.includes(me.id) ? (
<>
<p className="muted">
Your ally is fighting. You may reveal a traitor against
their opponent.
</p>
{!g.battle.traitorSubmitted.includes(me.id) ? (
<>
<TraitorDecisionPromises game={g} />
{actionButton(
'Reveal ally’s opponent as traitor',
{
type: 'traitorCall',
call: true,
},
!canRevealTraitor,
)}
{!canRevealTraitor && (
<p className="fine">{noTraitorReason}</p>
)}
{actionButton('Decline traitor support', {
type: 'traitorCall',
call: false,
}, !canDeclineTraitor)}
</>
) : (
<p className="muted">
Waiting for the other traitor decisions.
</p>
)}
</>
) : (
<p className="muted">
{g.battle.revealed
? 'The table is completing traitor decisions.'
: 'The combatants are preparing their plans.'}
</p>
)}
</>
)}
{[1, 2, 4, 7, 8].includes(g.phase) && !(g.phase === 4 && g.revivalOrder) &&
actionButton(
g.ready.includes(me.id)
? 'Waiting for the table'
: g.spiceWindow
? 'Accept spice blow'
: 'Ready for next phase',
{ type: 'ready' },
g.ready.includes(me.id),
)}
</>
)}
</aside>
</div>
<RevealedBattle game={g} />
{g.inflation && (
<p className="notice" role="status">
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'}
</p>
)}
{!reactionOwnsControls && <Bribes game={g} act={act} busy={busy} />}
<TruthHistory game={g} />
<HarkonnenExchangeInspection game={g} />
<section className="player-console">
<nav className="console-tabs">
{(['hand', 'leaders', 'log', 'rules'] as const).map((p) => (
<button
aria-pressed={panel === p}
className={panel === p ? 'selected' : ''}
onClick={() => setPanel(p)}
key={p}
>
{p === 'hand'
? `Your private hand · ${me.hand?.length ?? 0} / ${me.handLimit}`
: p === 'leaders'
? 'Your leaders'
: p === 'log'
? 'Table chronicle'
: 'Rules coverage'}
</button>
))}
</nav>
{panel === 'hand' && reactionOwnsControls ? (
<HandBrowser key={`${g.code}:${me.id}`} cards={me.hand ?? []} empty={<p className="muted">Your Treachery hand is empty.</p>}>
{(card) => (
<article className="treachery-card" key={card.id}>
<h3>{card.name}</h3>
<CardRules card={card} />
<CardInspector card={card} />
</article>
)}
</HandBrowser>
) : panel === 'hand' ? (
<>
<ChoamCashIn game={g} act={act} busy={busy} />
<RicheseSpecialKarama game={g} act={act} busy={busy} />
<EcazSpecialKarama quote={g.ecazSpecialKarama ?? null} act={act} busy={busy} />
<RicheseGift game={g} act={act} busy={busy} />
<Distrans game={g} act={act} busy={busy} />
<JuiceOfSapho game={g} act={act} busy={busy} />
<NullentropyBox game={g} act={act} busy={busy} />
<ResidualPoison game={g} act={act} busy={busy} />
<PortableSnooper game={g} act={act} busy={busy} />
<HandBrowser key={`${g.code}:${me.id}`} cards={me.hand ?? []} empty={
<p className="muted">
{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.'}
</p>
}>
{(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.'
: g.battle?.reinforcements?.blocked ?? 'Use either battle-card slot to play Reinforcements.'
: 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.karamaBlocked
? g.karamaBlocked
: g.response
? g.nexusTraitors?.pending
? 'Finish the private Nexus Traitor card return before responding to this power.'
: !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.'
: kullPurposeBlock(g, 'auction-free-purchase')
? kullPurposeBlock(g, 'auction-free-purchase')
: 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 (
<article
className={`treachery-card card-${richese ? presentation.role : c.kind}`}
key={c.id}
>
<span className="eyebrow">
{richese
? presentation.category
: battleCardLabel(c.kind)}
</span>
<h3>{c.name}</h3>
<CardRules card={c} />
<CardInspector card={c} />
{availability && !availability.available && (
<p
className="card-unavailable"
id={`card-unavailable-${c.id}`}
>
{availability.reason}
</p>
)}
{!availability &&
g.status === 'setup' &&
!g.response &&
c.effect !== 'truthtrance' && (
<p className="fine">
Finish setup before using this card.
</p>
)}
<span className="card-mark">
{richese
? presentation.role === 'weapon'
? '†'
: presentation.role === 'defense'
? '◇'
: '✧'
: c.kind === 'special'
? '✧'
: c.kind === 'shield' || c.kind === 'snooper'
? '◇'
: '†'}
</span>
{canUseAsTruthtranceRole(g, me, c) && (
<>
<p className="fine">
{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.
</p>
<Button
variant="outline"
disabled={
busy ||
!!g.truthtrance ||
!['setup', 'playing'].includes(g.status)
}
onClick={() =>
act({
type: 'card',
card: c.id,
...(c.effect === 'karama'
? { shrineTruthtrance: [c.id] }
: {}),
})
}
>
{c.effect === 'karama'
? 'Use Karama as Truthtrance'
: 'Declare Truthtrance'}
</Button>
{(me.hand?.filter(
(other) => canUseAsTruthtranceRole(g, me, other),
).length ?? 0) > 1 && (
<Button
variant="outline"
disabled={
busy ||
!!g.truthtrance ||
!['setup', 'playing'].includes(g.status)
}
onClick={() =>
act({
type: 'card',
card: c.id,
cards: me
.hand!.filter(
(other) =>
canUseAsTruthtranceRole(g, me, other) &&
other.id !== c.id,
)
.map((other) => other.id),
shrineTruthtrance: me
.hand!.filter(
(other) =>
canUseAsTruthtranceRole(g, me, other) &&
other.effect === 'karama',
)
.map((other) => other.id),
})
}
>
Declare both Truthtrances
</Button>
)}
</>
)}
{c.effect === 'amal' && (
<p className="fine">
At a phase opening, every faction returns half its
available spice to the bank, rounded up. Play before
everyone passes.
</p>
)}
{c.effect === 'thumper' && (
<p className="fine">
Play before the first spice draw to resolve Shai-Hulud
without drawing a card. On later turns, Fremen protection,
rides, Nexus and a waiting Sandtrout apply.{' '}
{g.turn === 1 ? <>First-turn preview policy: spending
Thumper discards it, but its worm encounter is ignored.
Normal spice drawing continues.</> : g.sandtrout ? <>
A waiting Sandtrout will suppress this encounter. Only
an immediate territory replacement receives double spice;
another worm instead resolves normally.</> : null}
</p>
)}
{c.effect === 'ghola' && (
<>
{g.ghola.eliteBlock && (
<p className="fine">{g.ghola.eliteBlock}</p>
)}
{homeworldRevivalActionBlock(g, { type: 'card', card: c.id, amount, elite: eliteAmount, leader: leader || undefined }) && (
<p className="fine" aria-live="polite">
{homeworldRevivalActionBlock(g, { type: 'card', card: c.id, amount, elite: eliteAmount, leader: leader || undefined })}
</p>
)}
<label htmlFor={`ghola-${c.id}`}>
Forces to revive (or choose a leader)
<Input
id={`ghola-${c.id}`}
type="number"
min={1}
max={g.ghola.maxForces}
value={amount}
onChange={(e) => setAmount(Number(e.target.value))}
/>
</label>
{me.elites && (
<EliteCount
label={
me.faction === 'ixians'
? 'Cyborgs to revive'
: 'Elite forces to revive'
}
value={eliteAmount}
onChange={setEliteAmount}
max={Math.min(amount, g.ghola.eliteRemaining)}
/>
)}
<label>
Revival target
<select
value={leader}
onChange={(e) => setLeader(e.target.value)}
>
<option value="">Forces</option>
{g.ghola.leaders.map((l) => (
<option key={l.id} value={l.id}>
{l.name}{l.id === 'duke-vidal' ? ' · set aside after revival' : ''}
</option>
))}
{g.ghola.kwisatz && (
<option value="kwisatz">Kwisatz Haderach</option>
)}
</select>
</label>
</>
)}
{g.advanced &&
canActivateKarama(c) &&
!me.specialKaramaUsed &&
me.faction === 'harkonnen' &&
g.phase === 3 && (
<>
<p className="muted">
Once per game: take up to four unseen cards, inspect
them, then return the same number.
</p>
<label>
Choose a player
<select
value={target}
onChange={(e) => setTarget(e.target.value)}
>
<option value="">Choose hand</option>
{g.players
.filter(
(p) =>
p.id !== me.id && (p.handCount ?? 0) > 0,
)
.map((p) => (
<option key={p.id} value={p.id}>
{p.name} · {p.handCount} cards
</option>
))}
</select>
</label>
<label htmlFor={`exchange-count-${c.id}`}>
Cards to take
<Input
id={`exchange-count-${c.id}`}
type="number"
min={1}
max={4}
value={amount}
onChange={(e) =>
setAmount(Number(e.target.value))
}
/>
</label>
<HelpTip topic="specialKarama" />
{actionButton(
'Use special Karama · exchange',
{
type: 'card',
mode: 'special',
card: c.id,
target,
amount,
},
!target,
)}
</>
)}
{g.advanced &&
canActivateKarama(c) &&
!me.specialKaramaUsed &&
me.faction === 'fremen' &&
g.phase === 1 && (
<>
<p className="muted">
Once per game: summon Shai-Hulud in a sand
territory. Resolve protection now; Nexus and rides
follow the spice blow.
</p>
<label htmlFor={`summon-worm-${c.id}`}>
Sand territory
<select
id={`summon-worm-${c.id}`}
value={
TERRITORIES.some(
(t) => t.id === selected && t.type === 'sand',
)
? selected
: ''
}
onChange={(e) => {
if (e.target.value) choose(e.target.value);
}}
>
<option value="">Choose territory</option>
{TERRITORIES.filter((t) => t.type === 'sand').map(
(t) => (
<option key={t.id} value={t.id}>
{t.name}
</option>
),
)}
</select>
</label>
<HelpTip topic="summonedWorm" />
{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 &&
canActivateKarama(c) &&
!me.specialKaramaUsed &&
me.faction === 'emperor' &&
g.phase === 4 && (
<>
<p className="muted">
Once per game: revive up to three forces or one
leader for free.
</p>
<label htmlFor={`special-revive-${c.id}`}>
Forces to revive
<Input
id={`special-revive-${c.id}`}
type="number"
min={1}
max={Math.min(3, me.tanks)}
value={amount}
onChange={(e) =>
setAmount(Number(e.target.value))
}
/>
</label>
{me.elites && (
<EliteCount
label="Sardaukar to revive"
value={eliteAmount}
max={Math.min(
1,
amount,
me.elites.tanks,
1 - me.elites.revived,
)}
onChange={setEliteAmount}
/>
)}
<HelpTip topic="specialKarama" />
{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) => (
<div key={l.id}>
{actionButton(
`Revive ${l.name} with special Karama`,
{
type: 'card',
mode: 'special',
card: c.id,
leader: l.id,
},
)}
</div>
))}
</>
)}
{c.effect === 'weather' && (
<label htmlFor="weather-distance">
Storm distance
<Input
id="weather-distance"
type="number"
min={0}
max={10}
value={weatherDistance}
onChange={(e) =>
setWeatherDistance(Number(e.target.value))
}
/>
</label>
)}
{canSpendKarama(c, 'shipment-price') &&
g.phase === 5 &&
g.active &&
(g.active === me.id ? shipmentAvailable : !g.players.find((p) => p.id === g.active)?.shipped) && (
<Button
variant="outline"
disabled={
busy ||
!!g.truthtrance ||
!!g.karamaShipping ||
!!g.response ||
!!g.decision
}
onClick={() =>
act({
type: 'card',
card: c.id,
mode: 'shipment',
target: g.active,
})
}
>
Guild rates for{' '}
{g.players.find((p) => p.id === g.active)?.name}
</Button>
)}
{(c.effect !== 'truthtrance' ||
canUseAsKaramaRole(g, me, c)) &&
(c.kind === 'special' ||
canUseAsKaramaRole(g, me, c)) && (
<Button
variant="outline"
aria-describedby={
availability && !availability.available
? `card-unavailable-${c.id}`
: undefined
}
disabled={
busy ||
!!g.truthtrance ||
(!!availability && !availability.available) ||
!!homeworldRevivalActionBlock(g, { type: 'card', card: c.id, amount, elite: eliteAmount, leader: leader || undefined }) ||
(g.status !== 'playing' &&
!(
g.status === 'setup' &&
g.response &&
canUseAsKaramaRole(g, me, c)
)) ||
(!!g.phaseOpening &&
(c.effect !== 'amal' ||
g.phaseOpening.passed.includes(me.id))) ||
(c.effect === 'amal' && !g.phaseOpening) ||
(c.effect === 'thumper' &&
(!g.beforeSpiceDraw ||
!!g.response ||
!!g.decision))
}
onClick={() =>
act({
type: 'card',
elite: eliteAmount,
card: c.id,
mode: canUseAsKaramaRole(g, me, c)
? g.response
? 'cancel'
: 'purchase'
: undefined,
amount:
c.effect === 'weather'
? weatherDistance
: c.effect === 'ghola'
? amount
: dial,
leader: leader || undefined,
})
}
>
{canUseAsKaramaRole(g, me, c)
? g.response
? 'Cancel this power'
: 'Take current auction card'
: 'Play card'}
</Button>
)}
</article>
);
}}
</HandBrowser>
{!!me.faceDancers?.length && (
<div className="notice">
<h3>Your Face Dancers</h3>
{me.faceDancers.map((c) => (
<div key={c.leader} className="traitor-identity">
<span>
{leaderName(c.leader)} ·{' '}
{c.revealed ? 'Revealed' : 'Secret'}
</span>
{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,
)}
</div>
))}
</div>
)}
{!!me.traitors?.length && (
<div className="traitor-list">
<span className="eyebrow">YOUR TRAITORS</span>
{me.traitors.map((id) => (
<div key={id} className="traitor-identity">
<span>{leaderName(id)}</span>
{inspectIdentity(id, 'traitor')}
</div>
))}
</div>
)}
</>
) : panel === 'leaders' ? (
<>
<p className="fine">
Inspect your leader identities here. Choose and commit a leader
through the battle controls when making a plan.
</p>
<div className="leader-roster">
{me.leaders.map((l) => (
<article key={l.id} className="leader-identity">
<LeaderPortrait
identityId={l.id}
name={l.name}
className="mx-auto size-20"
badge={
<>
<span className="sr-only">Leader strength </span>
{leaderStrengthLabel(l)}
</>
}
fallback={
<div className="leader-strength-disc">
<span className="sr-only">Leader strength </span>
{leaderStrengthLabel(l)}
</div>
}
/>
<p className="eyebrow">{faction(l.faction).name}</p>
<h3>{l.name}</h3>
<p>
{l.dead
? 'In the tanks'
: !controlsLeader(me, l)
? 'Controlled by another faction'
: l.usedAt
? 'Used in a battle this turn'
: 'Ready'}
</p>
<LeaderInspector
identity={{
id: l.id,
name: l.name,
factionName: faction(l.faction).name,
strength: leaderStrengthLabel(l),
}}
/>
</article>
))}
</div>
</>
) : panel === 'log' ? (
<ol className="game-log" aria-live="polite">
{[...g.log].reverse().map((e) => (
<li key={e.seq}>
<span>{String(e.seq).padStart(3, '0')}</span>
<div className="min-w-0 space-y-2">
<p className="m-0">{e.text.replace(
/used Karama to cancel fremenMovement\.$/,
'used Karama to cancel Fremen movement.',
)}</p>
<StormCardLogInspector component={e.component} />
</div>
</li>
))}
</ol>
) : (
<div className="coverage-copy">
<h3>Rules verification in progress</h3>
<p>
This build implements a playable portion of the basic game. It is
not yet a fully rules-compliant edition.
</p>
<p>
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.
</p>
<a href="/rules">
Open the rules coverage ledger <ArrowRight size={14} />
</a>
</div>
)}
</section>
<VictoryProgress
progress={g.victoryProgress}
players={g.players}
fremen={g.fremenVictory}
ecazHomeworld={g.ecazHomeworldVictory}
/>
{g.strongholdCards && (
<details className="m-4 rounded-xl border border-[#a88b60] p-4">
<summary className="cursor-pointer text-lg">
Inspect Stronghold Cards
</summary>
<StrongholdCardGallery
state={g.strongholdCards}
players={g.players}
/>
</details>
)}
<footer>
<span>Unofficial fan implementation · Development build</span>
<span>Board geometry: Truthsayer / MIT</span>
</footer>
</main>
);
}