Enable Sandmaster collection on flight and marker routes

This commit is contained in:
Quintine committed 2026-10-26 10:11:18 +11:00
1 parent 4af90e547c
commit ec549028ba
20 files changed
+1623 -165

No files matched your search

+81 -84
View File
@@ -8,75 +8,31 @@ import {
splitLocation,
} from '@/game/board';
import {
quoteSandmasterMovement,
sandmasterActionOrder,
sandmasterCollectionPiles,
sandmasterDefaultChoice,
sandmasterLeader,
sandmasterModeSupported,
sandmasterPathBlocked,
sandmasterAdjacent,
sandmasterRouteDistance,
type SandmasterChoice,
} from '@/game/sandmaster-movement';
import {
planetologistFlightSelection as movementFlightSelection,
type PlanetologistFlight as MovementFlight,
} from '@/game/planetologist-flight';
import {
SandmasterMovementAction, sandmasterMoveDraft, sandmasterMovementBlocked, sandmasterMovementSelection,
} from './sandmaster-movement-action';
import { Button } from './ui/button';
export { sandmasterMoveDraft } from './sandmaster-movement-action';
type RouteDraft = {
key: string;
routes: Record<string, string[]>;
declined: string[];
};
export function sandmasterMoveDraft(
game: GameView,
move: Action,
routes?: Record<string, string[]>,
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 =
@@ -85,28 +41,43 @@ function placeName(game: GameView, key: string) {
return `${name}, sector ${at.sector}`;
}
export function SandmasterMovement({
game,
move,
act,
busy,
unavailableReason,
}: {
type Props = {
game: GameView;
move: Action | null;
act: (action: Action) => void;
busy: boolean;
unavailableReason?: string | null;
}) {
};
export function SandmasterMovement(props: Props) {
const me = props.game.players.find(player => player.id === props.game.me);
return <SandmasterMovementDraft
key={JSON.stringify([props.game.me, props.game.turn, me?.moved,
props.game.ornithopter?.card?.id, props.game.ornithopter?.active?.event,
props.game.ornithopter?.active?.completed, props.game.discoveryOrnithopter?.token,
props.game.richeseNoField?.owner === props.game.me ? props.game.richeseNoField.event : null])}
{...props}
/>;
}
function SandmasterMovementDraft({
game,
move,
act,
busy,
unavailableReason,
}: Props) {
const id = useId();
const [flight, setFlight] = useState<MovementFlight | undefined>(undefined);
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 selected = sandmasterMovementSelection(game, move, flight);
const defaults = selected.move ? sandmasterDefaultChoice(game, game.me, selected.move) : null;
const order = selected.move ? sandmasterActionOrder(game, game.me, selected.move) : null;
const draftKey = JSON.stringify([
game.turn,
me?.moved ?? 0,
move,
selected.move,
defaults?.routes,
]);
const [saved, setSaved] = useState<RouteDraft | null>(null);
@@ -114,21 +85,19 @@ export function SandmasterMovement({
!me ||
!leader ||
!sandmasterModeSupported(game) ||
!move ||
!defaults ||
!order
!move
)
return null;
const routes = saved?.key === draftKey ? saved.routes : defaults.routes;
const routes = saved?.key === draftKey ? saved.routes : defaults?.routes ?? {};
const declined = saved?.key === draftKey ? saved.declined : [];
const draft = sandmasterMoveDraft(game, move, routes, declined);
const draft = sandmasterMoveDraft(game, move, Object.keys(routes).length ? routes : undefined, declined, flight);
const updateRoutes = (next: Record<string, string[]>) =>
setSaved({ key: draftKey, routes: next, declined });
const blocked = unavailableReason ?? draft.blocked;
const blocked = sandmasterMovementBlocked(game, draft, unavailableReason, selected.flight);
const leaderName =
game.allLeaders.find((candidate) => candidate.id === leader)?.name ??
leader;
const destination = location(order.to, order.sector);
const destination = order ? location(order.to, order.sector) : null;
return (
<section
className="my-4 flex min-w-0 flex-col gap-3"
@@ -141,7 +110,42 @@ export function SandmasterMovement({
that collection, and select the exact sector route for every source.
</p>
<p className="fine">Provisional multi-pile policy: you choose which sector supplies the one spice; this is not a publisher allocation ruling.</p>
{order.group.map(([from]) => {
{(game.ornithopter || game.discoveryOrnithopter) && (
<>
<label htmlFor={`${id}-flight`}>Movement transport</label>
<select id={`${id}-flight`} value={game.ornithopter?.active?.mode ?? selected.flight}
disabled={busy || !!game.ornithopter?.active}
onChange={event => setFlight(event.target.value as MovementFlight)}>
{(['ordinary', 'range3', 'twoGroups', 'discovery'] as const).map(choice => {
const offer = movementFlightSelection(game, choice);
const label = choice === 'ordinary' ? 'Ordinary movement' : choice === 'range3'
? 'Ornithopter card: fixed range three' : choice === 'twoGroups'
? 'Ornithopter card: two different movements' : 'Carried Discovery Ornithopter';
return <option key={choice} value={choice} disabled={!!offer.blocked}>
{label}{offer.blocked ? ' — unavailable' : ''}
</option>;
})}
</select>
<p className="fine" aria-label="Provisional Sandmaster flight composition">
Provisional flight composition: the fixed-range card and Discovery
flight allow routes up to three territories. Each two-group card
movement keeps normal range and uses different original forces.
Choose each actual route and optional spice collection before moving.
Only one flight source may be used; Planetologist remains separate.
</p>
{game.ornithopter?.active && !selected.transport.blocked && <p className="notice">
{game.ornithopter.active.completed} card movements completed ·{' '}
{game.ornithopter.active.remaining} remaining. Use the original unmoved cohort.
</p>}
</>
)}
{order?.noField && <p className="fine" aria-label="Provisional Sandmaster concealed marker">
Provisional marker composition: your concealed No-Field moves as one
force, including zero, and may collect along its route without revealing
its value. A marker and physical forces in the same source sector share
one route and collect only once per entered territory.
</p>}
{order && Object.keys(defaults?.routes ?? routes).map((from) => {
const route = routes[from] ?? [from];
const last = route.at(-1)!;
const extensions =
@@ -185,10 +189,10 @@ export function SandmasterMovement({
disabled={
busy ||
JSON.stringify(route) ===
JSON.stringify(defaults.routes[from])
JSON.stringify(defaults?.routes[from])
}
onClick={() =>
updateRoutes({ ...routes, [from]: defaults.routes[from] })
defaults && updateRoutes({ ...routes, [from]: defaults.routes[from] })
}
>
Use shortest route
@@ -259,16 +263,9 @@ export function SandmasterMovement({
{blocked}
</output>
)}
<Button
className="min-h-11 whitespace-normal motion-reduce:transition-none"
disabled={busy || !!blocked || !draft.action}
aria-describedby={blocked ? `${id}-reason` : undefined}
onClick={() => {
if (!busy && !blocked && draft.action) act(draft.action);
}}
>
Move with Sandmaster
</Button>
<SandmasterMovementAction game={game} move={move} routes={Object.keys(routes).length ? routes : undefined}
declined={declined} flight={flight} busy={busy} unavailableReason={unavailableReason}
reasonId={`${id}-reason`} act={act} />
<p className="fine">
Use the normal Move forces button to decline Sandmaster. A canceled
movement collects no spice.