Add movement and Traitor practice to the introduction
Expand /learn from five lessons/three exercises to seven lessons/five exercises. Movement practice uses shared board geometry and occupancy rules to teach ordinary range, separate-city ornithopters, storm blocking, full strongholds, one-group movement and conserved force counts. Traitor practice uses the production combat quote for an optional post-reveal call, a declined losing plan, a sole successful call and mutual betrayal, with the existing full private Traitor inspector. The fixed opponent scenario is explicitly teaching data, not live private information or an AI match. Migrate every v1 saved lesson to its same identity after insertion, preserving previous choices and outcomes under the existing browser key. Validate v2 movement commitments and reveal/call stages, whitelist saved fields and default only new choices. Browser testing exposed stale pre-update React state surviving hot refresh; a schema-keyed lesson remount runs the migration for development updates too. No live rules, AI strategy, room persistence or server scheduling changed. Eleven focused cases include all 96 movement combinations against actual authoritative actions, exact force/spice preservation, sole/mutual/declined Traitor outcomes, all five old lesson mappings and malformed or impossible save rejection. Independent review found no material teaching, privacy or restore issue. Types, lint, all 4899 offline tests and production build pass on the unchanged source fingerprint. HTTP and complete-game runs were not repeated for this browser-local guidance change. Browser checks verified range, storm and full-stronghold explanations, committed movement after refresh, private Traitor inspection, decline and successful-call outcomes, and mutual calls after a refresh at the revealed-choice stage. At a 390px viewport both new lessons fit within the 375px client width without horizontal overflow; keyboard movement selection committed four arrivals and one source remainder. Screenshot acceptance remains open. The viewport was reset and /learn retained as the deliverable. All 1174 rooms, 2456 seats and every original recovery/entry/handover row remain unchanged. SQLite integrity and foreign keys pass. No new live room, migration, reset, restart, usage check or automation was needed. Broader curriculum, rules/mode acceptance and publication remain unfinished. The status dashboard identifies durable unattended bot recovery as the next independent backend capability; pending faction-rule questions were preserved. Verified source tree: 6f25e31c2d8865dac823762d7da782f21f7538034c9d3c0e853872d796ced6f7
This commit is contained in:
1 parent
54c8030b9b
commit
e13ad454e2
11 files changed
+357
-53
No files matched your search
@@ -0,0 +1,40 @@
|
||||
import { distance, splitLocation, territory } from './board';
|
||||
import { strongholdPathBlocked, territoryEntryBlock, type OccupancySeat } from './occupancy';
|
||||
|
||||
export const INTRODUCTION_MOVE_SOURCE = 'red_chasm:7';
|
||||
export const INTRODUCTION_MOVE_DESTINATIONS = [
|
||||
'pasty_mesa:5', 'shield_wall:8', 'imperial_basin:9', 'tueks_sietch:5',
|
||||
] as const;
|
||||
export type IntroductionMoveDestination = typeof INTRODUCTION_MOVE_DESTINATIONS[number];
|
||||
export type IntroductionMovementChoice = {
|
||||
moveDestination: IntroductionMoveDestination;
|
||||
moveForces: number;
|
||||
moveCity: boolean;
|
||||
moveStorm: boolean;
|
||||
};
|
||||
|
||||
/** Fixed Basic teaching position; shared map geometry and occupancy rules. */
|
||||
export function introductionMovement(s: IntroductionMovementChoice) {
|
||||
const target = splitLocation(s.moveDestination);
|
||||
const forces: Record<string, number> = { [INTRODUCTION_MOVE_SOURCE]: 5 };
|
||||
if (s.moveCity) forces['arrakeen:10'] = 1;
|
||||
const players: OccupancySeat[] = [
|
||||
{ id: 'you', faction: 'atreides', forces },
|
||||
{ id: 'guild', faction: 'guild', forces: { 'tueks_sietch:5': 1 } },
|
||||
{ id: 'emperor', faction: 'emperor', forces: { 'tueks_sietch:5': 1 } },
|
||||
];
|
||||
const storm = s.moveStorm ? target.sector : 18;
|
||||
const range = s.moveCity ? 3 : 1;
|
||||
const steps = distance(INTRODUCTION_MOVE_SOURCE, s.moveDestination, key => {
|
||||
const at = splitLocation(key);
|
||||
return at.sector === storm || strongholdPathBlocked(players, 'you', at.territory);
|
||||
});
|
||||
const blocked = !Number.isSafeInteger(s.moveForces) || s.moveForces < 1 || s.moveForces > 5
|
||||
? 'Choose one to five of your source forces.'
|
||||
: target.sector === storm ? 'Forces cannot enter a sector covered by the storm.'
|
||||
: territoryEntryBlock(players, 'you', target.territory)
|
||||
?? (steps > range ? `This destination is beyond your ${range}-territory movement range.` : null);
|
||||
return { name: territory(target.territory).name, sector: target.sector,
|
||||
range, steps, blocked, allowed: blocked === null,
|
||||
sourceRemaining: 5 - s.moveForces, arrived: s.moveForces };
|
||||
}
|
||||
+44
-10
@@ -2,18 +2,21 @@ 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';
|
||||
|
||||
/** 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: '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 = {
|
||||
version: 1;
|
||||
export type IntroductionState = IntroductionMovementChoice & {
|
||||
version: 2;
|
||||
step: number;
|
||||
shipment: number;
|
||||
destination: 'stronghold' | 'sand';
|
||||
@@ -24,27 +27,48 @@ export type IntroductionState = {
|
||||
collectors: number;
|
||||
city: boolean;
|
||||
collected: boolean;
|
||||
moved: boolean;
|
||||
traitorStage: 'plans' | 'revealed' | 'resolved';
|
||||
traitorCall: boolean;
|
||||
opponentTraitor: 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 };
|
||||
return { version: 2, 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 };
|
||||
}
|
||||
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) ||
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed || (parsed.version !== 1 && parsed.version !== 2)) 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;
|
||||
const s = (parsed.version === 1 ? { ...newIntroduction(), ...parsed, version: 2,
|
||||
step: [0, 1, 3, 5, 6][parsed.step],
|
||||
moveDestination: 'pasty_mesa:5', moveForces: 3, moveCity: false, moveStorm: false,
|
||||
moved: false, traitorStage: 'plans', traitorCall: false, opponentTraitor: false } : parsed) 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)) return null;
|
||||
(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;
|
||||
// Whitelist fields; never retain credentials or arbitrary imported properties.
|
||||
return { version: 1, step: s.step, shipment: s.shipment, destination: s.destination,
|
||||
return { version: 2, 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 };
|
||||
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 };
|
||||
} catch { return null; }
|
||||
}
|
||||
export function introductionShipment(s: Pick<IntroductionState, 'shipment' | 'destination'>) {
|
||||
@@ -58,6 +82,12 @@ export const INTRODUCTION_CARDS = {
|
||||
};
|
||||
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;
|
||||
@@ -68,7 +98,11 @@ export function introductionBattle(s: Pick<IntroductionState, 'dial' | 'defense'
|
||||
};
|
||||
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: [] })),
|
||||
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 });
|
||||
}
|
||||
|
||||
+7
-7
@@ -229,21 +229,21 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
category: 'Getting started',
|
||||
coverage: 'Partial',
|
||||
developmentStage: 'Prototyped',
|
||||
summary: 'Practice shipping, sealed battle plans and spice collection before joining a table.',
|
||||
summary: 'Practice 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. 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.',
|
||||
'Choose Learn to play in the lobby or at the top of this reference. Seven lessons introduce table information, shipping budgets, movement, battle choices, Traitor calls, collection and saved-seat recovery.',
|
||||
'The five 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. Shipping, combat and collection use the same rule calculations 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.',
|
||||
'Bidding, faction lessons, Advanced combat, alliances, broader storm exceptions and expansions remain outside this 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: 'Implementation', status: 'Partial', detail: 'Seven lessons and five interactive Basic examples reuse production shipment, map/occupancy, combat and collection calculations. The complete teaching curriculum remains unfinished.', evidence: ['game/introduction.ts', 'game/introduction-movement.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', 'components/introduction-practice.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'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Focused checks cover 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'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user