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"
+8
View File
@@ -1,5 +1,13 @@
# Component inventory and verification ledger
**Kwisatz Haderach inspection, 21 September 2026:** original companion artwork,
readable enlargement, private loss tracking and availability guidance now connect
to Atreides controls, authorized plan inspection and the internal reference.
Public plan inclusion never exposes the loss count, current death state or
territory usage. This closes the historical missing inspection/portrait gap;
exact manufactured component layout and combined-rule certification remain open.
See [scope and evidence](KWISATZ_INSPECTION.md).
**Storm Card inspection, 21 September 2026:** six numeric reference faces,
authorized private Fremen forecasts and structured public reveal receipts now
share an enlarged inspector. The actual card value survives separately from
+20 -20
View File
@@ -6,27 +6,27 @@ gated.** Prototypes do not open completion or publication gates.
## Current checkpoint and work
Latest delivered checkpoint: `67e429e`, [Charity and Revival practice](INTERACTIVE_INTRODUCTION.md).
Types, lint, **4,941 offline tests** and production build passed. All 1,337 rooms,
2,805 seats and existing recovery/entry/handover/delegation rows stayed unchanged.
The running server was reused; prior lesson results and paid returns restore.
The preceding `c4ff75a` connects alliance practice, `b7ec940` connects bidding,
and `b9718ac` connects
[lobby AI configuration](LOBBY_AI_CONFIGURATION.md), and `b21b2bc` connects
[one-use AI permission](SEAT_AI_PERMISSION.md). The `8f7a62c` policy checkpoint
removed the usage cutoff and deferred AI refinement until feature completion,
with three adjacent 75% targets. Earlier evidence remains in the
[implementation history](IMPLEMENTATION_STATUS.md).
Latest delivered checkpoint: `ed53eb7`, [Storm Card inspection](STORM_CARD_INSPECTION.md).
Types, lint, **4,946 offline tests**, production build and **46 HTTP tests** passed.
All 1,337 original rooms, 2,805 original seats and existing auxiliary rows stayed
unchanged; HTTP checks created their own additional records. The current baseline
contains 1,373 rooms and 2,883 seats. The running server was reused.
Earlier delivered checkpoints include `67e429e` for Charity/Revival practice,
`c4ff75a` for alliances, `b7ec940` for bidding, `b9718ac` for lobby AI configuration
and `b21b2bc` for one-use AI permission. The `8f7a62c` policy checkpoint removed
the usage cutoff and deferred AI refinement until feature completion, with three
adjacent 75% targets. See the [implementation history](IMPLEMENTATION_STATUS.md).
Current work: [Storm Card inspection](STORM_CARD_INSPECTION.md) connects six
reference faces, the permitted Fremen forecast and public chronicle receipts to
enlarged inspection. Original card values stay separate from modified movement;
legacy text logs remain valid. No rules or AI strategy changes. Independent review,
browser/check/build/HTTP/preservation and Git evidence belong to its private report.
A bounded current-state Duke review confirmed revival/loan paths still cross
recorded custody, return and cycle questions; existing identity/price helpers and
non-Ecaz guards are already present. No duplicate ruling or invented outcome.
Storm/spice-blow, full faction/Advanced/expansion teaching and broader
Current work: [Kwisatz Haderach inspection](KWISATZ_INSPECTION.md) connects an
original companion face, private battle-loss progress and availability guidance,
authorized private/public plan components and a static internal reference.
Rules calculations, sealed choices, save shape and mode gates remain unchanged.
Source-bound checks, independent review, browser and preservation/Git evidence
belong to the private checkpoint. Full Advanced acceptance, exact manufactured
component verification and the remaining component inventory are unfinished.
A bounded Duke review confirmed revival/loan paths still cross recorded custody,
return and cycle questions; no duplicate ruling or invented outcome was added.
Storm/spice-blow teaching, full faction/Advanced/expansion teaching and broader
multiplayer acceptance remain unfinished.
A bounded remaining-effect audit found that the reviewed faction/Nexus candidates
+11
View File
@@ -1,5 +1,16 @@
# Dune implementation status
## 21 September 2026 — Kwisatz Haderach component inspection
The [companion inspector](KWISATZ_INSPECTION.md) connects original artwork, private
battle-loss tracking and availability guidance, permitted full-plan and public
revealed-plan components, and static reference inspection. Existing server
projection remains the privacy boundary; no rules calculation, save migration
or AI tuning changes. Focused rendering/projection/JSON tests supplement the
existing battle/death/revival checks. Independent review and source-bound
check/build/browser/preservation/Git evidence belong to the private checkpoint.
Manufactured-layout verification and full Advanced acceptance remain open.
## 21 September 2026 — Storm Card inspection
The [Storm Card inspector](STORM_CARD_INSPECTION.md) connects six numeric reference
+54
View File
@@ -0,0 +1,54 @@
# Kwisatz Haderach component inspection
21 September 2026. Prototyped component presentation; Advanced and expansion
release gates remain closed. Original artwork and crisp interface text present
the Atreides companion separately from ordinary leader and Traitor identities.
The inspector explains its conditional strength, protection, losses, territorial
limit, death and separate revival. It does not submit or alter a battle plan.
## Connected surfaces and privacy
- The owning Advanced Atreides panel and battle composer use only `me.kwisatz`:
exact cumulative battle losses, activation, death and this-turn usage. A capped
seven-loss progress bar accompanies the exact count. The inspector explains
an unavailable, dead, canceled or already-used-in-another-territory state.
- Public revealed plans and permitted Atreides full-plan inspection pass only
the existing inclusion boolean. They never pass an owner's loss count or live
availability. The existing reveal and inspection-owner guards remain intact.
- The reference shows a static component with no live game state. Its topic is
linked from Advanced combat and the implementation checklist.
The unchanged `viewGame` projection authorizes owner data, including after JSON
restoration. Allies do not receive it. `KwisatzInspector` accepts no game, seat,
action or authoritative deck. The display helper explains already-projected
state; normal engine validation remains authoritative. Normal revival price,
cycle and eligibility are still provided by the existing revival controls.
No save schema, migration, network behavior or AI strategy changes.
## Authority and remaining work
Existing source records are the [classic GF9 rulebook, p.17](https://www.gf9games.com/dunegame/wp-content/uploads/Dune-Rulebook.pdf#page=17),
the [November 2020 FAQ, p.9](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf#page=9)
and the [Ghola lifecycle ruling](GHOLA_LIFECYCLE_AND_AI.md).
The base PDF refresh returned HTTP 403 on 21 September; this checkpoint uses
the previously recorded source contract and existing tested engine behavior,
not a claim of a newly retrieved printed face. Rules prose is an original
paraphrase. The companion face and progress bar are an interface adaptation;
exact manufactured layout and every printed detail still require final review.
The portrait was generated once with the built-in image tool, inspected and
bundled locally. Provenance and dimensions are recorded in
`public/art/leaders/kwisatz-generation.json`. The character has blue irises, not
fully blue sclera; final art refinement remains open. It is not an ordinary
leader, Traitor or Face Dancer identity and is not added to those catalogs.
Recorded Suk Graduate, Reinforcements and Smuggler combinations remain governed
by [pending rule decisions](RULE_DECISIONS.md). The inspector does not resolve
them, enable modules or certify the Advanced game.
Focused tests exercise real sealed/revealed engine projections, opponent/ally
privacy, basic-mode absence, JSON restoration, display states and the bundled
asset. The full-plan rendering test uses an explicitly constructed authorized
view and separately verifies its existing owner/reveal guard; it does not prove
every source timing window. Required broad checks, independent review, browser
controls and saved-game preservation are recorded in the private checkpoint.
+37
View File
@@ -0,0 +1,37 @@
/** Already-authorized private projection, never an authoritative game or player. */
export type KwisatzDisplayState = Readonly<{
active: boolean;
dead: boolean;
losses: number;
usedAt?: string;
}>;
export type KwisatzBattleContext = Readonly<{
territory: string;
blocked: boolean;
}>;
/** Explanatory text only. The server continues to validate every battle plan. */
export function kwisatzAvailability(
state: KwisatzDisplayState,
battle?: KwisatzBattleContext,
): string {
if (state.dead) return 'In the Tleilaxu Tanks. Revive before using in battle.';
if (!state.active) return 'Not yet available. Atreides must lose seven forces in battle.';
if (battle?.blocked) return 'Karama prevents the bonus and traitor protection in this battle.';
if (state.usedAt && battle && state.usedAt !== battle.territory)
return 'Already used in another territory this turn.';
if (state.usedAt)
return 'May be used again in the same territory this turn.';
return 'Awakened. May accompany a leader or Cheap Hero in battle.';
}
export const KWISATZ_RULES = [
'In the Advanced game, count Atreides forces lost in battles. After seven losses, Kwisatz Haderach becomes available for later battles. Storm and worm losses do not count.',
'Include the counter with a leader or Cheap Hero in a sealed battle plan. It adds two to the leader’s strength only if that leader or hero survives. The accompanying leader or hero cannot turn traitor.',
'Use it in only one territory per turn; it may join more than one battle in that territory. It cannot fight without an accompanying leader or hero.',
'An ordinary companion death removes the bonus but does not kill Kwisatz Haderach or add two to the leader’s death bounty. A played Kwisatz Haderach dies only when a lasgun–shield explosion actually resolves; a successful traitor result takes precedence.',
'Revive it separately from its accompanying leader. Normal revival costs two spice before discounts, requires the permitted Atreides leader revival cycle and uses the normal leader allowance. Ghola can revive it without that allowance and permits use in another territory that turn.',
'Stone Burner and Artillery can suppress its strength contribution in their battle comparisons. Traitor protection does not prevent a Face Dancer replacement.',
'A Karama cancellation removes both its extra strength and its traitor protection for that battle. Inspecting this component does not commit a battle plan.',
] as const;
+20 -1
View File
@@ -5,6 +5,7 @@ import { RICHESE_CARD_DEFINITIONS } from './richese-cards';
import { HOMEWORLD_CARDS } from './homeworld-cards';
import { NEXUS_CARD_REFERENCE } from './nexus-card-reference';
import { LEADER_SKILL_CARDS } from './leader-skill-cards';
import { KWISATZ_RULES } from './kwisatz-display';
export const RULE_CHECKLIST_AREAS = [
'Implementation',
@@ -203,6 +204,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'setup',
'interactive-introduction',
'storm-cards',
'kwisatz-haderach',
'truthtrance-spice',
'ecaz-ambassadors',
'richese-cards',
@@ -2753,7 +2755,24 @@ export const RULE_TOPICS: RuleTopic[] = [
],
example:
'An Emperor army with one Sardaukar and five ordinary forces dials 3 and spends 1 spice. It may lose the supported Sardaukar and two unsupported ordinary forces, or one supported ordinary force and four unsupported ordinary forces.',
related: ['battle', 'revival', 'automatic-casualties'],
related: ['battle', 'revival', 'automatic-casualties', 'kwisatz-haderach'],
},
{
id: 'kwisatz-haderach',
title: 'Kwisatz Haderach: companion and loss track',
category: 'Advanced & expansions',
coverage: 'Partial',
developmentStage: 'Prototyped',
summary: 'Inspect the Atreides battle companion, its rules and your private battle-loss progress.',
steps: [...KWISATZ_RULES],
related: ['faction-atreides', 'advanced-combat', 'revival', 'card-ghola', 'card-karama'],
checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'Existing activation, battle inclusion and revival remain authoritative. The original component face and private loss track connect to inspection; combined-module and final physical-component verification remain open.', evidence: ['game/kwisatz-display.ts', 'game/engine.ts'] },
{ area: 'Player controls', status: 'Implemented', detail: 'Private Atreides status and battle composition, authorized full-plan inspection, public revealed plans and the reference share an enlarged component inspector.', evidence: ['components/kwisatz-inspector.tsx', 'components/game-table.tsx', 'components/battle-component-inspection.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'Existing legal battle and revival participation is unchanged. Strength tuning remains deferred until all non-AI features are complete.', evidence: ['game/bots.ts', 'tests/kwisatz.test.ts'] },
{ area: 'Documentation', status: 'Implemented', detail: 'Internal guidance explains activation, territory limits, conditional strength, protection, death and separate revival. Private progress is never inferred from a public plan.', evidence: ['game/kwisatz-display.ts', 'docs/KWISATZ_INSPECTION.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Focused checks cover private versus public views, sealed/revealed plan inspection, JSON restoration, availability labels and the separate companion asset. Full Advanced and combination acceptance remains open.', evidence: ['tests/kwisatz.test.ts', 'tests/kwisatz-inspection.test.tsx'] },
],
},
{
id: 'advanced-storm-spice',
@@ -0,0 +1,12 @@
{
"tool": "Built-in image_gen",
"date": "2026-09-21",
"name": "Kwisatz Haderach",
"path": "/art/leaders/kwisatz-haderach-v1.png",
"width": 1254,
"height": 1254,
"sha256": "056d921c9a0e4a0957d1a455a2d39508e31a42931d5b95196fbecb6f4d70a7fb",
"sourcePath": "/home/quintine/.codex/generated_images/01a0c0ff-4fba-7f52-8e94-3146a512bdb5/exec-f7127b6d-2871-411a-9bdc-995c25559803.png",
"prompt": "Original painted Kwisatz Haderach portrait, young adult prophetic desert man, dark wavy hair, luminous blue-within-blue eyes, stillsuit under dark moss-green mantle. Subtle desert haze and branching pale golden prescient light. Square centered head and shoulders, sage/olive/sand/bronze palette, natural brush texture. Original appearance, no actor likeness or copied film/boardgame artwork; no text, numerals, borders, logos or UI.",
"review": "Selected single generated portrait. Visible blue irises, olive mantle and desert/prescience backdrop; sclera are not fully blue. Original adaptation; no claim of matching manufactured card art."
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

+39
View File
@@ -0,0 +1,39 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { readFileSync } from 'node:fs';
import { registerHooks } from 'node:module';
import { kwisatzAvailability, type KwisatzDisplayState } from '../game/kwisatz-display';
const aliases = registerHooks({ resolve(specifier, context, next) {
return next(specifier === 'next/image' ? 'vinext/shims/image' : specifier, context);
} });
const { KwisatzPrivateStatus, KwisatzCardFace, KwisatzReferenceCard } = await import('../components/kwisatz-inspector');
aliases.deregister();
void test('KH private progress distinguishes awakening, death, cancellation and territory reuse', () => {
const base: KwisatzDisplayState = { active: true, dead: false, losses: 9 };
const render = (state: KwisatzDisplayState) => renderToStaticMarkup(createElement(KwisatzPrivateStatus, { state, usedTerritoryName: 'Arrakeen' }));
assert.match(render({ ...base, active: false, losses: 6 }), /6 of 7 battle losses/);
assert.match(render({ ...base, active: false, losses: 6 }), /Not yet available/);
assert.match(render(base), /9 of 7 battle losses · awakened/);
assert.match(render(base), /max="7" value="7"/);
assert.match(render({ ...base, dead: true }), /In the Tleilaxu Tanks/);
const used = { ...base, usedAt: 'arrakeen' };
assert.match(render(used), /Used this turn in Arrakeen/);
assert.match(kwisatzAvailability(used, { territory: 'arrakeen', blocked: false }), /same territory/);
assert.match(kwisatzAvailability(used, { territory: 'carthag', blocked: false }), /another territory/);
assert.match(kwisatzAvailability(used, { territory: 'arrakeen', blocked: true }), /Karama prevents/);
});
void test('KH reference has no private status and its original companion artwork is bundled separately', () => {
const html = renderToStaticMarkup(createElement(KwisatzReferenceCard));
assert.match(html, /Inspect Kwisatz Haderach · rules reference/);
assert.doesNotMatch(html, /Your private status|<progress|of 7 battle losses/);
const face = renderToStaticMarkup(createElement(KwisatzCardFace));
assert.match(face, /kwisatz-haderach-v1.png/);
const png = readFileSync(new URL('../public/art/leaders/kwisatz-haderach-v1.png', import.meta.url));
assert.equal(png.subarray(1, 4).toString(), 'PNG');
assert.equal(png.readUInt32BE(16), 1254);
assert.equal(png.readUInt32BE(20), 1254);
});
+51
View File
@@ -10,6 +10,57 @@ import {
type Game,
} from '../game/engine';
import { baseDeck } from '../game/cards';
import { registerHooks } from 'node:module';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
const aliases = registerHooks({ resolve(specifier, context, next) {
return next(specifier === 'next/image' ? 'vinext/shims/image' : specifier, context);
} });
const { RevealedBattle } = await import('../components/revealed-battle');
const { PrivateBattlePlan } = await import('../components/private-battle-plan');
const { BattleComponentInspection } = await import('../components/battle-component-inspection');
const { KwisatzPrivateStatus } = await import('../components/kwisatz-inspector');
aliases.deregister();
void test('KH component inspection follows real sealed and revealed projections without exposing private status', () => {
let g = prepared(fixture(true));
g.players[0].ally = 'g';
g.players[2].ally = 'a';
g = applyAction(g, 'a', { type: 'battlePlan', dial: 0, leader: 'atreides-0', kwisatz: true });
for (const id of ['a', 'e', 'g']) {
const v = viewGame(g, id);
assert.equal(renderToStaticMarkup(createElement(RevealedBattle, { game: v })), '');
assert.equal(Boolean(v.players[0].kwisatz), id === 'a');
}
g = applyAction(g, 'e', { type: 'battlePlan', dial: 0, leader: 'emperor-0' });
const before = JSON.stringify(g);
for (const id of ['a', 'e', 'g']) {
const v = viewGame(g, id);
const html = renderToStaticMarkup(createElement(RevealedBattle, { game: v }));
assert.match(html, /Inspect Kwisatz Haderach · plan component/);
assert.doesNotMatch(html, /Your private status|<progress|of 7 battle losses/);
assert.equal(renderToStaticMarkup(createElement(RevealedBattle, { game: viewGame(JSON.parse(before), id) })), html);
}
const own = viewGame(g, 'a').players[0].kwisatz!;
assert.match(renderToStaticMarkup(createElement(KwisatzPrivateStatus, { state: own })), /7 of 7 battle losses/);
assert.equal(JSON.stringify(g), before);
const basic = structuredClone(g);
basic.advanced = false;
assert.equal(viewGame(basic, 'a').players[0].kwisatz, undefined);
});
void test('authorized full-plan KH inspection needs only inclusion and never accepts another viewer', () => {
const v = viewGame(prepared(fixture(true)), 'a');
const plan = { dial: 0, support: 0, leader: null, weapon: null, defense: null, kwisatz: true };
// A constructed authorized view isolates the component boundary; timing is covered elsewhere.
const inspected = { ...v, battle: { ...v.battle!, fullPlan: { owner: 'a', target: 'a', stage: 'inspected' as const }, fullPlanInsight: { target: 'a', plan, cards: [] } } };
const html = renderToStaticMarkup(createElement(PrivateBattlePlan, { game: inspected }));
assert.match(html, /Inspect Kwisatz Haderach · plan component/);
assert.doesNotMatch(html, /Your private status|<progress|of 7 battle losses/);
assert.equal(renderToStaticMarkup(createElement(PrivateBattlePlan, { game: { ...inspected, me: 'g' } })), '');
assert.equal(renderToStaticMarkup(createElement(PrivateBattlePlan, { game: { ...inspected, battle: { ...inspected.battle, revealed: true } } })), '');
assert.equal(renderToStaticMarkup(createElement(BattleComponentInspection, { cards: [], playerName: 'Atreides', kwisatz: false })), '');
});
/** Transfer fixture cards from the actual deck; never duplicate another seat's hand. */
function setBattleHand(g: Game, id: string, ids: string[]) {
const owner = g.players.find((p) => p.id === id)!;