Files
Dune/components/ambassador-movement.tsx
T
Quintine c999054ae0 Counter incoming BG matching before Fremen Ambassador relocation
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.
2026-10-24 22:38:55 +11:00

386 lines
14 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 { 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>
);
}