Files
Dune/components/planetologist-movement.tsx
T

213 lines
7.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useId, useState } from 'react';
import type { Action, GameView } from '@/game/engine';
import {
planetologistLeader,
type PlanetologistMoveMode,
} from '@/game/planetologist-movement';
import {
gameTerritories,
splitLocation,
territory,
} from '@/game/board';
import {
PlanetologistMovementAction,
planetologistMoveDraft,
planetologistMovementBlocked,
planetologistNoFieldMarker,
} from './planetologist-movement-action';
import { Input } from './ui/input';
export { planetologistMoveDraft } from './planetologist-movement-action';
type Counts = Record<string, number>;
type Props = {
game: GameView;
act: (action: Action) => void;
busy: boolean;
destination: string;
sector: number;
unavailableReason?: string | null;
};
export function PlanetologistMovement(props: Props) {
const marker = planetologistNoFieldMarker(props.game);
const me = props.game.players.find(player => player.id === props.game.me);
return <PlanetologistMovementDraft
key={JSON.stringify([props.game.me, props.game.turn, me?.moved, marker?.tokenId, marker?.event, marker?.location])}
{...props}
/>;
}
function PlanetologistMovementDraft({
game,
act,
busy,
destination,
sector,
unavailableReason,
}: Props) {
const id = useId();
const [mode, setMode] = useState<PlanetologistMoveMode>('range');
const [forces, setForces] = useState<Counts>({});
const [elites, setElites] = useState<Counts>({});
const [includeNoField, setIncludeNoField] = useState(false);
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 marker = planetologistNoFieldMarker(game);
const draft = planetologistMoveDraft(
game,
mode,
destination,
sector,
forces,
elites,
includeNoField,
);
const blocked = planetologistMovementBlocked(game, draft, unavailableReason);
const leaderName =
game.allLeaders.find((candidate) => candidate.id === leader)?.name ??
leader;
return (
<section
className="my-4 flex min-w-0 flex-col gap-3"
aria-labelledby={`${id}-heading`}
>
<h3 id={`${id}-heading`}>Planetologist movement</h3>
<p>
{leaderName} may add one territory to one movement, up to three, or
gather forces from exactly two territories into the selected
destination.
</p>
{game.advanced&&me.faction==='beneGesserit'&&mode==='gather'&&(
<p className="notice">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.</p>
)}
<label htmlFor={`${id}-mode`}>Movement mode</label>
<select
id={`${id}-mode`}
value={mode}
disabled={busy}
onChange={(event) => {
const nextMode = event.target.value as PlanetologistMoveMode;
setMode(nextMode);
if (nextMode !== 'range') setIncludeNoField(false);
}}
>
<option value="range">One territory farther, maximum three</option>
<option value="gather">Gather from two territories</option>
</select>
<p className="fine">
Destination: {territory(destination).name}, sector {sector}. Select the
physical forces to move below.
</p>
{marker && (
<>
<label className="flex min-h-11 items-center gap-3" htmlFor={`${id}-no-field`}>
<input
id={`${id}-no-field`}
type="checkbox"
checked={includeNoField}
disabled={busy || mode !== 'range' || !!game.homeworldMobility?.noFieldMovementBlocked}
onChange={(event) => setIncludeNoField(event.target.checked)}
/>
Include your concealed No-Field from {territory(marker.location.territory).name}, sector {marker.location.sector}
</label>
<p className="fine" aria-label="Provisional Planetologist No-Field range">
Provisional range composition: the concealed marker moves as one
force with Planetologist’s extra range, up to three territories.
Move it alone or with physical forces from its own territory; its
value stays concealed. No direct joint FAQ settles this combination.
</p>
{game.homeworldMobility?.noFieldMovementBlocked && (
<p className="notice">{game.homeworldMobility.noFieldMovementBlocked}</p>
)}
</>
)}
{draft.action?.fighters === true && (
<p className="notice">
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.
</p>
)}
{me.faction === 'ixians' && (
<p className="fine">
Each origin uses its own selected cyborgs for movement range. Cyborgs
from one territory cannot carry suboids from the other territory farther.
</p>
)}
{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 (
<div className="grid gap-2" key={from}>
<label htmlFor={`${id}-${from}-forces`}>
{sourceName}, sector {source.sector} · {owned} forces
<Input
id={`${id}-${from}-forces`}
type="number"
min={0}
max={owned}
value={selected}
disabled={busy || source.sector === game.storm}
onChange={(event) =>
setForces({ ...forces, [from]: Number(event.target.value) })
}
/>
</label>
{me.elites && (
<label htmlFor={`${id}-${from}-elites`}>
Elite from this group · {ownedElite} available
<Input
id={`${id}-${from}-elites`}
type="number"
min={minimumElite}
max={Math.min(selected, ownedElite)}
value={elites[from] ?? 0}
disabled={busy || selected === 0}
onChange={(event) =>
setElites({
...elites,
[from]: Number(event.target.value),
})
}
/>
</label>
)}
</div>
);
})
) : (
<p className="fine">No physical board forces are available to move.</p>
)}
{blocked && (
<output className="notice" id={`${id}-reason`}>
{blocked}
</output>
)}
<PlanetologistMovementAction
game={game} mode={mode} destination={destination} sector={sector}
forces={forces} elites={elites} includeNoField={includeNoField}
busy={busy} unavailableReason={unavailableReason} reasonId={`${id}-reason`} act={act}
/>
<p className="fine">
Concealed No-Fields cannot join gather, either Ornithopter card movement
or Discovery flight.
Normal movement and arrival rules still apply.
</p>
</section>
);
}