Connect typed Homeworld custody, transport, battle, income, revival and Caladan reinforcement flows with private multiplayer recovery and controls. Preserve the optimized test workflow and keep unfinished rules modes gated. Verified with 3682 offline tests, typecheck, lint, production build and 40 HTTP tests; existing saved games remain unchanged.
713 lines
26 KiB
TypeScript
713 lines
26 KiB
TypeScript
'use client';
|
||
|
||
import { useId, useState } from 'react';
|
||
import { Dialog as DialogPrimitive } from '@base-ui/react/dialog';
|
||
import { Eye, X } from 'lucide-react';
|
||
import { Button } from '@/components/ui/button';
|
||
import {
|
||
Dialog,
|
||
DialogClose,
|
||
DialogDescription,
|
||
DialogOverlay,
|
||
DialogPortal,
|
||
DialogTitle,
|
||
DialogTrigger,
|
||
} from '@/components/ui/dialog';
|
||
import type { Action, GameView } from '@/game/engine';
|
||
import { territory } from '@/game/board';
|
||
import {
|
||
TERROR_COMMON_GAMEPLAY,
|
||
TERROR_DEFINITIONS,
|
||
TERROR_STRONGHOLDS,
|
||
type TerrorKind,
|
||
type TerrorToken,
|
||
} from '@/game/moritani-terror';
|
||
|
||
type ProjectedToken = NonNullable<GameView['moritaniTerror']>['tokens'][number];
|
||
|
||
/** Original vector marks. A hidden token always uses the same face and geometry. */
|
||
function TerrorArt({
|
||
kind,
|
||
large = false,
|
||
}: {
|
||
kind?: TerrorKind;
|
||
large?: boolean;
|
||
}) {
|
||
return (
|
||
<svg
|
||
viewBox="0 0 120 120"
|
||
className={large ? 'mx-auto my-6 size-40 max-w-full' : 'size-16 shrink-0'}
|
||
aria-hidden="true"
|
||
focusable="false"
|
||
>
|
||
<circle cx="60" cy="63" r="55" fill="#080e0f" />
|
||
<circle
|
||
cx="60"
|
||
cy="58"
|
||
r="54"
|
||
fill="#173a40"
|
||
stroke="#d2b77b"
|
||
strokeWidth="2"
|
||
/>
|
||
<circle cx="60" cy="58" r="46" fill="#10282d" stroke="#688987" />
|
||
<path
|
||
d="M 60 8 L 63 14 L 60 20 L 57 14 Z M 60 96 L 63 102 L 60 108 L 57 102 Z M 10 58 H 18 M 102 58 H 110"
|
||
fill="#ddc690"
|
||
stroke="#ddc690"
|
||
/>
|
||
<g
|
||
fill="none"
|
||
stroke="#f1dfb4"
|
||
strokeWidth="3"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
>
|
||
{kind === 'assassination' ? (
|
||
<>
|
||
<path d="M 68 26 L 75 33 L 53 66 L 42 75 L 45 61 Z M 40 65 L 54 76 M 47 72 L 37 88" />
|
||
<circle cx="77" cy="78" r="10" strokeWidth="1.5" />
|
||
</>
|
||
) : kind === 'atomics' ? (
|
||
<>
|
||
<circle cx="60" cy="58" r="9" fill="#b59a64" />
|
||
<path d="M 60 25 V 40 M 60 76 V 91 M 27 58 H 42 M 78 58 H 93 M 36 34 L 47 45 M 73 71 L 84 82 M 36 82 L 47 71 M 73 45 L 84 34" />
|
||
<circle cx="60" cy="58" r="25" strokeWidth="1" />
|
||
</>
|
||
) : kind === 'extortion' ? (
|
||
<>
|
||
<path d="M 38 38 H 82 V 82 H 38 Z M 47 38 V 30 H 73 V 38 M 38 50 H 82" />
|
||
<path d="M 60 58 L 66 65 L 60 74 L 54 65 Z" fill="#b59a64" />
|
||
</>
|
||
) : kind === 'robbery' ? (
|
||
<>
|
||
<path d="M 33 46 Q 60 29 87 46 L 80 70 Q 60 83 40 70 Z" />
|
||
<path d="M 43 50 L 54 54 L 44 59 M 77 50 L 66 54 L 76 59 M 55 70 H 65" />
|
||
</>
|
||
) : kind === 'sabotage' ? (
|
||
<>
|
||
<path d="M 53 28 H 67 L 70 39 L 81 41 L 87 54 L 79 63 L 81 76 L 67 84 L 58 76 L 45 79 L 36 65 L 43 55 L 40 41 L 52 38 Z" />
|
||
<path
|
||
d="M 68 39 L 53 57 L 67 59 L 52 79"
|
||
stroke="#d59a80"
|
||
strokeWidth="4"
|
||
/>
|
||
</>
|
||
) : kind === 'sneakAttack' ? (
|
||
<>
|
||
<path d="M 25 47 L 60 57 L 95 47 L 73 65 L 60 88 L 47 65 Z M 60 31 V 57 M 46 35 L 60 44 L 74 35" />
|
||
</>
|
||
) : (
|
||
<>
|
||
<path d="M 60 27 L 83 42 V 61 Q 81 78 60 89 Q 39 78 37 61 V 42 Z" />
|
||
<path d="M 49 59 V 52 A 11 11 0 0 1 71 52 V 59 M 47 59 H 73 V 75 H 47 Z" />
|
||
<path d="M 60 64 V 69" />
|
||
</>
|
||
)}
|
||
</g>
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
function tokenContext(token: ProjectedToken) {
|
||
if (token.status === 'placed' && token.location)
|
||
return `Placed · ${territory(token.location).name}`;
|
||
if (token.status === 'extortion') return 'Revealed · awaiting Mentat';
|
||
if (token.status === 'removed') return 'Revealed · removed from play';
|
||
return 'Private supply';
|
||
}
|
||
|
||
function TokenInspector({
|
||
token,
|
||
owner,
|
||
}: {
|
||
token: ProjectedToken;
|
||
owner: boolean;
|
||
}) {
|
||
const revealed = token.status === 'removed' || token.status === 'extortion';
|
||
// Defense in depth: even an accidentally over-broad projection cannot render
|
||
// an opponent's hidden face. Never derive a secret kind from a physical ID.
|
||
const kind = (owner || revealed) && 'kind' in token ? token.kind : undefined;
|
||
const definition = kind ? TERROR_DEFINITIONS[kind] : undefined;
|
||
const title = definition?.name ?? 'Hidden Terror';
|
||
const context = tokenContext(token);
|
||
return (
|
||
<Dialog>
|
||
<DialogTrigger
|
||
render={
|
||
<Button
|
||
variant="outline"
|
||
className="h-auto min-h-11 min-w-0 flex-col gap-2 whitespace-normal border-[#66796a] bg-[#172624] px-2 py-3 text-[#f2dfb9] motion-reduce:transition-none"
|
||
/>
|
||
}
|
||
aria-label={`Inspect ${title}: ${context}`}
|
||
>
|
||
<TerrorArt kind={kind} />
|
||
<span className="break-words font-serif text-base leading-tight">
|
||
{title}
|
||
</span>
|
||
<span className="break-words text-xs leading-5 text-[#c4cabc]">
|
||
{context}
|
||
</span>
|
||
<span className="inline-flex items-center gap-1 text-xs text-[#dfc78e]">
|
||
<Eye aria-hidden="true" /> Inspect
|
||
</span>
|
||
</DialogTrigger>
|
||
<DialogPortal>
|
||
<DialogOverlay className="bg-black/75 motion-reduce:animate-none motion-reduce:transition-none" />
|
||
<DialogPrimitive.Popup className="fixed top-1/2 left-1/2 z-50 flex max-h-[calc(100dvh-1rem)] w-[calc(100%-1rem)] max-w-3xl -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-2xl border border-[#8e7952] bg-[#141814] text-[#eeeae0] shadow-2xl outline-none">
|
||
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-[#414536] px-4 py-3">
|
||
<span className="text-xs font-semibold tracking-[0.16em] text-[#ddbc77] uppercase">
|
||
Moritani · Terror
|
||
</span>
|
||
<DialogClose
|
||
render={
|
||
<Button
|
||
variant="outline"
|
||
className="min-h-11 min-w-11 motion-reduce:transition-none"
|
||
/>
|
||
}
|
||
>
|
||
<X aria-hidden="true" /> Close
|
||
</DialogClose>
|
||
</div>
|
||
<div className="min-h-0 overflow-y-auto overscroll-contain p-4 sm:p-6">
|
||
<div className="grid items-start gap-6 md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]">
|
||
<article className="min-w-0 rounded-xl border border-[#a58b5c] bg-gradient-to-br from-[#294b4b] via-[#1c302d] to-[#171b17] p-5">
|
||
<p className="m-0 text-xs font-semibold tracking-widest text-[#e0cd9f] uppercase">
|
||
{context}
|
||
</p>
|
||
<TerrorArt kind={kind} large />
|
||
<DialogTitle className="break-words font-serif text-3xl leading-tight text-[#fff3d6] sm:text-4xl">
|
||
{title}
|
||
</DialogTitle>
|
||
<p className="mt-5 mb-0 text-sm leading-6 text-[#d3daca]">
|
||
{kind
|
||
? 'Original token illustration · gameplay guide'
|
||
: 'This placed token remains face down. Its identity is visible only to Moritani.'}
|
||
</p>
|
||
</article>
|
||
<section
|
||
className="min-w-0 space-y-4"
|
||
aria-label="Terror token guidance"
|
||
>
|
||
<DialogDescription className="m-0 text-base leading-7 text-[#f1dfb4]">
|
||
{definition?.summary ??
|
||
'A hidden Terror token marks this stronghold. Inspecting it does not reveal its identity or trigger an effect.'}
|
||
</DialogDescription>
|
||
{(definition?.gameplay ?? TERROR_COMMON_GAMEPLAY).map(
|
||
(paragraph) => (
|
||
<p
|
||
key={paragraph}
|
||
className="m-0 text-sm leading-7 text-[#e0e4d8]"
|
||
>
|
||
{paragraph}
|
||
</p>
|
||
),
|
||
)}
|
||
<p className="border-t border-[#414536] pt-4 text-sm leading-6 text-[#c9c2ae]">
|
||
Robbery, Sabotage, Sneak Attack and ordinary native-leader
|
||
Assassination have controls for supported entry reactions.
|
||
Enemy of My Enemy has partial alliance controls. Atomics and
|
||
Extortion remain under implementation, and complete Moritani
|
||
games remain unavailable. Inspection explains the rules; it
|
||
does not reveal a token or activate an effect.
|
||
</p>
|
||
</section>
|
||
</div>
|
||
</div>
|
||
</DialogPrimitive.Popup>
|
||
</DialogPortal>
|
||
</Dialog>
|
||
);
|
||
}
|
||
|
||
export function MoritaniTerrorSupply({ game }: { game: GameView }) {
|
||
const titleId = useId();
|
||
const view = game.moritaniTerror;
|
||
if (!view) return null;
|
||
const owner =
|
||
game.players.find((p) => p.id === game.me)?.faction === 'moritani';
|
||
const tokens = view.tokens.filter(
|
||
(token) => owner || token.status !== 'available',
|
||
);
|
||
return (
|
||
<section
|
||
className="min-w-0 rounded-xl border border-[#58624e] bg-[#161e19] p-3"
|
||
aria-labelledby={titleId}
|
||
>
|
||
<h3 id={titleId} className="m-0 font-serif text-xl text-[#f1dfb4]">
|
||
Moritani Terror
|
||
</h3>
|
||
<p className="mt-2 mb-3 text-sm leading-6 text-[#c4cabc]">
|
||
{owner
|
||
? 'Your supply and placed tokens. Inspect a face without revealing it to the table.'
|
||
: 'Placed tokens stay hidden. Revealed tokens can be inspected by everyone.'}
|
||
</p>
|
||
{tokens.length ? (
|
||
<div className="grid min-w-0 grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-6">
|
||
{tokens.map((token) => (
|
||
<TokenInspector key={token.id} token={token} owner={owner} />
|
||
))}
|
||
</div>
|
||
) : (
|
||
<p className="m-0 text-sm text-[#c4cabc]">No public Terror tokens.</p>
|
||
)}
|
||
</section>
|
||
);
|
||
}
|
||
|
||
export function MoritaniPlacement({
|
||
game,
|
||
act,
|
||
busy,
|
||
}: {
|
||
game: GameView;
|
||
act: (action: Action) => void;
|
||
busy: boolean;
|
||
}) {
|
||
const formId = useId();
|
||
const [selectedToken, setSelectedToken] = useState('');
|
||
const [selectedTerritory, setSelectedTerritory] = useState('');
|
||
const decision = game.decision;
|
||
if (
|
||
decision?.kind !== 'moritaniPlacement' ||
|
||
decision.player !== game.me ||
|
||
game.players.find((p) => p.id === game.me)?.faction !== 'moritani' ||
|
||
!game.moritaniTerror
|
||
)
|
||
return null;
|
||
const tokens = game.moritaniTerror.tokens.filter(
|
||
(token): token is TerrorToken =>
|
||
'kind' in token &&
|
||
(token.status === 'available' || token.status === 'placed'),
|
||
);
|
||
const token =
|
||
tokens.find((candidate) => candidate.id === selectedToken) ?? tokens[0];
|
||
const destinations = TERROR_STRONGHOLDS.filter(
|
||
(to) =>
|
||
to !== token?.location &&
|
||
!game.moritaniTerror!.tokens.some(
|
||
(candidate) =>
|
||
candidate.status === 'placed' && candidate.location === to,
|
||
),
|
||
);
|
||
const destination = destinations.includes(selectedTerritory)
|
||
? selectedTerritory
|
||
: destinations[0];
|
||
const waiting = Boolean(game.response);
|
||
const disabled = busy || waiting;
|
||
return (
|
||
<section className="notice min-w-0" aria-labelledby={`${formId}-title`}>
|
||
<h3 id={`${formId}-title`}>Place or relocate Terror</h3>
|
||
<p className="text-sm leading-6">
|
||
Choose one token and a stronghold without a Terror token for this Mentat
|
||
Pause. Storm does not prevent placement. Choose privately; the token’s
|
||
face stays hidden from opponents.
|
||
</p>
|
||
<div className="flex min-w-0 flex-col gap-3">
|
||
<label htmlFor={`${formId}-token`} className="text-sm font-medium">
|
||
Your Terror token
|
||
</label>
|
||
<select
|
||
id={`${formId}-token`}
|
||
className="min-h-11 w-full min-w-0 max-w-full"
|
||
disabled={disabled || !tokens.length}
|
||
value={token?.id ?? ''}
|
||
onChange={(event) => setSelectedToken(event.target.value)}
|
||
>
|
||
{!tokens.length && <option value="">No available token</option>}
|
||
{tokens.map((candidate) => (
|
||
<option key={candidate.id} value={candidate.id}>
|
||
{TERROR_DEFINITIONS[candidate.kind].name} ·{' '}
|
||
{candidate.location
|
||
? territory(candidate.location).name
|
||
: 'Supply'}
|
||
</option>
|
||
))}
|
||
</select>
|
||
<label htmlFor={`${formId}-territory`} className="text-sm font-medium">
|
||
Destination stronghold
|
||
</label>
|
||
<select
|
||
id={`${formId}-territory`}
|
||
className="min-h-11 w-full min-w-0 max-w-full"
|
||
disabled={disabled || !destinations.length || !token}
|
||
value={destination ?? ''}
|
||
onChange={(event) => setSelectedTerritory(event.target.value)}
|
||
>
|
||
{!destinations.length && (
|
||
<option value="">No empty stronghold</option>
|
||
)}
|
||
{destinations.map((to) => (
|
||
<option key={to} value={to}>
|
||
{territory(to).name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
<output className="m-0 text-sm leading-6">
|
||
{waiting
|
||
? 'Waiting for the table’s Karama response. The token has not moved.'
|
||
: 'The table may use Karama before your placement or relocation takes effect.'}
|
||
</output>
|
||
<Button
|
||
className="h-auto min-h-11 whitespace-normal motion-reduce:transition-none"
|
||
disabled={disabled || !token || !destination}
|
||
onClick={() => {
|
||
if (token && destination)
|
||
act({
|
||
type: 'decision',
|
||
token: token.id,
|
||
territory: destination,
|
||
});
|
||
}}
|
||
>
|
||
{token?.status === 'placed'
|
||
? 'Declare relocation'
|
||
: 'Declare placement'}
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
className="h-auto min-h-11 whitespace-normal motion-reduce:transition-none"
|
||
disabled={disabled}
|
||
onClick={() => act({ type: 'decision', decline: true })}
|
||
>
|
||
Pass this Mentat opportunity
|
||
</Button>
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
export function MoritaniEntry({
|
||
game,
|
||
act,
|
||
busy,
|
||
}: {
|
||
game: GameView;
|
||
act: (action: Action) => void;
|
||
busy: boolean;
|
||
}) {
|
||
const formId = useId();
|
||
const [selectedCard, setSelectedCard] = useState('');
|
||
const [selectedAmount, setSelectedAmount] = useState(0);
|
||
const entry = game.terrorEntry;
|
||
const me = game.players.find((player) => player.id === game.me);
|
||
if (
|
||
game.decision?.kind !== 'moritaniTerror' ||
|
||
game.decision.player !== game.me ||
|
||
!entry ||
|
||
!me
|
||
)
|
||
return null;
|
||
|
||
const disabled = busy || Boolean(game.response) || Boolean(game.truthtrance);
|
||
const controlClass =
|
||
'h-auto min-h-11 whitespace-normal motion-reduce:transition-none';
|
||
|
||
// This entrant branch must precede every private kind, definition and token lookup.
|
||
if (entry.stage === 'allianceReply') {
|
||
if (entry.entrant !== game.me) return null;
|
||
const moritani = game.players.find(
|
||
(player) => player.faction === 'moritani',
|
||
);
|
||
if (!moritani) return null;
|
||
return (
|
||
<section className="notice min-w-0" aria-labelledby={`${formId}-title`}>
|
||
<h3 id={`${formId}-title`}>Enemy of My Enemy</h3>
|
||
<p className="text-sm leading-6">
|
||
{moritani.name} offers you an alliance after your arrival in{' '}
|
||
{territory(entry.territory).name}. The Terror token remains hidden.
|
||
</p>
|
||
<p className="text-sm leading-6">
|
||
Accepting forms your alliance with Moritani, ends any existing
|
||
alliances either of you has, and returns the token to Moritani’s
|
||
supply without revealing it. Refusing requires the token to be
|
||
revealed and its effect to resolve.
|
||
</p>
|
||
{[me, moritani]
|
||
.filter((player) => player.ally)
|
||
.map((player) => {
|
||
const formerPartner = game.players.find(
|
||
(partner) => partner.id === player.ally,
|
||
);
|
||
return (
|
||
<p key={player.id} className="text-sm leading-6">
|
||
{player.name}’s alliance with{' '}
|
||
{formerPartner?.name ?? 'their current ally'} will end if you
|
||
accept.
|
||
</p>
|
||
);
|
||
})}
|
||
<div className="flex min-w-0 flex-col gap-3">
|
||
<Button
|
||
className={controlClass}
|
||
disabled={disabled}
|
||
onClick={() => act({ type: 'decision', accept: true })}
|
||
>
|
||
Accept alliance
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
className={controlClass}
|
||
disabled={disabled}
|
||
onClick={() => act({ type: 'decision', accept: false })}
|
||
>
|
||
Refuse alliance
|
||
</Button>
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
if (me.faction !== 'moritani' || !('kind' in entry) || !entry.kind)
|
||
return null;
|
||
|
||
const definition = TERROR_DEFINITIONS[entry.kind];
|
||
const token = game.moritaniTerror?.tokens.find(
|
||
(candidate) => 'kind' in candidate && candidate.kind === entry.kind,
|
||
);
|
||
const entrant = game.players.find((player) => player.id === entry.entrant);
|
||
const hand = me.hand ?? [];
|
||
const selected = hand.find((card) => card.id === selectedCard);
|
||
const discardCard = selected ?? hand[0];
|
||
const giving = entry.stage === 'gift';
|
||
const sneak = entry.sneakAttack;
|
||
const maximum = sneak?.maximum ?? 0;
|
||
const amount = selectedAmount <= maximum ? selectedAmount : 0;
|
||
const arrival = {
|
||
shipment: 'by shipment',
|
||
movement: 'by movement',
|
||
guildTransport: 'by Guild transport',
|
||
advisor: 'as an accompanying advisor',
|
||
wormRide: 'by worm ride',
|
||
ambassador: 'through the Fremen Ambassador',
|
||
homeworldRevival: 'through Homeworld revival',
|
||
caladanReinforcement: 'through Caladan reinforcement',
|
||
}[entry.cause];
|
||
|
||
return (
|
||
<section className="notice min-w-0" aria-labelledby={`${formId}-title`}>
|
||
<h3 id={`${formId}-title`} className="break-words">
|
||
{definition.name} · Terror reaction
|
||
</h3>
|
||
<p className="text-sm leading-6">
|
||
{entrant?.name ?? 'Another faction'} entered{' '}
|
||
{territory(entry.territory).name}, sector {entry.sector}, {arrival}.
|
||
</p>
|
||
{token && (
|
||
<div className="mb-4 grid grid-cols-1">
|
||
<TokenInspector token={token} owner />
|
||
</div>
|
||
)}
|
||
<div className="flex min-w-0 flex-col gap-3">
|
||
{entry.stage === 'offer' && (
|
||
<>
|
||
<p className="m-0 text-sm leading-6">
|
||
You may reveal this token against the entrant, or leave it hidden
|
||
and continue their action. Inspection keeps its face private.
|
||
</p>
|
||
{entry.kind === 'assassination' && entry.canReveal && (
|
||
<p className="m-0 text-sm leading-6">
|
||
Revealing Assassination immediately selects a random eligible
|
||
native leader. You do not choose the victim.
|
||
</p>
|
||
)}
|
||
{entry.kind === 'sneakAttack' && sneak && (
|
||
<div className="space-y-2 text-sm leading-6">
|
||
<p className="m-0">
|
||
Up to {maximum} of your reserve forces are available.
|
||
{sneak.blocked
|
||
? ` Positive entry is blocked: ${sneak.blocked}`
|
||
: ' They may enter this territory and sector for free.'}
|
||
</p>
|
||
<p className="m-0">
|
||
You may leave this token hidden, or reveal it and choose how
|
||
many forces to send. Revealing spends the token even if you
|
||
send no forces; zero remains available when entry is blocked.
|
||
</p>
|
||
</div>
|
||
)}
|
||
{entry.canOfferAlliance ? (
|
||
<div className="flex flex-col gap-3 border-y border-current/20 py-3">
|
||
<p className="m-0 text-sm leading-6">
|
||
Enemy of My Enemy lets you offer an alliance before revealing
|
||
this token. Acceptance ends both existing alliances and
|
||
returns the token hidden. Refusal requires its effect to
|
||
resolve. The table’s Karama response comes before the entrant
|
||
decides.
|
||
</p>
|
||
<Button
|
||
variant="outline"
|
||
className={controlClass}
|
||
disabled={disabled}
|
||
onClick={() => act({ type: 'decision', alliance: true })}
|
||
>
|
||
Offer alliance
|
||
</Button>
|
||
</div>
|
||
) : entry.allianceBlockedReason ? (
|
||
<p className="m-0 text-sm leading-6">
|
||
Enemy of My Enemy: {entry.allianceBlockedReason}
|
||
</p>
|
||
) : null}
|
||
{entry.canReveal ? (
|
||
<Button
|
||
className={controlClass}
|
||
disabled={disabled}
|
||
onClick={() => act({ type: 'decision', reveal: true })}
|
||
>
|
||
Reveal {definition.name}
|
||
</Button>
|
||
) : (
|
||
<p className="m-0 text-sm leading-6">
|
||
{entry.revealBlocked ??
|
||
`${definition.name} is still under implementation.`}{' '}
|
||
Leave the token hidden to continue this arrival.
|
||
</p>
|
||
)}
|
||
<Button
|
||
variant="outline"
|
||
className={controlClass}
|
||
disabled={disabled}
|
||
onClick={() => act({ type: 'decision', decline: true })}
|
||
>
|
||
Leave token hidden
|
||
</Button>
|
||
<p className="m-0 text-sm leading-6">
|
||
Robbery, Sabotage, Sneak Attack and ordinary native-leader
|
||
Assassination support eligible arrivals. Enemy of My Enemy has
|
||
partial alliance controls. Atomics and Extortion are not yet
|
||
available; complete Moritani games remain unavailable.
|
||
</p>
|
||
</>
|
||
)}
|
||
{entry.stage === 'robbery' && (
|
||
<>
|
||
<p className="m-0 text-sm leading-6">
|
||
Take half the entrant’s spice, rounded up, or draw the top
|
||
Treachery Card. A draw above your hand limit requires you to
|
||
choose a card to discard afterward.
|
||
</p>
|
||
<Button
|
||
className={controlClass}
|
||
disabled={disabled}
|
||
onClick={() => act({ type: 'decision', choice: 'spice' })}
|
||
>
|
||
Take half their spice
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
className={controlClass}
|
||
disabled={disabled}
|
||
onClick={() => act({ type: 'decision', choice: 'card' })}
|
||
>
|
||
Draw a Treachery Card
|
||
</Button>
|
||
</>
|
||
)}
|
||
{entry.stage === 'sneakAttack' && (
|
||
<>
|
||
<p className="m-0 text-sm leading-6">
|
||
Send forces from your reserves into{' '}
|
||
{territory(entry.territory).name}, sector {entry.sector}, for
|
||
free. This entry does not use your ordinary shipment or movement
|
||
turn. You may send no forces.
|
||
</p>
|
||
<label htmlFor={`${formId}-amount`} className="text-sm font-medium">
|
||
Forces from your reserves
|
||
</label>
|
||
<select
|
||
id={`${formId}-amount`}
|
||
className="min-h-11 w-full min-w-0 max-w-full"
|
||
disabled={disabled}
|
||
value={amount}
|
||
aria-describedby={`${formId}-entry-limit`}
|
||
onChange={(event) =>
|
||
setSelectedAmount(Number(event.target.value))
|
||
}
|
||
>
|
||
{Array.from({ length: maximum + 1 }, (_, count) => (
|
||
<option key={count} value={count}>
|
||
{count}
|
||
{count === 0 ? ' · no forces' : ''}
|
||
</option>
|
||
))}
|
||
</select>
|
||
<p id={`${formId}-entry-limit`} className="m-0 text-sm leading-6">
|
||
{sneak?.blocked ??
|
||
`Up to ${maximum} forces are available for this entry.`}
|
||
</p>
|
||
<Button
|
||
className={controlClass}
|
||
disabled={
|
||
disabled || !sneak || amount <= 0 || Boolean(sneak.blocked)
|
||
}
|
||
onClick={() => {
|
||
if (sneak && amount > 0 && !sneak.blocked)
|
||
act({ type: 'decision', amount });
|
||
}}
|
||
>
|
||
Send selected forces
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
className={controlClass}
|
||
disabled={disabled}
|
||
onClick={() => act({ type: 'decision', amount: 0 })}
|
||
>
|
||
Send no forces
|
||
</Button>
|
||
</>
|
||
)}
|
||
{(entry.stage === 'discard' || giving) && (
|
||
<>
|
||
<p className="m-0 text-sm leading-6">
|
||
{giving
|
||
? 'Sabotage’s random discard has resolved where possible. You may now give the entrant one of your own cards, or keep your hand.'
|
||
: 'Your Robbery draw exceeded your hand limit. Choose one of your own cards to discard, including the card just drawn.'}
|
||
</p>
|
||
<label htmlFor={`${formId}-card`} className="text-sm font-medium">
|
||
{giving ? 'Card to give (optional)' : 'Your card to discard'}
|
||
</label>
|
||
<select
|
||
id={`${formId}-card`}
|
||
className="min-h-11 w-full min-w-0 max-w-full"
|
||
disabled={disabled || !hand.length}
|
||
value={giving ? (selected?.id ?? '') : (discardCard?.id ?? '')}
|
||
onChange={(event) => setSelectedCard(event.target.value)}
|
||
>
|
||
{giving && <option value="">None · keep my hand</option>}
|
||
{!giving && !hand.length && (
|
||
<option value="">No available card</option>
|
||
)}
|
||
{hand.map((card) => (
|
||
<option key={card.id} value={card.id}>
|
||
{card.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
<Button
|
||
className={controlClass}
|
||
disabled={disabled || (!giving && !discardCard)}
|
||
onClick={() => {
|
||
if (giving && !selected)
|
||
act({ type: 'decision', decline: true });
|
||
else {
|
||
const card = giving ? selected : discardCard;
|
||
if (card) act({ type: 'decision', card: card.id });
|
||
}
|
||
}}
|
||
>
|
||
{giving
|
||
? selected
|
||
? 'Give selected card'
|
||
: 'Keep my hand'
|
||
: 'Discard selected card'}
|
||
</Button>
|
||
</>
|
||
)}
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|