Files
Dune/components/ecaz-entry.tsx
T
2026-09-08 07:37:52 +10:00

368 lines
14 KiB
TypeScript

'use client';
import { useId, useState } from 'react';
import type { Action, GameView } from '@/game/engine';
import { AMBASSADOR_REFERENCE } from '@/game/ambassador-reference';
import { territory } from '@/game/board';
import { faction } from '@/game/catalog';
import { leaderStrengthLabel } from '@/game/cards';
import { CHEAP_HERO_TRAITOR } from '@/game/traitors';
import { Button } from './ui/button';
import { CardInspector, CardRules } from './card-inspector';
import { LeaderInspector } from './leader-inspector';
import { AmbassadorInspector, AMBASSADOR_COVERAGE } from './ecaz-ambassadors';
import { AmbassadorMovement } from './ambassador-movement';
import { AmbassadorShipment } from './ambassador-shipment';
const buttonClass = 'min-h-11 whitespace-normal motion-reduce:transition-none';
export function EcazEntry({
game,
act,
busy,
}: {
game: GameView;
act: (action: Action) => void;
busy: boolean;
}) {
const id = useId();
const [selected, setSelected] = useState<string[]>([]);
const entry = game.ambassadorEntry;
if (
!entry ||
game.decision?.kind !== 'ecazAmbassador' ||
game.decision.player !== game.me ||
!['offer', 'copy', 'cards', 'move', 'ship'].includes(entry.stage)
)
return null;
const me = game.players.find((p) => p.id === game.me)!;
const guide = AMBASSADOR_REFERENCE[entry.effect];
const token = game.ambassadors?.tokens.find((t) => t.effect === entry.effect);
const send = (fields: Omit<Action, 'type'>) => {
if (!busy) act({ type: 'decision', event: entry.event, ...fields });
};
const choices = entry.cards.flatMap((option) => {
const card = me.hand?.find((card) => card.id === option.card);
return card ? [{ card, blocked: option.blocked }] : [];
});
const chosen = choices.filter(
(choice) => !choice.blocked && selected.includes(choice.card.id),
);
const legalSelection = entry.effect === 'choam' || chosen.length === 1;
const dukeBlock =
entry.dukeAcquisition?.blocked ??
(entry.dukeAcquisition
? null
: 'Duke acquisition is unavailable for this opportunity.');
const duke = game.dukeVidal?.leader;
return (
<section
className="notice flex min-w-0 flex-col gap-4"
aria-label="Ecaz Ambassador entry decision"
>
<h3 className="m-0 font-serif text-xl">
{guide.name} Ambassador · {territory(entry.territory).name}
</h3>
<p className="m-0 text-sm leading-6">
{game.players.find((p) => p.id === entry.entrant)?.name} entered this
stronghold. {guide.gameplay}
</p>
{token && <AmbassadorInspector token={token} />}
{entry.stage === 'offer' ? (
<>
{entry.effect === 'ecaz' ? (
<>
<p className="m-0 text-sm leading-6">
Acquire Duke Vidal for Ecaz. The Ambassador returns to your
supply. Ecaz keeps Duke until battle use or until Moritani takes
him.
</p>
{duke && (
<LeaderInspector
identity={{
id: duke.id,
name: duke.name,
factionName: faction(duke.faction).name,
strength: leaderStrengthLabel(duke),
}}
/>
)}
<Button
className={buttonClass}
disabled={busy || !!dukeBlock}
aria-describedby={dukeBlock ? `${id}-duke-block` : undefined}
onClick={() => {
if (!dukeBlock)
send({
trigger: true,
beneficiary: entry.owner,
choice: 'duke',
});
}}
>
Acquire Duke Vidal for Ecaz
</Button>
{dukeBlock && (
<p id={`${id}-duke-block`} className="m-0 text-sm leading-6">
{dukeBlock}
</p>
)}
<p className="m-0 text-sm leading-6">
Alliance proposals, acquisition for an ally and Duke loans
through this Ambassador remain unfinished.
</p>
</>
) : (
<>
<p className="m-0 text-sm leading-6">
Trigger the Ambassador for yourself or an eligible ally, or
leave it in place. Triggering commits the token before the
beneficiary resolves its effect.
</p>
{entry.effect === 'richese' && (
<p className="m-0 text-sm leading-6">
Triggering automatically spends 3 spice from the selected
beneficiary and draws a card if the purchase can be completed.
Otherwise no spice is spent and no card is drawn. The token is
used either way; there is no later purchase choice.
</p>
)}
{entry.beneficiaries.map((option) => (
<div key={option.player} className="flex flex-col gap-2">
<Button
className={buttonClass}
disabled={busy || !!option.blocked}
onClick={() =>
send({ trigger: true, beneficiary: option.player })
}
>
Trigger for{' '}
{option.player === game.me
? 'yourself'
: game.players.find((p) => p.id === option.player)?.name}
</Button>
{option.blocked && (
<p className="m-0 text-sm leading-6">{option.blocked}</p>
)}
</div>
))}
</>
)}
<Button
variant="outline"
className={buttonClass}
disabled={busy}
onClick={() => send({ decline: true })}
>
Leave Ambassador in place
</Button>
</>
) : entry.stage === 'copy' ? (
<>
<p className="m-0 text-sm leading-6">
Choose an effect outside the original supply for this group. The
Bene Gesserit token has already been permanently removed.
</p>
{entry.copies.map((option) => {
const copied = AMBASSADOR_REFERENCE[option.effect];
const face = game.ambassadors?.tokens.find(
(t) => t.effect === option.effect,
);
return (
<article
key={option.effect}
className="flex min-w-0 flex-col gap-2 rounded-lg border border-[#485542] p-3"
>
<h4 className="m-0 font-serif text-lg">{copied.name}</h4>
<p className="m-0 text-sm leading-6">{copied.gameplay}</p>
{option.effect === 'richese' && (
<p className="m-0 text-sm leading-6">
This automatically spends 3 of your spice and draws a card
if the purchase can be completed. Otherwise no spice is
spent and no card is drawn. The Bene Gesserit token remains
removed.
</p>
)}
{face && <AmbassadorInspector token={face} />}
{option.blocked && (
<p className="m-0 text-sm leading-6">{option.blocked}</p>
)}
<Button
className={buttonClass}
disabled={busy || !!option.blocked}
onClick={() => send({ effect: option.effect })}
>
Use {copied.name} effect
</Button>
</article>
);
})}
</>
) : entry.stage === 'ship' ? (
<AmbassadorShipment
key={entry.event}
game={game}
act={act}
busy={busy}
/>
) : entry.stage === 'move' ? (
<AmbassadorMovement
key={entry.event}
game={game}
act={act}
busy={busy}
/>
) : (
<>
<p className="m-0 text-sm leading-6">
{entry.effect === 'choam'
? 'Choose any number of available cards, including none. Each discarded card earns three spice.'
: 'Choose exactly one available card to discard and draw its replacement.'}
</p>
<fieldset
className="flex min-w-0 flex-col gap-3 border-0 p-0"
disabled={busy}
>
<legend className="mb-2 text-sm font-semibold">
Your cards available for this effect
</legend>
{choices.map(({ card, blocked }) => (
<article
key={card.id}
className="flex min-w-0 flex-col gap-2 rounded-lg border border-[#485542] p-3"
>
<label
htmlFor={`${id}-${card.id}`}
className="flex min-h-11 cursor-pointer items-center gap-3 text-base"
>
<input
id={`${id}-${card.id}`}
type={entry.effect === 'ixians' ? 'radio' : 'checkbox'}
name={`${id}-discard`}
checked={!blocked && selected.includes(card.id)}
disabled={busy || !!blocked}
onChange={(event) =>
setSelected((current) =>
entry.effect === 'ixians'
? [card.id]
: event.target.checked
? [...current, card.id]
: current.filter((value) => value !== card.id),
)
}
/>
{card.name}
</label>
<CardRules card={card} />
<CardInspector card={card} />
{blocked && <p className="m-0 text-sm leading-6">{blocked}</p>}
</article>
))}
</fieldset>
<Button
className={buttonClass}
disabled={busy || !legalSelection}
onClick={() => {
if (legalSelection)
send({ cards: chosen.map((choice) => choice.card.id) });
}}
>
{entry.effect === 'choam'
? `Discard ${chosen.length} cards · gain ${chosen.length * 3} spice`
: 'Discard selected card and draw replacement'}
</Button>
</>
)}
<p className="m-0 text-xs leading-5">{AMBASSADOR_COVERAGE}</p>
</section>
);
}
/** Only recipient-projected snapshots are read. Local disclosures never send a game action. */
export function AmbassadorInsights({ game }: { game: GameView }) {
const snapshots = game.ambassadorInsights.filter(
(entry) => entry.viewer === game.me,
);
if (!snapshots.length) return null;
return (
<section
className="notice flex min-w-0 flex-col gap-3"
aria-label="Your private Ambassador inspection history"
>
<h3 className="m-0 font-serif text-xl">Private Ambassador inspections</h3>
<p className="m-0 text-sm leading-6">
These are snapshots of what you inspected at that moment, not a live
view of another hand. Only you receive them. Opening or closing an
inspection does not take a game action.
</p>
{snapshots.map((snapshot, index) => {
const target = game.players.find((p) => p.id === snapshot.target);
const leader = game.allLeaders.find((l) => l.id === snapshot.traitor);
const cheapHero = snapshot.traitor === CHEAP_HERO_TRAITOR;
return (
<details
key={snapshot.event}
open={index === snapshots.length - 1}
className="min-w-0 rounded-lg border border-[#485542] p-3"
>
<summary className="min-h-11 cursor-pointer py-2 text-base focus-visible:outline-2 focus-visible:outline-offset-2">
Turn {snapshot.turn} · {target?.name ?? 'Player'} ·{' '}
{snapshot.effect === 'atreides'
? 'Treachery hand'
: target?.faction === 'tleilaxu'
? 'Face Dancer identity'
: 'Traitor identity'}
</summary>
{snapshot.effect === 'atreides' ? (
<div className="grid min-w-0 gap-3 pt-3 sm:grid-cols-2">
{!snapshot.cards.length && (
<p className="text-sm leading-6">
The inspected hand was empty.
</p>
)}
{snapshot.cards.map((card) => (
<article
key={card.id}
className="flex min-w-0 flex-col gap-2 rounded-lg border border-[#485542] p-3"
>
<h4 className="m-0 font-serif text-lg">{card.name}</h4>
<CardRules card={card} />
<CardInspector card={card} />
</article>
))}
</div>
) : cheapHero || leader ? (
<div className="pt-3">
<LeaderInspector
kind={
target?.faction === 'tleilaxu' ? 'faceDancer' : 'traitor'
}
identity={
cheapHero
? {
name: 'Cheap Hero / Heroine',
factionName: 'Any faction',
cheapHero: true,
strength: 0,
}
: {
id: leader!.id,
name: leader!.name,
factionName: faction(leader!.faction).name,
strength: leaderStrengthLabel(leader!),
}
}
/>
</div>
) : (
<p className="text-sm leading-6">
Recorded identity: {snapshot.traitor ?? 'None'}
</p>
)}
</details>
);
})}
</section>
);
}