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';
|
'use client';
|
||||||
|
|
||||||
import { CardInspector } from './card-inspector';
|
import { CardInspector } from './card-inspector';
|
||||||
|
import { KwisatzInspector } from './kwisatz-inspector';
|
||||||
import {
|
import {
|
||||||
LeaderInspector,
|
LeaderInspector,
|
||||||
type LeaderDisplayIdentity,
|
type LeaderDisplayIdentity,
|
||||||
@@ -12,16 +13,19 @@ export function BattleComponentInspection({
|
|||||||
cards,
|
cards,
|
||||||
leader,
|
leader,
|
||||||
playerName,
|
playerName,
|
||||||
|
kwisatz,
|
||||||
}: {
|
}: {
|
||||||
cards: readonly VisibleCard[];
|
cards: readonly VisibleCard[];
|
||||||
leader?: LeaderDisplayIdentity;
|
leader?: LeaderDisplayIdentity;
|
||||||
playerName: string;
|
playerName: string;
|
||||||
|
kwisatz?: boolean;
|
||||||
}) {
|
}) {
|
||||||
if (!cards.length && !leader) return null;
|
if (!cards.length && !leader && !kwisatz) return null;
|
||||||
return (
|
return (
|
||||||
<details className="battle-component-inspection">
|
<details className="battle-component-inspection">
|
||||||
<summary>Inspect {playerName}’s plan components</summary>
|
<summary>Inspect {playerName}’s plan components</summary>
|
||||||
<div className="visible-component-list">
|
<div className="visible-component-list">
|
||||||
|
{kwisatz && <KwisatzInspector context="plan" />}
|
||||||
{leader && (
|
{leader && (
|
||||||
<div>
|
<div>
|
||||||
<strong>{leader.name}</strong>
|
<strong>{leader.name}</strong>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import { EcazSetup } from './ecaz-setup';
|
import { EcazSetup } from './ecaz-setup';
|
||||||
import { StormCardInspector, StormCardLogInspector } from './storm-cards';
|
import { StormCardInspector, StormCardLogInspector } from './storm-cards';
|
||||||
|
import { KwisatzInspector } from './kwisatz-inspector';
|
||||||
import { LobbyBotControls } from './lobby-bot-controls';
|
import { LobbyBotControls } from './lobby-bot-controls';
|
||||||
import { IxRicheseTechnology } from './ix-richese-technology';
|
import { IxRicheseTechnology } from './ix-richese-technology';
|
||||||
import { nexusGuildCunningAction, nexusGuildCunningActive, nexusGuildMovementAvailable, nexusGuildShipmentAvailable, nexusGuildSkipShipmentAction } from '@/game/nexus-guild-cunning-options';
|
import { nexusGuildCunningAction, nexusGuildCunningActive, nexusGuildMovementAvailable, nexusGuildShipmentAvailable, nexusGuildSkipShipmentAction } from '@/game/nexus-guild-cunning-options';
|
||||||
@@ -1198,7 +1199,7 @@ export function GameTable({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{me.kwisatz && (
|
{me.kwisatz && (
|
||||||
<p className="fine">
|
<div className="space-y-2"><p className="fine">
|
||||||
Kwisatz Haderach:{' '}
|
Kwisatz Haderach:{' '}
|
||||||
{me.kwisatz.dead
|
{me.kwisatz.dead
|
||||||
? 'awaiting revival'
|
? 'awaiting revival'
|
||||||
@@ -1209,6 +1210,8 @@ export function GameTable({
|
|||||||
? ` · used in ${combatName(me.kwisatz.usedAt)} this turn`
|
? ` · used in ${combatName(me.kwisatz.usedAt)} this turn`
|
||||||
: ''}
|
: ''}
|
||||||
</p>
|
</p>
|
||||||
|
<KwisatzInspector state={me.kwisatz} usedTerritoryName={me.kwisatz.usedAt ? combatName(me.kwisatz.usedAt) : undefined} />
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{g.stormForecast !== null && (
|
{g.stormForecast !== null && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -4688,7 +4691,7 @@ export function GameTable({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{me.kwisatz && (
|
{me.kwisatz && (
|
||||||
<label>
|
<div className="space-y-2"><label>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={kwisatz}
|
checked={kwisatz}
|
||||||
@@ -4703,6 +4706,8 @@ export function GameTable({
|
|||||||
/>
|
/>
|
||||||
Use Kwisatz Haderach (+2; prevents traitors)
|
Use Kwisatz Haderach (+2; prevents traitors)
|
||||||
</label>
|
</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 && (
|
{g.battle.fullPlan?.target === me.id && (
|
||||||
<p className="notice">
|
<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'
|
game.players.find((p) => p.id === insight.target)?.name ?? 'Player'
|
||||||
}
|
}
|
||||||
cards={insight.cards}
|
cards={insight.cards}
|
||||||
|
kwisatz={insight.plan.kwisatz}
|
||||||
leader={
|
leader={
|
||||||
leader
|
leader
|
||||||
? {
|
? {
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ export function RevealedBattle({ game }: { game: GameView }) {
|
|||||||
<span>Added after reveal: {name(battle.lateDefense[id])}</span>
|
<span>Added after reveal: {name(battle.lateDefense[id])}</span>
|
||||||
)}
|
)}
|
||||||
<BattleComponentInspection
|
<BattleComponentInspection
|
||||||
|
kwisatz={plan.kwisatz}
|
||||||
playerName={
|
playerName={
|
||||||
game.players.find((p) => p.id === id)?.name ?? 'Player'
|
game.players.find((p) => p.id === id)?.name ?? 'Player'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { StrongholdCardGallery } from './stronghold-cards';
|
|||||||
import { HomeworldCardGallery } from './homeworld-cards';
|
import { HomeworldCardGallery } from './homeworld-cards';
|
||||||
import { NexusCardGallery } from './nexus-cards';
|
import { NexusCardGallery } from './nexus-cards';
|
||||||
import { StormCardGallery } from './storm-cards';
|
import { StormCardGallery } from './storm-cards';
|
||||||
|
import { KwisatzReferenceCard } from './kwisatz-inspector';
|
||||||
import { createStrongholdCards } from '@/game/stronghold-cards';
|
import { createStrongholdCards } from '@/game/stronghold-cards';
|
||||||
import { useSearchParams } from 'next/navigation';
|
import { useSearchParams } from 'next/navigation';
|
||||||
import { Suspense, useState, useEffect, useRef } from 'react';
|
import { Suspense, useState, useEffect, useRef } from 'react';
|
||||||
@@ -198,6 +199,7 @@ function RulesReferenceContent() {
|
|||||||
{topic.id === 'homeworlds' && <HomeworldCardGallery />}
|
{topic.id === 'homeworlds' && <HomeworldCardGallery />}
|
||||||
{topic.id === 'nexus-cards' && <NexusCardGallery />}
|
{topic.id === 'nexus-cards' && <NexusCardGallery />}
|
||||||
{topic.id === 'storm-cards' && <StormCardGallery />}
|
{topic.id === 'storm-cards' && <StormCardGallery />}
|
||||||
|
{topic.id === 'kwisatz-haderach' && <KwisatzReferenceCard />}
|
||||||
{topic.id === 'richese-cards' && (
|
{topic.id === 'richese-cards' && (
|
||||||
<section
|
<section
|
||||||
aria-label="Richese card collection"
|
aria-label="Richese card collection"
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
# Component inventory and verification ledger
|
# 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,
|
**Storm Card inspection, 21 September 2026:** six numeric reference faces,
|
||||||
authorized private Fremen forecasts and structured public reveal receipts now
|
authorized private Fremen forecasts and structured public reveal receipts now
|
||||||
share an enlarged inspector. The actual card value survives separately from
|
share an enlarged inspector. The actual card value survives separately from
|
||||||
|
|||||||
+20
-20
@@ -6,27 +6,27 @@ gated.** Prototypes do not open completion or publication gates.
|
|||||||
|
|
||||||
## Current checkpoint and work
|
## Current checkpoint and work
|
||||||
|
|
||||||
Latest delivered checkpoint: `67e429e`, [Charity and Revival practice](INTERACTIVE_INTRODUCTION.md).
|
Latest delivered checkpoint: `ed53eb7`, [Storm Card inspection](STORM_CARD_INSPECTION.md).
|
||||||
Types, lint, **4,941 offline tests** and production build passed. All 1,337 rooms,
|
Types, lint, **4,946 offline tests**, production build and **46 HTTP tests** passed.
|
||||||
2,805 seats and existing recovery/entry/handover/delegation rows stayed unchanged.
|
All 1,337 original rooms, 2,805 original seats and existing auxiliary rows stayed
|
||||||
The running server was reused; prior lesson results and paid returns restore.
|
unchanged; HTTP checks created their own additional records. The current baseline
|
||||||
The preceding `c4ff75a` connects alliance practice, `b7ec940` connects bidding,
|
contains 1,373 rooms and 2,883 seats. The running server was reused.
|
||||||
and `b9718ac` connects
|
Earlier delivered checkpoints include `67e429e` for Charity/Revival practice,
|
||||||
[lobby AI configuration](LOBBY_AI_CONFIGURATION.md), and `b21b2bc` connects
|
`c4ff75a` for alliances, `b7ec940` for bidding, `b9718ac` for lobby AI configuration
|
||||||
[one-use AI permission](SEAT_AI_PERMISSION.md). The `8f7a62c` policy checkpoint
|
and `b21b2bc` for one-use AI permission. The `8f7a62c` policy checkpoint removed
|
||||||
removed the usage cutoff and deferred AI refinement until feature completion,
|
the usage cutoff and deferred AI refinement until feature completion, with three
|
||||||
with three adjacent 75% targets. Earlier evidence remains in the
|
adjacent 75% targets. See the [implementation history](IMPLEMENTATION_STATUS.md).
|
||||||
[implementation history](IMPLEMENTATION_STATUS.md).
|
|
||||||
|
|
||||||
Current work: [Storm Card inspection](STORM_CARD_INSPECTION.md) connects six
|
Current work: [Kwisatz Haderach inspection](KWISATZ_INSPECTION.md) connects an
|
||||||
reference faces, the permitted Fremen forecast and public chronicle receipts to
|
original companion face, private battle-loss progress and availability guidance,
|
||||||
enlarged inspection. Original card values stay separate from modified movement;
|
authorized private/public plan components and a static internal reference.
|
||||||
legacy text logs remain valid. No rules or AI strategy changes. Independent review,
|
Rules calculations, sealed choices, save shape and mode gates remain unchanged.
|
||||||
browser/check/build/HTTP/preservation and Git evidence belong to its private report.
|
Source-bound checks, independent review, browser and preservation/Git evidence
|
||||||
A bounded current-state Duke review confirmed revival/loan paths still cross
|
belong to the private checkpoint. Full Advanced acceptance, exact manufactured
|
||||||
recorded custody, return and cycle questions; existing identity/price helpers and
|
component verification and the remaining component inventory are unfinished.
|
||||||
non-Ecaz guards are already present. No duplicate ruling or invented outcome.
|
A bounded Duke review confirmed revival/loan paths still cross recorded custody,
|
||||||
Storm/spice-blow, full faction/Advanced/expansion teaching and broader
|
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.
|
multiplayer acceptance remain unfinished.
|
||||||
|
|
||||||
A bounded remaining-effect audit found that the reviewed faction/Nexus candidates
|
A bounded remaining-effect audit found that the reviewed faction/Nexus candidates
|
||||||
|
|||||||
@@ -1,5 +1,16 @@
|
|||||||
# Dune implementation status
|
# 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
|
## 21 September 2026 — Storm Card inspection
|
||||||
|
|
||||||
The [Storm Card inspector](STORM_CARD_INSPECTION.md) connects six numeric reference
|
The [Storm Card inspector](STORM_CARD_INSPECTION.md) connects six numeric reference
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
@@ -5,6 +5,7 @@ import { RICHESE_CARD_DEFINITIONS } from './richese-cards';
|
|||||||
import { HOMEWORLD_CARDS } from './homeworld-cards';
|
import { HOMEWORLD_CARDS } from './homeworld-cards';
|
||||||
import { NEXUS_CARD_REFERENCE } from './nexus-card-reference';
|
import { NEXUS_CARD_REFERENCE } from './nexus-card-reference';
|
||||||
import { LEADER_SKILL_CARDS } from './leader-skill-cards';
|
import { LEADER_SKILL_CARDS } from './leader-skill-cards';
|
||||||
|
import { KWISATZ_RULES } from './kwisatz-display';
|
||||||
|
|
||||||
export const RULE_CHECKLIST_AREAS = [
|
export const RULE_CHECKLIST_AREAS = [
|
||||||
'Implementation',
|
'Implementation',
|
||||||
@@ -203,6 +204,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
|||||||
'setup',
|
'setup',
|
||||||
'interactive-introduction',
|
'interactive-introduction',
|
||||||
'storm-cards',
|
'storm-cards',
|
||||||
|
'kwisatz-haderach',
|
||||||
'truthtrance-spice',
|
'truthtrance-spice',
|
||||||
'ecaz-ambassadors',
|
'ecaz-ambassadors',
|
||||||
'richese-cards',
|
'richese-cards',
|
||||||
@@ -2753,7 +2755,24 @@ export const RULE_TOPICS: RuleTopic[] = [
|
|||||||
],
|
],
|
||||||
example:
|
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.',
|
'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',
|
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 |
@@ -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);
|
||||||
|
});
|
||||||
@@ -10,6 +10,57 @@ import {
|
|||||||
type Game,
|
type Game,
|
||||||
} from '../game/engine';
|
} from '../game/engine';
|
||||||
import { baseDeck } from '../game/cards';
|
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. */
|
/** Transfer fixture cards from the actual deck; never duplicate another seat's hand. */
|
||||||
function setBattleHand(g: Game, id: string, ids: string[]) {
|
function setBattleHand(g: Game, id: string, ids: string[]) {
|
||||||
const owner = g.players.find((p) => p.id === id)!;
|
const owner = g.players.find((p) => p.id === id)!;
|
||||||
|
|||||||
Reference in new issue
Block a user