'use client'; import { useId, useState } from 'react'; import type { Action, GameView } from '@/game/engine'; import { location, splitLocation, territory } from '@/game/board'; import { sandmasterDestinationCollection } from '@/game/sandmaster-movement'; import { quoteAdvisorAmbassadorMatching, AdvisorAmbassadorMatchingError } from '@/game/advisor-ambassador-matching'; import { Button } from './ui/button'; import { Input } from './ui/input'; const bounded = (value: number | undefined, minimum: number, maximum: number) => Math.max( minimum, Math.min(maximum, Number.isSafeInteger(value) ? value! : minimum), ); /** A draft belongs to this Ambassador event, never to the entrant's normal move. */ export function AmbassadorMovement({ game, act, busy, }: { game: GameView; act: (action: Action) => void; busy: boolean; }) { const id = useId(); const [sourceId, setSourceId] = useState(''); const [destinationKey, setDestinationKey] = useState(''); const [forces, setForces] = useState>({}); const [elites, setElites] = useState>({}); const [takeMarker, setTakeMarker] = useState(false); const [fighters, setFighters] = useState(false); const [collectSandmaster, setCollectSandmaster] = useState(true); const [sandmasterSelection, setSandmasterSelection] = useState<{ source: string; target: string; key: string; } | null>(null); const entry = game.ambassadorEntry; if ( entry?.stage !== 'move' || entry.beneficiary !== game.me || game.decision?.kind !== 'ecazAmbassador' || game.decision.player !== game.me || !entry.movement ) return null; const sources = entry.movement.sources; const source = sources.find((s) => s.territory === sourceId) ?? sources[0]; if (!source) return (

No legal relocation is currently available.

); const destinations = source.destinations; const destination = destinations.find( (d) => location(d.territory, d.sector) === destinationKey, ) ?? destinations.find((d) => !d.blocked) ?? destinations[0]; const target = destination ? location(destination.territory, destination.sector) : ''; const rows = source.sectors.map((s) => { const count = s.key === target ? 0 : bounded(forces[s.key], 0, s.forces); const minimum = Math.max(0, count - (s.forces - s.elites)); return { ...s, count, minimum, maximum: Math.min(count, s.elites), elite: bounded(elites[s.key], minimum, Math.min(count, s.elites)), }; }); const selectedForces = Object.fromEntries( rows.filter((s) => s.count > 0).map((s) => [s.key, s.count]), ); const selectedElites = Object.fromEntries( rows.filter((s) => s.count > 0).map((s) => [s.key, s.elite]), ); const total = rows.reduce((n, s) => n + s.count, 0); const markerAtTarget = !!source.marker && location(source.territory, source.marker.sector) === target; const includesMarker = !!source.marker && takeMarker && !markerAtTarget; const sandmaster = !includesMarker && destination ? sandmasterDestinationCollection(game, game.me, source.territory, destination.territory, destination.sector) : null; const sandmasterPile = sandmasterSelection?.source === source.territory && sandmasterSelection.target === target && sandmaster?.piles.some((pile) => pile.key === sandmasterSelection.key) ? sandmasterSelection.key : sandmaster?.key; let matchingBlock:string|null=null; if(destination&&total>0){ try{ quoteAdvisorAmbassadorMatching({...game,pendingAmbassador:{...entry,turn:game.turn,phase:game.phase, beneficiary:game.me},advisorAmbassadorMatchingDenied:entry.matchingDenied}, {parent:entry.event,owner:game.me,origin:source.territory,destination:destination.territory, sector:destination.sector,advisors:destination.advisors,group:selectedForces}); }catch(error){if(!(error instanceof AdvisorAmbassadorMatchingError))throw error;matchingBlock=error.message;} } const reason = destination?.blocked ?? matchingBlock ?? (!destination ? 'Choose an available destination.' : !total && !includesMarker ? 'Choose physical forces or the concealed No-Field to relocate.' : destination.maximum !== null && total + Number(includesMarker) > destination.maximum ? `At most ${destination.maximum} entering forces are currently supported at this destination; the concealed marker counts as one.` : null); const chooseDestination = (key: string) => { setDestinationKey(key); setForces((old) => ({ ...old, [key]: 0 })); setElites((old) => ({ ...old, [key]: 0 })); setFighters(false); setSandmasterSelection(null); }; const targetTerritories = [...new Set(destinations.map((d) => d.territory))]; return (

Relocate a group from one territory. This does not spend your ordinary shipment or movement. The Ambassador has already been triggered.

{game.advanced&&game.players.find(player=>player.id===game.me)?.faction==='beneGesserit'&&(

An opposite-type incoming group has a separate matching response before relocation. If matching is prevented, choose another legal packet or destination; the original trigger stays committed.

)} {rows.map((s) => (
Sector {splitLocation(s.key).sector} · {s.forces} physical forces available {s.elites > 0 && ( )} {s.key === target && (

Forces already in the destination sector stay in place.

)}
))} {source.marker && (

The marker can move alone or with physical forces. Its concealed value stays unchanged. {markerAtTarget ? ' It is already in the destination sector and stays in place.' : ''}

)} {destination?.advisors && (

This group arrives as advisors.

)} {destination?.canFight && ( )} {sandmaster && ( )} {sandmaster && !sandmaster.blocked && sandmaster.piles.length > 1 && ( )} {sandmaster && !sandmaster.blocked && sandmaster.piles.length > 1 && (

You choose the one-spice debit sector. This provisional allocation policy is not a publisher ruling.

)}

Selected: {total} physical forces {includesMarker ? ' and one concealed No-Field' : ''}.

{reason && (

{reason}

)}
); }