'use client'; import { useId, useState } from 'react'; import type { Action, GameView } from '@/game/engine'; import { planetologistLeader, planetologistMovementModeSupported, selectedOriginElites, planetologistGatherAdvisorEntry, type PlanetologistMoveMode, } from '@/game/planetologist-movement'; import { botGroundMoveAllowed } from '@/game/bot-mobility'; import { isAdvisor } from '@/game/advisors'; import { quoteAdvisorGatherMatching, AdvisorGatherMatchingError } from '@/game/advisor-gather-matching'; import { gameTerritories, location, splitLocation, territory, } from '@/game/board'; import { Button } from './ui/button'; import { Input } from './ui/input'; type Counts = Record; export function planetologistMoveDraft( game: GameView, mode: PlanetologistMoveMode, destination: string, sector: number, forces: Counts, elites: Counts, ): { action: Action | null; blocked: string | null } { const me = game.players.find((player) => player.id === game.me); if (!me || !planetologistLeader(game, me.id)) return { action: null, blocked: 'A living, uncaptured Planetologist is required.', }; if ( !planetologistMovementModeSupported(game) ) return { action: null, blocked: 'Planetologist movement with expansion factions awaits its movement audit.', }; const selected = Object.entries(forces).filter(([, count]) => count > 0); for (const [from, count] of selected) { const owned = me.forces[from] ?? 0; if (!Number.isSafeInteger(count) || count < 1 || count > owned) return { action: null, blocked: 'Choose available whole force counts.' }; const elite = elites[from] ?? 0; const ownedElite = me.elites?.forces[from] ?? 0; const minimumElite = Math.max(0, count - (owned - ownedElite)); if ( !Number.isSafeInteger(elite) || elite < minimumElite || elite > Math.min(count, ownedElite) ) return { action: null, blocked: 'Choose an available normal and elite mix for every source.', }; } const origins = new Set( selected.map(([from]) => splitLocation(from).territory), ); if ( (mode === 'range' && origins.size !== 1) || (mode === 'gather' && origins.size !== 2) ) return { action: null, blocked: mode === 'range' ? 'Select forces from one territory.' : 'Select forces from exactly two different territories.', }; const entry = planetologistGatherAdvisorEntry(game, me, [...origins], destination); if (entry.blocked) return { action: null, blocked: entry.blocked }; const target = location(destination, sector); if ( selected.some( ([from]) => !botGroundMoveAllowed(game, me, from, target, selectedOriginElites(selected, elites, splitLocation(from).territory), mode), ) ) return { action: null, blocked: mode === 'range' ? 'The selected route is beyond the boosted range or is blocked.' : 'Each selected source must reach the destination using its normal range.', }; if(mode==='gather'){ try{ quoteAdvisorGatherMatching(game,{owner:me.id,move:me.moved??0,destination,sector, advisors:isAdvisor(me,destination),group:Object.fromEntries(selected)}); }catch(error){if(!(error instanceof AdvisorGatherMatchingError))throw error;return {action:null,blocked:error.message};} } return { action: { type: 'move', planetologist: mode, ...(entry.requiresFlip ? { fighters: true } : {}), forces: Object.fromEntries(selected), ...(me.elites ? { eliteForces: Object.fromEntries( selected.map(([from]) => [from, elites[from] ?? 0]), ), } : {}), territory: destination, sector, }, blocked: null, }; } export function PlanetologistMovement({ game, act, busy, destination, sector, unavailableReason, }: { game: GameView; act: (action: Action) => void; busy: boolean; destination: string; sector: number; unavailableReason?: string | null; }) { const id = useId(); const [mode, setMode] = useState('range'); const [forces, setForces] = useState({}); const [elites, setElites] = useState({}); const me = game.players.find((player) => player.id === game.me); const leader = me && planetologistLeader(game, me.id); if (!me || !leader) return null; const sources = Object.entries(me.forces).filter(([, count]) => count > 0); const draft = planetologistMoveDraft( game, mode, destination, sector, forces, elites, ); const blocked = unavailableReason ?? (game.phase !== 5 || game.active !== me.id ? 'Planetologist movement is available only during your movement turn.' : game.ornithopter?.active ? 'Finish the current Ornithopter movement first.' : game.truthtrance || game.nexusTraitors?.pending ? 'Resolve the current private response first.' : me.autopilot || game.automaticContinuationPending ? 'Wait for the current automatic action to finish.' : draft.blocked); const leaderName = game.allLeaders.find((candidate) => candidate.id === leader)?.name ?? leader; return (

Planetologist movement

{leaderName} may add one territory to one movement, up to three, or gather forces from exactly two territories into the selected destination.

{game.advanced&&me.faction==='beneGesserit'&&mode==='gather'&&(

When joining your residents, each incoming origin that changes type has its own matching response. Both groups wait for every required allowance. Prevention leaves the movement unused and keeps both groups in place.

)}

Destination: {territory(destination).name}, sector {sector}. Select the physical forces to move below.

{draft.action?.fighters === true && (

Provisional mixed-gather policy: both groups arrive as fighters. The advisor flip is declared before either group moves. If canceled, both groups stay in place and no movement is spent; flipping remains blocked for that movement opportunity.

)} {me.faction === 'ixians' && (

Each origin uses its own selected cyborgs for movement range. Cyborgs from one territory cannot carry suboids from the other territory farther.

)} {sources.length ? ( sources.map(([from, owned]) => { const source = splitLocation(from); const sourceName = gameTerritories(game).find( (candidate) => candidate.id === source.territory, )?.name ?? source.territory; const selected = forces[from] ?? 0; const ownedElite = me.elites?.forces[from] ?? 0; const minimumElite = Math.max(0, selected - (owned - ownedElite)); return (
{me.elites && ( )}
); }) ) : (

No board forces are available to move.

)} {blocked && ( {blocked} )}

This does not combine with a No-Field or either Ornithopter movement. Normal movement and arrival rules still apply.

); }