Add Storm and Spice Blow practice

Expand the interactive introduction to thirteen lessons and eleven Basic examples. Separate storm sealing, reveal and movement; compare first-turn and later-turn ranges, wraparound, exposure and shelter. Offer four spice sequences for accumulation, storm loss, later worms and first-turn worm return, with Nexus opening only after the replacement blow finishes. Reuse existing card inspection and preserve all previous lesson identities and results through version 6 migration.

Extract unchanged storm-sector, exposure, worm-protection and spice-placement calculations into a focused module shared by engine, preflight and teaching. Existing battle-wheel defaults stay intact while storm practice supplies its minimum and accessible label. No live state schema, rules interpretation, mode gate or AI strategy changes.

All 4954 offline tests, types, lint and production build pass on stable final source. Focused tests compare all 24 offered storm dials and four spice cases with actual engine actions, verify exact ignored-worm return, and exercise every saved stage and v1–v5 migration. Independent review found no material findings. Earlier broad runs passed but were invalidated by a final rules-copy clarification and migration documentation correction; final3 verifies the stable source.

Browser checks retain the earlier Fremen revival outcome after migration, restore a sealed storm and completed Nexus across refresh, verify wrap/shelter/zero movement, exercise all four spice cases and inspect the revealed worm. Mobile controls and modal fit 390px; Escape restores the trigger. Screenshot capture remains unavailable. Full faction, Fremen-ride, Advanced/expansion teaching and complete game acceptance remain open.

All 1373 rooms, 2883 seats and auxiliary rows remain unchanged; SQLite integrity and foreign keys pass. Server reused without restart. HTTP tests not repeated because HTTP, auth, persistence and state shape are unchanged.

Private evidence: 20260921-opening-phase-practice.
Verified source tree: 6bd333ec6938904b31523f8b20042e516ba55b6ac4c63973e5bb7ee74baa71fb
This commit is contained in:
Quintine committed 2026-09-21 09:24:51 +10:00
1 parent 66c41b2af5
commit b2fcef8d51
19 files changed
+450 -95

No files matched your search

