Files
Dune/components/ecaz-entry.tsx
T
Quintine 0e1a442dca Wire the Tleilaxu Ambassador force return
The last unimplemented Ambassador effect is now reachable. A placed Tleilaxu
token gives its beneficiary — the Ecaz owner or its ally — a private
ecazAmbassador decision at the new revival stage: one to four physical counters
return from the Tanks to reserves at zero cost, quoted by the existing
game/tleilaxu-ambassador-forces.ts, which also enforces the shared
Fedaykin/Sardaukar one-per-turn counter and keeps Ixian cyborgs uncapped.

The player view projects the maximum, Tanks and elite inventory and current
elite usage to the beneficiary only; the entry control offers a per-count
button and an explicit "Return no forces" decline; all four AI profiles choose
a legal return from the decision owner's own view or decline when the Tanks are
empty. Player-facing coverage text now lists Tleilaxu.

Kept gated exactly as recorded: the printed leader alternative, ordinary
force/free/leader allowance accounting around this independent grant,
dead-twice eligibility, and Tleilaxu free-income repeat scope. No ordinary
revival allowance, price, phase or income path changes.

Evidence: tests/tleilaxu-ambassador-engine.test.ts drives a genuine arrival into
a placed token and asserts the beneficiary-only projection, the typed return
with unchanged spice, the shared elite limit, malformed/over-maximum rejection
with immutability, the ordinary-only return, the decline path and the rendered
control. The four-profile ecaz-entry-bots loop now covers tleilaxu, and the
former unsupported-effect and copy-list pins were updated to the supported
behaviour rather than re-pinned. Offline suite 7,797/7,797 with typecheck and
lint clean. No game, seat, saved state or deployment changes.
2026-10-07 18:28:57 +11:00

477 lines
18 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', '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>
{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. Duke loans 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 === '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">
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">
Return up to {entry.revival?.maximum ?? 0} physical{' '}
{(entry.revival?.maximum ?? 0) === 1 ? 'force' : 'forces'} from the
Tanks to your reserves for free. Ordinary revival allowances,
prices and income are unchanged. The printed leader alternative
stays unavailable pending its recorded interpretation.
</p>
{entry.revival?.blocked && (
<p className="m-0 text-sm leading-6">{entry.revival.blocked}</p>
)}
{!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>
);
}