Provisionally let either physical card mode replace an earned unused Hajr movement after the second shipment. Keep active group rights separate from actual completed credits and original movement counts. Add v3 original-count receipts while preserving old formats; abandon partial flights through a bound discard suffix before Cunning resumes. Connect persistent controls and minimal legal bot candidates. Validation: frozen13-file125/125, types/lint/build and eight original-action/rendered-table continuations. Public ready/start, cache1/Hajr1 purchases, real worm/alliance/Guild Nexus draw and paid first2/second1 shipments cover both modes, earlier flights and Basic early endings. Eight minimal policy proposals are legal. No frozen repair; bounded continuation review found no concrete defect. New Semuta grammar lacks direct combined claim/capture proof. Wider combinations, complete modes, final assurance and browser/deployed acceptance remain open. No saved-room/reset/restart/deployment operation.
244 lines
8.7 KiB
TypeScript
244 lines
8.7 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 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>
|
||
);
|
||
}
|