Each player summary opens enlarged original counter diagrams and public physical pools. The inventory separates reserves, Tanks, board locations and foreign Homeworld deployments. Native Homeworlds break down existing reserves; elites and advisor stances remain subsets. The reference gallery covers all twelve factions, including Suboids/Cyborgs, Sardaukar, Fedaykin and BG advisor faces. Actual force keys retain unplaced Mobile Stronghold and Discovery groups. Basic Homeworld special identity comes from public typed pools, not mode alone. Concealed No-Fields expose only a separate marker/location; no value or future materialization is inferred. Temporary battle strength creates no physical counter. Invalid projections show a refresh notice instead of invented totals. Inspection updates from current props and submits no action or saved mutation. Independent review found no material issue and clarified No-Field wording: revelation draws from reserves available then, without earmarking counters. Eleven new focused cases cover faction identity, typed pools, advisors, genuine Basic Homeworld public views, native/foreign splits, genuine Ix setup before HMS placement, a controlled Discovery position, JSON continuation, input immutability, concealed0/3/5 equivalence, named controls and malformed projection feedback. A test-only Homeworld projection initially omitted its card identity; corrected before final checks. Lint's semantic status element was corrected to output. Types, lint, all 4984 offline tests and production build passed on stable source. No HTTP/auth/schema/persistence behavior changed, so no additional HTTP suite was required. Browser QA reused an isolated existing three-human Basic Nexus without game actions. All three public inspectors showed correct pools; Escape restored trigger focus, Enter opened inspection and PageDown scrolled the labelled region. At390px the dialog359px fit, body text16px and Close44px high. All twelve gallery choices rendered named160px diagrams without gallery overflow. Internal guide, Back and refresh worked; viewport reset and preview retained. Screenshot capture timed out. Full visual/screen-reader/200% zoom, manufactured component fidelity and all-configuration browser acceptance remain open. All 1499 original rooms, 3171 seats and auxiliary rows are unchanged; SQLite integrity and foreign keys pass. The healthy server was reused without restart, migration, reset or automation. AI strategy/calibration remains deferred until non-AI features are complete; no mode/publication gate was opened. Private evidence: 20260921-force-inspection. Verified source tree: 5d7ffd471229faa13224fd5a87d33c3ac5af866446241a7881926a54c1ee49eb
357 lines
14 KiB
TypeScript
357 lines
14 KiB
TypeScript
'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 { 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 === '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>
|
||
);
|
||
}
|