Files
Dune/components/rules-reference.tsx
T
Quintine c2f24fc546 Add public faction-sheet inspectors and a twelve-faction gallery
Add an enlarged public sheet beneath each player, available while actions are
pending. Only faction identity and table rules mode enter the reader; it sends
no action and receives no private player data. Basic/Advanced preview explicitly
leaves the table mode unchanged. Native mode control, keyboard scrolling,
Escape/focus restoration and internal links use existing dialog components.
The internal gallery covers all twelve factions with bundled Homeworld artwork.

Share every faction's Basic and Advanced paragraphs with the main reference.
Replace Richese's generic placeholder, centralize Ecaz/Moritani guidance from
settled contracts, correct CHOAM Auditor/Baliset/Jubba availability and align the
Fremen/Ecaz Sietch Tabr exception. Full printed text, unresolved powers and
combined-rule acceptance remain explicit. No new rule, AI strategy or gate.

Four focused tests cover all12 identities, shared paragraphs, artwork files,
internal links, mode separation, gallery defaults and per-viewer busy controls.
The first busy-control assertion falsely matched CSS disabled variants; it now
checks actual disabled attributes. All16 related focused cases pass. Independent
review caught missing Moritani optionality and stale Fremen exception text;
those were fixed and final review found no material issue. Auditor limits and
full actual-card cancellation cost were checked against the existing contract.
Types, lint, all 5000 offline tests and production build pass on stable source.
No API/auth/persistence changes; HTTP tests were not repeated.

Browser QA reused the existing two-player Basic auction without game actions.
Both own and opposing inspectors work; Enter opens, Escape closes and returns
focus, reopening resets Basic, and Advanced preview explicitly preserves the
Basic table. ArrowDown scrolls the focused long sheet. At390px, dialog359x828,
scroll client/width342 without horizontal overflow,16px text and44px controls.
Gallery334px fits without overflow; Richese, Ecaz and Moritani selection and
Advanced paragraphs render, faction change resets Basic and bundled art loads.
Internal help and Advanced Moritani links expand their topics. Back and refresh
preserve four hand cards and the pending minimum bid2; normal viewport restored.
Screenshot capture timed out; full visual/screen-reader acceptance remains open.

All 1539 opening games, 3259 saved seats and auxiliary rows
remain identical, including the QA room. SQLite integrity and foreign keys pass.
No new games, server restart, migration, reset or recurring automation.

Private evidence: 20260921-faction-sheets.
Verified source tree: 6b738958f30fc381d3e9eb26398239bbd88f1d4281f8ff5ff2bb3b3259494a93
2026-09-21 12:17:06 +10:00