+1 -1
View File
@@ -25,7 +25,7 @@ Until that stage, AI work focuses on minimal legal participation and critical
playability fixes. playability fixes.
Choose **Learn to play** for the [interactive introduction](docs/INTERACTIVE_INTRODUCTION.md) Choose **Learn to play** for the [interactive introduction](docs/INTERACTIVE_INTRODUCTION.md)
at `/learn`. Practice alliances, charity, bidding, revival, shipping, movement, battle plans, Traitor calls and collection before entering a at `/learn`. Practice storm dials, spice blows, alliances, charity, bidding, revival, shipping, movement, battle plans, Traitor calls and collection before entering a
room; lesson progress stays in your browser and does not change saved games. 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. 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.
+12 -8
View File
@@ -7,6 +7,8 @@ export type BattleWheelProps = {
value: number; value: number;
onChange: (value: number) => void; onChange: (value: number) => void;
max: number; max: number;
min?: number;
sliderLabel?: string;
step: number; step: number;
disabled?: boolean; disabled?: boolean;
id?: string; id?: string;
@@ -22,6 +24,8 @@ export function BattleWheel({
value, value,
onChange, onChange,
max, max,
min = 0,
sliderLabel = 'Forces dialed slider',
step, step,
disabled = false, disabled = false,
id = 'forces-dialed', id = 'forces-dialed',
@@ -29,14 +33,14 @@ export function BattleWheel({
const graphicId = useId(); const graphicId = useId();
const rimId = `${graphicId}-rim`; const rimId = `${graphicId}-rim`;
const faceId = `${graphicId}-face`; const faceId = `${graphicId}-face`;
const fraction = max > 0 ? Math.min(1, Math.max(0, value / max)) : 0; const fraction = max > min ? Math.min(1, Math.max(0, (value - min) / (max - min))) : 0;
const angle = -135 + fraction * 270; const angle = -135 + fraction * 270;
// This caps decorative detail only; the native controls keep the supplied step. // This caps decorative detail only; the native controls keep the supplied step.
const divisions = Math.min(80, Math.max(1, Math.ceil(max / step))); const divisions = Math.min(80, Math.max(1, Math.ceil((max - min) / step)));
const labels = [ const labels = [
...new Set( ...new Set(
[0, 0.25, 0.5, 0.75, 1].map((position) => [0, 0.25, 0.5, 0.75, 1].map((position) =>
Math.min(max, Math.round((max * position) / step) * step), Math.min(max, min + Math.round(((max - min) * position) / step) * step),
), ),
), ),
]; ];
@@ -91,7 +95,7 @@ export function BattleWheel({
); );
})} })}
{labels.map((mark) => { {labels.map((mark) => {
const label = point(-135 + (max > 0 ? mark / max : 0) * 270, 62); const label = point(-135 + (max > min ? (mark - min) / (max - min) : 0) * 270, 62);
return ( return (
<text <text
key={mark} key={mark}
@@ -156,16 +160,16 @@ export function BattleWheel({
<input <input
className={styles.range} className={styles.range}
type="range" type="range"
min={0} min={min}
max={max} max={max}
step={step} step={step}
value={value} value={value}
disabled={disabled} disabled={disabled}
aria-label="Forces dialed slider" aria-label={sliderLabel}
onChange={(event) => onChange(Number(event.target.value))} onChange={(event) => onChange(Number(event.target.value))}
/> />
<div className={styles.bounds} aria-hidden="true"> <div className={styles.bounds} aria-hidden="true">
<span>0</span> <span>{min}</span>
<span>{max}</span> <span>{max}</span>
</div> </div>
</div> </div>
@@ -173,7 +177,7 @@ export function BattleWheel({
className={styles.number} className={styles.number}
id={id} id={id}
type="number" type="number"
min={0} min={min}
max={max} max={max}
step={step} step={step}
value={value} value={value}
+84
View File
@@ -0,0 +1,84 @@
import type { IntroductionState } from '@/game/introduction';
import { introductionStorm, introductionSpiceBlow, PRACTICE_PREVIOUS_BLOW } from '@/game/introduction-opening';
import { splitLocation, territory } from '@/game/board';
import { BattleWheel } from './battle-wheel';
import { SpiceCardInspector, spiceCardTitle } from './spice-card-inspector';
import styles from './introduction.module.css';
type Props = { state: IntroductionState; update: (patch: Partial<IntroductionState>) => void };
const spicePlaces = (spice: Record<string, number>) => Object.entries(spice).map(([key, amount]) =>
`${territory(splitLocation(key).territory).name}, sector ${splitLocation(key).sector}: ${amount} spice`).join('; ') || 'No spice on the board';
export function StormLesson({ state, update }: Props) {
const result = introductionStorm(state);
const first = state.stormExample === 'first';
return <>
<p>Two players secretly dial the storm’s distance on their Battle Wheels. On the first turn, the players nearest
the Storm Start dial 0–20. On later Basic turns, the last two players to use the wheels in battle dial 1–3;
if no battle occurred, the previous pair stays. This example has no Weather Control or other reacting powers.</p>
<div className={styles.columns}>
<div className={styles.controls}>
<label htmlFor="learn-storm-example">Storm example</label>
<select id="learn-storm-example" value={state.stormExample} onChange={e => update({ stormExample: e.target.value as IntroductionState['stormExample'], stormDial: 2, stormStage: 'draft' })}>
<option value="later">Later Basic turn · exposed and sheltered forces</option>
<option value="first">First turn · starting Atreides forces</option>
</select>
<label htmlFor="learn-storm-dial">Your storm dial</label>
<div className={styles.wheel}><BattleWheel id="learn-storm-dial" sliderLabel="Storm distance slider" value={state.stormDial} min={first ? 0 : 1} max={first ? 20 : 3} step={1}
disabled={state.stormStage !== 'draft'} onChange={stormDial => { if (Number.isInteger(stormDial) && stormDial >= (first ? 0 : 1) && stormDial <= (first ? 20 : 3)) update({ stormDial }); }} /></div>
<button disabled={state.stormStage !== 'draft'} onClick={() => update({ stormStage: 'sealed' })}>Seal practice storm dial</button>
<button disabled={state.stormStage !== 'sealed'} onClick={() => update({ stormStage: 'revealed' })}>Reveal both practice dials</button>
<button disabled={state.stormStage !== 'revealed'} onClick={() => update({ stormStage: 'resolved' })}>Move practice storm</button>
<button onClick={() => update({ stormStage: 'draft' })}>Reset storm example</button>
</div>
<div className={styles.result} aria-live="polite">
<h3>{state.stormStage === 'resolved' ? 'Storm movement complete' : state.stormStage === 'revealed' ? 'Both dials revealed' : state.stormStage === 'sealed' ? 'Your dial is sealed' : 'Prepare a secret dial'}</h3>
<p>The storm is at sector <strong>{result.sector}</strong>.</p>
{result.distance === null ? <p>The other dial stays hidden. Sealing does not move the storm.</p> : <p>Your {state.stormDial} plus the other player’s {result.otherDial} makes {result.distance} sectors counterclockwise.</p>}
{state.stormStage === 'revealed' && <p>At a live table, players may use eligible storm cards before movement. This example has none.</p>}
{state.stormStage === 'resolved' && <p>{result.crossed.length ? `Crossed sectors: ${result.crossed.join(' → ')}.` : 'Zero movement crosses no sector.'}</p>}
<ul>{result.locations.map(place => <li key={place.key}>{place.name}, sector {place.sector}: {place.after} forces{state.stormStage === 'resolved' ? ` (${place.before - place.after} lost)` : ''} · {place.sheltered ? 'sheltered' : 'exposed sand'}</li>)}</ul>
<p>Your reserves: {result.reserves}. Your Tanks: {result.tanks}.</p>
<p>{spicePlaces(result.spice)}.</p>
</div>
</div>
<p>Storm crosses every intervening sector and wraps from 18 to 1. Exposed forces go to the Tanks and crossed spice disappears.
Rock, strongholds and Polar Sink shelter forces; the intact Shield Wall also shelters Imperial Basin.
The first example starts with sheltered Atreides forces. Later examples are separate prepared positions.</p>
</>;
}
export function SpiceBlowLesson({ state, update }: Props) {
const result = introductionSpiceBlow(state);
return <>
<p>A territory card places its printed spice in one sector. When the preceding eligible discard is a territory card,
Shai-Hulud affects that whole territory, then drawing continues to a new territory card. In these separate examples, no Fremen forces,
allies, Harvester or other reacting powers are present.</p>
<div className={styles.columns}>
<div className={styles.controls}>
<label htmlFor="learn-blow-example">Spice Blow example</label>
<select id="learn-blow-example" value={state.blowExample} onChange={e => update({ blowExample: e.target.value as IntroductionState['blowExample'], blowStage: 'draft' })}>
<option value="clear">Add spice to an existing pile</option><option value="storm">The card’s sector is in storm</option>
<option value="worm">Shai-Hulud in a later turn</option><option value="first-worm">Ignore Shai-Hulud on turn one</option>
</select>
<p>Turn {result.turn} · storm sector {result.storm}.</p>
{result.turn > 1 && <><p>Previous territory card: {spiceCardTitle(PRACTICE_PREVIOUS_BLOW)}. Three Emperor forces occupy that territory.</p><SpiceCardInspector card={PRACTICE_PREVIOUS_BLOW} context="Practice previous discard" /></>}
<button disabled={state.blowStage !== 'draft'} onClick={() => update({ blowStage: 'revealed' })}>Reveal practice spice sequence</button>
<button disabled={state.blowStage !== 'revealed'} onClick={() => update({ blowStage: 'settled' })}>{state.blowExample === 'worm' ? 'Finish blow and open Nexus' : 'Finish practice Spice Blow'}</button>
<button onClick={() => update({ blowStage: 'draft' })}>Reset Spice Blow example</button>
</div>
<div className={styles.result} aria-live="polite">
<h3>{state.blowStage === 'draft' ? 'The next draw is face down' : result.nexus ? 'Nexus: negotiate alliances' : state.blowStage === 'settled' ? 'Continue to CHOAM Charity' : 'Spice sequence revealed'}</h3>
{result.revealed.map((card, index) => <div key={index}><p>{index + 1}. {spiceCardTitle(card)}</p><SpiceCardInspector card={card} context="Practice revealed card" /></div>)}
<p>{spicePlaces(result.spice)}.</p>
<p>Emperor forces in The Great Flat: {result.forces}. Tanks: {result.tanks}. Reserves: {result.reserves}.</p>
{result.added !== null && <p>{result.added ? `${result.added} spice was added to Broken Land.` : 'Broken Land is in storm: its card adds no spice.'}</p>}
{state.blowExample === 'worm' && state.blowStage !== 'draft' && <p>Shai-Hulud consumed all six spice and three Emperor forces in The Great Flat. It did not attack the new Broken Land blow. {state.blowStage === 'settled' ? 'The replacement blow is finished and the Nexus is open.' : 'Finish the new blow before starting alliance negotiations.'}</p>}
{result.skippedWorm && <p>{state.blowStage === 'settled' ? 'The ignored worm is shuffled back after this first-turn blow.' : 'On turn one, set the worm aside and draw a replacement. It causes no destruction or Nexus.'}</p>}
{result.nexus && <p>The next lesson lets you practice an alliance offer, acceptance or withdrawal. Once negotiations finish, eligible Fremen may ride before Charity; this example has no Fremen to move.</p>}
</div>
</div>
<p>Ordinary worms consume forces and spice throughout the previous territory, across its sectors. Fremen normally
survive and may protect allies or ride, subject to their response windows. Advanced double blows and expansion cards have separate rules.</p>
</>;
}
+15 -12
View File
@@ -9,6 +9,7 @@ import { MovementLesson, TraitorLesson } from './introduction-practice';
import { BiddingLesson } from './introduction-bidding'; import { BiddingLesson } from './introduction-bidding';
import { AllianceLesson } from './introduction-alliance'; import { AllianceLesson } from './introduction-alliance';
import { CharityLesson, RevivalLesson } from './introduction-resources'; import { CharityLesson, RevivalLesson } from './introduction-resources';
import { StormLesson, SpiceBlowLesson } from './introduction-opening';
import { import {
INTRODUCTION_CARDS, INTRODUCTION_LEADERS, INTRODUCTION_STEPS, INTRODUCTION_STORAGE_KEY, INTRODUCTION_CARDS, INTRODUCTION_LEADERS, INTRODUCTION_STEPS, INTRODUCTION_STORAGE_KEY,
introductionBattle, introductionCollection, introductionShipment, newIntroduction, restoreIntroduction, introductionBattle, introductionCollection, introductionShipment, newIntroduction, restoreIntroduction,
@@ -27,10 +28,10 @@ export function Introduction() {
<Link href="/">← Return to Arrakis</Link> <Link href="/">← Return to Arrakis</Link>
<p className="eyebrow">LEARN TO PLAY · BASIC GAME</p> <p className="eyebrow">LEARN TO PLAY · BASIC GAME</p>
<h1>Your first steps on Arrakis</h1> <h1>Your first steps on Arrakis</h1>
<p>Practice the Basic phases, from alliances and resources to bidding, movement, battle and collection, then take a seat at a real table. These are separate examples; they do not change any saved game.</p> <p>Practice the Basic phases, from the storm and spice blow to alliances, resources, bidding, movement, battle and collection, then take a seat at a real table. These are separate examples; they do not change any saved game.</p>
</header> </header>
{/* Remount on schema changes so a development hot update also migrates old state. */} {/* Remount on schema changes so a development hot update also migrates old state. */}
{hydrated ? <IntroductionLessons key="v5" /> : <output>Restoring your lesson…</output>} {hydrated ? <IntroductionLessons key="v6" /> : <output>Restoring your lesson…</output>}
</main>; </main>;
} }
@@ -78,11 +79,13 @@ function IntroductionLessons() {
<ol className={styles.phases}>{PHASES.map(phase => <li key={phase}>{phase}</li>)}</ol> <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> <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 && <AllianceLesson state={state} update={update} />} {state.step === 1 && <StormLesson state={state} update={update} />}
{state.step === 2 && <CharityLesson state={state} update={update} />} {state.step === 2 && <SpiceBlowLesson state={state} update={update} />}
{state.step === 3 && <BiddingLesson state={state} update={update} />} {state.step === 3 && <AllianceLesson state={state} update={update} />}
{state.step === 4 && <RevivalLesson state={state} update={update} />} {state.step === 4 && <CharityLesson state={state} update={update} />}
{state.step === 5 && <> {state.step === 5 && <BiddingLesson state={state} update={update} />}
{state.step === 6 && <RevivalLesson state={state} update={update} />}
{state.step === 7 && <>
<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> <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.columns}>
<div className={styles.controls}> <div className={styles.controls}>
@@ -106,8 +109,8 @@ function IntroductionLessons() {
</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> <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 === 6 && <MovementLesson state={state} update={update} />} {state.step === 8 && <MovementLesson state={state} update={update} />}
{state.step === 7 && <> {state.step === 9 && <>
<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> <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.columns}>
<div className={styles.controls}> <div className={styles.controls}>
@@ -135,8 +138,8 @@ function IntroductionLessons() {
</div> </div>
</div> </div>
</>} </>}
{state.step === 8 && <TraitorLesson state={state} update={update} />} {state.step === 10 && <TraitorLesson state={state} update={update} />}
{state.step === 9 && <> {state.step === 11 && <>
<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> <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.columns}>
<div className={styles.controls}> <div className={styles.controls}>
@@ -152,7 +155,7 @@ function IntroductionLessons() {
</div> </div>
</div> </div>
</>} </>}
{state.step === 10 && <> {state.step === 12 && <>
<p>You can revisit any example. This introduction covers ordinary alliances, bidding, core resources, movement and battle choices; it does not teach every faction, card, alliance power, storm exception or expansion.</p> <p>You can revisit any example. This introduction covers ordinary alliances, bidding, core resources, movement and battle choices; it does not teach every faction, card, alliance power, storm exception or expansion.</p>
<ul> <ul>
<li>Create a private room, choose a faction, and add friends or AI players. Share the room invitation.</li> <li>Create a private room, choose a faction, and add friends or AI players. Share the room invitation.</li>
+17 -19
View File
@@ -6,28 +6,26 @@ gated.** Prototypes do not open completion or publication gates.
## Current checkpoint and work ## Current checkpoint and work
Latest delivered checkpoint: `ed53eb7`, [Storm Card inspection](STORM_CARD_INSPECTION.md). Latest delivered checkpoint: `66c41b2`, [Kwisatz Haderach inspection](KWISATZ_INSPECTION.md).
Types, lint, **4,946 offline tests**, production build and **46 HTTP tests** passed. Types, lint, **4,950 offline tests** and production build passed. All 1,373 rooms,
All 1,337 original rooms, 2,805 original seats and existing auxiliary rows stayed 2,883 seats and existing auxiliary rows stayed unchanged. The running server was
unchanged; HTTP checks created their own additional records. The current baseline reused. Earlier checkpoints include `ed53eb7` for Storm Cards, `67e429e` for
contains 1,373 rooms and 2,883 seats. The running server was reused. Charity/Revival practice, `c4ff75a` for alliances, `b7ec940` for bidding,
Earlier delivered checkpoints include `67e429e` for Charity/Revival practice, `b9718ac` for lobby AI configuration and `b21b2bc` for one-use AI permission.
`c4ff75a` for alliances, `b7ec940` for bidding, `b9718ac` for lobby AI configuration The `8f7a62c` policy checkpoint removed the usage cutoff and deferred AI refinement
and `b21b2bc` for one-use AI permission. The `8f7a62c` policy checkpoint removed until feature completion, with three adjacent 75% targets. See the
the usage cutoff and deferred AI refinement until feature completion, with three [implementation history](IMPLEMENTATION_STATUS.md).
adjacent 75% targets. See the [implementation history](IMPLEMENTATION_STATUS.md).
Current work: [Kwisatz Haderach inspection](KWISATZ_INSPECTION.md) connects an Current work: [Storm and Spice Blow practice](INTERACTIVE_INTRODUCTION.md) expands
original companion face, private battle-loss progress and availability guidance, the introduction to thirteen lessons and eleven independent Basic examples.
authorized private/public plan components and a static internal reference. Sealed dials, reveal, sector traversal, shelter, spice accumulation/storm loss,
Rules calculations, sealed choices, save shape and mode gates remain unchanged. first-turn worm return and later-worm Nexus timing reuse shared live calculations.
Source-bound checks, independent review, browser and preservation/Git evidence Version 6 preserves old lesson identities and outcomes. Full faction, Fremen-ride,
belong to the private checkpoint. Full Advanced acceptance, exact manufactured Advanced and expansion teaching remain unfinished. Source-bound checks, review,
component verification and the remaining component inventory are unfinished. browser and preservation/Git evidence belong to the private checkpoint.
A bounded Duke review confirmed revival/loan paths still cross recorded custody, A bounded Duke review confirmed revival/loan paths still cross recorded custody,
return and cycle questions; no duplicate ruling or invented outcome was added. return and cycle questions; no duplicate ruling or invented outcome was added.
Storm/spice-blow teaching, full faction/Advanced/expansion teaching and broader Broader multiplayer and component 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
still cross recorded pending rulings. Continue independent features rather than still cross recorded pending rulings. Continue independent features rather than
+13
View File
@@ -1,5 +1,18 @@
# Dune implementation status # Dune implementation status
## 21 September 2026 — Storm and Spice Blow practice
The [interactive introduction](INTERACTIVE_INTRODUCTION.md) now has thirteen
lessons and eleven independent Basic examples. Storm separates sealed dials,
reveal and movement, using both first-turn and later-turn ranges. Spice Blow
compares accumulation, storm loss, later Shai-Hulud and first-turn worm return,
with Nexus beginning after the replacement blow. Shared live calculations keep
exposure and placement consistent; no rules or save-schema change. Version 6
preserves all prior lesson identities and results. Focused engine comparisons,
independent review and source-bound check/build/browser/preservation/Git evidence
belong to the private checkpoint. Full faction, worm-ride and expansion teaching
and complete game acceptance remain open.
## 21 September 2026 — Kwisatz Haderach component inspection ## 21 September 2026 — Kwisatz Haderach component inspection
The [companion inspector](KWISATZ_INSPECTION.md) connects original artwork, private The [companion inspector](KWISATZ_INSPECTION.md) connects original artwork, private
+41 -7
View File
@@ -4,12 +4,46 @@
from the lobby and rules reference. It adds the previously missing guided from the lobby and rules reference. It adds the previously missing guided
introduction alongside the reference's existing standalone practice wheel. introduction alongside the reference's existing standalone practice wheel.
Eleven navigable lessons cover public/private table information and the phase Thirteen navigable lessons cover public/private table information and the phase
sequence, ordinary Nexus alliances, charity, bidding, revival, shipment budgets, movement, sealed battle plans, Traitor calls, spice sequence, Storm, Spice Blow, ordinary Nexus alliances, charity, bidding, revival, shipment budgets, movement, sealed battle plans, Traitor calls, spice
collection and joining or recovering a saved table. Nine independent Basic practice positions let a player collection and joining or recovering a saved table. Eleven independent Basic practice positions let a player
change inputs, commit, inspect results and retry. These positions are deliberately change inputs, commit, inspect results and retry. These positions are deliberately
separate examples rather than a complete game or an authoritative live room. separate examples rather than a complete game or an authoritative live room.
## Storm and Spice Blow practice
The Storm exercise separates draft, sealed, publicly revealed and moved stages.
The first-turn example uses the printed Storm Start, 0–20 dials and sheltered
Atreides starting forces. The later Basic position starts at sector 16, combines
the learner's 1–3 with a fixed opposing three, and compares exposed forces in
Cielago South/North with rock and stronghold shelter across the sector-18 wrap.
Shared sector/exposure calculations supply the result; changing options resets
only this example. The Battle Wheel now accepts an optional minimum and slider
label; existing combat defaults remain zero and “Forces dialed slider”.
Spice Blow offers four deterministic sequences: add eight Broken Land spice to
three already there; lose the card's spice to its storm sector; resolve one later
Shai-Hulud at the previous Great Flat territory then draw Broken Land; or ignore
and set aside a first-turn worm until the completed blow shuffles it back.
The normal examples have three Emperor forces in The Great Flat. A later worm
destroys those forces and all six spice there, then the replacement blow opens.
Finishing that window opens Nexus negotiations. There are no Fremen forces or
reacting cards in these examples; interactive riding, consecutive worms and
Advanced/expansion teaching remain separate unfinished curriculum work.
`game/disaster-rules.ts` extracts the unchanged live storm-sector, territory
exposure, worm-protection and spice-placement expressions for shared teaching.
The engine and disaster preflight reuse them. No state schema or timing changes.
The lessons compute results from validated choices; they never load live rooms.
Tests compare every offered storm dial and spice scenario against real actions,
check exact first-turn worm return, preserve all eleven v5 lesson identities and
validate every saved stage. The existing v1–v4 migrations remain covered.
Authority remains the classic GF9 base rulebook, printed pp.4 and 6–7, and
November 2020 FAQ p.1 as recorded in `tests/first-storm.test.ts` and the current
engine contracts. This checkpoint changes teaching and factors calculations;
it does not claim a new rules interpretation or complete rules certification.
The examples call `reserveShipmentCost`, `quoteBattleResolution`, The examples call `reserveShipmentCost`, `quoteBattleResolution`,
`quoteSpiceCollection` and the shared board distance/occupancy helpers. Battle uses six ordinary forces, real Atreides/Guild `quoteSpiceCollection` and the shared board distance/occupancy helpers. Battle uses six ordinary forces, real Atreides/Guild
leaders and canonical base-deck cards. The fixed opponent's dial, leader and leaders and canonical base-deck cards. The fixed opponent's dial, leader and
@@ -96,7 +130,7 @@ Resource regressions compare ordinary claims/returns against actual engine
actions, preserve inputs, check cumulative spending and allowances, reject actions, preserve inputs, check cumulative spending and allowances, reject
impossible saves and retain all nine v4 lesson identities and prior commitments. impossible saves and retain all nine v4 lesson identities and prior commitments.
Final source-bound browser/check/build/preservation and review evidence belongs Final source-bound browser/check/build/preservation and review evidence belongs
to this checkpoint's private report and Git delivery. Storm/spice-blow hazards, to this checkpoint's private report and Git delivery. Broader storm/spice-blow exceptions,
full faction/Advanced/expansion teaching and the complete curriculum remain open. full faction/Advanced/expansion teaching and the complete curriculum remain open.
The movement position starts with five ordinary forces in Red Chasm. It compares The movement position starts with five ordinary forces in Red Chasm. It compares
@@ -110,9 +144,9 @@ actual authoritative `applyAction` transitions; no live movement rule changed.
The existing wheel, keyboard controls and card inspectors remain available. The existing wheel, keyboard controls and card inspectors remain available.
Lesson changes focus the heading; responsive columns stack on small screens. Lesson changes focus the heading; responsive columns stack on small screens.
Browser storage contains only a versioned, validated set of lesson choices under Browser storage contains only a versioned, validated set of lesson choices under
`dune-introduction-v1`. Version 5 payloads explicitly migrate all five version 1, `dune-introduction-v1`. Version 6 payloads explicitly migrate all five version 1,
seven version 2, eight version 3 and nine version 4 lesson positions to the same lesson after insertion, seven version 2, eight version 3, nine version 4 and eleven version 5 lesson positions to the same lesson after insertion,
preserving prior choices and committed outcomes. New resource fields start at defaults for every older version. Versions 1–3 preserving prior choices and committed outcomes. All older versions receive fresh opening-phase defaults. Resource fields start at defaults for versions 1–4; version 5 keeps its resource history. Versions 1–3
receive alliance defaults. receive alliance defaults.
Versions 1 and 2 receive bidding defaults; version 1 also receives movement/Traitor defaults. Hot updates Versions 1 and 2 receive bidding defaults; version 1 also receives movement/Traitor defaults. Hot updates
remount the lesson state to apply this migration. Only the player's bid/pass remount the lesson state to apply this migration. Only the player's bid/pass
+5 -9
View File
@@ -1,3 +1,4 @@
import { stormExposesTerritory, stormSectorAfter, wormConsumesForces } from './disaster-rules';
import type { Game, Player, ResponseWindow } from './engine'; import type { Game, Player, ResponseWindow } from './engine';
import { splitLocation, territory, validLocation } from './board'; import { splitLocation, territory, validLocation } from './board';
import { SPICE_CARDS } from './cards'; import { SPICE_CARDS } from './cards';
@@ -22,12 +23,7 @@ function boardKey(key: string) {
return loc; return loc;
} }
function exposed(g: Game, key: string) { function exposed(g: Game, key: string) {
const t = territory(boardKey(key).territory); return stormExposesTerritory(boardKey(key).territory, g.shieldWallDestroyed);
return (
(t.type === 'sand' && t.id !== 'imperial_basin') ||
(!!g.shieldWallDestroyed &&
['arrakeen', 'carthag', 'imperial_basin'].includes(t.id))
);
} }
/** Validate only physical groups that this disaster can destroy. Concealed /** Validate only physical groups that this disaster can destroy. Concealed
* materialization is quoted without allocating its actual reveal event. */ * materialization is quoted without allocating its actual reveal event. */
@@ -104,7 +100,7 @@ export function validateStormTraversal(g: Game) {
new Set(r.pending).size === r.pending.length, new Set(r.pending).size === r.pending.length,
'The pending storm needs a valid, finite traversal.', 'The pending storm needs a valid, finite traversal.',
); );
const current = ((r.from - 1 + r.traversed) % 18) + 1; const current = stormSectorAfter(r.from, r.traversed);
requireDisaster( requireDisaster(
r.pending.every( r.pending.every(
(key) => (key) =>
@@ -115,7 +111,7 @@ export function validateStormTraversal(g: Game) {
const crossed = new Set( const crossed = new Set(
Array.from( Array.from(
{ length: r.distance - r.traversed }, { length: r.distance - r.traversed },
(_, i) => ((r.from + r.traversed + i) % 18) + 1, (_, i) => stormSectorAfter(r.from, r.traversed + i + 1),
), ),
); );
for (const p of g.players) for (const p of g.players)
@@ -185,7 +181,7 @@ export function validateWormDevouring(
) { ) {
requireDisaster(validTerritory(t), 'The worm needs a valid territory.'); requireDisaster(validTerritory(t), 'The worm needs a valid territory.');
for (const p of g.players) for (const p of g.players)
if ((!protectFremen || p.faction !== 'fremen') && p.id !== protectedAlly) if (wormConsumesForces(p, protectedAlly, protectFremen))
casualtyCustody( casualtyCustody(
g, g,
p, p,
+25
View File
@@ -0,0 +1,25 @@
import { location, territory } from './board';
import type { FactionId } from './catalog';
/** Sector zero remains supported for legacy storm continuations. Callers validate ranges. */
export function stormSectorAfter(from: number, distance: number) {
return ((from - 1 + distance) % 18) + 1;
}
export function stormExposesTerritory(id: string, shieldWallDestroyed = false) {
const t = territory(id);
return (t.type === 'sand' && t.id !== 'imperial_basin') ||
(shieldWallDestroyed && ['arrakeen', 'carthag', 'imperial_basin'].includes(t.id));
}
export function wormConsumesForces(player: { id: string; faction: FactionId }, protectedAlly?: string, protectFremen = true) {
return (!protectFremen || player.faction !== 'fremen') && player.id !== protectedAlly;
}
/** A territory card adds to existing spice unless its printed sector is under storm. */
export function quoteSpicePlacement(spice: Readonly<Record<string, number>>, storm: number,
card: Readonly<{ territory: string; sector: number; amount: number }>) {
const key = location(card.territory, card.sector);
const added = card.sector === storm ? 0 : card.amount;
return { key, added, total: (spice[key] ?? 0) + added, blocked: card.sector === storm };
}
+10 -18
View File
@@ -1,3 +1,4 @@
import { quoteSpicePlacement, stormExposesTerritory, stormSectorAfter, wormConsumesForces } from './disaster-rules';
import { isStormCardDistance, type StormCardComponent } from './storm-cards'; import { isStormCardDistance, type StormCardComponent } from './storm-cards';
import { createLeaderSkills, validateLeaderSkills, dealLeaderSkills, chooseLeaderSkill, returnDeadLeaderSkills, offerRevivedLeaderSkill, drawRevivedLeaderSkills, declineRevivedLeaderSkill, LeaderSkillError, type LeaderSkillsState, type LeaderSkillsView } from './leader-skills'; import { createLeaderSkills, validateLeaderSkills, dealLeaderSkills, chooseLeaderSkill, returnDeadLeaderSkills, offerRevivedLeaderSkill, drawRevivedLeaderSkills, declineRevivedLeaderSkill, LeaderSkillError, type LeaderSkillsState, type LeaderSkillsView } from './leader-skills';
import { leaderSkillCard, type LeaderSkillId } from './leader-skill-cards'; import { leaderSkillCard, type LeaderSkillId } from './leader-skill-cards';
@@ -6082,12 +6083,7 @@ function stormOrder(g: Game) {
.sort((a, b) => distance(a) - distance(b)); .sort((a, b) => distance(a) - distance(b));
} }
function stormExposed(g: Game, key: string) { function stormExposed(g: Game, key: string) {
const t = territory(splitLocation(key).territory); return stormExposesTerritory(splitLocation(key).territory, g.shieldWallDestroyed);
return (
(t.type === 'sand' && t.id !== 'imperial_basin') ||
(g.shieldWallDestroyed &&
['arrakeen', 'carthag', 'imperial_basin'].includes(t.id))
);
} }
function moveStorm(g: Game, n: number) { function moveStorm(g: Game, n: number) {
g.stormResolution = { from: g.storm, distance: n, traversed: 0, pending: [], g.stormResolution = { from: g.storm, distance: n, traversed: 0, pending: [],
@@ -6186,7 +6182,7 @@ function continueStorm(g: Game) {
} }
if (resolution.traversed === resolution.distance) break; if (resolution.traversed === resolution.distance) break;
resolution.traversed++; resolution.traversed++;
const s = ((resolution.from - 1 + resolution.traversed) % 18) + 1; const s = stormSectorAfter(resolution.from, resolution.traversed);
if (g.shieldWallDestroyed) if (g.shieldWallDestroyed)
for (const city of ['arrakeen', 'carthag']) for (const city of ['arrakeen', 'carthag'])
if (territory(city).sectors.includes(s)) if (territory(city).sectors.includes(s))
@@ -6196,9 +6192,7 @@ function continueStorm(g: Game) {
if (marker && marker.sector === s) { if (marker && marker.sector === s) {
const t = territory(marker.territory); const t = territory(marker.territory);
if ( if (
(t.type === 'sand' && t.id !== 'imperial_basin') || stormExposesTerritory(t.id, g.shieldWallDestroyed)
(g.shieldWallDestroyed &&
['arrakeen', 'carthag', 'imperial_basin'].includes(t.id))
) )
revealPlayerNoField(g, p, 'storm'); revealPlayerNoField(g, p, 'storm');
} }
@@ -6206,9 +6200,7 @@ function continueStorm(g: Game) {
const loc = splitLocation(key), const loc = splitLocation(key),
t = territory(loc.territory); t = territory(loc.territory);
const exposed = const exposed =
(t.type === 'sand' && t.id !== 'imperial_basin') || stormExposesTerritory(t.id, g.shieldWallDestroyed);
(g.shieldWallDestroyed &&
['arrakeen', 'carthag', 'imperial_basin'].includes(t.id));
if (loc.sector === s && exposed) { if (loc.sector === s && exposed) {
if ( if (
p.faction === 'choam' && p.faction === 'choam' &&
@@ -6231,7 +6223,7 @@ function continueStorm(g: Game) {
if (g.ecologicalStorm?.stage === 'traversal') if (g.ecologicalStorm?.stage === 'traversal')
g.ecologicalStorm = nexusRule(() => finishDiscoveryStorm(g.ecologicalStorm!)); g.ecologicalStorm = nexusRule(() => finishDiscoveryStorm(g.ecologicalStorm!));
delete g.stormMovementSource; delete g.stormMovementSource;
g.storm = ((resolution.from - 1 + resolution.distance) % 18) + 1; g.storm = stormSectorAfter(resolution.from, resolution.distance);
stormOrder(g); stormOrder(g);
if (g.turn === 1) assignRemainingTech(g); if (g.turn === 1) assignRemainingTech(g);
log(g, `Storm moved ${resolution.distance} sectors to sector ${g.storm}.`); log(g, `Storm moved ${resolution.distance} sectors to sector ${g.storm}.`);
@@ -6288,7 +6280,7 @@ function devour(
validateWormDevouring(g, t, protectedAlly, protectFremen), validateWormDevouring(g, t, protectedAlly, protectFremen),
); );
for (const p of g.players) for (const p of g.players)
if ((!protectFremen || p.faction !== 'fremen') && p.id !== protectedAlly) if (wormConsumesForces(p, protectedAlly, protectFremen))
killTerritory(g, p, t, Infinity, false, 'worm'); killTerritory(g, p, t, Infinity, false, 'worm');
for (const k of Object.keys(g.spice)) for (const k of Object.keys(g.spice))
if (splitLocation(k).territory === t) delete g.spice[k]; if (splitLocation(k).territory === t) delete g.spice[k];
@@ -6800,9 +6792,9 @@ function continueSpice(g: Game, injected?: SpiceCard) {
g.spiceDiscard[sequence.pile].push(card); g.spiceDiscard[sequence.pile].push(card);
const amount = card.amount * (doubleNext ? 2 : 1); const amount = card.amount * (doubleNext ? 2 : 1);
g.spiceWindow = { ...card, amount, harvested: false }; g.spiceWindow = { ...card, amount, harvested: false };
if (card.sector !== g.storm) { const placement = quoteSpicePlacement(g.spice, g.storm, { ...card, amount });
const k = location(card.territory, card.sector); if (!placement.blocked) {
g.spice[k] = (g.spice[k] ?? 0) + amount; g.spice[placement.key] = placement.total;
log(g, `${amount} spice appeared in ${territory(card.territory).name}.`); log(g, `${amount} spice appeared in ${territory(card.territory).name}.`);
} else log(g, 'The spice blow was lost to the storm.'); } else log(g, 'The spice blow was lost to the storm.');
} }
+85
View File
@@ -0,0 +1,85 @@
import { SPICE_CARDS, type SpiceCard } from './cards';
import { splitLocation, territory } from './board';
import { quoteSpicePlacement, stormExposesTerritory, stormSectorAfter, wormConsumesForces } from './disaster-rules';
import { STORM_START_SECTOR } from './player-positions';
export type IntroductionOpeningChoice = {
stormExample: 'first' | 'later';
stormDial: number;
stormStage: 'draft' | 'sealed' | 'revealed' | 'resolved';
blowExample: 'clear' | 'storm' | 'worm' | 'first-worm';
blowStage: 'draft' | 'revealed' | 'settled';
};
export const OPENING_DEFAULTS: IntroductionOpeningChoice = {
stormExample: 'later', stormDial: 2, stormStage: 'draft', blowExample: 'clear', blowStage: 'draft',
};
export function validateOpeningChoice(s: IntroductionOpeningChoice) {
if (!['first', 'later'].includes(s.stormExample) || !Number.isSafeInteger(s.stormDial) ||
s.stormDial < (s.stormExample === 'first' ? 0 : 1) || s.stormDial > (s.stormExample === 'first' ? 20 : 3) ||
!['draft', 'sealed', 'revealed', 'resolved'].includes(s.stormStage) ||
!['clear', 'storm', 'worm', 'first-worm'].includes(s.blowExample) || !['draft', 'revealed', 'settled'].includes(s.blowStage))
throw new Error('Invalid opening-phase practice.');
}
export function introductionStorm(s: IntroductionOpeningChoice) {
validateOpeningChoice(s);
const first = s.stormExample === 'first';
const start = first ? STORM_START_SECTOR : 16;
const otherDial = first ? 0 : 3;
const distance = s.stormDial + otherDial;
const crossed = Array.from({ length: distance }, (_, i) => stormSectorAfter(start, i + 1));
const initialForces: Record<string, number> = first ? { 'arrakeen:10': 10 } : {
'cielago_south:2': 2, 'cielago_north:3': 2, 'false_wall_west:18': 2, 'habbanya_ridge_sietch:17': 2,
};
const initialSpice: Record<string, number> = first ? {} : { 'cielago_south:2': 4, 'hagga_basin:12': 3 };
const forces = { ...initialForces }, spice = { ...initialSpice };
let tanks = 0;
if (s.stormStage === 'resolved') {
for (const [key, count] of Object.entries(forces)) {
const loc = splitLocation(key);
if (crossed.includes(loc.sector) && stormExposesTerritory(loc.territory)) { tanks += count; delete forces[key]; }
}
for (const key of Object.keys(spice)) if (crossed.includes(splitLocation(key).sector)) delete spice[key];
}
return { start, sector: s.stormStage === 'resolved' ? stormSectorAfter(start, distance) : start,
distance: ['revealed', 'resolved'].includes(s.stormStage) ? distance : null,
otherDial: ['revealed', 'resolved'].includes(s.stormStage) ? otherDial : null,
crossed: s.stormStage === 'resolved' ? crossed : [],
initialForces, initialSpice, forces, spice, tanks, reserves: first ? 10 : 12,
locations: Object.keys(initialForces).map(key => ({ key, name: territory(splitLocation(key).territory).name,
sector: splitLocation(key).sector, before: initialForces[key], after: forces[key] ?? 0,
sheltered: !stormExposesTerritory(splitLocation(key).territory) })),
};
}
function spiceCard(id: string) {
const row = SPICE_CARDS.find(([territory]) => territory === id)!;
return { territory: row[0], amount: row[1], sector: row[2] };
}
export const PRACTICE_BLOW_CARD = spiceCard('broken_land');
export const PRACTICE_PREVIOUS_BLOW = spiceCard('the_great_flat');
export function introductionSpiceBlow(s: IntroductionOpeningChoice) {
validateOpeningChoice(s);
const first = s.blowExample === 'first-worm';
const worm = s.blowExample === 'worm';
const storm = s.blowExample === 'storm' ? PRACTICE_BLOW_CARD.sector : 18;
const initialSpice: Record<string, number> = first ? {} : { 'the_great_flat:15': 6, ...(s.blowExample === 'storm' ? {} : { 'broken_land:12': 3 }) };
const spice = { ...initialSpice };
let forces = first ? 0 : 3;
const revealed: SpiceCard[] = [];
if ((s.blowStage !== 'draft')) {
if (worm || first) revealed.push({ worm: true });
if (worm) {
if (wormConsumesForces({ id: 'practice', faction: 'emperor' })) forces = 0;
for (const key of Object.keys(spice)) if (splitLocation(key).territory === PRACTICE_PREVIOUS_BLOW.territory) delete spice[key];
}
const placement = quoteSpicePlacement(spice, storm, PRACTICE_BLOW_CARD);
if (!placement.blocked) spice[placement.key] = placement.total;
revealed.push(PRACTICE_BLOW_CARD);
}
return { storm, turn: first ? 1 : 2, initialSpice, spice, forces, tanks: !first && (s.blowStage !== 'draft') ? 3 - forces : 0,
reserves: first ? 20 : 17, revealed, nexus: worm && s.blowStage === 'settled',
skippedWorm: first && (s.blowStage !== 'draft'), added: (s.blowStage !== 'draft') ? quoteSpicePlacement(initialSpice, storm, PRACTICE_BLOW_CARD).added : null,
};
}
+14 -7
View File
@@ -1,3 +1,4 @@
import { introductionStorm, introductionSpiceBlow, OPENING_DEFAULTS, type IntroductionOpeningChoice } from './introduction-opening';
import { baseDeck, leaders } from './cards'; import { baseDeck, leaders } from './cards';
import { reserveShipmentCost } from './shipment-price'; import { reserveShipmentCost } from './shipment-price';
import { quoteBattleResolution, type ResolutionCombatant } from './battle-resolution-quote'; import { quoteBattleResolution, type ResolutionCombatant } from './battle-resolution-quote';
@@ -11,6 +12,8 @@ import { introductionCharity, introductionRevival, RESOURCE_DEFAULTS, type Intro
export const INTRODUCTION_STORAGE_KEY = 'dune-introduction-v1'; export const INTRODUCTION_STORAGE_KEY = 'dune-introduction-v1';
export const INTRODUCTION_STEPS = [ export const INTRODUCTION_STEPS = [
{ title: 'Your place at the table', topic: 'setup' }, { title: 'Your place at the table', topic: 'setup' },
{ title: 'Dial and move the storm', topic: 'storm' },
{ title: 'Reveal spice and Shai-Hulud', topic: 'spice-blow' },
{ title: 'Choose an ally at the Nexus', topic: 'spice-blow' }, { title: 'Choose an ally at the Nexus', topic: 'spice-blow' },
{ title: 'Claim CHOAM Charity', topic: 'charity' }, { title: 'Claim CHOAM Charity', topic: 'charity' },
{ title: 'Bid for a hidden card', topic: 'bidding' }, { title: 'Bid for a hidden card', topic: 'bidding' },
@@ -22,8 +25,8 @@ export const INTRODUCTION_STEPS = [
{ title: 'Collect the spice', topic: 'collection' }, { title: 'Collect the spice', topic: 'collection' },
{ title: 'Join a table', topic: 'privacy' }, { title: 'Join a table', topic: 'privacy' },
] as const; ] as const;
export type IntroductionState = IntroductionMovementChoice & IntroductionBiddingChoice & IntroductionAllianceChoice & IntroductionResourceChoice & { export type IntroductionState = IntroductionMovementChoice & IntroductionBiddingChoice & IntroductionAllianceChoice & IntroductionResourceChoice & IntroductionOpeningChoice & {
version: 5; version: 6;
step: number; step: number;
shipment: number; shipment: number;
destination: 'stronghold' | 'sand'; destination: 'stronghold' | 'sand';
@@ -40,7 +43,7 @@ export type IntroductionState = IntroductionMovementChoice & IntroductionBidding
opponentTraitor: boolean; opponentTraitor: boolean;
}; };
export function newIntroduction(): IntroductionState { export function newIntroduction(): IntroductionState {
return { ...RESOURCE_DEFAULTS, revivalActions: [], ...INTRODUCTION_ALLIANCE_DEFAULTS, allianceActions: [], version: 5, step: 0, shipment: 4, destination: 'sand', shipped: false, return { ...OPENING_DEFAULTS, ...RESOURCE_DEFAULTS, revivalActions: [], ...INTRODUCTION_ALLIANCE_DEFAULTS, allianceActions: [], version: 6, step: 0, shipment: 4, destination: 'sand', shipped: false,
dial: 2, defense: 'shield', revealed: false, collectors: 3, city: false, collected: false, dial: 2, defense: 'shield', revealed: false, collectors: 3, city: false, collected: false,
moveDestination: 'pasty_mesa:5', moveForces: 3, moveCity: false, moveStorm: false, moveDestination: 'pasty_mesa:5', moveForces: 3, moveCity: false, moveStorm: false,
moved: false, traitorStage: 'plans', traitorCall: false, opponentTraitor: false, moved: false, traitorStage: 'plans', traitorCall: false, opponentTraitor: false,
@@ -52,23 +55,25 @@ export function restoreIntroduction(raw: string | null): IntroductionState | nul
if (!raw) return null; if (!raw) return null;
try { try {
const parsed = JSON.parse(raw); const parsed = JSON.parse(raw);
if (!parsed || ![1, 2, 3, 4, 5].includes(parsed.version)) return null; if (!parsed || ![1, 2, 3, 4, 5, 6].includes(parsed.version)) return null;
// Keep the existing browser key and translate old positions, not just indices. // Keep the existing browser key and translate old positions, not just indices.
if (parsed.version === 1 && !integer(parsed.step, 0, 4)) return null; if (parsed.version === 1 && !integer(parsed.step, 0, 4)) return null;
if (parsed.version === 2 && !integer(parsed.step, 0, 6)) return null; if (parsed.version === 2 && !integer(parsed.step, 0, 6)) return null;
if (parsed.version === 3 && !integer(parsed.step, 0, 7)) return null; if (parsed.version === 3 && !integer(parsed.step, 0, 7)) return null;
if (parsed.version === 4 && !integer(parsed.step, 0, 8)) return null; if (parsed.version === 4 && !integer(parsed.step, 0, 8)) return null;
if (parsed.version === 5 && !integer(parsed.step, 0, 10)) return null;
const resourceDefaults = { ...RESOURCE_DEFAULTS, revivalActions: [] }; const resourceDefaults = { ...RESOURCE_DEFAULTS, revivalActions: [] };
const auctionDefaults = { auctionScenario: 'contest', auctionHandFull: false, auctionBid: 1, auctionActions: [] }; const auctionDefaults = { auctionScenario: 'contest', auctionHandFull: false, auctionBid: 1, auctionActions: [] };
const allianceDefaults = { ...INTRODUCTION_ALLIANCE_DEFAULTS, allianceActions: [] }; const allianceDefaults = { ...INTRODUCTION_ALLIANCE_DEFAULTS, allianceActions: [] };
const s = (parsed.version === 1 ? { ...newIntroduction(), ...parsed, version: 5, const previous = (parsed.version === 1 ? { ...newIntroduction(), ...parsed, version: 5,
step: [0, 5, 7, 9, 10][parsed.step], step: [0, 5, 7, 9, 10][parsed.step],
moveDestination: 'pasty_mesa:5', moveForces: 3, moveCity: false, moveStorm: false, moveDestination: 'pasty_mesa:5', moveForces: 3, moveCity: false, moveStorm: false,
moved: false, traitorStage: 'plans', traitorCall: false, opponentTraitor: false, ...auctionDefaults, ...allianceDefaults, ...resourceDefaults } moved: false, traitorStage: 'plans', traitorCall: false, opponentTraitor: false, ...auctionDefaults, ...allianceDefaults, ...resourceDefaults }
: parsed.version === 2 ? { ...parsed, version: 5, step: [0, 5, 6, 7, 8, 9, 10][parsed.step], ...auctionDefaults, ...allianceDefaults, ...resourceDefaults } : parsed.version === 2 ? { ...parsed, version: 5, step: [0, 5, 6, 7, 8, 9, 10][parsed.step], ...auctionDefaults, ...allianceDefaults, ...resourceDefaults }
: parsed.version === 3 ? { ...parsed, version: 5, step: [0, 3, 5, 6, 7, 8, 9, 10][parsed.step], ...allianceDefaults, ...resourceDefaults } : parsed.version === 3 ? { ...parsed, version: 5, step: [0, 3, 5, 6, 7, 8, 9, 10][parsed.step], ...allianceDefaults, ...resourceDefaults }
: parsed.version === 4 ? { ...parsed, version: 5, step: [0, 1, 3, 5, 6, 7, 8, 9, 10][parsed.step], ...resourceDefaults } : parsed.version === 4 ? { ...parsed, version: 5, step: [0, 1, 3, 5, 6, 7, 8, 9, 10][parsed.step], ...resourceDefaults }
: parsed) as IntroductionState; : parsed);
const s = (previous.version === 5 ? { ...previous, version: 6, step: [0, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12][previous.step], ...OPENING_DEFAULTS } : previous) as IntroductionState;
if (!integer(s.step, 0, INTRODUCTION_STEPS.length - 1) || if (!integer(s.step, 0, INTRODUCTION_STEPS.length - 1) ||
!integer(s.shipment, 1, 6) || !['stronghold', 'sand'].includes(s.destination) || !integer(s.shipment, 1, 6) || !['stronghold', 'sand'].includes(s.destination) ||
!integer(s.dial, 0, 6) || !['shield', 'snooper'].includes(s.defense) || !integer(s.dial, 0, 6) || !['shield', 'snooper'].includes(s.defense) ||
@@ -84,8 +89,10 @@ export function restoreIntroduction(raw: string | null): IntroductionState | nul
introductionAlliance(s); introductionAlliance(s);
introductionCharity(s); introductionCharity(s);
introductionRevival(s); introductionRevival(s);
introductionStorm(s);
introductionSpiceBlow(s);
// Whitelist fields; never retain credentials or arbitrary imported properties. // Whitelist fields; never retain credentials or arbitrary imported properties.
return { version: 5, step: s.step, shipment: s.shipment, destination: s.destination, return { version: 6, stormExample: s.stormExample, stormDial: s.stormDial, stormStage: s.stormStage, blowExample: s.blowExample, blowStage: s.blowStage, step: s.step, shipment: s.shipment, destination: s.destination,
shipped: s.shipped, dial: s.dial, defense: s.defense, revealed: s.revealed, shipped: s.shipped, dial: s.dial, defense: s.defense, revealed: s.revealed,
collectors: s.collectors, city: s.city, collected: s.collected, collectors: s.collectors, city: s.city, collected: s.collected,
moveDestination: s.moveDestination, moveForces: s.moveForces, moveCity: s.moveCity, moveDestination: s.moveDestination, moveForces: s.moveForces, moveCity: s.moveCity,
+8 -7
View File
@@ -233,10 +233,11 @@ export const RULE_TOPICS: RuleTopic[] = [
category: 'Getting started', category: 'Getting started',
coverage: 'Partial', coverage: 'Partial',
developmentStage: 'Prototyped', developmentStage: 'Prototyped',
summary: 'Practice alliances, charity, bidding, revival, shipping, movement, sealed battle plans, Traitor calls and spice collection before joining a table.', summary: 'Practice storm dials, spice blows, alliances, charity, bidding, revival, shipping, movement, sealed battle plans, Traitor calls and spice collection before joining a table.',
steps: [ steps: [
'Choose Learn to play in the lobby or at the top of this reference. Eleven lessons introduce table information, ordinary Nexus alliances, charity, bidding, revival, shipping budgets, movement, battle choices, Traitor calls, collection and saved-seat recovery.', 'Choose Learn to play in the lobby or at the top of this reference. Thirteen lessons introduce table information, storm dials, spice blows, ordinary Nexus alliances, charity, bidding, revival, shipping budgets, movement, battle choices, Traitor calls, collection and saved-seat recovery.',
'The nine practice positions are separate Basic examples. Compare movement range with city access, storm and stronghold limits; reveal a matching Traitor or decline after both plans appear. Bidding, shipping, combat and collection use the same rule calculations as live games.', 'The eleven practice positions are separate Basic examples. Compare movement range with city access, storm and stronghold limits; reveal a matching Traitor or decline after both plans appear. Bidding, shipping, combat and collection use the same rule calculations as live games.',
'Storm practice separates your sealed dial, both revealed dials and movement. Compare first-turn 0–20 and later Basic 1–3 ranges, counterclockwise wrapping, exposed sand, sheltered forces and lost spice. Spice Blow compares accumulation, storm loss, later Shai-Hulud and ignored first-turn worms; finish the replacement blow before opening the Nexus.',
'Bidding lets you raise, pass and re-enter while the card remains for sale. Only the winner pays; a purchased card stays private. Try a full hand or opponents who all pass, then restart the example.', 'Bidding lets you raise, pass and re-enter while the card remains for sale. Only the winner pays; a purchased card stays private. Try a full hand or opponents who all pass, then restart the example.',
'Charity compares zero, one and two-or-more spice. Revival compares three factions’ free allowances, cumulative force returns, shared spending and leader eligibility across first-death, surviving and repeat-death rosters. Force and leader returns remain separately limited.', 'Charity compares zero, one and two-or-more spice. Revival compares three factions’ free allowances, cumulative force returns, shared spending and leader eligibility across first-death, surviving and repeat-death rosters. Force and leader returns remain separately limited.',
'At the Nexus, offer an alliance or accept the Emperor’s offer. An unanswered offer does not form an alliance. Break it or finish the Nexus, then compare three versus four shared strongholds at a later Mentat Pause. Changing practice options restarts that example.', 'At the Nexus, offer an alliance or accept the Emperor’s offer. An unanswered offer does not form an alliance. Break it or finish the Nexus, then compare three versus four shared strongholds at a later Mentat Pause. Changing practice options restarts that example.',
@@ -244,13 +245,13 @@ export const RULE_TOPICS: RuleTopic[] = [
'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.', '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, alliance powers, broader storm exceptions and expansions remain outside this introduction. The full reference and phase guidance explain more; existing mode gates still apply.', 'Faction lessons, Advanced combat, alliance powers, broader storm exceptions and expansions remain outside this introduction. The full reference and phase guidance explain more; existing mode gates still apply.',
], ],
related: ['setup', 'spice-blow', 'alliance-funding', 'charity', 'revival', 'bidding', 'movement', 'battle', 'collection', 'privacy', 'implementation-checklist'], related: ['setup', 'storm', 'spice-blow', 'alliance-funding', 'charity', 'revival', 'bidding', 'movement', 'battle', 'collection', 'privacy', 'implementation-checklist'],
checklist: [ checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'Eleven lessons and nine interactive Basic examples reuse production alliance/victory, charity/revival, bidding, shipment, map/occupancy, combat and collection calculations. The complete teaching curriculum remains unfinished.', evidence: ['game/introduction.ts', 'game/introduction-movement.ts', 'game/introduction-bidding.ts', 'game/introduction-alliance.ts', 'game/nexus-alliance.ts', 'game/introduction-resources.ts'] }, { area: 'Implementation', status: 'Partial', detail: 'Thirteen lessons and eleven interactive Basic examples reuse production storm/exposure/spice, alliance/victory, charity/revival, bidding, shipment, map/occupancy, combat and collection calculations. The complete teaching curriculum remains unfinished.', evidence: ['game/introduction.ts', 'game/introduction-movement.ts', 'game/introduction-bidding.ts', 'game/introduction-alliance.ts', 'game/nexus-alliance.ts', 'game/introduction-resources.ts', 'game/introduction-opening.ts', 'game/disaster-rules.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Charity claims, cumulative free/paid force returns, eligible leader revival and inspection, alliance offer/accept/withdraw/break and later victory, bid/pass, full-hand and all-pass choices, lesson navigation, keyboard force sliders and battle wheel, card inspectors, seal/reveal, retry and browser-local continuation are connected.', evidence: ['components/introduction.tsx', 'components/introduction-practice.tsx', 'components/introduction-bidding.tsx', 'components/introduction-alliance.tsx', 'components/introduction-resources.tsx'] }, { area: 'Player controls', status: 'Partial', detail: 'Separate storm sealing/reveal/movement, four spice scenarios and first-turn skip/Nexus timing, charity claims, cumulative free/paid force returns, eligible leader revival and inspection, alliance offer/accept/withdraw/break and later victory, bid/pass, full-hand and all-pass choices, lesson navigation, keyboard force sliders and battle wheel, card inspectors, seal/reveal, retry and browser-local continuation are connected.', evidence: ['components/introduction.tsx', 'components/introduction-practice.tsx', 'components/introduction-bidding.tsx', 'components/introduction-alliance.tsx', 'components/introduction-resources.tsx', 'components/introduction-opening.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'Fixed alliance responses, bidding scripts and an opponent battle plan provide reproducible teaching examples. The introduction is not an AI game and does not demonstrate opponent strategy or calibration.' }, { area: 'AI', status: 'Partial', detail: 'Fixed alliance responses, bidding scripts and an opponent battle plan provide reproducible teaching examples. 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: '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 compare resource payments, allowances and leader custody with engine actions, validate paid-save continuation and prior lesson migration, and cover alliance consent/break and later victory across 24 engine comparisons, bidding/re-entry against the live engine, full hands, payment/card custody, pricing, all offered plans, weapon survival, ties, losses, collection limits, 96 movement comparisons against the engine, single/mutual/declined Traitor calls and saved-choice migration/validation. This does not certify all rules or the complete learning journey.', evidence: ['tests/introduction.test.ts', 'tests/introduction-practice.test.ts', 'tests/introduction-bidding.test.ts', 'tests/introduction-alliance.test.ts', 'tests/nexus-alliance.test.ts', 'tests/introduction-resources.test.ts', 'tests/introduction-resources-migration.test.ts'] }, { area: 'Verification', status: 'Partial', detail: 'Focused checks compare all 24 offered storm dials and four spice scenarios with real engine actions, preserve v5 lesson identities, and compare resource payments, allowances and leader custody with engine actions, validate paid-save continuation and prior lesson migration, and cover alliance consent/break and later victory across 24 engine comparisons, bidding/re-entry against the live engine, full hands, payment/card custody, pricing, all offered plans, weapon survival, ties, losses, collection limits, 96 movement comparisons against the engine, single/mutual/declined Traitor calls and saved-choice migration/validation. This does not certify all rules or the complete learning journey.', evidence: ['tests/introduction.test.ts', 'tests/introduction-practice.test.ts', 'tests/introduction-bidding.test.ts', 'tests/introduction-alliance.test.ts', 'tests/nexus-alliance.test.ts', 'tests/introduction-resources.test.ts', 'tests/introduction-resources-migration.test.ts', 'tests/introduction-opening.test.ts'] },
], ],
}, },
{ {
+1 -1
View File
@@ -82,7 +82,7 @@ void test('v3 migration preserves all eight lesson identities, completed auction
shipped: true, moved: true, moveCity: true, revealed: true, collected: true, shipped: true, moved: true, moveCity: true, revealed: true, collected: true,
traitorStage: 'resolved', traitorCall: true, allianceActions: ['invalid'], allianceChecked: true, token: 'discarded' }; traitorStage: 'resolved', traitorCall: true, allianceActions: ['invalid'], allianceChecked: true, token: 'discarded' };
const restored = restoreIntroduction(JSON.stringify(old))!; const restored = restoreIntroduction(JSON.stringify(old))!;
assert.equal(restored.version, 5); assert.equal(restored.version, 6);
assert.equal(INTRODUCTION_STEPS[restored.step].title, title); assert.equal(INTRODUCTION_STEPS[restored.step].title, title);
assert.deepEqual(restored.auctionActions, [null, 4]); assert.deepEqual(restored.auctionActions, [null, 4]);
assert.ok(restored.shipped && restored.moved && restored.revealed && restored.collected && restored.traitorCall); assert.ok(restored.shipped && restored.moved && restored.revealed && restored.collected && restored.traitorCall);
+1 -1
View File
@@ -214,7 +214,7 @@ void test('all seven v2 lessons keep their identity and prior decisions when bid
unrelated: 'removed', unrelated: 'removed',
}; };
const result = restoreIntroduction(JSON.stringify(old))!; const result = restoreIntroduction(JSON.stringify(old))!;
assert.equal(result.version, 5); assert.equal(result.version, 6);
assert.equal(INTRODUCTION_STEPS[result.step].title, title); assert.equal(INTRODUCTION_STEPS[result.step].title, title);
assert.equal(result.shipped, true); assert.equal(result.shipped, true);
assert.equal(result.revealed, true); assert.equal(result.revealed, true);
+113
View File
@@ -0,0 +1,113 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Game } from '../game/engine';
import { introductionStorm, introductionSpiceBlow, OPENING_DEFAULTS } from '../game/introduction-opening';
import { INTRODUCTION_STEPS, newIntroduction, restoreIntroduction } from '../game/introduction';
import { placeFixtureHand } from './fixture-hand';
function started() {
let g = createGame('OPENLESS', newPlayer('a', 'Atreides', 'atreides'));
joinGame(g, newPlayer('e', 'Emperor', 'emperor'));
g.players.forEach(p => { p.ready = true; });
g = applyAction(g, 'a', { type: 'start' });
for (const p of g.players) g = applyAction(g, p.id, { type: 'traitor', leader: p.traitorChoices[0] });
for (let i = 0; i < g.players.length; i++) placeFixtureHand(g, i, []);
return g;
}
function ready(state: Game) {
let g = state;
for (const p of state.players) g = applyAction(g, p.id, { type: 'ready' });
return g;
}
void test('every storm lesson dial matches real sealing, reveal, traversal, shelter and custody', () => {
for (const stormExample of ['first', 'later'] as const) for (let stormDial = stormExample === 'first' ? 0 : 1; stormDial <= (stormExample === 'first' ? 20 : 3); stormDial++) {
const choice = { ...OPENING_DEFAULTS, stormExample, stormDial };
const opening = introductionStorm(choice);
assert.equal(opening.otherDial, null);
assert.equal(introductionStorm({ ...choice, stormStage: 'sealed' }).distance, null);
let g = started();
g.turn = stormExample === 'first' ? 1 : 2;
g.storm = opening.start; g.stormDialers = ['a', 'e']; g.ready = [];
g.players[0].forces = { ...opening.initialForces }; g.players[0].reserves = opening.reserves;
g.spice = { ...opening.initialSpice };
g = applyAction(g, 'a', { type: 'stormDial', amount: stormDial });
assert.equal(viewGame(g, 'e').stormRevealed, null);
const revealed = introductionStorm({ ...choice, stormStage: 'revealed' });
g = applyAction(g, 'e', { type: 'stormDial', amount: revealed.otherDial! });
assert.equal(g.stormPending, revealed.distance);
assert.equal(g.storm, revealed.sector);
const expected = introductionStorm({ ...choice, stormStage: 'resolved' });
g = ready(g);
assert.equal(g.storm, expected.sector);
assert.deepEqual(g.players[0].forces, expected.forces);
assert.equal(g.players[0].tanks, expected.tanks);
assert.deepEqual(g.spice, expected.spice);
assert.equal(g.players[0].reserves + g.players[0].tanks + Object.values(g.players[0].forces).reduce((a,b)=>a+b,0),20);
}
});
void test('spice examples match actual draw, accumulation, storm loss, first-turn skip and Nexus timing', () => {
for (const blowExample of ['clear', 'storm', 'worm', 'first-worm'] as const) {
const choice = { ...OPENING_DEFAULTS, blowExample };
const opening = introductionSpiceBlow(choice);
assert.deepEqual(opening.revealed, []);
let g = started();
g.turn = opening.turn; g.phase = 1; g.storm = opening.storm; g.ready = [];
const originalWorms = g.spiceDeck.filter(c => 'worm' in c).length;
g.spice = { ...opening.initialSpice };
g.players[0].forces = {}; g.players[0].reserves = 20;
g.players[1].forces = opening.forces ? { 'the_great_flat:15': opening.forces } : {};
g.players[1].reserves = opening.reserves;
const take = (kind: 'worm' | 'the_great_flat' | 'broken_land') => {
const i = g.spiceDeck.findIndex(c => kind === 'worm' ? 'worm' in c : 'territory' in c && c.territory === kind);
assert.ok(i >= 0); return g.spiceDeck.splice(i,1)[0];
};
if (opening.turn > 1) g.spiceDiscard[0].push(take('the_great_flat'));
const cards = blowExample.includes('worm') ? [take('worm'),take('broken_land')] : [take('broken_land')];
g.spiceDeck.unshift(...cards);
g = ready(g);
const expected = introductionSpiceBlow({ ...choice, blowStage: 'revealed' });
assert.deepEqual(g.spice, expected.spice);
assert.equal(g.players[1].forces['the_great_flat:15'] ?? 0,expected.forces);
assert.equal(g.players[1].tanks,expected.tanks);
assert.ok(g.spiceWindow,'Replacement blow remains open before Nexus');
assert.equal(expected.nexus,false);
g = ready(JSON.parse(JSON.stringify(g)) as Game);
const settled = introductionSpiceBlow({ ...choice, blowStage: 'settled' });
assert.equal(!!g.nexus,settled.nexus);
assert.equal(g.spiceWindow,null);
assert.equal(g.phase,settled.nexus?1:2);
if (blowExample === 'first-worm') {
assert.equal(g.spiceDeck.filter(c => 'worm' in c).length, originalWorms);
assert.equal(g.spiceSequence, null);
}
}
});
void test('v5 progress retains all eleven lesson identities and results with fresh opening examples', () => {
const titles=['Your place at the table','Choose an ally at the Nexus','Claim CHOAM Charity','Bid for a hidden card',
'Revive forces and leaders','Ship within your budget','Move across the board','Seal a battle plan','Reveal a Traitor','Collect the spice','Join a table'];
for (const [step,title] of titles.entries()) {
const old = { ...newIntroduction(), version:5, step, charityClaimed:true, revivalActions:[{kind:'forces',amount:1}],
allianceActions:['offer'], allianceClosed:true, allianceChecked:true, stormDial:'ignore', blowStage:'ignore', room:'exclude' };
const restored=restoreIntroduction(JSON.stringify(old))!;
assert.equal(restored.version,6); assert.equal(INTRODUCTION_STEPS[restored.step].title,title);
assert.equal(restored.charityClaimed,true); assert.deepEqual(restored.revivalActions,[{kind:'forces',amount:1}]);
assert.equal(restored.allianceChecked,true); assert.equal(restored.stormDial,2); assert.equal(restored.blowStage,'draft');
assert.equal(Object.hasOwn(restored,'room'),false);
assert.deepEqual(restoreIntroduction(JSON.stringify(restored)),restored);
}
assert.equal(restoreIntroduction(JSON.stringify({...newIntroduction(),version:5,step:11})),null);
});
void test('every opening practice stage survives JSON and malformed choices fail closed', () => {
for (const stormStage of ['draft','sealed','revealed','resolved'] as const) for (const blowStage of ['draft','revealed','settled'] as const) {
const s={...newIntroduction(),stormStage,blowStage,blowExample:'worm' as const};
assert.deepEqual(restoreIntroduction(JSON.stringify(s)),s);
const before=structuredClone(s); introductionStorm(s); introductionSpiceBlow(s); assert.deepEqual(s,before);
}
for (const patch of [{stormDial:0},{stormDial:4},{stormDial:1.5},{stormExample:'advanced'},{stormStage:'moved'},
{stormExample:'first',stormDial:21},{blowExample:'sandtrout'},{blowStage:'nexus'}, {blowStage:true}])
assert.equal(restoreIntroduction(JSON.stringify({...newIntroduction(),...patch})),null);
});
+2 -2
View File
@@ -69,7 +69,7 @@ void test('all five v1 lessons migrate by identity and retain prior committed ch
for (const [step, title] of titles.entries()) { for (const [step, title] of titles.entries()) {
const result = restoreIntroduction(JSON.stringify({ ...old, step, token: 'excluded', moved: true, traitorCall: true }))!; const result = restoreIntroduction(JSON.stringify({ ...old, step, token: 'excluded', moved: true, traitorCall: true }))!;
assert.equal(INTRODUCTION_STEPS[result.step].title, title); assert.equal(INTRODUCTION_STEPS[result.step].title, title);
assert.equal(result.version, 5); assert.equal(result.shipped, true); assert.equal(result.revealed, true); assert.equal(result.version, 6); assert.equal(result.shipped, true); assert.equal(result.revealed, true);
assert.equal(result.dial, 3); assert.equal(result.collected, true); assert.equal(result.collectors, 2); assert.equal(result.dial, 3); assert.equal(result.collected, true); assert.equal(result.collectors, 2);
assert.equal(result.moved, false); assert.equal(result.traitorCall, false); assert.equal('token' in result, false); assert.equal(result.moved, false); assert.equal(result.traitorCall, false); assert.equal('token' in result, false);
assert.deepEqual(restoreIntroduction(JSON.stringify(result)), result); assert.deepEqual(restoreIntroduction(JSON.stringify(result)), result);
@@ -86,7 +86,7 @@ void test('new practice choices survive all stages and reject impossible saved c
} }
for (const patch of [{ moveDestination: 'invented:7' }, { moveForces: 0 }, { moveCity: 'yes' }, for (const patch of [{ moveDestination: 'invented:7' }, { moveForces: 0 }, { moveCity: 'yes' },
{ moved: true, moveStorm: true }, { moved: true, moveDestination: 'imperial_basin:9' }, { moved: true, moveStorm: true }, { moved: true, moveDestination: 'imperial_basin:9' },
{ traitorStage: 'later' }, { traitorCall: true }, { opponentTraitor: 1 }, { version: 6 }]) { { traitorStage: 'later' }, { traitorCall: true }, { opponentTraitor: 1 }, { version: 7 }]) {
assert.equal(restoreIntroduction(JSON.stringify({ ...newIntroduction(), ...patch })), null); assert.equal(restoreIntroduction(JSON.stringify({ ...newIntroduction(), ...patch })), null);
} }
}); });
@@ -13,7 +13,7 @@ void test('all nine v4 lessons retain their identity and prior commitments when
allianceChecked: true, charitySpice: 'ignore', charityClaimed: true, revivalActions: ['ignore'], allianceChecked: true, charitySpice: 'ignore', charityClaimed: true, revivalActions: ['ignore'],
token: 'discarded' }; token: 'discarded' };
const restored = restoreIntroduction(JSON.stringify(old))!; const restored = restoreIntroduction(JSON.stringify(old))!;
assert.equal(restored.version, 5); assert.equal(restored.version, 6);
assert.equal(INTRODUCTION_STEPS[restored.step].title, title); assert.equal(INTRODUCTION_STEPS[restored.step].title, title);
assert.ok(restored.shipped && restored.moved && restored.revealed && restored.collected && restored.traitorCall); assert.ok(restored.shipped && restored.moved && restored.revealed && restored.collected && restored.traitorCall);
assert.deepEqual(restored.auctionActions, [null, 4]); assert.deepEqual(restored.auctionActions, [null, 4]);
+2 -2
View File
@@ -44,8 +44,8 @@ void test('saved practice restores sealed choices, rejects corrupt versions and
const saved = { ...newIntroduction(), step: 3, dial: 3, revealed: true, shipped: true }; const saved = { ...newIntroduction(), step: 3, dial: 3, revealed: true, shipped: true };
assert.deepEqual(restoreIntroduction(JSON.stringify(saved)), saved); assert.deepEqual(restoreIntroduction(JSON.stringify(saved)), saved);
assert.deepEqual(restoreIntroduction(JSON.stringify({ ...saved, token: 'not-a-real-token', hand: ['private'] })), 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: 6 }), for (const raw of [null, '', '{', 'null', '[]', JSON.stringify({ ...saved, version: 7 }),
JSON.stringify({ ...saved, step: -1 }), JSON.stringify({ ...saved, step: 11 }), JSON.stringify({ ...saved, step: -1 }), JSON.stringify({ ...saved, step: 13 }),
JSON.stringify({ ...saved, defense: 'lasgun' }), JSON.stringify({ ...saved, dial: 2.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, collectors: 99 }), JSON.stringify({ ...saved, revealed: 'true' }),
JSON.stringify({ ...saved, shipment: 6, destination: 'sand', shipped: true })]) { JSON.stringify({ ...saved, shipment: 6, destination: 'sand', shipped: true })]) {