Files
Dune/components/ecaz-entry.tsx
T

509 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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', 'allianceReply', 'loan', 'copy', 'cards', 'move', 'ship', 'revival'].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 allianceBlock =
entry.allianceOffer?.blocked ??
(entry.allianceOffer ? null : 'This alliance opportunity is unavailable.');
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>
{game.advanced && entry.effect === 'ecaz' && (
<p className="fine">
Advanced source override: supplied rulebook, physical page 30, adds an
available Duke after accepted alliance consent automatically. The
publisher token guide’s optional loan wording above remains Basic guidance.
</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>
)}
<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">
Both factions must be unallied. The entrant chooses whether to
accept. The triggered Ambassador returns to supply even if the
offer is refused.{' '}
{game.advanced
? 'In Advanced, accepting also adds an available Duke to the entrant’s pool automatically. The first version uses one-battle tenure and sets an unused living Duke aside at turn end.'
: 'In Basic, an accepted alliance can be followed by an optional Duke loan when the disc is available.'}
</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 === '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.{' '}
{game.advanced
? 'An available Duke Vidal is added to your pool automatically, without a second acknowledgement. One-battle tenure is provisional; an unused living Duke is set aside at turn end.'
: 'In Basic, Ecaz can then optionally lend an available Duke Vidal.'}{' '}
Your remaining actions then resume.
</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 === 'loan' ? (
<>
<p className="m-0 text-sm leading-6">
Lend Duke Vidal to your new ally for this turn, or continue without
lending him. Basic prototype: one battle; an unused living Duke is
set aside at turn end. Return and capture interactions remain provisional.
</p>
<Button className={buttonClass} disabled={busy || !!entry.dukeLoan?.blocked}
onClick={() => send({ loan: true })}>Lend Duke Vidal to the new ally</Button>
{entry.dukeLoan?.blocked && <p>{entry.dukeLoan.blocked}</p>}
<Button variant="outline" className={buttonClass} disabled={busy}
onClick={() => send({ loan: false })}>Continue without lending Duke</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 === 'revival' ? (
<>
<p className="m-0 text-sm leading-6">
Revive one eligible first-death own leader, including Atreides’ first
killed Kwisatz Haderach, or return up to {entry.revival?.maximum ?? 0}
{' '}physical forces for free. This independent grant leaves ordinary
allowances and income unchanged provisionally. Repeat-return and
shared-leader interactions remain unfinished.
</p>
{entry.revival?.blocked && (
<p className="m-0 text-sm leading-6">{entry.revival.blocked}</p>
)}
{!entry.revival?.blocked && entry.revival?.leaders.map(leader => (
<Button key={leader.id} variant="outline" className={buttonClass}
disabled={busy} onClick={() => send({ leader: leader.id })}>
Revive {leader.name} for free
</Button>
))}
{!entry.revival?.blocked &&
(entry.revival?.maximum ?? 0) > 0 && (
<fieldset
className="flex min-w-0 flex-col gap-2 border-0 p-0"
disabled={busy}
>
<legend className="text-sm">Forces to return</legend>
{Array.from(
{ length: entry.revival!.maximum },
(_, index) => index + 1,
).map((count) => (
<Button
key={count}
variant="outline"
className={buttonClass}
disabled={busy}
onClick={() =>
send({
forces: count,
elite: Math.min(
entry.revival!.eliteTanks,
Math.max(0, 1 - entry.revival!.eliteRevived),
count,
),
})
}
>
Return {count} {count === 1 ? 'force' : 'forces'}
</Button>
))}
</fieldset>
)}
<Button
variant="outline"
className={buttonClass}
disabled={busy}
onClick={() => send({ decline: true })}
>
Return no forces
</Button>
</>
) : 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>
);
}