From e13ad454e2d0dd8a30b5d1ead81fe1f9921badc3 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Sun, 20 Sep 2026 15:44:14 +1000 Subject: [PATCH] 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 --- README.md | 2 +- components/introduction-practice.tsx | 95 ++++++++++++++++++++++++++++ components/introduction.tsx | 16 +++-- docs/CURRENT_STATUS.md | 32 ++++++---- docs/IMPLEMENTATION_STATUS.md | 12 ++++ docs/INTERACTIVE_INTRODUCTION.md | 47 ++++++++++---- game/introduction-movement.ts | 40 ++++++++++++ game/introduction.ts | 54 +++++++++++++--- game/reference.ts | 14 ++-- tests/introduction-practice.test.ts | 92 +++++++++++++++++++++++++++ tests/introduction.test.ts | 6 +- 11 files changed, 357 insertions(+), 53 deletions(-) create mode 100644 components/introduction-practice.tsx create mode 100644 game/introduction-movement.ts create mode 100644 tests/introduction-practice.test.ts diff --git a/README.md b/README.md index c6fb9523..815b0dfd 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ Until that stage, AI work focuses on minimal legal participation and critical playability fixes. Choose **Learn to play** for the [interactive introduction](docs/INTERACTIVE_INTRODUCTION.md) -at `/learn`. Practice shipping, battle plans and collection before entering a +at `/learn`. Practice shipping, movement, battle plans, Traitor calls and collection before entering a room; lesson progress stays in your browser and does not change saved games. Local games persist in `.wrangler/state`. Keep this directory across restarts. Apply new additive migrations before running code that depends on them. Never delete the local database to resolve a connection or rules problem. `.openai/hosting.json` belongs to the existing Sites project; do not recreate the hosting project. diff --git a/components/introduction-practice.tsx b/components/introduction-practice.tsx new file mode 100644 index 00000000..17f185bf --- /dev/null +++ b/components/introduction-practice.tsx @@ -0,0 +1,95 @@ +import { INTRODUCTION_LEADERS, introductionTraitor, type IntroductionState } from '@/game/introduction'; +import { INTRODUCTION_MOVE_DESTINATIONS, introductionMovement } from '@/game/introduction-movement'; +import { splitLocation, territory } from '@/game/board'; +import { LeaderInspector } from './leader-inspector'; +import styles from './introduction.module.css'; + +type Props = { state: IntroductionState; update: (patch: Partial) => void }; + +export function MovementLesson({ state, update }: Props) { + const move = introductionMovement(state); + return <> +

You have five ordinary Atreides forces in Red Chasm, sector 7. Move some or all as one group. This separate practice position has no allies or reacting powers.

+
+
+ + + + update({ moveForces: Number(e.target.value), moved: false })} /> + + + +
+
+

{state.moved ? 'Movement complete' : 'Check the route'}

+

Your range is {move.range} {move.range === 1 ? 'territory' : 'territories'}{state.moveCity ? ' because you already occupy Arrakeen.' : '.'}

+

Guild and Emperor each occupy Tuek’s Sietch in this position. A third faction cannot enter or pass through that stronghold.

+ {move.blocked ?

{move.blocked}

: <> +

The shortest open route to {move.name}, sector {move.sector}, crosses {move.steps} {move.steps === 1 ? 'territory' : 'territories'}.

+ {state.moved ?

{move.arrived} forces arrived. {move.sourceRemaining} remain in Red Chasm. {state.moveCity && 'The separate Arrakeen force stays there. '}Movement costs no spice and uses your one ordinary move.

+ :

You can move this group. Forces left behind stay in Red Chasm.

} + } + {state.moved && } +
+
+

Crossing a territory boundary uses range; crossing a sector line within one territory does not add range. Storm sectors and stronghold capacity still constrain the path. Ornithopters let any one group move up to three territories, even when it starts away from the city.

+

Change a control to start a fresh example. Live movement has further faction, alliance and card rules; this exercise uses ordinary Basic movement.

