The original own-leader grant omitted the shared Duke because it searched only native rosters and KH. Offer Duke only to Ecaz using canonical dead-disc identity/custody validation and the existing independent permission. Preserve ordinary first-death/KH limits and unsupported source envelopes. Native Tleilaxu and Bene Gesserit copied effects share the choice. Return the actual shared disc alive and set aside, preserve strength and death history, clear obsolete use/concealment and record any satisfied native cohort opening before clearing death. Spend no spice, Ghola or ordinary leader allowance; add no native disc or skill. Finish the same committed Ambassador and retain original entrant movement. Persistent guidance explains that a separate legal acquisition is still needed. Source-specific tests, genuine public Basic acquisition/death/arrival programmes, actual GameTable/entry rendering and four existing minimal profiles verify the consumer. Controlled Advanced/copy/repeated/cohort/ownership/event probes remain separate. Exact frozen checks and limits are in docs/TLEILAXU_AMBASSADOR_RULES.md. This composes the printed grant with the selected set-aside/independent Duke policy; it does not settle generic ordinary repeat/income policy, open a module, certify full modes or claim browser/deployed/final assurance. No strategic AI refinement or saved-room/database/reset/restart/deployment operation.
513 lines
20 KiB
TypeScript
513 lines
20 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', '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 leader or return up to {entry.revival?.maximum ?? 0}
|
||
{' '}physical forces for free. This independent grant leaves ordinary
|
||
allowances and income unchanged provisionally. Ordinary leaders and
|
||
Atreides’ Kwisatz Haderach retain their first-death restriction.
|
||
Other revival interactions remain under development.
|
||
</p>
|
||
{entry.revival?.leaders.some(leader => leader.destination === 'setAside') && <p className="m-0 text-sm leading-6">
|
||
Duke returns alive to the shared area, unclaimed. A separate legal
|
||
acquisition is needed before he can fight.
|
||
</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>
|
||
);
|
||
}
|