Add an interactive Basic-game introduction

New players can open Learn to play from the lobby or rules reference. Five lessons explain public/private information and turn phases, then offer three independent exercises for ordinary shipment budgets, a sealed battle plan and spice collection, followed by joining and recovering a real table. Shipping, combat and collection use existing production quotes rather than a separate simplified calculator. The fixed opposing plan is a teaching example, not an AI match.

Reuse the battle wheel and card inspectors, provide keyboard inputs, move focus to the current lesson heading and stack the layout on phones. Versioned browser-local choices preserve lesson position, shipment, defense/dial and reveal state. Whitelisted validation rejects corrupt saves and impossible committed shipments. SSR uses a stable hydration placeholder before reading browser storage; direct user handlers save choices and retain usable in-memory state on storage failure. No room, seat credential or multiplayer API is used.

Six new tests cover prices and affordability, every offered battle dial/defense, protected ties, weapon death, actual losses/bounty, collection limits and city capacity, saved-choice validation and internal rule links. Types, lint, 4872 offline tests and production build pass on the unchanged source fingerprint below. The first required check stopped on React setState-in-effect and semantic-status lint findings; these were fixed before the successful complete run. Independent review found no material defect in the examples or final hydration/save implementation.

Browser controls verified an unaffordable shipment, legal six-force shipment, card inspection, protected 8-8 aggressor victory, Snooper/projectile death and bounty, sealed refresh, city collection, lobby return/re-entry and newly changed choices after the hydration correction. At 390px the battle controls and lobby Learn link stayed within page bounds; the viewport override was reset. Screenshot capture timed out, so visual screenshot acceptance is still open. Storage-denial browser testing and the broader faction, movement, bidding, alliance, Advanced and expansion curriculum remain unfinished.

All 1106 current rooms, 2314 seats and all original recovery/entry/handover rows exactly match the fresh outage backup. SQLite integrity and foreign-key checks pass. The healthy server was reused; no restart, database migration/reset or maintenance automation was needed. HTTP integration and full-game samples were not rerun for this isolated teaching surface; server game behavior did not change.

A bounded independent primary-source review supports Smuggler's reveal-bound deferred survival receipt but found no exact ruling for modified leader strength. Record the one new user question and preserve the unanswered gate; lower collection remains missing. This does not enable any rules mode, expansion or publication gate.

Verified source tree: 99718ab49c4fec14820cbaf35f2e0f815e6424e910ee67c4daa1941afdeca2f0
This commit is contained in:
Quintine committed 2026-09-20 14:25:58 +10:00
1 parent 9b67a780eb
commit 9be7dfb9fb
14 files changed
+472 -6

No files matched your search

