diff --git a/README.md b/README.md index a8522c02..1f909b71 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,10 @@ npm run dev -- --host 0.0.0.0 Open `http://localhost:3000`. Create a room, share its eight-character invitation, and add human or AI seats. The host can configure Easy, Medium, Hard and Brutal AI opponents. Current calibration demonstrates a substantially weaker Easy policy; it does not yet demonstrate a consistent ordering among the upper three levels. +Choose **Learn to play** for the [interactive introduction](docs/INTERACTIVE_INTRODUCTION.md) +at `/learn`. Practice shipping, battle plans 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. ## Saved seats and reconnecting diff --git a/app/learn/page.tsx b/app/learn/page.tsx new file mode 100644 index 00000000..c7facca1 --- /dev/null +++ b/app/learn/page.tsx @@ -0,0 +1,5 @@ +import { Introduction } from '@/components/introduction'; + +export default function Learn() { + return ; +} diff --git a/app/page.tsx b/app/page.tsx index c7394e5c..cb616d11 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -892,6 +892,7 @@ export default function Home() {
Multiplayer table{' '} + Learn to play Rulebook diff --git a/components/introduction.module.css b/components/introduction.module.css new file mode 100644 index 00000000..edce322b --- /dev/null +++ b/components/introduction.module.css @@ -0,0 +1,33 @@ +.page { max-width: 1120px; margin: 0 auto; padding: 2rem clamp(1rem, 4vw, 3rem) 4rem; color: #e9e2cf; } +.header { max-width: 760px; margin-bottom: 1.5rem; } +.header h1 { font-family: var(--font-display), Georgia, serif; font-size: clamp(2rem, 5vw, 3.6rem); line-height: 1.1; margin: .7rem 0 1rem; } +.page p, .page li { line-height: 1.65; } +.page p { margin: .8rem 0; } +.page a { color: #e8c982; text-underline-offset: .2em; } +.page a:hover { text-decoration: underline; } +.steps { display: flex; gap: .5rem; flex-wrap: wrap; margin: 1.5rem 0; } +.page button { min-height: 44px; padding: .6rem .9rem; background: #252d23; color: #eee6cf; border: 1px solid #778064; border-radius: .4rem; cursor: pointer; } +.page button:hover:enabled { background: #374232; } +.page button:disabled { opacity: .5; cursor: default; } +.page :is(button, a, input, select):focus-visible { outline: 3px solid #efd294; outline-offset: 3px; } +.steps button { flex: 1 1 155px; text-align: left; display: flex; gap: .6rem; align-items: center; } +.steps button[aria-current] { background: #cdb177; color: #171d16; border-color: #f3d79b; } +.steps span { font-variant-numeric: tabular-nums; } +.lesson { background: #151d16; border: 1px solid #58604a; border-radius: .8rem; padding: clamp(1rem, 3vw, 2rem); } +.lesson h2 { font-family: var(--font-display), Georgia, serif; font-size: 1.8rem; line-height: 1.2; margin: .6rem 0 1.2rem; } +.lesson h3 { font-size: 1.05rem; color: #ecd29b; margin: .5rem 0; font-weight: 650; } +.lesson ul, .phases { padding-left: 1.3rem; } +.phases { columns: 2; margin: 1rem 0; } +.columns { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 1.5rem; margin: 1.5rem 0; } +.controls { display: flex; flex-direction: column; gap: .8rem; min-width: 0; } +.controls select { background: #202a21; color: #eee6cf; border: 1px solid #899072; border-radius: .3rem; min-height: 44px; padding: .5rem; width: 100%; } +.controls input { accent-color: #d8b777; } +.controls input[type=range] { width: 100%; min-height: 44px; } +.checkbox { display: flex; align-items: center; gap: .7rem; min-height: 44px; } +.checkbox input { width: 20px; height: 20px; flex-shrink: 0; } +.wheel { max-width: 250px; margin: 0 auto; width: 100%; } +.result { background: #20291f; border: 1px solid #637052; border-radius: .6rem; padding: 1rem; min-width: 0; align-self: start; } +.footer { display: flex; align-items: center; justify-content: space-between; gap: 1rem; border-top: 1px solid #454f3b; margin-top: 2rem; padding-top: 1.2rem; flex-wrap: wrap; } +.links { display: flex; flex-wrap: wrap; gap: 1.5rem; margin-top: 1.5rem; } +.saved { display: block; margin-top: .8rem; color: #b8c0a7; font-size: .85rem; } +@media (max-width: 650px) { .columns { grid-template-columns: minmax(0, 1fr); } .steps button { flex-basis: 45%; font-size: .9rem; } .footer { align-items: stretch; } .footer a { flex-basis: 100%; } } diff --git a/components/introduction.tsx b/components/introduction.tsx new file mode 100644 index 00000000..f5f9ae04 --- /dev/null +++ b/components/introduction.tsx @@ -0,0 +1,163 @@ +'use client'; + +import Link from 'next/link'; +import { useEffect, useRef, useState, useSyncExternalStore } from 'react'; +import { BattleWheel } from './battle-wheel'; +import { CardInspector, CardRules } from './card-inspector'; +import { PHASES } from '@/game/catalog'; +import { + INTRODUCTION_CARDS, INTRODUCTION_LEADERS, INTRODUCTION_STEPS, INTRODUCTION_STORAGE_KEY, + introductionBattle, introductionCollection, introductionShipment, newIntroduction, restoreIntroduction, + type IntroductionState, +} from '@/game/introduction'; +import styles from './introduction.module.css'; + +const subscribeHydration = () => () => {}; +const clientSnapshot = () => true; +const serverSnapshot = () => false; + +export function Introduction() { + const hydrated = useSyncExternalStore(subscribeHydration, clientSnapshot, serverSnapshot); + return
+
+ ← 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.

+
+ {hydrated ? : Restoring your lesson…} +
; +} + +function IntroductionLessons() { + const [{ state, storageFailed }, setProgress] = useState(() => { + try { return { state: restoreIntroduction(localStorage.getItem(INTRODUCTION_STORAGE_KEY)) ?? newIntroduction(), storageFailed: false }; } + catch { return { state: newIntroduction(), storageFailed: true }; } + }); + const heading = useRef(null); + const focusNext = useRef(false); + useEffect(() => { + if (focusNext.current) { heading.current?.focus(); focusNext.current = false; } + }, [state.step]); + const update = (patch: Partial) => { + const next = { ...state, ...patch }; + let failed = false; + try { localStorage.setItem(INTRODUCTION_STORAGE_KEY, JSON.stringify(next)); } + catch { failed = true; } + setProgress({ state: next, storageFailed: failed }); + }; + const go = (step: number) => { focusNext.current = true; update({ step }); }; + const shipment = introductionShipment(state); + const battle = state.revealed ? introductionBattle(state) : null; + const collection = introductionCollection(state); + const lesson = INTRODUCTION_STEPS[state.step]; + const won = battle?.winner === 'you'; + const survivingForces = battle ? (won ? 6 - state.dial : 0) : 0; + return <> + +
+

{state.step + 1} / {INTRODUCTION_STEPS.length}

+

{lesson.title}

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

Each faction has its own powers. You will practice as Atreides with ordinary forces in the Basic game. Advanced combat and expansion powers come later.

+
+

On the table

Everyone can see the board, forces, storm and revealed cards. Your spice, Treachery hand and Traitors stay private unless a rule reveals them.

+

Your objective

Normally, hold three strongholds alone or four together with your ally at the Mentat Pause. Player-count and faction victory rules can change that target; the live table shows your current victory progress.

+
+

A turn follows these phases

+
    {PHASES.map(phase =>
  1. {phase}
  2. )}
+

The table tells you who must act. Your decisions can wait while another player seals a plan or answers a power. Automatic effects show in the chronicle.

+ } + {state.step === 1 && <> +

You have 8 spice and six available reserve forces. Ship from reserves before moving on the board. This example has no discount, storm, occupancy restriction or reacting faction power.

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

{state.shipped ? 'Shipment complete' : 'Review the cost'}

+

{state.shipment} forces cost {shipment.cost} spice.

+ {shipment.affordable ?

{state.shipped ? 'You have' : 'You would have'} {shipment.remaining} spice left.

+ :

You need {shipment.cost - 8} more spice. Reduce the force count or choose the stronghold.

} + {state.shipped &&

{state.shipment} forces arrived; {6 - state.shipment} of your six available reserves remain. You could now move one group already on the board, including these newly shipped forces.

} +
+
+

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

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

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.

+
+
+ +
update({ dial })} />
+ + + + + +
+
+ {!battle ? <>

Opponent’s plan is sealed

Choose how much to risk. In a normal victory you lose the forces you dial; in defeat you lose all your forces in this battle. In Basic play, ordinary forces need no spice support.

Your selected leader contributes strength only if they survive the weapon exchange. Plans reveal together.

+ : <>

{won ? 'You won the battle' : 'Your opponent won'}

+

The opponent dialed 3 with {INTRODUCTION_LEADERS.opponent.name} (strength {INTRODUCTION_LEADERS.opponent.strength}), a {INTRODUCTION_CARDS.weapon.name} and no defense.

+ +

{battle.leaderDeaths.attacker ? 'Your Snooper stops poison, so it cannot stop this projectile weapon. Your leader dies and adds no strength.' : 'Your Shield stops the projectile weapon. Your leader survives and adds five strength.'}

+

Your total: {battle.scores!.attacker}. Opponent: {battle.scores!.defender}. {battle.scores!.attacker === battle.scores!.defender && 'The aggressor wins this ordinary tie.'}

+

{6 - survivingForces} of your forces go to the Tanks; {survivingForces} remain in Wind Pass. {won ? 'The opponent loses all six forces.' : 'The opponent loses the three dialed forces and keeps three.'}

+

{won ? 'A normal winner may keep or discard the cards they used.' : 'The defeated player discards the cards used in the plan.'} {battle.bounty?.amount ? `The winner also collects ${battle.bounty.amount} spice for the killed leader.` : 'Neither leader died, so there is no leader bounty.'}

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

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.

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

{state.collected ? 'Collection complete' : 'Collection capacity'}

+

Each ordinary force can collect {state.city ? 3 : 2} spice{state.city ? ' because you occupy Arrakeen' : ''}.

+

{state.collected ? 'You collected' : 'You can collect'} {collection.amount} spice. {collection.remaining} {state.collected ? 'remains' : 'would remain'} on the board.

+

Spice beyond your capacity stays for a later turn. In a real table this calculation is automatic.

+
+
+ } + {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.

+
    +
  • 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.
  • +
  • Inspect your private Treachery hand and Traitors. Use the phase guide when a decision is unfamiliar.
  • +
  • Use Protect your saved seat and keep its recovery kit private. An invitation identifies the room; the kit restores your seat after cookie loss.
  • +
+

Refresh restores a live game through its saved seat. If a request has an uncertain result, use the offered retry rather than repeating the game action.

+
Create or join a game →Explore the full reference →
+ } +
+ + Read the related rules + {state.step < INTRODUCTION_STEPS.length - 1 && } +
+
+ {storageFailed ? 'Browser storage is unavailable. You can still practice, but a refresh may reset this lesson.' : 'Your lesson position and practice choices are saved in this browser.'} + ; +} diff --git a/components/rules-reference.tsx b/components/rules-reference.tsx index e9f9b2d2..403dc930 100644 --- a/components/rules-reference.tsx +++ b/components/rules-reference.tsx @@ -103,6 +103,7 @@ function RulesReferenceContent() { Find a phase, card or faction. Follow the action guides, inspect examples and move directly between related rules.

+

New to Dune? Try the interactive introduction →

This reference is growing with the game. Advanced play and expansion rules are unfinished.{' '} diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index a5356002..00f86577 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -6,13 +6,20 @@ gated.** Prototypes do not open completion or publication gates. ## Current checkpoint and work -Latest delivered checkpoint: `bfa24b0`, [optional table sounds](TABLE_SOUNDS.md). -Types, lint, **4,856 offline tests** and production build passed. Browser controls -verified mute, keyboard volume, refresh and cross-tab preferences; physical audio -and screenshot acceptance remain open. All 1,072 games and original seat/recovery -records were preserved, and the server remained healthy after the outage. +Latest delivered checkpoint: `9b67a78`, [early Sapho aggressor](SAPHO_AGGRESSOR.md). +Types, lint, **4,866 offline tests**, production build and **43 HTTP tests** passed. +Two genuine CHOAM/Richese samples completed; focused and browser evidence exercised +the new priority effect. After the next outage all 1,106 rooms were present, the +original baseline rows and latest QA save were unchanged, and a private seat +restored after browser refresh. The healthy server was reused without a reset. Earlier evidence remains in the [implementation history](IMPLEMENTATION_STATUS.md). +Current work: the [interactive introduction](INTERACTIVE_INTRODUCTION.md) adds +five lessons with shared-rule shipping, sealed battle and collection exercises, +card inspection and browser-local continuation. Broader faction/expansion +teaching remains unfinished. Final verification and delivery belong to the +source-bound report and Git message. + The connected [Harass & Withdraw prototype](HARASS_WITHDRAW_RUNTIME.md) supports either battle-card slot, private category inspections, exact undialed normal/elite returns, opponent-Traitor cancellation, mandatory disposal, controls and all @@ -23,7 +30,7 @@ card-specific discard precedence are explicitly labeled inferences. The Stone Burner timing question is pending; no user answer is assumed. Reinforcements remains unfinished. This does not open variant, mode or publication gates. -Current checkpoint: [early Sapho aggressor](SAPHO_AGGRESSOR.md) connects the +The preceding [early Sapho aggressor](SAPHO_AGGRESSOR.md) connects the printed battle-priority effect during the existing shared pre-plan opportunity. Physical attacker/defender/chooser stay stable; Habbanya takes precedence in either slot. Shared priority feeds Stone admission, ordinary/Stone resolution, public @@ -38,6 +45,11 @@ cannot safely depend on hidden custody or alter the printed deck. Mirror remains gated pending the existing disposal ruling and copied-choice work. Continue with independent missing faction/card functions, then integrated play and refinement. +The Smuggler follow-up review supports a saved reveal-time receipt with settlement +only on leader survival, but located no official ruling on modified leader +strength. The user has been asked about Kwisatz/trainer/Stunner composition; no +answer is assumed. See the [source follow-up](LEADER_SKILLS_RULES.md#20-september-smuggler-follow-up). + ## Authoritative navigation - [Rules checklist](../game/reference.ts), topic `implementation-checklist` and diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 4817815c..5ddc8bba 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,20 @@ # Dune implementation status +## 20 September 2026 — Interactive introduction prototype + +[Five guided lessons](INTERACTIVE_INTRODUCTION.md) connect shipping budgets, +sealed battle plans and spice collection to shared production rule quotes, with +keyboard controls, card inspection and browser-local saved progress. The fixed +opponent is a teaching example, not an AI match. The wider faction, movement, +bidding, Advanced and expansion curriculum remains unfinished. Focused checks +cover combat outcomes and saved-choice validation; final browser/check/build, +preservation and Git evidence belong to the private source-bound report and +commit message. No mode or publication gate is opened. + +Independent Smuggler review supports the deferred survival receipt but found no +official modifier-strength ruling. That single new question remains explicit in +the decision register; no pending answer was inferred to enable collection. + ## 20 September 2026 — Sapho early battle aggressor [Early aggressor](SAPHO_AGGRESSOR.md) connects the held physical card, existing diff --git a/docs/INTERACTIVE_INTRODUCTION.md b/docs/INTERACTIVE_INTRODUCTION.md new file mode 100644 index 00000000..a702d430 --- /dev/null +++ b/docs/INTERACTIVE_INTRODUCTION.md @@ -0,0 +1,44 @@ +# Interactive introduction + +20 September 2026. **Prototyped; partial teaching coverage.** `/learn` is linked +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 +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 +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. + +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, +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; +the last write is restored on the next visit. No multiplayer API or seat token is +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 +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 +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. diff --git a/docs/LEADER_SKILLS_RULES.md b/docs/LEADER_SKILLS_RULES.md index 9969343c..3602c0b2 100644 --- a/docs/LEADER_SKILLS_RULES.md +++ b/docs/LEADER_SKILLS_RULES.md @@ -184,6 +184,29 @@ designer expressly presents the latter as useful to Fremen, but does not publish a general stacking order for every expansion movement modifier ([designer example, 19:13](https://www.youtube.com/watch?v=XT_azRVLq_0&t=1153s)). +## 20 September Smuggler follow-up + +A bounded renewed check of the GF9 CHOAM & Richese rules/Q&A, the +[core FAQ](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf), +Future Pastimes expansion page and Jack Reda walkthrough found no ruling defining +Smuggler's leader strength with Kwisatz, another face-up trainer or Stunner. The +walkthrough at 12:19–12:46 repeats reveal timing and survival with an unmodified +strength-five example. It does not settle those modifiers. A user question now +asks whether to keep those combinations gated, use printed/copied disc value, +or include leader bonuses with a stunned leader counted as zero. No answer is +assumed; this is not a publisher ruling. + +The prior pending-receipt contract remains viable: bind the original battle, +controller, leader and spice pile at reveal, then transfer only after survival is +known. The printed card has no victory requirement; the designer's winning +example does not add one. A captive's lower effect belongs to its controller. +Resolving this reveal-time collection logically before Sandmaster's after-win +addition is an explicit timing inference. A drained pile leaves no existing spice +for Sandmaster to augment. Multiple-pile allocation remains unresolved. Pending +spice is not spendable personal custody; this provisional timing must remain +explicit when the collection function is connected. Runtime collection is still +missing; the independent introduction proceeded while the modifier answer waits. + ## Implementation readiness contract A bounded prototype can now use the exact fourteen-card inventory, modified diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 0f702099..e63cfb25 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -57,6 +57,7 @@ These are unresolved in the cited records. Do not infer an answer from elapsed t | Mirror Weapon | Whether copied Tooth/Artillery mandatory-discard rules attach to the physical Mirror remains unanswered. Every genuine Richese setup includes those cards and Stone Burner; an ordinary-copy-only admission gate cannot depend on concealed custody or alter the printed deck. | [Engine audit](MIRROR_WEAPON_ENGINE_AUDIT.md) | | Semuta Drug | Full-hand take-before-discard and the privacy-neutral immediate reaction protocol remain unresolved. Typed discard continuations already exist; they are not activation. | [Source update](SEMUTA_SOURCE_UPDATE_20260907.md), [privacy review](SEMUTA_PRIVACY_REVIEW.md) | | Leader Skills edge cases | Capture/replacement eligibility, Banker escrow custody (user question sent), Smuggler survival settlement, Mentat empty-hand fallback (private historical observations persist after death), Diplomat lower retreat ordering, modified-defense inheritance and remaining simultaneous optional timing need resolution before those cases are enabled. These are recorded source gaps, not answered user rulings. | [Material boundaries](LEADER_SKILLS_RULES.md#material-boundaries-not-settled-by-the-sources), [Diplomat boundary](DIPLOMAT_DEFENSE.md#evidence-and-remaining-scope) | +| Smuggler collection strength | A fresh primary-source check found no modifier ruling. The user was asked whether to keep affected combinations gated, use printed/copied disc value, or include leader bonuses with Stunner zero. No answer is assumed. The reveal-bound deferred receipt is supported as an explicit timing inference; lower collection remains unimplemented. | [Smuggler follow-up](LEADER_SKILLS_RULES.md#20-september-smuggler-follow-up) | | Suk Graduate / Atreides | Whether rescued dialed casualties count toward the seven-loss Kwisatz Haderach threshold remains unresolved. The user question has been sent; Advanced Atreides rescue stays guarded. | [Source and runtime boundary](SUK_GRADUATE_RULES.md) | | Ordinary Guild cancellation | Unaffordable repricing, replacement/retry/decline, consumed opportunity, contributor adjustment and ordering with special shipment-stop. The affordability question was already sent. | [Material timing](GUILD_RATE_KARAMA_RULES.md#material-unresolved-timing) | | Base special Karama | Unfunded auction recovery, stopped-shipment settlement and Atreides full-plan commitment order remain provisional. Native full-plan/Voice/Prescience/Truthtrance/Ghola interactions now have genuine-setup focused evidence, but the source audit does not settle the special offer/deadline or target-first interpretation. | [Explicit base interpretations](BASE_ADVANCED_READINESS_20260907.md#explicit-unresolved-base-interpretations), [full-plan interaction audit](ATREIDES_FULL_PLAN_TIMING.md) | diff --git a/game/introduction.ts b/game/introduction.ts new file mode 100644 index 00000000..65d41e6b --- /dev/null +++ b/game/introduction.ts @@ -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) { + 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) { + 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) { + 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'] }; +} diff --git a/game/reference.ts b/game/reference.ts index 26139079..28030629 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -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', diff --git a/tests/introduction.test.ts b/tests/introduction.test.ts new file mode 100644 index 00000000..b1325e24 --- /dev/null +++ b/tests/introduction.test.ts @@ -0,0 +1,58 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { INTRODUCTION_STEPS, introductionBattle, introductionCollection, introductionShipment, newIntroduction, restoreIntroduction } from '../game/introduction'; +import { RULE_TOPICS } from '../game/reference'; + +void test('shipping practice applies ordinary per-force prices and exposes an unaffordable choice', () => { + assert.deepEqual(introductionShipment({ shipment: 4, destination: 'sand' }), { cost: 8, remaining: 0, affordable: true }); + assert.deepEqual(introductionShipment({ shipment: 6, destination: 'sand' }), { cost: 12, remaining: -4, affordable: false }); + assert.deepEqual(introductionShipment({ shipment: 6, destination: 'stronghold' }), { cost: 6, remaining: 2, affordable: true }); +}); +void test('battle practice resolves protected ties, losing dials and the wrong defense through actual combat', () => { + const tie = introductionBattle({ dial: 3, defense: 'shield' }); + assert.equal(tie.winner, 'you'); + assert.deepEqual(tie.scores, { attacker: 8, defender: 8 }); + assert.deepEqual(tie.leaderDeaths, { attacker: false, defender: false }); + assert.equal(tie.basicWinnerLosses, 3); + assert.deepEqual(tie.destroyedArmies, ['opponent']); + const loss = introductionBattle({ dial: 2, defense: 'shield' }); + assert.equal(loss.winner, 'opponent'); + assert.deepEqual(loss.destroyedArmies, ['you']); + assert.equal(loss.basicWinnerLosses, 3); + const killed = introductionBattle({ dial: 6, defense: 'snooper' }); + assert.equal(killed.winner, 'opponent'); + assert.equal(killed.leaderDeaths.attacker, true); + assert.deepEqual(killed.scores, { attacker: 6, defender: 8 }); + assert.deepEqual(killed.bounty, { player: 'opponent', amount: 5 }); + assert.equal(killed.discarded.some(entry => entry.player === 'you'), true); +}); +void test('every offered battle plan stays legal and normal with no support payment in Basic', () => { + for (let dial = 0; dial <= 6; dial++) for (const defense of ['shield', 'snooper'] as const) { + const result = introductionBattle({ dial, defense }); + assert.equal(result.result, 'normal'); + assert.ok(result.payments.every(payment => payment.cost === 0)); + assert.equal(result.leaderDeaths.defender, false); + } +}); +void test('collection practice caps actual income at the pile and applies the city rate', () => { + assert.deepEqual(introductionCollection({ collectors: 0, city: true }), { amount: 0, remaining: 8 }); + assert.deepEqual(introductionCollection({ collectors: 3, city: false }), { amount: 6, remaining: 2 }); + assert.deepEqual(introductionCollection({ collectors: 3, city: true }), { amount: 8, remaining: 0 }); + 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 }; + 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 }), + 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 })]) { + assert.equal(restoreIntroduction(raw), null); + } + assert.deepEqual(introductionBattle(restoreIntroduction(JSON.stringify(saved))!), introductionBattle(saved)); +}); +void test('each introduction lesson links to an existing internal rule topic', () => { + for (const step of INTRODUCTION_STEPS) assert.ok(RULE_TOPICS.some(topic => topic.id === step.topic), step.topic); +});