Files
Dune/components/leader-inspector.tsx
T
Quintine de26aaf029 Implement Advanced Ecaz Loyalty with public card inspection
Before genuine Advanced Ecaz Traitor dealing, randomly set aside one of the five
native Ecaz cards face up for the game. Store its owner and exact identity once;
exclude it from the initial deck, Tleilaxu Face Dancer setup and Nexus replacement
universes. Do not change leader custody or add choices, payment or Karama windows.
Absent markers preserve older already-dealt saves without taking held cards away.
Validate owner, timing and distinct physical custody before actions, normalization
and projection. Expose a detached public card identity, never private deck order.

Show the named card, portrait and enlarged Traitor inspection above the table.
A dedicated Loyalty context explains public set-aside custody and cannot-call
rules; browser review caught and fixed the generic held-Traitor guidance.
Add internal help, checklist, shared Advanced faction text and source documentation.
The official page8 requirement and page16 noKarama entry support the effect;
relative pre-Traitor prediction/skill sequencing is an explicit composition.
Gated Skills/Ecaz and Moritani assassination/Ecaz are not certified.

Twelve new cases plus related regressions cover candidate selection, immutable
invalid-state rejection,2..6 genuine deals, Basic/noEcaz, completed Tleilaxu setup,
Harkonnen/Tleilaxu Nexus draws, full physical census, detached public views, all4
legal AI setups, JSON continuation, legacy custody and public rendering.
Independent review found no material issue after genuine setup/legacy test fixes
and the focused inspector correction.50 related focused cases pass. A mistyped
filename filter failed closed before the corrected union ran successfully.
Types, lint, all 5012 offline tests, production build and 49 HTTP tests
pass on stable source. Earlier type/lint findings were corrected before this run.

Six genuine faction-game samples finish with no rejected actions and repeated
JSON restoration: CHOAM Basic1054/Advanced225 actions, Ecaz Basic91/Advanced245,
combined Basic476/Advanced704. Existing physical Treachery/force custody checks
pass; focused Loyalty tests cover expansion Traitor census. These bounded legal
samples are not strength calibration or complete expansion acceptance.

Browser QA created a fresh Ecaz/Easy Harkonnen ready lobby and initialized its
Advanced setup before dealing using backup and CAS. Bindikk Narvi was public
before choices and remained excluded through actual human Traitor/force setup,
Storm and refresh. The saved human Traitor is Beast Rabban; a pointer action just
after viewport reset differed from its intended Feyd target, so exact-target
pointer acceptance across viewport changes is not claimed. Ordinary source
bindings remain name/ID-specific. One hand card,12spice,14reserves and the pending
Storm dial restore; both public projections agree and saved seat records match.
The Loyalty inspector shows correct card-specific guidance; Escape restores focus.
At390px its panel has no horizontal overflow,16px text and44/48px controls; the
expanded scroll body fits342px. Internal help opens its complete checklist topic.
Normal viewport restored. Screenshot capture timed out; full visual, long-inspector
keyboard and broader module acceptance remain open.

All 1539 pre-existing rooms, 3259 human seat records and
auxiliary rows remain unchanged. SQLite integrity and foreign keys pass.
Only isolated QA/HTTP fixtures add rooms. No server restart, database reset,
migration, recurring automation, AI strategy change or mode/publication gate change.

Private evidence: 20260921-gameplay-review.
Verified source tree: 881bf1912665350086d80be664d479a1b2724146da6ccc9576790c07e0449ee6
2026-09-21 12:43:37 +10:00