359 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import Link from 'next/link';
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 { ForceReferenceGallery } from './force-inspector';
import { FactionSheetGallery } from './faction-inspector';
import { createStrongholdCards } from '@/game/stronghold-cards';
import { useSearchParams } from 'next/navigation';
import { Suspense, useState, useEffect, useRef } from 'react';
import { Search, ArrowUpRight, BookOpen } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { RULE_TOPICS } from '@/game/reference';
import { BattleWheel } from './battle-wheel';
import { RICHESE_CARD_DEFINITIONS } from '@/game/richese-cards';
import { CardInspector, CardRules } from './card-inspector';
export function RulesReference() {
return (
<Suspense
fallback={<p className="reference-page">Loading the rules reference…</p>}
>
<RulesReferenceContent />
</Suspense>
);
}
function RulesReferenceContent() {
const searchParams = useSearchParams();
const topicQuery = searchParams.get('topic');
const [query, setQuery] = useState('');
const [practiceDial, setPracticeDial] = useState(3);
const [category, setCategory] = useState('All topics');
const [opened, setOpened] = useState<Record<string, boolean>>({});
const pendingScroll = useRef<string | null>(null);
useEffect(() => {
const reveal = () => {
const id = topicQuery || window.location.hash.slice(1);
if (!RULE_TOPICS.some((t) => t.id === id)) return;
pendingScroll.current = id;
setQuery('');
setCategory('All topics');
setOpened((previous) => ({ ...previous, [id]: true }));
};
reveal();
window.addEventListener('hashchange', reveal);
return () => window.removeEventListener('hashchange', reveal);
}, [topicQuery]);
// Scroll only after React has rendered the destination with cleared filters.
useEffect(() => {
const id = pendingScroll.current;
if (!id) return;
const article = document.getElementById(id);
if (!article) return;
pendingScroll.current = null;
article.scrollIntoView({ block: 'start' });
});
const openTopic = (id: string) => {
pendingScroll.current = id;
setQuery('');
setCategory('All topics');
setOpened((previous) => ({ ...previous, [id]: true }));
};
const categories = [
'All topics',
...new Set(RULE_TOPICS.map((t) => t.category)),
];
const terms = query.toLowerCase().trim().split(/\s+/).filter(Boolean);
const topics = RULE_TOPICS.filter(
(t) =>
(category === 'All topics' || t.category === category) &&
terms.every((term) =>
[
t.title,
t.summary,
t.searchText ?? '',
t.developmentStage ?? '',
...t.steps,
t.example ?? '',
...(t.checklist ?? []).flatMap((item) => [
item.area,
item.status,
item.detail,
...(item.evidence ?? []),
]),
]
.join(' ')
.toLowerCase()
.includes(term),
),
);
return (
<main className="reference-page">
<header className="reference-header">
<Link href="/">← Return to Arrakis</Link>
<span className="eyebrow">
<BookOpen size={14} /> THE MENTAT’S REFERENCE
</span>
<h1>
Know the rules.
<br />
<em>Choose your moment.</em>
</h1>
<p>
Find a phase, card or faction. Follow the action guides, inspect
examples and move directly between related rules.
</p>
<p><Link href="/learn">New to Dune? Try the interactive introduction →</Link></p>
<p className="reference-status">
This reference is growing with the game. Advanced play and expansion
rules are unfinished.{' '}
<Link href="/rules/coverage">
View the implementation ledger <ArrowUpRight size={13} />
</Link>
{' · '}
<Link href="/rules?topic=implementation-checklist#implementation-checklist">
Open feature checklists <ArrowUpRight size={13} />
</Link>
</p>
</header>
<div className="reference-layout">
<aside className="reference-nav">
<label htmlFor="rules-search">
<Search size={16} /> Search the reference
</label>
<Input
id="rules-search"
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Storm, Karama, allies…"
/>
<nav aria-label="Rule categories">
{categories.map((c) => (
<button
key={c}
aria-pressed={category === c}
onClick={() => setCategory(c)}
>
{c}
<span>
{c === 'All topics'
? RULE_TOPICS.length
: RULE_TOPICS.filter((t) => t.category === c).length}
</span>
</button>
))}
</nav>
</aside>
<section className="reference-results" aria-label="Rule topics">
<output className="fine">
{topics.length} {topics.length === 1 ? 'topic' : 'topics'}
{query ? ` matching “${query}”` : ''}
</output>
{topics.length === 0 && (
<p>No matching topics. Try a card name or choose All topics.</p>
)}
{topics.map((topic) => (
<article id={topic.id} key={topic.id} className="rule-topic">
<details
open={terms.length > 0 || !!opened[topic.id]}
onToggle={(event) => {
if (terms.length) return;
const isOpen = event.currentTarget.open;
setOpened((previous) =>
previous[topic.id] === isOpen
? previous
: { ...previous, [topic.id]: isOpen },
);
}}
>
<summary>
<span className="eyebrow">{topic.category}</span>
<h2>{topic.title}</h2>
<span
className={`coverage-label coverage-${topic.coverage.toLowerCase()}`}
>
{topic.coverage}
</span>
{topic.developmentStage && (
<span className="coverage-label">
Development: {topic.developmentStage}
</span>
)}
<p>{topic.summary}</p>
</summary>
<div className="rule-topic-content">
<ol>
{topic.steps.map((step) => (
<li key={step}>{step}</li>
))}
</ol>
{topic.id === 'stronghold-cards' && (
<StrongholdCardGallery
state={createStrongholdCards()}
players={[]}
/>
)}
{topic.id === 'homeworlds' && <HomeworldCardGallery />}
{topic.id === 'nexus-cards' && <NexusCardGallery />}
{topic.id === 'storm-cards' && <StormCardGallery />}
{topic.id === 'kwisatz-haderach' && <KwisatzReferenceCard />}
{topic.id === 'force-counters' && <ForceReferenceGallery />}
{topic.id === 'faction-sheets' && <FactionSheetGallery />}
{topic.id === 'richese-cards' && (
<section
aria-label="Richese card collection"
className="grid gap-4 sm:grid-cols-2"
>
{RICHESE_CARD_DEFINITIONS.map(({ card, printedType }) => (
<article
key={card.id}
className="flex min-w-0 flex-col gap-3 rounded-lg border border-[#454b3c] bg-[#1d221b] p-4"
>
<h3 className="m-0 break-words font-serif text-xl text-[#efd9a8]">
{card.name}
</h3>
<p className="m-0 text-xs text-[#c1c5b8]">
{printedType} · 1 card
</p>
<CardRules card={card} />
<CardInspector card={card} />
</article>
))}
</section>
)}
{topic.checklist && (
<section
aria-label={`${topic.title} implementation checklist`}
>
<h3>Feature checklist</h3>
<div className="max-w-full overflow-x-auto">
<table className="w-full text-left text-sm leading-6">
<caption className="sr-only">
{topic.title}: implementation, controls, AI,
documentation and verification
</caption>
<thead>
<tr>
<th scope="col" className="p-2">
Area
</th>
<th scope="col" className="p-2">
Status
</th>
<th scope="col" className="p-2">
Scope and evidence
</th>
</tr>
</thead>
<tbody>
{topic.checklist.map((item) => (
<tr key={item.area}>
<th scope="row" className="p-2 align-top">
{item.area}
</th>
<td className="p-2 align-top">{item.status}</td>
<td className="p-2 align-top">
<p className="m-0">{item.detail}</p>
{item.evidence && (
<ul className="mt-2 list-disc pl-4">
{item.evidence.map((file) => (
<li key={file}>
<code className="break-all text-xs">
{file}
</code>
</li>
))}
</ul>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
)}
{topic.id === 'battle' && (
<aside className="rule-example">
<strong>Try a basic battle wheel</strong>
<p>
Practice with ten ordinary forces and a strength-five
leader. Use the slider, arrow keys or number field. In a
game, your choice stays private until the plans are
revealed.
</p>
<div style={{ maxWidth: 280 }}>
<label htmlFor="practice-battle-dial">
Practice dial
</label>
<BattleWheel
id="practice-battle-dial"
value={practiceDial}
onChange={(value) =>
setPracticeDial(
Math.min(10, Math.max(0, Math.round(value))),
)
}
max={10}
step={1}
/>
</div>
<p aria-live="polite">
If the leader survives, your total is {practiceDial} + 5
= {practiceDial + 5}. In a normal victory you lose the{' '}
{practiceDial} dialed forces; in a defeat you lose all
ten. Weapon, traitor and faction effects can change the
result.
</p>
<p className="fine">
This practice wheel does not change a live game.
</p>
</aside>
)}
{topic.example && (
<aside className="rule-example">
<strong>Example</strong>
<p>{topic.example}</p>
</aside>
)}
{topic.related && (
<nav
aria-label={`Related to ${topic.title}`}
className="related-rules"
>
{topic.related.map((id) => (
<a
href={`/rules?topic=${id}#${id}`}
key={id}
onClick={() => {
openTopic(id);
}}
>
{RULE_TOPICS.find((t) => t.id === id)?.title ?? id}{' '}
<ArrowUpRight size={13} />
</a>
))}
</nav>
)}
<a
className="rule-permalink"
href={`/rules?topic=${topic.id}#${topic.id}`}
onClick={() => openTopic(topic.id)}
>
Link to this topic
</a>
</div>
</details>
</article>
))}
</section>
</div>
</main>
);
}