Add an interactive Basic-game introduction
New players can open Learn to play from the lobby or rules reference. Five lessons explain public/private information and turn phases, then offer three independent exercises for ordinary shipment budgets, a sealed battle plan and spice collection, followed by joining and recovering a real table. Shipping, combat and collection use existing production quotes rather than a separate simplified calculator. The fixed opposing plan is a teaching example, not an AI match. Reuse the battle wheel and card inspectors, provide keyboard inputs, move focus to the current lesson heading and stack the layout on phones. Versioned browser-local choices preserve lesson position, shipment, defense/dial and reveal state. Whitelisted validation rejects corrupt saves and impossible committed shipments. SSR uses a stable hydration placeholder before reading browser storage; direct user handlers save choices and retain usable in-memory state on storage failure. No room, seat credential or multiplayer API is used. Six new tests cover prices and affordability, every offered battle dial/defense, protected ties, weapon death, actual losses/bounty, collection limits and city capacity, saved-choice validation and internal rule links. Types, lint, 4872 offline tests and production build pass on the unchanged source fingerprint below. The first required check stopped on React setState-in-effect and semantic-status lint findings; these were fixed before the successful complete run. Independent review found no material defect in the examples or final hydration/save implementation. Browser controls verified an unaffordable shipment, legal six-force shipment, card inspection, protected 8-8 aggressor victory, Snooper/projectile death and bounty, sealed refresh, city collection, lobby return/re-entry and newly changed choices after the hydration correction. At 390px the battle controls and lobby Learn link stayed within page bounds; the viewport override was reset. Screenshot capture timed out, so visual screenshot acceptance is still open. Storage-denial browser testing and the broader faction, movement, bidding, alliance, Advanced and expansion curriculum remain unfinished. All 1106 current rooms, 2314 seats and all original recovery/entry/handover rows exactly match the fresh outage backup. SQLite integrity and foreign-key checks pass. The healthy server was reused; no restart, database migration/reset or maintenance automation was needed. HTTP integration and full-game samples were not rerun for this isolated teaching surface; server game behavior did not change. A bounded independent primary-source review supports Smuggler's reveal-bound deferred survival receipt but found no exact ruling for modified leader strength. Record the one new user question and preserve the unanswered gate; lower collection remains missing. This does not enable any rules mode, expansion or publication gate. Verified source tree: 99718ab49c4fec14820cbaf35f2e0f815e6424e910ee67c4daa1941afdeca2f0
This commit is contained in:
1 parent
9b67a780eb
commit
9be7dfb9fb
14 files changed
+472
-6
No files matched your search
@@ -0,0 +1,82 @@
|
||||
import { baseDeck, leaders } from './cards';
|
||||
import { reserveShipmentCost } from './shipment-price';
|
||||
import { quoteBattleResolution, type ResolutionCombatant } from './battle-resolution-quote';
|
||||
import { quoteSpiceCollection } from './board-resolution-quote';
|
||||
|
||||
/** 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: 'Ship within your budget', topic: 'movement' },
|
||||
{ title: 'Seal a battle plan', topic: 'battle' },
|
||||
{ title: 'Collect the spice', topic: 'collection' },
|
||||
{ title: 'Join a table', topic: 'privacy' },
|
||||
] as const;
|
||||
export type IntroductionState = {
|
||||
version: 1;
|
||||
step: number;
|
||||
shipment: number;
|
||||
destination: 'stronghold' | 'sand';
|
||||
shipped: boolean;
|
||||
dial: number;
|
||||
defense: 'shield' | 'snooper';
|
||||
revealed: boolean;
|
||||
collectors: number;
|
||||
city: boolean;
|
||||
collected: boolean;
|
||||
};
|
||||
export function newIntroduction(): IntroductionState {
|
||||
return { version: 1, step: 0, shipment: 4, destination: 'sand', shipped: false,
|
||||
dial: 2, defense: 'shield', revealed: false, collectors: 3, city: false, collected: false };
|
||||
}
|
||||
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 s = JSON.parse(raw) as IntroductionState;
|
||||
if (!s || s.version !== 1 || !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)) return null;
|
||||
// Whitelist fields; never retain credentials or arbitrary imported properties.
|
||||
return { version: 1, 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 };
|
||||
} 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'>) {
|
||||
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: ['you', 'opponent'].map(id => ({ id, beneficiary: id, called: false, traitors: [] })),
|
||||
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'] };
|
||||
}
|
||||
@@ -201,6 +201,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
],
|
||||
related: [
|
||||
'setup',
|
||||
'interactive-introduction',
|
||||
'truthtrance-spice',
|
||||
'ecaz-ambassadors',
|
||||
'richese-cards',
|
||||
@@ -222,6 +223,29 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'advanced-combat',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'interactive-introduction',
|
||||
title: 'Interactive introduction',
|
||||
category: 'Getting started',
|
||||
coverage: 'Partial',
|
||||
developmentStage: 'Prototyped',
|
||||
summary: 'Practice shipping, sealed battle plans and spice collection before joining a table.',
|
||||
steps: [
|
||||
'Choose Learn to play in the lobby or at the top of this reference. Five lessons introduce table information, shipping budgets, battle choices, collection and saved-seat recovery.',
|
||||
'The three practice positions are separate Basic examples. Adjust the forces or destination, seal a plan with a defense, and compare collection with and without Arrakeen. Calculations use the same rule quotes as live games.',
|
||||
'The opponent uses a fixed teaching plan, revealed only after you seal yours. Try another plan to compare a Shield with a Snooper, or a low dial with the aggressor’s winning tie. This is an exercise, not an AI match.',
|
||||
'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, alliances, storm exceptions and expansions remain outside this first introduction. The full reference and phase guidance explain more; existing mode gates still apply.',
|
||||
],
|
||||
related: ['setup', 'movement', 'battle', 'collection', 'privacy', 'implementation-checklist'],
|
||||
checklist: [
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Five lessons and three interactive Basic examples reuse production shipment, combat and collection quotes. The complete teaching curriculum remains unfinished.', evidence: ['game/introduction.ts'] },
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'Lesson navigation, keyboard force sliders and battle wheel, card inspectors, seal/reveal, retry and browser-local continuation are connected.', evidence: ['components/introduction.tsx'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'A fixed opponent plan provides a reproducible teaching example. 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 cover pricing, all offered plans, weapon survival, ties, losses, collection limits and saved-choice validation. This does not certify all rules or the complete learning journey.', evidence: ['tests/introduction.test.ts'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'table-sounds',
|
||||
title: 'Sound effects',
|
||||
|
||||
Reference in new issue
Block a user