Initial commit
This commit is contained in:
commit
8c709fb52f
771 files changed
+222983
No files matched your search
@@ -0,0 +1,367 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user