Files
Dune/components/rules-reference.tsx
T
Quintine 5a76ae0410 Connect genuine Ix expansion setup to playable prototypes
Add an offline Ix initializer through the real setup pipeline, supporting Basic
and Advanced play, base factions, Ixians and Tleilaxu with the full 47-card deck
and 22-card spice deck. Preserve all normal start and publication gates.

Add a reusable local prototype starter: back up the named fresh ready lobby,
initialize it once by exact version/state comparison and preserve seat/recovery
tables and other rooms. Update the existing checklist with a separate prototype
stage and document functional scope, source interpretations and later refinement.

Six new tests cover all-profile private setup, exact typed starting forces,
Face Dancer/treachery custody, JSON continuation, two fixed-seed complete games
and local SQLite write/rejection boundaries. Independent review caught a missing
JSON version update, now fixed and covered; focused checking fixed one test typing
error before the stable broad check. No new rule interpretation was introduced.

Validation: types, lint and 4,211 offline tests passed (123.237s); production build
passed (6.543s); all 40 HTTP tests passed (23.578s). Source-bound report:
20260913-ix-prototype-final/report.json, fingerprint
3b8f295593bcb314bb72308d81739006b11d949d1e5906dbf6ce0704b15ebd72.

Browser W5LE6VFB: human Ixian + Medium Tleilaxu + Hard Atreides from genuine
setup, private starting-card refresh, Weather Control, mobile stronghold placement,
then owned Brutal autopilot. Atreides won on turn 1; this short complete journey
is not proof of every card or faction interaction. Finished private hand restored
at phone width; expanded Poison Tooth was readable. Fixed-seed offline games
2026091301 (Basic) and 2026091302 (Advanced) both completed with both Ix factions.

All 64 opening room versions/hashes are unchanged. One browser room and 30 HTTP
rooms were added, totaling 95. Reused the running server; no restart or automation.

Aggregate goal counters: 59,524,858 to 59,976,567 at completed verification,
451,709 tokens before final Git overhead. Two bounded workhorse helpers handled
inventory/tests and independent review; one stable full check/build/HTTP run.
Full game scope remains active and unfinished; future work prioritizes missing
functional prototypes before exhaustive refinement and final acceptance.
2026-09-13 12:17:33 +10:00

350 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 { 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 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 === '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>
);
}