+ ; +} + +export function TraitorLesson({ state, update }: Props) { + const resolved = state.traitorStage === 'resolved'; + const revealed = state.traitorStage !== 'plans'; + const outcome = resolved ? introductionTraitor(state) : null; + const remaining = (player: string) => !outcome ? 6 : outcome.destroyedArmies.includes(player) + ? 0 : 6 - (outcome.winner === player ? outcome.basicWinnerLosses ?? 0 : 0); + return <> +

A fresh battle has six forces on each side. You privately hold the Traitor for {INTRODUCTION_LEADERS.opponent.name}. A matching Traitor can overturn a losing plan, but you choose whether to reveal it after both plans appear.

+ +
+
+ +

This chooses the teaching scenario. At a real table, you cannot see the opponent’s private Traitors.

+ {!revealed ? <> +

Your example plan uses {INTRODUCTION_LEADERS.you.name}, dial 2, no weapon and a Snooper. The opposing plan stays face down until you reveal both.

+ + : <> +

Both plans are revealed

+

You: {INTRODUCTION_LEADERS.you.name}, dial 2, Snooper, no weapon.

+

Opponent: {INTRODUCTION_LEADERS.opponent.name}, dial 3, projectile weapon, no defense.

+ {!resolved && <> + + + } + } +
+
+ {!outcome ? <>

{revealed ? 'Your Traitor matches' : 'Wait for the reveal'}

{revealed + ? 'The opposing leader matches the card you hold. You may call it now or keep it secret. Ordinary weapon and score resolution waits for the Traitor decisions.' + : 'Having a Traitor is not enough: the opposing plan must use that leader. Your card remains private until you call it.'}

+ : <> +

{outcome.result === 'mutualTraitors' ? 'Both leaders betrayed their factions' + : outcome.winner === 'you' ? 'Your Traitor wins the battle' : 'Your opponent wins'}

+

{outcome.result === 'mutualTraitors' ? 'Both leaders die and both armies are destroyed. Neither side wins or receives a leader bounty.' + : outcome.result === 'traitor' ? `${outcome.winner === 'you' ? 'Your' : 'The opponent’s'} successful call wins before weapons or battle scores resolve. The winner keeps every force and its leader; the betrayed leader dies and its army is destroyed.` + : 'You declined and the opponent made no call. The projectile kills your leader because a Snooper only stops poison. Ordinary scores decide the battle.'}

+ {outcome.scores &&

Your strength: {outcome.scores.attacker}. Opponent: {outcome.scores.defender}.

} +

Your forces remaining: {remaining('you')}. Opponent: {remaining('opponent')}.

+

{outcome.bounty?.amount ? `${outcome.bounty.player === 'you' ? 'You collect' : 'The opponent collects'} ${outcome.bounty.amount} spice for the killed leader.` : 'No leader bounty is paid.'}

+

{outcome.winner ? 'The defeated player discards the cards used in the plan. The winner may keep or discard its used card.' : 'Both sides discard the cards used in their plans.'} A called Traitor card stays face up with its owner.

+ + } +
+
+ ; +} diff --git a/components/introduction.tsx b/components/introduction.tsx index f5f9ae04..d2bef812 100644 --- a/components/introduction.tsx +++ b/components/introduction.tsx @@ -5,6 +5,7 @@ import { useEffect, useRef, useState, useSyncExternalStore } from 'react'; import { BattleWheel } from './battle-wheel'; import { CardInspector, CardRules } from './card-inspector'; import { PHASES } from '@/game/catalog'; +import { MovementLesson, TraitorLesson } from './introduction-practice'; import { INTRODUCTION_CARDS, INTRODUCTION_LEADERS, INTRODUCTION_STEPS, INTRODUCTION_STORAGE_KEY, introductionBattle, introductionCollection, introductionShipment, newIntroduction, restoreIntroduction, @@ -23,9 +24,10 @@ export function Introduction() { ← Return to Arrakis

LEARN TO PLAY · BASIC GAME

A first turn on Arrakis

-

Try three small examples, then take a seat at a real table. These are separate practice positions; they do not change any saved game.

+

Practice shipping, movement, battle plans, Traitor calls and collection, then take a seat at a real table. These are separate examples; they do not change any saved game.

- {hydrated ? : Restoring your lesson…} + {/* Remount on schema changes so a development hot update also migrates old state. */} + {hydrated ? : Restoring your lesson…} ; } @@ -97,7 +99,8 @@ function IntroductionLessons() {

Ordinary movement reaches an adjacent territory. Ornithopters and faction powers can extend its range. Inspect the live destination guide before committing.

} - {state.step === 2 && <> + {state.step === 2 && } + {state.step === 3 && <>

A separate battle: you are the aggressor in Wind Pass with six ordinary forces and {INTRODUCTION_LEADERS.you.name}, strength {INTRODUCTION_LEADERS.you.strength}. Your opponent also has six forces. Neither side calls a Traitor or uses a faction power in this example.

@@ -125,7 +128,8 @@ function IntroductionLessons() {
} - {state.step === 3 && <> + {state.step === 4 && } + {state.step === 5 && <>

A fresh position has 8 spice in Wind Pass, sector 14. You have surviving forces in that same sector, outside the storm. Collection uses the forces still on the board after battles.

@@ -141,8 +145,8 @@ function IntroductionLessons() {
} - {state.step === 4 && <> -

You can revisit any example. This introduction covers the core resource and battle choices; it does not teach every faction, card, alliance, storm exception or expansion.

+ {state.step === 6 && <> +

You can revisit any example. This introduction covers core resources, movement and battle choices; it does not teach bidding or every faction, card, alliance, storm exception or expansion.

  • Create a private room, choose a faction, and add friends or AI players. Share the room invitation.
  • Mark yourself ready after reviewing the selected rules. The host begins once all seats are ready.
  • diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 79ae464d..57a6e7a8 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -6,20 +6,26 @@ gated.** Prototypes do not open completion or publication gates. ## Current checkpoint and work -Latest delivered gameplay checkpoint: `20cc605`, [Smuggler battle collection](SMUGGLER_BATTLE.md). -Types, lint, **4,884 offline tests**, production build and **43 HTTP tests** passed. -The surviving unmodified leader collects even after losing or capture, with saved -settlement before Sandmaster. All 1,106 baseline rooms and 2,314 seats stayed -unchanged; isolated checks added 34 rooms and 71 seats. No restart was needed. -The subsequent `8f7a62c` policy checkpoint removed the usage cutoff and deferred -AI refinement until feature completion, with three adjacent 75% win-rate targets. -Earlier evidence remains in the [implementation history](IMPLEMENTATION_STATUS.md). +Latest delivered gameplay checkpoint: `54c8030`, [Sandmaster worm rides](SANDMASTER_WORM.md). +Types, lint, **4,894 offline tests**, production build and **43 HTTP tests** passed. +Optional native Fremen destination collection connects multi-sector forces, +controls, minimal legal AI and saved Bene Gesserit arrival continuation. All 1,140 +baseline rooms and 2,385 seats stayed unchanged; isolated checks added 34 rooms +and 71 seats. No restart was needed. The `8f7a62c` policy checkpoint removed the +usage cutoff and deferred AI refinement until feature completion, with three +adjacent 75% win-rate targets. Earlier evidence remains in the +[implementation history](IMPLEMENTATION_STATUS.md). -Current work: [Sandmaster worm rides](SANDMASTER_WORM.md) connect optional native -Fremen destination collection, multi-sector forces, controls, minimal legal AI -participation and saved Bene Gesserit arrival continuation. Multiple piles and -combined modules remain guarded. Final verification and delivery belong to the -source-bound private report and Git message. +Current work: the [interactive introduction](INTERACTIVE_INTRODUCTION.md) adds +movement and Traitor practice through shared rules, legal controls and migrated +browser-local saves. Broader curriculum remains unfinished. Final verification +and delivery belong to the source-bound private report and Git message. + +A bounded remaining-effect audit found that the reviewed faction/Nexus candidates +still cross recorded pending rulings. Continue independent features rather than +inventing answers. Durable unattended bot resumption is the next clear backend +capability: it needs a platform scheduler and saved due-work metadata; current +request/reconnect continuation does not establish it. The connected [Harass & Withdraw prototype](HARASS_WITHDRAW_RUNTIME.md) supports either battle-card slot, private category inspections, exact undialed normal/elite diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index db10b6b5..855fea52 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,17 @@ # Dune implementation status +## 20 September 2026 — Movement and Traitor introduction exercises + +The [introduction](INTERACTIVE_INTRODUCTION.md) now connects seven lessons and +five Basic practice positions. Movement uses shared geometry/occupancy; Traitor +call, decline and mutual betrayal use the production battle quote. Version 2 +browser saves explicitly migrate every old lesson and preserve its previous +choices. A hot-update state mismatch found in the browser is corrected by +remounting the lesson component on schema change. Focused comparisons exercise +96 actual engine moves and all offered Traitor outcomes. Independent review and +final browser/check/build, preservation and Git evidence belong to the private +source-bound checkpoint. Full curriculum remains unfinished; mode/publication gates stay closed. + ## 20 September 2026 — Sandmaster worm-ride prototype [Native Fremen worm rides](SANDMASTER_WORM.md) collect one optional destination diff --git a/docs/INTERACTIVE_INTRODUCTION.md b/docs/INTERACTIVE_INTRODUCTION.md index a702d430..c6500149 100644 --- a/docs/INTERACTIVE_INTRODUCTION.md +++ b/docs/INTERACTIVE_INTRODUCTION.md @@ -4,25 +4,43 @@ from the lobby and rules reference. It adds the previously missing guided introduction alongside the reference's existing standalone practice wheel. -Five navigable lessons cover public/private table information and the phase -sequence, shipment budgets, sealed battle plans, spice collection and joining or -recovering a saved table. Three independent Basic practice positions let a player +Seven navigable lessons cover public/private table information and the phase +sequence, shipment budgets, movement, sealed battle plans, Traitor calls, spice +collection and joining or recovering a saved table. Five independent Basic practice positions let a player change inputs, commit, inspect results and retry. These positions are deliberately separate examples rather than a complete game or an authoritative live room. -The examples call `reserveShipmentCost`, `quoteBattleResolution` and -`quoteSpiceCollection`. Battle uses six ordinary forces, real Atreides/Guild +The examples call `reserveShipmentCost`, `quoteBattleResolution`, +`quoteSpiceCollection` and the shared board distance/occupancy helpers. Battle uses six ordinary forces, real Atreides/Guild leaders and canonical base-deck cards. The fixed opponent's dial, leader and projectile weapon appear only after the player's seal action. The resulting weapon survival, scores, ordinary aggressor tie, force losses and bounty come -from the production combat quote. There are no Traitor calls or faction powers -in this teaching position. It is not an AI policy or hidden-information test of -the four live difficulty levels. +from the production combat quote. There are no Traitor calls or faction powers in the original sealed-plan +position. A separate Traitor lesson offers call or decline after revealing both +plans, with an explicitly selected opposing-call scenario. Ordinary, sole-call +and mutual-call casualties, leader survival, cards and bounty use the same +production combat quote. The private Traitor has the existing full inspector. +These fixed teaching scenarios are not AI policy or hidden-information tests +of the four live difficulty levels. + +The movement position starts with five ordinary forces in Red Chasm. It compares +range one with range three from a separate Arrakeen occupant, exact sectors, +storm exclusion and a stronghold already occupied by two other factions. Accepted +moves preserve the source remainder and city force and spend no spice. Choices +reset that practice position, rather than granting multiple moves in a turn. All +96 offered destination/range/storm/force combinations are cross-checked against +actual authoritative `applyAction` transitions; no live movement rule changed. The existing wheel, keyboard controls and card inspectors remain available. Lesson changes focus the heading; responsive columns stack on small screens. Browser storage contains only a versioned, validated set of lesson choices under -`dune-introduction-v1`. It includes the sealed/revealed state. Unknown versions, +`dune-introduction-v1`. Version 2 payloads explicitly migrate each of the five old +version 1 lesson indices to the same lesson after insertion, preserving every +prior choice and committed outcome. New movement and Traitor fields start at +their defaults; hot updates remount the lesson state to apply this migration. +Future lesson insertions must update the schema and mapping again. The save +includes movement commitments and the before-reveal, revealed and resolved +Traitor stages. Unknown versions, out-of-range values and impossible saved shipments fall back to the first lesson. Unknown fields are dropped. Storage failure leaves the exercise usable in memory and displays that refresh may reset it. Separate tabs may save different progress; @@ -31,7 +49,9 @@ used, and no server restart or database migration is required. `tests/introduction.test.ts` checks unaffordable shipping, protected ties, wrong defense and leader death, every offered dial/defense pair, collection capacity, -save round trips and malformed state. Browser interaction checks confirmed the +save round trips and malformed state. `tests/introduction-practice.test.ts` adds +authoritative movement comparisons, single/mutual/declined Traitor outcomes, all +five legacy lesson migrations and impossible-new-save rejection. Browser interaction checks for the initial prototype confirmed the unaffordable-shipment explanation, a legal shipment, card inspection, a protected 8–8 tie, the wrong defense causing leader death, saved reveal after refresh, city collection and lobby return/re-entry. At a 390px viewport the battle controls @@ -39,6 +59,7 @@ and new lobby link stayed within the page bounds. Screenshot capture timed out, so visual screenshot acceptance remains open; DOM bounds are narrower evidence. Final source-bound reports and Git delivery record required check/build results. -This does not replace a full interactive curriculum. Movement paths, bidding, -Traitor calls, alliances, faction lessons, Advanced support and expansion lessons -remain future work. No rules mode, expansion or publication gate is opened. +This does not replace a full interactive curriculum. Bidding, alliances, broader movement/arrival powers, faction lessons, Advanced +support and expansion lessons remain future work. The expanded lessons retain +Partial/Prototyped coverage. Final browser/check/build, preservation and Git +evidence for this follow-up belong to its source-bound private checkpoint. No rules mode, expansion or publication gate is opened. diff --git a/game/introduction-movement.ts b/game/introduction-movement.ts new file mode 100644 index 00000000..3688c28a --- /dev/null +++ b/game/introduction-movement.ts @@ -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 = { [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 }; +} diff --git a/game/introduction.ts b/game/introduction.ts index 65d41e6b..5b367571 100644 --- a/game/introduction.ts +++ b/game/introduction.ts @@ -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) { @@ -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) { + 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; @@ -68,7 +98,11 @@ export function introductionBattle(s: Pick ({ 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 }); } diff --git a/game/reference.ts b/game/reference.ts index ed8a95a7..a416de56 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -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'] }, ], }, { diff --git a/tests/introduction-practice.test.ts b/tests/introduction-practice.test.ts new file mode 100644 index 00000000..6e530aaa --- /dev/null +++ b/tests/introduction-practice.test.ts @@ -0,0 +1,92 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { INTRODUCTION_STEPS, introductionTraitor, newIntroduction, restoreIntroduction } from '../game/introduction'; +import { INTRODUCTION_MOVE_DESTINATIONS, introductionMovement } from '../game/introduction-movement'; +import { applyAction, createGame, newPlayer } from '../game/engine'; +import { splitLocation } from '../game/board'; + +void test('movement practice agrees with authoritative moves across every destination, range and storm choice', () => { + for (const moveDestination of INTRODUCTION_MOVE_DESTINATIONS) for (const moveCity of [false, true]) + for (const moveStorm of [false, true]) for (const moveForces of [1, 3, 5]) { + const choice = { moveDestination, moveCity, moveStorm, moveForces }; + const quote = introductionMovement(choice), target = splitLocation(moveDestination); + const g = createGame('PRACTICE', newPlayer('you', 'You', 'atreides')); + g.players.push(newPlayer('guild', 'Guild', 'guild'), newPlayer('emperor', 'Emperor', 'emperor')); + Object.assign(g, { status: 'playing', phase: 5, turn: 2, active: 'you', order: ['you', 'guild', 'emperor'], + movementRemaining: ['you', 'guild', 'emperor'], storm: moveStorm ? target.sector : 18, phaseOpening: null }); + g.players[0].forces = { 'red_chasm:7': 5, ...(moveCity ? { 'arrakeen:10': 1 } : {}) }; + g.players[0].reserves = moveCity ? 14 : 15; g.players[0].shipped = true; + g.players[0].spice = 8; + for (const p of g.players.slice(1)) { p.forces = { 'tueks_sietch:5': 1 }; p.reserves = 19; } + const action = { type: 'move', forces: { 'red_chasm:7': moveForces }, territory: target.territory, sector: target.sector }; + const before = structuredClone(g); + if (quote.allowed) { + const done = applyAction(g, 'you', action), own = done.players[0]; + assert.equal(own.forces[moveDestination], quote.arrived); + assert.equal(own.forces['red_chasm:7'] ?? 0, quote.sourceRemaining); + assert.equal(own.forces['arrakeen:10'] ?? 0, moveCity ? 1 : 0); + assert.equal(own.spice, 8); assert.equal(own.moved, 1); + assert.equal(own.reserves + Object.values(own.forces).reduce((s, n) => s + n, 0), 20); + } else assert.throws(() => applyAction(g, 'you', action)); + assert.deepEqual(g, before); + } +}); +void test('movement practice distinguishes range, destination storm and full stronghold restrictions', () => { + const s = newIntroduction(); + assert.equal(introductionMovement(s).steps, 1); + assert.equal(introductionMovement(s).allowed, true); + assert.match(introductionMovement({ ...s, moveDestination: 'imperial_basin:9' }).blocked!, /range/); + assert.equal(introductionMovement({ ...s, moveDestination: 'imperial_basin:9', moveCity: true }).allowed, true); + assert.match(introductionMovement({ ...s, moveCity: true, moveStorm: true }).blocked!, /storm/); + assert.match(introductionMovement({ ...s, moveDestination: 'tueks_sietch:5', moveCity: true }).blocked!, /three occupying factions/); + for (const moveForces of [0, 6, 1.5, NaN]) assert.equal(introductionMovement({ ...s, moveForces }).allowed, false); +}); +void test('declined, sole and mutual Traitor calls quote the actual casualty, leader and card consequences', () => { + const decline = introductionTraitor({ traitorCall: false, opponentTraitor: false }); + assert.equal(decline.result, 'normal'); assert.equal(decline.winner, 'opponent'); + assert.deepEqual(decline.scores, { attacker: 2, defender: 8 }); + assert.equal(decline.basicWinnerLosses, 3); + for (const you of [true, false]) { + const outcome = introductionTraitor({ traitorCall: you, opponentTraitor: !you }); + assert.equal(outcome.result, 'traitor'); assert.equal(outcome.winner, you ? 'you' : 'opponent'); + assert.equal(outcome.scores, null); assert.equal(outcome.basicWinnerLosses, null); + assert.deepEqual(outcome.destroyedArmies, [you ? 'opponent' : 'you']); + assert.deepEqual(outcome.leaderDeaths, { attacker: !you, defender: you }); + assert.deepEqual(outcome.bounty, { player: you ? 'you' : 'opponent', amount: 5 }); + assert.equal(outcome.discarded.length, 1); assert.equal(outcome.winnerCards.length, 1); + assert.equal(outcome.discarded[0].player, you ? 'opponent' : 'you'); + } + const both = introductionTraitor({ traitorCall: true, opponentTraitor: true }); + assert.equal(both.result, 'mutualTraitors'); assert.equal(both.winner, null); assert.equal(both.bounty, null); + assert.deepEqual(both.leaderDeaths, { attacker: true, defender: true }); + assert.deepEqual(both.destroyedArmies, ['you', 'opponent']); + assert.equal(both.discarded.length, 2); assert.deepEqual(both.winnerCards, []); +}); +void test('all five v1 lessons migrate by identity and retain prior committed choices', () => { + const old = { version: 1, step: 0, shipment: 4, destination: 'sand', shipped: true, + dial: 3, defense: 'shield', revealed: true, collectors: 2, city: true, collected: true }; + const titles = ['Your place at the table', 'Ship within your budget', 'Seal a battle plan', 'Collect the spice', 'Join a table']; + for (const [step, title] of titles.entries()) { + const result = restoreIntroduction(JSON.stringify({ ...old, step, token: 'excluded', moved: true, traitorCall: true }))!; + assert.equal(INTRODUCTION_STEPS[result.step].title, title); + assert.equal(result.version, 2); assert.equal(result.shipped, true); assert.equal(result.revealed, true); + assert.equal(result.dial, 3); assert.equal(result.collected, true); assert.equal(result.collectors, 2); + assert.equal(result.moved, false); assert.equal(result.traitorCall, false); assert.equal('token' in result, false); + assert.deepEqual(restoreIntroduction(JSON.stringify(result)), result); + } + assert.equal(restoreIntroduction(JSON.stringify({ ...old, step: 5 })), null); + assert.equal(restoreIntroduction(JSON.stringify({ ...old, shipment: 6 })), null); +}); +void test('new practice choices survive all stages and reject impossible saved commitments', () => { + for (const traitorStage of ['plans', 'revealed', 'resolved'] as const) for (const opponentTraitor of [false, true]) { + const s = { ...newIntroduction(), step: 4, traitorStage, opponentTraitor, + traitorCall: traitorStage === 'resolved', moveCity: true, moved: true }; + const restored = restoreIntroduction(JSON.stringify(s)); assert.deepEqual(restored, s); + if (traitorStage === 'resolved') assert.deepEqual(introductionTraitor(restored!), introductionTraitor(s)); + } + for (const patch of [{ moveDestination: 'invented:7' }, { moveForces: 0 }, { moveCity: 'yes' }, + { moved: true, moveStorm: true }, { moved: true, moveDestination: 'imperial_basin:9' }, + { traitorStage: 'later' }, { traitorCall: true }, { opponentTraitor: 1 }, { version: 3 }]) { + assert.equal(restoreIntroduction(JSON.stringify({ ...newIntroduction(), ...patch })), null); + } +}); diff --git a/tests/introduction.test.ts b/tests/introduction.test.ts index b1325e24..0b2cd054 100644 --- a/tests/introduction.test.ts +++ b/tests/introduction.test.ts @@ -41,11 +41,11 @@ void test('collection practice caps actual income at the pile and applies the ci assert.deepEqual(introductionCollection({ collectors: 6, city: false }), { amount: 8, remaining: 0 }); }); void test('saved practice restores sealed choices, rejects corrupt versions and strips unrelated data', () => { - const saved = { ...newIntroduction(), step: 2, dial: 3, revealed: true, shipped: true }; + const saved = { ...newIntroduction(), step: 3, dial: 3, revealed: true, shipped: true }; assert.deepEqual(restoreIntroduction(JSON.stringify(saved)), saved); assert.deepEqual(restoreIntroduction(JSON.stringify({ ...saved, token: 'not-a-real-token', hand: ['private'] })), saved); - for (const raw of [null, '', '{', 'null', '[]', JSON.stringify({ ...saved, version: 2 }), - JSON.stringify({ ...saved, step: -1 }), JSON.stringify({ ...saved, step: 5 }), + for (const raw of [null, '', '{', 'null', '[]', JSON.stringify({ ...saved, version: 3 }), + JSON.stringify({ ...saved, step: -1 }), JSON.stringify({ ...saved, step: 7 }), JSON.stringify({ ...saved, defense: 'lasgun' }), JSON.stringify({ ...saved, dial: 2.5 }), JSON.stringify({ ...saved, collectors: 99 }), JSON.stringify({ ...saved, revealed: 'true' }), JSON.stringify({ ...saved, shipment: 6, destination: 'sand', shipped: true })]) {