Bind the selected packet to its original allied grant before transfer or collection. Preserve the committed trigger, restore legal replacement choices after prevention and resume the original movement once.
386 lines
14 KiB
TypeScript
386 lines
14 KiB
TypeScript
'use client';
|
||
|
||
import { useId, useState } from 'react';
|
||
import type { Action, GameView } from '@/game/engine';
|
||
import { location, splitLocation, territory } from '@/game/board';
|
||
import { sandmasterDestinationCollection } from '@/game/sandmaster-movement';
|
||
import { quoteAdvisorAmbassadorMatching, AdvisorAmbassadorMatchingError } from '@/game/advisor-ambassador-matching';
|
||
import { Button } from './ui/button';
|
||
import { Input } from './ui/input';
|
||
|
||
const bounded = (value: number | undefined, minimum: number, maximum: number) =>
|
||
Math.max(
|
||
minimum,
|
||
Math.min(maximum, Number.isSafeInteger(value) ? value! : minimum),
|
||
);
|
||
|
||
/** A draft belongs to this Ambassador event, never to the entrant's normal move. */
|
||
export function AmbassadorMovement({
|
||
game,
|
||
act,
|
||
busy,
|
||
}: {
|
||
game: GameView;
|
||
act: (action: Action) => void;
|
||
busy: boolean;
|
||
}) {
|
||
const id = useId();
|
||
const [sourceId, setSourceId] = useState('');
|
||
const [destinationKey, setDestinationKey] = useState('');
|
||
const [forces, setForces] = useState<Record<string, number>>({});
|
||
const [elites, setElites] = useState<Record<string, number>>({});
|
||
const [takeMarker, setTakeMarker] = useState(false);
|
||
const [fighters, setFighters] = useState(false);
|
||
const [collectSandmaster, setCollectSandmaster] = useState(true);
|
||
const [sandmasterSelection, setSandmasterSelection] = useState<{
|
||
source: string;
|
||
target: string;
|
||
key: string;
|
||
} | null>(null);
|
||
const entry = game.ambassadorEntry;
|
||
if (
|
||
entry?.stage !== 'move' ||
|
||
entry.beneficiary !== game.me ||
|
||
game.decision?.kind !== 'ecazAmbassador' ||
|
||
game.decision.player !== game.me ||
|
||
!entry.movement
|
||
)
|
||
return null;
|
||
const sources = entry.movement.sources;
|
||
const source = sources.find((s) => s.territory === sourceId) ?? sources[0];
|
||
if (!source)
|
||
return (
|
||
<p className="m-0 text-sm leading-6">
|
||
No legal relocation is currently available.
|
||
</p>
|
||
);
|
||
const destinations = source.destinations;
|
||
const destination =
|
||
destinations.find(
|
||
(d) => location(d.territory, d.sector) === destinationKey,
|
||
) ??
|
||
destinations.find((d) => !d.blocked) ??
|
||
destinations[0];
|
||
const target = destination
|
||
? location(destination.territory, destination.sector)
|
||
: '';
|
||
const rows = source.sectors.map((s) => {
|
||
const count = s.key === target ? 0 : bounded(forces[s.key], 0, s.forces);
|
||
const minimum = Math.max(0, count - (s.forces - s.elites));
|
||
return {
|
||
...s,
|
||
count,
|
||
minimum,
|
||
maximum: Math.min(count, s.elites),
|
||
elite: bounded(elites[s.key], minimum, Math.min(count, s.elites)),
|
||
};
|
||
});
|
||
const selectedForces = Object.fromEntries(
|
||
rows.filter((s) => s.count > 0).map((s) => [s.key, s.count]),
|
||
);
|
||
const selectedElites = Object.fromEntries(
|
||
rows.filter((s) => s.count > 0).map((s) => [s.key, s.elite]),
|
||
);
|
||
const total = rows.reduce((n, s) => n + s.count, 0);
|
||
const markerAtTarget =
|
||
!!source.marker &&
|
||
location(source.territory, source.marker.sector) === target;
|
||
const includesMarker = !!source.marker && takeMarker && !markerAtTarget;
|
||
const sandmaster = !includesMarker && destination
|
||
? sandmasterDestinationCollection(game, game.me, source.territory, destination.territory, destination.sector)
|
||
: null;
|
||
const sandmasterPile =
|
||
sandmasterSelection?.source === source.territory &&
|
||
sandmasterSelection.target === target &&
|
||
sandmaster?.piles.some((pile) => pile.key === sandmasterSelection.key)
|
||
? sandmasterSelection.key
|
||
: sandmaster?.key;
|
||
let matchingBlock:string|null=null;
|
||
if(destination&&total>0){
|
||
try{
|
||
quoteAdvisorAmbassadorMatching({...game,pendingAmbassador:{...entry,turn:game.turn,phase:game.phase,
|
||
beneficiary:game.me},advisorAmbassadorMatchingDenied:entry.matchingDenied},
|
||
{parent:entry.event,owner:game.me,origin:source.territory,destination:destination.territory,
|
||
sector:destination.sector,advisors:destination.advisors,group:selectedForces});
|
||
}catch(error){if(!(error instanceof AdvisorAmbassadorMatchingError))throw error;matchingBlock=error.message;}
|
||
}
|
||
const reason =
|
||
destination?.blocked ??
|
||
matchingBlock ??
|
||
(!destination
|
||
? 'Choose an available destination.'
|
||
: !total && !includesMarker
|
||
? 'Choose physical forces or the concealed No-Field to relocate.'
|
||
: destination.maximum !== null &&
|
||
total + Number(includesMarker) > destination.maximum
|
||
? `At most ${destination.maximum} entering forces are currently supported at this destination; the concealed marker counts as one.`
|
||
: null);
|
||
const chooseDestination = (key: string) => {
|
||
setDestinationKey(key);
|
||
setForces((old) => ({ ...old, [key]: 0 }));
|
||
setElites((old) => ({ ...old, [key]: 0 }));
|
||
setFighters(false);
|
||
setSandmasterSelection(null);
|
||
};
|
||
const targetTerritories = [...new Set(destinations.map((d) => d.territory))];
|
||
return (
|
||
<div
|
||
className="flex min-w-0 flex-col gap-4"
|
||
aria-label="Ambassador force relocation"
|
||
>
|
||
<p className="m-0 text-sm leading-6">
|
||
Relocate a group from one territory. This does not spend your ordinary
|
||
shipment or movement. The Ambassador has already been triggered.
|
||
</p>
|
||
{game.advanced&&game.players.find(player=>player.id===game.me)?.faction==='beneGesserit'&&(
|
||
<p className="notice">An opposite-type incoming group has a separate matching response before relocation. If matching is prevented, choose another legal packet or destination; the original trigger stays committed.</p>
|
||
)}
|
||
<label className="flex flex-col gap-2" htmlFor={`${id}-source`}>
|
||
Source territory
|
||
<select
|
||
id={`${id}-source`}
|
||
className="min-h-11"
|
||
disabled={busy}
|
||
value={source.territory}
|
||
onChange={(event) => {
|
||
setSourceId(event.target.value);
|
||
setDestinationKey('');
|
||
setForces({});
|
||
setElites({});
|
||
setTakeMarker(false);
|
||
setFighters(false);
|
||
setSandmasterSelection(null);
|
||
}}
|
||
>
|
||
{sources.map((s) => (
|
||
<option key={s.territory} value={s.territory}>
|
||
{territory(s.territory).name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
{rows.map((s) => (
|
||
<fieldset
|
||
className="flex min-w-0 flex-col gap-3 rounded-lg border p-3"
|
||
key={s.key}
|
||
disabled={busy}
|
||
>
|
||
<legend className="px-1 text-sm">
|
||
Sector {splitLocation(s.key).sector} · {s.forces} physical forces
|
||
available
|
||
</legend>
|
||
<label
|
||
className="flex flex-col gap-2"
|
||
htmlFor={`${id}-forces-${s.key}`}
|
||
>
|
||
Physical forces to relocate
|
||
<Input
|
||
id={`${id}-forces-${s.key}`}
|
||
className="min-h-11"
|
||
type="number"
|
||
min={0}
|
||
max={s.forces}
|
||
step={1}
|
||
value={s.count}
|
||
disabled={s.key === target}
|
||
onChange={(e) =>
|
||
setForces({
|
||
...forces,
|
||
[s.key]: bounded(Number(e.target.value), 0, s.forces),
|
||
})
|
||
}
|
||
/>
|
||
</label>
|
||
{s.elites > 0 && (
|
||
<label
|
||
className="flex flex-col gap-2"
|
||
htmlFor={`${id}-elites-${s.key}`}
|
||
>
|
||
Elite forces included · {s.minimum}–{s.maximum}
|
||
<Input
|
||
id={`${id}-elites-${s.key}`}
|
||
className="min-h-11"
|
||
type="number"
|
||
min={s.minimum}
|
||
max={s.maximum}
|
||
step={1}
|
||
value={s.elite}
|
||
disabled={s.key === target || !s.count}
|
||
onChange={(e) =>
|
||
setElites({
|
||
...elites,
|
||
[s.key]: bounded(
|
||
Number(e.target.value),
|
||
s.minimum,
|
||
s.maximum,
|
||
),
|
||
})
|
||
}
|
||
/>
|
||
</label>
|
||
)}
|
||
{s.key === target && (
|
||
<p className="m-0 text-sm">
|
||
Forces already in the destination sector stay in place.
|
||
</p>
|
||
)}
|
||
</fieldset>
|
||
))}
|
||
{source.marker && (
|
||
<div className="flex flex-col gap-2">
|
||
<label className="flex min-h-11 items-center gap-3">
|
||
<input
|
||
type="checkbox"
|
||
disabled={busy || markerAtTarget}
|
||
checked={includesMarker}
|
||
onChange={(e) => setTakeMarker(e.target.checked)}
|
||
/>
|
||
Include concealed No-Field from sector {source.marker.sector}
|
||
</label>
|
||
<p className="m-0 text-sm">
|
||
The marker can move alone or with physical forces. Its concealed
|
||
value stays unchanged.
|
||
{markerAtTarget
|
||
? ' It is already in the destination sector and stays in place.'
|
||
: ''}
|
||
</p>
|
||
</div>
|
||
)}
|
||
<label className="flex flex-col gap-2" htmlFor={`${id}-destination`}>
|
||
Destination territory
|
||
<select
|
||
id={`${id}-destination`}
|
||
className="min-h-11"
|
||
disabled={busy}
|
||
value={destination?.territory ?? ''}
|
||
onChange={(e) => {
|
||
const options = destinations.filter(
|
||
(d) => d.territory === e.target.value,
|
||
);
|
||
const chosen = options.find((d) => !d.blocked) ?? options[0];
|
||
if (chosen)
|
||
chooseDestination(location(chosen.territory, chosen.sector));
|
||
}}
|
||
>
|
||
{targetTerritories.map((t) => (
|
||
<option key={t} value={t}>
|
||
{territory(t).name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<label className="flex flex-col gap-2" htmlFor={`${id}-sector`}>
|
||
Destination sector
|
||
<select
|
||
id={`${id}-sector`}
|
||
className="min-h-11"
|
||
disabled={busy}
|
||
value={target}
|
||
onChange={(e) => chooseDestination(e.target.value)}
|
||
>
|
||
{destinations
|
||
.filter((d) => d.territory === destination?.territory)
|
||
.map((d) => (
|
||
<option key={d.sector} value={location(d.territory, d.sector)}>
|
||
{d.sector === 0 ? 'Sheltered interior' : d.sector}
|
||
{d.blocked ? ' · Unavailable' : ''}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
{destination?.advisors && (
|
||
<p className="m-0 text-sm">This group arrives as advisors.</p>
|
||
)}
|
||
{destination?.canFight && (
|
||
<label className="flex min-h-11 items-center gap-3">
|
||
<input
|
||
type="checkbox"
|
||
checked={fighters}
|
||
disabled={busy}
|
||
onChange={(e) => setFighters(e.target.checked)}
|
||
/>
|
||
Request a flip to fighters on arrival
|
||
</label>
|
||
)}
|
||
{sandmaster && (
|
||
<label className="flex min-h-11 items-center gap-3">
|
||
<input
|
||
type="checkbox"
|
||
checked={collectSandmaster && !sandmaster.blocked}
|
||
disabled={busy || !!sandmaster.blocked}
|
||
onChange={(event) => setCollectSandmaster(event.target.checked)}
|
||
/>
|
||
{sandmaster.blocked ?? 'Collect 1 spice here with Sandmaster before arrival reactions'}
|
||
</label>
|
||
)}
|
||
{sandmaster && !sandmaster.blocked && sandmaster.piles.length > 1 && (
|
||
<label className="flex flex-col gap-2" htmlFor={`${id}-sandmaster-pile`}>
|
||
Sandmaster debit sector — provisional owner allocation
|
||
<select
|
||
id={`${id}-sandmaster-pile`}
|
||
className="min-h-11"
|
||
disabled={busy || !collectSandmaster}
|
||
value={sandmasterPile ?? ''}
|
||
onChange={(event) => setSandmasterSelection({
|
||
source: source.territory,
|
||
target,
|
||
key: event.target.value,
|
||
})}
|
||
>
|
||
{sandmaster.piles.map((pile) => (
|
||
<option key={pile.key} value={pile.key}>
|
||
Sector {splitLocation(pile.key).sector}: {pile.before} → {pile.before - 1}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
)}
|
||
{sandmaster && !sandmaster.blocked && sandmaster.piles.length > 1 && (
|
||
<p className="fine">You choose the one-spice debit sector. This provisional allocation policy is not a publisher ruling.</p>
|
||
)}
|
||
<p className="m-0 text-sm" aria-live="polite">
|
||
Selected: {total} physical forces
|
||
{includesMarker ? ' and one concealed No-Field' : ''}.
|
||
</p>
|
||
{reason && (
|
||
<p className="m-0 text-sm leading-6" id={`${id}-reason`}>
|
||
{reason}
|
||
</p>
|
||
)}
|
||
<Button
|
||
className="min-h-11 whitespace-normal"
|
||
disabled={busy || !!reason}
|
||
aria-describedby={reason ? `${id}-reason` : undefined}
|
||
onClick={() => {
|
||
if (busy || reason || !destination) return;
|
||
act({
|
||
type: 'decision',
|
||
event: entry.event,
|
||
forces: selectedForces,
|
||
eliteForces: selectedElites,
|
||
territory: destination.territory,
|
||
sector: destination.sector,
|
||
...(includesMarker && source.marker
|
||
? {
|
||
noField: {
|
||
tokenId: source.marker.tokenId,
|
||
event: source.marker.event,
|
||
},
|
||
}
|
||
: {}),
|
||
...(fighters && destination.canFight ? { fighters: true } : {}),
|
||
...(sandmaster?.blocked === null
|
||
? {
|
||
sandmasterCollect: collectSandmaster,
|
||
...(collectSandmaster && sandmasterPile ? { sandmasterPile } : {}),
|
||
}
|
||
: {}),
|
||
});
|
||
}}
|
||
>
|
||
Relocate selected forces
|
||
</Button>
|
||
</div>
|
||
);
|
||
}
|