'use client'; import { useId, useState } from 'react'; import type { Action, GameView } from '@/game/engine'; import { gameTerritories, location, splitLocation, } from '@/game/board'; import { quoteSandmasterMovement, sandmasterActionOrder, sandmasterCollectionPiles, sandmasterDefaultChoice, sandmasterLeader, sandmasterModeSupported, sandmasterPathBlocked, sandmasterAdjacent, sandmasterRouteDistance, type SandmasterChoice, } from '@/game/sandmaster-movement'; import { Button } from './ui/button'; type RouteDraft = { key: string; routes: Record; declined: string[]; }; export function sandmasterMoveDraft( game: GameView, move: Action, routes?: Record, declined: readonly string[] = [], ): { action: Action | null; choice: SandmasterChoice | null; collectible: string[]; blocked: string | null; } { const defaults = sandmasterDefaultChoice(game, game.me, move); const order = sandmasterActionOrder(game, game.me, move); if (!defaults || !order) return { action: null, choice: null, collectible: [], blocked: 'Sandmaster needs a supported ordinary force movement and its living native trainer.', }; const selectedRoutes = routes ?? defaults.routes; const offered = sandmasterCollectionPiles(game, selectedRoutes); const collectible = Object.values(offered).flat(); const choice = { routes: selectedRoutes, collect: Object.values(offered).map(piles => piles.find(key => !declined.includes(key))) .filter((key): key is string => key !== undefined), }; try { quoteSandmasterMovement(game, game.me, order, choice); return { action: { ...move, sandmaster: choice }, choice, collectible, blocked: null, }; } catch (error) { return { action: null, choice, collectible, blocked: error instanceof Error ? error.message : 'Choose a legal Sandmaster route.', }; } } function placeName(game: GameView, key: string) { const at = splitLocation(key); const name = gameTerritories(game).find((candidate) => candidate.id === at.territory) ?.name ?? at.territory; return `${name}, sector ${at.sector}`; } export function SandmasterMovement({ game, move, act, busy, unavailableReason, }: { game: GameView; move: Action | null; act: (action: Action) => void; busy: boolean; unavailableReason?: string | null; }) { const id = useId(); const me = game.players.find((player) => player.id === game.me); const leader = sandmasterLeader(game, game.me); const defaults = move ? sandmasterDefaultChoice(game, game.me, move) : null; const order = move ? sandmasterActionOrder(game, game.me, move) : null; const draftKey = JSON.stringify([ game.turn, me?.moved ?? 0, move, defaults?.routes, ]); const [saved, setSaved] = useState(null); if ( !me || !leader || !sandmasterModeSupported(game) || !move || !defaults || !order ) return null; const routes = saved?.key === draftKey ? saved.routes : defaults.routes; const declined = saved?.key === draftKey ? saved.declined : []; const draft = sandmasterMoveDraft(game, move, routes, declined); const updateRoutes = (next: Record) => setSaved({ key: draftKey, routes: next, declined }); const blocked = unavailableReason ?? draft.blocked; const leaderName = game.allLeaders.find((candidate) => candidate.id === leader)?.name ?? leader; const destination = location(order.to, order.sector); return (

Sandmaster movement

{leaderName} may collect 1 spice in each distinct territory these forces enter or pass through. Choose one existing pile per territory, or decline that collection, and select the exact sector route for every source.

Provisional multi-pile policy: you choose which sector supplies the one spice; this is not a publisher allocation ruling.

{order.group.map(([from]) => { const route = routes[from] ?? [from]; const last = route.at(-1)!; const extensions = last === destination ? [] : sandmasterAdjacent(game, last).filter( (next) => !route.includes(next) && !sandmasterPathBlocked(game, game.me, from, next) && sandmasterRouteDistance(game, [...route, next]) <= order.range, ); return (
{placeName(game, from)} route

{route.map((key) => placeName(game, key)).join(' → ')} ·{' '} {sandmasterRouteDistance(game, route)} / {order.range} territories

{last !== destination && ( )}
); })} {draft.collectible.length ? (
Optional spice collection {draft.collectible.map((key) => ( ))}
) : (

This route enters no territory with positive spice. You may still declare the route without collecting.

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

Use the normal Move forces button to decline Sandmaster. A canceled movement collects no spice.

); }