252 lines
9.2 KiB
TypeScript
252 lines
9.2 KiB
TypeScript
'use client';
|
||
|
||
import { useId, useState } from 'react';
|
||
import type { Action, GameView } from '@/game/engine';
|
||
import {
|
||
validateCohortSelection,
|
||
type OrnithopterMode,
|
||
} from '@/game/ornithopter';
|
||
import { location, splitLocation, gameTerritories } from '@/game/board';
|
||
import { botGroundMoveAllowed } from '@/game/bot-mobility';
|
||
import { Button } from './ui/button';
|
||
import { CardInspector, CardRules } from './card-inspector';
|
||
|
||
/** The parent supplies its existing ordinary/elite/multi-sector/marker draft. */
|
||
export function OrnithopterMovement({
|
||
game,
|
||
act,
|
||
busy,
|
||
move,
|
||
unavailableReason,
|
||
}: {
|
||
game: GameView;
|
||
act: (action: Action) => void;
|
||
busy: boolean;
|
||
move: Action | null;
|
||
unavailableReason?: string;
|
||
}) {
|
||
const id = useId();
|
||
const [selected, setSelected] = useState<OrnithopterMode>('range3');
|
||
const card = game.ornithopter;
|
||
const me = game.players.find((p) => p.id === game.me);
|
||
if (!card || !me) return null;
|
||
const advisorGuidance = game.advanced && me.faction === 'beneGesserit';
|
||
const guildGuidance = !!game.nexusGuildCunning?.active;
|
||
const active = card.active;
|
||
const mode =
|
||
active?.mode ??
|
||
(
|
||
card.modes.find(
|
||
(option) => option.mode === selected && !option.blocked,
|
||
) ??
|
||
card.modes.find((option) => !option.blocked) ??
|
||
card.modes[0]
|
||
)?.mode;
|
||
let reason =
|
||
unavailableReason ??
|
||
card.blocked ??
|
||
(!active
|
||
? card.modes.find((option) => option.mode === mode)?.blocked
|
||
: null) ??
|
||
(!move || move.type !== 'move'
|
||
? 'Select a valid movement group and destination above.'
|
||
: null);
|
||
if (!reason && mode === 'twoGroups' && me.fremenMovementBlocked && move) {
|
||
const selectedForces = typeof move.from === 'string'
|
||
? { [move.from]: Number(move.amount) }
|
||
: (move.forces as Record<string, number> | undefined) ?? {};
|
||
if (Object.entries(selectedForces).some(([from, count]) => count > 0 &&
|
||
!botGroundMoveAllowed(game, me, from,
|
||
location(String(move.territory), Number(move.sector)), 0)))
|
||
reason = 'The Fremen two-territory advantage is unavailable. Choose a reachable destination within the remaining normal range.';
|
||
}
|
||
if (!reason && active?.cohort && move) {
|
||
const forces =
|
||
(move.forces as Record<string, number> | undefined) ??
|
||
(typeof move.from === 'string'
|
||
? { [move.from]: Number(move.amount) }
|
||
: {});
|
||
const elites =
|
||
(move.eliteForces as Record<string, number> | undefined) ??
|
||
(typeof move.from === 'string'
|
||
? { [move.from]: Number(move.elite ?? 0) }
|
||
: {});
|
||
const marker =
|
||
game.richeseNoField?.owner === game.me
|
||
? game.richeseNoField.private?.deployed
|
||
: null;
|
||
try {
|
||
validateCohortSelection(
|
||
active.cohort,
|
||
me.forces,
|
||
me.elites?.forces ?? {},
|
||
forces,
|
||
elites,
|
||
typeof move.noField === 'string' && marker
|
||
? {
|
||
tokenId: move.noField,
|
||
event: typeof move.event === 'string' ? move.event : '',
|
||
from: location(marker.location.territory, marker.location.sector),
|
||
}
|
||
: undefined,
|
||
);
|
||
} catch (error) {
|
||
reason =
|
||
error instanceof Error && /cohort|original unmoved|marker/i.test(error.message)
|
||
? 'Choose forces that have not moved with this card. The first group cannot move again.'
|
||
: 'The selected force counts are no longer available. Choose an available group.';
|
||
}
|
||
}
|
||
return (
|
||
<section
|
||
className="my-4 flex min-w-0 flex-col gap-4"
|
||
aria-labelledby={`${id}-heading`}
|
||
>
|
||
<h3 id={`${id}-heading`}>
|
||
{active
|
||
? 'Ornithopter movement in progress'
|
||
: 'Use Ornithopter for this movement'}
|
||
</h3>
|
||
<CardRules card={card.card} />
|
||
<CardInspector card={card.card} />
|
||
{active ? (
|
||
<>
|
||
<p>
|
||
{active.completed} group moved · {active.remaining} remaining.{' '}
|
||
{active.mode === 'twoGroups'
|
||
? 'The next group must use different original forces.'
|
||
: 'This group has a maximum range of three territories.'}
|
||
</p>
|
||
{!!active.cohort && (
|
||
<div className="flex flex-col gap-2">
|
||
<p>Original unmoved forces available for selection:</p>
|
||
<ul>
|
||
{Object.entries(active.cohort.forces)
|
||
.filter(([, n]) => n > 0)
|
||
.map(([key, n]) => {
|
||
const imperial = key === 'homeworld:emperor' ? 'Kaitain'
|
||
: key === 'homeworld:emperor:salusa' ? 'Salusa Secundus' : null;
|
||
if (imperial) return (
|
||
<li key={key}>
|
||
{imperial}: {n} forces, including {active.cohort!.elites[key] ?? 0} Sardaukar.
|
||
{' '}Use the Imperial movement panel for this group.
|
||
</li>
|
||
);
|
||
const source = splitLocation(key);
|
||
return (
|
||
<li key={key}>
|
||
{gameTerritories(game).find(
|
||
(t) => t.id === source.territory,
|
||
)?.name ?? source.territory}
|
||
, sector {source.sector}: {n} forces{me.elites ? `, including ${active.cohort!.elites[key] ?? 0} elite` : ''}.
|
||
</li>
|
||
);
|
||
})}
|
||
{active.cohort.noField && (
|
||
<li>
|
||
One original unmoved concealed No-Field; select its current
|
||
source above.
|
||
</li>
|
||
)}
|
||
</ul>
|
||
</div>
|
||
)}
|
||
</>
|
||
) : (
|
||
<>
|
||
<label htmlFor={`${id}-mode`}>Card movement mode</label>
|
||
<select
|
||
id={`${id}-mode`}
|
||
className="min-h-11 w-full min-w-0"
|
||
value={mode}
|
||
disabled={busy}
|
||
onChange={(e) => setSelected(e.target.value as OrnithopterMode)}
|
||
>
|
||
{card.modes.map((option) => (
|
||
<option
|
||
key={option.mode}
|
||
value={option.mode}
|
||
disabled={!!option.blocked}
|
||
>
|
||
{option.mode === 'range3'
|
||
? 'One group, up to three territories'
|
||
: 'Two different groups, normal movement'}
|
||
</option>
|
||
))}
|
||
</select>
|
||
{card.modes
|
||
.filter((option) => option.blocked)
|
||
.map((option) => (
|
||
<p className="fine" key={option.mode}>
|
||
{option.mode === 'range3'
|
||
? 'Three-territory mode'
|
||
: 'Two-group mode'}
|
||
: {option.blocked}
|
||
</p>
|
||
))}
|
||
<p className="fine">
|
||
Uses the forces, elite mix, No-Field and destination selected above.
|
||
The card is played with the first move and discarded when its use
|
||
ends. Storm and entry rules still apply.
|
||
</p>
|
||
</>
|
||
)}
|
||
{guildGuidance && (
|
||
<p className="notice" id={`${id}-guild-guidance`}>
|
||
Provisional Guild Cunning combination: Ornithopter replaces one unused
|
||
Hajr movement after the second shipment. Two-group mode keeps its second
|
||
different group available until you finish it or end your turn. Ending
|
||
early grants no movement for the abandoned group.
|
||
</p>
|
||
)}
|
||
{advisorGuidance && (
|
||
<p className="notice" id={`${id}-advisor-guidance`}>
|
||
Advanced Bene Gesserit advisors can use both card modes. Advisors in
|
||
Arrakeen or Carthag do not grant the city’s movement range. This card’s
|
||
one-group mode independently gives range three. Two-group mode uses
|
||
your current normal range and different original forces. Normal entry
|
||
and advisor conversion still apply.
|
||
</p>
|
||
)}
|
||
{me.faction === 'choam' && game.choamMovementBonus > 0 && (
|
||
<p className="notice">
|
||
Provisional Kulon combination: the one-group card move stays capped
|
||
at three territories. Kulon still extends each normal-range move in
|
||
the two-group mode.
|
||
</p>
|
||
)}
|
||
{reason && (
|
||
<p className="notice" id={`${id}-reason`}>
|
||
{reason}
|
||
</p>
|
||
)}
|
||
<Button
|
||
className="min-h-11 whitespace-normal motion-reduce:transition-none"
|
||
disabled={busy || !!reason}
|
||
aria-describedby={[
|
||
...(guildGuidance ? [`${id}-guild-guidance`] : []),
|
||
...(advisorGuidance ? [`${id}-advisor-guidance`] : []),
|
||
...(reason ? [`${id}-reason`] : []),
|
||
].join(' ') || undefined}
|
||
onClick={() => {
|
||
if (busy || reason || !move || !mode) return;
|
||
const action = { ...move };
|
||
delete action.movementCard;
|
||
delete action.ornithopter;
|
||
delete action.ornithopterEvent;
|
||
act({
|
||
...action,
|
||
...(active
|
||
? { ornithopterEvent: active.event }
|
||
: { movementCard: card.card.id, ornithopter: mode }),
|
||
});
|
||
}}
|
||
>
|
||
{active
|
||
? 'Move next selected group'
|
||
: 'Play Ornithopter and move selected group'}
|
||
</Button>
|
||
</section>
|
||
);
|
||
}
|