Files
Dune/components/homeworld-shipment.tsx
Quintine 7e3d745807
Verify and publish checkpoint container / container (push) Canceled after 0s
Prototype native Homeworld No-Field return
2026-10-29 04:55:44 +11:00

348 lines
15 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';
/** The concealed marker does not withdraw a reserve counter until 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);
const [forces, setForces] = useState({ normal: 0, elite: 0 });
if (!me || me.faction !== 'richese' || info?.owner !== me.id || !info.private) return null;
const deployed = info.private.deployed;
const source = deployed && 'homeworld' in deployed.location ? deployed.location.homeworld : undefined;
const targets = (game.homeworlds?.worlds ?? []).filter(world =>
(world.native !== me.id || !!source && game.advanced) &&
world.native !== me.ally && world.id !== source);
const destination = targets.find(world => world.id === selectedDestination) ??
targets.find(world => world.native !== me.id) ?? targets[0];
const tokens = info.private.tokens.filter(token => source ? token.id === deployed?.tokenId : token.id !== info.private!.lastShipped);
const token = tokens.find(candidate => candidate.id === selectedToken) ?? tokens[0];
const pool = source ? game.homeworlds?.worlds?.find(world => world.id === source)?.forces[me.id] :
{ normal: me.reserves - (me.elites?.reserves ?? 0), elite: me.elites?.reserves ?? 0 };
const quote = richeseHomeworldMarkerChoice(game, destination?.id ?? '', token?.id ?? '', allyPayment, source,
forces);
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 {source ? `from ${homeworldName(game, source)}` : 'from your supply'} to
another non-allied Homeworld. Its value stays private and its represented forces
remain in your reserves until the marker is revealed. On your own Homeworld,
the represented forces are already in those reserves and are not added again.
This uses your normal shipment. Physical forces from the same source may accompany it.</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>
{pool && <fieldset className="space-y-2">
<legend>Accompanying forces from {source ? homeworldName(game, source) : 'your reserves'}</legend>
{(['normal', 'elite'] as const).filter(kind => kind === 'normal' || pool.elite > 0).map(kind =>
<label key={kind} className="block space-y-1">
{kind === 'normal' ? 'Normal forces' : specialForceName(me.faction)} accompanying No-Field · {pool[kind]} available
<Input type="number" min={0} max={pool[kind]} step={1} value={forces[kind]}
onChange={event => setForces({ ...forces, [kind]: Number(event.target.value) })} />
</label>)}
</fieldset>}
{me.ally && <label className="block space-y-1">Allied spice contribution · {game.aid.available} pledged
<Input type="number" min={0} max={Math.min(quote.cost, game.aid.available)} step={1} value={allyPayment}
onChange={event => setAllyPayment(Number(event.target.value))} />
</label>}
<output className="notice block" aria-live="polite">
{quote.amount} total (including one marker) · {quote.cost === 1 ? 'one' : quote.cost} 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) =>
(g.advanced || world.native !== g.me || me.faction === 'guild') &&
!(!g.advanced && 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 && world.native !== g.me) ??
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.{' '}
{g.advanced
? 'You may return a foreign garrison to your own Homeworld.'
: 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>
);
}