+4
View File
@@ -14,6 +14,10 @@ npm run dev -- --host 0.0.0.0
Open `http://localhost:3000`. Create a room, share its eight-character invitation, and add human or AI seats. The host can configure Easy, Medium, Hard and Brutal AI opponents. Current calibration demonstrates a substantially weaker Easy policy; it does not yet demonstrate a consistent ordering among the upper three levels.
Choose **Learn to play** for the [interactive introduction](docs/INTERACTIVE_INTRODUCTION.md)
at `/learn`. Practice shipping, battle plans and collection before entering a
room; lesson progress stays in your browser and does not change saved games.
Local games persist in `.wrangler/state`. Keep this directory across restarts. Apply new additive migrations before running code that depends on them. Never delete the local database to resolve a connection or rules problem. `.openai/hosting.json` belongs to the existing Sites project; do not recreate the hosting project.
## Saved seats and reconnecting
+5
View File
@@ -0,0 +1,5 @@
import { Introduction } from '@/components/introduction';
export default function Learn() {
return <Introduction />;
}
+1
View File
@@ -892,6 +892,7 @@ export default function Home() {
</div>
<div className="masthead-right">
<span className="status-dot" /> Multiplayer table{' '}
<Link href="/learn">Learn to play</Link>
<Link href="/rules">
<BookOpen size={17} />
Rulebook
+33
View File
@@ -0,0 +1,33 @@
.page { max-width: 1120px; margin: 0 auto; padding: 2rem clamp(1rem, 4vw, 3rem) 4rem; color: #e9e2cf; }
.header { max-width: 760px; margin-bottom: 1.5rem; }
.header h1 { font-family: var(--font-display), Georgia, serif; font-size: clamp(2rem, 5vw, 3.6rem); line-height: 1.1; margin: .7rem 0 1rem; }
.page p, .page li { line-height: 1.65; }
.page p { margin: .8rem 0; }
.page a { color: #e8c982; text-underline-offset: .2em; }
.page a:hover { text-decoration: underline; }
.steps { display: flex; gap: .5rem; flex-wrap: wrap; margin: 1.5rem 0; }
.page button { min-height: 44px; padding: .6rem .9rem; background: #252d23; color: #eee6cf; border: 1px solid #778064; border-radius: .4rem; cursor: pointer; }
.page button:hover:enabled { background: #374232; }
.page button:disabled { opacity: .5; cursor: default; }
.page :is(button, a, input, select):focus-visible { outline: 3px solid #efd294; outline-offset: 3px; }
.steps button { flex: 1 1 155px; text-align: left; display: flex; gap: .6rem; align-items: center; }
.steps button[aria-current] { background: #cdb177; color: #171d16; border-color: #f3d79b; }
.steps span { font-variant-numeric: tabular-nums; }
.lesson { background: #151d16; border: 1px solid #58604a; border-radius: .8rem; padding: clamp(1rem, 3vw, 2rem); }
.lesson h2 { font-family: var(--font-display), Georgia, serif; font-size: 1.8rem; line-height: 1.2; margin: .6rem 0 1.2rem; }
.lesson h3 { font-size: 1.05rem; color: #ecd29b; margin: .5rem 0; font-weight: 650; }
.lesson ul, .phases { padding-left: 1.3rem; }
.phases { columns: 2; margin: 1rem 0; }
.columns { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 1.5rem; margin: 1.5rem 0; }
.controls { display: flex; flex-direction: column; gap: .8rem; min-width: 0; }
.controls select { background: #202a21; color: #eee6cf; border: 1px solid #899072; border-radius: .3rem; min-height: 44px; padding: .5rem; width: 100%; }
.controls input { accent-color: #d8b777; }
.controls input[type=range] { width: 100%; min-height: 44px; }
.checkbox { display: flex; align-items: center; gap: .7rem; min-height: 44px; }
.checkbox input { width: 20px; height: 20px; flex-shrink: 0; }
.wheel { max-width: 250px; margin: 0 auto; width: 100%; }
.result { background: #20291f; border: 1px solid #637052; border-radius: .6rem; padding: 1rem; min-width: 0; align-self: start; }
.footer { display: flex; align-items: center; justify-content: space-between; gap: 1rem; border-top: 1px solid #454f3b; margin-top: 2rem; padding-top: 1.2rem; flex-wrap: wrap; }
.links { display: flex; flex-wrap: wrap; gap: 1.5rem; margin-top: 1.5rem; }
.saved { display: block; margin-top: .8rem; color: #b8c0a7; font-size: .85rem; }
@media (max-width: 650px) { .columns { grid-template-columns: minmax(0, 1fr); } .steps button { flex-basis: 45%; font-size: .9rem; } .footer { align-items: stretch; } .footer a { flex-basis: 100%; } }
+163
View File
@@ -0,0 +1,163 @@
'use client';
import Link from 'next/link';
import { useEffect, useRef, useState, useSyncExternalStore } from 'react';
import { BattleWheel } from './battle-wheel';
import { CardInspector, CardRules } from './card-inspector';
import { PHASES } from '@/game/catalog';
import {
INTRODUCTION_CARDS, INTRODUCTION_LEADERS, INTRODUCTION_STEPS, INTRODUCTION_STORAGE_KEY,
introductionBattle, introductionCollection, introductionShipment, newIntroduction, restoreIntroduction,
type IntroductionState,
} from '@/game/introduction';
import styles from './introduction.module.css';
const subscribeHydration = () => () => {};
const clientSnapshot = () => true;
const serverSnapshot = () => false;
export function Introduction() {
const hydrated = useSyncExternalStore(subscribeHydration, clientSnapshot, serverSnapshot);
return <main className={styles.page}>
<header className={styles.header}>
<Link href="/">← Return to Arrakis</Link>
<p className="eyebrow">LEARN TO PLAY · BASIC GAME</p>
<h1>A first turn on Arrakis</h1>
<p>Try three small examples, then take a seat at a real table. These are separate practice positions; they do not change any saved game.</p>
</header>
{hydrated ? <IntroductionLessons /> : <output>Restoring your lesson…</output>}
</main>;
}
function IntroductionLessons() {
const [{ state, storageFailed }, setProgress] = useState(() => {
try { return { state: restoreIntroduction(localStorage.getItem(INTRODUCTION_STORAGE_KEY)) ?? newIntroduction(), storageFailed: false }; }
catch { return { state: newIntroduction(), storageFailed: true }; }
});
const heading = useRef<HTMLHeadingElement>(null);
const focusNext = useRef(false);
useEffect(() => {
if (focusNext.current) { heading.current?.focus(); focusNext.current = false; }
}, [state.step]);
const update = (patch: Partial<IntroductionState>) => {
const next = { ...state, ...patch };
let failed = false;
try { localStorage.setItem(INTRODUCTION_STORAGE_KEY, JSON.stringify(next)); }
catch { failed = true; }
setProgress({ state: next, storageFailed: failed });
};
const go = (step: number) => { focusNext.current = true; update({ step }); };
const shipment = introductionShipment(state);
const battle = state.revealed ? introductionBattle(state) : null;
const collection = introductionCollection(state);
const lesson = INTRODUCTION_STEPS[state.step];
const won = battle?.winner === 'you';
const survivingForces = battle ? (won ? 6 - state.dial : 0) : 0;
return <>
<nav className={styles.steps} aria-label="Introduction lessons">
{INTRODUCTION_STEPS.map((step, index) => <button key={step.title}
aria-current={state.step === index ? 'step' : undefined} onClick={() => go(index)}>
<span>{index + 1}</span>{step.title}
</button>)}
</nav>
<section className={styles.lesson} aria-labelledby="lesson-title">
<p className="eyebrow">{state.step + 1} / {INTRODUCTION_STEPS.length}</p>
<h2 id="lesson-title" ref={heading} tabIndex={-1}>{lesson.title}</h2>
{state.step === 0 && <>
<p>Each faction has its own powers. You will practice as Atreides with ordinary forces in the Basic game. Advanced combat and expansion powers come later.</p>
<div className={styles.columns}>
<div><h3>On the table</h3><p>Everyone can see the board, forces, storm and revealed cards. Your spice, Treachery hand and Traitors stay private unless a rule reveals them.</p></div>
<div><h3>Your objective</h3><p>Normally, hold three strongholds alone or four together with your ally at the Mentat Pause. Player-count and faction victory rules can change that target; the live table shows your current victory progress.</p></div>
</div>
<h3>A turn follows these phases</h3>
<ol className={styles.phases}>{PHASES.map(phase => <li key={phase}>{phase}</li>)}</ol>
<p>The table tells you who must act. Your decisions can wait while another player seals a plan or answers a power. Automatic effects show in the chronicle.</p>
</>}
{state.step === 1 && <>
<p>You have <strong>8 spice</strong> and six available reserve forces. Ship from reserves before moving on the board. This example has no discount, storm, occupancy restriction or reacting faction power.</p>
<div className={styles.columns}>
<div className={styles.controls}>
<label htmlFor="learn-destination">Destination</label>
<select id="learn-destination" value={state.destination} onChange={e => update({ destination: e.target.value as IntroductionState['destination'], shipped: false })}>
<option value="sand">Wind Pass · 2 spice per force</option>
<option value="stronghold">Arrakeen · 1 spice per force</option>
</select>
<label htmlFor="learn-shipment">Forces to ship: {state.shipment}</label>
<input id="learn-shipment" type="range" min="1" max="6" step="1" value={state.shipment}
onChange={e => update({ shipment: Number(e.target.value), shipped: false })} />
<button disabled={!shipment.affordable || state.shipped} onClick={() => update({ shipped: true })}>Ship practice forces</button>
</div>
<div className={styles.result} aria-live="polite">
<h3>{state.shipped ? 'Shipment complete' : 'Review the cost'}</h3>
<p>{state.shipment} forces cost <strong>{shipment.cost} spice</strong>.</p>
{shipment.affordable ? <p>{state.shipped ? 'You have' : 'You would have'} {shipment.remaining} spice left.</p>
: <p>You need {shipment.cost - 8} more spice. Reduce the force count or choose the stronghold.</p>}
{state.shipped && <p>{state.shipment} forces arrived; {6 - state.shipment} of your six available reserves remain. You could now move one group already on the board, including these newly shipped forces.</p>}
</div>
</div>
<p className="fine">Ordinary movement reaches an adjacent territory. Ornithopters and faction powers can extend its range. Inspect the live destination guide before committing.</p>
</>}
{state.step === 2 && <>
<p>A separate battle: you are the aggressor in Wind Pass with six ordinary forces and {INTRODUCTION_LEADERS.you.name}, strength {INTRODUCTION_LEADERS.you.strength}. Your opponent also has six forces. Neither side calls a Traitor or uses a faction power in this example.</p>
<div className={styles.columns}>
<div className={styles.controls}>
<label htmlFor="learn-dial">Forces dialed</label>
<div className={styles.wheel}><BattleWheel id="learn-dial" value={state.dial} max={6} step={1} disabled={state.revealed} onChange={dial => update({ dial })} /></div>
<label htmlFor="learn-defense">Your defense · no weapon</label>
<select id="learn-defense" value={state.defense} disabled={state.revealed} onChange={e => update({ defense: e.target.value as IntroductionState['defense'] })}>
<option value="shield">Shield</option><option value="snooper">Snooper</option>
</select>
<CardRules card={INTRODUCTION_CARDS[state.defense]} />
<CardInspector card={INTRODUCTION_CARDS[state.defense]} />
<button disabled={state.revealed} onClick={() => update({ revealed: true })}>Seal plan and reveal both</button>
</div>
<div className={styles.result} aria-live="polite">
{!battle ? <><h3>Opponent’s plan is sealed</h3><p>Choose how much to risk. In a normal victory you lose the forces you dial; in defeat you lose all your forces in this battle. In Basic play, ordinary forces need no spice support.</p><p>Your selected leader contributes strength only if they survive the weapon exchange. Plans reveal together.</p></>
: <><h3>{won ? 'You won the battle' : 'Your opponent won'}</h3>
<p>The opponent dialed 3 with {INTRODUCTION_LEADERS.opponent.name} (strength {INTRODUCTION_LEADERS.opponent.strength}), a {INTRODUCTION_CARDS.weapon.name} and no defense.</p>
<CardInspector card={INTRODUCTION_CARDS.weapon} />
<p>{battle.leaderDeaths.attacker ? 'Your Snooper stops poison, so it cannot stop this projectile weapon. Your leader dies and adds no strength.' : 'Your Shield stops the projectile weapon. Your leader survives and adds five strength.'}</p>
<p><strong>Your total: {battle.scores!.attacker}. Opponent: {battle.scores!.defender}.</strong> {battle.scores!.attacker === battle.scores!.defender && 'The aggressor wins this ordinary tie.'}</p>
<p>{6 - survivingForces} of your forces go to the Tanks; {survivingForces} remain in Wind Pass. {won ? 'The opponent loses all six forces.' : 'The opponent loses the three dialed forces and keeps three.'}</p>
<p>{won ? 'A normal winner may keep or discard the cards they used.' : 'The defeated player discards the cards used in the plan.'} {battle.bounty?.amount ? `The winner also collects ${battle.bounty.amount} spice for the killed leader.` : 'Neither leader died, so there is no leader bounty.'}</p>
<button onClick={() => update({ revealed: false })}>Try another plan</button>
</>}
</div>
</div>
</>}
{state.step === 3 && <>
<p>A fresh position has 8 spice in Wind Pass, sector 14. You have surviving forces in that same sector, outside the storm. Collection uses the forces still on the board after battles.</p>
<div className={styles.columns}>
<div className={styles.controls}>
<label htmlFor="learn-collectors">Forces at the spice: {state.collectors}</label>
<input id="learn-collectors" type="range" min="0" max="6" step="1" value={state.collectors} onChange={e => update({ collectors: Number(e.target.value), collected: false })} />
<label className={styles.checkbox}><input type="checkbox" checked={state.city} onChange={e => update({ city: e.target.checked, collected: false })} />Also occupy Arrakeen with a separate force</label>
<button disabled={state.collected} onClick={() => update({ collected: true })}>Collect practice spice</button>
</div>
<div className={styles.result} aria-live="polite"><h3>{state.collected ? 'Collection complete' : 'Collection capacity'}</h3>
<p>Each ordinary force can collect {state.city ? 3 : 2} spice{state.city ? ' because you occupy Arrakeen' : ''}.</p>
<p>{state.collected ? 'You collected' : 'You can collect'} <strong>{collection.amount} spice</strong>. {collection.remaining} {state.collected ? 'remains' : 'would remain'} on the board.</p>
<p>Spice beyond your capacity stays for a later turn. In a real table this calculation is automatic.</p>
</div>
</div>
</>}
{state.step === 4 && <>
<p>You can revisit any example. This introduction covers the core resource and battle choices; it does not teach every faction, card, alliance, storm exception or expansion.</p>
<ul>
<li>Create a private room, choose a faction, and add friends or AI players. Share the room invitation.</li>
<li>Mark yourself ready after reviewing the selected rules. The host begins once all seats are ready.</li>
<li>Inspect your private Treachery hand and Traitors. Use the phase guide when a decision is unfamiliar.</li>
<li>Use <strong>Protect your saved seat</strong> and keep its recovery kit private. An invitation identifies the room; the kit restores your seat after cookie loss.</li>
</ul>
<p>Refresh restores a live game through its saved seat. If a request has an uncertain result, use the offered retry rather than repeating the game action.</p>
<div className={styles.links}><Link href="/">Create or join a game →</Link><Link href="/rules">Explore the full reference →</Link></div>
</>}
<footer className={styles.footer}>
<button disabled={state.step === 0} onClick={() => go(state.step - 1)}>Previous lesson</button>
<Link href={`/rules?topic=${lesson.topic}#${lesson.topic}`}>Read the related rules</Link>
{state.step < INTRODUCTION_STEPS.length - 1 && <button onClick={() => go(state.step + 1)}>Next lesson</button>}
</footer>
</section>
<output className={styles.saved}>{storageFailed ? 'Browser storage is unavailable. You can still practice, but a refresh may reset this lesson.' : 'Your lesson position and practice choices are saved in this browser.'}</output>
</>;
}
+1
View File
@@ -103,6 +103,7 @@ function RulesReferenceContent() {
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.{' '}
+18 -6
View File
@@ -6,13 +6,20 @@ gated.** Prototypes do not open completion or publication gates.
## Current checkpoint and work
Latest delivered checkpoint: `bfa24b0`, [optional table sounds](TABLE_SOUNDS.md).
Types, lint, **4,856 offline tests** and production build passed. Browser controls
verified mute, keyboard volume, refresh and cross-tab preferences; physical audio
and screenshot acceptance remain open. All 1,072 games and original seat/recovery
records were preserved, and the server remained healthy after the outage.
Latest delivered checkpoint: `9b67a78`, [early Sapho aggressor](SAPHO_AGGRESSOR.md).
Types, lint, **4,866 offline tests**, production build and **43 HTTP tests** passed.
Two genuine CHOAM/Richese samples completed; focused and browser evidence exercised
the new priority effect. After the next outage all 1,106 rooms were present, the
original baseline rows and latest QA save were unchanged, and a private seat
restored after browser refresh. The healthy server was reused without a reset.
Earlier evidence remains in the [implementation history](IMPLEMENTATION_STATUS.md).
Current work: the [interactive introduction](INTERACTIVE_INTRODUCTION.md) adds
five lessons with shared-rule shipping, sealed battle and collection exercises,
card inspection and browser-local continuation. Broader faction/expansion
teaching remains unfinished. Final verification and delivery belong to the
source-bound report and Git message.
The connected [Harass & Withdraw prototype](HARASS_WITHDRAW_RUNTIME.md) supports either
battle-card slot, private category inspections, exact undialed normal/elite
returns, opponent-Traitor cancellation, mandatory disposal, controls and all
@@ -23,7 +30,7 @@ card-specific discard precedence are explicitly labeled inferences. The Stone
Burner timing question is pending; no user answer is assumed. Reinforcements
remains unfinished. This does not open variant, mode or publication gates.
Current checkpoint: [early Sapho aggressor](SAPHO_AGGRESSOR.md) connects the
The preceding [early Sapho aggressor](SAPHO_AGGRESSOR.md) connects the
printed battle-priority effect during the existing shared pre-plan opportunity.
Physical attacker/defender/chooser stay stable; Habbanya takes precedence in either
slot. Shared priority feeds Stone admission, ordinary/Stone resolution, public
@@ -38,6 +45,11 @@ cannot safely depend on hidden custody or alter the printed deck. Mirror remains
gated pending the existing disposal ruling and copied-choice work. Continue with
independent missing faction/card functions, then integrated play and refinement.
The Smuggler follow-up review supports a saved reveal-time receipt with settlement
only on leader survival, but located no official ruling on modified leader
strength. The user has been asked about Kwisatz/trainer/Stunner composition; no
answer is assumed. See the [source follow-up](LEADER_SKILLS_RULES.md#20-september-smuggler-follow-up).
## Authoritative navigation
- [Rules checklist](../game/reference.ts), topic `implementation-checklist` and
+15
View File
@@ -1,5 +1,20 @@
# Dune implementation status
## 20 September 2026 — Interactive introduction prototype
[Five guided lessons](INTERACTIVE_INTRODUCTION.md) connect shipping budgets,
sealed battle plans and spice collection to shared production rule quotes, with
keyboard controls, card inspection and browser-local saved progress. The fixed
opponent is a teaching example, not an AI match. The wider faction, movement,
bidding, Advanced and expansion curriculum remains unfinished. Focused checks
cover combat outcomes and saved-choice validation; final browser/check/build,
preservation and Git evidence belong to the private source-bound report and
commit message. No mode or publication gate is opened.
Independent Smuggler review supports the deferred survival receipt but found no
official modifier-strength ruling. That single new question remains explicit in
the decision register; no pending answer was inferred to enable collection.
## 20 September 2026 — Sapho early battle aggressor
[Early aggressor](SAPHO_AGGRESSOR.md) connects the held physical card, existing
+44
View File
@@ -0,0 +1,44 @@
# Interactive introduction
20 September 2026. **Prototyped; partial teaching coverage.** `/learn` is linked
from the lobby and rules reference. It adds the previously missing guided
introduction alongside the reference's existing standalone practice wheel.
Five navigable lessons cover public/private table information and the phase
sequence, shipment budgets, sealed battle plans, spice collection and joining or
recovering a saved table. Three independent Basic practice positions let a player
change inputs, commit, inspect results and retry. These positions are deliberately
separate examples rather than a complete game or an authoritative live room.
The examples call `reserveShipmentCost`, `quoteBattleResolution` and
`quoteSpiceCollection`. Battle uses six ordinary forces, real Atreides/Guild
leaders and canonical base-deck cards. The fixed opponent's dial, leader and
projectile weapon appear only after the player's seal action. The resulting
weapon survival, scores, ordinary aggressor tie, force losses and bounty come
from the production combat quote. There are no Traitor calls or faction powers
in this teaching position. It is not an AI policy or hidden-information test of
the four live difficulty levels.
The existing wheel, keyboard controls and card inspectors remain available.
Lesson changes focus the heading; responsive columns stack on small screens.
Browser storage contains only a versioned, validated set of lesson choices under
`dune-introduction-v1`. It includes the sealed/revealed state. Unknown versions,
out-of-range values and impossible saved shipments fall back to the first lesson.
Unknown fields are dropped. Storage failure leaves the exercise usable in memory
and displays that refresh may reset it. Separate tabs may save different progress;
the last write is restored on the next visit. No multiplayer API or seat token is
used, and no server restart or database migration is required.
`tests/introduction.test.ts` checks unaffordable shipping, protected ties, wrong
defense and leader death, every offered dial/defense pair, collection capacity,
save round trips and malformed state. Browser interaction checks confirmed the
unaffordable-shipment explanation, a legal shipment, card inspection, a protected
8–8 tie, the wrong defense causing leader death, saved reveal after refresh,
city collection and lobby return/re-entry. At a 390px viewport the battle controls
and new lobby link stayed within the page bounds. Screenshot capture timed out,
so visual screenshot acceptance remains open; DOM bounds are narrower evidence.
Final source-bound reports and Git delivery record required check/build results.
This does not replace a full interactive curriculum. Movement paths, bidding,
Traitor calls, alliances, faction lessons, Advanced support and expansion lessons
remain future work. No rules mode, expansion or publication gate is opened.
+23
View File
@@ -184,6 +184,29 @@ designer expressly presents the latter as useful to Fremen, but does not
publish a general stacking order for every expansion movement modifier
([designer example, 19:13](https://www.youtube.com/watch?v=XT_azRVLq_0&t=1153s)).
## 20 September Smuggler follow-up
A bounded renewed check of the GF9 CHOAM & Richese rules/Q&A, the
[core FAQ](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf),
Future Pastimes expansion page and Jack Reda walkthrough found no ruling defining
Smuggler's leader strength with Kwisatz, another face-up trainer or Stunner. The
walkthrough at 12:19–12:46 repeats reveal timing and survival with an unmodified
strength-five example. It does not settle those modifiers. A user question now
asks whether to keep those combinations gated, use printed/copied disc value,
or include leader bonuses with a stunned leader counted as zero. No answer is
assumed; this is not a publisher ruling.
The prior pending-receipt contract remains viable: bind the original battle,
controller, leader and spice pile at reveal, then transfer only after survival is
known. The printed card has no victory requirement; the designer's winning
example does not add one. A captive's lower effect belongs to its controller.
Resolving this reveal-time collection logically before Sandmaster's after-win
addition is an explicit timing inference. A drained pile leaves no existing spice
for Sandmaster to augment. Multiple-pile allocation remains unresolved. Pending
spice is not spendable personal custody; this provisional timing must remain
explicit when the collection function is connected. Runtime collection is still
missing; the independent introduction proceeded while the modifier answer waits.
## Implementation readiness contract
A bounded prototype can now use the exact fourteen-card inventory, modified
+1
View File
@@ -57,6 +57,7 @@ These are unresolved in the cited records. Do not infer an answer from elapsed t
| Mirror Weapon | Whether copied Tooth/Artillery mandatory-discard rules attach to the physical Mirror remains unanswered. Every genuine Richese setup includes those cards and Stone Burner; an ordinary-copy-only admission gate cannot depend on concealed custody or alter the printed deck. | [Engine audit](MIRROR_WEAPON_ENGINE_AUDIT.md) |
| Semuta Drug | Full-hand take-before-discard and the privacy-neutral immediate reaction protocol remain unresolved. Typed discard continuations already exist; they are not activation. | [Source update](SEMUTA_SOURCE_UPDATE_20260907.md), [privacy review](SEMUTA_PRIVACY_REVIEW.md) |
| Leader Skills edge cases | Capture/replacement eligibility, Banker escrow custody (user question sent), Smuggler survival settlement, Mentat empty-hand fallback (private historical observations persist after death), Diplomat lower retreat ordering, modified-defense inheritance and remaining simultaneous optional timing need resolution before those cases are enabled. These are recorded source gaps, not answered user rulings. | [Material boundaries](LEADER_SKILLS_RULES.md#material-boundaries-not-settled-by-the-sources), [Diplomat boundary](DIPLOMAT_DEFENSE.md#evidence-and-remaining-scope) |
| Smuggler collection strength | A fresh primary-source check found no modifier ruling. The user was asked whether to keep affected combinations gated, use printed/copied disc value, or include leader bonuses with Stunner zero. No answer is assumed. The reveal-bound deferred receipt is supported as an explicit timing inference; lower collection remains unimplemented. | [Smuggler follow-up](LEADER_SKILLS_RULES.md#20-september-smuggler-follow-up) |
| Suk Graduate / Atreides | Whether rescued dialed casualties count toward the seven-loss Kwisatz Haderach threshold remains unresolved. The user question has been sent; Advanced Atreides rescue stays guarded. | [Source and runtime boundary](SUK_GRADUATE_RULES.md) |
| Ordinary Guild cancellation | Unaffordable repricing, replacement/retry/decline, consumed opportunity, contributor adjustment and ordering with special shipment-stop. The affordability question was already sent. | [Material timing](GUILD_RATE_KARAMA_RULES.md#material-unresolved-timing) |
| Base special Karama | Unfunded auction recovery, stopped-shipment settlement and Atreides full-plan commitment order remain provisional. Native full-plan/Voice/Prescience/Truthtrance/Ghola interactions now have genuine-setup focused evidence, but the source audit does not settle the special offer/deadline or target-first interpretation. | [Explicit base interpretations](BASE_ADVANCED_READINESS_20260907.md#explicit-unresolved-base-interpretations), [full-plan interaction audit](ATREIDES_FULL_PLAN_TIMING.md) |
+82
View File
@@ -0,0 +1,82 @@
import { baseDeck, leaders } from './cards';
import { reserveShipmentCost } from './shipment-price';
import { quoteBattleResolution, type ResolutionCombatant } from './battle-resolution-quote';
import { quoteSpiceCollection } from './board-resolution-quote';
/** Fixed teaching examples, never a room, seat or multiplayer save. */
export const INTRODUCTION_STORAGE_KEY = 'dune-introduction-v1';
export const INTRODUCTION_STEPS = [
{ title: 'Your place at the table', topic: 'setup' },
{ title: 'Ship within your budget', topic: 'movement' },
{ title: 'Seal a battle plan', topic: 'battle' },
{ title: 'Collect the spice', topic: 'collection' },
{ title: 'Join a table', topic: 'privacy' },
] as const;
export type IntroductionState = {
version: 1;
step: number;
shipment: number;
destination: 'stronghold' | 'sand';
shipped: boolean;
dial: number;
defense: 'shield' | 'snooper';
revealed: boolean;
collectors: number;
city: boolean;
collected: boolean;
};
export function newIntroduction(): IntroductionState {
return { version: 1, step: 0, shipment: 4, destination: 'sand', shipped: false,
dial: 2, defense: 'shield', revealed: false, collectors: 3, city: false, collected: false };
}
const integer = (value: unknown, min: number, max: number): value is number =>
Number.isSafeInteger(value) && (value as number) >= min && (value as number) <= max;
export function restoreIntroduction(raw: string | null): IntroductionState | null {
if (!raw) return null;
try {
const s = JSON.parse(raw) as IntroductionState;
if (!s || s.version !== 1 || !integer(s.step, 0, INTRODUCTION_STEPS.length - 1) ||
!integer(s.shipment, 1, 6) || !['stronghold', 'sand'].includes(s.destination) ||
!integer(s.dial, 0, 6) || !['shield', 'snooper'].includes(s.defense) ||
!integer(s.collectors, 0, 6) ||
![s.shipped, s.revealed, s.city, s.collected].every(v => typeof v === 'boolean') ||
(s.shipped && introductionShipment(s).cost > 8)) return null;
// Whitelist fields; never retain credentials or arbitrary imported properties.
return { version: 1, step: s.step, shipment: s.shipment, destination: s.destination,
shipped: s.shipped, dial: s.dial, defense: s.defense, revealed: s.revealed,
collectors: s.collectors, city: s.city, collected: s.collected };
} catch { return null; }
}
export function introductionShipment(s: Pick<IntroductionState, 'shipment' | 'destination'>) {
const cost = reserveShipmentCost({ faction: 'atreides', halfRate: false }, s.destination, s.shipment);
return { cost, remaining: 8 - cost, affordable: cost <= 8 };
}
export const INTRODUCTION_CARDS = {
shield: baseDeck().find(c => c.kind === 'shield')!,
snooper: baseDeck().find(c => c.kind === 'snooper')!,
weapon: baseDeck().find(c => c.kind === 'projectile')!,
};
export const INTRODUCTION_LEADERS = { you: leaders('atreides')[0], opponent: leaders('guild')[0] };
export function introductionBattle(s: Pick<IntroductionState, 'dial' | 'defense'>) {
const side = (you: boolean): ResolutionCombatant => {
const leader = you ? INTRODUCTION_LEADERS.you : INTRODUCTION_LEADERS.opponent;
const card = you ? INTRODUCTION_CARDS[s.defense] : INTRODUCTION_CARDS.weapon;
return { id: you ? 'you' : 'opponent', faction: you ? 'atreides' : 'guild', spice: 0,
hand: [card], leader, plan: { dial: you ? s.dial : 3, support: 0, leader: leader.id,
weapon: you ? null : card.id, defense: you ? card.id : null },
forces: { normal: 6, elite: 0, eliteStrength: 1, freeSupport: true } };
};
return quoteBattleResolution({ advanced: false, turn: 2, territory: 'wind_pass',
attacker: side(true), defender: side(false),
voters: ['you', 'opponent'].map(id => ({ id, beneficiary: id, called: false, traitors: [] })),
participants: [{ id: 'you', faction: 'atreides' }, { id: 'opponent', faction: 'guild' }],
physicalCards: baseDeck(), pendingAuditorPresent: false, pendingRetentionPresent: false });
}
export function introductionCollection(s: Pick<IntroductionState, 'collectors' | 'city'>) {
const forces: Record<string, number> = { 'wind_pass:14': s.collectors };
if (s.city) forces['arrakeen:10'] = 1;
const quote = quoteSpiceCollection({ advanced: false, storm: 18, order: ['you'],
players: [{ id: 'you', faction: 'atreides', ally: null, forces, spice: 0 }],
spice: { 'wind_pass:14': 8 } });
return { amount: quote.receipts[0].collected, remaining: quote.spice['wind_pass:14'] };
}
+24
View File
@@ -201,6 +201,7 @@ export const RULE_TOPICS: RuleTopic[] = [
],
related: [
'setup',
'interactive-introduction',
'truthtrance-spice',
'ecaz-ambassadors',
'richese-cards',
@@ -222,6 +223,29 @@ export const RULE_TOPICS: RuleTopic[] = [
'advanced-combat',
],
},
{
id: 'interactive-introduction',
title: 'Interactive introduction',
category: 'Getting started',
coverage: 'Partial',
developmentStage: 'Prototyped',
summary: 'Practice shipping, sealed battle plans and spice collection before joining a table.',
steps: [
'Choose Learn to play in the lobby or at the top of this reference. Five lessons introduce table information, shipping budgets, battle choices, collection and saved-seat recovery.',
'The three practice positions are separate Basic examples. Adjust the forces or destination, seal a plan with a defense, and compare collection with and without Arrakeen. Calculations use the same rule quotes as live games.',
'The opponent uses a fixed teaching plan, revealed only after you seal yours. Try another plan to compare a Shield with a Snooper, or a low dial with the aggressor’s winning tie. This is an exercise, not an AI match.',
'Lesson progress stays in this browser, including a revealed plan. No room, multiplayer action or saved-seat credential is created or read. If browser storage is unavailable, practice still works for the current visit.',
'Faction lessons, Advanced combat, alliances, storm exceptions and expansions remain outside this first introduction. The full reference and phase guidance explain more; existing mode gates still apply.',
],
related: ['setup', 'movement', 'battle', 'collection', 'privacy', 'implementation-checklist'],
checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'Five lessons and three interactive Basic examples reuse production shipment, combat and collection quotes. The complete teaching curriculum remains unfinished.', evidence: ['game/introduction.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Lesson navigation, keyboard force sliders and battle wheel, card inspectors, seal/reveal, retry and browser-local continuation are connected.', evidence: ['components/introduction.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'A fixed opponent plan provides a reproducible teaching example. The introduction is not an AI game and does not demonstrate opponent strategy or calibration.' },
{ area: 'Documentation', status: 'Partial', detail: 'Internal links connect each lesson to existing rules; the setup and saved-seat guidance distinguish practice from a live multiplayer game.', evidence: ['docs/INTERACTIVE_INTRODUCTION.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Focused checks cover pricing, all offered plans, weapon survival, ties, losses, collection limits and saved-choice validation. This does not certify all rules or the complete learning journey.', evidence: ['tests/introduction.test.ts'] },
],
},
{
id: 'table-sounds',
title: 'Sound effects',
+58
View File
@@ -0,0 +1,58 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { INTRODUCTION_STEPS, introductionBattle, introductionCollection, introductionShipment, newIntroduction, restoreIntroduction } from '../game/introduction';
import { RULE_TOPICS } from '../game/reference';
void test('shipping practice applies ordinary per-force prices and exposes an unaffordable choice', () => {
assert.deepEqual(introductionShipment({ shipment: 4, destination: 'sand' }), { cost: 8, remaining: 0, affordable: true });
assert.deepEqual(introductionShipment({ shipment: 6, destination: 'sand' }), { cost: 12, remaining: -4, affordable: false });
assert.deepEqual(introductionShipment({ shipment: 6, destination: 'stronghold' }), { cost: 6, remaining: 2, affordable: true });
});
void test('battle practice resolves protected ties, losing dials and the wrong defense through actual combat', () => {
const tie = introductionBattle({ dial: 3, defense: 'shield' });
assert.equal(tie.winner, 'you');
assert.deepEqual(tie.scores, { attacker: 8, defender: 8 });
assert.deepEqual(tie.leaderDeaths, { attacker: false, defender: false });
assert.equal(tie.basicWinnerLosses, 3);
assert.deepEqual(tie.destroyedArmies, ['opponent']);
const loss = introductionBattle({ dial: 2, defense: 'shield' });
assert.equal(loss.winner, 'opponent');
assert.deepEqual(loss.destroyedArmies, ['you']);
assert.equal(loss.basicWinnerLosses, 3);
const killed = introductionBattle({ dial: 6, defense: 'snooper' });
assert.equal(killed.winner, 'opponent');
assert.equal(killed.leaderDeaths.attacker, true);
assert.deepEqual(killed.scores, { attacker: 6, defender: 8 });
assert.deepEqual(killed.bounty, { player: 'opponent', amount: 5 });
assert.equal(killed.discarded.some(entry => entry.player === 'you'), true);
});
void test('every offered battle plan stays legal and normal with no support payment in Basic', () => {
for (let dial = 0; dial <= 6; dial++) for (const defense of ['shield', 'snooper'] as const) {
const result = introductionBattle({ dial, defense });
assert.equal(result.result, 'normal');
assert.ok(result.payments.every(payment => payment.cost === 0));
assert.equal(result.leaderDeaths.defender, false);
}
});
void test('collection practice caps actual income at the pile and applies the city rate', () => {
assert.deepEqual(introductionCollection({ collectors: 0, city: true }), { amount: 0, remaining: 8 });
assert.deepEqual(introductionCollection({ collectors: 3, city: false }), { amount: 6, remaining: 2 });
assert.deepEqual(introductionCollection({ collectors: 3, city: true }), { amount: 8, remaining: 0 });
assert.deepEqual(introductionCollection({ collectors: 6, city: false }), { amount: 8, remaining: 0 });
});
void test('saved practice restores sealed choices, rejects corrupt versions and strips unrelated data', () => {
const saved = { ...newIntroduction(), step: 2, dial: 3, revealed: true, shipped: true };
assert.deepEqual(restoreIntroduction(JSON.stringify(saved)), saved);
assert.deepEqual(restoreIntroduction(JSON.stringify({ ...saved, token: 'not-a-real-token', hand: ['private'] })), saved);
for (const raw of [null, '', '{', 'null', '[]', JSON.stringify({ ...saved, version: 2 }),
JSON.stringify({ ...saved, step: -1 }), JSON.stringify({ ...saved, step: 5 }),
JSON.stringify({ ...saved, defense: 'lasgun' }), JSON.stringify({ ...saved, dial: 2.5 }),
JSON.stringify({ ...saved, collectors: 99 }), JSON.stringify({ ...saved, revealed: 'true' }),
JSON.stringify({ ...saved, shipment: 6, destination: 'sand', shipped: true })]) {
assert.equal(restoreIntroduction(raw), null);
}
assert.deepEqual(introductionBattle(restoreIntroduction(JSON.stringify(saved))!), introductionBattle(saved));
});
void test('each introduction lesson links to an existing internal rule topic', () => {
for (const step of INTRODUCTION_STEPS) assert.ok(RULE_TOPICS.some(topic => topic.id === step.topic), step.topic);
});