Enable Sandmaster collection on flight and marker routes
This commit is contained in:
1 parent
4af90e547c
commit
ec549028ba
20 files changed
+1623
-165
No files matched your search
@@ -4977,14 +4977,10 @@ export function GameTable({
|
||||
act={act}
|
||||
busy={busy}
|
||||
unavailableReason={
|
||||
!movementAvailable
|
||||
!movementAvailable && !g.ornithopter?.active
|
||||
? 'No ordinary movement is currently available.'
|
||||
: (me.moved ?? 0) >= (me.movesAllowed ?? 1)
|
||||
: !g.ornithopter?.active && (me.moved ?? 0) >= (me.movesAllowed ?? 1)
|
||||
? 'No ordinary movement remains this turn.'
|
||||
: ordinaryMovementRouteBlocked
|
||||
? 'Choose an unblocked source and route outside the storm.'
|
||||
: canceledFremenRouteBlocked
|
||||
? 'Choose an ordinary route within your available range.'
|
||||
: selectedDestinationInStorm
|
||||
? 'Choose a destination outside the storm.'
|
||||
: liveShipmentPromises(
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
'use client';
|
||||
|
||||
import type { Action, GameView } from '@/game/engine';
|
||||
import {
|
||||
quoteSandmasterMovement, sandmasterActionOrder, sandmasterCollectionPiles, sandmasterDefaultChoice,
|
||||
type SandmasterChoice,
|
||||
} from '@/game/sandmaster-movement';
|
||||
import {
|
||||
planetologistFlightSelection as movementFlightSelection,
|
||||
type PlanetologistFlight as MovementFlight,
|
||||
} from '@/game/planetologist-flight';
|
||||
import { nexusGuildMovementAvailable } from '@/game/nexus-guild-cunning-options';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
export type { PlanetologistFlight as MovementFlight } from '@/game/planetologist-flight';
|
||||
const flightFields = ['movementCard', 'ornithopter', 'ornithopterEvent', 'discoveryOrnithopter'] as const;
|
||||
|
||||
/** Apply only the currently projected selected transport to the parent's group. */
|
||||
export function sandmasterMovementSelection(game: GameView, move: Action | null, flight?: MovementFlight) {
|
||||
const movementMode = move?.ornithopter;
|
||||
const selectedFlight: MovementFlight = flight ?? (move?.discoveryOrnithopter !== undefined ? 'discovery'
|
||||
: movementMode === 'range3' || movementMode === 'twoGroups' ? movementMode : 'ordinary');
|
||||
const transport = movementFlightSelection(game, selectedFlight);
|
||||
const fail = (blocked: string) => ({ move: null, flight: selectedFlight, transport, blocked });
|
||||
if (!move || move.type !== 'move') return fail('Select a current movement group and destination.');
|
||||
if (move.planetologist !== undefined) return fail('Use Sandmaster without combining it with Planetologist.');
|
||||
if (transport.blocked) return fail(transport.blocked);
|
||||
if (flightFields.some(key => move[key] !== undefined && move[key] !== transport.fields[key]))
|
||||
return fail('Choose one current flight source, card mode or active movement event.');
|
||||
const selected: Action = { ...move, ...transport.fields };
|
||||
delete selected.sandmaster;
|
||||
return { move: selected, flight: selectedFlight, transport, blocked: null };
|
||||
}
|
||||
|
||||
export function sandmasterMoveDraft(game: GameView, move: Action,
|
||||
routes?: Record<string, string[]>, declined: readonly string[] = [], flight?: MovementFlight): {
|
||||
action: Action | null; choice: SandmasterChoice | null; collectible: string[]; blocked: string | null;
|
||||
} {
|
||||
const selected = sandmasterMovementSelection(game, move, flight);
|
||||
if (!selected.move) return { action: null, choice: null, collectible: [], blocked: selected.blocked };
|
||||
const defaults = sandmasterDefaultChoice(game, game.me, selected.move);
|
||||
const order = sandmasterActionOrder(game, game.me, selected.move);
|
||||
if (!defaults || !order) return { action: null, choice: null, collectible: [],
|
||||
blocked: 'Sandmaster needs a current legal force or owned marker group, route and 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: { ...selected.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.' };
|
||||
}
|
||||
}
|
||||
|
||||
export function sandmasterMovementBlocked(game: GameView,
|
||||
draft: ReturnType<typeof sandmasterMoveDraft>, unavailableReason?: string | null,
|
||||
flight: MovementFlight = 'ordinary'): string | null {
|
||||
const me = game.players.find(player => player.id === game.me);
|
||||
const transport = movementFlightSelection(game, flight);
|
||||
const currentCard = !transport.blocked && typeof transport.fields.ornithopterEvent === 'string';
|
||||
return unavailableReason ||
|
||||
(!me || game.status !== 'playing' || game.phase !== 5 || game.active !== me.id
|
||||
? 'Sandmaster movement is available only during your movement turn.'
|
||||
: game.response || game.decision || game.phaseOpening || game.truthtrance ||
|
||||
game.nexusTraitors?.pending || game.nexusCards?.waiting.length
|
||||
? 'Resolve the current response or decision first.'
|
||||
: me.autopilot || game.automaticContinuationPending
|
||||
? 'Wait for the current automatic action to finish.'
|
||||
: transport.blocked
|
||||
? transport.blocked
|
||||
: !currentCard && !nexusGuildMovementAvailable(game)
|
||||
? 'No movement remains this turn.' : draft.blocked);
|
||||
}
|
||||
|
||||
export interface SandmasterMovementActionProps {
|
||||
game: GameView;
|
||||
move: Action | null;
|
||||
routes?: Record<string, string[]>;
|
||||
declined?: readonly string[];
|
||||
flight?: MovementFlight;
|
||||
busy: boolean;
|
||||
unavailableReason?: string | null;
|
||||
reasonId?: string;
|
||||
act: (action: Action) => void;
|
||||
}
|
||||
|
||||
function receipt(game: GameView, draft: ReturnType<typeof sandmasterMoveDraft>) {
|
||||
if (!draft.action || !draft.choice) return null;
|
||||
const order = sandmasterActionOrder(game, game.me, draft.action);
|
||||
if (!order) return null;
|
||||
try { return JSON.stringify(quoteSandmasterMovement(game, game.me, order, draft.choice)); }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
/** Hook-free actual submit leaf, including current route/pile/frame proof. */
|
||||
export function SandmasterMovementAction({ game, move, routes, declined = [], flight,
|
||||
busy, unavailableReason, reasonId, act }: SandmasterMovementActionProps) {
|
||||
const selected = sandmasterMovementSelection(game, move, flight);
|
||||
const draft = move ? sandmasterMoveDraft(game, move, routes, declined, flight)
|
||||
: { action: null, choice: null, collectible: [], blocked: selected.blocked };
|
||||
const blocked = sandmasterMovementBlocked(game, draft, unavailableReason, selected.flight);
|
||||
const snapshot = receipt(game, draft);
|
||||
return <Button
|
||||
className="min-h-11 whitespace-normal motion-reduce:transition-none"
|
||||
data-testid="sandmaster-move"
|
||||
disabled={busy || !!blocked || !draft.action || !snapshot}
|
||||
aria-describedby={blocked ? reasonId : undefined}
|
||||
onClick={() => {
|
||||
if (busy || blocked || !move || !draft.action || !snapshot) return;
|
||||
const current = sandmasterMoveDraft(game, move, routes, declined, flight);
|
||||
if (sandmasterMovementBlocked(game, current, unavailableReason, selected.flight) || !current.action) return;
|
||||
const next = sandmasterMovementSelection(game, move, flight);
|
||||
if (JSON.stringify(next.transport.fields) !== JSON.stringify(selected.transport.fields) ||
|
||||
receipt(game, current) !== snapshot) return;
|
||||
act(current.action);
|
||||
}}
|
||||
>Move with Sandmaster</Button>;
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user