351 lines
16 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 } from 'react';
import { Dialog as DialogPrimitive } from '@base-ui/react/dialog';
import { Eye, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogClose,
DialogDescription,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { HELP } from '@/game/reference';
import { LeaderPortrait } from './leader-portrait';
/** Supply only identity information this viewer is entitled to inspect. */
export type LeaderDisplayIdentity = Readonly<
{ id?: string; name: string; factionName: string } & (
| { cheapHero: true; strength: 0 }
| { cheapHero?: false; strength: number | string }
)
>;
export type LeaderInspectorProps = {
identity: LeaderDisplayIdentity;
kind?: 'leader' | 'traitor' | 'faceDancer' | 'loyalty';
assassination?: 'choice' | 'revealed' | 'retired';
};
/** An original geometric medallion, not a portrait or a printed component reproduction. */
function IdentityMedallion({ traitor }: { traitor: boolean }) {
const gradient = `${useId()}-seal`;
return (
<svg
viewBox="0 0 200 200"
className="mx-auto my-5 w-40 max-w-full sm:w-48"
aria-hidden="true"
focusable="false"
>
<defs>
<radialGradient id={gradient} cx="38%" cy="30%" r="75%">
<stop offset="0" stopColor={traitor ? '#563e38' : '#414c3b'} />
<stop offset="1" stopColor="#151a15" />
</radialGradient>
</defs>
<circle
cx="100"
cy="100"
r="92"
fill={`url(#${gradient})`}
stroke="#c3a365"
strokeWidth="2"
/>
<circle
cx="100"
cy="100"
r="84"
fill="none"
stroke="#bfa46e"
strokeOpacity="0.55"
/>
<circle
cx="100"
cy="100"
r="66"
fill="none"
stroke="#bfa46e"
strokeOpacity="0.25"
/>
{Array.from({ length: 16 }, (_, index) => (
<path
key={index}
d="M 100 12 V 20"
stroke="#ddc48c"
strokeWidth={index % 4 === 0 ? 3 : 1}
transform={`rotate(${index * 22.5} 100 100)`}
/>
))}
<path
d="M 100 40 L 139 100 L 100 160 L 61 100 Z"
fill="none"
stroke="#e3c787"
strokeWidth="2"
/>
<path
d={
traitor
? 'M 100 56 L 108 103 L 100 119 L 92 103 Z M 80 120 H 120 M 100 120 V 144'
: 'M 77 76 L 88 93 L 100 68 L 112 93 L 123 76 L 116 112 H 84 Z M 85 125 H 115'
}
fill="none"
stroke="#f1dba8"
strokeWidth="3"
strokeLinejoin="round"
/>
<circle cx="36" cy="100" r="3" fill="#c3a365" />
<circle cx="164" cy="100" r="3" fill="#c3a365" />
</svg>
);
}
/** Read-only: no game, catalog lookup, deck, session or action callback is accepted. */
export function LeaderInspector({
identity,
kind = 'leader',
assassination,
}: LeaderInspectorProps) {
const loyalty = kind === 'loyalty';
const traitor = kind === 'traitor' || loyalty;
const faceDancer = kind === 'faceDancer';
const label = faceDancer ? 'Face Dancer' : loyalty ? 'traitor' : kind;
const cheapHero = identity.cheapHero === true;
const description = loyalty
? 'This Ecaz Traitor Card was randomly set aside face up before initial dealing. Everyone may inspect it; nobody holds it as a Traitor or Face Dancer.'
: traitor && assassination === 'retired'
? 'This face-up card marks the faction against which Moritani has already used Assassinate Leaders. It is set aside, separate from held Traitor Cards.'
: faceDancer
? cheapHero
? 'This Face Dancer identity matches either Cheap Hero or Cheap Heroine used as the winning battle leader, regardless of the hero card’s name or the player’s faction.'
: 'This Face Dancer identity matches the named winning leader. Tleilaxu can reveal an unrevealed match after that battle and its rewards are settled.'
: traitor
? cheapHero
? 'This traitor identity matches either Cheap Hero or Cheap Heroine used as a battle leader, regardless of the hero card’s name or the player’s faction.'
: 'This traitor card identifies the named leader. Holding it does not add that leader to your available battle leaders.'
: cheapHero
? 'A Cheap Hero or Heroine can stand in for a leader in a battle plan. Its leader strength is zero.'
: 'This identity shows the leader’s faction and strength. Battle powers can change the strength used in a battle.';
return (
<Dialog>
<DialogTrigger
render={
<Button
variant="outline"
className="min-h-11 min-w-11 motion-reduce:transition-none"
/>
}
aria-label={`Inspect ${label}: ${identity.name}`}
>
<Eye aria-hidden="true" />
Inspect {label}
</DialogTrigger>
<DialogPortal>
<DialogOverlay className="bg-black/75 motion-reduce:animate-none motion-reduce:transition-none" />
<DialogPrimitive.Popup className="fixed top-1/2 left-1/2 z-50 flex max-h-[calc(100dvh-1rem)] w-[calc(100%-1rem)] max-w-3xl -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-2xl border border-[#8e7952] bg-[#141814] text-[#eeeae0] shadow-2xl outline-none duration-150 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 motion-reduce:animate-none motion-reduce:transition-none sm:max-h-[calc(100dvh-3rem)] sm:w-[calc(100%-3rem)]">
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-[#414536] px-4 py-3">
<span className="text-xs font-semibold tracking-[0.16em] text-[#ddbc77] uppercase">
{label} inspection
</span>
<DialogClose
render={
<Button
variant="outline"
className="min-h-11 min-w-11 motion-reduce:transition-none"
/>
}
>
<X aria-hidden="true" />
Close
</DialogClose>
</div>
<div className="min-h-0 overflow-y-auto overscroll-contain p-4 sm:p-6">
<div className="grid items-start gap-6 md:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]">
<article
className={`min-w-0 rounded-xl border border-[#a58b5c] bg-gradient-to-br ${faceDancer ? 'from-[#43374b] via-[#2e2835]' : traitor ? 'from-[#482e2b] via-[#2e2721]' : 'from-[#344432] via-[#292f25]'} to-[#171b17] p-5 shadow-lg sm:p-6`}
>
<p className="m-0 break-words text-sm font-semibold tracking-wider text-[#f1d79f] uppercase">
{identity.factionName}
</p>
<LeaderPortrait
identityId={cheapHero ? undefined : identity.id}
name={identity.name}
className="mx-auto my-5 size-40 max-w-full sm:size-48"
sizes="(min-width: 640px) 192px, 160px"
fallback={<IdentityMedallion traitor={traitor} />}
/>
<DialogTitle className="break-words font-serif text-3xl leading-tight text-[#fff3d6] sm:text-4xl">
{identity.name}
</DialogTitle>
<dl className="mt-5 border-y border-[#b59a64]/40 py-4">
<dt className="text-sm font-medium text-[#d6c9ad]">
Leader strength
</dt>
<dd className="m-0 mt-1 break-words font-serif text-5xl leading-tight text-[#f6e4b9]">
{identity.strength}
</dd>
</dl>
<p className="mt-4 mb-0 text-xs tracking-wider text-[#cebea0] uppercase">
{faceDancer
? 'Face Dancer identity'
: traitor
? 'Traitor identity'
: 'Leader identity'}{' '}
· gameplay guide
</p>
</article>
<section
className="flex min-w-0 flex-col gap-5"
aria-label={`${faceDancer ? 'Face Dancer' : traitor ? 'Traitor' : 'Leader'} guidance`}
>
<DialogDescription className="m-0 text-base leading-7 text-[#eee5d2]">
{description}
</DialogDescription>
{identity.id === 'choam-auditor' && (
<div className="space-y-4 text-base leading-7 text-[#e0e4d8]">
<p>
Advanced CHOAM has this additional strength-2 leader and
its matching traitor identity. It remains separate from
the five ordinary leaders.
</p>
<p>
After using the Auditor in battle, CHOAM may inspect two
random opposing hand cards if it survived, or one if it
died. Exclude cards used in that battle. The opponent may
pay CHOAM one spice per card actually available to cancel
the entire inspection; partial cancellation is
unavailable. Karama can prevent the audit.
</p>
<p>
The Auditor may use CHOAM’s one normal leader revival
without waiting for all other leaders to enter the Tanks.
It cannot be captured by Harkonnen, acquired as a Tleilaxu
foreign ghola, or receive a leader skill. CHOAM may revive
it with its own Ghola card.
</p>
</div>
)}
{faceDancer ? (
<>
<p className="m-0 text-base leading-7 text-[#e0e4d8]">
Inspection does not reveal a Face Dancer or replace
forces. Use the table’s Face Dancer decision after another
faction’s winning battle, losses, card choices and rewards
have been resolved.
</p>
<p className="m-0 text-base leading-7 text-[#e0e4d8]">
Return the winner’s surviving forces to reserves. Tleilaxu
may replace up to that many with their own forces from
reserves or the board. Send the winning leader to the
tanks without another leader bounty; an already dead
leader does not die again.
</p>
<p className="m-0 text-base leading-7 text-[#e0e4d8]">
Kwisatz Haderach does not protect the accompanying leader
from Face Dancing. Karama cannot cancel this reveal or the
replacement forces.
</p>
<p className="m-0 text-base leading-7 text-[#e0e4d8]">
Keep a revealed Face Dancer face up until all three have
been used, then shuffle them back into the unused deck and
draw three new identities. During Mentat Pause, one
unrevealed Face Dancer may instead be exchanged once per
turn; that exchange has a Karama response.
</p>
{cheapHero && (
<p className="m-0 text-base leading-7 text-[#e0e4d8]">
Either hero can be the winning identity for Face
Dancing. Its leader strength is zero; the hero treachery
card and this Face Dancer identity remain separate
components.
</p>
)}
</>
) : loyalty ? (
<>
<p className="m-0 text-base leading-7 text-[#e0e4d8]">
The card remains outside the deck for the rest of this game.
It cannot be drawn, held or called as a traitor. Inspection
does not move the card or change its public identity.
</p>
<p className="m-0 text-base leading-7 text-[#e0e4d8]">
Loyalty is automatic and Karama cannot prevent it. The
corresponding leader disc keeps its normal ownership,
battle, death and revival rules.
</p>
</>
) : traitor && assassination ? (
<>
<p className="m-0 text-base leading-7 text-[#e0e4d8]">
{assassination === 'choice' ? 'Inspection does not reveal this card. Use the assassination controls to reveal it publicly or decline.' : 'This identity has already been revealed publicly through Assassinate Leaders.'}
</p>
<p className="m-0 text-base leading-7 text-[#e0e4d8]">
After a qualifying lost battle, Moritani may reveal a different opposing-faction leader’s Traitor Card. A living target goes to the Tanks and pays its printed strength; a target already in the Tanks pays no spice.
</p>
<p className="m-0 text-base leading-7 text-[#e0e4d8]">
{assassination === 'retired' ? 'This physical card no longer belongs to a hand and cannot be called as a held traitor. Moritani has drawn its private replacement.' : 'The revealed card is set aside face up during Mentat Pause, when Moritani draws one private replacement.'}
{' '}The marker records the faction’s once-per-game assassination use. Karama cannot cancel this advantage.
</p>
</>
) : traitor ? (
<>
<p className="m-0 text-base leading-7 text-[#e0e4d8]">
Inspection is not a traitor call and does not reveal this
identity to other players. After the battle plans are
revealed, use the table’s traitor decision to make a legal
call.
</p>
<p className="m-0 text-base leading-7 text-[#e0e4d8]">
Keep the identity after revealing it; it can match again
in later battles. A matching identity alone does not
bypass battle protections such as Kwisatz Haderach.
</p>
{!cheapHero && (
<p className="m-0 text-base leading-7 text-[#e0e4d8]">
Keeping your own leader as a traitor can prevent another
player from holding that traitor.
</p>
)}
{cheapHero && (
<p className="m-0 text-base leading-7 text-[#e0e4d8]">
This is the expansion traitor identity, separate from
the hero treachery cards. It can match any Cheap Hero or
Heroine used as a battle leader. The hero has zero
leader strength and grants no leader bounty.
</p>
)}
</>
) : (
<>
<p className="m-0 text-base leading-7 text-[#e0e4d8]">
{HELP.leader}
</p>
<p className="m-0 text-base leading-7 text-[#e0e4d8]">
Inspection does not choose or commit a leader. Close this
view to review the available leaders in your battle plan.
</p>
{cheapHero && (
<p className="m-0 text-base leading-7 text-[#e0e4d8]">
The hero treachery card is discarded after use. The
separate Cheap Hero traitor identity can match either
hero.
</p>
)}
</>
)}
{identity.strength === 'X' && (
<p className="m-0 rounded-lg border border-[#454b3c] bg-[#1d221b] p-4 text-base leading-7 text-[#e0e4d8]">
X marks variable strength. Use the faction’s battle rule to
determine its value against the opposing leader.
</p>
)}
</section>
</div>
</div>
</DialogPrimitive.Popup>
</DialogPortal>
</Dialog>
);
}