From b2fcef8d51b29f64fd60a2e1f7bec695d4367003 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Mon, 21 Sep 2026 09:24:51 +1000 Subject: [PATCH] Add Storm and Spice Blow practice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 2 +- components/battle-wheel.tsx | 20 ++-- components/introduction-opening.tsx | 84 +++++++++++++ components/introduction.tsx | 27 +++-- docs/CURRENT_STATUS.md | 36 +++--- docs/IMPLEMENTATION_STATUS.md | 13 ++ docs/INTERACTIVE_INTRODUCTION.md | 48 ++++++-- game/disaster-preflight.ts | 14 +-- game/disaster-rules.ts | 25 ++++ game/engine.ts | 28 ++--- game/introduction-opening.ts | 85 +++++++++++++ game/introduction.ts | 21 ++-- game/reference.ts | 15 +-- tests/introduction-alliance.test.ts | 2 +- tests/introduction-bidding.test.ts | 2 +- tests/introduction-opening.test.ts | 113 ++++++++++++++++++ tests/introduction-practice.test.ts | 4 +- .../introduction-resources-migration.test.ts | 2 +- tests/introduction.test.ts | 4 +- 19 files changed, 450 insertions(+), 95 deletions(-) create mode 100644 components/introduction-opening.tsx create mode 100644 game/disaster-rules.ts create mode 100644 game/introduction-opening.ts create mode 100644 tests/introduction-opening.test.ts diff --git a/README.md b/README.md index 35a73fad..7e63cdf4 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Until that stage, AI work focuses on minimal legal participation and critical playability fixes. 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. 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. diff --git a/components/battle-wheel.tsx b/components/battle-wheel.tsx index 230271fc..01a1a336 100644 --- a/components/battle-wheel.tsx +++ b/components/battle-wheel.tsx @@ -7,6 +7,8 @@ export type BattleWheelProps = { value: number; onChange: (value: number) => void; max: number; + min?: number; + sliderLabel?: string; step: number; disabled?: boolean; id?: string; @@ -22,6 +24,8 @@ export function BattleWheel({ value, onChange, max, + min = 0, + sliderLabel = 'Forces dialed slider', step, disabled = false, id = 'forces-dialed', @@ -29,14 +33,14 @@ export function BattleWheel({ const graphicId = useId(); const rimId = `${graphicId}-rim`; 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; // 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 = [ ...new Set( [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) => { - 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 ( onChange(Number(event.target.value))} /> @@ -173,7 +177,7 @@ export function BattleWheel({ className={styles.number} id={id} type="number" - min={0} + min={min} max={max} step={step} value={value} diff --git a/components/introduction-opening.tsx b/components/introduction-opening.tsx new file mode 100644 index 00000000..6003d999 --- /dev/null +++ b/components/introduction-opening.tsx @@ -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) => void }; +const spicePlaces = (spice: Record) => 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 <> +

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.

+
+
+ + + +
{ if (Number.isInteger(stormDial) && stormDial >= (first ? 0 : 1) && stormDial <= (first ? 20 : 3)) update({ stormDial }); }} />
+ + + + +
+
+

{state.stormStage === 'resolved' ? 'Storm movement complete' : state.stormStage === 'revealed' ? 'Both dials revealed' : state.stormStage === 'sealed' ? 'Your dial is sealed' : 'Prepare a secret dial'}

+

The storm is at sector {result.sector}.

+ {result.distance === null ?

The other dial stays hidden. Sealing does not move the storm.

:

Your {state.stormDial} plus the other player’s {result.otherDial} makes {result.distance} sectors counterclockwise.

} + {state.stormStage === 'revealed' &&

At a live table, players may use eligible storm cards before movement. This example has none.

} + {state.stormStage === 'resolved' &&

{result.crossed.length ? `Crossed sectors: ${result.crossed.join(' → ')}.` : 'Zero movement crosses no sector.'}

} +
    {result.locations.map(place =>
  • {place.name}, sector {place.sector}: {place.after} forces{state.stormStage === 'resolved' ? ` (${place.before - place.after} lost)` : ''} · {place.sheltered ? 'sheltered' : 'exposed sand'}
  • )}
+

Your reserves: {result.reserves}. Your Tanks: {result.tanks}.

+

{spicePlaces(result.spice)}.

+
+
+

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.

+ ; +} + +export function SpiceBlowLesson({ state, update }: Props) { + const result = introductionSpiceBlow(state); + return <> +

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.

+
+
+ + +

Turn {result.turn} · storm sector {result.storm}.

+ {result.turn > 1 && <>

Previous territory card: {spiceCardTitle(PRACTICE_PREVIOUS_BLOW)}. Three Emperor forces occupy that territory.

} + + + +
+
+

{state.blowStage === 'draft' ? 'The next draw is face down' : result.nexus ? 'Nexus: negotiate alliances' : state.blowStage === 'settled' ? 'Continue to CHOAM Charity' : 'Spice sequence revealed'}

+ {result.revealed.map((card, index) =>

{index + 1}. {spiceCardTitle(card)}

)} +

{spicePlaces(result.spice)}.

+

Emperor forces in The Great Flat: {result.forces}. Tanks: {result.tanks}. Reserves: {result.reserves}.

+ {result.added !== null &&

{result.added ? `${result.added} spice was added to Broken Land.` : 'Broken Land is in storm: its card adds no spice.'}

} + {state.blowExample === 'worm' && state.blowStage !== 'draft' &&

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.'}

} + {result.skippedWorm &&

{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.'}

} + {result.nexus &&

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.

} +
+
+

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.

+ ; +} diff --git a/components/introduction.tsx b/components/introduction.tsx index 6aa72430..d052fe15 100644 --- a/components/introduction.tsx +++ b/components/introduction.tsx @@ -9,6 +9,7 @@ import { MovementLesson, TraitorLesson } from './introduction-practice'; import { BiddingLesson } from './introduction-bidding'; import { AllianceLesson } from './introduction-alliance'; import { CharityLesson, RevivalLesson } from './introduction-resources'; +import { StormLesson, SpiceBlowLesson } from './introduction-opening'; import { INTRODUCTION_CARDS, INTRODUCTION_LEADERS, INTRODUCTION_STEPS, INTRODUCTION_STORAGE_KEY, introductionBattle, introductionCollection, introductionShipment, newIntroduction, restoreIntroduction, @@ -27,10 +28,10 @@ export function Introduction() { ← Return to Arrakis

LEARN TO PLAY · BASIC GAME

Your first steps on Arrakis

-

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.

+

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.

{/* Remount on schema changes so a development hot update also migrates old state. */} - {hydrated ? : Restoring your lesson…} + {hydrated ? : Restoring your lesson…} ; } @@ -78,11 +79,13 @@ function IntroductionLessons() {
    {PHASES.map(phase =>
  1. {phase}
  2. )}

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.

} - {state.step === 1 && } - {state.step === 2 && } - {state.step === 3 && } - {state.step === 4 && } - {state.step === 5 && <> + {state.step === 1 && } + {state.step === 2 && } + {state.step === 3 && } + {state.step === 4 && } + {state.step === 5 && } + {state.step === 6 && } + {state.step === 7 && <>

You have 8 spice 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.

@@ -106,8 +109,8 @@ function IntroductionLessons() {

Ordinary movement reaches an adjacent territory. Ornithopters and faction powers can extend its range. Inspect the live destination guide before committing.

} - {state.step === 6 && } - {state.step === 7 && <> + {state.step === 8 && } + {state.step === 9 && <>

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.

@@ -135,8 +138,8 @@ function IntroductionLessons() {
} - {state.step === 8 && } - {state.step === 9 && <> + {state.step === 10 && } + {state.step === 11 && <>

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.

@@ -152,7 +155,7 @@ function IntroductionLessons() {
} - {state.step === 10 && <> + {state.step === 12 && <>

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.

  • Create a private room, choose a faction, and add friends or AI players. Share the room invitation.
  • diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 6589eb6e..36859bb9 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -6,28 +6,26 @@ gated.** Prototypes do not open completion or publication gates. ## Current checkpoint and work -Latest delivered checkpoint: `ed53eb7`, [Storm Card inspection](STORM_CARD_INSPECTION.md). -Types, lint, **4,946 offline tests**, production build and **46 HTTP tests** passed. -All 1,337 original rooms, 2,805 original seats and existing auxiliary rows stayed -unchanged; HTTP checks created their own additional records. The current baseline -contains 1,373 rooms and 2,883 seats. The running server was reused. -Earlier delivered checkpoints include `67e429e` for Charity/Revival practice, -`c4ff75a` for alliances, `b7ec940` for bidding, `b9718ac` for lobby AI configuration -and `b21b2bc` for one-use AI permission. The `8f7a62c` policy checkpoint removed -the usage cutoff and deferred AI refinement until feature completion, with three -adjacent 75% targets. See the [implementation history](IMPLEMENTATION_STATUS.md). +Latest delivered checkpoint: `66c41b2`, [Kwisatz Haderach inspection](KWISATZ_INSPECTION.md). +Types, lint, **4,950 offline tests** and production build passed. All 1,373 rooms, +2,883 seats and existing auxiliary rows stayed unchanged. The running server was +reused. Earlier checkpoints include `ed53eb7` for Storm Cards, `67e429e` for +Charity/Revival practice, `c4ff75a` for alliances, `b7ec940` for bidding, +`b9718ac` for lobby AI configuration and `b21b2bc` for one-use AI permission. +The `8f7a62c` policy checkpoint removed the usage cutoff and deferred AI refinement +until feature completion, with three adjacent 75% targets. See the +[implementation history](IMPLEMENTATION_STATUS.md). -Current work: [Kwisatz Haderach inspection](KWISATZ_INSPECTION.md) connects an -original companion face, private battle-loss progress and availability guidance, -authorized private/public plan components and a static internal reference. -Rules calculations, sealed choices, save shape and mode gates remain unchanged. -Source-bound checks, independent review, browser and preservation/Git evidence -belong to the private checkpoint. Full Advanced acceptance, exact manufactured -component verification and the remaining component inventory are unfinished. +Current work: [Storm and Spice Blow practice](INTERACTIVE_INTRODUCTION.md) expands +the introduction to thirteen lessons and eleven independent Basic examples. +Sealed dials, reveal, sector traversal, shelter, spice accumulation/storm loss, +first-turn worm return and later-worm Nexus timing reuse shared live calculations. +Version 6 preserves old lesson identities and outcomes. Full faction, Fremen-ride, +Advanced and expansion teaching remain unfinished. Source-bound checks, review, +browser and preservation/Git evidence belong to the private checkpoint. A bounded Duke review confirmed revival/loan paths still cross recorded custody, return and cycle questions; no duplicate ruling or invented outcome was added. -Storm/spice-blow teaching, full faction/Advanced/expansion teaching and broader -multiplayer acceptance remain unfinished. +Broader multiplayer and component acceptance remain unfinished. A bounded remaining-effect audit found that the reviewed faction/Nexus candidates still cross recorded pending rulings. Continue independent features rather than diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 690a7200..51229135 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,18 @@ # 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 The [companion inspector](KWISATZ_INSPECTION.md) connects original artwork, private diff --git a/docs/INTERACTIVE_INTRODUCTION.md b/docs/INTERACTIVE_INTRODUCTION.md index b3966b87..ab5df6e2 100644 --- a/docs/INTERACTIVE_INTRODUCTION.md +++ b/docs/INTERACTIVE_INTRODUCTION.md @@ -4,12 +4,46 @@ from the lobby and rules reference. It adds the previously missing guided introduction alongside the reference's existing standalone practice wheel. -Eleven 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 -collection and joining or recovering a saved table. Nine independent Basic practice positions let a player +Thirteen navigable lessons cover public/private table information and the phase +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. Eleven 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. +## 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`, `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 @@ -96,7 +130,7 @@ Resource regressions compare ordinary claims/returns against actual engine actions, preserve inputs, check cumulative spending and allowances, reject impossible saves and retain all nine v4 lesson identities and prior commitments. 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. 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. 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`. Version 5 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, -preserving prior choices and committed outcomes. New resource fields start at defaults for every older version. Versions 1–3 +`dune-introduction-v1`. Version 6 payloads explicitly migrate all five version 1, +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. 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. 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 diff --git a/game/disaster-preflight.ts b/game/disaster-preflight.ts index 2cb47d6b..c25d9b1c 100644 --- a/game/disaster-preflight.ts +++ b/game/disaster-preflight.ts @@ -1,3 +1,4 @@ +import { stormExposesTerritory, stormSectorAfter, wormConsumesForces } from './disaster-rules'; import type { Game, Player, ResponseWindow } from './engine'; import { splitLocation, territory, validLocation } from './board'; import { SPICE_CARDS } from './cards'; @@ -22,12 +23,7 @@ function boardKey(key: string) { return loc; } function exposed(g: Game, key: string) { - const t = territory(boardKey(key).territory); - return ( - (t.type === 'sand' && t.id !== 'imperial_basin') || - (!!g.shieldWallDestroyed && - ['arrakeen', 'carthag', 'imperial_basin'].includes(t.id)) - ); + return stormExposesTerritory(boardKey(key).territory, g.shieldWallDestroyed); } /** Validate only physical groups that this disaster can destroy. Concealed * 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, '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( r.pending.every( (key) => @@ -115,7 +111,7 @@ export function validateStormTraversal(g: Game) { const crossed = new Set( Array.from( { 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) @@ -185,7 +181,7 @@ export function validateWormDevouring( ) { requireDisaster(validTerritory(t), 'The worm needs a valid territory.'); for (const p of g.players) - if ((!protectFremen || p.faction !== 'fremen') && p.id !== protectedAlly) + if (wormConsumesForces(p, protectedAlly, protectFremen)) casualtyCustody( g, p, diff --git a/game/disaster-rules.ts b/game/disaster-rules.ts new file mode 100644 index 00000000..f1be6e1d --- /dev/null +++ b/game/disaster-rules.ts @@ -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>, 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 }; +} diff --git a/game/engine.ts b/game/engine.ts index 0ab0387c..58467448 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -1,3 +1,4 @@ +import { quoteSpicePlacement, stormExposesTerritory, stormSectorAfter, wormConsumesForces } from './disaster-rules'; 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 { leaderSkillCard, type LeaderSkillId } from './leader-skill-cards'; @@ -6082,12 +6083,7 @@ function stormOrder(g: Game) { .sort((a, b) => distance(a) - distance(b)); } function stormExposed(g: Game, key: string) { - const t = territory(splitLocation(key).territory); - return ( - (t.type === 'sand' && t.id !== 'imperial_basin') || - (g.shieldWallDestroyed && - ['arrakeen', 'carthag', 'imperial_basin'].includes(t.id)) - ); + return stormExposesTerritory(splitLocation(key).territory, g.shieldWallDestroyed); } function moveStorm(g: Game, n: number) { g.stormResolution = { from: g.storm, distance: n, traversed: 0, pending: [], @@ -6186,7 +6182,7 @@ function continueStorm(g: Game) { } if (resolution.traversed === resolution.distance) break; resolution.traversed++; - const s = ((resolution.from - 1 + resolution.traversed) % 18) + 1; + const s = stormSectorAfter(resolution.from, resolution.traversed); if (g.shieldWallDestroyed) for (const city of ['arrakeen', 'carthag']) if (territory(city).sectors.includes(s)) @@ -6196,9 +6192,7 @@ function continueStorm(g: Game) { if (marker && marker.sector === s) { const t = territory(marker.territory); if ( - (t.type === 'sand' && t.id !== 'imperial_basin') || - (g.shieldWallDestroyed && - ['arrakeen', 'carthag', 'imperial_basin'].includes(t.id)) + stormExposesTerritory(t.id, g.shieldWallDestroyed) ) revealPlayerNoField(g, p, 'storm'); } @@ -6206,9 +6200,7 @@ function continueStorm(g: Game) { const loc = splitLocation(key), t = territory(loc.territory); const exposed = - (t.type === 'sand' && t.id !== 'imperial_basin') || - (g.shieldWallDestroyed && - ['arrakeen', 'carthag', 'imperial_basin'].includes(t.id)); + stormExposesTerritory(t.id, g.shieldWallDestroyed); if (loc.sector === s && exposed) { if ( p.faction === 'choam' && @@ -6231,7 +6223,7 @@ function continueStorm(g: Game) { if (g.ecologicalStorm?.stage === 'traversal') g.ecologicalStorm = nexusRule(() => finishDiscoveryStorm(g.ecologicalStorm!)); delete g.stormMovementSource; - g.storm = ((resolution.from - 1 + resolution.distance) % 18) + 1; + g.storm = stormSectorAfter(resolution.from, resolution.distance); stormOrder(g); if (g.turn === 1) assignRemainingTech(g); log(g, `Storm moved ${resolution.distance} sectors to sector ${g.storm}.`); @@ -6288,7 +6280,7 @@ function devour( validateWormDevouring(g, t, protectedAlly, protectFremen), ); 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'); for (const k of Object.keys(g.spice)) 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); const amount = card.amount * (doubleNext ? 2 : 1); g.spiceWindow = { ...card, amount, harvested: false }; - if (card.sector !== g.storm) { - const k = location(card.territory, card.sector); - g.spice[k] = (g.spice[k] ?? 0) + amount; + const placement = quoteSpicePlacement(g.spice, g.storm, { ...card, amount }); + if (!placement.blocked) { + g.spice[placement.key] = placement.total; log(g, `${amount} spice appeared in ${territory(card.territory).name}.`); } else log(g, 'The spice blow was lost to the storm.'); } diff --git a/game/introduction-opening.ts b/game/introduction-opening.ts new file mode 100644 index 00000000..9f99de50 --- /dev/null +++ b/game/introduction-opening.ts @@ -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 = 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 = 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 = 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, + }; +} diff --git a/game/introduction.ts b/game/introduction.ts index f9ae7cb1..c3eed794 100644 --- a/game/introduction.ts +++ b/game/introduction.ts @@ -1,3 +1,4 @@ +import { introductionStorm, introductionSpiceBlow, OPENING_DEFAULTS, type IntroductionOpeningChoice } from './introduction-opening'; import { baseDeck, leaders } from './cards'; import { reserveShipmentCost } from './shipment-price'; 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_STEPS = [ { 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: 'Claim CHOAM Charity', topic: 'charity' }, { title: 'Bid for a hidden card', topic: 'bidding' }, @@ -22,8 +25,8 @@ export const INTRODUCTION_STEPS = [ { title: 'Collect the spice', topic: 'collection' }, { title: 'Join a table', topic: 'privacy' }, ] as const; -export type IntroductionState = IntroductionMovementChoice & IntroductionBiddingChoice & IntroductionAllianceChoice & IntroductionResourceChoice & { - version: 5; +export type IntroductionState = IntroductionMovementChoice & IntroductionBiddingChoice & IntroductionAllianceChoice & IntroductionResourceChoice & IntroductionOpeningChoice & { + version: 6; step: number; shipment: number; destination: 'stronghold' | 'sand'; @@ -40,7 +43,7 @@ export type IntroductionState = IntroductionMovementChoice & IntroductionBidding opponentTraitor: boolean; }; 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, moveDestination: 'pasty_mesa:5', moveForces: 3, moveCity: false, moveStorm: 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; try { 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. 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 === 3 && !integer(parsed.step, 0, 7)) 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 auctionDefaults = { auctionScenario: 'contest', auctionHandFull: false, auctionBid: 1, auctionActions: [] }; 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], moveDestination: 'pasty_mesa:5', moveForces: 3, moveCity: false, moveStorm: false, 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 === 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) 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) || !integer(s.shipment, 1, 6) || !['stronghold', 'sand'].includes(s.destination) || !integer(s.dial, 0, 6) || !['shield', 'snooper'].includes(s.defense) || @@ -84,8 +89,10 @@ export function restoreIntroduction(raw: string | null): IntroductionState | nul introductionAlliance(s); introductionCharity(s); introductionRevival(s); + introductionStorm(s); + introductionSpiceBlow(s); // 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, collectors: s.collectors, city: s.city, collected: s.collected, moveDestination: s.moveDestination, moveForces: s.moveForces, moveCity: s.moveCity, diff --git a/game/reference.ts b/game/reference.ts index d8a73ebe..6f9240c3 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -233,10 +233,11 @@ export const RULE_TOPICS: RuleTopic[] = [ category: 'Getting started', coverage: 'Partial', 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: [ - '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.', - '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.', + '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 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.', '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.', @@ -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.', '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: [ - { 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: '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: '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: '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: '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'] }, ], }, { diff --git a/tests/introduction-alliance.test.ts b/tests/introduction-alliance.test.ts index 1820c18d..894e9769 100644 --- a/tests/introduction-alliance.test.ts +++ b/tests/introduction-alliance.test.ts @@ -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, traitorStage: 'resolved', traitorCall: true, allianceActions: ['invalid'], allianceChecked: true, token: 'discarded' }; 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.deepEqual(restored.auctionActions, [null, 4]); assert.ok(restored.shipped && restored.moved && restored.revealed && restored.collected && restored.traitorCall); diff --git a/tests/introduction-bidding.test.ts b/tests/introduction-bidding.test.ts index 6789a3a2..095277e6 100644 --- a/tests/introduction-bidding.test.ts +++ b/tests/introduction-bidding.test.ts @@ -214,7 +214,7 @@ void test('all seven v2 lessons keep their identity and prior decisions when bid unrelated: 'removed', }; 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(result.shipped, true); assert.equal(result.revealed, true); diff --git a/tests/introduction-opening.test.ts b/tests/introduction-opening.test.ts new file mode 100644 index 00000000..34826190 --- /dev/null +++ b/tests/introduction-opening.test.ts @@ -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); +}); diff --git a/tests/introduction-practice.test.ts b/tests/introduction-practice.test.ts index cda0c2dd..d0c44302 100644 --- a/tests/introduction-practice.test.ts +++ b/tests/introduction-practice.test.ts @@ -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()) { const result = restoreIntroduction(JSON.stringify({ ...old, step, token: 'excluded', moved: true, traitorCall: true }))!; 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.moved, false); assert.equal(result.traitorCall, false); assert.equal('token' in result, false); 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' }, { 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); } }); diff --git a/tests/introduction-resources-migration.test.ts b/tests/introduction-resources-migration.test.ts index fa33c487..a46a11ee 100644 --- a/tests/introduction-resources-migration.test.ts +++ b/tests/introduction-resources-migration.test.ts @@ -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'], token: 'discarded' }; 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.ok(restored.shipped && restored.moved && restored.revealed && restored.collected && restored.traitorCall); assert.deepEqual(restored.auctionActions, [null, 4]); diff --git a/tests/introduction.test.ts b/tests/introduction.test.ts index 06b85a0b..cae307a0 100644 --- a/tests/introduction.test.ts +++ b/tests/introduction.test.ts @@ -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 }; 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: 6 }), - JSON.stringify({ ...saved, step: -1 }), JSON.stringify({ ...saved, step: 11 }), + for (const raw of [null, '', '{', 'null', '[]', JSON.stringify({ ...saved, version: 7 }), + JSON.stringify({ ...saved, step: -1 }), JSON.stringify({ ...saved, step: 13 }), 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 })]) {