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:
Quintine committed 2026-09-21 09:00:51 +10:00
1 parent ed53eb7d76
commit 66c41b2af5
16 files changed
+341 -24

No files matched your search

+5 -1
View File
@@ -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>
+7 -2
View File
@@ -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">
+73
View File
@@ -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>;
}
+1
View File
@@ -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
? {
+1
View File
@@ -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'
}
+2
View File
@@ -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"