Add Kwisatz Haderach component inspection
Connect an original companion face and enlarged rules to the private Atreides panel and battle composer, authorized full-plan inspection, public revealed plans and an internal reference topic. Private loss progress and availability come only from the existing owner projection; plan components receive only inclusion. Rules calculations, save shape, AI strategy and mode gates remain unchanged. Add focused projection, sealed/revealed rendering, JSON restoration, private-status and asset regressions. All 4950 offline tests, types, lint and production build pass. Independent privacy/rules review found no material findings. Browser checks verify reference opening, loaded artwork, Escape/Close focus return, mobile bounds and keyboard scrolling to the final rule. The focusable scroll region has a documented narrow lint exception. Earlier image-convention and focusability lint failures are retained in private evidence. HTTP tests are unnecessary for this presentation-only change. All 1373 rooms, 2883 seats and original auxiliary rows remain unchanged; SQLite integrity and foreign keys pass. Existing server reused without restart. Screenshot capture, complete live Advanced browser acceptance and final manufactured-component/art verification remain open. No rules gate is opened by this checkpoint. Private evidence: 20260921-kwisatz-inspection. Verified source tree: 1181471eed47b86390882999a2ffce0db6a44b1379c7e8f7fc406bef39a94179
This commit is contained in:
1 parent
ed53eb7d76
commit
66c41b2af5
16 files changed
+341
-24
No files matched your search
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { CardInspector } from './card-inspector';
|
||||
import { KwisatzInspector } from './kwisatz-inspector';
|
||||
import {
|
||||
LeaderInspector,
|
||||
type LeaderDisplayIdentity,
|
||||
@@ -12,16 +13,19 @@ export function BattleComponentInspection({
|
||||
cards,
|
||||
leader,
|
||||
playerName,
|
||||
kwisatz,
|
||||
}: {
|
||||
cards: readonly VisibleCard[];
|
||||
leader?: LeaderDisplayIdentity;
|
||||
playerName: string;
|
||||
kwisatz?: boolean;
|
||||
}) {
|
||||
if (!cards.length && !leader) return null;
|
||||
if (!cards.length && !leader && !kwisatz) return null;
|
||||
return (
|
||||
<details className="battle-component-inspection">
|
||||
<summary>Inspect {playerName}’s plan components</summary>
|
||||
<div className="visible-component-list">
|
||||
{kwisatz && <KwisatzInspector context="plan" />}
|
||||
{leader && (
|
||||
<div>
|
||||
<strong>{leader.name}</strong>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
import { EcazSetup } from './ecaz-setup';
|
||||
import { StormCardInspector, StormCardLogInspector } from './storm-cards';
|
||||
import { KwisatzInspector } from './kwisatz-inspector';
|
||||
import { LobbyBotControls } from './lobby-bot-controls';
|
||||
import { IxRicheseTechnology } from './ix-richese-technology';
|
||||
import { nexusGuildCunningAction, nexusGuildCunningActive, nexusGuildMovementAvailable, nexusGuildShipmentAvailable, nexusGuildSkipShipmentAction } from '@/game/nexus-guild-cunning-options';
|
||||
@@ -1198,7 +1199,7 @@ export function GameTable({
|
||||
</p>
|
||||
)}
|
||||
{me.kwisatz && (
|
||||
<p className="fine">
|
||||
<div className="space-y-2"><p className="fine">
|
||||
Kwisatz Haderach:{' '}
|
||||
{me.kwisatz.dead
|
||||
? 'awaiting revival'
|
||||
@@ -1209,6 +1210,8 @@ export function GameTable({
|
||||
? ` · used in ${combatName(me.kwisatz.usedAt)} this turn`
|
||||
: ''}
|
||||
</p>
|
||||
<KwisatzInspector state={me.kwisatz} usedTerritoryName={me.kwisatz.usedAt ? combatName(me.kwisatz.usedAt) : undefined} />
|
||||
</div>
|
||||
)}
|
||||
{g.stormForecast !== null && (
|
||||
<div className="space-y-2">
|
||||
@@ -4688,7 +4691,7 @@ export function GameTable({
|
||||
</p>
|
||||
)}
|
||||
{me.kwisatz && (
|
||||
<label>
|
||||
<div className="space-y-2"><label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={kwisatz}
|
||||
@@ -4703,6 +4706,8 @@ export function GameTable({
|
||||
/>
|
||||
Use Kwisatz Haderach (+2; prevents traitors)
|
||||
</label>
|
||||
<KwisatzInspector state={me.kwisatz} usedTerritoryName={me.kwisatz.usedAt ? combatName(me.kwisatz.usedAt) : undefined} battle={{ territory: g.battle.territory, blocked: g.battle.kwisatzBlocked }} />
|
||||
</div>
|
||||
)}
|
||||
{g.battle.fullPlan?.target === me.id && (
|
||||
<p className="notice">
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
'use client';
|
||||
|
||||
import { Dialog as DialogPrimitive } from '@base-ui/react/dialog';
|
||||
import { Eye, X } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import { Button } from './ui/button';
|
||||
import { Dialog, DialogClose, DialogDescription, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger } from './ui/dialog';
|
||||
import { KWISATZ_RULES, kwisatzAvailability, type KwisatzBattleContext, type KwisatzDisplayState } from '@/game/kwisatz-display';
|
||||
|
||||
export function KwisatzCardFace() {
|
||||
return <figure aria-label="Kwisatz Haderach companion card" className="mx-auto my-0 w-full max-w-sm overflow-hidden rounded-xl border-2 border-[#96815a] bg-[#252b25] text-[#f5e8c9]">
|
||||
<figcaption className="p-4 text-center font-serif text-2xl">Kwisatz Haderach</figcaption>
|
||||
<Image unoptimized src="/art/leaders/kwisatz-haderach-v1.png" alt="Original portrait of Kwisatz Haderach, with blue eyes and a green desert mantle" width={1254} height={1254} className="aspect-square w-full object-cover" />
|
||||
<div className="space-y-2 p-4 text-base leading-7">
|
||||
<p className="m-0 font-semibold">Atreides · Advanced game · +2 strength</p>
|
||||
<p className="m-0">A battle companion for a leader or Cheap Hero. The bonus counts if the accompanying leader survives; that leader cannot turn traitor.</p>
|
||||
<p className="m-0">Available after seven Atreides battle-force losses. One territory per turn.</p>
|
||||
</div>
|
||||
</figure>;
|
||||
}
|
||||
|
||||
export function KwisatzPrivateStatus({ state, battle, usedTerritoryName }: {
|
||||
state: KwisatzDisplayState;
|
||||
battle?: KwisatzBattleContext;
|
||||
usedTerritoryName?: string;
|
||||
}) {
|
||||
return <section aria-label="Your private Kwisatz Haderach status" className="space-y-2 rounded-lg border border-[#647555] bg-[#243023] p-4 text-base leading-7">
|
||||
<h3 className="m-0 font-semibold">Your private status</h3>
|
||||
<p className="m-0">{state.losses} of 7 battle losses{state.active ? ' · awakened' : ''}</p>
|
||||
<progress aria-label="Atreides battle losses toward Kwisatz Haderach" max={7} value={Math.min(7, Math.max(0, state.losses))} className="h-3 w-full accent-[#c9ad70]" />
|
||||
<p className="m-0">{kwisatzAvailability(state, battle)}</p>
|
||||
{state.usedAt && <p className="m-0">Used this turn in {usedTerritoryName ?? state.usedAt}.</p>}
|
||||
</section>;
|
||||
}
|
||||
|
||||
/** State is supplied only from me.kwisatz; reference inspection has no live state. */
|
||||
export function KwisatzInspector({ state, battle, usedTerritoryName, context = 'reference' }: {
|
||||
state?: KwisatzDisplayState;
|
||||
battle?: KwisatzBattleContext;
|
||||
usedTerritoryName?: string;
|
||||
context?: 'reference' | 'plan';
|
||||
}) {
|
||||
const label = state ? 'private status' : context === 'plan' ? 'plan component' : 'rules reference';
|
||||
return <Dialog>
|
||||
<DialogTrigger render={<Button variant="outline" className="min-h-11 max-w-full whitespace-normal motion-reduce:transition-none" />} aria-label={`Inspect Kwisatz Haderach · ${label}`}>
|
||||
<Eye aria-hidden="true" />Inspect Kwisatz Haderach
|
||||
</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-2xl -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">
|
||||
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-[#414536] px-4 py-3">
|
||||
<span className="text-sm font-semibold text-[#ddbc77]">{state ? 'Private component inspection' : context === 'plan' ? 'Included in this plan' : 'Rules reference'}</span>
|
||||
<DialogClose render={<Button variant="outline" className="min-h-11 min-w-11 motion-reduce:transition-none" />}><X aria-hidden="true" />Close</DialogClose>
|
||||
</div>
|
||||
{/* oxlint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- Keyboard users need to focus this independently scrolling rules region. */}
|
||||
<section aria-label="Kwisatz Haderach details" tabIndex={0} className="min-h-0 space-y-5 overflow-y-auto overscroll-contain p-5 focus-visible:outline-2 focus-visible:outline-[#ddbc77] sm:p-6">
|
||||
<DialogTitle className="m-0 font-serif text-2xl text-[#fff3d6]">Kwisatz Haderach</DialogTitle>
|
||||
<DialogDescription className="m-0 text-base leading-7 text-[#d9d4c7]">{state ? 'Your battle-loss count and availability are private. Opening this view does not reveal them.' : context === 'plan' ? 'Kwisatz Haderach is included in this plan you are allowed to inspect. No private loss count or current availability is shown.' : 'The Atreides battle companion for the Advanced game. This reference does not show any game’s private loss count or battle plan.'}</DialogDescription>
|
||||
{state && <KwisatzPrivateStatus state={state} battle={battle} usedTerritoryName={usedTerritoryName} />}
|
||||
<KwisatzCardFace />
|
||||
<ol className="m-0 list-decimal space-y-3 pl-5 text-base leading-7">{KWISATZ_RULES.map(rule => <li key={rule}>{rule}</li>)}</ol>
|
||||
</section>
|
||||
</DialogPrimitive.Popup>
|
||||
</DialogPortal>
|
||||
</Dialog>;
|
||||
}
|
||||
|
||||
export function KwisatzReferenceCard() {
|
||||
return <section aria-label="Kwisatz Haderach component" className="space-y-4">
|
||||
<KwisatzCardFace />
|
||||
<KwisatzInspector />
|
||||
</section>;
|
||||
}
|
||||
@@ -71,6 +71,7 @@ export function PrivateBattlePlan({ game }: { game: GameView }) {
|
||||
game.players.find((p) => p.id === insight.target)?.name ?? 'Player'
|
||||
}
|
||||
cards={insight.cards}
|
||||
kwisatz={insight.plan.kwisatz}
|
||||
leader={
|
||||
leader
|
||||
? {
|
||||
|
||||
@@ -106,6 +106,7 @@ export function RevealedBattle({ game }: { game: GameView }) {
|
||||
<span>Added after reveal: {name(battle.lateDefense[id])}</span>
|
||||
)}
|
||||
<BattleComponentInspection
|
||||
kwisatz={plan.kwisatz}
|
||||
playerName={
|
||||
game.players.find((p) => p.id === id)?.name ?? 'Player'
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { StrongholdCardGallery } from './stronghold-cards';
|
||||
import { HomeworldCardGallery } from './homeworld-cards';
|
||||
import { NexusCardGallery } from './nexus-cards';
|
||||
import { StormCardGallery } from './storm-cards';
|
||||
import { KwisatzReferenceCard } from './kwisatz-inspector';
|
||||
import { createStrongholdCards } from '@/game/stronghold-cards';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { Suspense, useState, useEffect, useRef } from 'react';
|
||||
@@ -198,6 +199,7 @@ function RulesReferenceContent() {
|
||||
{topic.id === 'homeworlds' && <HomeworldCardGallery />}
|
||||
{topic.id === 'nexus-cards' && <NexusCardGallery />}
|
||||
{topic.id === 'storm-cards' && <StormCardGallery />}
|
||||
{topic.id === 'kwisatz-haderach' && <KwisatzReferenceCard />}
|
||||
{topic.id === 'richese-cards' && (
|
||||
<section
|
||||
aria-label="Richese card collection"
|
||||
|
||||
Reference in new issue
Block a user