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

+5 -9
View File
@@ -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,
+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 { 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.');
}
+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 { 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,
+8 -7
View File
@@ -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'] },
],
},
{