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
154 lines
10 KiB
TypeScript
154 lines
10 KiB
TypeScript
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';
|
|
import { quoteSpiceCollection } from './board-resolution-quote';
|
|
import { INTRODUCTION_MOVE_DESTINATIONS, introductionMovement, type IntroductionMovementChoice } from './introduction-movement';
|
|
import { introductionBidding, type IntroductionBiddingChoice } from './introduction-bidding';
|
|
import { introductionAlliance, INTRODUCTION_ALLIANCE_DEFAULTS, type IntroductionAllianceChoice } from './introduction-alliance';
|
|
import { introductionCharity, introductionRevival, RESOURCE_DEFAULTS, type IntroductionResourceChoice } from './introduction-resources';
|
|
|
|
/** Fixed teaching examples, never a room, seat or multiplayer save. */
|
|
export const INTRODUCTION_STORAGE_KEY = 'dune-introduction-v1';
|
|
export const INTRODUCTION_STEPS = [
|
|
{ title: 'Your place at the table', topic: 'setup' },
|
|
{ title: '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' },
|
|
{ title: 'Revive forces and leaders', topic: 'revival' },
|
|
{ title: 'Ship within your budget', topic: 'movement' },
|
|
{ title: 'Move across the board', topic: 'movement' },
|
|
{ title: 'Seal a battle plan', topic: 'battle' },
|
|
{ title: 'Reveal a Traitor', topic: 'battle' },
|
|
{ title: 'Collect the spice', topic: 'collection' },
|
|
{ title: 'Join a table', topic: 'privacy' },
|
|
] as const;
|
|
export type IntroductionState = IntroductionMovementChoice & IntroductionBiddingChoice & IntroductionAllianceChoice & IntroductionResourceChoice & IntroductionOpeningChoice & {
|
|
version: 6;
|
|
step: number;
|
|
shipment: number;
|
|
destination: 'stronghold' | 'sand';
|
|
shipped: boolean;
|
|
dial: number;
|
|
defense: 'shield' | 'snooper';
|
|
revealed: boolean;
|
|
collectors: number;
|
|
city: boolean;
|
|
collected: boolean;
|
|
moved: boolean;
|
|
traitorStage: 'plans' | 'revealed' | 'resolved';
|
|
traitorCall: boolean;
|
|
opponentTraitor: boolean;
|
|
};
|
|
export function newIntroduction(): IntroductionState {
|
|
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,
|
|
auctionScenario: 'contest', auctionHandFull: false, auctionBid: 1, auctionActions: [] };
|
|
}
|
|
const integer = (value: unknown, min: number, max: number): value is number =>
|
|
Number.isSafeInteger(value) && (value as number) >= min && (value as number) <= max;
|
|
export function restoreIntroduction(raw: string | null): IntroductionState | null {
|
|
if (!raw) return null;
|
|
try {
|
|
const parsed = JSON.parse(raw);
|
|
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 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);
|
|
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) ||
|
|
!integer(s.collectors, 0, 6) ||
|
|
![s.shipped, s.revealed, s.city, s.collected].every(v => typeof v === 'boolean') ||
|
|
(s.shipped && introductionShipment(s).cost > 8) ||
|
|
!INTRODUCTION_MOVE_DESTINATIONS.includes(s.moveDestination) || !integer(s.moveForces, 1, 5) ||
|
|
![s.moveCity, s.moveStorm, s.moved, s.traitorCall, s.opponentTraitor].every(v => typeof v === 'boolean') ||
|
|
!['plans', 'revealed', 'resolved'].includes(s.traitorStage) ||
|
|
(s.traitorStage !== 'resolved' && s.traitorCall) ||
|
|
(s.moved && !introductionMovement(s).allowed)) return null;
|
|
introductionBidding(s);
|
|
introductionAlliance(s);
|
|
introductionCharity(s);
|
|
introductionRevival(s);
|
|
introductionStorm(s);
|
|
introductionSpiceBlow(s);
|
|
// Whitelist fields; never retain credentials or arbitrary imported properties.
|
|
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,
|
|
moveStorm: s.moveStorm, moved: s.moved, traitorStage: s.traitorStage,
|
|
traitorCall: s.traitorCall, opponentTraitor: s.opponentTraitor,
|
|
auctionScenario: s.auctionScenario, auctionHandFull: s.auctionHandFull,
|
|
auctionBid: s.auctionBid, auctionActions: [...s.auctionActions],
|
|
allianceScenario: s.allianceScenario, allianceHoldings: s.allianceHoldings,
|
|
allianceActions: [...s.allianceActions], allianceClosed: s.allianceClosed, allianceChecked: s.allianceChecked,
|
|
charitySpice: s.charitySpice, charityClaimed: s.charityClaimed,
|
|
revivalFaction: s.revivalFaction, revivalSpice: s.revivalSpice, revivalAmount: s.revivalAmount,
|
|
revivalLeader: s.revivalLeader, revivalRoster: s.revivalRoster,
|
|
revivalActions: s.revivalActions.map(a => a.kind === 'forces' ? { kind: a.kind, amount: a.amount } : { kind: a.kind, leader: a.leader }) };
|
|
} catch { return null; }
|
|
}
|
|
export function introductionShipment(s: Pick<IntroductionState, 'shipment' | 'destination'>) {
|
|
const cost = reserveShipmentCost({ faction: 'atreides', halfRate: false }, s.destination, s.shipment);
|
|
return { cost, remaining: 8 - cost, affordable: cost <= 8 };
|
|
}
|
|
export const INTRODUCTION_CARDS = {
|
|
shield: baseDeck().find(c => c.kind === 'shield')!,
|
|
snooper: baseDeck().find(c => c.kind === 'snooper')!,
|
|
weapon: baseDeck().find(c => c.kind === 'projectile')!,
|
|
};
|
|
export const INTRODUCTION_LEADERS = { you: leaders('atreides')[0], opponent: leaders('guild')[0] };
|
|
export function introductionBattle(s: Pick<IntroductionState, 'dial' | 'defense'>) {
|
|
return practiceBattle(s, false, false);
|
|
}
|
|
export function introductionTraitor(s: Pick<IntroductionState, 'traitorCall' | 'opponentTraitor'>) {
|
|
return practiceBattle({ dial: 2, defense: 'snooper' }, s.traitorCall, s.opponentTraitor);
|
|
}
|
|
function practiceBattle(s: Pick<IntroductionState, 'dial' | 'defense'>, call: boolean, opponentCall: boolean) {
|
|
const side = (you: boolean): ResolutionCombatant => {
|
|
const leader = you ? INTRODUCTION_LEADERS.you : INTRODUCTION_LEADERS.opponent;
|
|
const card = you ? INTRODUCTION_CARDS[s.defense] : INTRODUCTION_CARDS.weapon;
|
|
return { id: you ? 'you' : 'opponent', faction: you ? 'atreides' : 'guild', spice: 0,
|
|
hand: [card], leader, plan: { dial: you ? s.dial : 3, support: 0, leader: leader.id,
|
|
weapon: you ? null : card.id, defense: you ? card.id : null },
|
|
forces: { normal: 6, elite: 0, eliteStrength: 1, freeSupport: true } };
|
|
};
|
|
return quoteBattleResolution({ advanced: false, turn: 2, territory: 'wind_pass',
|
|
attacker: side(true), defender: side(false),
|
|
voters: [
|
|
{ id: 'you', beneficiary: 'you', called: call, traitors: [INTRODUCTION_LEADERS.opponent.id] },
|
|
{ id: 'opponent', beneficiary: 'opponent', called: opponentCall,
|
|
traitors: opponentCall ? [INTRODUCTION_LEADERS.you.id] : [] },
|
|
],
|
|
participants: [{ id: 'you', faction: 'atreides' }, { id: 'opponent', faction: 'guild' }],
|
|
physicalCards: baseDeck(), pendingAuditorPresent: false, pendingRetentionPresent: false });
|
|
}
|
|
export function introductionCollection(s: Pick<IntroductionState, 'collectors' | 'city'>) {
|
|
const forces: Record<string, number> = { 'wind_pass:14': s.collectors };
|
|
if (s.city) forces['arrakeen:10'] = 1;
|
|
const quote = quoteSpiceCollection({ advanced: false, storm: 18, order: ['you'],
|
|
players: [{ id: 'you', faction: 'atreides', ally: null, forces, spice: 0 }],
|
|
spice: { 'wind_pass:14': 8 } });
|
|
return { amount: quote.receipts[0].collected, remaining: quote.spice['wind_pass:14'] };
|
|
}
|