Files
Dune/components/homeworld-shipment.tsx
T

327 lines
13 KiB
TypeScript

'use client';
import { useState } from 'react';
import type { Action, GameView } from '@/game/engine';
import { specialForceName } from '@/game/combat';
import {
homeworldName,
homeworldShipmentChoice,
richeseHomeworldMarkerChoice,
type HomeworldShipmentSources,
} from '@/game/homeworld-shipment-options';
import { Button } from './ui/button';
import { Input } from './ui/input';
/** This private control is separate from physical-source shipment: the marker
* costs one but does not withdraw a reserve counter until it is revealed. */
export function HomeworldNoFieldShipment({ game, act, busy }: {
game: GameView; act: (action: Action) => void; busy: boolean;
}) {
const me = game.players.find(player => player.id === game.me);
const info = game.richeseNoField;
const [selectedDestination, setSelectedDestination] = useState('');
const [selectedToken, setSelectedToken] = useState('');
const [allyPayment, setAllyPayment] = useState(0);
if (!me || me.faction !== 'richese' || info?.owner !== me.id || !info.private) return null;
const targets = (game.homeworlds?.worlds ?? []).filter(world =>
world.native !== me.id && world.native !== me.ally);
const destination = targets.find(world => world.id === selectedDestination) ?? targets[0];
const tokens = info.private.tokens.filter(token => token.id !== info.private!.lastShipped);
const token = tokens.find(candidate => candidate.id === selectedToken) ?? tokens[0];
const quote = richeseHomeworldMarkerChoice(game, destination?.id ?? '', token?.id ?? '', allyPayment);
return <fieldset disabled={busy || !!me.autopilot || !!game.homeworldShipment?.blocked}
className="space-y-3 pt-3">
<legend>Ship a concealed No-Field</legend>
<p className="fine">Provisional Homeworld rule: ship one concealed marker from your supply to
another non-allied Homeworld for one spice. Its value stays private and its physical forces
remain in your reserves until the marker is revealed. This uses your normal shipment.</p>
<label className="block space-y-1">Destination Homeworld for No-Field
<select aria-label="No-Field destination Homeworld" value={destination?.id ?? ''}
onChange={event => setSelectedDestination(event.target.value)}>
{!targets.length && <option value="">No eligible destination</option>}
{targets.map(world => <option key={world.id} value={world.id}>
{homeworldName(game, world.id)} · {game.players.find(player => player.id === world.native)?.name}
</option>)}
</select>
</label>
<label className="block space-y-1">Private No-Field token
<select aria-label="Private No-Field token" value={token?.id ?? ''}
onChange={event => setSelectedToken(event.target.value)}>
{!tokens.length && <option value="">No different token available</option>}
{tokens.map(candidate => <option key={candidate.id} value={candidate.id}>No-Field {candidate.value}</option>)}
</select>
</label>
{me.ally && <label className="block space-y-1">Allied spice contribution · {game.aid.available} pledged
<Input type="number" min={0} max={Math.min(1, game.aid.available)} step={1} value={allyPayment}
onChange={event => setAllyPayment(Number(event.target.value))} />
</label>}
<output className="notice block" aria-live="polite">
One marker · one spice total · you pay {quote.ownPayment}, ally pays {quote.allyPayment}.
{quote.blocked && ` ${quote.blocked}`}
</output>
<Button className="game-action" disabled={busy || !!me.autopilot || !quote.action}
onClick={() => { if (quote.action) act(quote.action); }}>
Ship No-Field to {homeworldName(game, destination?.id ?? '')}
</Button>
</fieldset>;
}
export function HomeworldShipment({
game: g,
act,
busy,
}: {
game: GameView;
act: (action: Action) => void;
busy: boolean;
}) {
const worlds = g.homeworlds?.worlds ?? [];
const me = g.players.find((player) => player.id === g.me)!;
const available = worlds.filter((world) => {
const force = world.forces[g.me];
return force && force.normal + force.elite > 0;
});
const targets = worlds.filter(
(world) =>
(world.native !== g.me || me.faction === 'guild') &&
!(g.guildTransportCanceled && me.faction === 'guild' && world.native === me.id) &&
world.native !== me.ally,
);
const [origin, setOrigin] = useState(available[0]?.id ?? '');
const [destination, setDestination] = useState(
targets.find((world) => world.id !== available[0]?.id)?.id ?? '',
);
const [sources, setSources] = useState<HomeworldShipmentSources>({});
const [allyPayment, setAllyPayment] = useState(0);
const [nexusEvent, setNexusEvent] = useState('');
const useNexus =
g.karamaShipping?.player !== g.me &&
!!g.nexusGuildSecretAlly &&
nexusEvent === g.nexusGuildSecretAlly.event;
const option = g.homeworldShipment;
if (!option) return null;
const imperial = g.advanced && me.faction === 'emperor';
const selectedOrigins = available.filter((world) =>
origin === 'imperial-pair' ? world.native === me.id : world.id === origin,
);
const selected = Object.fromEntries(
selectedOrigins.flatMap((world) => {
const choice = sources[world.id];
return choice && choice.normal + choice.elite !== 0
? [[world.id, choice]]
: [];
}),
);
const quote = homeworldShipmentChoice(
g,
destination,
selected,
allyPayment,
useNexus ? nexusEvent : undefined,
);
const special = specialForceName(me.faction);
return (
<details className="rounded-lg border border-[#a88b60]/50 p-3">
<summary className="cursor-pointer py-2">
Ship to another Homeworld
</summary>
<p className="fine">
Use your shipment to send forces from one Homeworld to another. Native
reserves and foreign garrisons have separate source pools.{' '}
{me.faction === 'guild'
? g.guildTransportCanceled
? 'The native Guild return permission is canceled: Junction cannot be a destination, even with a purchased price.'
: 'Guild may also return a foreign garrison to Junction.'
: 'Your own Homeworld cannot be a destination.'}{' '}
Allied Homeworlds cannot be destinations.
</p>
<p className="fine">
{useNexus
? 'Selected Guild Nexus price: one spice per two forces, rounded up.'
: g.karamaShipping?.player === g.me
? 'Purchased Karama price: one spice per two forces, rounded up, paid entirely to the bank even if the native Guild rate is canceled.'
: me.faction === 'guild'
? g.guildRateCanceled
? 'The native Guild rate is canceled: pay one spice per force.'
: 'Guild pays one spice per two forces, rounded up.'
: 'Pay one spice per force.'}{' '}
This uses your shipment; your normal movement remains available.
</p>
{g.nexusGuildSecretAlly && (
<div className="notice">
<label className="decision-checkbox min-h-11">
<input
type="checkbox"
checked={useNexus}
disabled={
busy ||
!!g.nexusGuildSecretAlly.blocked ||
g.karamaShipping?.player === g.me
}
onChange={(event) =>
setNexusEvent(
event.target.checked ? g.nexusGuildSecretAlly!.event : '',
)
}
/>
Use Guild Nexus Secret Ally for Homeworld shipment
</label>
<p>
Spend the Nexus for its independent price of one spice per two
forces, rounded up; it does not stack with a purchased Karama price.
Existing source and destination restrictions still apply.
</p>
{g.nexusGuildSecretAlly.blocked && (
<p>{g.nexusGuildSecretAlly.blocked}</p>
)}
</div>
)}
{option.blocked && (
<output className="notice block">{option.blocked}</output>
)}
<fieldset disabled={busy || !!option.blocked} className="space-y-3 pt-3">
<label className="block space-y-1">
<span>Source Homeworld</span>
<select
aria-label="Source Homeworld"
className="w-full"
value={origin}
onChange={(event) => {
setOrigin(event.target.value);
setSources({});
}}
>
{!available.length && (
<option value="">No forces on Homeworlds</option>
)}
{available.map((world) => (
<option key={world.id} value={world.id}>
{homeworldName(g, world.id)} ·{' '}
{world.native === me.id
? 'native reserves'
: 'foreign garrison'}
</option>
))}
{imperial &&
available.filter((world) => world.native === me.id).length ===
2 && (
<option value="imperial-pair">
Kaitain + Salusa Secundus · combined native shipment
</option>
)}
</select>
</label>
{selectedOrigins.map((world) => {
const pool = world.forces[me.id];
const chosen = sources[world.id] ?? { normal: 0, elite: 0 };
return (
<fieldset
key={world.id}
className="space-y-2 rounded border border-[#a88b60]/30 p-3"
>
<legend>
{homeworldName(g, world.id)} ·{' '}
{world.native === me.id
? 'native reserves'
: 'foreign garrison'}
</legend>
<p className="fine">
Available: {pool.normal} normal
{pool.elite > 0 ? ` + ${pool.elite} ${special}` : ''}.
</p>
{(['normal', 'elite'] as const)
.filter((kind) => kind === 'normal' || pool.elite > 0)
.map((kind) => (
<label key={kind} className="block space-y-1">
<span>
{kind === 'normal' ? 'Normal forces' : special} from{' '}
{homeworldName(g, world.id)}
</span>
<Input
type="number"
min={0}
max={pool[kind]}
step={1}
value={chosen[kind]}
onChange={(event) =>
setSources({
...sources,
[world.id]: {
...chosen,
[kind]: Number(event.target.value),
},
})
}
/>
</label>
))}
</fieldset>
);
})}
<label className="block space-y-1">
<span>Destination Homeworld</span>
<select
aria-label="Destination Homeworld"
className="w-full"
value={destination}
onChange={(event) => setDestination(event.target.value)}
>
{!targets.length && (
<option value="">No eligible destination</option>
)}
{targets.map((world) => (
<option
key={world.id}
value={world.id}
disabled={selectedOrigins.some(
(source) => source.id === world.id,
)}
>
{homeworldName(g, world.id)} ·{' '}
{world.native === me.id
? 'return to your Homeworld'
: g.players.find((player) => player.id === world.native)
?.name}
</option>
))}
</select>
</label>
{me.ally && (
<label className="block space-y-1">
<span>Allied spice contribution · {g.aid.available} pledged</span>
<Input
type="number"
min={0}
max={Math.min(quote.cost, g.aid.available)}
step={1}
value={allyPayment}
onChange={(event) => setAllyPayment(Number(event.target.value))}
/>
</label>
)}
<p className="fine">
Your available spice: {me.spice ?? 0}. Allied pledge:{' '}
{g.aid.available}.
</p>
<output className="notice block" aria-live="polite">
{quote.amount} forces · {quote.cost} spice total
{quote.amount > 0
? ` · you pay ${quote.ownPayment}, ally pays ${quote.allyPayment}`
: ''}
.{quote.blocked && ` ${quote.blocked}`}
</output>
<Button
className="game-action"
disabled={busy || !quote.action}
onClick={() => {
if (quote.action) act(quote.action);
}}
>
Ship to {homeworldName(g, destination)}
</Button>
</fieldset>
<HomeworldNoFieldShipment game={g} act={act} busy={busy} />
</details>
);
}