Implement Homeworld mechanics through Caladan reinforcement checkpoint
Connect typed Homeworld custody, transport, battle, income, revival and Caladan reinforcement flows with private multiplayer recovery and controls. Preserve the optimized test workflow and keep unfinished rules modes gated. Verified with 3682 offline tests, typecheck, lint, production build and 40 HTTP tests; existing saved games remain unchanged.
This commit is contained in:
1 parent
3be49512f7
commit
28ba1618ba
266 files changed
+47680
-16851
No files matched your search
@@ -84,7 +84,10 @@ export function AmbassadorMovement({
|
||||
? 'Choose an available destination.'
|
||||
: !total && !includesMarker
|
||||
? 'Choose physical forces or the concealed No-Field to relocate.'
|
||||
: null);
|
||||
: 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 }));
|
||||
|
||||
@@ -34,7 +34,17 @@ export function AmbassadorShipment({
|
||||
return null;
|
||||
const me = game.players.find((p) => p.id === game.me)!;
|
||||
const shipment = entry.shipment;
|
||||
const maximum = Math.min(shipment.maximum, me.reserves);
|
||||
const destination =
|
||||
shipment.destinations.find(
|
||||
(d) => location(d.territory, d.sector) === destinationKey,
|
||||
) ??
|
||||
shipment.destinations.find((d) => !d.blocked) ??
|
||||
shipment.destinations[0];
|
||||
const maximum = Math.min(
|
||||
shipment.maximum,
|
||||
me.reserves,
|
||||
destination?.maximum ?? shipment.maximum,
|
||||
);
|
||||
const amount = maximum ? bounded(count ?? maximum, 1, maximum) : 0;
|
||||
const minimumElite = Math.max(
|
||||
0,
|
||||
@@ -42,12 +52,6 @@ export function AmbassadorShipment({
|
||||
);
|
||||
const maximumElite = Math.min(amount, shipment.eliteReserves);
|
||||
const elite = bounded(eliteCount, minimumElite, maximumElite);
|
||||
const destination =
|
||||
shipment.destinations.find(
|
||||
(d) => location(d.territory, d.sector) === destinationKey,
|
||||
) ??
|
||||
shipment.destinations.find((d) => !d.blocked) ??
|
||||
shipment.destinations[0];
|
||||
const reason = !amount
|
||||
? 'You have no reserve forces available for this shipment.'
|
||||
: !destination
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import type { Action, GameView } from '@/game/engine';
|
||||
import {
|
||||
biddingEndCanAct,
|
||||
biddingEndDiscardChoice,
|
||||
} from '@/game/bidding-end-options';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { CardInspector } from './card-inspector';
|
||||
|
||||
export function BiddingEnd({
|
||||
game,
|
||||
act,
|
||||
busy,
|
||||
}: {
|
||||
game: GameView;
|
||||
act: (action: Action) => void;
|
||||
busy: boolean;
|
||||
}) {
|
||||
if (!game.biddingEnd) return null;
|
||||
return (
|
||||
<BiddingEndChoice
|
||||
key={game.biddingEnd.event}
|
||||
game={game}
|
||||
act={act}
|
||||
busy={busy}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BiddingEndChoice({
|
||||
game: g,
|
||||
act,
|
||||
busy,
|
||||
}: {
|
||||
game: GameView;
|
||||
act: (action: Action) => void;
|
||||
busy: boolean;
|
||||
}) {
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
const end = g.biddingEnd!;
|
||||
const me = g.players.find((p) => p.id === g.me)!;
|
||||
const canAct = biddingEndCanAct(g);
|
||||
const heldSelection = selected.filter((id) =>
|
||||
me.hand?.some((card) => card.id === id),
|
||||
);
|
||||
const choice = biddingEndDiscardChoice(g, heldSelection);
|
||||
const market = g.choamMarket?.owner === g.me ? g.choamMarket : null;
|
||||
const kaitain = end.kaitain?.owner === g.me;
|
||||
const send = (action: Action) =>
|
||||
act({ ...action, type: 'biddingEnd', event: end.event });
|
||||
return (
|
||||
<section aria-label="End of Bidding" className="space-y-3">
|
||||
<h3>End of Bidding</h3>
|
||||
<p className="fine">
|
||||
Optional card actions remain available until everyone below is ready. A
|
||||
new action clears readiness so the table can respond to the change.
|
||||
</p>
|
||||
<ul className="fine">
|
||||
{end.owners.map((id) => (
|
||||
<li key={id}>
|
||||
{g.players.find((p) => p.id === id)?.name}:{' '}
|
||||
{end.ready.includes(id) ? 'Ready' : 'Choosing'}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{kaitain && (
|
||||
<>
|
||||
<h3>Kaitain paid discards</h3>
|
||||
<p>
|
||||
Pay 2 spice per chosen Treachery Card. Discarding a card does not
|
||||
play its effect.
|
||||
</p>
|
||||
{!end.kaitain!.eligible && (
|
||||
<p className="fine">
|
||||
Kaitain must have high population to use paid discards.
|
||||
</p>
|
||||
)}
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{me.hand?.map((card) => (
|
||||
<div
|
||||
key={card.id}
|
||||
className="min-w-0 space-y-2 rounded-lg border border-[#414536] p-3"
|
||||
>
|
||||
<label className="flex min-h-11 items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={`Discard ${card.name} for 2 spice`}
|
||||
checked={heldSelection.includes(card.id)}
|
||||
disabled={busy || !canAct || !end.kaitain!.eligible}
|
||||
onChange={(event) =>
|
||||
setSelected((old) =>
|
||||
event.target.checked
|
||||
? [...old.filter((id) => id !== card.id), card.id]
|
||||
: old.filter((id) => id !== card.id),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<span>{card.name}</span>
|
||||
</label>
|
||||
<CardInspector card={card} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p aria-live="polite">
|
||||
{heldSelection.length} selected · {choice.cost} spice · You have{' '}
|
||||
{me.spice ?? 0} spice.
|
||||
</p>
|
||||
{choice.blocked && <p className="fine">{choice.blocked}</p>}
|
||||
<Button
|
||||
className="game-action"
|
||||
disabled={busy || !choice.action}
|
||||
onClick={() => {
|
||||
if (choice.action) {
|
||||
setSelected([]);
|
||||
act(choice.action);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Pay {choice.cost} spice and discard {heldSelection.length}{' '}
|
||||
{heldSelection.length === 1 ? 'card' : 'cards'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{market && (
|
||||
<>
|
||||
<h3>CHOAM card market</h3>
|
||||
<p className="fine">
|
||||
Sales reveal the card and give the table a Karama response. Trade
|
||||
offers stay private to you and your ally.
|
||||
</p>
|
||||
{market.worthlessSaleBlocked && (
|
||||
<p className="fine">{market.worthlessSaleBlocked}</p>
|
||||
)}
|
||||
{market.sales?.map((sale) => (
|
||||
<Button
|
||||
className="game-action"
|
||||
key={`${sale.card}-${sale.price}`}
|
||||
disabled={busy || !canAct}
|
||||
onClick={() =>
|
||||
send({
|
||||
type: 'biddingEnd',
|
||||
mode: 'sell',
|
||||
card: sale.card,
|
||||
witness: sale.witness,
|
||||
})
|
||||
}
|
||||
>
|
||||
Sell {sale.witness ? 'surplus ' : ''}
|
||||
{me.hand?.find((card) => card.id === sale.card)?.name} ·{' '}
|
||||
{sale.price} spice
|
||||
</Button>
|
||||
))}
|
||||
{!market.sales?.length && (
|
||||
<p className="fine">No eligible sales remain this phase.</p>
|
||||
)}
|
||||
{market.canTrade && (
|
||||
<>
|
||||
<h3>Offer your ally a card</h3>
|
||||
{me.hand?.map((card) => (
|
||||
<div key={card.id} className="space-y-2">
|
||||
<Button
|
||||
className="game-action"
|
||||
variant="outline"
|
||||
disabled={busy || !canAct}
|
||||
onClick={() =>
|
||||
send({ type: 'biddingEnd', mode: 'trade', card: card.id })
|
||||
}
|
||||
>
|
||||
Offer {card.name}
|
||||
</Button>
|
||||
<CardInspector card={card} />
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{end.owners.includes(g.me) && (
|
||||
<Button
|
||||
className="game-action"
|
||||
disabled={busy || !canAct || end.ready.includes(g.me)}
|
||||
onClick={() => send({ type: 'biddingEnd', mode: 'ready' })}
|
||||
>
|
||||
{end.ready.includes(g.me)
|
||||
? 'Ready for Revival'
|
||||
: 'Ready for Revival — finish card actions'}
|
||||
</Button>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import type { Action, GameView } from '@/game/engine';
|
||||
import {
|
||||
caladanReinforcementCanAct,
|
||||
caladanReinforcementChoice,
|
||||
} from '@/game/caladan-reinforcement-options';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
type Props = { game: GameView; act: (action: Action) => void; busy: boolean };
|
||||
|
||||
export function CaladanReinforcement({ game, act, busy }: Props) {
|
||||
if (
|
||||
!game.caladanReinforcement ||
|
||||
game.caladanReinforcement.player !== game.me ||
|
||||
game.decision?.kind !== 'caladanReinforcement' ||
|
||||
game.decision.player !== game.me
|
||||
)
|
||||
return null;
|
||||
return (
|
||||
<ReinforcementChoice
|
||||
key={game.caladanReinforcement.event}
|
||||
game={game}
|
||||
act={act}
|
||||
busy={busy}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ReinforcementChoice({ game, act, busy }: Props) {
|
||||
const offer = game.caladanReinforcement!;
|
||||
const [destination, setDestination] = useState(
|
||||
offer.destinations.find((target) => !target.blocked)?.id ?? '',
|
||||
);
|
||||
const canAct = caladanReinforcementCanAct(game);
|
||||
const choice = caladanReinforcementChoice(game, destination);
|
||||
return (
|
||||
<section aria-label="Caladan reinforcement" className="space-y-3">
|
||||
<p>
|
||||
Add one reserve force at the battle location, or leave it in reserves.
|
||||
</p>
|
||||
<label className="block space-y-1">
|
||||
<span>Reinforcement destination</span>
|
||||
<select
|
||||
aria-label="Reinforcement destination"
|
||||
className="w-full"
|
||||
value={destination}
|
||||
disabled={busy || !canAct || !!offer.blocked}
|
||||
onChange={(event) => setDestination(event.target.value)}
|
||||
>
|
||||
{!offer.destinations.some((target) => !target.blocked) && (
|
||||
<option value="">No legal reinforcement destination</option>
|
||||
)}
|
||||
{offer.destinations.map((target) => (
|
||||
<option
|
||||
key={target.id}
|
||||
value={target.id}
|
||||
disabled={!!target.blocked}
|
||||
>
|
||||
{target.name}
|
||||
{target.blocked ? ` — ${target.blocked}` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{choice.blocked && (
|
||||
<p className="fine" aria-live="polite">
|
||||
{choice.blocked}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
className="game-action"
|
||||
disabled={busy || !choice.action}
|
||||
onClick={() => {
|
||||
if (choice.action) act(choice.action);
|
||||
}}
|
||||
>
|
||||
Add one reserve force
|
||||
</Button>
|
||||
<Button
|
||||
className="game-action"
|
||||
variant="outline"
|
||||
disabled={busy || !canAct}
|
||||
onClick={() =>
|
||||
act({ type: 'decision', event: offer.event, decline: true })
|
||||
}
|
||||
>
|
||||
Leave in reserves
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -76,6 +76,9 @@ export function ChoamMarket({
|
||||
exact duplicate for three. A sale reveals the card and gives the table a
|
||||
Karama response.
|
||||
</p>
|
||||
{market.worthlessSaleBlocked && (
|
||||
<p className="fine">{market.worthlessSaleBlocked}</p>
|
||||
)}
|
||||
{market.sales?.map((sale) => (
|
||||
<Button
|
||||
className="game-action"
|
||||
|
||||
@@ -23,7 +23,7 @@ import { TERRITORIES } from '@/game/board';
|
||||
import { faction } from '@/game/catalog';
|
||||
|
||||
export const AMBASSADOR_COVERAGE =
|
||||
'Development coverage: Ambassador placement, Ecaz’s direct Duke acquisition, Emperor, Atreides, Harkonnen, CHOAM, Ixian, Richese, Fremen and Guild entry effects are supported, plus Bene Gesserit copies of those eight ordinary effects. Ecaz alliance and loan choices, other effects and some competing arrival reactions remain unfinished; full Ecaz starts remain disabled.';
|
||||
'Development coverage: Ambassador placement, Ecaz’s direct Duke acquisition and consensual alliance, Emperor, Atreides, Harkonnen, CHOAM, Ixian, Richese, Fremen and Guild entry effects are supported, plus Bene Gesserit copies of those eight ordinary effects. Duke loan choices, other effects and some competing arrival reactions remain unfinished; full Ecaz starts remain disabled.';
|
||||
function tokenLocation(token: AmbassadorToken) {
|
||||
if (token.zone === 'placed')
|
||||
return `Placed in ${TERRITORIES.find((t) => t.id === token.location)?.name ?? 'a stronghold'}`;
|
||||
|
||||
@@ -32,7 +32,9 @@ export function EcazEntry({
|
||||
!entry ||
|
||||
game.decision?.kind !== 'ecazAmbassador' ||
|
||||
game.decision.player !== game.me ||
|
||||
!['offer', 'copy', 'cards', 'move', 'ship'].includes(entry.stage)
|
||||
!['offer', 'allianceReply', 'copy', 'cards', 'move', 'ship'].includes(
|
||||
entry.stage,
|
||||
)
|
||||
)
|
||||
return null;
|
||||
const me = game.players.find((p) => p.id === game.me)!;
|
||||
@@ -54,6 +56,9 @@ export function EcazEntry({
|
||||
(entry.dukeAcquisition
|
||||
? null
|
||||
: 'Duke acquisition is unavailable for this opportunity.');
|
||||
const allianceBlock =
|
||||
entry.allianceOffer?.blocked ??
|
||||
(entry.allianceOffer ? null : 'This alliance opportunity is unavailable.');
|
||||
const duke = game.dukeVidal?.leader;
|
||||
return (
|
||||
<section
|
||||
@@ -107,9 +112,36 @@ export function EcazEntry({
|
||||
{dukeBlock}
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
className={buttonClass}
|
||||
disabled={busy || !!allianceBlock}
|
||||
aria-describedby={
|
||||
allianceBlock ? `${id}-alliance-block` : undefined
|
||||
}
|
||||
onClick={() => {
|
||||
if (!allianceBlock)
|
||||
send({
|
||||
trigger: true,
|
||||
beneficiary: entry.owner,
|
||||
choice: 'alliance',
|
||||
});
|
||||
}}
|
||||
>
|
||||
Offer alliance to{' '}
|
||||
{game.players.find((p) => p.id === entry.entrant)?.name}
|
||||
</Button>
|
||||
{allianceBlock && (
|
||||
<p
|
||||
id={`${id}-alliance-block`}
|
||||
className="m-0 text-sm leading-6"
|
||||
>
|
||||
{allianceBlock}
|
||||
</p>
|
||||
)}
|
||||
<p className="m-0 text-sm leading-6">
|
||||
Alliance proposals, acquisition for an ally and Duke loans
|
||||
through this Ambassador remain unfinished.
|
||||
Both factions must be unallied. The entrant chooses whether to
|
||||
accept. The triggered Ambassador returns to supply even if the
|
||||
offer is refused. Duke loans remain unfinished.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
@@ -157,6 +189,30 @@ export function EcazEntry({
|
||||
Leave Ambassador in place
|
||||
</Button>
|
||||
</>
|
||||
) : entry.stage === 'allianceReply' ? (
|
||||
<>
|
||||
<p className="m-0 text-sm leading-6">
|
||||
{game.players.find((p) => p.id === entry.owner)?.name} offers you an
|
||||
alliance. Accepting activates both factions’ alliance abilities
|
||||
immediately. Duke Vidal is not included in this offer. Your
|
||||
remaining actions resume after your reply.
|
||||
</p>
|
||||
<Button
|
||||
className={buttonClass}
|
||||
disabled={busy}
|
||||
onClick={() => send({ accept: true })}
|
||||
>
|
||||
Accept alliance
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={buttonClass}
|
||||
disabled={busy}
|
||||
onClick={() => send({ accept: false })}
|
||||
>
|
||||
Refuse alliance
|
||||
</Button>
|
||||
</>
|
||||
) : entry.stage === 'copy' ? (
|
||||
<>
|
||||
<p className="m-0 text-sm leading-6">
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { GameView } from '@/game/engine';
|
||||
import { PHASES } from '@/game/catalog';
|
||||
|
||||
export function EcazPoisonIncome({ game: g }: { game: GameView }) {
|
||||
if (
|
||||
g.players.find((player) => player.id === g.me)?.faction !== 'ecaz' ||
|
||||
!g.ecazPoisonIncome?.length
|
||||
)
|
||||
return null;
|
||||
return (
|
||||
<section
|
||||
aria-label="Your Ecaz poison income"
|
||||
className="rounded-lg border border-[#a88b60]/50 p-3"
|
||||
>
|
||||
<h4>Your Ecaz poison income</h4>
|
||||
<p className="fine">
|
||||
Private record of spice you received when poison weapons were discarded.
|
||||
</p>
|
||||
<ul className="fine">
|
||||
{g.ecazPoisonIncome
|
||||
.slice(-6)
|
||||
.reverse()
|
||||
.map((income, index) => (
|
||||
<li key={`${income.turn}-${income.phase}-${index}`}>
|
||||
Turn {income.turn} · {PHASES[income.phase]}: +{income.amount}{' '}
|
||||
spice from {income.count} discarded poison{' '}
|
||||
{income.count === 1 ? 'weapon' : 'weapons'}.
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -15,30 +15,53 @@ export function FaceDanceDecision({
|
||||
}) {
|
||||
const d = g.decision;
|
||||
const me = g.players.find((p) => p.id === g.me)!;
|
||||
const world =
|
||||
d?.kind === 'faceDance'
|
||||
? g.combatLocations?.find(
|
||||
(place) => place.id === d.territory && place.kind === 'homeworld',
|
||||
)
|
||||
: undefined;
|
||||
const foreign =
|
||||
d?.kind === 'faceDance' ? world?.forces?.[d.winner] : undefined;
|
||||
const maximum =
|
||||
d?.kind === 'faceDance'
|
||||
? Object.entries(g.players.find((p) => p.id === d.winner)!.forces)
|
||||
.filter(([key]) => splitLocation(key).territory === d.territory)
|
||||
.reduce((sum, [, count]) => sum + count, 0)
|
||||
? world
|
||||
? (foreign?.normal ?? 0) + (foreign?.elite ?? 0)
|
||||
: Object.entries(g.players.find((p) => p.id === d.winner)!.forces)
|
||||
.filter(([key]) => splitLocation(key).territory === d.territory)
|
||||
.reduce((sum, [, count]) => sum + count, 0)
|
||||
: 0;
|
||||
const [sources, setSources] = useState<Record<string, number>>({
|
||||
reserves: Math.min(me.reserves, maximum),
|
||||
});
|
||||
const [sources, setSources] = useState<Record<string, number>>(
|
||||
world
|
||||
? {}
|
||||
: {
|
||||
reserves: Math.min(me.reserves, maximum),
|
||||
},
|
||||
);
|
||||
const [sector, setSector] = useState(
|
||||
d?.kind === 'faceDance' ? territory(d.territory).sectors[0] : 0,
|
||||
d?.kind === 'faceDance' && !world ? territory(d.territory).sectors[0] : 0,
|
||||
);
|
||||
if (d?.kind !== 'faceDance') return null;
|
||||
const held = me.faceDancers?.some(
|
||||
(c) => !c.revealed && c.leader === d.identity,
|
||||
);
|
||||
const total = Object.values(sources).reduce((sum, n) => sum + n, 0);
|
||||
const availableSources = world
|
||||
? me.forces
|
||||
: { reserves: me.reserves, ...me.forces };
|
||||
return (
|
||||
<>
|
||||
<h2>Face Dancer opportunity</h2>
|
||||
<p>
|
||||
{g.players.find((p) => p.id === d.winner)?.name} won in{' '}
|
||||
{territory(d.territory).name}. Their battle rewards remain theirs.
|
||||
{world?.name ?? territory(d.territory).name}. Their battle rewards
|
||||
remain theirs.
|
||||
</p>
|
||||
{d.blocked && (
|
||||
<output className="notice block">
|
||||
{d.blocked}
|
||||
</output>
|
||||
)}
|
||||
{held ? (
|
||||
<>
|
||||
<p>
|
||||
@@ -46,51 +69,69 @@ export function FaceDanceDecision({
|
||||
up to {maximum} with your forces. Their leader goes to the tanks
|
||||
without a spice bounty.
|
||||
</p>
|
||||
{Object.entries({ reserves: me.reserves, ...me.forces }).map(
|
||||
([key, available]) => (
|
||||
<label key={key} htmlFor={`face-source-${key}`}>
|
||||
{key === 'reserves'
|
||||
? 'Reserves'
|
||||
: `${territory(splitLocation(key).territory).name} · sector ${splitLocation(key).sector}`}{' '}
|
||||
({available} available)
|
||||
<Input
|
||||
id={`face-source-${key}`}
|
||||
type="number"
|
||||
min={0}
|
||||
max={Math.min(available, maximum)}
|
||||
value={sources[key] ?? 0}
|
||||
onChange={(e) =>
|
||||
setSources({ ...sources, [key]: Number(e.target.value) })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
),
|
||||
{world && (
|
||||
<p>
|
||||
Your native forces already at {world.name} remain there. Choose
|
||||
any additional replacement forces from your positions on Arrakis,
|
||||
or reveal with zero additional forces.
|
||||
</p>
|
||||
)}
|
||||
{Object.entries(availableSources).map(([key, available]) => (
|
||||
<label key={key} htmlFor={`face-source-${key}`}>
|
||||
{key === 'reserves'
|
||||
? 'Reserves'
|
||||
: `${territory(splitLocation(key).territory).name} · sector ${splitLocation(key).sector}`}{' '}
|
||||
({available} available)
|
||||
<Input
|
||||
id={`face-source-${key}`}
|
||||
type="number"
|
||||
min={0}
|
||||
max={Math.min(available, maximum)}
|
||||
value={sources[key] ?? 0}
|
||||
onChange={(e) =>
|
||||
setSources({ ...sources, [key]: Number(e.target.value) })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
{!world && (
|
||||
<label htmlFor="face-sector">
|
||||
Replacement sector
|
||||
<select
|
||||
id="face-sector"
|
||||
value={sector}
|
||||
onChange={(e) => setSector(Number(e.target.value))}
|
||||
>
|
||||
{territory(d.territory).sectors.map((s) => (
|
||||
<option value={s} key={s}>
|
||||
{s === 0 ? 'Polar Sink' : `Sector ${s}`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
<label htmlFor="face-sector">
|
||||
Replacement sector
|
||||
<select
|
||||
id="face-sector"
|
||||
value={sector}
|
||||
onChange={(e) => setSector(Number(e.target.value))}
|
||||
>
|
||||
{territory(d.territory).sectors.map((s) => (
|
||||
<option value={s} key={s}>
|
||||
{s === 0 ? 'Polar Sink' : `Sector ${s}`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<p>
|
||||
{total} / {maximum} replacement forces selected
|
||||
</p>
|
||||
<Button
|
||||
disabled={
|
||||
busy ||
|
||||
!!d.blocked ||
|
||||
total > maximum ||
|
||||
Object.values(sources).some((n) => n < 0 || !Number.isInteger(n))
|
||||
Object.entries(sources).some(
|
||||
([key, n]) =>
|
||||
n < 0 ||
|
||||
!Number.isSafeInteger(n) ||
|
||||
n > (availableSources[key] ?? 0),
|
||||
)
|
||||
}
|
||||
onClick={() =>
|
||||
act({ type: 'decision', reveal: true, sources, sector })
|
||||
act({
|
||||
type: 'decision',
|
||||
reveal: true,
|
||||
sources,
|
||||
...(world ? {} : { sector }),
|
||||
})
|
||||
}
|
||||
>
|
||||
Reveal Face Dancer
|
||||
|
||||
+419
-158
@@ -4,6 +4,23 @@ import {
|
||||
liveShipmentPromises,
|
||||
} from '@/game/shipment-promises';
|
||||
import { ShipmentPromises } from './shipment-promises';
|
||||
import { HomeworldShipment } from './homeworld-shipment';
|
||||
import { HomeworldRevivalDeployment } from './homeworld-revival-deployment';
|
||||
import { CaladanReinforcement } from './caladan-reinforcement';
|
||||
import { homeworldRevivalActionBlock } from '@/game/homeworld-revival-deployment-options';
|
||||
import { GuildHomeworldShipment } from './guild-homeworld-shipment';
|
||||
import { JunctionTransport } from './junction-transport';
|
||||
import { BiddingEnd } from './bidding-end';
|
||||
import { homeworldShipmentPaymentBlock } from '@/game/homeworld-payment-options';
|
||||
import {
|
||||
HomeworldTable,
|
||||
NativeShipmentChoice,
|
||||
EmperorHomeworldMovement,
|
||||
} from './homeworld-table';
|
||||
import { nativeShipmentSources } from '@/game/homeworld-options';
|
||||
import { botBattleChoices } from '@/game/bot-battle-choices';
|
||||
import { specialForceName } from '@/game/combat';
|
||||
import type { NativeReserveSelections } from '@/game/homeworld-native-reserves';
|
||||
import {
|
||||
StrongholdCardGallery,
|
||||
StrongholdCopyChoice,
|
||||
@@ -142,6 +159,10 @@ export function GameTable({
|
||||
onExit: () => void;
|
||||
}) {
|
||||
const me = g.players.find((p) => p.id === g.me)!;
|
||||
const combatName = (id: string) =>
|
||||
g.combatLocations?.find((place) => place.id === id)?.name ??
|
||||
gameTerritories(g).find((place) => place.id === id)?.name ??
|
||||
id;
|
||||
const turnOwner = tableActionOwner(g);
|
||||
const turnOwnerName = g.players.find((p) => p.id === turnOwner)?.name;
|
||||
const setupStage = g.setupStage;
|
||||
@@ -230,7 +251,14 @@ export function GameTable({
|
||||
const nextBotFaction = availableBotFactions.some((f) => f.id === botFaction)
|
||||
? botFaction
|
||||
: (availableBotFactions[0]?.id ?? '');
|
||||
const [selected, setSelected] = useState(g.battle?.territory ?? 'arrakeen');
|
||||
const [selectedId, setSelected] = useState(() =>
|
||||
gameTerritories(g).some((location) => location.id === g.battle?.territory)
|
||||
? g.battle!.territory
|
||||
: 'arrakeen',
|
||||
);
|
||||
const selected = gameTerritories(g).some((location) => location.id === selectedId)
|
||||
? selectedId
|
||||
: 'arrakeen';
|
||||
const [sector, setSector] = useState(10);
|
||||
const [amount, setAmount] = useState(1);
|
||||
const [bidDraft, setBidDraft] = useState({ auction: '', value: 1 });
|
||||
@@ -246,6 +274,23 @@ export function GameTable({
|
||||
? Number.MAX_SAFE_INTEGER
|
||||
: (me.spice ?? 0) + g.aid.available;
|
||||
const [eliteAmount, setEliteAmount] = useState(0);
|
||||
const [homeworldDraft, setHomeworldDraft] = useState<{
|
||||
key: string;
|
||||
sources: NativeReserveSelections;
|
||||
} | null>(null);
|
||||
const homeworldSourceKey = JSON.stringify([
|
||||
g.code,
|
||||
g.turn,
|
||||
g.phase,
|
||||
g.active,
|
||||
g.homeworlds,
|
||||
amount,
|
||||
eliteAmount,
|
||||
]);
|
||||
const homeworldSources =
|
||||
homeworldDraft?.key === homeworldSourceKey
|
||||
? homeworldDraft.sources
|
||||
: nativeShipmentSources(g, amount, eliteAmount);
|
||||
const [freeCyborgFirst, setFreeCyborgFirst] = useState(true);
|
||||
const [allyEliteAmount, setAllyEliteAmount] = useState(0);
|
||||
const [eliteForces, setEliteForces] = useState<Record<string, number>>({});
|
||||
@@ -297,7 +342,7 @@ export function GameTable({
|
||||
? ownBattleForces.freeSupport
|
||||
? 0
|
||||
: (ownBattleForces.normalFixedHalf ? 0 : ownBattleForces.normal) +
|
||||
ownBattleForces.elite
|
||||
(ownBattleForces.eliteFreeSupport ? 0 : ownBattleForces.elite)
|
||||
: Number.POSITIVE_INFINITY,
|
||||
);
|
||||
const battleSupport = Number.isFinite(support)
|
||||
@@ -359,6 +404,22 @@ export function GameTable({
|
||||
const payment =
|
||||
!me.ally || allyPayment === '' ? {} : { allyPayment: Number(allyPayment) };
|
||||
const shipmentProblems: string[] = [];
|
||||
if (homeworldSources) {
|
||||
const homes = g.homeworlds!.worlds!.filter((w) => w.native === me.id);
|
||||
if (
|
||||
homes.some((home) =>
|
||||
(['normal', 'elite'] as const).some((kind) => {
|
||||
const n = homeworldSources[home.id]?.[kind];
|
||||
return (
|
||||
!Number.isSafeInteger(n) || n < 0 || n > home.forces[me.id][kind]
|
||||
);
|
||||
}),
|
||||
)
|
||||
)
|
||||
shipmentProblems.push(
|
||||
'Choose available whole numbers of normal forces and Sardaukar from each Homeworld.',
|
||||
);
|
||||
}
|
||||
if (
|
||||
liveShipmentPromises(g.shipmentPromises, me.id, g.turn).some(
|
||||
(p) => matchesShipment(p, { territory: selected, amount }) !== p.answer,
|
||||
@@ -416,6 +477,10 @@ export function GameTable({
|
||||
selectedAllyPayment >= 0 &&
|
||||
selectedAllyPayment <= shipmentCost;
|
||||
if (shipmentCost !== null) {
|
||||
if (validShipmentShare) {
|
||||
const blocked = homeworldShipmentPaymentBlock(g, shipmentCost, selectedAllyPayment);
|
||||
if (blocked) shipmentProblems.push(blocked);
|
||||
}
|
||||
if (!validShipmentShare)
|
||||
shipmentProblems.push(
|
||||
`Ally payment must be a whole number from 0 to ${shipmentCost}.`,
|
||||
@@ -462,7 +527,7 @@ export function GameTable({
|
||||
!!ownNoField &&
|
||||
!!source &&
|
||||
ownNoField.location.territory === splitLocation(source).territory;
|
||||
const includesNoField = moveNoField && markerAtSource;
|
||||
const includesNoField = moveNoField && markerAtSource && !g.homeworldMobility?.noFieldMovementBlocked;
|
||||
const physicalDraftEntries = combineSectors
|
||||
? Object.entries(moveForces).filter(
|
||||
([key]) =>
|
||||
@@ -538,8 +603,10 @@ export function GameTable({
|
||||
);
|
||||
const act = (a: Action) => void send(a);
|
||||
const choose = (id: string) => {
|
||||
const location = gameTerritories(g).find((candidate) => candidate.id === id);
|
||||
if (!location) return;
|
||||
setSelected(id);
|
||||
setSector(territory(id).sectors[0]);
|
||||
setSector(location.sectors[0]);
|
||||
};
|
||||
const leaderName = (id: string) =>
|
||||
id === CHEAP_HERO_TRAITOR
|
||||
@@ -997,7 +1064,7 @@ export function GameTable({
|
||||
? 'awakened'
|
||||
: `${me.kwisatz.losses} of 7 battle losses`}
|
||||
{me.kwisatz.usedAt
|
||||
? ` · used in ${territory(me.kwisatz.usedAt).name} this turn`
|
||||
? ` · used in ${combatName(me.kwisatz.usedAt)} this turn`
|
||||
: ''}
|
||||
</p>
|
||||
)}
|
||||
@@ -1276,6 +1343,7 @@ export function GameTable({
|
||||
})}
|
||||
</svg>
|
||||
</div>
|
||||
<HomeworldTable game={g} />
|
||||
<div className="territory-detail">
|
||||
<div>
|
||||
<span className="eyebrow">
|
||||
@@ -1373,6 +1441,10 @@ export function GameTable({
|
||||
{g.status === 'finished' ? 'FINAL OUTCOME' : 'YOUR NEXT DECISION'}
|
||||
</div>
|
||||
<PrivateBattlePlan game={g} />
|
||||
{g.biddingEnd && <BiddingEnd game={g} act={act} busy={busy} />}
|
||||
{g.junctionTransport && [g.junctionTransport.owner, g.junctionTransport.recipient].includes(me.id) && (
|
||||
<JunctionTransport key={me.id === g.junctionTransport.owner ? g.junctionTransport.offerEvent : g.junctionTransport.event} game={g} act={act} busy={busy} />
|
||||
)}
|
||||
<RicheseNoFieldControls
|
||||
game={g}
|
||||
act={act}
|
||||
@@ -1765,6 +1837,21 @@ export function GameTable({
|
||||
}[g.response.kind]
|
||||
}
|
||||
</h2>
|
||||
{g.paymentIncome?.low && (
|
||||
<p className="notice">
|
||||
Of the {g.paymentIncome.gross} spice payment,{' '}
|
||||
{g.players.find((player) => player.id === g.paymentIncome?.owner)?.name}
|
||||
{' '}receives {g.paymentIncome.income}. Low-population{' '}
|
||||
{g.paymentIncome.kind === 'shipment' ? 'Junction' : 'Kaitain'}
|
||||
{' '}leaves {g.paymentIncome.bank} spice in the bank. The payer’s cost stays the same.
|
||||
</p>
|
||||
)}
|
||||
{g.response.kind === 'advisor' && (
|
||||
<p className="notice">
|
||||
{g.players.find((player) => player.id === g.response?.owner)?.name} declared {g.response.amount ?? 1} free {(g.response.amount ?? 1) === 1 ? 'force' : 'forces'} to {territory(splitLocation(g.response.location ?? 'polar_sink:0').territory).name}.
|
||||
{' '}Allow the arrival or cancel Spiritual Advisors. Canceling prevents the whole declared group.
|
||||
</p>
|
||||
)}
|
||||
{g.response.kind === 'moritaniRetention' &&
|
||||
g.moritaniRetentionCard && (
|
||||
<div className="notice">
|
||||
@@ -1941,163 +2028,171 @@ export function GameTable({
|
||||
<>
|
||||
<span className="eyebrow">Player decision</span>
|
||||
<h2>
|
||||
{g.decision.kind === 'stoneBurner'
|
||||
? 'Stone Burner'
|
||||
: g.decision.kind === 'nullentropy'
|
||||
? 'Private Nullentropy Box search'
|
||||
: g.decision.kind === 'richeseAllyOpportunity'
|
||||
? 'Offer allied No-Field shipment'
|
||||
: g.decision.kind === 'richeseAllyShipment'
|
||||
? 'Allied No-Field shipment'
|
||||
: [
|
||||
'richeseBlackMarket',
|
||||
'richeseDeclaration',
|
||||
'richeseCache',
|
||||
'richeseUnbid',
|
||||
].includes(g.decision.kind)
|
||||
? 'Richese auction decision'
|
||||
: g.decision.kind === 'ecazAmbassador'
|
||||
? 'Ecaz · Ambassador entry effect'
|
||||
: g.decision.kind === 'ecazPlacement'
|
||||
? 'Ecaz · Ambassador placement'
|
||||
: g.decision.kind === 'moritaniRetention'
|
||||
? 'Moritani · allied card retention'
|
||||
: g.decision.kind === 'moritaniTerror'
|
||||
? 'Moritani · Terror entry reaction'
|
||||
: g.decision.kind === 'moritaniSetup'
|
||||
? 'Moritani · final deployment'
|
||||
: g.decision.kind === 'moritaniPlacement'
|
||||
? 'Moritani · hidden Terror placement'
|
||||
: g.decision.kind === 'choamStorm'
|
||||
? 'CHOAM storm protection'
|
||||
: g.decision.kind === 'choamMovement'
|
||||
? 'CHOAM movement response'
|
||||
: g.decision.kind === 'choamMentat'
|
||||
? 'CHOAM’s final Mentat opportunity'
|
||||
: g.decision.kind ===
|
||||
'choamFreeRevival'
|
||||
? 'CHOAM free revival response'
|
||||
{g.decision.kind === 'caladanReinforcement'
|
||||
? 'Caladan victory reinforcement'
|
||||
: g.decision.kind === 'homeworldRevivalDeployment'
|
||||
? 'Revival deployment'
|
||||
: g.decision.kind === 'ecazSpice'
|
||||
? 'Shared spice collection'
|
||||
: g.decision.kind === 'stoneBurner'
|
||||
? 'Stone Burner'
|
||||
: g.decision.kind === 'nullentropy'
|
||||
? 'Private Nullentropy Box search'
|
||||
: g.decision.kind === 'richeseAllyOpportunity'
|
||||
? 'Offer allied No-Field shipment'
|
||||
: g.decision.kind === 'richeseAllyShipment'
|
||||
? 'Allied No-Field shipment'
|
||||
: [
|
||||
'richeseBlackMarket',
|
||||
'richeseDeclaration',
|
||||
'richeseCache',
|
||||
'richeseUnbid',
|
||||
].includes(g.decision.kind)
|
||||
? 'Richese auction decision'
|
||||
: g.decision.kind === 'ecazAmbassador'
|
||||
? 'Ecaz · Ambassador entry effect'
|
||||
: g.decision.kind === 'ecazPlacement'
|
||||
? 'Ecaz · Ambassador placement'
|
||||
: g.decision.kind === 'moritaniRetention'
|
||||
? 'Moritani · allied card retention'
|
||||
: g.decision.kind === 'moritaniTerror'
|
||||
? 'Moritani · Terror entry reaction'
|
||||
: g.decision.kind === 'moritaniSetup'
|
||||
? 'Moritani · final deployment'
|
||||
: g.decision.kind === 'moritaniPlacement'
|
||||
? 'Moritani · hidden Terror placement'
|
||||
: g.decision.kind === 'choamStorm'
|
||||
? 'CHOAM storm protection'
|
||||
: g.decision.kind === 'choamMovement'
|
||||
? 'CHOAM movement response'
|
||||
: g.decision.kind === 'choamMentat'
|
||||
? 'CHOAM’s final Mentat opportunity'
|
||||
: g.decision.kind ===
|
||||
'choamBattleFunding'
|
||||
? 'CHOAM battle funding'
|
||||
'choamFreeRevival'
|
||||
? 'CHOAM free revival response'
|
||||
: g.decision.kind ===
|
||||
'choamMarket'
|
||||
? 'CHOAM · end of phase'
|
||||
'choamBattleFunding'
|
||||
? 'CHOAM battle funding'
|
||||
: g.decision.kind ===
|
||||
'choamTradeReply' ||
|
||||
g.decision.kind ===
|
||||
'choamTradeConfirm'
|
||||
? 'Allied card exchange'
|
||||
'choamMarket'
|
||||
? 'CHOAM · end of phase'
|
||||
: g.decision.kind ===
|
||||
'ixSetup'
|
||||
? 'Choose your starting technology'
|
||||
'choamTradeReply' ||
|
||||
g.decision.kind ===
|
||||
'choamTradeConfirm'
|
||||
? 'Allied card exchange'
|
||||
: g.decision.kind ===
|
||||
'ixAuction'
|
||||
? 'Prepare the auction'
|
||||
'ixSetup'
|
||||
? 'Choose your starting technology'
|
||||
: g.decision.kind ===
|
||||
'ixTechnology'
|
||||
? 'Substitute an auction card'
|
||||
'ixAuction'
|
||||
? 'Prepare the auction'
|
||||
: g.decision.kind ===
|
||||
'ixAllyCard'
|
||||
? 'Keep or replace your purchase'
|
||||
'ixTechnology'
|
||||
? 'Substitute an auction card'
|
||||
: g.decision
|
||||
.kind ===
|
||||
'mobileStronghold'
|
||||
? g.decision
|
||||
.placement
|
||||
? 'Place your stronghold'
|
||||
: 'Relocate before the storm'
|
||||
'ixAllyCard'
|
||||
? 'Keep or replace your purchase'
|
||||
: g.decision
|
||||
.kind ===
|
||||
'ixSubstitution'
|
||||
? 'Retain lost cyborgs'
|
||||
'mobileStronghold'
|
||||
? g.decision
|
||||
.placement
|
||||
? 'Place your stronghold'
|
||||
: 'Relocate before the storm'
|
||||
: g.decision
|
||||
.kind ===
|
||||
'revivalStop'
|
||||
? 'A revival awaits clearance'
|
||||
'ixSubstitution'
|
||||
? 'Retain lost cyborgs'
|
||||
: g.decision
|
||||
.kind ===
|
||||
'faceDance'
|
||||
? 'A hidden allegiance'
|
||||
'revivalStop'
|
||||
? 'A revival awaits clearance'
|
||||
: g.decision
|
||||
.kind ===
|
||||
'poisonTooth'
|
||||
? 'Activate Poison Tooth?'
|
||||
'faceDance'
|
||||
? 'A hidden allegiance'
|
||||
: g
|
||||
.decision
|
||||
.kind ===
|
||||
'techToken'
|
||||
? 'Claim a tech token'
|
||||
'poisonTooth'
|
||||
? 'Activate Poison Tooth?'
|
||||
: g
|
||||
.decision
|
||||
.kind ===
|
||||
'fullPlanOffer'
|
||||
? 'See the entire battle plan'
|
||||
'techToken'
|
||||
? 'Claim a tech token'
|
||||
: g
|
||||
.decision
|
||||
.kind ===
|
||||
'guildShipment'
|
||||
? 'A shipment awaits clearance'
|
||||
'fullPlanOffer'
|
||||
? 'See the entire battle plan'
|
||||
: g
|
||||
.decision
|
||||
.kind ===
|
||||
'handExchange'
|
||||
? 'Choose cards to return'
|
||||
'guildShipment' || g.decision.kind === 'homeworldShipmentGuild'
|
||||
? 'A shipment awaits clearance'
|
||||
: g
|
||||
.decision
|
||||
.kind ===
|
||||
'captureOffer' ||
|
||||
g
|
||||
.decision
|
||||
.kind ===
|
||||
'capturedLeader'
|
||||
? 'A captive leader'
|
||||
'handExchange'
|
||||
? 'Choose cards to return'
|
||||
: g
|
||||
.decision
|
||||
.kind ===
|
||||
'guildTiming'
|
||||
? 'Choose when to act'
|
||||
'captureOffer' ||
|
||||
g
|
||||
.decision
|
||||
.kind ===
|
||||
'capturedLeader'
|
||||
? 'A captive leader'
|
||||
: g
|
||||
.decision
|
||||
.kind ===
|
||||
'stormLosses'
|
||||
? 'Survive the storm'
|
||||
'guildTiming'
|
||||
? 'Choose when to act'
|
||||
: g
|
||||
.decision
|
||||
.kind ===
|
||||
'wormPlacement'
|
||||
? 'Place an additional worm'
|
||||
'stormLosses'
|
||||
? 'Survive the storm'
|
||||
: g
|
||||
.decision
|
||||
.kind ===
|
||||
'battleLosses'
|
||||
? 'Choose your casualties'
|
||||
'wormPlacement'
|
||||
? 'Place an additional worm'
|
||||
: g
|
||||
.decision
|
||||
.kind ===
|
||||
'auctionPayment'
|
||||
? 'Settle your bid'
|
||||
: [
|
||||
'advisor',
|
||||
'intrusion',
|
||||
'advisorBattle',
|
||||
].includes(
|
||||
g
|
||||
.decision
|
||||
.kind,
|
||||
)
|
||||
? 'Advisors & fighters'
|
||||
: g
|
||||
.decision
|
||||
.kind ===
|
||||
'wormProtection'
|
||||
? 'Protection of Shai-Hulud'
|
||||
'battleLosses'
|
||||
? 'Choose your casualties'
|
||||
: g
|
||||
.decision
|
||||
.kind ===
|
||||
'auctionPayment'
|
||||
? 'Settle your bid'
|
||||
: [
|
||||
'advisor',
|
||||
'intrusion',
|
||||
'advisorBattle',
|
||||
].includes(
|
||||
g
|
||||
.decision
|
||||
.kind,
|
||||
)
|
||||
? 'Advisors & fighters'
|
||||
: g
|
||||
.decision
|
||||
.kind ===
|
||||
'wormRide'
|
||||
? 'Ride the sandworm'
|
||||
: 'Spoils of battle'}
|
||||
'wormProtection'
|
||||
? 'Protection of Shai-Hulud'
|
||||
: g
|
||||
.decision
|
||||
.kind ===
|
||||
'wormRide'
|
||||
? 'Ride the sandworm'
|
||||
: 'Spoils of battle'}
|
||||
</h2>
|
||||
{g.decision.kind === 'capturedLeader' &&
|
||||
(g.decision.controller ?? g.decision.owner) === me.id && (
|
||||
@@ -2118,7 +2213,7 @@ export function GameTable({
|
||||
)?.name
|
||||
}{' '}
|
||||
has declared a move of {g.decision.amount} forces into{' '}
|
||||
{territory(g.decision.territory).name}, sector{' '}
|
||||
{combatName(g.decision.territory)}, sector{' '}
|
||||
{g.decision.sector}. CHOAM must allow the move or respond
|
||||
with Baliset; no forces have moved yet.
|
||||
</p>
|
||||
@@ -2209,6 +2304,10 @@ export function GameTable({
|
||||
act={act}
|
||||
busy={busy}
|
||||
/>
|
||||
) : g.decision.kind === 'homeworldRevivalDeployment' ? (
|
||||
<HomeworldRevivalDeployment game={g} act={act} busy={busy} />
|
||||
) : g.decision.kind === 'caladanReinforcement' ? (
|
||||
<CaladanReinforcement game={g} act={act} busy={busy} />
|
||||
) : g.decision.kind === 'ecazSpice' ? (
|
||||
g.ecazSpice?.allocation ? (
|
||||
<EcazSpice
|
||||
@@ -2277,6 +2376,9 @@ export function GameTable({
|
||||
turn.
|
||||
</p>
|
||||
<HelpTip topic="tleilaxuSpecial" />
|
||||
{g.revival.specialKaramaBlock && (
|
||||
<p role="status">{g.revival.specialKaramaBlock}</p>
|
||||
)}
|
||||
{me.hand
|
||||
?.filter((c) => c.effect === 'karama')
|
||||
.map((c) => (
|
||||
@@ -2284,6 +2386,7 @@ export function GameTable({
|
||||
{actionButton(
|
||||
'Spend special Karama · prevent revival',
|
||||
{ type: 'card', card: c.id, mode: 'special' },
|
||||
!!g.revival.specialKaramaBlock,
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
@@ -2402,6 +2505,17 @@ export function GameTable({
|
||||
Restoring the saved battle continuation. Your inspection
|
||||
remains available above; no confirmation is needed.
|
||||
</p>
|
||||
) : g.decision.kind === 'homeworldShipmentGuild' ? (
|
||||
<>
|
||||
<p className="muted">
|
||||
{g.players.find((p) => p.id === (g.decision?.kind === 'homeworldShipmentGuild' ? g.decision.shipper : ''))?.name} declares {g.decision.amount} forces to {combatName(g.decision.destination)}. Allow this shipment or spend your once-per-game special Karama to stop it.
|
||||
</p>
|
||||
<HelpTip topic="guildShipment" />
|
||||
{actionButton('Allow Homeworld shipment', { type: 'decision', event: g.decision.event, allow: true })}
|
||||
{me.hand?.filter((c) => c.effect === 'karama').map((c) => (
|
||||
<div key={c.id}>{actionButton('Spend Karama · stop shipment', { type: 'card', mode: 'special', card: c.id })}</div>
|
||||
))}
|
||||
</>
|
||||
) : g.decision.kind === 'guildShipment' ? (
|
||||
<>
|
||||
<p className="muted">
|
||||
@@ -2415,7 +2529,7 @@ export function GameTable({
|
||||
)?.name
|
||||
}{' '}
|
||||
declares {g.decision.amount} forces to{' '}
|
||||
{territory(g.decision.territory).name}, sector{' '}
|
||||
{combatName(g.decision.territory)}, sector{' '}
|
||||
{g.decision.sector}. Allow this shipment or spend your
|
||||
once-per-game special Karama to stop it.
|
||||
</p>
|
||||
@@ -2484,10 +2598,9 @@ export function GameTable({
|
||||
) : g.decision.kind === 'intrusion' ? (
|
||||
<>
|
||||
<p className="muted">
|
||||
Another faction entered{' '}
|
||||
{territory(g.decision.territory).name}. You may turn every
|
||||
fighter there into an advisor. A Karama response resolves
|
||||
before the flip.
|
||||
Another faction entered {combatName(g.decision.territory)}.
|
||||
You may turn every fighter there into an advisor. A Karama
|
||||
response resolves before the flip.
|
||||
</p>
|
||||
{actionButton('Become advisors', {
|
||||
type: 'decision',
|
||||
@@ -2641,11 +2754,74 @@ export function GameTable({
|
||||
accept: false,
|
||||
})}
|
||||
</>
|
||||
) : g.decision.kind === 'homeworldDefense' ? (
|
||||
<>
|
||||
<RevealedBattle game={g} />
|
||||
<h3>Late poison defense</h3>
|
||||
<p className="muted">
|
||||
The Traitor window is complete. You may add Portable Snooper
|
||||
before this Homeworld battle resolves. It protects against
|
||||
ordinary poison, not Poison Tooth.
|
||||
</p>
|
||||
{me.hand
|
||||
?.filter(
|
||||
(card) =>
|
||||
richeseCardDefinition(card)?.card.effect ===
|
||||
'portableSnooper',
|
||||
)
|
||||
.map((card) => (
|
||||
<div key={card.id}>
|
||||
<CardRules card={card} />
|
||||
<CardInspector card={card} />
|
||||
</div>
|
||||
))}
|
||||
{actionButton('Use Portable Snooper', {
|
||||
type: 'decision',
|
||||
event: g.decision.event,
|
||||
use: true,
|
||||
})}
|
||||
{actionButton('Keep current defense', {
|
||||
type: 'decision',
|
||||
event: g.decision.event,
|
||||
use: false,
|
||||
})}
|
||||
</>
|
||||
) : g.decision.kind === 'homeworldExplosion' ? (
|
||||
<>
|
||||
<h3>Native explosion casualties</h3>
|
||||
<p className="muted">
|
||||
The Lasgun–shield explosion limits your native losses at{' '}
|
||||
{combatName(g.decision.territory)} to the Homeworld’s
|
||||
printed battle strength. Choose the physical counters sent
|
||||
to the Tanks; your other native forces remain here.
|
||||
</p>
|
||||
<p className="fine">
|
||||
Available: {g.decision.pool.normal}{' '}
|
||||
{me.faction === 'ixians' ? 'Suboid' : 'ordinary'} and{' '}
|
||||
{g.decision.pool.elite} {specialForceName(me.faction)}{' '}
|
||||
counters.
|
||||
</p>
|
||||
{g.decision.options.map((option, choice) => (
|
||||
<div key={choice}>
|
||||
{actionButton(
|
||||
`Lose ${option.normal} ${me.faction === 'ixians' ? 'Suboid' : 'ordinary'} + ${option.elite} ${specialForceName(me.faction)} counters`,
|
||||
{
|
||||
type: 'decision',
|
||||
event:
|
||||
g.decision?.kind === 'homeworldExplosion'
|
||||
? g.decision.event
|
||||
: undefined,
|
||||
choice,
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
) : g.decision.kind === 'battleLosses' ? (
|
||||
<>
|
||||
<p className="muted">
|
||||
Choose forces to lose that match your revealed strength and
|
||||
spice support.
|
||||
At {combatName(g.decision.territory)}, choose forces to lose
|
||||
that match your revealed strength and spice support.
|
||||
</p>
|
||||
{g.decision.options.map((option, choice) => (
|
||||
<div key={choice}>
|
||||
@@ -2682,23 +2858,24 @@ export function GameTable({
|
||||
<>
|
||||
<p className="muted">
|
||||
Another faction has shipped from off-planet. You may send
|
||||
one reserve force to the Polar Sink for free.
|
||||
up to {g.homeworldMobility?.advisorSinkMaximum ?? 1} reserve forces to the Polar Sink for free.
|
||||
</p>
|
||||
{g.guildAmbassadorAdvisorChoices ? (
|
||||
g.guildAmbassadorAdvisorChoices.choices.map((choice) => {
|
||||
const reasonId = `guild-advisor-${choice.accompany}-${choice.territory}-${choice.sector}`;
|
||||
const reasonId = `guild-advisor-${choice.accompany}-${choice.territory}-${choice.sector}-${choice.amount}`;
|
||||
return (
|
||||
<div key={reasonId}>
|
||||
{actionButton(
|
||||
choice.accompany
|
||||
? `Accompany · ${territory(choice.territory).name} · sector ${choice.sector}`
|
||||
: 'Send to Polar Sink',
|
||||
: `Send ${choice.amount} to Polar Sink`,
|
||||
{
|
||||
type: 'decision',
|
||||
accept: true,
|
||||
accompany: choice.accompany,
|
||||
territory: choice.territory,
|
||||
sector: choice.sector,
|
||||
amount: choice.amount,
|
||||
},
|
||||
!!choice.blocked,
|
||||
choice.blocked ? reasonId : undefined,
|
||||
@@ -2731,10 +2908,11 @@ export function GameTable({
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{actionButton('Send to Polar Sink', {
|
||||
type: 'decision',
|
||||
accept: true,
|
||||
})}
|
||||
{Array.from({length: g.homeworldMobility?.advisorSinkMaximum ?? 1}, (_, index) => (
|
||||
<div key={index}>{actionButton(`Send ${index + 1} to Polar Sink`, {
|
||||
type: 'decision', accept: true, amount: index + 1,
|
||||
})}</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{actionButton('Decline shipment', {
|
||||
@@ -2746,7 +2924,7 @@ export function GameTable({
|
||||
<>
|
||||
<p className="muted">
|
||||
Shai-Hulud has appeared in{' '}
|
||||
{territory(g.decision.territory).name}. You may protect your
|
||||
{combatName(g.decision.territory)}. You may protect your
|
||||
ally’s forces from being devoured.
|
||||
</p>
|
||||
{actionButton('Protect my ally', {
|
||||
@@ -2762,7 +2940,7 @@ export function GameTable({
|
||||
<>
|
||||
<p className="muted">
|
||||
The Nexus has concluded. Move some or all of your forces
|
||||
from {territory(g.decision.territory).name} to a legal
|
||||
from {combatName(g.decision.territory)} to a legal
|
||||
territory. This does not use your normal movement.
|
||||
</p>
|
||||
{Object.entries(me.forces)
|
||||
@@ -3012,8 +3190,9 @@ export function GameTable({
|
||||
{g.players
|
||||
.filter((p) => p.id !== me.id)
|
||||
.map((p) => (
|
||||
<option value={p.id} key={p.id}>
|
||||
<option value={p.id} key={p.id} disabled={!!g.homeworldAllianceBlocks?.[p.id]}>
|
||||
{p.name}
|
||||
{g.homeworldAllianceBlocks?.[p.id] ? ` · ${g.homeworldAllianceBlocks[p.id]}` : ''}
|
||||
{g.allianceOffers[p.id] === me.id
|
||||
? ' · Invited you'
|
||||
: ''}
|
||||
@@ -3021,10 +3200,11 @@ export function GameTable({
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{g.homeworldAllianceBlocks?.[target] && <p className="notice">{g.homeworldAllianceBlocks[target]}</p>}
|
||||
{actionButton('Propose / accept alliance', {
|
||||
type: 'alliance',
|
||||
target,
|
||||
})}
|
||||
}, !target || !!g.homeworldAllianceBlocks?.[target])}
|
||||
{me.ally &&
|
||||
actionButton('Break alliance', { type: 'alliance' })}
|
||||
</>
|
||||
@@ -3047,8 +3227,10 @@ export function GameTable({
|
||||
</p>
|
||||
<p className="fine">
|
||||
{g.charity.payer
|
||||
? `Charity is paid from ${g.players.find((p) => p.id === g.charity.payer)?.name}’s spice.`
|
||||
? `The ordinary ${g.charity.ordinary} spice is paid from ${g.players.find((p) => p.id === g.charity.payer)?.name}’s spice.`
|
||||
: 'Charity is paid by the Spice Bank.'}
|
||||
{g.charity.homeworld > 0 &&
|
||||
` Low Homeworld population adds ${g.charity.homeworld} spice directly from the bank${g.charity.multiplier === 2 ? ' after Inflation doubles the bonus' : ''}.`}
|
||||
</p>
|
||||
{actionButton(
|
||||
g.charity.amount > 0
|
||||
@@ -3177,6 +3359,23 @@ export function GameTable({
|
||||
</p>
|
||||
)}
|
||||
<RevivalCommerce game={g} act={act} busy={busy} />
|
||||
{g.revival.homeworldBonus > 0 && (
|
||||
<p className="notice">
|
||||
Low Homeworld population adds one to your free revival
|
||||
rate. The next request uses your population after this
|
||||
group returns.
|
||||
{g.revival.freeBlocked
|
||||
? ' La La La currently prevents taking any free revivals.'
|
||||
: ''}
|
||||
</p>
|
||||
)}
|
||||
{g.revival.tleilaxuHomeworldIncomeBlocked && (
|
||||
<p className="notice">
|
||||
Tleilax started Revival at low population. Tleilaxu
|
||||
receives no bank reward for other factions’ free revivals
|
||||
this phase; paid revival and Ghola income remain separate.
|
||||
</p>
|
||||
)}
|
||||
{g.revival.freeBlocked && (
|
||||
<p className="notice">
|
||||
La La La prevents your free force revivals for this phase.
|
||||
@@ -3255,7 +3454,7 @@ export function GameTable({
|
||||
onChange={setAllyEliteAmount}
|
||||
max={Math.min(
|
||||
amount,
|
||||
eliteRevivalRemaining(ally),
|
||||
eliteRevivalRemaining(ally, g.advanced),
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
@@ -3307,7 +3506,13 @@ export function GameTable({
|
||||
},
|
||||
g.revival.prevented ||
|
||||
!me.tanks ||
|
||||
amount > g.revival.forcesRemaining,
|
||||
amount > g.revival.forcesRemaining ||
|
||||
!!homeworldRevivalActionBlock(g, { type: 'revive', amount, elite: eliteAmount }),
|
||||
)}
|
||||
{homeworldRevivalActionBlock(g, { type: 'revive', amount, elite: eliteAmount }) && (
|
||||
<p className="fine" aria-live="polite">
|
||||
{homeworldRevivalActionBlock(g, { type: 'revive', amount, elite: eliteAmount })}
|
||||
</p>
|
||||
)}
|
||||
{me.faction === 'fremen' &&
|
||||
amount > g.revival.forcesRemaining ? (
|
||||
@@ -3392,6 +3597,20 @@ export function GameTable({
|
||||
{g.phase === 5 && (
|
||||
<ShipmentPromises game={g} act={act} busy={busy} />
|
||||
)}
|
||||
{g.phase === 5 && g.active === me.id && g.homeworldShipment && !me.shipped && (
|
||||
<HomeworldShipment key={g.homeworldShipment.event} game={g} act={act} busy={busy} />
|
||||
)}
|
||||
{g.phase === 5 && g.active === me.id && g.guildHomeworldShipment && !me.shipped && (
|
||||
<GuildHomeworldShipment key={g.guildHomeworldShipment.event} game={g} act={act} busy={busy} />
|
||||
)}
|
||||
{g.phase === 5 && g.homeworldMove && (
|
||||
<EmperorHomeworldMovement
|
||||
key={g.homeworldMove.event}
|
||||
game={g}
|
||||
act={act}
|
||||
busy={busy}
|
||||
/>
|
||||
)}
|
||||
{g.phase === 5 &&
|
||||
me.faction === 'choam' &&
|
||||
g.choamMovementBonus > 0 && (
|
||||
@@ -3467,6 +3686,19 @@ export function GameTable({
|
||||
)}
|
||||
{!me.shipped && (
|
||||
<>
|
||||
<NativeShipmentChoice
|
||||
game={g}
|
||||
amount={amount}
|
||||
elite={eliteAmount}
|
||||
sources={homeworldSources}
|
||||
busy={busy}
|
||||
onChange={(sources) =>
|
||||
setHomeworldDraft({
|
||||
key: homeworldSourceKey,
|
||||
sources,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<ShipmentQuote
|
||||
id="reserve-shipment-quote"
|
||||
quote={shipmentQuote}
|
||||
@@ -3492,6 +3724,7 @@ export function GameTable({
|
||||
territory: selected,
|
||||
sector,
|
||||
amount,
|
||||
...(homeworldSources ? { homeworldSources } : {}),
|
||||
})
|
||||
}
|
||||
>
|
||||
@@ -3505,7 +3738,7 @@ export function GameTable({
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includesNoField}
|
||||
disabled={busy}
|
||||
disabled={busy || !!g.homeworldMobility?.noFieldMovementBlocked}
|
||||
onChange={(event) =>
|
||||
setMoveNoField(event.target.checked)
|
||||
}
|
||||
@@ -3521,6 +3754,9 @@ export function GameTable({
|
||||
only the marker. Its hidden value stays unchanged.
|
||||
</p>
|
||||
)}
|
||||
{markerAtSource && g.homeworldMobility?.noFieldMovementBlocked && (
|
||||
<p className="fine">{g.homeworldMobility.noFieldMovementBlocked} Physical forces may still move without the token.</p>
|
||||
)}
|
||||
<label className="decision-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -3734,30 +3970,18 @@ export function GameTable({
|
||||
{g.phase === 6 && !g.battle && g.active === me.id && (
|
||||
<>
|
||||
<p className="muted">
|
||||
Select a disputed territory on the map, then choose your
|
||||
opponent.
|
||||
Choose one of your unresolved battles.
|
||||
</p>
|
||||
<label>
|
||||
Opponent
|
||||
<select
|
||||
value={target}
|
||||
onChange={(e) => setTarget(e.target.value)}
|
||||
{botBattleChoices(g).map((battle) => (
|
||||
<div
|
||||
key={`${String(battle.territory)}-${String(battle.target)}`}
|
||||
>
|
||||
<option value="">Choose opponent</option>
|
||||
{g.players
|
||||
.filter((p) => p.id !== me.id)
|
||||
.map((p) => (
|
||||
<option value={p.id} key={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{actionButton('Begin battle', {
|
||||
type: 'chooseBattle',
|
||||
territory: selected,
|
||||
target,
|
||||
})}
|
||||
{actionButton(
|
||||
`Fight ${g.players.find((p) => p.id === battle.target)?.name} · ${combatName(String(battle.territory))}`,
|
||||
battle,
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{g.phase === 8 && me.faction === 'choam' && (
|
||||
@@ -3793,8 +4017,28 @@ export function GameTable({
|
||||
<>
|
||||
<p className="battle-location">
|
||||
<Swords size={17} />
|
||||
{territory(g.battle.territory).name}
|
||||
{g.battle.locationName ?? combatName(g.battle.territory)}
|
||||
</p>
|
||||
{!!g.battle.native && (
|
||||
<p className="notice">
|
||||
Only the native faction may reveal Traitors or Face
|
||||
Dancers here.
|
||||
{[g.battle.attacker, g.battle.defender].includes(
|
||||
g.battle.native,
|
||||
) && (
|
||||
<>
|
||||
{' '}
|
||||
{
|
||||
g.players.find((p) => p.id === g.battle?.native)
|
||||
?.name
|
||||
}{' '}
|
||||
adds +{g.battle.nativeBattleStrength} native strength
|
||||
in ordinary strength comparisons, separately from the
|
||||
dial.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{g.battle.eliteBlocked.length > 0 && (
|
||||
<p className="notice">
|
||||
Karama:{' '}
|
||||
@@ -3919,6 +4163,14 @@ export function GameTable({
|
||||
? `Your concealed No-Field can reveal ${battleForces} physical forces from your current reserves. This estimate is private.`
|
||||
: `You have ${battleForces} fighting forces here.`}
|
||||
</p>
|
||||
{ownBattleForces?.eliteFreeSupport && (
|
||||
<p className="notice">
|
||||
Salusa Secundus is at high population. Your
|
||||
Sardaukar fight at their full current strength
|
||||
without spice support; normal forces still
|
||||
require support.
|
||||
</p>
|
||||
)}
|
||||
{g.advanced &&
|
||||
(me.faction !== 'fremen' ||
|
||||
g.battle.fremenSupportBlocked) && (
|
||||
@@ -4412,6 +4664,14 @@ export function GameTable({
|
||||
)}
|
||||
{c.effect === 'ghola' && (
|
||||
<>
|
||||
{g.ghola.eliteBlock && (
|
||||
<p className="fine">{g.ghola.eliteBlock}</p>
|
||||
)}
|
||||
{homeworldRevivalActionBlock(g, { type: 'card', card: c.id, amount, elite: eliteAmount, leader: leader || undefined }) && (
|
||||
<p className="fine" aria-live="polite">
|
||||
{homeworldRevivalActionBlock(g, { type: 'card', card: c.id, amount, elite: eliteAmount, leader: leader || undefined })}
|
||||
</p>
|
||||
)}
|
||||
<label htmlFor={`ghola-${c.id}`}>
|
||||
Forces to revive (or choose a leader)
|
||||
<Input
|
||||
@@ -4683,6 +4943,7 @@ export function GameTable({
|
||||
busy ||
|
||||
!!g.truthtrance ||
|
||||
(!!availability && !availability.available) ||
|
||||
!!homeworldRevivalActionBlock(g, { type: 'card', card: c.id, amount, elite: eliteAmount, leader: leader || undefined }) ||
|
||||
(g.status !== 'playing' &&
|
||||
!(
|
||||
g.status === 'setup' &&
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import type { Action, GameView } from '@/game/engine';
|
||||
import {
|
||||
guildHomeworldShipmentChoice,
|
||||
guildHomeworldShipmentOrigins,
|
||||
} from '@/game/guild-homeworld-shipment-options';
|
||||
import {
|
||||
homeworldName,
|
||||
type HomeworldShipmentSources,
|
||||
} from '@/game/homeworld-shipment-options';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
|
||||
export function GuildHomeworldShipment({
|
||||
game: g,
|
||||
act,
|
||||
busy,
|
||||
}: {
|
||||
game: GameView;
|
||||
act: (action: Action) => void;
|
||||
busy: boolean;
|
||||
}) {
|
||||
const origins = guildHomeworldShipmentOrigins(g);
|
||||
const me = g.players.find((player) => player.id === g.me)!;
|
||||
const targets = (g.homeworlds?.worlds ?? []).filter(
|
||||
(world) => world.native !== me.ally,
|
||||
);
|
||||
const [origin, setOrigin] = useState(origins[0]?.territory.id ?? '');
|
||||
const [destination, setDestination] = useState(
|
||||
targets.find((world) => world.native === me.id)?.id ?? targets[0]?.id ?? '',
|
||||
);
|
||||
const [sources, setSources] = useState<HomeworldShipmentSources>({});
|
||||
const [allyPayment, setAllyPayment] = useState(0);
|
||||
const option = g.guildHomeworldShipment;
|
||||
if (!option || me.faction !== 'guild') return null;
|
||||
const selectedOrigin = origins.find(
|
||||
(candidate) => candidate.territory.id === origin,
|
||||
);
|
||||
const selected = Object.fromEntries(
|
||||
(selectedOrigin?.pools ?? []).flatMap((pool) => {
|
||||
const chosen = sources[pool.key];
|
||||
return chosen && chosen.normal + chosen.elite > 0
|
||||
? [[pool.key, chosen]]
|
||||
: [];
|
||||
}),
|
||||
);
|
||||
const quote = guildHomeworldShipmentChoice(
|
||||
g,
|
||||
destination,
|
||||
selected,
|
||||
allyPayment,
|
||||
);
|
||||
return (
|
||||
<details className="rounded-lg border border-[#a88b60]/50 p-3">
|
||||
<summary className="cursor-pointer py-2">
|
||||
Guild transport from Arrakis to a Homeworld
|
||||
</summary>
|
||||
<p className="fine">
|
||||
Ship forces from one Arrakis territory to Junction or another faction’s
|
||||
Homeworld. You may combine forces in different sectors of that
|
||||
territory. Allied Homeworlds cannot be destinations.
|
||||
</p>
|
||||
<p className="fine">
|
||||
Pay one spice per two forces, rounded up. This uses your shipment; your
|
||||
normal movement remains available.
|
||||
</p>
|
||||
{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>Arrakis source territory</span>
|
||||
<select
|
||||
aria-label="Arrakis source territory"
|
||||
className="w-full"
|
||||
value={origin}
|
||||
onChange={(event) => {
|
||||
setOrigin(event.target.value);
|
||||
setSources({});
|
||||
}}
|
||||
>
|
||||
{!origins.length && (
|
||||
<option value="">No Guild forces on Arrakis</option>
|
||||
)}
|
||||
{origins.map((candidate) => (
|
||||
<option
|
||||
key={candidate.territory.id}
|
||||
value={candidate.territory.id}
|
||||
>
|
||||
{candidate.territory.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{selectedOrigin?.pools.map((pool) => {
|
||||
const label = `${selectedOrigin.territory.name}${pool.sector === null ? '' : ` sector ${pool.sector}`}`;
|
||||
const storm = pool.sector === g.storm;
|
||||
return (
|
||||
<label key={pool.key} className="block space-y-1">
|
||||
<span>
|
||||
Forces from {label} · {pool.normal} available
|
||||
{storm ? ' · blocked by storm' : ''}
|
||||
</span>
|
||||
<Input
|
||||
aria-label={`Guild forces from ${label}`}
|
||||
type="number"
|
||||
min={0}
|
||||
max={pool.normal}
|
||||
step={1}
|
||||
disabled={storm}
|
||||
value={sources[pool.key]?.normal ?? 0}
|
||||
onChange={(event) =>
|
||||
setSources({
|
||||
...sources,
|
||||
[pool.key]: {
|
||||
normal: Number(event.target.value),
|
||||
elite: 0,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
<label className="block space-y-1">
|
||||
<span>Homeworld destination for Guild transport</span>
|
||||
<select
|
||||
aria-label="Homeworld destination for Guild transport"
|
||||
className="w-full"
|
||||
value={destination}
|
||||
onChange={(event) => setDestination(event.target.value)}
|
||||
>
|
||||
{targets.map((world) => (
|
||||
<option key={world.id} value={world.id}>
|
||||
{homeworldName(g, world.id)}
|
||||
{world.native === me.id ? ' · return to your Homeworld' : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{me.ally && (
|
||||
<label className="block space-y-1">
|
||||
<span>
|
||||
Allied contribution for Guild transport · {g.aid.available}{' '}
|
||||
pledged
|
||||
</span>
|
||||
<Input
|
||||
aria-label="Allied contribution for Guild transport"
|
||||
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);
|
||||
}}
|
||||
>
|
||||
Transport to {homeworldName(g, destination)}
|
||||
</Button>
|
||||
</fieldset>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
'use client';
|
||||
|
||||
import Image from 'next/image';
|
||||
import { Dialog as DialogPrimitive } from '@base-ui/react/dialog';
|
||||
import { FACTIONS } from '@/game/catalog';
|
||||
import { HOMEWORLD_CARDS, type HomeworldCard } from '@/game/homeworld-cards';
|
||||
import { Button } from './ui/button';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from './ui/tabs';
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogDescription,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from './ui/dialog';
|
||||
|
||||
function HomeworldPopulation({
|
||||
card,
|
||||
side,
|
||||
enlarged,
|
||||
}: {
|
||||
card: HomeworldCard;
|
||||
side: 'high' | 'low';
|
||||
enlarged: boolean;
|
||||
}) {
|
||||
const face = card[side];
|
||||
return (
|
||||
<section
|
||||
aria-label={`${side === 'high' ? 'High' : 'Low'} population rules`}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-[#a88b60]/40 pb-3">
|
||||
<h4 className="m-0 font-serif text-xl">
|
||||
{side === 'high' ? 'High' : 'Low'} population
|
||||
</h4>
|
||||
<span className="rounded border border-[#a88b60]/60 px-3 py-1 text-base tabular-nums">
|
||||
{face.reserves.min}–{face.reserves.max}{' '}
|
||||
{card.reserveType === 'sardaukar' ? 'Sardaukar' : 'reserves'}
|
||||
</span>
|
||||
</div>
|
||||
{face.gameplay.map((text) => (
|
||||
<p
|
||||
key={text}
|
||||
className={
|
||||
enlarged ? 'm-0 text-xl! leading-8!' : 'm-0 text-base! leading-7!'
|
||||
}
|
||||
>
|
||||
{text}
|
||||
</p>
|
||||
))}
|
||||
<div className="grid gap-2 border-t border-[#a88b60]/40 pt-3 text-sm leading-6 sm:grid-cols-2">
|
||||
<p className="m-0">
|
||||
<strong className="text-[#edcc8c]">
|
||||
+{face.battleStrength} battle strength
|
||||
</strong>
|
||||
<br />
|
||||
Added to the native faction’s dial here.
|
||||
</p>
|
||||
<p className="m-0">
|
||||
<strong className="text-[#edcc8c]">
|
||||
{face.battleStrength} explosion losses
|
||||
</strong>
|
||||
<br />
|
||||
Native forces lost to a Lasgun/Shield explosion here.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function HomeworldFace({
|
||||
card,
|
||||
enlarged = false,
|
||||
}: {
|
||||
card: HomeworldCard;
|
||||
enlarged?: boolean;
|
||||
}) {
|
||||
const faction = FACTIONS.find((item) => item.id === card.faction)!;
|
||||
return (
|
||||
<article className="min-w-0 overflow-hidden rounded-xl border border-[#a88b60] bg-[#20271f] text-[#f2e8d3] shadow-lg">
|
||||
<header
|
||||
className="flex min-h-24 flex-wrap items-center gap-3 border-b border-[#a88b60]/50 px-4 py-4"
|
||||
style={{ borderTop: `4px solid ${faction.color}` }}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="m-0 text-sm leading-6 text-[#d1c7b0]">
|
||||
{faction.name} · Homeworld
|
||||
</p>
|
||||
<h3 className="m-0 break-words font-serif text-2xl leading-tight text-[#fff1d4]">
|
||||
{card.name}
|
||||
</h3>
|
||||
</div>
|
||||
{card.id === 'salusa_secundus' && (
|
||||
<span className="text-sm text-[#edcc8c]">Advanced only</span>
|
||||
)}
|
||||
</header>
|
||||
<Image
|
||||
src={`/art/homeworlds/${card.id}-v1.png`}
|
||||
alt=""
|
||||
width={1536}
|
||||
height={768}
|
||||
unoptimized
|
||||
loading="lazy"
|
||||
className="block aspect-[2/1] w-full object-cover"
|
||||
/>
|
||||
<Tabs defaultValue="high" className="gap-4 p-4">
|
||||
<TabsList
|
||||
aria-label={`${card.name} card face`}
|
||||
className="grid h-auto! w-full grid-cols-2 bg-[#101912] p-1"
|
||||
>
|
||||
<TabsTrigger
|
||||
value="high"
|
||||
className="min-h-11 whitespace-normal px-2 text-[#d1c7b0] data-active:bg-[#3f513b] data-active:text-white"
|
||||
>
|
||||
High face
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="reverse"
|
||||
className="min-h-11 whitespace-normal px-2 text-[#d1c7b0] data-active:bg-[#3f513b] data-active:text-white"
|
||||
>
|
||||
Low / occupied face
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="high">
|
||||
<HomeworldPopulation card={card} side="high" enlarged={enlarged} />
|
||||
</TabsContent>
|
||||
<TabsContent value="reverse" className="space-y-6">
|
||||
<HomeworldPopulation card={card} side="low" enlarged={enlarged} />
|
||||
<section
|
||||
aria-label="Occupied rules"
|
||||
className="space-y-4 border-t-2 border-[#a88b60] pt-4"
|
||||
>
|
||||
<h4 className="m-0 font-serif text-xl">Occupied</h4>
|
||||
{card.occupied.gameplay.map((text) => (
|
||||
<p
|
||||
key={text}
|
||||
className={
|
||||
enlarged
|
||||
? 'm-0 text-xl! leading-8!'
|
||||
: 'm-0 text-base! leading-7!'
|
||||
}
|
||||
>
|
||||
{text}
|
||||
</p>
|
||||
))}
|
||||
<p className="m-0 flex items-center gap-2 text-sm leading-6 text-[#edcc8c]">
|
||||
<span aria-hidden="true" className="text-xl">
|
||||
{'◆'.repeat(card.occupied.spiceIcons)}
|
||||
</span>
|
||||
{card.occupied.spiceIcons} bank spice during Spice Collection
|
||||
</p>
|
||||
<p className="m-0 text-sm leading-6 text-[#d1c7b0]">
|
||||
The low-population penalty continues while occupied.
|
||||
</p>
|
||||
</section>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
{card.id === 'salusa_secundus' && (
|
||||
<p className="m-0 px-4 pb-4 text-sm leading-6 text-[#d1c7b0]">
|
||||
Both printed ranges include 2. The module’s minimum-high rule makes 2
|
||||
Sardaukar high population.
|
||||
</p>
|
||||
)}
|
||||
{!enlarged && (
|
||||
<div className="px-4 pb-4">
|
||||
<Dialog>
|
||||
<DialogTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="outline"
|
||||
className="min-h-11 w-full whitespace-normal"
|
||||
/>
|
||||
}
|
||||
>
|
||||
Inspect {card.name}
|
||||
</DialogTrigger>
|
||||
<DialogPortal>
|
||||
<DialogOverlay className="bg-black/75 motion-reduce:animate-none" />
|
||||
<DialogPrimitive.Popup className="fixed top-1/2 left-1/2 z-50 flex max-h-[calc(100dvh-1rem)] w-[calc(100%-1rem)] max-w-2xl -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-xl border border-[#a88b60] bg-[#171d18] text-[#f2e8d3] shadow-2xl outline-none motion-reduce:animate-none">
|
||||
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-[#a88b60]/40 px-4 py-3">
|
||||
<DialogTitle className="text-base leading-6">
|
||||
{card.name}
|
||||
</DialogTitle>
|
||||
<DialogClose
|
||||
render={
|
||||
<Button variant="outline" className="min-h-11 min-w-16" />
|
||||
}
|
||||
>
|
||||
Close
|
||||
</DialogClose>
|
||||
</div>
|
||||
<DialogDescription className="sr-only">
|
||||
Both faces of the Homeworld card, including population ranges,
|
||||
native battle values and occupied effects.
|
||||
</DialogDescription>
|
||||
<div className="min-h-0 overflow-y-auto overscroll-contain p-3 sm:p-5">
|
||||
<HomeworldFace card={card} enlarged />
|
||||
</div>
|
||||
</DialogPrimitive.Popup>
|
||||
</DialogPortal>
|
||||
</Dialog>
|
||||
</div>
|
||||
)}
|
||||
<footer className="border-t border-[#a88b60]/40 px-4 py-3 text-xs leading-5 tracking-wide text-[#c9baa0]">
|
||||
Ecaz & Moritani · 1 two-sided card + 1 world token
|
||||
</footer>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export function HomeworldCardGallery() {
|
||||
return (
|
||||
<section aria-label="Homeworld card collection" className="space-y-4">
|
||||
<p className="m-0 text-base leading-7 text-[#d1c7b0]">
|
||||
13 worlds · Inspect either face to read its complete gameplay effects.
|
||||
Homeworld games are not yet available.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 items-start gap-5 xl:grid-cols-2">
|
||||
{HOMEWORLD_CARDS.map((card) => (
|
||||
<HomeworldFace key={card.id} card={card} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import type { Action, GameView } from '@/game/engine';
|
||||
import {
|
||||
homeworldRevivalDeploymentCanAct,
|
||||
homeworldRevivalDeploymentChoice,
|
||||
} from '@/game/homeworld-revival-deployment-options';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
type Props = {
|
||||
game: GameView;
|
||||
act: (action: Action) => void;
|
||||
busy: boolean;
|
||||
};
|
||||
|
||||
export function HomeworldRevivalDeployment({ game, act, busy }: Props) {
|
||||
if (
|
||||
!game.homeworldRevivalDeployment ||
|
||||
game.homeworldRevivalDeployment.player !== game.me ||
|
||||
game.decision?.kind !== 'homeworldRevivalDeployment' ||
|
||||
game.decision.player !== game.me
|
||||
)
|
||||
return null;
|
||||
return (
|
||||
<DeploymentChoice
|
||||
key={game.homeworldRevivalDeployment.event}
|
||||
game={game}
|
||||
act={act}
|
||||
busy={busy}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DeploymentChoice({ game, act, busy }: Props) {
|
||||
const offer = game.homeworldRevivalDeployment!;
|
||||
const [destination, setDestination] = useState(
|
||||
offer.destinations.find((target) => !target.blocked)?.id ?? '',
|
||||
);
|
||||
const canAct = homeworldRevivalDeploymentCanAct(game);
|
||||
const choice = homeworldRevivalDeploymentChoice(game, destination);
|
||||
const amount = offer.normal + offer.elite;
|
||||
return (
|
||||
<section aria-label="Revival deployment" className="space-y-3">
|
||||
<h3>
|
||||
{offer.kind === 'fedaykin'
|
||||
? 'Place revived Fedaykin'
|
||||
: 'Place revived forces'}
|
||||
</h3>
|
||||
<p>
|
||||
Place the entire eligible revived group in one destination, or leave it
|
||||
in reserves.
|
||||
</p>
|
||||
<p className="fine">
|
||||
{offer.normal} ordinary · {offer.elite} starred · {amount} total
|
||||
</p>
|
||||
<label className="block space-y-1">
|
||||
<span>Revival destination</span>
|
||||
<select
|
||||
aria-label="Revival destination"
|
||||
className="w-full"
|
||||
value={destination}
|
||||
disabled={busy || !canAct || !!offer.blocked}
|
||||
onChange={(event) => setDestination(event.target.value)}
|
||||
>
|
||||
{!offer.destinations.some((target) => !target.blocked) && (
|
||||
<option value="">No legal revival destination</option>
|
||||
)}
|
||||
{offer.destinations.map((target) => (
|
||||
<option
|
||||
key={target.id}
|
||||
value={target.id}
|
||||
disabled={!!target.blocked}
|
||||
>
|
||||
{target.name}
|
||||
{target.blocked ? ` — ${target.blocked}` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{choice.blocked && (
|
||||
<p className="fine" aria-live="polite">
|
||||
{choice.blocked}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
className="game-action"
|
||||
disabled={busy || !choice.action}
|
||||
onClick={() => {
|
||||
if (choice.action) act(choice.action);
|
||||
}}
|
||||
>
|
||||
Place {amount} revived {amount === 1 ? 'force' : 'forces'}
|
||||
</Button>
|
||||
<Button
|
||||
className="game-action"
|
||||
variant="outline"
|
||||
disabled={busy || !canAct}
|
||||
onClick={() =>
|
||||
act({ type: 'decision', event: offer.event, decline: true })
|
||||
}
|
||||
>
|
||||
Leave in reserves
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import type { Action, GameView } from '@/game/engine';
|
||||
import { specialForceName } from '@/game/combat';
|
||||
import {
|
||||
homeworldName,
|
||||
homeworldShipmentChoice,
|
||||
type HomeworldShipmentSources,
|
||||
} from '@/game/homeworld-shipment-options';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
|
||||
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') &&
|
||||
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 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);
|
||||
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'
|
||||
? '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">
|
||||
{me.faction === 'guild'
|
||||
? 'Guild pays one spice per two forces, rounded up.'
|
||||
: 'Pay one spice per force.'}{' '}
|
||||
This uses your shipment; your normal movement remains available.
|
||||
</p>
|
||||
{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>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import type { Action, GameView } from '@/game/engine';
|
||||
import { faction } from '@/game/catalog';
|
||||
import { HOMEWORLD_CARDS } from '@/game/homeworld-cards';
|
||||
import type { NativeReserveSelections } from '@/game/homeworld-native-reserves';
|
||||
import { HomeworldFace } from './homeworld-cards';
|
||||
import { EcazPoisonIncome } from './ecaz-poison-income';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
|
||||
export function HomeworldTable({ game: g }: { game: GameView }) {
|
||||
const worlds = g.homeworlds?.worlds;
|
||||
if (!worlds) return null;
|
||||
return (
|
||||
<section aria-label="Homeworld forces" className="space-y-4 p-4">
|
||||
<h3 className="font-serif text-xl">Homeworlds</h3>
|
||||
<p className="fine">
|
||||
Native forces here are your reserves. Salusa Secundus counts Sardaukar
|
||||
for its population.
|
||||
</p>
|
||||
<EcazPoisonIncome game={g} />
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{worlds.map((world) => {
|
||||
const card = HOMEWORLD_CARDS.find((c) => c.id === world.card)!;
|
||||
const owner = g.players.find((p) => p.id === world.native)!;
|
||||
return (
|
||||
<article
|
||||
key={world.id}
|
||||
className="min-w-0 rounded-lg border border-[#a88b60]/50 p-3"
|
||||
style={{ borderTop: `3px solid ${faction(owner.faction).color}` }}
|
||||
>
|
||||
<div className="flex flex-wrap justify-between gap-2">
|
||||
<strong>{card.name}</strong>
|
||||
<span className="capitalize">
|
||||
{world.side} population · {world.population}
|
||||
</span>
|
||||
</div>
|
||||
<p className="fine">
|
||||
{faction(owner.faction).name} · {owner.name}
|
||||
</p>
|
||||
{Object.entries(world.forces).map(([id, forces]) => (
|
||||
<p key={id} className="text-base! leading-7!">
|
||||
{id === world.native
|
||||
? 'Native reserves'
|
||||
: g.players.find((p) => p.id === id)?.name}
|
||||
: {forces.normal} normal
|
||||
{forces.elite > 0
|
||||
? ` + ${forces.elite} ${owner.faction === 'emperor' && id === owner.id ? 'Sardaukar' : 'special'}`
|
||||
: ''}
|
||||
</p>
|
||||
))}
|
||||
<details>
|
||||
<summary className="cursor-pointer py-2">
|
||||
{card.name} card
|
||||
</summary>
|
||||
<HomeworldFace card={card} />
|
||||
</details>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** The complementary Kaitain allocation is explicit in the submitted order. */
|
||||
export function NativeShipmentChoice({
|
||||
game: g,
|
||||
amount,
|
||||
elite,
|
||||
sources,
|
||||
onChange,
|
||||
busy,
|
||||
}: {
|
||||
game: GameView;
|
||||
amount: number;
|
||||
elite: number;
|
||||
sources: NativeReserveSelections | null;
|
||||
onChange: (sources: NativeReserveSelections) => void;
|
||||
busy: boolean;
|
||||
}) {
|
||||
const homes = g.homeworlds?.worlds?.filter((w) => w.native === g.me);
|
||||
if (!homes || homes.length !== 2 || !sources) return null;
|
||||
const salusa = homes.find((w) => w.secondary)!;
|
||||
const kaitain = homes.find((w) => !w.secondary)!;
|
||||
const chosen = sources[salusa.id];
|
||||
const requested = { normal: amount - elite, elite };
|
||||
return (
|
||||
<fieldset
|
||||
disabled={busy}
|
||||
className="space-y-3 rounded-lg border border-[#a88b60]/50 p-3"
|
||||
>
|
||||
<legend>Shipment sources</legend>
|
||||
<p className="fine">
|
||||
Choose the counters leaving Salusa. The remainder leaves Kaitain in the
|
||||
same shipment.
|
||||
</p>
|
||||
{(['normal', 'elite'] as const).map((kind) => {
|
||||
const minimum = Math.max(
|
||||
0,
|
||||
requested[kind] - kaitain.forces[g.me][kind],
|
||||
);
|
||||
const maximum = Math.min(requested[kind], salusa.forces[g.me][kind]);
|
||||
return (
|
||||
<label key={kind} className="block space-y-1">
|
||||
<span>
|
||||
{kind === 'normal' ? 'Normal forces' : 'Sardaukar'} from Salusa (
|
||||
{minimum}–{maximum})
|
||||
</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={minimum}
|
||||
max={maximum}
|
||||
step={1}
|
||||
value={chosen[kind]}
|
||||
onChange={(e) => {
|
||||
const count = Number(e.target.value);
|
||||
onChange({
|
||||
...sources,
|
||||
[salusa.id]: { ...chosen, [kind]: count },
|
||||
[kaitain.id]: {
|
||||
...sources[kaitain.id],
|
||||
[kind]: requested[kind] - count,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
<p className="fine">
|
||||
Kaitain: {sources[kaitain.id].normal} normal +{' '}
|
||||
{sources[kaitain.id].elite} Sardaukar. Salusa: {chosen.normal} normal +{' '}
|
||||
{chosen.elite} Sardaukar.
|
||||
</p>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
export function EmperorHomeworldMovement({
|
||||
game: g,
|
||||
act,
|
||||
busy,
|
||||
}: {
|
||||
game: GameView;
|
||||
act: (action: Action) => void;
|
||||
busy: boolean;
|
||||
}) {
|
||||
const [origin, setOrigin] = useState('homeworld:emperor');
|
||||
const [normal, setNormal] = useState(0);
|
||||
const [elite, setElite] = useState(0);
|
||||
const option = g.homeworldMove;
|
||||
const homes = g.homeworlds?.worlds?.filter((w) => w.native === g.me);
|
||||
if (!option || !homes || homes.length !== 2) return null;
|
||||
const source = homes.find((w) => w.id === origin)!;
|
||||
const destination = homes.find((w) => w.id !== origin)!;
|
||||
const available = source.forces[g.me];
|
||||
const name = (id: string) =>
|
||||
id === 'homeworld:emperor' ? 'Kaitain' : 'Salusa Secundus';
|
||||
const invalid =
|
||||
!Number.isSafeInteger(normal) ||
|
||||
!Number.isSafeInteger(elite) ||
|
||||
normal < 0 ||
|
||||
elite < 0 ||
|
||||
normal > available.normal ||
|
||||
elite > available.elite ||
|
||||
normal + elite === 0;
|
||||
return (
|
||||
<details className="rounded-lg border border-[#a88b60]/50 p-3">
|
||||
<summary className="cursor-pointer py-2">
|
||||
Move between Imperial Homeworlds
|
||||
</summary>
|
||||
<p className="fine">
|
||||
Move normal forces or Sardaukar between Kaitain and Salusa Secundus.
|
||||
Costs one movement and no spice. Moving first passes your unused
|
||||
shipment.
|
||||
</p>
|
||||
{option.blocked && (
|
||||
<output className="notice block">{option.blocked}</output>
|
||||
)}
|
||||
<fieldset disabled={busy || !!option.blocked} className="space-y-3">
|
||||
<label className="block">
|
||||
From
|
||||
<select
|
||||
value={origin}
|
||||
onChange={(e) => {
|
||||
setOrigin(e.target.value);
|
||||
setNormal(0);
|
||||
setElite(0);
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
{homes.map((home) => (
|
||||
<option key={home.id} value={home.id}>
|
||||
{name(home.id)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
Normal forces (0–{available.normal})
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={available.normal}
|
||||
step={1}
|
||||
value={normal}
|
||||
onChange={(e) => setNormal(Number(e.target.value))}
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
Sardaukar (0–{available.elite})
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={available.elite}
|
||||
step={1}
|
||||
value={elite}
|
||||
onChange={(e) => setElite(Number(e.target.value))}
|
||||
/>
|
||||
</label>
|
||||
<p className="fine">
|
||||
{option.remaining} movement{option.remaining === 1 ? '' : 's'}{' '}
|
||||
remaining.
|
||||
</p>
|
||||
<Button
|
||||
className="game-action"
|
||||
disabled={invalid || busy || !!option.blocked}
|
||||
onClick={() =>
|
||||
act({
|
||||
type: 'emperorHomeworldMove',
|
||||
event: option.event,
|
||||
origin,
|
||||
normal,
|
||||
elite,
|
||||
})
|
||||
}
|
||||
>
|
||||
Move to {name(destination.id)}
|
||||
</Button>
|
||||
</fieldset>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -18,25 +18,42 @@ export function IxSubstitution({
|
||||
if (g.decision?.kind !== 'ixSubstitution') return null;
|
||||
const d = g.decision;
|
||||
const me = g.players.find((p) => p.id === g.me)!;
|
||||
const available = Object.entries(me.forces)
|
||||
.filter(([key]) => splitLocation(key).territory === d.territory)
|
||||
.map(
|
||||
([key, count]) => [key, count - (me.elites?.forces[key] ?? 0)] as const,
|
||||
)
|
||||
.filter(([, count]) => count > 0);
|
||||
const world = g.combatLocations?.find(
|
||||
(place) => place.id === d.territory && place.kind === 'homeworld',
|
||||
);
|
||||
const available = (
|
||||
world
|
||||
? [[world.id, world.forces?.[me.id]?.normal ?? 0] as const]
|
||||
: Object.entries(me.forces)
|
||||
.filter(([key]) => splitLocation(key).territory === d.territory)
|
||||
.map(
|
||||
([key, count]) =>
|
||||
[key, count - (me.elites?.forces[key] ?? 0)] as const,
|
||||
)
|
||||
).filter(([, count]) => count > 0);
|
||||
const total = (value: Record<string, number>) =>
|
||||
Object.values(value).reduce((a, b) => a + b, 0);
|
||||
const invalid = (
|
||||
selected: Record<string, number>,
|
||||
limits: Record<string, number>,
|
||||
) =>
|
||||
Object.entries(selected).some(
|
||||
([key, count]) =>
|
||||
!Number.isSafeInteger(count) || count < 0 || count > (limits[key] ?? 0),
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<p>
|
||||
Exchange surviving suboids, one for one, to retain cyborgs lost in this
|
||||
battle. Retained cyborgs return to their casualty sectors. Opponents may
|
||||
cancel this substitution with Karama.
|
||||
battle. Retained cyborgs return to{' '}
|
||||
{world ? world.name : 'their casualty sectors'}. Opponents may cancel
|
||||
this substitution with Karama.
|
||||
</p>
|
||||
<h3>Suboids to sacrifice</h3>
|
||||
{available.map(([key, max]) => (
|
||||
<label key={key}>
|
||||
Sector {splitLocation(key).sector} · {max} suboids available
|
||||
{world ? world.name : `Sector ${splitLocation(key).sector}`} · {max}{' '}
|
||||
suboids available
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
@@ -51,7 +68,8 @@ export function IxSubstitution({
|
||||
<h3>Cyborgs to retain</h3>
|
||||
{Object.entries(d.losses).map(([key, max]) => (
|
||||
<label key={key}>
|
||||
Sector {splitLocation(key).sector} · {max} cyborgs lost
|
||||
{world ? world.name : `Sector ${splitLocation(key).sector}`} · {max}{' '}
|
||||
cyborgs lost
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
@@ -67,7 +85,13 @@ export function IxSubstitution({
|
||||
{total(sources)} suboids for {total(recover)} cyborgs
|
||||
</p>
|
||||
<Button
|
||||
disabled={busy || !total(sources) || total(sources) !== total(recover)}
|
||||
disabled={
|
||||
busy ||
|
||||
!total(sources) ||
|
||||
total(sources) !== total(recover) ||
|
||||
invalid(sources, Object.fromEntries(available)) ||
|
||||
invalid(recover, d.losses)
|
||||
}
|
||||
onClick={() => act({ type: 'decision', sources, recover })}
|
||||
>
|
||||
Confirm substitution
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import type { Action, GameView } from '@/game/engine';
|
||||
import {
|
||||
junctionTransportChoice,
|
||||
junctionTransportDestinations,
|
||||
junctionTransportOrigins,
|
||||
} from '@/game/junction-transport-options';
|
||||
import type { HomeworldShipmentSources } from '@/game/homeworld-shipment-options';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
|
||||
export function JunctionTransport({
|
||||
game: g,
|
||||
act,
|
||||
busy,
|
||||
}: {
|
||||
game: GameView;
|
||||
act: (action: Action) => void;
|
||||
busy: boolean;
|
||||
}) {
|
||||
const origins = junctionTransportOrigins(g);
|
||||
const destinations = junctionTransportDestinations(g);
|
||||
const [origin, setOrigin] = useState(origins[0]?.id ?? '');
|
||||
const [destination, setDestination] = useState(destinations[0]?.id ?? '');
|
||||
const [sources, setSources] = useState<HomeworldShipmentSources>({});
|
||||
const [allyPayment, setAllyPayment] = useState(0);
|
||||
const option = g.junctionTransport;
|
||||
const me = g.players.find((p) => p.id === g.me);
|
||||
if (!option || !me || ![option.owner, option.recipient].includes(me.id))
|
||||
return null;
|
||||
const recipient = g.players.find((p) => p.id === option.recipient);
|
||||
if (me.id === option.owner)
|
||||
return (
|
||||
<details className="rounded-lg border border-[#a88b60]/50 p-3">
|
||||
<summary className="cursor-pointer py-2">
|
||||
Offer Junction transport
|
||||
</summary>
|
||||
<p className="fine">
|
||||
Junction allows you to offer {recipient?.name ?? 'the active faction'}{' '}
|
||||
cross-shipment at half or full price, including travel to and from
|
||||
Homeworlds. They choose whether to use it for their shipment.
|
||||
</p>
|
||||
{option.offer && (
|
||||
<p className="fine">Current offer: {option.offer.rate} price.</p>
|
||||
)}
|
||||
{option.blocked && (
|
||||
<output className="notice block">{option.blocked}</output>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2 pt-3">
|
||||
{(['half', 'full'] as const).map((rate) => (
|
||||
<Button
|
||||
key={rate}
|
||||
disabled={busy || !option.canOffer || option.offer?.rate === rate}
|
||||
onClick={() =>
|
||||
act({
|
||||
type: 'offerJunctionTransport',
|
||||
event: option.offerEvent,
|
||||
rate,
|
||||
})
|
||||
}
|
||||
>
|
||||
Offer {rate} price
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
const selectedOrigin = origins.find((candidate) => candidate.id === origin);
|
||||
const selected = Object.fromEntries(
|
||||
(selectedOrigin?.pools ?? []).flatMap((pool) => {
|
||||
const chosen = sources[pool.key];
|
||||
return chosen && chosen.normal + chosen.elite > 0
|
||||
? [[pool.key, chosen]]
|
||||
: [];
|
||||
}),
|
||||
);
|
||||
const quote = junctionTransportChoice(g, destination, selected, allyPayment);
|
||||
return (
|
||||
<details className="rounded-lg border border-[#a88b60]/50 p-3">
|
||||
<summary className="cursor-pointer py-2">
|
||||
Junction transport{option.offer ? ` · ${option.offer.rate} price` : ''}
|
||||
</summary>
|
||||
<p className="fine">
|
||||
Guild’s offer allows one transport from an Arrakis territory or
|
||||
Homeworld, including a return to your own Homeworld. This uses your
|
||||
shipment; movement remains available.
|
||||
</p>
|
||||
{!option.offer ? (
|
||||
<output className="notice block">
|
||||
Guild has not offered transport for this shipment. Your ordinary
|
||||
actions remain available.
|
||||
</output>
|
||||
) : (
|
||||
<>
|
||||
<p className="fine">
|
||||
One force costs one spice to a Homeworld or stronghold, two
|
||||
elsewhere. The Guild’s {option.offer.rate}-price offer applies to
|
||||
the total; fractions round up.
|
||||
</p>
|
||||
{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>Junction transport source</span>
|
||||
<select
|
||||
aria-label="Junction transport source"
|
||||
className="w-full"
|
||||
value={origin}
|
||||
onChange={(event) => {
|
||||
setOrigin(event.target.value);
|
||||
setSources({});
|
||||
}}
|
||||
>
|
||||
{!origins.length && (
|
||||
<option value="">No available physical source</option>
|
||||
)}
|
||||
{origins.map((candidate) => (
|
||||
<option key={candidate.id} value={candidate.id}>
|
||||
{candidate.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{selectedOrigin?.pools.map((pool) => (
|
||||
<div key={pool.key} className="space-y-2">
|
||||
<p className="fine">
|
||||
{pool.name}
|
||||
{pool.sector && pool.sector === g.storm
|
||||
? ' · blocked by storm'
|
||||
: ''}
|
||||
</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' : 'Special'} forces ·{' '}
|
||||
{pool[kind]} available
|
||||
</span>
|
||||
<Input
|
||||
aria-label={`${kind === 'normal' ? 'Normal' : 'Special'} forces from ${pool.name} for Junction transport`}
|
||||
type="number"
|
||||
min={0}
|
||||
max={pool[kind]}
|
||||
step={1}
|
||||
disabled={
|
||||
pool.sector !== null &&
|
||||
pool.sector !== 0 &&
|
||||
pool.sector === g.storm
|
||||
}
|
||||
value={sources[pool.key]?.[kind] ?? 0}
|
||||
onChange={(event) =>
|
||||
setSources({
|
||||
...sources,
|
||||
[pool.key]: {
|
||||
normal: sources[pool.key]?.normal ?? 0,
|
||||
elite: sources[pool.key]?.elite ?? 0,
|
||||
[kind]: Number(event.target.value),
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
<label className="block space-y-1">
|
||||
<span>Junction transport destination</span>
|
||||
<select
|
||||
aria-label="Junction transport destination"
|
||||
className="w-full"
|
||||
value={destination}
|
||||
onChange={(event) => setDestination(event.target.value)}
|
||||
>
|
||||
{destinations.map((target) => (
|
||||
<option key={target.id} value={target.id}>
|
||||
{target.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{me.ally && (
|
||||
<label className="block space-y-1">
|
||||
<span>Allied contribution · {g.aid.available} pledged</span>
|
||||
<Input
|
||||
aria-label="Allied contribution for Junction transport"
|
||||
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.elite} special) · {quote.cost} spice
|
||||
{quote.amount > 0
|
||||
? ` · you pay ${quote.ownPayment}, ally pays ${quote.allyPayment}`
|
||||
: ''}
|
||||
{quote.advisors ? ' · arrives as advisors' : ''}.
|
||||
{quote.blocked ? ` ${quote.blocked}` : ''}
|
||||
</output>
|
||||
<Button
|
||||
className="game-action"
|
||||
disabled={busy || !quote.action}
|
||||
onClick={() => {
|
||||
if (quote.action) act(quote.action);
|
||||
}}
|
||||
>
|
||||
Use Junction transport
|
||||
</Button>
|
||||
</fieldset>
|
||||
</>
|
||||
)}
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -28,6 +28,7 @@ export function MobileStronghold({
|
||||
}) {
|
||||
const pointer = g.mobileStronghold?.location;
|
||||
const placement = !pointer;
|
||||
const blocked = !placement ? g.homeworldMobility?.mobileStrongholdBlocked : null;
|
||||
const [destination, setDestination] = useState('polar_sink:0');
|
||||
const [route, setRoute] = useState<string[]>(pointer ? [pointer] : []);
|
||||
const [collect, setCollect] = useState(true);
|
||||
@@ -51,11 +52,12 @@ export function MobileStronghold({
|
||||
);
|
||||
const valid =
|
||||
placement ||
|
||||
(route.length > 1 &&
|
||||
(!blocked && route.length > 1 &&
|
||||
route.every((key) => splitLocation(key).sector !== g.storm) &&
|
||||
territory(splitLocation(current).territory).type !== 'stronghold');
|
||||
return (
|
||||
<div className="mobile-route">
|
||||
{blocked && <p className="notice">{blocked}</p>}
|
||||
<p>
|
||||
{placement
|
||||
? 'Point your stronghold at a sector in any non-stronghold territory. Placement does not collect spice.'
|
||||
|
||||
@@ -481,6 +481,8 @@ export function MoritaniEntry({
|
||||
advisor: 'as an accompanying advisor',
|
||||
wormRide: 'by worm ride',
|
||||
ambassador: 'through the Fremen Ambassador',
|
||||
homeworldRevival: 'through Homeworld revival',
|
||||
caladanReinforcement: 'through Caladan reinforcement',
|
||||
}[entry.cause];
|
||||
|
||||
return (
|
||||
|
||||
@@ -51,6 +51,12 @@ export function RevealedBattle({ game }: { game: GameView }) {
|
||||
{game.advanced ? ` · ${plan.support} spice support` : ''}
|
||||
{!!plan.allyPayment && ` (${plan.allyPayment} from CHOAM)`}
|
||||
</span>
|
||||
{battle.native === id && !battle.cards.some(isStoneBurner) && (
|
||||
<span>
|
||||
Native Homeworld bonus: +{battle.nativeBattleStrength} to the
|
||||
battle score, separately from the dial
|
||||
</span>
|
||||
)}
|
||||
<span>Leader: {name(plan.leader)}</span>
|
||||
{plan.kwisatz && (
|
||||
<span>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
import Link from 'next/link';
|
||||
import { StrongholdCardGallery } from './stronghold-cards';
|
||||
import { HomeworldCardGallery } from './homeworld-cards';
|
||||
import { createStrongholdCards } from '@/game/stronghold-cards';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { Suspense, useState, useEffect, useRef } from 'react';
|
||||
@@ -69,6 +70,7 @@ function RulesReferenceContent() {
|
||||
[
|
||||
t.title,
|
||||
t.summary,
|
||||
t.searchText ?? '',
|
||||
...t.steps,
|
||||
t.example ?? '',
|
||||
...(t.checklist ?? []).flatMap((item) => [
|
||||
@@ -184,6 +186,7 @@ function RulesReferenceContent() {
|
||||
players={[]}
|
||||
/>
|
||||
)}
|
||||
{topic.id === 'homeworlds' && <HomeworldCardGallery />}
|
||||
{topic.id === 'richese-cards' && (
|
||||
<section
|
||||
aria-label="Richese card collection"
|
||||
|
||||
Reference in new issue
Block a user