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) { 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) { return practiceBattle(s, false, false); } export function introductionTraitor(s: Pick) { return practiceBattle({ dial: 2, defense: 'snooper' }, s.traitorCall, s.opponentTraitor); } function practiceBattle(s: Pick, 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) { const forces: Record = { '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'] }; }