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
351 lines
16 KiB
TypeScript
351 lines
16 KiB
TypeScript
'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>
|
||
);
|
||
}
|