Support Basic No-Field Guild Ambassador shipments
Connect native concealment and allied supplier/recipient consent to the independent grant, with explicit provisional cap and transport-fee policy. Preserve actual tokens, ordinary allowances, source-scoped Karama prevention and original income/arrival continuation. Add genuine three/four-seat sources, focused quote/control/engine cases and bounded runtime/minimal-bot proof. Advanced and optional profiles retain their existing guards.
This commit is contained in:
1 parent
4b2f4f3fa3
commit
659e2a4a9a
19 files changed
+2221
-56
No files matched your search
@@ -0,0 +1,172 @@
|
||||
'use client';
|
||||
|
||||
import { useId, useState } from 'react';
|
||||
import type { Action, GameView } from '@/game/engine';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
export type AmbassadorNoFieldOffer = NonNullable<
|
||||
NonNullable<GameView['ambassadorEntry']>['noField']
|
||||
>;
|
||||
|
||||
const titles: Record<AmbassadorNoFieldOffer['stage'], string> = {
|
||||
own: 'Use your No-Field',
|
||||
request: 'Request an allied No-Field',
|
||||
supply: 'Supply an allied No-Field',
|
||||
consent: 'Confirm allied No-Field shipment',
|
||||
};
|
||||
|
||||
const submitLabels: Record<AmbassadorNoFieldOffer['stage'], string> = {
|
||||
own: 'Confirm No-Field choice',
|
||||
request: 'Request selected No-Field shipment',
|
||||
supply: 'Offer selected No-Field shipment',
|
||||
consent: 'Accept selected No-Field offer',
|
||||
};
|
||||
|
||||
/** Receives complete server actions; selecting a marker never edits a physical shipment. */
|
||||
export function AmbassadorNoFieldChoice({
|
||||
offer,
|
||||
event,
|
||||
selectedKey,
|
||||
onSelect,
|
||||
act,
|
||||
busy,
|
||||
id = 'ambassador-no-field-choice',
|
||||
}: {
|
||||
offer: AmbassadorNoFieldOffer;
|
||||
event: string;
|
||||
selectedKey: string;
|
||||
onSelect: (key: string) => void;
|
||||
act: (action: Action) => void;
|
||||
busy: boolean;
|
||||
id?: string;
|
||||
}) {
|
||||
const selected = offer.options.find((option) => option.key === selectedKey);
|
||||
const reason = selected?.blocked ??
|
||||
(selected ? null : 'Choose a No-Field option.');
|
||||
return (
|
||||
<section
|
||||
className="flex min-w-0 flex-col gap-3 rounded border p-3"
|
||||
aria-label="Ambassador No-Field shipment"
|
||||
data-event={event}
|
||||
data-stage={offer.stage}
|
||||
>
|
||||
<h4 className="m-0 font-semibold">{titles[offer.stage]}</h4>
|
||||
<p className="m-0 text-sm leading-6">
|
||||
Choose a No-Field as the entire Ambassador shipment grant.
|
||||
</p>
|
||||
<p className="m-0 text-sm leading-6">{offer.policy}</p>
|
||||
<label className="flex flex-col gap-2" htmlFor={`${id}-option`}>
|
||||
No-Field choice
|
||||
<select
|
||||
id={`${id}-option`}
|
||||
className="min-h-11"
|
||||
value={selected?.key ?? ''}
|
||||
disabled={busy || !offer.options.length}
|
||||
onChange={(e) => {
|
||||
if (!busy) onSelect(e.target.value);
|
||||
}}
|
||||
>
|
||||
<option value="">Choose a No-Field option</option>
|
||||
{offer.options.map((option) => (
|
||||
<option key={option.key} value={option.key} disabled={!!option.blocked}>
|
||||
{option.label}{option.blocked ? ` · ${option.blocked}` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{reason && <p id={`${id}-reason`} className="m-0 text-sm">{reason}</p>}
|
||||
<Button
|
||||
data-testid="ambassador-no-field-submit"
|
||||
className="min-h-11 whitespace-normal"
|
||||
disabled={busy || !!reason}
|
||||
aria-describedby={reason ? `${id}-reason` : undefined}
|
||||
onClick={() => {
|
||||
if (!busy && selected && !selected.blocked) act(selected.action);
|
||||
}}
|
||||
>
|
||||
{submitLabels[offer.stage]}
|
||||
</Button>
|
||||
{offer.decline && (
|
||||
<Button
|
||||
data-testid="ambassador-no-field-decline"
|
||||
className="min-h-11 whitespace-normal"
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
if (!busy && offer.decline) act(offer.decline);
|
||||
}}
|
||||
>
|
||||
{offer.declineLabel ?? 'Decline No-Field offer'}
|
||||
</Button>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function AmbassadorNoFieldSelection({
|
||||
offer,
|
||||
event,
|
||||
act,
|
||||
busy,
|
||||
}: {
|
||||
offer: AmbassadorNoFieldOffer;
|
||||
event: string;
|
||||
act: (action: Action) => void;
|
||||
busy: boolean;
|
||||
}) {
|
||||
const id = useId();
|
||||
const [selectedKey, setSelectedKey] = useState('');
|
||||
return (
|
||||
<AmbassadorNoFieldChoice
|
||||
id={id}
|
||||
offer={offer}
|
||||
event={event}
|
||||
selectedKey={selectedKey}
|
||||
onSelect={setSelectedKey}
|
||||
act={act}
|
||||
busy={busy}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function AmbassadorNoField({
|
||||
game,
|
||||
act,
|
||||
busy,
|
||||
destination,
|
||||
sector,
|
||||
}: {
|
||||
game: GameView;
|
||||
act: (action: Action) => void;
|
||||
busy: boolean;
|
||||
destination?: string;
|
||||
sector?: number;
|
||||
}) {
|
||||
const entry = game.ambassadorEntry;
|
||||
if (
|
||||
entry?.stage !== 'ship' ||
|
||||
game.decision?.kind !== 'ecazAmbassador' ||
|
||||
game.decision.player !== game.me ||
|
||||
!entry.noField
|
||||
) return null;
|
||||
const offer = entry.noField;
|
||||
const filtered = offer.stage === 'own' || offer.stage === 'request'
|
||||
? {
|
||||
...offer,
|
||||
options: offer.options.filter((option) =>
|
||||
!option.territory ||
|
||||
((!destination || option.territory === destination) &&
|
||||
(sector === undefined || option.sector === sector)),
|
||||
),
|
||||
}
|
||||
: offer;
|
||||
return (
|
||||
<AmbassadorNoFieldSelection
|
||||
key={`${entry.event}:${offer.stage}:${destination ?? ''}:${sector ?? ''}`}
|
||||
offer={filtered}
|
||||
event={entry.event}
|
||||
act={act}
|
||||
busy={busy}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import type { Action, GameView } from '@/game/engine';
|
||||
import { location, territory } from '@/game/board';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
import { AmbassadorNoField } from './ambassador-no-field';
|
||||
|
||||
const bounded = (value: number, min: number, max: number) =>
|
||||
Math.max(min, Math.min(max, Number.isSafeInteger(value) ? value : min));
|
||||
@@ -26,24 +27,46 @@ export function AmbassadorShipment({
|
||||
const entry = game.ambassadorEntry;
|
||||
if (
|
||||
entry?.stage !== 'ship' ||
|
||||
entry.beneficiary !== game.me ||
|
||||
game.decision?.kind !== 'ecazAmbassador' ||
|
||||
game.decision.player !== game.me ||
|
||||
!entry.shipment
|
||||
game.decision.player !== game.me
|
||||
)
|
||||
return null;
|
||||
if (entry.noField?.stage === 'supply' || entry.noField?.stage === 'consent')
|
||||
return <AmbassadorNoField game={game} act={act} busy={busy} />;
|
||||
if (entry.beneficiary !== game.me || !entry.shipment) return null;
|
||||
const me = game.players.find((p) => p.id === game.me)!;
|
||||
const shipment = entry.shipment;
|
||||
const destinations = shipment.destinations.map((d) => ({
|
||||
territory: d.territory,
|
||||
sector: d.sector,
|
||||
blocked: d.blocked,
|
||||
}));
|
||||
for (const option of entry.noField?.options ?? []) {
|
||||
if (!option.territory || option.sector === undefined) continue;
|
||||
const existing = destinations.find((d) =>
|
||||
d.territory === option.territory && d.sector === option.sector,
|
||||
);
|
||||
if (existing) {
|
||||
if (!option.blocked) existing.blocked = null;
|
||||
} else destinations.push({
|
||||
territory: option.territory,
|
||||
sector: option.sector,
|
||||
blocked: option.blocked,
|
||||
});
|
||||
}
|
||||
const destination =
|
||||
shipment.destinations.find(
|
||||
destinations.find(
|
||||
(d) => location(d.territory, d.sector) === destinationKey,
|
||||
) ??
|
||||
shipment.destinations.find((d) => !d.blocked) ??
|
||||
shipment.destinations[0];
|
||||
destinations.find((d) => !d.blocked) ??
|
||||
destinations[0];
|
||||
const physicalDestination = shipment.destinations.find((d) =>
|
||||
d.territory === destination?.territory && d.sector === destination?.sector,
|
||||
);
|
||||
const maximum = Math.min(
|
||||
shipment.maximum,
|
||||
me.reserves,
|
||||
destination?.maximum ?? shipment.maximum,
|
||||
physicalDestination?.maximum ?? 0,
|
||||
);
|
||||
const amount = maximum ? bounded(count ?? maximum, 1, maximum) : 0;
|
||||
const minimumElite = Math.max(
|
||||
@@ -54,11 +77,11 @@ export function AmbassadorShipment({
|
||||
const elite = bounded(eliteCount, minimumElite, maximumElite);
|
||||
const reason = !amount
|
||||
? 'You have no reserve forces available for this shipment.'
|
||||
: !destination
|
||||
: !physicalDestination
|
||||
? 'No shipment destination is currently available.'
|
||||
: destination.blocked;
|
||||
: physicalDestination.blocked;
|
||||
const territories = [
|
||||
...new Set(shipment.destinations.map((d) => d.territory)),
|
||||
...new Set(destinations.map((d) => d.territory)),
|
||||
];
|
||||
return (
|
||||
<div
|
||||
@@ -112,9 +135,9 @@ export function AmbassadorShipment({
|
||||
id={`${id}-territory`}
|
||||
className="min-h-11"
|
||||
value={destination?.territory ?? ''}
|
||||
disabled={busy || !shipment.destinations.length}
|
||||
disabled={busy || !destinations.length}
|
||||
onChange={(e) => {
|
||||
const options = shipment.destinations.filter(
|
||||
const options = destinations.filter(
|
||||
(d) => d.territory === e.target.value,
|
||||
);
|
||||
const chosen = options.find((d) => !d.blocked) ?? options[0];
|
||||
@@ -142,7 +165,7 @@ export function AmbassadorShipment({
|
||||
disabled={busy || !destination}
|
||||
onChange={(e) => setDestinationKey(e.target.value)}
|
||||
>
|
||||
{shipment.destinations
|
||||
{destinations
|
||||
.filter((d) => d.territory === destination?.territory)
|
||||
.map((d) => (
|
||||
<option key={d.sector} value={location(d.territory, d.sector)}>
|
||||
@@ -152,16 +175,13 @@ export function AmbassadorShipment({
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{destination?.advisors && (
|
||||
{physicalDestination?.advisors && (
|
||||
<p className="m-0 text-sm">These forces join your advisors.</p>
|
||||
)}
|
||||
<p className="m-0 text-sm" aria-live="polite">
|
||||
Selected: {amount} physical forces, including {elite} elites. Cost: 0
|
||||
spice.
|
||||
</p>
|
||||
<p className="m-0 text-sm">
|
||||
No-Field substitution for this effect remains unfinished.
|
||||
</p>
|
||||
{reason && (
|
||||
<p id={`${id}-reason`} className="m-0 text-sm leading-6">
|
||||
{reason}
|
||||
@@ -195,6 +215,13 @@ export function AmbassadorShipment({
|
||||
>
|
||||
Send no forces
|
||||
</Button>
|
||||
<AmbassadorNoField
|
||||
game={game}
|
||||
act={act}
|
||||
busy={busy}
|
||||
destination={destination?.territory}
|
||||
sector={destination?.sector}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user