Connect a qualifying native Advanced Moritani battle loss to private reveal/continue controls, live or already-dead opposing-faction targets, printed-strength bank bounty, and automatic Mentat replacement. Keep the revealed physical card held until Mentat, then set it aside as a public per-faction use marker and draw one private card. All four AI profiles use the owner-only legal quote. New local preview profile supports genuine setup without opening public starts. Keep unresolved normal traitor-call duration and uniform private no-choice UX explicit. Record normal calls and guard later use without claiming a settled permanent loss. Exclude exceptional captured/foreign leader custody, other expansion factions and combined optional modules from this bounded preview. No mode or publication gate opens. Independent source/privacy/persistence review and 32 focused cases pass. Battle obligations independently bind cleanup, sealed normal-call logs wait for public revelation, physical replacement custody rejects missing cards and forged same-turn chains, and invalid actions remain immutable. Five authenticated SQLite cases cover pending/resolved restoration, Truthtrance interruption, competing submissions, stale/duplicate actions and corrupt saves. The local writer preserves seats and unrelated rooms. Validation: types, lint, 4690 offline tests, production build and 43 HTTP tests passed with unchanged source. Two genuine Advanced four-profile games finish in 296 and 101 actions with zero rejected candidates, ten periodic JSON rounds and eight restored final seat views. All physical cards, forces and elites remain conserved. Two observed assassination opportunities are declined; these complete games do not establish natural reveal/replacement use. The separate browser exercise explicitly stages conserved battle forces and one physical traitor swap after genuine setup. Actual battle actions open the choice. Master Bewt dies once and pays three spice; the card remains held through cleanup and is automatically replaced at Mentat with a public set-aside marker. Pending and resolved refresh, readable context-correct inspectors, physical custody and private projection pass. Guild independently collects two spice from Arrakeen. The saved QA game remains at human Terror placement. After the reported outage, all 801 opening games and original seat/recovery records are preserved (835 after HTTP checks). All 802 rooms and original records immediately before verification also remain unchanged. Database integrity and foreign keys pass. The healthy server was reused without restart, reset or recurring automation. Verified source tree: ad64f6775fb4c481002cc5eccf6d31e490085d11e10b7b19fb7df4947bcf0ccd
335 lines
16 KiB
TypeScript
335 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';
|
||
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 traitor = kind === 'traitor';
|
||
const faceDancer = kind === 'faceDancer';
|
||
const label = faceDancer ? 'Face Dancer' : kind;
|
||
const cheapHero = identity.cheapHero === true;
|
||
const description = 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>
|
||
)}
|
||
</>
|
||
) : 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>
|
||
);
|
||
}
|